[
  {
    "path": ".github/dependabot.yml",
    "content": "# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\nversion: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    reviewers:\n      - ibizaman\n"
  },
  {
    "path": ".github/workflows/auto-merge.yaml",
    "content": "name: Auto Merge\n\non:\n  # Try enabling auto-merge for a pull request when a draft is marked as “ready for review”, when\n  # a required label is applied or when a “do not merge” label is removed, or when a pull request\n  # is updated in any way (opened, synchronized, reopened, edited).\n  pull_request_target:\n    types:\n      - opened\n      - synchronize\n      - reopened\n      - edited\n      - labeled\n      - unlabeled\n      - ready_for_review\n\n  # Try enabling auto-merge for the specified pull request or all open pull requests if none is\n  # specified.\n  workflow_dispatch:\n    inputs:\n      pull-request:\n        description: Pull Request Number\n        required: false\n\njobs:\n  automerge:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: reitermarkus/automerge@v2\n        with:\n          token: ${{ secrets.GH_TOKEN_FOR_UPDATES }}\n          merge-method: rebase\n          do-not-merge-labels: never-merge\n          required-labels: automerge\n          pull-request: ${{ github.event.inputs.pull-request }}\n          review: ${{ github.event.inputs.review }}\n          dry-run: false\n"
  },
  {
    "path": ".github/workflows/build.yaml",
    "content": "# name: build\n# on: push\n# jobs:\n#   checks:\n#     uses: nixbuild/nixbuild-action/.github/workflows/ci-workflow.yml@v19\n#     with:\n#       nix_conf: |\n#         allow-import-from-derivation = true\n#     secrets:\n#       nixbuild_token: ${{ secrets.nixbuild_token }}\n\n\nname: \"build\"\non:\n  pull_request:\n  push:\n    branches: [ \"main\" ]\n\njobs:\n  path-filter:\n    runs-on: ubuntu-latest\n    outputs:\n      changed: ${{ steps.filter.outputs.any_changed }}\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - uses: tj-actions/changed-files@v47\n        id: filter\n        with:\n          files: |\n            lib/**\n            modules/**\n            !modules/**/docs/**\n            test/**\n            flake.lock\n            flake.nix\n            .github/workflows/build.yaml\n          separator: \"\\n\"\n\n      - env:\n          ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_changed_files }}\n        run: |\n          echo $ALL_CHANGED_FILES\n\n\n  build-matrix:\n    needs: [ \"path-filter\" ]\n    if: needs.path-filter.outputs.changed == 'true'\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n          enable_kvm: true\n          extra_nix_config: |\n            keep-outputs = true\n            keep-failed = true\n      - name: Setup Caching\n        uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n      - name: Generate Matrix\n        id: generate-matrix\n        run: |\n          set -euox pipefail\n\n          nix flake show --allow-import-from-derivation --json \\\n              | jq -c '.[\"checks\"][\"x86_64-linux\"] | keys' > .output\n\n          cat .output\n\n          echo dynamic_list=\"$(cat .output)\" >> \"$GITHUB_OUTPUT\"\n    outputs:\n      check: ${{ steps.generate-matrix.outputs.dynamic_list }}\n\n  manual:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n          enable_kvm: true\n          extra_nix_config: |\n            keep-outputs = true\n            keep-failed = true\n      - name: Setup Caching\n        uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n      - name: Build\n        run: |\n          nix \\\n            --print-build-logs \\\n            --option keep-going true \\\n            --show-trace \\\n            build .#manualHtml\n\n  tests:\n    runs-on: ubuntu-latest\n    needs: [ \"build-matrix\" ]\n    strategy:\n      fail-fast: false\n      matrix:\n        check: ${{ fromJson(needs.build-matrix.outputs.check) }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n          enable_kvm: true\n          extra_nix_config: |\n            keep-outputs = true\n            keep-failed = true\n      - name: Setup Caching\n        uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n      - name: Build\n        run: |\n          echo \"resultPath=$(nix eval .#checks.x86_64-linux.${{ matrix.check }} --raw)\" >> $GITHUB_ENV\n          nix build --print-build-logs --show-trace .#checks.x86_64-linux.${{ matrix.check }}\n      - name: Upload Build Result\n        uses: actions/upload-artifact@v7\n        if: always() && startsWith(matrix.check, 'vm_')\n        with:\n          name: ${{ matrix.check }}\n          path: ${{ env.resultPath }}/trace/*\n          overwrite: true\n          if-no-files-found: ignore\n\n  results:\n    name: Final Results\n    runs-on: ubuntu-latest\n    needs: [ manual, tests ]\n    if: '!cancelled()'\n    steps:\n      - run: |\n          result=\"${{ needs.manual.result }}\"\n          if ! [[ $result == \"success\" || $result == \"skipped\" ]]; then\n            exit 1\n          fi\n          result=\"${{ needs.tests.result }}\"\n          if ! [[ $result == \"success\" || $result == \"skipped\" ]]; then\n            exit 1\n          fi\n          exit 0\n"
  },
  {
    "path": ".github/workflows/demo.yml",
    "content": "name: Demo\n\non:\n  workflow_dispatch:\n  pull_request:\n  push:\n    branches:\n      - main\n\njobs:\n  path-filter:\n    runs-on: ubuntu-latest\n    outputs:\n      changed: ${{ steps.filter.outputs.any_changed }}\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - uses: tj-actions/changed-files@v47\n        id: filter\n        with:\n          files: |\n            demo/**\n            lib/**\n            modules/**\n            !modules/**/docs/**\n            test/**\n            flake.lock\n            flake.nix\n            .github/workflows/demo.yml\n          separator: \"\\n\"\n\n      - env:\n          ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_changed_files }}\n        run: |\n          echo $ALL_CHANGED_FILES\n\n  build:\n    needs: [ \"path-filter\" ]\n    if: needs.path-filter.outputs.changed == 'true'\n    strategy:\n      fail-fast: false\n      matrix:\n        demo:\n          - name:  homeassistant\n            flake: basic\n          - name:  homeassistant\n            flake: ldap\n\n          - name:  nextcloud\n            flake: basic\n          - name:  nextcloud\n            flake: ldap\n          - name:  nextcloud\n            flake: sso\n\n          - name:  minimal\n            flake: minimal\n          - name:  minimal\n            flake: lowlevel\n          - name:  minimal\n            flake: sops\n\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n          enable_kvm: true\n          extra_nix_config: |\n            keep-outputs = true\n            keep-failed = true\n\n      - uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n\n      - name: Build ${{ matrix.demo.name }} .#${{ matrix.demo.flake }}\n        run: |\n          cd demo/${{ matrix.demo.name }}\n          nix flake update --override-input selfhostblocks ../.. selfhostblocks\n          nix \\\n            --print-build-logs \\\n            --option keep-going true \\\n            --show-trace \\\n            build .#nixosConfigurations.${{ matrix.demo.flake }}.config.system.build.toplevel\n          nix \\\n            --print-build-logs \\\n            --option keep-going true \\\n            --show-trace \\\n            build .#nixosConfigurations.${{ matrix.demo.flake }}.config.system.build.vm\n\n  result:\n    runs-on: ubuntu-latest\n    needs: [ \"build\" ]\n    if: '!cancelled()'\n    steps:\n      - run: |\n          result=\"${{ needs.build.result }}\"\n          if [[ $result == \"success\" || $result == \"skipped\" ]]; then\n            exit 0\n          else\n            exit 1\n          fi\n"
  },
  {
    "path": ".github/workflows/format.yaml",
    "content": "name: \"format\"\non:\n  pull_request:\n  push:\n    branches: [ \"main\" ]\n\njobs:\n  format:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n      - name: Setup Caching\n        uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n      - name: Check Formatting\n        run: |\n          find . -name '*.nix' | nix fmt -- --ci\n"
  },
  {
    "path": ".github/workflows/lock-update.yaml",
    "content": "name: Update Flake Lock\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 0 * * *' # runs daily at 00:00\n\njobs:\n  lockfile:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n      - name: Update flake.lock\n        uses: DeterminateSystems/update-flake-lock@main\n        with:\n          token: ${{ secrets.GH_TOKEN_FOR_UPDATES }}\n          pr-labels: |\n            automerge\n"
  },
  {
    "path": ".github/workflows/pages.yml",
    "content": "# Inspired from https://github.com/nix-community/nix-on-droid/blob/039379abeee67144d4094d80bbdaf183fb2eabe5/.github/workflows/docs.yml\nname: Deploy docs\n\non:\n  push:\n    branches: [\"main\"]\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: \"pages\"\n  cancel-in-progress: false\n\njobs:\n  # Single deploy job since we're just deploying\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v6\n\n      - name: Install Nix\n        uses: cachix/install-nix-action@v31\n        with:\n          github_access_token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Setup Caching\n        uses: cachix/cachix-action@v17\n        with:\n          name: selfhostblocks\n          authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'\n\n      - name: Build docs\n        run: |\n          nix \\\n            --print-build-logs \\\n            --option keep-going true \\\n            --show-trace \\\n            build .#manualHtml\n\n          # see https://github.com/actions/deploy-pages/issues/58\n          cp \\\n            --recursive \\\n            --dereference \\\n            --no-preserve=mode,ownership \\\n            result/share/doc/selfhostblocks \\\n            public\n\n      - name: Setup Pages\n        uses: actions/configure-pages@v6\n\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: ./public\n\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v5\n"
  },
  {
    "path": ".github/workflows/version.yaml",
    "content": "name: Version Bump\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - VERSION\n\njobs:\n  create-tag:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n        with:\n          fetch-depth: 1\n      - name: Get version\n        id: vars\n        run: echo \"version=v$(cat VERSION)\" >> $GITHUB_OUTPUT\n      - uses: rickstaa/action-create-tag@v1.7.2\n        with:\n          tag: ${{ steps.vars.outputs.version }}\n"
  },
  {
    "path": ".gitignore",
    "content": "*.qcow2\nresult\nresult-*\ndocs/redirects.json.backup\n.nixos-test-history\n\\#*#"
  },
  {
    "path": "CHANGELOG.md",
    "content": "<!---\n\nTemplate:\n\n## Breaking Changes\n\n## New Features\n\n## User Facing Backwards Compatible Changes\n\n## Fixes\n\n## Other Changes\n\n-->\n\n# Upcoming Release\n\n## Breaking Changes\n\n- 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.\n\n## New Features\n\n- Added Immich Public Proxy service\n- Add homepage service with dashboard contract implemented by all services\n- Add scrutiny service.\n- ZFS module now supports setting permissions\n- Add landing page for mailserver and dashboard contract integration\n\n## Bug Fixes\n\n- Use configurable dataDir in arr stack\n- Forgejo ensures ldap is setup when sso is configured\n- Add nixpkgs patches on aarch64-linux too\n- Self-signed certs are now idempotent\n- Prometheus scrapes metrics at 15s interval instead of 1m\n\n## Other Changes\n\n- Arr stack declares ldap groups, declare ApiKeys and bypasses auth for readarr when sso is enabled\n- Forgejo declares ldap group\n\n# v0.7.3\n\n## New Features\n\n- 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.\n- 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).\n  On top of minor changes, the most notable one was:\n  - Updated Jellyfin LDAP and SSO plugins and configuration. @Codys-Wright\n\n## Bug Fixes\n\n- Fix Restic and Authelia modules referencing systemd services without the `.service` suffix and leading to \n\n# v0.7.2\n\n## New Features\n\n- Forgejo uses secrets contract for smtp password.\n- Add [Firefly-iii](https://shb.skarabox.com/services-firefly-iii.html) service.\n- Jellyfin can [install plugins declaratively](https://shb.skarabox.com/services-jellyfin.html#services-jellyfin-options-shb.jellyfin.plugins).\n  (Support is quite crude and WIP).\n- Jellyfin configures LDAP and SSO fully declaratively, including installing necessary plugins.\n- Nextcloud 32 is fully supported thanks to tests for version 31 and 32.\n\n## Fixes\n\n- Revert Authelia to continue using dots in systemd service names.\n  This caused issue with nginx name resolution.\n\n## Other Changes\n\n- Authelia uses non deprecated `smtp.address` option.\n- Add documentation for Nginx block\n- Now a user which is only member of the admin LDAP group of a service can login.\n  Before, some services required a user to be member of both the user and admin LDAP group.\n  This is ensured by regression tests going forward.\n\n# v0.7.1\n\n## New Features\n\n- Add a Grafana dashboard showing SSL certificate renewal jobs\n\n## Fixes\n\n- Fix let's encrypt certificate renewal jobs by removing duplicated domain name.\n  Also adds an assertion to catch these kinds of errors.\n\n## Other Changes\n\n- Reduce number of late SSL renewal alert by merging all metrics corresponding to one CN.\n\n# v0.7.0\n\n## Breaking Changes\n\n- Fix pkgs overrides not being passed to users of SelfHostBlocks.\n  This will require to update your flake to follow the example in the [Usage](https://shb.skarabox.com/usage.html) section.\n\n## New Features\n\n- Add a Grafana dashboard showing stats on backup jobs\n  and also an alert if a backup job did not run in the last 24 hours or never succeeded in the last 24 hours.\n- Add SSO integration in Grafana.\n- Add Paperless service.\n\n## Fixes\n\n- Allow to upload big files in Immich.\n- Only enable php-fpm Prometheus exporter if Nextcloud is enabled.\n\n## Other Changes\n\n- Add recipe to setup DNS server with DNSSEC.\n\n# v0.6.1\n\n## New Features\n\n- Implement backup and databasebackup contracts with BorgBackup block.\n\n## Fixes\n\n- Add back erroneously removed Prometheus collectors.\n\n# v0.6.0\n\n## Breaking Changes\n\n- Removed Nextcloud 30, update to Nextcloud 31 then after to 32.\n- Removed the `sops` module in the `default` NixOS module. Removed the `all` NixOS module.\n\n## New Features\n\n- Meilisearch configured with production environment and master key.\n\n## Other Changes\n\n- Only import hardcodedsecret module in tests.\n- Better usage section in manual.\n- Added new demo for minimal SelfHostBlocks setup, which is tested in CI.\n- Format all files in repo and make sure they are formatted in CI.\n\n# v0.5.1\n\n## New Features\n\n- Added Karakeep service with SSO integration.\n- Add SelfHostBlocks' `lib` into `pkgs.lib.shb`. Integrates with [Skarabox](https://github.com/ibizaman/skarabox/blob/631ff5af0b5c850bb63a3b3df451df9707c0af4e/template/flake.nix#L42-L43) too.\n\n## Other Changes\n\n- Moved implementation guide under contributing section.\n\n# v0.5.0\n\n## Breaking Changes\n\n- Modules in the `nixosModules` output field do not anymore have the `system` in their path.\n  `selfhostblocks.nixosModules.x86_64-linux.home-assistant` becomes `selfhostblocks.nixosModules.home-assistant`\n  like it always should have been.\n\n## Fixes\n\n- Added test case making sure a user belonging to a not authorized LDAP group cannot login.\n  Fixed Open WebUI module.\n- Now importing a single module, like `selfhostblocks.nixosModules.home-assistant`, will\n  import all needed block modules at the same time.\n\n## Other Changes\n\n- Nextcloud module can now setup SSO integration without setting up LDAP integration.\n\n# v0.4.4\n\n## New Features\n\n- Added Pinchflat service with SSO integration. Declarative user creation only supported through SSO integration.\n- Added Immich service with SSO integration.\n- Added Open WebUI service with SSO integration.\n\n# v0.4.3\n\n## New Features\n\n- Allow user to change their SSO password in Authelia.\n- Make Audiobookshelf SSO integration respect admin users.\n\n## Fixes\n\n- Fix permission on Nextcloud systemd service.\n- Delete Forgejo backups correctly to avoid them piling up.\n\n## Other Changes\n\n- Add recipes section to the documentation.\n\n# v0.4.2\n\n## New Features\n\n- The LLDAP and Authelia modules gain a debug mode where a mitmdump instance is added so all traffic is printed.\n\n## Fixes\n\n- By default, LLDAP module only enforces groups declaratively. Users that are not defined declaratively\n  are not anymore deleted by inadvertence.\n- SSO integration with most services got fixed. A recent incompatible change in upstream Authelia broke most of them.\n- Fixed PostgreSQL and Home Assistant modules after nixpkgs updates.\n- Fixed Nextcloud module SSO integration with Authelia.\n- Make Nextcloud SSO integration respect admin users.\n\n# v0.4.1\n\n## New Features\n\n- LLDAP now manages users, groups, user attributes and group attributes declaratively.\n- Individual modules are exposed in the flake output for each block and service.\n- A mitmdump block is added that can be placed between two services and print all requests and responses.\n- The SSO setup for Audiobookshelf is now a bit more declarative.\n\n## Other Changes\n\n- Forgejo got a new playwright test to check the LDAP integration.\n- Some renaming options have been added retroactively for jellyfin and forgejo.\n\n# v0.4.0\n\n## Breaking Changes\n\n- Rename ldap module to lldap as well as option name `shb.ldap` to `shb.lldap`.\n\n## New Features\n\n- Jellyfin service now waits for Jellyfin server to be fully available before starting.\n- Add debug option for Jellyfin.\n- Allow to choose port for Jellyfin.\n- Make Jellyfin setup including initial admin user declarative.\n\n## Fixes\n\n- Fix Jellyfin redirect URI scheme after update.\n\n## Other Changes\n\n- Add documentation for LLDAP and Authelia block and link to it from other docs.\n\n# v0.3.1\n\n## Breaking Changes\n\n- Default version of Nextcloud is now 30.\n- Disable memories app on Nextcloud because it is broken.\n\n## New Features\n\n- Add patchNixpkgs function and pre-patched patchedNixpkgs output.\n\n## Fixes\n\n- Fix secrets passing to Nextcloud service after update.\n\n## Other Changes\n\n- Bump nixpkgs to https://github.com/NixOS/nixpkgs/commit/216207b1e58325f3590277d9102b45273afe9878\n\n# v0.3.0\n\n## New Features\n\n- Add option to add extra args to hledger command.\n\n## Breaking Changes\n\n- Default version of Nextcloud is now 29.\n\n## Fixes\n\n- Home Assistant config gets correctly generated with secrets\n  even if LDAP integration is not enabled.\n- Fix Jellyfin SSO plugin which was left badly configured\n  after a code refactoring.\n\n## Other Changes\n\n- Add a lot of playwright tests for services.\n- Add service implementation manual page to document\n  how to integrate a service in SHB.\n- Add `update-redirects` command to manage the `redirect.json` page.\n- Add home-assistant manual.\n\n# v0.2.10\n\n## New Features\n\n- Add `shb.forgejo.users` option to create users declaratively.\n\n## Fixes\n\n- Make Nextcloud create the external storage if it's a local storage\n  and the directory does not exist yet.\n- Disable flow to change password on first login for admin Forgejo user.\n  This is not necessary since the password comes from some secret store.\n\n## Breaking Changes\n\n- Fix internal link for Home Assistant\n  which now points to the fqdn. This fixes Voice Assistant\n  onboarding. This is a breaking change if one relies on\n  reaching Home Assistant through the IP address but I\n  don't recommend that. It's much better to have a DNS\n  server running locally which redirects the fqdn to the\n  server running Home Assistant.\n\n## Other Changes\n\n- Refactor tests and add playwright tests for services.\n\n# v0.2.9\n\n## New Features\n\n- Add Memories Nextcloud app declaratively configured.\n- Add Recognize Nextcloud app declaratively configured.\n\n# v0.2.8\n\n## New Features\n\n- Add dashboard for SSL certificates validity\n  and alert they did not renew on time.\n\n## Fixes\n\n- Only enable php-fpm exporter when php-fpm is enabled.\n\n## Breaking Changes\n\n- Remove upgrade script from postgres 13 to 14 and 14 to 15.\n\n# v0.2.7\n\n## New Features\n\n- Add dashboard for Nextcloud with PHP-FPM exporter.\n- Add voice option to Home-Assistant.\n\n## User Facing Backwards Compatible Changes\n\n- Add hostname and domain labels for scraped Prometheus metrics and Loki logs.\n\n# v0.2.6\n\n## New Features\n\n- Add dashboard for deluge.\n\n# v0.2.5\n\n## Other Changes\n\n- Fix more modules using backup contract.\n\n# v0.2.4\n\n## Other Changes\n\n- Fix modules using backup contract.\n\n# v0.2.3\n\n## Breaking Changes\n\n- Options `before_backup` and `after_backup` for backup contract have been renamed to\n  `beforeBackup` and `afterBackup`.\n- All options using the backup and databasebackup contracts now use the new style.\n\n## Other Changes\n\n- Show how to pin Self Host Blocks flake input to a tag.\n\n# v0.2.2\n\n## User Facing Backwards Compatible Changes\n\n- Fix: add implementation for `sops.nix` module.\n\n## Other Changes\n\n- Use VERSION when rendering manual too.\n\n# v0.2.1\n\n## User Facing Backwards Compatible Changes\n\n- Add `sops.nix` module to `nixosModules.default`.\n\n## Other Changes\n\n- Auto-tagging of git repo when VERSION file gets updated.\n- Add VERSION file to track version.\n\n# v0.2.0\n\n## New Features\n\n- Backup:\n  - Add feature to backup databases with the database backup contract, implemented with `shb.restic.databases`.\n\n## Breaking Changes\n\n- Remove dependency on `sops-nix`.\n- 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.\n- Rename all `shb.arr.*.APIKey` to `shb.arr.*.ApiKey`.\n- Remove `shb.vaultwarden.ldapEndpoint` option because it was not used in the implementation anyway.\n- Bump Nextcloud default version from 27 to 28. Add support for version 29.\n- Deluge config breaks the authFile into an attrset of user to password file. Also deluge has tests now.\n- 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.\n- Authelia options changed:\n  - `shb.authelia.oidcClients.id` -> `shb.authelia.oidcClients.client_id`\n  - `shb.authelia.oidcClients.description` -> `shb.authelia.oidcClients.client_name`\n  - `shb.authelia.oidcClients.secret` -> `shb.authelia.oidcClients.client_secret`\n  - `shb.authelia.ldapEndpoint` -> `shb.authelia.ldapHostname` and `shb.authelia.ldapPort`\n  - `shb.authelia.jwtSecretFile` -> `shb.authelia.jwtSecret.result.path`\n  - `shb.authelia.ldapAdminPasswordFile` -> `shb.authelia.ldapAdminPassword.result.path`\n  - `shb.authelia.sessionSecretFile` -> `shb.authelia.sessionSecret.result.path`\n  - `shb.authelia.storageEncryptionKeyFile` -> `shb.authelia.storageEncryptionKey.result.path`\n  - `shb.authelia.identityProvidersOIDCIssuerPrivateKeyFile` -> `shb.authelia.identityProvidersOIDCIssuerPrivateKey.result.path`\n  - `shb.authelia.smtp.passwordFile` -> `shb.authelia.smtp.password.result.path`\n- Make Nextcloud automatically disable maintenance mode upon service restart.\n- `shb.ldap.ldapUserPasswordFile` -> `shb.ldap.ldapUserPassword.result.path`\n- `shb.ldap.jwtSecretFile` -> `shb.ldap.jwtSecret.result.path`\n- Jellyfin changes:\n  - `shb.jellyfin.ldap.passwordFile` -> `shb.jellyfin.ldap.adminPassword.result.path`.\n  - `shb.jellyfin.sso.secretFile` -> `shb.jellyfin.ldap.sharedSecret.result.path`.\n  - + `shb.jellyfin.ldap.sharedSecretForAuthelia`.\n- Forgejo changes:\n  - `shb.forgejo.ldap.adminPasswordFile` -> `shb.forgejo.ldap.adminPassword.result.path`.\n  - `shb.forgejo.sso.secretFile` -> `shb.forgejo.ldap.sharedSecret.result.path`.\n  - `shb.forgejo.sso.secretFileForAuthelia` -> `shb.forgejo.ldap.sharedSecretForAuthelia.result.path`.\n  - `shb.forgejo.adminPasswordFile` -> `shb.forgejo.adminPassword.result.path`.\n  - `shb.forgejo.databasePasswordFile` -> `shb.forgejo.databasePassword.result.path`.\n- Backup:\n  - `shb.restic.instances` options has been split between `shb.restic.instances.request` and `shb.restic.instances.settings`, matching better with contracts.\n- Use of secret contract everywhere.\n\n## User Facing Backwards Compatible Changes\n\n- Add mount contract.\n- Export torrent metrics.\n- Bump chunkSize in Nextcloud to boost performance.\n- Fix home-assistant onboarding file generation. Added new VM test.\n- OIDC and SMTP config are now optional in Vaultwarden. Added new VM test.\n- Add default OIDC config for Authelia. This way, Authelia can start even with no config or only forward auth configs.\n- Fix replaceSecrets function. It wasn't working correctly with functions from `lib.generators` and `pkgs.pkgs-lib.formats`. Also more test coverage.\n- Add udev extra rules to allow smartctl Prometheus exporter to find NVMe drives.\n- Revert Loki to major version 2 because upgrading to version 3 required manual intervention as Loki\n  refuses to start. So until this issue is tackled, reverting is the best immediate fix.\n  See https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4\n- Add prometheus deluge exporter support. It just needs the `shb.deluge.prometheusScraperPasswordFile` option to be set.\n\n## Other Changes\n\n- Add pretty printing of test errors. Instead of:\n  ```\n  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\"]}}}}\n  ```\n  You now see:\n  ```\n  error: testRadarr failed (- expected, + result)\n   {\n     \"dictionary_item_added\": [\n       \"root['shb']['nginx']['vhosts']\"\n     ],\n     \"dictionary_item_removed\": [\n       \"root['shb']['nginx']['authEndpoint']\"\n     ]\n   }\n  ```\n- Made Nextcloud LDAP setup use a hardcoded configID. This makes the detection of an existing config much more robust.\n\n# 0.1.0\n\nCreation of CHANGELOG.md\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "![GitHub Release](https://img.shields.io/github/v/release/ibizaman/selfhostblocks)\n![GitHub commits since latest release (branch)](https://img.shields.io/github/commits-since/ibizaman/selfhostblocks/latest/main)\n![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/w/ibizaman/selfhostblocks/main)\n![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-raw/ibizaman/selfhostblocks)\n![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed-raw/ibizaman/selfhostblocks?label=closed)\n![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-raw/ibizaman/selfhostblocks)\n![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed-raw/ibizaman/selfhostblocks?label=closed)\n\n[![Documentation](https://github.com/ibizaman/selfhostblocks/actions/workflows/pages.yml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/pages.yml)\n[![Tests](https://github.com/ibizaman/selfhostblocks/actions/workflows/build.yaml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/build.yaml)\n[![Demo](https://github.com/ibizaman/selfhostblocks/actions/workflows/demo.yml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/demo.yml)\n![Matrix](https://img.shields.io/matrix/selfhostblocks%3Amatrix.org)\n\n<hr />\n\n# SelfHostBlocks\n\nSelfHostBlocks is:\n\n- Your escape from the cloud, for privacy and data sovereignty enthusiast. [Why?](#why-self-hosting)\n- A groupware to self-host [all your data](#services): documents, pictures, calendars, contacts, etc.\n- An opinionated NixOS server management OS for a [safe self-hosting experience](#features).\n- A NixOS distribution making sure all services build and work correctly thanks to NixOS VM tests.\n- A collection of NixOS modules standardizing options so configuring services [look the same](#unified-interfaces).\n- A testing ground for [contracts](#contracts) which intents to make nixpkgs modules more modular.\n- [Upstreaming][] as much as possible.\n\n[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\n\n## Why Self-Hosting\n\nIt is obvious by now that\na deep dependency on proprietary service providers - \"the cloud\" -\nis a significant liability.\nOne aspect often talked about is privacy\nwhich is inherently not guaranteed when using a proprietary service\nand is a valid concern.\nA more punishing issue is having your account closed or locked\nwithout prior warning\nWhen that happens,\nyou get an instantaneous sinking feeling in your stomach\nat the realization you lost access to your data,\npossibly without recourse.\n\nHosting services yourself is the obvious alternative\nto alleviate those concerns\nbut it tends to require a lot of technical skills and time.\nSelfHostBlocks (together with its sibling project [Skarabox][])\naims to lower the bar to self-hosting,\nand provides an opinionated server management system based on NixOS modules\nembedding best practices.\nContrary to other server management projects,\nits main focus is ease of long term maintenance\nbefore ease of installation.\nTo achieve this, it provides building blocks to setup services.\nSome are already provided out of the box,\nand customizing or adding additional ones is done easily.\n\nThe building blocks fit nicely together thanks to [contracts](#contracts)\nwhich SelfHostBlocks sets out to introduce into nixpkgs.\nThis will increase modularity, code reuse\nand empower end users to assemble components\nthat fit together to build their server.\n\n## TOC\n\n<!--toc:start-->\n- [Usage](#usage)\n  - [At a Glance](#at-a-glance)\n  - [Existing Installation](#existing-installation)\n  - [Installation From Scratch](#installation-from-scratch)\n- [Features](#features)\n  - [Services](#services)\n  - [Blocks](#blocks)\n  - [Unified Interfaces](#unified-interfaces)\n  - [Contracts](#contracts)\n  - [Interfacing With Other OSes](#interfacing-with-other-oses)\n  - [Sitting on the Shoulders of a Giant](#sitting-on-the-shoulders-of-a-giant)\n  - [Automatic Updates](#automatic-updates)\n  - [Demos](#demos)\n- [Roadmap](#roadmap)\n- [Community](#community)\n- [Funding](#funding)\n- [License](#license)\n<!--toc:end-->\n\n## Usage\n\n> **Caution:** You should know that although I am using everything in this repo for my personal\n> production server, this is really just a one person effort for now and there are most certainly\n> bugs that I didn't discover yet.\n\nTo get started using SelfHostBlocks, the following snippet is enough:\n\n```nix\n{\n  inputs.selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n\n  outputs = { selfhostblocks, ... }: let\n    system = \"x86_64-linux\";\n    nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n  in\n    nixosConfigurations = {\n      myserver = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          selfhostblocks.nixosModules.default\n          ./configuration.nix\n        ];\n      };\n    };\n}\n```\n\nSelfHostBlocks provides its own patched nixpkgs, so you are required to use it\notherwise evaluation can quickly break.\n[The usage section](https://shb.skarabox.com/usage.html) of the manual has\nmore details and goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][]\nand also how to handle secrets management with [SOPS][].\n\n[Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena\n[nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild\n[deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs\n[SOPS]: https://shb.skarabox.com/usage.html#usage-secrets\n\nThen, to actually configure services, you can choose which one interests you in\nthe [services section](https://shb.skarabox.com/services.html) of the manual.\n\nThe [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases.\n\nHead over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org)\nfor any remaining question, or just to say hi :)\n\n### Installation From Scratch\n\nI do recommend for this my sibling project [Skarabox][]\nwhich bootstraps a new server and sets up a few tools:\n\n- Create a bootable ISO, installable on an USB key.\n- Handles one or two (in raid 1) SSDs for root partition.\n- Handles two (in raid 1) or more hard drives for data partition.\n- [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) to install NixOS headlessly.\n- [disko](https://github.com/nix-community/disko) to format the drives using native ZFS encryption with remote unlocking through ssh.\n- [sops-nix](https://github.com/Mic92/sops-nix) to handle secrets.\n- [deploy-rs](https://github.com/serokell/deploy-rs) to deploy updates.\n\n[Skarabox]:  https://github.com/ibizaman/skarabox\n\n## Features\n\nSelfHostBlocks provides building blocks that take care of common self-hosting needs:\n\n- Backup for all services.\n- Automatic creation of ZFS datasets per service.\n- LDAP and SSO integration for most services.\n- Monitoring with Grafana and Prometheus stack with provided dashboards and integration with Scrutiny.\n- Automatic reverse proxy and certificate management for HTTPS.\n- VPN and proxy tunneling services.\n\nGreat care is taken to make the proposed stack robust.\nThis translates into a test suite comprised of automated NixOS VM tests\nwhich includes playwright tests to verify some important workflow\nlike logging in.\n\nThis test suite also serves as a guaranty that all services provided by SelfHostBlocks\nall evaluate, build and work correctly together. It works similarly as a distribution but here it's all [automated](#automatic-updates).\n\nAlso, the stack fits together nicely thanks to [contracts](#contracts).\n\n### Services\n\n[Provided services](https://shb.skarabox.com/services.html) are:\n\n- Nextcloud\n- Audiobookshelf\n- Deluge + *arr stack\n- Simple NixOS Mailserver\n- Firefly-iii\n- Forgejo\n- Grocy\n- Hledger\n- Home-Assistant\n- Jellyfin\n- Karakeep\n- Open WebUI\n- Pinchflat\n- Vaultwarden\n\nLike explained above, those services all benefit from\nout of the box backup,\nLDAP and SSO integration,\nmonitoring with Grafana,\nreverse proxy and certificate management\nand VPN integration for the *arr suite.\n\nSome services do not have an entry yet in the manual.\nTo know options for those, the only way for now\nis to go to the [All Options][] section of the manual.\n\n[All Options]: https://shb.skarabox.com/options.html\n\n### Blocks\n\nThe services above rely on the following [common blocks][]\nwhich altogether provides a solid foundation for self-hosting services:\n\n- Authelia\n- BorgBackup\n- Davfs\n- LDAP\n- Monitoring (Grafana - Prometheus - Loki stack + Scrutiny)\n- Nginx\n- PostgreSQL\n- Restic\n- Sops\n- SSL\n- Tinyproxy\n- VPN\n- ZFS\n\nThose blocks can be used with services\nnot provided by SelfHostBlocks as shown [in the manual][common blocks].\n\n[common blocks]: https://shb.skarabox.com/blocks.html\n\nThe manual also provides documentation for each individual blocks.\n\n### Unified Interfaces\n\nThanks to the blocks,\nSelfHostBlocks provides an unified configuration interface\nfor the services it provides.\n\nCompare the configuration for Nextcloud and Forgejo.\nThe following snippets focus on similitudes and assume the relevant blocks - like secrets - are configured off-screen.\nIt also does not show specific options for each service.\nThese are still complete snippets that configure HTTPS,\nsubdomain serving the service, LDAP and SSO integration.\n\n```nix\nshb.nextcloud = {\n  enable = true;\n  subdomain = \"nextcloud\";\n  domain = \"example.com\";\n\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  apps.ldap = {\n    enable = true;\n    host = \"127.0.0.1\";\n    port = config.shb.lldap.ldapPort;\n    dcdomain = config.shb.lldap.dcdomain;\n    adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap/admin_password\".result;\n  };\n  apps.sso = {\n    enable = true;\n    endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n    secret.result = config.shb.sops.secret.\"nextcloud/sso/secret\".result;\n    secretForAuthelia.result = config.shb.sops.secret.\"nextcloud/sso/secretForAuthelia\".result;\n  };\n};\n```\n\n```nix\nshb.forgejo = {\n  enable = true;\n  subdomain = \"forgejo\";\n  domain = \"example.com\";\n\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  ldap = {\n    enable = true;\n    host = \"127.0.0.1\";\n    port = config.shb.lldap.ldapPort;\n    dcdomain = config.shb.lldap.dcdomain;\n    adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap/admin_password\".result;\n  };\n\n  sso = {\n    enable = true;\n    endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n    secret.result = config.shb.sops.secret.\"forgejo/sso/secret\".result;\n    secretForAuthelia.result = config.shb.sops.secret.\"forgejo/sso/secretForAuthelia\".result;\n  };\n};\n```\n\nAs you can see, they are pretty similar!\nThis makes setting up a new service pretty easy and intuitive.\n\nSelfHostBlocks provides an ever growing list of [services](#services)\nthat are configured in the same way.\n\n### Contracts\n\nTo make building blocks that fit nicely together,\nSelfHostBlocks pioneers [contracts][] which allows you, the final user,\nto be more in control of which piece goes where.\nThis lets you choose, for example,\nany reverse proxy you want or any database you want,\nwithout requiring work from maintainers of the services you want to self host.\n\nAn [RFC][] exists to upstream this concept into `nixpkgs`.\nThe [manual][contracts] also provides an explanation of the why and how of contracts.\n\nAlso, two videos exist of me presenting the topic,\nthe first at [NixCon North America in spring of 2024][NixConNA2024]\nand the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024].\n\n[contracts]: https://shb.skarabox.com/contracts.html\n[RFC]: https://github.com/NixOS/rfcs/pull/189\n[NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM\n[NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc\n\n### Interfacing With Other OSes\n\nThanks to [contracts](#contracts), one can interface NixOS\nwith systems on other OSes.\nThe [RFC][] explains how that works.\n\n### Sitting on the Shoulders of a Giant\n\nBy using SelfHostBlocks, you get all the benefits of NixOS\nwhich are, for self hosted applications specifically:\n\n- declarative configuration;\n- atomic configuration rollbacks;\n- real programming language to define configurations;\n- create your own higher level abstractions on top of SelfHostBlocks;\n- integration with the rest of nixpkgs;\n- much fewer \"works on my machine\" type of issues.\n\n### Automatic Updates\n\nSelfHostBlocks follows nixpkgs unstable branch closely.\nThere is a GitHub action running every couple of days that updates\nthe `nixpkgs` input in the root `flakes.nix`,\nruns the tests and merges the PR automatically\nif the tests pass.\n\nA release is then made every few commits,\nwhenever deemed sensible.\nOn your side, to update I recommend pinning to a release\nwith the following command,\nreplacing the RELEASE with the one you want:\n\n```bash\nRELEASE=0.2.4\nnix flake update \\\n  --override-input selfhostblocks github:ibizaman/selfhostblocks/$RELEASE \\\n  selfhostblocks\n```\n\n### Demos\n\nDemos that start and deploy a service\non a Virtual Machine on your computer are located\nunder the [demo](./demo/) folder.\n\nThese show the onboarding experience you would get\nif you deployed one of the services on your own server.\n\n## Roadmap\n\nCurrently, the Nextcloud and Vaultwarden services\nand the SSL and backup blocks\nare the most advanced and most documented.\n\nDocumenting all services and blocks will be done\nas I make all blocks and services use the contracts.\n\nUpstreaming changes is also on the roadmap.\n\nCheck the [issues][] and the [milestones]() to see planned work.\nFeel free to add more or to contribute!\n\n[issues]: (https://github.com/ibizaman/selfhostblocks/issues)\n[milestones]: https://github.com/ibizaman/selfhostblocks/milestones\n\nAll blocks and services have NixOS tests.\nAlso, I am personally using all the blocks and services in this project, so they do work to some extent.\n\n## Community\n\nThis project has been the main focus\nof my (non work) life for the past 3 year now\nand I intend to continue working on this for a long time.\n\nAll issues and PRs are welcome:\n\n- Use this project. Something does not make sense? Something's not working?\n- Documentation. Something is not clear?\n- New services. Have one of your preferred service not integrated yet?\n- Better patterns. See something weird in the code?\n\nFor PRs, if they are substantial changes, please open an issue to\ndiscuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html)\nof the manual.\n\nIssues that are being worked on are labeled with the [in progress][] label.\nBefore starting work on those, you might want to talk about it in the issue tracker\nor in the [matrix][] channel.\n\nThe prioritized issues are those belonging to the [next milestone][milestone].\nThose issues are not set in stone and I'd be very happy to solve\nan issue an user has before scratching my own itch.\n\n[in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22\n[matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org\n[milestone]: https://github.com/ibizaman/selfhostblocks/milestones\n\nOne aspect that's close to my heart is I intent to make SelfHostBlocks the lightest layer on top of nixpkgs as\npossible. I want to upstream as much as possible. I will still take some time to experiment here but\nwhen I'm satisfied with how things look, I'll upstream changes.\n\n## Funding\n\nI was lucky to [obtain a grant][nlnet] from NlNet which is an European fund,\nunder [NGI Zero Core][NGI0],\nto work on this project.\nThis also funds the contracts RFC.\n\nGo apply for a grant too!\n\n[nlnet]: https://nlnet.nl/project/SelfHostBlocks\n[NGI0]: https://nlnet.nl/core/\n\n<p>\n<img alt=\"NlNet logo\" src=\"https://nlnet.nl/logo/banner.svg\" width=\"200\" />\n<img alt=\"NGI Zero Core logo\" src=\"https://nlnet.nl/image/logos/NGI0Core_tag.svg\" width=\"200\" />\n</p>\n\n## License\n\nI'm following the [Nextcloud](https://github.com/nextcloud/server) license which is AGPLv3.\nSee [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.\n"
  },
  {
    "path": "VERSION",
    "content": "0.8.0"
  },
  {
    "path": "demo/homeassistant/README.md",
    "content": "# Home Assistant Demo {#demo-homeassistant}\n\n**This whole demo is highly insecure as all the private keys are available publicly. This is\nonly done for convenience as it is just a demo. Do not expose the VM to the internet.**\n\nThe [`flake.nix`](./flake.nix) file sets up a Home Assistant server with Self Host Blocks. There are actually 2 demos:\n\n- The `basic` demo sets up a lone Home Assistant server accessible through http.\n- The `ldap` demo builds on top of the `basic` demo integrating Home Assistant with a LDAP provider.\n\n<!--\nThey were set up by following the [manual](https://shb.skarabox.com/services-homeassistant.html).\n-->\n\nThis guide will show how to deploy these demos to a Virtual Machine, like showed\n[here](https://nixos.wiki/wiki/NixOS_modules#Developing_modules).\n\n## Deploy to the VM {#demo-homeassistant-deploy}\n\nThe demos are setup to either deploy to a VM through `nixos-rebuild` or through\n[Colmena](https://colmena.cli.rs).\n\nUsing `nixos-rebuild` is very fast and requires less steps because it reuses your nix store.\n\nUsing `colmena` is more authentic because you are deploying to a stock VM, like you would with a\nreal machine but it needs to copy over all required store derivations so it takes a few minutes the\nfirst time.\n\n### Deploy with nixos-rebuild {#demo-homeassistant-deploy-nixosrebuild}\n\nAssuming your current working directory is the one where this Readme file is located, the one-liner\ncommand which builds and starts the VM configured to run Self Host Blocks' Nextcloud is:\n\n```nix\nrm nixos.qcow2; \\\n  nixos-rebuild build-vm --flake .#basic \\\n  && QEMU_NET_OPTS=\"hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80\" \\\n     ./result/bin/run-nixos-vm\n```\n\nThis will deploy the `basic` demo. If you want to deploy the `ldap` demo, use the `.#ldap` flake\nuris.\n\nYou can even test the demos from any directory without cloning this repository by using the GitHub\nuri like `github:ibizaman/selfhostblocks?path=demo/nextcloud`\n\nIt is very important to remove leftover `nixos.qcow2` files, if any.\n\nYou can ssh into the VM like this, but this is not required for the demo:\n\n```bash\nssh -F ssh_config example\n```\n\nBut before that works, you will need to change the permission of the ssh key like so:\n\n```bash\nchmod 600 sshkey\n```\n\nThis is only needed because git mangles with the permissions. You will not even see this change in\n`git status`.\n### Deploy with Colmena {#demo-homeassistant-deploy-colmena}\n\nIf you deploy with Colmena, you must first build the VM and start it:\n\n```bash\nrm nixos.qcow2; \\\n  nixos-rebuild build-vm-with-bootloader --fast -I nixos-config=./configuration.nix -I nixpkgs=. ; \\\n  QEMU_NET_OPTS=\"hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80\" ./result/bin/run-nixos-vm\n```\n\nIt is very important to remove leftover `nixos.qcow2` files, if any.\n\nThis last call is blocking, so I advice adding a `&` at the end of the command otherwise you will\nneed to run the rest of the commands in another terminal.\n\nWith the VM started, make the secrets in `secrets.yaml` decryptable in the VM. This change will\nappear in `git status` but you don't need to commit this.\n\n```bash\nSOPS_AGE_KEY_FILE=keys.txt \\\n  nix run --impure nixpkgs#sops -- --config sops.yaml -r -i \\\n  --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') \\\n  secrets.yaml\n```\n\nThe nested command, the one in between the parenthesis `$(...)`, is used to print the VM's public\nage key, which is then added to the `secrets.yaml` file in order to make the secrets decryptable by\nthe VM.\n\nIf you forget this step, the deploy will seem to go fine but the secrets won't be populated and\nneither LLDAP nor Home Assistant will start.\n\nMake the ssh key private:\n\n```bash\nchmod 600 sshkey\n```\n\nThis is only needed because git mangles with the permissions. You will not even see this change in\n`git status`.\n\nYou can ssh into the VM with, but this is not required for the demo:\n\n```bash\nssh -F ssh_config example\n```\n\n### Home Assistant through HTTP {#demo-homeassistant-deploy-basic}\n\n<!--\n:::: {.note}\nThis section corresponds to the `basic` section of the [Home Assistant\nmanual](services-nextcloud.html#services-homeassistant-server-usage-basic).\n::::\n-->\n\nAssuming you already deployed the `basic` demo, now you must add the following entry to the\n`/etc/hosts` file on the host machine (not the VM):\n\n```nix\nnetworking.hosts = {\n  \"127.0.0.1\" = [ \"ha.example.com\" ];\n};\n```\n\nWhich produces:\n\n```bash\n$ cat /etc/hosts\n127.0.0.1 ha.example.com\n```\n\nGo to [http://ha.example.com:8080](http://ha.example.com:8080) and you will be greeted with the Home\nAssistant setup wizard which will allow you to create an admin user.\n\nAnd that's the end of the demo\n\n### Home Assistant with LDAP through HTTP {#demo-homeassistant-deploy-ldap}\n\n<!--\n:::: {.note}\nThis section corresponds to the `ldap` section of the [Home Assistant\nmanual](services-nextcloud.html#services-homeassistant-server-usage-ldap).\n::::\n-->\n\nAssuming you already deployed the `ldap` demo, now you must add the following entry to the\n`/etc/hosts` file on the host machine (not the VM):\n\n```nix\nnetworking.hosts = {\n  \"127.0.0.1\" = [ \"ha.example.com\" \"ldap.example.com\" ];\n};\n```\n\nWhich produces:\n\n```bash\n$ cat /etc/hosts\n127.0.0.1 ha.example.com ldap.example.com\n```\n\nGo first to [http://ldap.example.com:8080](http://ldap.example.com:8080) and login with:\n\n- username: `admin`\n- password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is `fccb94f0f64bddfe299c81410096499a`.\n\nCreate the group `homeassistant_user` and a user assigned to that group.\n\nGo to [http://ha.example.com:8080](http://ha.example.com:8080) and login with the\nuser and password you just created above.\n\n## In More Details {#demo-homeassistant-in-more-details}\n\n### Files {#demo-homeassistant-files}\n\n- [`flake.nix`](./flake.nix): nix entry point, defines one target host for\n  [colmena](https://colmena.cli.rs) to deploy to as well as the selfhostblocks' config for\n  setting up the home assistant server paired with the LDAP server.\n- [`configuration.nix`](./configuration.nix): defines all configuration required for colmena\n  to deploy to the VM. The file has comments if you're interested.\n- [`hardware-configuration.nix`](./hardware-configuration.nix): defines VM specific layout.\n  This was generated with nixos-generate-config on the VM.\n- Secrets related files:\n  - [`keys.txt`](./keys.txt): your private key for sops-nix, allows you to edit the `secrets.yaml`\n    file. This file should never be published but here I did it for convenience, to be able to\n    deploy to the VM in less steps.\n  - [`secrets.yaml`](./secrets.yaml): encrypted file containing required secrets for Home Assistant\n    and the LDAP server. This file can be publicly accessible.\n  - [`sops.yaml`](./sops.yaml): describes how to create the `secrets.yaml` file. Can be publicly\n    accessible.\n- SSH related files:\n  - [`sshkey(.pub)`](./sshkey): your private and public ssh keys. Again, the private key should usually not\n    be published as it is here but this makes it possible to deploy to the VM in less steps.\n  - [`ssh_config`](./ssh_config): the ssh config allowing you to ssh into the VM by just using the\n    hostname `example`. Usually you would store this info in your `~/.ssh/config` file but it's\n    provided here to avoid making you do that.\n\n### Virtual Machine {#demo-homeassistant-virtual-machine}\n\n_More info about the VM._\n\nWe use `build-vm-with-bootloader` instead of just `build-vm` as that's the only way to deploy to the VM.\n\nThe VM's User and password are both `nixos`, as setup in the [`configuration.nix`](./configuration.nix) file under\n`user.users.nixos.initialPassword`.\n\nYou can login with `ssh -F ssh_config example`. You just need to accept the fingerprint.\n\nThe 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.\n\nThat being said, the VM uses `tmpfs` to create the writable nix store so if you stumble in a disk\nspace issue, you must increase the\n`virtualisation.vmVariantWithBootLoader.virtualisation.memorySize` setting.\n\n### Secrets {#demo-homeassistant-secrets}\n\n_More info about the secrets can be found in the [Usage](https://shb.skarabox.com/usage.html) manual_\n\nTo open the `secrets.yaml` file and optionnally edit it, run:\n\n```bash\nSOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \\\n  --config sops.yaml \\\n  secrets.yaml\n```\n\nThe `secrets.yaml` file must follow the format:\n\n```yaml\nhome-assistant:\n    country: \"US\"\n    latitude: \"0.100\"\n    longitude: \"-0.100\"\n    time_zone: \"America/Los_Angeles\"\nlldap:\n    user_password: XXX...\n    jwt_secret: YYY...\n```\n\nYou can generate random secrets with:\n\n```bash\n$ nix run nixpkgs#openssl -- rand -hex 64\n```\n\nIf you choose a password too small, some services could refuse to start.\n\n#### Why do we need the VM's public key {#demo-homeassistant-tips-public-key-necessity}\n\nThe [`sops.yaml`](./sops.yaml) file describes what private keys can decrypt and encrypt the\n[`secrets.yaml`](./secrets.yaml) file containing the application secrets. Usually, you will create and add\nsecrets to that file and when deploying, it will be decrypted and the secrets will be copied\nin the `/run/secrets` folder on the VM. We thus need one private key for you to edit the\n[`secrets.yaml`](./secrets.yaml) file and one in the VM for it to decrypt the secrets.\n\nYour private key is already pre-generated in this repo, it's the [`sshkey`](./sshkey) file. But when\ncreating the VM for Colmena, a new private key and its accompanying public key were automatically\ngenerated under `/etc/ssh/ssh_host_ed25519_key` in the VM. We just need to get the public key and\nadd it to the `secrets.yaml` which we did in the Deploy section.\n\n### SSH {#demo-homeassistant-tips-ssh}\n\nThe private and public ssh keys were created with:\n\n```bash\nssh-keygen -t ed25519 -f sshkey\n```\n\nYou 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.\nThis allows us also to disable ssh password authentication.\n\nFor reference, if instead you didn't copy the key over on VM creating and enabled ssh\nauthentication, here is what you would need to do to copy over the key:\n\n```bash\nnix shell nixpkgs#openssh --command ssh-copy-id -i sshkey -F ssh_config example\n```\n\n### Deploy {#demo-homeassistant-tips-deploy}\n\nIf you get a NAR hash mismatch error like hereunder, you need to run `nix flake lock --update-input\nselfhostblocks`.\n\n```\nerror: NAR hash mismatch in input ...\n```\n\n### Update Demo {#demo-homeassistant-tips-update-demo}\n\nIf you update the Self Host Blocks configuration in `flake.nix` file, you can just re-deploy.\n\nIf you update the `configuration.nix` file, you will need to rebuild the VM from scratch.\n\nIf you update a module in the Self Host Blocks repository, you will need to update the lock file with:\n\n```bash\nnix flake lock --override-input selfhostblocks ../.. --update-input selfhostblocks\n```\n"
  },
  {
    "path": "demo/homeassistant/configuration.nix",
    "content": "{ config, pkgs, ... }:\n\nlet\n  targetUser = \"nixos\";\n  targetPort = 2222;\nin\n{\n  imports = [\n    # Include the results of the hardware scan.\n    ./hardware-configuration.nix\n  ];\n\n  boot.loader.grub.enable = true;\n  boot.kernelModules = [ \"kvm-intel\" ];\n  system.stateVersion = \"22.11\";\n\n  # Options above are generate by running nixos-generate-config on the VM.\n\n  # Needed otherwise deploy will say system won't be able to boot.\n  boot.loader.grub.device = \"/dev/vdb\";\n  # Needed to avoid getting into not available disk space in /boot.\n  boot.loader.grub.configurationLimit = 1;\n  # The NixOS /nix/.rw-store mountpoint is backed by tmpfs which uses memory. We need to increase\n  # the available disk space to install home-assistant.\n  virtualisation.vmVariantWithBootLoader.virtualisation.memorySize = 8192;\n\n  # Options above are needed to deploy in a VM.\n\n  nix.settings.experimental-features = [\n    \"nix-command\"\n    \"flakes\"\n  ];\n\n  # We need to create the user we will deploy with.\n  users.users.${targetUser} = {\n    isNormalUser = true;\n    extraGroups = [ \"wheel\" ]; # Enable ‘sudo’ for the user.\n    initialPassword = \"nixos\";\n    # With this option, you don't need to use ssh-copy-id to copy the public ssh key to the VM.\n    openssh.authorizedKeys.keyFiles = [\n      ./sshkey.pub\n    ];\n  };\n\n  # The user we're deploying with must be able to run sudo without password.\n  security.sudo.extraRules = [\n    {\n      users = [ targetUser ];\n      commands = [\n        {\n          command = \"ALL\";\n          options = [ \"NOPASSWD\" ];\n        }\n      ];\n    }\n  ];\n\n  # Needed to allow the user we're deploying with to write to the nix store.\n  nix.settings.trusted-users = [\n    targetUser\n  ];\n\n  # We need to enable the ssh daemon to be able to deploy.\n  services.openssh = {\n    enable = true;\n    ports = [ targetPort ];\n    settings = {\n      PermitRootLogin = \"no\";\n      PasswordAuthentication = false;\n    };\n  };\n}\n"
  },
  {
    "path": "demo/homeassistant/flake.nix",
    "content": "{\n  description = \"Home Assistant example for Self Host Blocks\";\n\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n    sops-nix.url = \"github:Mic92/sops-nix\";\n  };\n\n  outputs =\n    inputs@{\n      self,\n      selfhostblocks,\n      sops-nix,\n    }:\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n\n      basic =\n        { config, ... }:\n        {\n          imports = [\n            ./configuration.nix\n            selfhostblocks.nixosModules.authelia\n            selfhostblocks.nixosModules.home-assistant\n            selfhostblocks.nixosModules.sops\n            selfhostblocks.nixosModules.ssl\n            sops-nix.nixosModules.default\n          ];\n\n          sops.defaultSopsFile = ./secrets.yaml;\n\n          shb.home-assistant = {\n            enable = true;\n            domain = \"example.com\";\n            subdomain = \"ha\";\n            config = {\n              name = \"SHB Home Assistant\";\n              country.source = config.shb.sops.secret.\"home-assistant/country\".result.path;\n              latitude.source = config.shb.sops.secret.\"home-assistant/latitude\".result.path;\n              longitude.source = config.shb.sops.secret.\"home-assistant/longitude\".result.path;\n              time_zone.source = config.shb.sops.secret.\"home-assistant/time_zone\".result.path;\n              unit_system = \"metric\";\n            };\n          };\n          shb.sops.secret.\"home-assistant/country\".request = {\n            mode = \"0440\";\n            owner = \"hass\";\n            group = \"hass\";\n            restartUnits = [ \"home-assistant.service\" ];\n          };\n          shb.sops.secret.\"home-assistant/latitude\".request = {\n            mode = \"0440\";\n            owner = \"hass\";\n            group = \"hass\";\n            restartUnits = [ \"home-assistant.service\" ];\n          };\n          shb.sops.secret.\"home-assistant/longitude\".request = {\n            mode = \"0440\";\n            owner = \"hass\";\n            group = \"hass\";\n            restartUnits = [ \"home-assistant.service\" ];\n          };\n          shb.sops.secret.\"home-assistant/time_zone\".request = {\n            mode = \"0440\";\n            owner = \"hass\";\n            group = \"hass\";\n            restartUnits = [ \"home-assistant.service\" ];\n          };\n\n          nixpkgs.config.permittedInsecurePackages = [\n            \"openssl-1.1.1w\"\n          ];\n        };\n\n      ldap =\n        { config, ... }:\n        {\n          shb.lldap = {\n            enable = true;\n            domain = \"example.com\";\n            subdomain = \"ldap\";\n            ldapPort = 3890;\n            webUIListenPort = 17170;\n            dcdomain = \"dc=example,dc=com\";\n            ldapUserPassword.result = config.shb.sops.secret.\"lldap/user_password\".result;\n            jwtSecret.result = config.shb.sops.secret.\"lldap/jwt_secret\".result;\n          };\n          shb.sops.secret.\"lldap/user_password\".request = config.shb.lldap.ldapUserPassword.request;\n          shb.sops.secret.\"lldap/jwt_secret\".request = config.shb.lldap.jwtSecret.request;\n\n          shb.home-assistant.ldap = {\n            enable = true;\n            host = \"127.0.0.1\";\n            port = config.shb.lldap.webUIListenPort;\n            userGroup = \"homeassistant_user\";\n          };\n        };\n\n      sopsConfig = {\n        sops.age.keyFile = \"/etc/sops/my_key\";\n        environment.etc.\"sops/my_key\".source = ./keys.txt;\n      };\n    in\n    {\n      nixosConfigurations = {\n        basic = nixpkgs'.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            basic\n            sopsConfig\n          ];\n        };\n\n        ldap = nixpkgs'.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            basic\n            ldap\n            sopsConfig\n          ];\n        };\n      };\n\n      colmena = {\n        meta = {\n          nixpkgs = import nixpkgs' {\n            system = \"x86_64-linux\";\n          };\n          specialArgs = inputs;\n        };\n\n        basic =\n          { config, ... }:\n          {\n            imports = [\n              basic\n            ];\n\n            # Used by colmena to know which target host to deploy to.\n            deployment = {\n              targetHost = \"example\";\n              targetUser = \"nixos\";\n              targetPort = 2222;\n            };\n          };\n\n        ldap =\n          { config, ... }:\n          {\n            imports = [\n              basic\n              ldap\n            ];\n\n            # Used by colmena to know which target host to deploy to.\n            deployment = {\n              targetHost = \"example\";\n              targetUser = \"nixos\";\n              targetPort = 2222;\n            };\n          };\n      };\n    };\n}\n"
  },
  {
    "path": "demo/homeassistant/hardware-configuration.nix",
    "content": "# This file was generated by running nixos-generate-config on the VM.\n#\n# Do not modify this file!  It was generated by ‘nixos-generate-config’\n# and may be overwritten by future invocations.  Please make changes\n# to /etc/nixos/configuration.nix instead.\n{\n  config,\n  lib,\n  pkgs,\n  modulesPath,\n  ...\n}:\n\n{\n  imports = [\n    (modulesPath + \"/profiles/qemu-guest.nix\")\n  ];\n\n  boot.initrd.availableKernelModules = [\n    \"ata_piix\"\n    \"uhci_hcd\"\n    \"virtio_pci\"\n    \"floppy\"\n    \"sr_mod\"\n    \"virtio_blk\"\n  ];\n  boot.initrd.kernelModules = [ ];\n  boot.kernelModules = [ \"kvm-intel\" ];\n  boot.extraModulePackages = [ ];\n\n  fileSystems.\"/\" = {\n    device = \"/dev/vda\";\n    fsType = \"ext4\";\n  };\n\n  fileSystems.\"/nix/.ro-store\" = {\n    device = \"nix-store\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/nix/.rw-store\" = {\n    device = \"tmpfs\";\n    fsType = \"tmpfs\";\n  };\n\n  fileSystems.\"/tmp/shared\" = {\n    device = \"shared\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/tmp/xchg\" = {\n    device = \"xchg\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/nix/store\" = {\n    device = \"overlay\";\n    fsType = \"overlay\";\n  };\n\n  fileSystems.\"/boot\" = {\n    device = \"/dev/vdb2\";\n    fsType = \"vfat\";\n  };\n\n  swapDevices = [ ];\n\n  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking\n  # (the default) this is the recommended approach. When using systemd-networkd it's\n  # still possible to use this option, but it's recommended to use it in conjunction\n  # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.\n  networking.useDHCP = lib.mkDefault true;\n  # networking.interfaces.eth0.useDHCP = lib.mkDefault true;\n\n  nixpkgs.hostPlatform = lib.mkDefault \"x86_64-linux\";\n  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;\n}\n"
  },
  {
    "path": "demo/homeassistant/keys.txt",
    "content": "# created: 2023-11-17T00:05:25-08:00\n# public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\nAGE-SECRET-KEY-1EPLAHXWDEM5ZZAU7NFGHT5TWU08ZUCWTHYTLD8XC89350MZ0T79SA2MQAL\n"
  },
  {
    "path": "demo/homeassistant/secrets.yaml",
    "content": "home-assistant:\n    country: ENC[AES256_GCM,data:2Ng=,iv:/VMB6yi3e8piAx8DzLGGhLsozxWUWX2R7NcmACFng8Q=,tag:Tx0Iy1AnLmPrnYu7XtbesA==,type:str]\n    latitude: ENC[AES256_GCM,data:p/O1HW4=,iv:CRgL4wcM3gMNu/OAHVoQuLcRD9J3SbkxsjvobiabQ0g=,tag:uIo5Rv7geOtVcarp4Qkqww==,type:str]\n    longitude: ENC[AES256_GCM,data:sVyww6F7,iv:9EZYXSkv+rhD77lqmC+c8i+wf46KPYloVoK+ok3bWYY=,tag:c+lmtcGvULtMdu9ZTDewjA==,type:str]\n    time_zone: ENC[AES256_GCM,data:JKXdsQZrtB1B77klxuemw1tZbg==,iv:nItJfpwp2XWmBHbohrjNMWQ8TpL2Xsv22UujZRgDscw=,tag:wrHbA1yycutUUn79F9wy6Q==,type:str]\nlldap:\n    user_password: ENC[AES256_GCM,data:JrFraqFSqAhRVjB5fagIoB864aejt24q+qqWeu8ySC0=,iv:RS7VS+9tsSknn9SwpfyYVi41m3lN4SkZ4CSwrzH/Eso=,tag:5L7fx6/KhDtjHPruwac/sw==,type:str]\n    jwt_secret: ENC[AES256_GCM,data:W1T/QoxuzMD+2AL7sP5KkMcC+GvFdd4kfd70rHLnQD+jWNs9G0igkC/BxxgbIfnSASwtSnBaaiU6/pxLFOcUVh0Nyd0Zmb/KTbagpUvSl//AZnTt/WKF9Q/8sqKzsGv0QdMyZKWi4cxiEILcTbxOsgwriFGgOJ1k5N8JEif15ig=,iv:rHlRt6nWMz8rVmU0aKH6VWWVXunOfJcDvZOxgWbK1FI=,tag:qC6N61rE8CfPSXrsEqFoIQ==,type:str]\nsops:\n    kms: []\n    gcp_kms: []\n    azure_kv: []\n    hc_vault: []\n    age:\n        - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\n          enc: |\n            -----BEGIN AGE ENCRYPTED FILE-----\n            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWVU9TMjJlRzNKY0hFSktD\n            MkFMUkg2OTZ4aFZMUUJ0UEF3OVpxWFloVWtJCmtrb2UzUDI2b0poc21Cd1A1N0xW\n            cnBZVVNrcllVNktpS0kzRGozbHREK1UKLS0tIHZmSUhTVkRQNGUremZXQlJOOGNB\n            SExYU3VXNVVjMElXdlVsc1VmOFRwYlEKQYeGc8F33qs3PzxXmbwqX+c+fZeEuPpv\n            n0zBA46/HdoCYyuZsW828XVftVcQqiThq/XAe0i648k7E8Slo3Y5bg==\n            -----END AGE ENCRYPTED FILE-----\n        - recipient: age1slc23ln7g0ty5re2n25w3hq0sw2eyphnshe45af55vd23fgwtuhq36hpqr\n          enc: |\n            -----BEGIN AGE ENCRYPTED FILE-----\n            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCNlpOL3lFMVA1L3NkQlE1\n            bnJIRlZ4Z1lCSWdJTzdtTW5SNXRXOTZ6UDJnCndwamZnWnA5TzdsSzZ4MjlTN09K\n            YVZCZkFINDRjQWh2dFVuSmswbWw1dlkKLS0tIGdMalFlc1VrOGdHU2tIZzZoak1n\n            VlJpS1BYd2UrZU1mZTEwU1BYODhqM2sKvQnFV8xsy1tEmYZu4izBYb7XQqTPOLTL\n            bRkU6n17uiyXNbiXDAbX0Png/XmVG96/+Zl38BBXPQvARX8c2tzq6w==\n            -----END AGE ENCRYPTED FILE-----\n    lastmodified: \"2024-02-12T05:07:51Z\"\n    mac: ENC[AES256_GCM,data:MOmvK0g6Wj+fND154QUhmXujsDOKMO5CRRckru+eDRPeHcJZUnI/jjolcI8y+LEdhUVf0Ln8E38GSxZT/8EW3CfCNkOUikGFdfxuQ2uzNp/1wMvNaF988lrXMBfQ7Il18AiYVK0QhGReGXJa6wBVUb2Qfrg41WC65UvQtMOByqI=,iv:Rscvq1l7YgNapC0NkabQHBzirzsPEr8ykAQqx+qGoi0=,tag:ud+K72bnUV1hnsjcewNrsw==,type:str]\n    pgp: []\n    unencrypted_suffix: _unencrypted\n    version: 3.8.1\n"
  },
  {
    "path": "demo/homeassistant/sops.yaml",
    "content": "keys:\n  - &admin age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\ncreation_rules:\n  - path_regex: secrets.yaml$\n    key_groups:\n    - age:\n      - *admin\n"
  },
  {
    "path": "demo/homeassistant/ssh_config",
    "content": "Host example\n  Port 2222\n  User nixos\n  HostName 127.0.0.1\n  IdentityFile sshkey\n  IdentitiesOnly yes\n  StrictHostKeyChecking no\n  UserKnownHostsFile=/dev/null"
  },
  {
    "path": "demo/homeassistant/sshkey",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIwAAAJiBL8xSgS/M\nUgAAAAtzc2gtZWQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIw\nAAAECzMZfgJIQJUVgyKZ3IYnEVvwnYXJ8nstc4/g1H41dC/vueAR1wO7hRVt7ZnMGEqfYe\nE9bQ+USaASlv+SQyJ4UjAAAAEWV4YW1wbGVAbG9jYWxob3N0AQIDBA==\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "demo/homeassistant/sshkey.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPueAR1wO7hRVt7ZnMGEqfYeE9bQ+USaASlv+SQyJ4Uj example@localhost\n"
  },
  {
    "path": "demo/minimal/flake.nix",
    "content": "{\n  description = \"Minimal example to setup SelfHostBlocks\";\n\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n\n    sops-nix = {\n      url = \"github:Mic92/sops-nix\";\n    };\n  };\n\n  outputs =\n    {\n      self,\n      selfhostblocks,\n      sops-nix,\n    }:\n    {\n      nixosConfigurations =\n        let\n          system = \"x86_64-linux\";\n          nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n\n          # This module makes the assertions happy and the build succeed.\n          # This is of course wrong and will not work on any real system.\n          filesystemModule = {\n            fileSystems.\"/\".device = \"/dev/null\";\n            boot.loader.grub.devices = [ \"/dev/null\" ];\n          };\n        in\n        {\n          # Test with:\n          #   nix build .#nixosConfigurations.minimal.config.system.build.toplevel\n          minimal = nixpkgs'.nixosSystem {\n            inherit system;\n            modules = [\n              selfhostblocks.nixosModules.default\n              filesystemModule\n              # This modules showcases the use of SHB's lib.\n              (\n                {\n                  config,\n                  lib,\n                  shb,\n                  ...\n                }:\n                {\n                  options.myOption = lib.mkOption {\n                    # Using provided nixosSystem directly.\n                    # SHB's lib is available under `shb` thanks to the overlay.\n                    type = shb.secretFileType;\n                  };\n                  config = {\n                    myOption.source = \"/a/path\";\n                    # Use the option.\n                    environment.etc.myOption.text = config.myOption.source;\n                  };\n                }\n              )\n            ];\n          };\n\n          # Test with:\n          #   nix build .#nixosConfigurations.sops.config.system.build.toplevel\n          #   nix eval .#nixosConfigurations.sops.config.myOption\n          sops = nixpkgs'.nixosSystem {\n            inherit system;\n            modules = [\n              selfhostblocks.nixosModules.default\n              selfhostblocks.nixosModules.sops\n              sops-nix.nixosModules.default\n              filesystemModule\n              # This modules showcases the use of SHB's lib.\n              (\n                {\n                  config,\n                  lib,\n                  shb,\n                  ...\n                }:\n                {\n                  options.myOption = lib.mkOption {\n                    # Using provided nixosSystem directly.\n                    # SHB's lib is available under `shb` thanks to the overlay.\n                    type = shb.secretFileType;\n                  };\n                  config = {\n                    myOption.source = \"/a/path\";\n                    # Use the option.\n                    environment.etc.myOption.text = config.myOption.source;\n                  };\n                }\n              )\n            ];\n          };\n\n          # This example shows how to import the nixosSystem patches to nixpkgs manually.\n          #\n          # Test with:\n          #   nix build .#nixosConfigurations.lowlevel.config.system.build.toplevel\n          #   nix eval .#nixosConfigurations.lowlevel.config.myOption\n          lowlevel =\n            let\n              # We must import nixosSystem directly from the patched nixpkgs\n              # otherwise we do not get the patches.\n              nixosSystem' = import \"${nixpkgs'}/nixos/lib/eval-config.nix\";\n            in\n            nixosSystem' {\n              inherit system;\n              modules = [\n                selfhostblocks.nixosModules.default\n                filesystemModule\n                # This modules showcases the use of SHB's lib.\n                (\n                  {\n                    config,\n                    lib,\n                    shb,\n                    ...\n                  }:\n                  {\n                    options.myOption = lib.mkOption {\n                      # Using provided nixosSystem directly.\n                      # SHB's lib is available under `shb` thanks to the overlay.\n                      type = shb.secretFileType;\n                    };\n                    config = {\n                      myOption.source = \"/a/path\";\n                      # Use the option.\n                      environment.etc.myOption.text = config.myOption.source;\n                    };\n                  }\n                )\n              ];\n            };\n\n          # This example shows how to apply patches to nixpkgs manually.\n          #\n          # Test with:\n          #   nix build .#nixosConfigurations.manual.config.system.build.toplevel\n          #   nix eval .#nixosConfigurations.manual.config.myOption\n          manual =\n            let\n              pkgs = import selfhostblocks.inputs.nixpkgs {\n                inherit system;\n              };\n              nixpkgs' = pkgs.applyPatches {\n                name = \"nixpkgs-patched\";\n                src = selfhostblocks.inputs.nixpkgs;\n                patches = selfhostblocks.lib.${system}.patches;\n              };\n              # We must import nixosSystem directly from the patched nixpkgs\n              # otherwise we do not get the patches.\n              nixosSystem' = import \"${nixpkgs'}/nixos/lib/eval-config.nix\";\n            in\n            nixosSystem' {\n              inherit system;\n              modules = [\n                selfhostblocks.nixosModules.default\n                filesystemModule\n                # This modules showcases the use of SHB's lib.\n                (\n                  {\n                    config,\n                    lib,\n                    shb,\n                    ...\n                  }:\n                  {\n                    options.myOption = lib.mkOption {\n                      # Using provided nixosSystem directly.\n                      # SHB's lib is available under `shb` thanks to the overlay.\n                      type = shb.secretFileType;\n                    };\n                    config = {\n                      myOption.source = \"/a/path\";\n                      # Use the option.\n                      environment.etc.myOption.text = config.myOption.source;\n                    };\n                  }\n                )\n              ];\n            };\n\n          # Test with:\n          #   nix build .#nixosConfigurations.contractsDirect.config.system.build.toplevel\n          contractsDirect =\n            let\n              nixosSystem' = import \"${selfhostblocks.inputs.nixpkgs}/nixos/lib/eval-config.nix\";\n            in\n            nixosSystem' {\n              inherit system;\n              modules = [\n                filesystemModule\n                (import \"${selfhostblocks}/lib/module.nix\")\n                (\n                  {\n                    config,\n                    lib,\n                    shb,\n                    ...\n                  }:\n                  {\n                    options.myOption = lib.mkOption {\n                      # Using provided nixosSystem directly.\n                      # SHB's lib is available under `shb` thanks to the overlay.\n                      type = shb.secretFileType;\n                    };\n                    config = {\n                      myOption.source = \"/a/path\";\n                      # Use the option.\n                      environment.etc.myOption.text = config.myOption.source;\n                    };\n                  }\n                )\n              ];\n            };\n        };\n    };\n}\n"
  },
  {
    "path": "demo/nextcloud/README.md",
    "content": "# Nextcloud Demo {#demo-nextcloud}\n\n**This whole demo is highly insecure as all the private keys are available publicly. This is\nonly done for convenience as it is just a demo. Do not expose the VM to the internet.**\n\nThe [`flake.nix`](./flake.nix) file sets up a Nextcloud server with Self Host Blocks. There are\nactually 3 demos:\n\n- The `basic` demo sets up a lone Nextcloud server accessible through http with the Preview\n  Generator app enabled.\n- The `ldap` demo builds on top of the `basic` demo integrating Nextcloud with a LDAP provider.\n- The `sso` demo builds on top of the `lsap` demo integrating Nextcloud with a SSO provider.\n\nThey were set up by following the [manual](https://shb.skarabox.com/services-nextcloud.html). This\nguide will show how to deploy these demos to a Virtual Machine, like showed\n[here](https://nixos.wiki/wiki/NixOS_modules#Developing_modules).\n\n## Deploy to the VM {#demo-nextcloud-deploy}\n\nThe demos are setup to either deploy to a VM through `nixos-rebuild` or through\n[Colmena](https://colmena.cli.rs).\n\nUsing `nixos-rebuild` is very fast and requires less steps because it reuses your nix store.\n\nUsing `colmena` is more authentic because you are deploying to a stock VM, like you would with a\nreal machine but it needs to copy over all required store derivations so it takes a few minutes the\nfirst time.\n\n### Deploy with nixos-rebuild {#demo-nextcloud-deploy-nixosrebuild}\n\nAssuming your current working directory is the one where this Readme file is located, the one-liner\ncommand which builds and starts the VM configured to run Self Host Blocks' Nextcloud is:\n\n```nix\nrm nixos.qcow2; \\\n  nixos-rebuild build-vm --flake .#basic \\\n  && QEMU_NET_OPTS=\"hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80\" \\\n     ./result/bin/run-nixos-vm\n```\n\nThis will deploy the `basic` demo. If you want to deploy the `ldap` or `sso` demos, use respectively\nthe `.#ldap` or `.#sso` flake uris.\n\nYou can even test the demos from any directory without cloning this repository by using the GitHub\nuri like `github:ibizaman/selfhostblocks?path=demo/nextcloud`\n\nIt is very important to remove leftover `nixos.qcow2` files, if any.\n\nYou can ssh into the VM like this, but this is not required for the demo:\n\n```bash\nssh -F ssh_config example\n```\n\nBut before that works, you will need to change the permission of the ssh key like so:\n\n```bash\nchmod 600 sshkey\n```\n\nThis is only needed because git mangles with the permissions. You will not even see this change in\n`git status`.\n\n### Deploy with Colmena {#demo-nextcloud-deploy-colmena}\n\nIf you deploy with Colmena, you must first build the VM and start it:\n\n```bash\nrm nixos.qcow2; \\\n  nixos-rebuild build-vm-with-bootloader --fast -I nixos-config=./configuration.nix -I nixpkgs=. ; \\\n  QEMU_NET_OPTS=\"hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80\" ./result/bin/run-nixos-vm\n```\n\nIt is very important to remove leftover `nixos.qcow2` files, if any.\n\nThis last call is blocking, so I advice adding a `&` at the end of the command otherwise you will\nneed to run the rest of the commands in another terminal.\n\nWith the VM started, make the secrets in `secrets.yaml` decryptable in the VM. This change will\nappear in `git status` but you don't need to commit this.\n\n```bash\nSOPS_AGE_KEY_FILE=keys.txt \\\n  nix run --impure nixpkgs#sops -- --config sops.yaml -r -i \\\n  --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') \\\n  secrets.yaml\n```\n\nThe nested command, the one in between the parenthesis `$(...)`, is used to print the VM's public\nage key, which is then added to the `secrets.yaml` file in order to make the secrets decryptable by\nthe VM.\n\nIf you forget this step, the deploy will seem to go fine but the secrets won't be populated and\nNextcloud will not start.\n\nMake the ssh key private:\n\n```bash\nchmod 600 sshkey\n```\n\nThis is only needed because git mangles with the permissions. You will not even see this change in\n`git status`.\n\nYou can ssh into the VM like this, but this is not required for the demo:\n\n```bash\nssh -F ssh_config example\n```\n\n### Nextcloud through HTTP {#demo-nextcloud-deploy-basic}\n\n:::: {.note}\nThis section corresponds to the `basic` section of the [Nextcloud\nmanual](services-nextcloud.html#services-nextcloudserver-usage-basic).\n::::\n\nAssuming you already deployed the `basic` demo, now you must add the following entry to the\n`/etc/hosts` file on the host machine (not the VM):\n\n```nix\nnetworking.hosts = {\n  \"127.0.0.1\" = [ \"n.example.com\" ];\n};\n```\n\nWhich produces:\n\n```bash\n$ cat /etc/hosts\n127.0.0.1 n.example.com\n```\n\nGo to [http://n.example.com:8080](http://n.example.com:8080) and login with:\n\n- username: `root`\n- password: the value of the field `nextcloud.adminpass` in the `secrets.yaml` file which is\n  `43bb4b8f82fc645ce3260b5db803c5a8`.\n\nThis is the admin user of Nextcloud and that's the end of the `basic` demo.\n\n### Nextcloud with LDAP through HTTP {#demo-nextcloud-deploy-ldap}\n\n:::: {.note}\nThis section corresponds to the `ldap` section of the [Nextcloud\nmanual](services-nextcloud.html#services-nextcloudserver-usage-ldap).\n::::\n\nAssuming you already deployed the `ldap` demo, now you must add the following entry to the\n`/etc/hosts` file on the host machine (not the VM):\n\n```nix\nnetworking.hosts = {\n  \"127.0.0.1\" = [ \"n.example.com\" \"ldap.example.com\" ];\n};\n```\n\nWhich produces:\n\n```bash\n$ cat /etc/hosts\n127.0.0.1 n.example.com ldap.example.com\n```\n\nGo first to [http://ldap.example.com:8080](http://ldap.example.com:8080) and login with:\n\n- username: `admin`\n- password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is\n  `c2e32e54ea3e0053eb30841f818a3d9a`.\n\nCreate the group `nextcloud_user` and a create a user and assign them to that group.\n\nFinally, go to [http://n.example.com/login:8080](http://n.example.com/login:8080) and login with the user and\npassword you just created above.\nYou might need to wait a minute or two until Nextcloud initialized correctly.\nUntil then, you'll get a 502 Bad Gateway error.\n\nNextcloud doesn't like being run without SSL protection, which this demo does not setup, so you\nmight see errors loading scripts. See the `sso` demo for SSL.\n\nThis is the end of the `ldap` demo.\n\n### Nextcloud with LDAP and SSO through self-signed HTTPS {#demo-nextcloud-deploy-sso}\n\n:::: {.note}\nThis section corresponds to the `sso` section of the [Nextcloud\nmanual](services-nextcloud.html#services-nextcloudserver-usage-oidc).\n::::\n\nAt this point, it is assumed you already deployed the `sso` demo. This time, we cannot simply edit local\n`/etc/hosts`, because Nextcloud SSO addon must be able to connect to Authelia by domain name\n(`auth.example.com`). Instead, there is a `dnsmasq` server running in the VM and you must create a\nSOCKS proxy to connect to it like so:\n\n```bash\nssh -F ssh_config -D 1080 -N example\n```\n\nThis is a blocking call but it is not necessary to fork this process in the background by appending\n`&` because we will not need to use the terminal for the rest of the demo.\n\nNow, configure your browser to use that SOCKS proxy. When that's done go to\n[https://ldap.example.com](https://ldap.example.com) and login with:\n\n- username: `admin`\n- password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is\n  `c2e32e54ea3e0053eb30841f818a3d9a`.\n\nCreate the group `nextcloud_user` and a create a user and assign them to that group.\n\nVisit [https://auth.example.com](https://auth.example.com) and make your browserauthorize the certificate.\n\nFinally, go to [https://n.example.com](https://n.example.com) and login with the user and\npassword you just created above. You will see that the login page is actually the one from the SSO provider.\n\nThis is the end of the `sso` demo.\n\n## In More Details {#demo-nextcloud-tips}\n\n### Files {#demo-nextcloud-tips-files}\n\n- [`flake.nix`](./flake.nix): nix entry point, defines the target hosts for\n  [colmena](https://colmena.cli.rs) to deploy to as well as the selfhostblocks' config for setting\n  up Nextcloud and the auxiliary services.\n- [`configuration.nix`](./configuration.nix): defines all configuration required for colmena\n  to deploy to the VM. The file has comments if you're interested.\n- [`hardware-configuration.nix`](./hardware-configuration.nix): defines VM specific layout.\n  This was generated with nixos-generate-config on the VM.\n- Secrets related files:\n  - [`keys.txt`](./keys.txt): your private key for sops-nix, allows you to edit the `secrets.yaml`\n    file. This file should never be published but here I did it for convenience, to be able to\n    deploy to the VM in less steps.\n  - [`secrets.yaml`](./secrets.yaml): encrypted file containing required secrets for Nextcloud. This file can be publicly accessible.\n  - [`sops.yaml`](./sops.yaml): describes how to create the `secrets.yaml` file. Can be publicly\n    accessible.\n- SSH related files:\n  - [`sshkey(.pub)`](./sshkey): your private and public ssh keys. Again, the private key should usually not\n    be published as it is here but this makes it possible to deploy to the VM in less steps.\n  - [`ssh_config`](./ssh_config): the ssh config allowing you to ssh into the VM by just using the\n    hostname `example`. Usually you would store this info in your `~/.ssh/config` file but it's\n    provided here to avoid making you do that.\n\n### Virtual Machine {#demo-nextcloud-tips-virtual-machine}\n\n_More info about the VM._\n\nWe use `build-vm-with-bootloader` instead of just `build-vm` as that's the only way to deploy to the VM.\n\nThe VM's User and password are both `nixos`, as setup in the [`configuration.nix`](./configuration.nix) file under\n`user.users.nixos.initialPassword`.\n\nYou can login with `ssh -F ssh_config example`. You just need to accept the fingerprint.\n\nThe 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.\n\nThat being said, the VM uses `tmpfs` to create the writable nix store so if you stumble in a disk\nspace issue, you must increase the\n`virtualisation.vmVariantWithBootLoader.virtualisation.memorySize` setting.\n\n### Secrets {#demo-nextcloud-tips-secrets}\n\n_More info about the secrets can be found in the [Usage](https://shb.skarabox.com/usage.html) manual_\n\nTo open the `secrets.yaml` file and optionnally edit it, run:\n\n```bash\nSOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \\\n  --config sops.yaml \\\n  secrets.yaml\n```\n\nYou can generate random secrets with:\n\n```bash\nnix run nixpkgs#openssl -- rand -hex 64\n```\n\nIf you choose secrets too small, some services could refuse to start.\n\n#### Why do we need the VM's public key {#demo-nextcloud-tips-public-key-necessity}\n\nThe [`sops.yaml`](./sops.yaml) file describes what private keys can decrypt and encrypt the\n[`secrets.yaml`](./secrets.yaml) file containing the application secrets. Usually, you will create and add\nsecrets to that file and when deploying, it will be decrypted and the secrets will be copied\nin the `/run/secrets` folder on the VM. We thus need one private key for you to edit the\n[`secrets.yaml`](./secrets.yaml) file and one in the VM for it to decrypt the secrets.\n\nYour private key is already pre-generated in this repo, it's the [`sshkey`](./sshkey) file. But when\ncreating the VM for Colmena, a new private key and its accompanying public key were automatically\ngenerated under `/etc/ssh/ssh_host_ed25519_key` in the VM. We just need to get the public key and\nadd it to the `secrets.yaml` which we did in the Deploy section.\n\n### SSH {#demo-nextcloud-tips-ssh}\n\nThe private and public ssh keys were created with:\n\n```bash\nssh-keygen -t ed25519 -f sshkey\n```\n\nYou 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.\nThis allows us also to disable ssh password authentication.\n\nFor reference, if instead you didn't copy the key over on VM creating and enabled ssh\nauthentication, here is what you would need to do to copy over the key:\n\n```bash\n$ nix shell nixpkgs#openssh --command ssh-copy-id -i sshkey -F ssh_config example\n```\n\n### Deploy {#demo-nextcloud-tips-deploy}\n\nIf you get a NAR hash mismatch error like hereunder, you need to run `nix flake lock --update-input\nselfhostblocks`.\n\n```\nerror: NAR hash mismatch in input ...\n```\n\n### Update Demo {#demo-nextcloud-tips-update-demo}\n\nIf you update the Self Host Blocks configuration in `flake.nix` file, you can just re-deploy.\n\nIf you update the `configuration.nix` file, you will need to rebuild the VM from scratch.\n\nIf you update a module in the Self Host Blocks repository, you will need to update the lock file with:\n\n```bash\nnix flake lock --override-input selfhostblocks ../.. --update-input selfhostblocks\n```\n"
  },
  {
    "path": "demo/nextcloud/configuration.nix",
    "content": "{ config, pkgs, ... }:\n\nlet\n  targetUser = \"nixos\";\n  targetPort = 2222;\nin\n{\n  imports = [\n    # Include the results of the hardware scan.\n    ./hardware-configuration.nix\n  ];\n\n  boot.loader.grub.enable = true;\n  boot.kernelModules = [ \"kvm-intel\" ];\n  system.stateVersion = \"22.11\";\n\n  # Options above are generate by running nixos-generate-config on the VM.\n\n  # Needed otherwise deploy will say system won't be able to boot.\n  boot.loader.grub.device = \"/dev/vdb\";\n  # Needed to avoid getting into not available disk space in /boot.\n  boot.loader.grub.configurationLimit = 1;\n  # The NixOS /nix/.rw-store mountpoint is backed by tmpfs which uses memory. We need to increase\n  # the available disk space to install home-assistant.\n  virtualisation.vmVariant.virtualisation.memorySize = 8192;\n  virtualisation.vmVariantWithBootLoader.virtualisation.memorySize = 8192;\n\n  # Options above are needed to deploy in a VM.\n\n  nix.settings.experimental-features = [\n    \"nix-command\"\n    \"flakes\"\n  ];\n\n  # We need to create the user we will deploy with.\n  users.users.${targetUser} = {\n    isNormalUser = true;\n    extraGroups = [ \"wheel\" ]; # Enable ‘sudo’ for the user.\n    initialPassword = \"nixos\";\n    # With this option, you don't need to use ssh-copy-id to copy the public ssh key to the VM.\n    openssh.authorizedKeys.keyFiles = [\n      ./sshkey.pub\n    ];\n  };\n\n  # The user we're deploying with must be able to run sudo without password.\n  security.sudo.extraRules = [\n    {\n      users = [ targetUser ];\n      commands = [\n        {\n          command = \"ALL\";\n          options = [ \"NOPASSWD\" ];\n        }\n      ];\n    }\n  ];\n\n  # Needed to allow the user we're deploying with to write to the nix store.\n  nix.settings.trusted-users = [\n    targetUser\n  ];\n\n  # We need to enable the ssh daemon to be able to deploy.\n  services.openssh = {\n    enable = true;\n    ports = [ targetPort ];\n    settings = {\n      PermitRootLogin = \"no\";\n      PasswordAuthentication = false;\n    };\n  };\n}\n"
  },
  {
    "path": "demo/nextcloud/flake.nix",
    "content": "{\n  description = \"Nextcloud example for Self Host Blocks\";\n\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n    sops-nix.url = \"github:Mic92/sops-nix\";\n  };\n\n  outputs =\n    inputs@{\n      self,\n      selfhostblocks,\n      sops-nix,\n    }:\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n\n      basic =\n        { config, ... }:\n        {\n          imports = [\n            ./configuration.nix\n            selfhostblocks.nixosModules.authelia\n            selfhostblocks.nixosModules.nextcloud-server\n            selfhostblocks.nixosModules.nginx\n            selfhostblocks.nixosModules.sops\n            selfhostblocks.nixosModules.ssl\n            sops-nix.nixosModules.default\n          ];\n\n          sops.defaultSopsFile = ./secrets.yaml;\n\n          shb.nextcloud = {\n            enable = true;\n            domain = \"example.com\";\n            subdomain = \"n\";\n            dataDir = \"/var/lib/nextcloud\";\n            tracing = null;\n            defaultPhoneRegion = \"US\";\n\n            # This option is only needed because we do not access Nextcloud at the default port in the VM.\n            port = 8080;\n\n            adminPass.result = config.shb.sops.secret.\"nextcloud/adminpass\".result;\n\n            apps = {\n              previewgenerator.enable = true;\n            };\n          };\n          shb.sops.secret.\"nextcloud/adminpass\".request = config.shb.nextcloud.adminPass.request;\n\n          # Set to true for more debug info with `journalctl -f -u nginx`.\n          shb.nginx.accessLog = true;\n          shb.nginx.debugLog = false;\n        };\n\n      ldap =\n        { config, ... }:\n        {\n          shb.lldap = {\n            enable = true;\n            domain = \"example.com\";\n            subdomain = \"ldap\";\n            ldapPort = 3890;\n            webUIListenPort = 17170;\n            dcdomain = \"dc=example,dc=com\";\n            ldapUserPassword.result = config.shb.sops.secret.\"lldap/user_password\".result;\n            jwtSecret.result = config.shb.sops.secret.\"lldap/jwt_secret\".result;\n          };\n          shb.sops.secret.\"lldap/user_password\".request = config.shb.lldap.ldapUserPassword.request;\n          shb.sops.secret.\"lldap/jwt_secret\".request = config.shb.lldap.jwtSecret.request;\n\n          shb.nextcloud.apps.ldap = {\n            enable = true;\n            host = \"127.0.0.1\";\n            port = config.shb.lldap.ldapPort;\n            dcdomain = config.shb.lldap.dcdomain;\n            adminName = \"admin\";\n            adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap_admin_password\".result;\n            userGroup = \"nextcloud_user\";\n          };\n          shb.sops.secret.\"nextcloud/ldap_admin_password\" = {\n            request = config.shb.nextcloud.apps.ldap.adminPassword.request;\n            settings.key = \"lldap/user_password\";\n          };\n        };\n\n      sso =\n        { config, lib, ... }:\n        {\n          shb.certs = {\n            cas.selfsigned.myca = {\n              name = \"My CA\";\n            };\n            certs.selfsigned = {\n              n = {\n                ca = config.shb.certs.cas.selfsigned.myca;\n                domain = \"*.example.com\";\n                group = \"nginx\";\n              };\n            };\n          };\n          shb.nextcloud = {\n            port = lib.mkForce null;\n            ssl = config.shb.certs.certs.selfsigned.n;\n          };\n          shb.lldap.ssl = config.shb.certs.certs.selfsigned.n;\n\n          services.dnsmasq = {\n            enable = true;\n            settings = {\n              domain-needed = true;\n              # no-resolv = true;\n              bogus-priv = true;\n              address = map (hostname: \"/${hostname}/127.0.0.1\") [\n                \"example.com\"\n                \"n.example.com\"\n                \"ldap.example.com\"\n                \"auth.example.com\"\n              ];\n            };\n          };\n\n          shb.authelia = {\n            enable = true;\n            domain = \"example.com\";\n            subdomain = \"auth\";\n            ssl = config.shb.certs.certs.selfsigned.n;\n            ldapPort = config.shb.lldap.ldapPort;\n            ldapHostname = \"127.0.0.1\";\n            dcdomain = config.shb.lldap.dcdomain;\n\n            secrets = {\n              jwtSecret.result = config.shb.sops.secret.\"authelia/jwt_secret\".result;\n              ldapAdminPassword.result = config.shb.sops.secret.\"authelia/ldap_admin_password\".result;\n              sessionSecret.result = config.shb.sops.secret.\"authelia/session_secret\".result;\n              storageEncryptionKey.result = config.shb.sops.secret.\"authelia/storage_encryption_key\".result;\n              identityProvidersOIDCHMACSecret.result = config.shb.sops.secret.\"authelia/hmac_secret\".result;\n              identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret.\"authelia/private_key\".result;\n            };\n          };\n          shb.sops.secret.\"authelia/jwt_secret\".request = config.shb.authelia.secrets.jwtSecret.request;\n          shb.sops.secret.\"authelia/ldap_admin_password\" = {\n            request = config.shb.authelia.secrets.ldapAdminPassword.request;\n            settings.key = \"lldap/user_password\";\n          };\n          shb.sops.secret.\"authelia/session_secret\".request =\n            config.shb.authelia.secrets.sessionSecret.request;\n          shb.sops.secret.\"authelia/storage_encryption_key\".request =\n            config.shb.authelia.secrets.storageEncryptionKey.request;\n          shb.sops.secret.\"authelia/hmac_secret\".request =\n            config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;\n          shb.sops.secret.\"authelia/private_key\".request =\n            config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;\n\n          shb.nextcloud.apps.sso = {\n            enable = true;\n            endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n            clientID = \"nextcloud\";\n            fallbackDefaultAuth = true;\n\n            secret.result = config.shb.sops.secret.\"nextcloud/sso/secret\".result;\n            secretForAuthelia.result = config.shb.sops.secret.\"authelia/nextcloud_sso_secret\".result;\n          };\n          shb.sops.secret.\"nextcloud/sso/secret\".request = config.shb.nextcloud.apps.sso.secret.request;\n          shb.sops.secret.\"authelia/nextcloud_sso_secret\" = {\n            request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;\n            settings.key = \"nextcloud/sso/secret\";\n          };\n        };\n\n      sopsConfig = {\n        sops.age.keyFile = \"/etc/sops/my_key\";\n        environment.etc.\"sops/my_key\".source = ./keys.txt;\n      };\n    in\n    {\n      nixosConfigurations = {\n        basic = nixpkgs'.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            sopsConfig\n            basic\n          ];\n        };\n        ldap = nixpkgs'.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            sopsConfig\n            basic\n            ldap\n          ];\n        };\n        sso = nixpkgs'.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            sopsConfig\n            basic\n            ldap\n            sso\n          ];\n        };\n      };\n\n      colmena = {\n        meta = {\n          nixpkgs = import nixpkgs' {\n            system = \"x86_64-linux\";\n          };\n          specialArgs = inputs;\n        };\n\n        basic =\n          { config, ... }:\n          {\n            imports = [\n              basic\n            ];\n\n            deployment = {\n              targetHost = \"example\";\n              targetUser = \"nixos\";\n              targetPort = 2222;\n            };\n          };\n\n        ldap =\n          { config, ... }:\n          {\n            imports = [\n              basic\n              ldap\n            ];\n\n            deployment = {\n              targetHost = \"example\";\n              targetUser = \"nixos\";\n              targetPort = 2222;\n            };\n          };\n\n        sso =\n          { config, ... }:\n          {\n            imports = [\n              basic\n              ldap\n              sso\n            ];\n\n            deployment = {\n              targetHost = \"example\";\n              targetUser = \"nixos\";\n              targetPort = 2222;\n            };\n          };\n      };\n    };\n}\n"
  },
  {
    "path": "demo/nextcloud/hardware-configuration.nix",
    "content": "# This file was generated by running nixos-generate-config on the VM.\n#\n# Do not modify this file!  It was generated by ‘nixos-generate-config’\n# and may be overwritten by future invocations.  Please make changes\n# to /etc/nixos/configuration.nix instead.\n{\n  config,\n  lib,\n  pkgs,\n  modulesPath,\n  ...\n}:\n\n{\n  imports = [\n    (modulesPath + \"/profiles/qemu-guest.nix\")\n  ];\n\n  boot.initrd.availableKernelModules = [\n    \"ata_piix\"\n    \"uhci_hcd\"\n    \"virtio_pci\"\n    \"floppy\"\n    \"sr_mod\"\n    \"virtio_blk\"\n  ];\n  boot.initrd.kernelModules = [ ];\n  boot.kernelModules = [ \"kvm-intel\" ];\n  boot.extraModulePackages = [ ];\n\n  fileSystems.\"/\" = {\n    device = \"/dev/vda\";\n    fsType = \"ext4\";\n  };\n\n  fileSystems.\"/nix/.ro-store\" = {\n    device = \"nix-store\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/nix/.rw-store\" = {\n    device = \"tmpfs\";\n    fsType = \"tmpfs\";\n  };\n\n  fileSystems.\"/tmp/shared\" = {\n    device = \"shared\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/tmp/xchg\" = {\n    device = \"xchg\";\n    fsType = \"9p\";\n  };\n\n  fileSystems.\"/nix/store\" = {\n    device = \"overlay\";\n    fsType = \"overlay\";\n  };\n\n  fileSystems.\"/boot\" = {\n    device = \"/dev/vdb2\";\n    fsType = \"vfat\";\n  };\n\n  swapDevices = [ ];\n\n  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking\n  # (the default) this is the recommended approach. When using systemd-networkd it's\n  # still possible to use this option, but it's recommended to use it in conjunction\n  # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.\n  networking.useDHCP = lib.mkDefault true;\n  # networking.interfaces.eth0.useDHCP = lib.mkDefault true;\n\n  nixpkgs.hostPlatform = lib.mkDefault \"x86_64-linux\";\n  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;\n}\n"
  },
  {
    "path": "demo/nextcloud/keys.txt",
    "content": "# created: 2023-11-17T00:05:25-08:00\n# public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\nAGE-SECRET-KEY-1EPLAHXWDEM5ZZAU7NFGHT5TWU08ZUCWTHYTLD8XC89350MZ0T79SA2MQAL\n"
  },
  {
    "path": "demo/nextcloud/secrets.yaml",
    "content": "nextcloud:\n    adminpass: ENC[AES256_GCM,data:nD/4oml7mXbWF0axiqWQCZujFqeJMF0P/1vY9f4EPqg=,iv:KoxmL9tLPBoIJT7rxkEhxrQqZFicbEm8qXbZMrnHSGY=,tag:gwvrHsX22ygfUcOlxeC/5g==,type:str]\n    onlyoffice:\n        jwt_secret: ENC[AES256_GCM,data:v4BScbfRHpHAZ0MCIyb1H1vYISsR1JQRaI1mFHbZKDNhuf5Zyc6znzz+DtqXOZfVNgp9aIeWIEam0GI/O3ih0jzEN0ut/jqI3onoSghq22h2VTKdLMcT6JG2p/R1mHgD+C7KeeepcdWMbwLXswi2jBys3FyxTY3mfiNv3AcndGA=,iv:TFs+fTlMGWKTVJ3pUmXCpGskQ2h6uSLr+TlmG6OXQYg=,tag:Ixm0VtO5ySCQxiKweDop0A==,type:str]\n    sso:\n        secret: ENC[AES256_GCM,data:9uZfvBXETbP47Cf6lZNLqskqmbxcAaQ/e3jiHqW9VweqrmByyadaE3DgCcODUJNEatuFxIyP+ptBdeX9FBRPmAvVl/BaK5oKzp84i+5zb1nvxvxBx+KQhqFKZgk81jJQeMSxwLlDKguWnLx83QhYvOMphZNQOeLQ/Cx+qrvCWsk=,iv:pF87avRdm2tgwA+cQnvcYSUIxAh18jDrMA6eAHoyBZU=,tag:FaJwUr2fR9dZUdDOfq/C5Q==,type:str]\nlldap:\n    user_password: ENC[AES256_GCM,data:4ImmaC2T1hj6L8tzrxv4d7/I4F9xEA/uuc56QOqkY08=,iv:SljGhXi3SYoMNcR9onwqthOAyFX1D8KsegmWRypbblQ=,tag:Aw+juIV2AM0J+89itNDjVA==,type:str]\n    jwt_secret: ENC[AES256_GCM,data:btABIOGRgioXmPe8QirhyozQzhVaAcF2sbB07hevz+Q=,iv:vBOq4Mab3RE69rOA8ZbMX72Gm3KEng6HaCveZrXsIrU=,tag:zkbJ+SeNnzQyAZxOjso8fg==,type:str]\nauthelia:\n    jwt_secret: ENC[AES256_GCM,data:xom/W92DGS2RafO+olwG8oKAbKPbkPKyZ2mYv0lWqtVAWUFwSoCGLgxe4uHAoGcLosJmDxU/srq+HNPzYORY8+mHn9wMoQgYg2oceLw2xamYdkIzvswof6LoYAV7MaZReYgYXcqMy2LZuU3PnnE4wag3liSuEx4qtJrLKB52ljE=,iv:t5PsBdZDze3/4S8utfnkmiToaorqq5BiJn99JuRirXY=,tag:ZJCszIOpaSwl9Sua8VWHoA==,type:str]\n    storage_encryption_key: ENC[AES256_GCM,data:wUmF+0etuhEr3FNy7x0LBJunn1vmWO+IExm/wgkh0CEDWzxblpylC/PGAGgHdlJMQOhUY6tDPD67sJgO2g+yTBB3lfOo/kql0gnGVKQjRMMHqfEEmXK56yXP+J2JePJ6DlaqzdAXko4Tmh4GnRKsswMQZVA5PDOuHHNRcVTCb0E=,iv:wz1Mry7jMwGvD9mF1/PbQsHb/jmm8WOWchLL95YADeY=,tag:AZp43iti+nxW0TYK7MlYNg==,type:str]\n    session_secret: ENC[AES256_GCM,data:TSe2YEyXl0Ls8wAynUYRJBQL8mbC1i/31ueuCj7d7ouO9gCX/Igz6OM9EgWigxucsMVQkiUtDCI9DD9B8jFaYGMIiB9FrKQnixigptrIUj210zJ3Aer38GyFxSI541PaBzmnauEo1MtBykjSg93xyI6ivB8FJmmauQOMYNiTYvk=,iv:OBtUCw7BevaF3VQKLJ2HiB828IzJqS27SZUOoAqoD+E=,tag:WfCGlHi6a15AYeSFXnnOVw==,type:str]\n    hmac_secret: ENC[AES256_GCM,data:RmPr/kJmimMmeZCluMBsYL+w5VtJ1IZNFo2VOVNGiu0ajMJoK06RQx9AAYb+GvPRrGz9wzRy38hTH7unIiq59WOZCw245StsawSCeszadh8RrjPJPNCKPt3vaBbIzlvz0xMvgX4UT2k+uK1dqR7QXiCrBDludU3nnHIpbgkcADM=,iv:z5KLaAlevgk2HsxMWggU1DL0g+Ae+DaBLZ0SnZoKYcA=,tag:2ChIOxMCI4psqIhX+GE8EQ==,type:str]\n    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]\nsops:\n    kms: []\n    gcp_kms: []\n    azure_kv: []\n    hc_vault: []\n    age:\n        - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\n          enc: |\n            -----BEGIN AGE ENCRYPTED FILE-----\n            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqRjg2SWR0SjhpWExqbi9E\n            a3pJbXJyMmMyY1F5NFNVNWY0TXRicFdycEhJCkdWL1dmNjdCRVhKNmllcGpmNkNV\n            U1lTUjI3elBoOStNZVhoL1o3WGZLWjgKLS0tIE1XRTVPUE91d2k2dFpMbVJ1a0ZB\n            dTNrOUhzOSsvRnNSMC9VOTJaY1orWUEK8IcLk/4X7O+ZRosM7KNQNSEgyGkFklRw\n            YSutsre5OOEUx1X+hxzu2GF9I4DGcSAbQtzPYBq7qcwxUR+oIXiJyQ==\n            -----END AGE ENCRYPTED FILE-----\n    lastmodified: \"2025-03-17T00:29:32Z\"\n    mac: ENC[AES256_GCM,data:eE3F1K/brgKMnixJQo/A/VYjafNLAGKuSq1n8857yjsiNnro/hwDy9jNKLH3a6/5DX/aOjMfZJzgH3ycb7f4771IohrWoDLjymaVdgJXsTITXZaLQyN+QHoOTRbXAJwG1f4Mr2kEAdwK7JLtu9TqX82o2DmBWNRxkkn1Kv5NjiA=,iv:OSAI0b4H40xbzKQbD6F2B5Xu/8enUIclfds8uYH/q3o=,tag:fYTnxx8IQYMyXAeVTUiQ+A==,type:str]\n    pgp: []\n    unencrypted_suffix: _unencrypted\n    version: 3.9.2\n"
  },
  {
    "path": "demo/nextcloud/sops.yaml",
    "content": "keys:\n  - &admin age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\ncreation_rules:\n  - path_regex: secrets.yaml$\n    key_groups:\n    - age:\n      - *admin\n"
  },
  {
    "path": "demo/nextcloud/ssh_config",
    "content": "Host example\n  Port 2222\n  User nixos\n  HostName 127.0.0.1\n  IdentityFile sshkey\n  IdentitiesOnly yes\n  StrictHostKeyChecking no\n  UserKnownHostsFile=/dev/null"
  },
  {
    "path": "demo/nextcloud/sshkey",
    "content": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIwAAAJiBL8xSgS/M\nUgAAAAtzc2gtZWQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIw\nAAAECzMZfgJIQJUVgyKZ3IYnEVvwnYXJ8nstc4/g1H41dC/vueAR1wO7hRVt7ZnMGEqfYe\nE9bQ+USaASlv+SQyJ4UjAAAAEWV4YW1wbGVAbG9jYWxob3N0AQIDBA==\n-----END OPENSSH PRIVATE KEY-----\n"
  },
  {
    "path": "demo/nextcloud/sshkey.pub",
    "content": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPueAR1wO7hRVt7ZnMGEqfYeE9bQ+USaASlv+SQyJ4Uj example@localhost\n"
  },
  {
    "path": "docs/blocks.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Blocks {#blocks}\n\nBlocks help you self-host apps or services. They implement a specific function like backup or secure\naccess through a subdomain. Each block is designed to be usable on its own and to fit nicely with\nothers.\n\nAll blocks are implemented under the blocks folder [in the repository](@REPO@/modules/blocks).\n\nAll services in SHB document how to setup the various blocks provided here.\nFor custom services or those not provided by SHB,\nthe [Expose a service Recipe](recipes-exposeService.html) explains how to use the blocks here.\n\n## Authentication {#blocks-category-authentication}\n\n```{=include=} chapters html:into-file=//blocks-authelia.html\nmodules/blocks/authelia/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//blocks-lldap.html\nmodules/blocks/lldap/docs/default.md\n```\n\n## Backup {#blocks-category-backup}\n\n```{=include=} chapters html:into-file=//blocks-borgbackup.html\nmodules/blocks/borgbackup/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//blocks-restic.html\nmodules/blocks/restic/docs/default.md\n```\n\n## Database {#blocks-category-database}\n\n```{=include=} chapters html:into-file=//blocks-postgresql.html\nmodules/blocks/postgresql/docs/default.md\n```\n\n## Secrets {#blocks-category-secrets}\n\n```{=include=} chapters html:into-file=//blocks-sops.html\nmodules/blocks/sops/docs/default.md\n```\n\n## Network {#blocks-category-network}\n\n```{=include=} chapters html:into-file=//blocks-ssl.html\nmodules/blocks/ssl/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//blocks-nginx.html\nmodules/blocks/nginx/docs/default.md\n```\n\n## Introspection {#blocks-category-introspection}\n\n```{=include=} chapters html:into-file=//blocks-monitoring.html\nmodules/blocks/monitoring/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//blocks-mitmdump.html\nmodules/blocks/mitmdump/docs/default.md\n```\n"
  },
  {
    "path": "docs/contracts.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Contracts {#contracts}\n\n::: {.note}\nAn [RFC][] has been created which is the most up-to-date version of contracts.\nThe text here is still relevant although the implementation itself has changed a little bit.\n\n[RFC]: https://github.com/NixOS/rfcs/pull/189\n:::\n\nA contract decouples modules that use a functionality from modules that provide it. A first\nintuition for contracts is they are generally related to accessing a shared resource.\n\nA few examples of contracts are generating SSL certificates, creating a user or knowing which files\nand folders to backup.\nIndeed, when generating certificates, the service using those do not care how they were created.\nThey just need to know where the certificate files are located.\n\nA contract is made between a `requester` module and a `provider` module.\nFor example, a `backup` contract can be made between the [Nextcloud service][] and the [Restic service][].\nThe former is the `requester` - the one wanted to be backed up -\nand the latter is the `provider` of the contract - the one backing up files.\nThe `backup contract` would then say which set of options the `requester` and `provider` modules\nmust use to talk to each other.\n\n[Nextcloud service]: ./services-nextcloud.html\n[Restic service]: ./blocks-restic.html\n\n## Provided contracts {#contracts-provided}\n\nSelf Host Blocks is a proving ground of contracts. This repository adds a layer on top of services\navailable in nixpkgs to make them work using contracts. In time, we hope to upstream as much of this\nas possible, reducing the quite thick layer that it is now.\n\nProvided contracts are:\n\n- [SSL generator contract](contracts-ssl.html) to generate SSL certificates.\n  Two providers are implemented: self-signed and Let's Encrypt.\n- [Backup contract][] to backup directories.\n  Two providers are implemented: [BorgBackup][] and [Restic][].\n- [Database Backup contract](contracts-databasebackup.html) to backup database dumps.\n  One provider is implemented: [BorgBackup][] and [Restic][].\n- [Contract for Secrets](contracts-secret.html) to provide secrets that are deployed outside of the Nix store.\n  One provider is implemented: [SOPS][].\n- [Dashboard contract](contracts-dashboard.html) to show services in a nice user-facing dashboard.\n  One provider is implemented: [Homepage][].\n\n[backup contract]: contracts-backup.html\n[borgbackup]: blocks-borgbackup.html\n[homepage]: services-homepage.html\n[restic]: blocks-restic.html\n[sops]: blocks-sops.html\n\n```{=include=} chapters html:into-file=//contracts-ssl.html\nmodules/contracts/ssl/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//contracts-backup.html\nmodules/contracts/backup/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//contracts-databasebackup.html\nmodules/contracts/databasebackup/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//contracts-secret.html\nmodules/contracts/secret/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//contracts-dashboard.html\nmodules/contracts/dashboard/docs/default.md\n```\n\n## Problem Statement {#contracts-why}\n\nCurrently in nixpkgs, every module accessing a shared resource\nmust either implement the logic needed to setup that resource themselves\nor either instruct the user how to set it up themselves.\n\nFor example, this is what the Nextcloud module looks like.\nIt sets up the `nginx module` and a database,\nletting you choose between multiple databases.\n\n![](./assets/contracts_before.png \"A module composed of a core logic and a lot of peripheral logic.\")\n\nThis has a few disadvantages:\n\n_I'm using the Nextcloud module to make the following examples more concrete\nbut this applies to all other modules._\n\n- This leads to a lot of **duplicated code**.\n  If the Nextcloud module wants to support a new type of database,\n  the maintainer of the Nextcloud module must do the work.\n  And if another module wants to support it too,\n  the maintainers of that module cannot re-use easily the work\n  of the Nextcloud maintainer,\n  apart from copy-pasting and adapting the code.\n- This also leads to **tight coupling**.\n  The code written to integrate Nextcloud with the Nginx reverse proxy\n  is hard to decouple and make generic.\n  Letting the user choose between Nginx and another reverse proxy\n  will require a lot of work.\n- There is also a **lack of separation of concerns**.\n  The maintainers of a service must be experts\n  in all implementations they let the users choose from.\n- This is **not extendable**.\n  If you, the user of the module, want to use another\n  implementation that is not supported, you are out of luck.\n  You can always dive into the module's code and extend it with a lot of `mkForce`,\n  but that is not an optimal experience.\n- Finally, there is **no interoperability**.\n  It is not currently possible to integrate the Nextcloud module\n  with an existing database or reverse proxy or other type of shared resource\n  that already exists on a non-NixOS machine.\n\nWe do believe that the decoupling contracts provides helps alleviate all the issues outlined above\nwhich makes it an essential step towards better interoperability.\n\n![](./assets/contracts_after.png \"A module containing only logic using peripheral logic through contracts.\")\n\nIndeed, contracts allow:\n\n- **Reuse of code**.\n  Since the implementation of a contract lives outside of modules using it,\n  using the same implementation and code elsewhere without copy-pasting is trivial.\n- **Loose coupling**.\n  Modules that use a contract do not care how they are implemented\n  as long as the implementation follows the behavior outlined by the contract.\n- Full **separation of concerns** (see diagram below).\n  Now, each party's concern is separated with a clear boundary.\n  The maintainer of a module using a contract can be different from the maintainers\n  of the implementation, allowing them to be experts in their own respective fields.\n  But more importantly, the contracts themselves can be created and maintained by the community.\n- Full **extensibility**.\n  The final user themselves can choose an implementation,\n  even new custom implementations not available in nixpkgs, without changing existing code.\n- **Incremental adoption**.\n  Contracts can help bridge a NixOS system with any non-NixOS one.\n  For that, one can hardcode a requester or provider module to match\n  how the non-NixOS system is configured.\n  The responsibility falls of course on the user to make sure both system agree on the configuration.\n- Last but not least, **Testability**.\n  Thanks to NixOS VM test, we can even go one step further\n  by ensuring each implementation of a contract, even custom ones,\n  provides required options and behaves as the contract requires\n  thanks to generic NixOS tests.\n  For an example, see the [generic backup contract test][generic backup test]\n  and the [instantiated NixOS tests][instantiated backup test]\n  ensuring the providers do implement the contract correctly.\n\n![](./assets/contracts_separationofconcerns.png \"Separation of concerns thanks to contracts.\")\n\n## Concept {#contracts-concept}\n\nConceptually, a contract is an attrset of options with a defined behavior.\n\nLet's take a reduced `secret` contract as example.\nThis contract allows a `requester` module to ask for a secret\nand a `provider` module to generate that secret outside of the nix store\nand provide it back to the `requester`.\nIn this case, the options for the contract could look like so:\n\n_The full secret contract can be found [here][secret contract]._\n\n[secret contract]: ./contracts-secret.html\n\n```nix\n{ lib, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule str;\nin\n{\n  # Filled out by the requester module.\n  request = mkOption {\n    type = submodule {\n      options = {\n        owner = mkOption {\n          description = \"Linux user owning the secret file.\";\n          type = str;\n        };\n      };\n    };\n  };\n\n  # Filled out by the provider module.\n  result = mkOption {\n    type = submodule {\n      options = {\n        path = mkOption {\n          description = \"Linux user owning the secret file.\";\n          type = str;\n        };\n      };\n    };\n  };\n\n  # Options specific for each provider.\n  settings = mkOption {\n    type = submodule {\n      options = {\n        encryptedFile = mkOption {\n          description = \"Encrypted file containing the secret.\";\n          type = path;\n        };\n      };\n    };\n  };\n}\n```\n\nUnfortunately, the contract needs to be more complicated to handle several constraints.\n\n1. First, to fill out the contract,\n   the `requester` must set the defaults for the `request.*` options\n   and the `provider` for the `result.*` options.\n\n   Since one cannot do that after calling the `mkOption` function,\n   the `request` and `result` attributes must be functions\n   taking in the defaults as arguments.\n\n2. Another constraint is a `provider` module of a contract\n   will need to work for several `requester` modules.\n   This means that the option to provide the contract will be an\n   `attrsOf` of something, not just plainly the contract.\n\n   Think of a provider for the secret contract,\n   if it didn't use `attrsOf`, one could only create an unique secret\n   for all the modules, which is not useful.\n\n3. Also, one usually want the defaults\n   for the contract to be computed from some other option.\n   For a `provider` module, the options in the `result` could be computed\n   from the `name` provided in the `attrsOf`\n   or from a value given in the `request` or `setting` attrset.\n\n   For example, a `provider` module for the `secret` contract would want\n   something like the following in pseudo code:\n   \n   ```nix\n   services.provier = {\n     secret = mkOption {\n       type = attrsOf (submodule ({ name, ... }: {\n         result = {\n           path = mkOption {\n             type = str;\n             default = \"/run/secrets/${name}\";\n           };\n         };\n       }))\n     };\n   };\n   ```\n   \n   Another example is for a `provider` module for the `backup` contract\n   which would want the name of the restore script to depend on the path\n   to the repository it is backing up to.\n   This is necessary to differentiate which source to restore from\n   in case one wants to backup a same `requester` service\n   to multiple different repositories.\n   One could be local and another remote, for example.\n   \n   ```nix\n   services.provider = {\n     backup = mkOption {\n       type = attrsOf (submodule ({ name, config, ... }: {\n         settings = {\n         };\n   \n         result = {\n           restoreScript = {\n             type = str;\n             default = \"provider-restore-${name}-${config.settings.repository.path}\";\n           };\n         };\n       }));\n     };\n   };\n   ```\n\n4. Finally, the last constraint, which is also the more demanding,\n   is we want to generate the documentation\n   for the options with `nixos-generate-config`.\n   For that, the complicated `default` we give to options\n   that depend on other options break the documentation generation.\n   So instead of using only `default`,\n   we must also define `defaultText` attributes.\n\n   This means the actual `mkRequest` and `mkResult` functions\n   must take twice as many arguments as there are option.\n   One for the `default` and the other for the `defaultText`.\n   This will not be shown in the following snippets as it is\n   already complicated enough.\n\nThese are all the justifications to why the final contract structure\nis as presented in the next section.\nIt makes it harder to write, but much easier to use,\nwhich is nice property.\n\n## Schema {#contracts-schema}\n\nA contract for a version of the [backup contract][] with less options\nwould look like so:\n\n```nix\n{ lib, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule str;\nin\n{\n  mkRequest =\n    { owner ? \"root\",\n    }: mkOption {\n      default = {\n        inherit owner;\n      };\n\n      type = submodule {\n        options = {\n          owner = mkOption {\n            description = \"Linux user owning the secret file.\";\n            type = str;\n            default = owner;\n          };\n        };\n      };\n    };\n\n  mkResult =\n    { path ? \"/run/secrets/secret\",\n    }: mkOption {\n    type = submodule {\n      options = {\n        path = mkOption {\n          description = \"Linux user owning the secret file.\";\n          type = str;\n          default = path;\n        };\n      };\n    };\n  };\n}\n```\n\nAssuming the `services.requester` module needs to receive a password from the user\nand wants to use the `secret contract` for that,\nit would then setup the option like so:\n\n```nix\n{ pkgs, lib, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\n\n  contracts = pkgs.callPackage ./modules/contracts {};\n\n  mkRequester = requestCfg: {\n    request = contracts.secret.mkRequest requestCfg;\n\n    result = contracts.secret.mkResult {};\n  };\nin\n{\n  options.services.requester = {\n    password = mkOption {\n      description = \"Password for the service.\";\n      type = submodule {\n        options = mkRequester {\n          owner = \"requester\";\n        };\n      };\n    };\n  };\n  config = {\n    // Use config.services.requester.password.result.path\n  };\n}\n```\n\nA provider that can create multiple secrets would have an `attrsOf` option\nand use the contract in it like so:\n\n```nix\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) attrsOf submodule;\n\n  contracts = pkgs.callPackage ./modules/contracts {};\n\n  mkProvider =\n    module:\n    { resultCfg,\n      settings ? {},\n    }: {\n      request = contracts.secret.mkRequest {};\n\n      result = contracts.secret.mkResult resultCfg;\n    } // optionalAttrs (settings != {}) { inherit settings; };\nin\n{\n  options.services.provider = {\n    secrets = mkOption {\n      type = attrsOf (submodule ({ name, options, ... }: {\n        options = mkProvider {\n          resultCfg = {\n            path = \"/run/secrets/${name}\";\n          };\n\n          settings = mkOption {\n            description = \"Settings specific to the Sops provider.\";\n\n            type = attrsOf (submodule {\n              options = {\n                repository = mkOption {\n                };\n              };\n            });\n            default = {};\n          };\n        };\n      }));\n    };\n  };\n}\n```\n\nThe `mkRequester` and `mkProvider` are provided by Self Host Blocks\nas they are generic, so the actual syntax is a little bit different.\nThey were copied here that way so the snippets were self-contained.\n\nTo see a full contract in action, the secret contract is a good example.\nIt is composed of:\n\n- [the contract][secret contract ref],\n- [the mkRequester and mkProvider][contract lib] functions,\n- [a requester][],\n- [a provider][].\n\n[secret contract ref]: ./contracts-secret.html#contract-secret-options\n[contract lib]: @REPO@/modules/contracts/default.nix\n[a requester]: ./blocks-sops.html#blocks-sops-options-shb.sops.secret\n[a provider]: ./services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass\n\n## Contract Tests {#contracts-test}\n\nTo make sure all providers module of a contract have the same behavior,\ngeneric NixOS VM tests exist per contract.\nThey are generic because they work on any module,\nas long as the module implements the contract of course.\n\nA simplified test for a secret contract would look like the following.\nFirst, there is the generic test:\n\n```nix\n{ pkgs, lib, shb, ... }:\nlet\n  inherit (lib) getAttrFromPath setAttrByPath;\nin\n  { name,\n    configRoot,\n    settingsCfg,\n    modules ? [],\n    owner ? \"root\",\n    content ? \"secretPasswordA\",\n  }: shb.test.runNixOSTest {\n    inherit name;\n    \n    nodes.machine = { config, ... }: {\n      imports = modules;\n      \n      config = setAttrByPath configRoot {\n        secretA = {\n          request = {\n            inherit owner;\n          };\n          settings = settingsCfg content;\n        };\n      };\n    };\n\n    testScript = { nodes, ... }:\n      let\n        result = (getAttrFromPath configRoot nodes.machine).\"A\".result;\n      in\n        ''\n          owner = machine.succeed(\"stat -c '%U' ${result.path}\").strip()\n          if owner != \"${owner}\":\n              raise Exception(f\"Owner should be '${owner}' but got '{owner}'\")\n\n          content = machine.succeed(\"cat ${result.path}\").strip()\n          if content != \"${content}\":\n              raise Exception(f\"Content should be '${content}' but got '{content}'\")\n        '';\n  }\n```\n\nThis test is generic because it sets the `request` on an option\nwhose path is not yet known.\nIt achieves this by calling `setAttrByPath configRoot` where `configRoot`\nis a path to a module, for example `[ \"services\" \"provider\" ]` for a module\nwhose root option is under `services.provider`.\n\nThis test validates multiple aspects of the contract:\n\n- The provider must understand the options of the `request`.\n  Here `request.owner`.\n- The provider correctly provides the expected result.\n  Here the location of the secret in the `result.path` option.\n- The provider must behave as expected.\n  Here, the secret located at `result.path` must have the correct `owner`\n  and the correct `content`.\n\nInstantiating the test for a given provider looks like so:\n\n```nix\n{\n  hardcoded_root = contracts.test.secret {\n    name = \"hardcoded_root\";\n\n    modules = [ ./modules/blocks/hardcodedsecret.nix ];\n    configRoot = [ \"shb\" \"hardcodedsecret\" ];\n    settingsCfg = secret: {\n      content = secret;\n    };\n  };\n\n  hardcoded_user = contracts.test.secret {\n    name = \"hardcoded_user\";\n\n    owner = \"user\";\n    modules = [ ./modules/blocks/hardcodedsecret.nix ];\n    configRoot = [ \"shb\" \"hardcodedsecret\" ];\n    settingsCfg = secret: {\n      content = secret;\n    };\n  };\n}\n```\n\nValidating a new provider is then just a matter of extending the above snippet.\n\nTo see a full contract test in action, the test for backup contract is a good example.\nIt is composed of:\n\n- the [generic test][generic backup test]\n- and [instantiated tests][instantiated backup test] for some providers.\n\n[generic backup test]: @REPO@/modules/contracts/backup/test.nix\n[instantiated backup test]: @REPO@/test/contracts/backup.nix\n\n## Videos {#contracts-videos}\n\nTwo videos exist of me presenting the topic,\nthe first at [NixCon North America in spring of 2024][NixConNA2024]\nand the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024].\n\n[NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM\n[NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc\n\n## Are there contracts in nixpkgs already? {#contracts-nixpkgs}\n\nActually not quite, but close. There are some ubiquitous options in nixpkgs. Those I found are:\n\n- `services.<name>.enable`\n- `services.<name>.package`\n- `services.<name>.openFirewall`\n- `services.<name>.user`\n- `services.<name>.group`\n\nWhat makes those nearly contracts are:\n\n- Pretty much every service provides them.\n- Users of a service expects them to exist and expects a consistent type and behavior from them.\n  Indeed, everyone knows what happens if you set `enable = true`.\n- Maintainers of a service knows that users expects those options. They also know what behavior the\n  user expects when setting those options.\n- The name of the options is the same everywhere.\n\nThe only thing missing to make these explicit contracts is, well, the contracts themselves.\nCurrently, they are conventions and not contracts.\n"
  },
  {
    "path": "docs/contributing.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Contributing {#contributing}\n\nAll issues and Pull Requests are welcome!\n\n- Use this project. Something does not make sense? Something's not working?\n- Documentation. Something is not clear?\n- New services. Have one of your preferred service not integrated yet?\n- Better patterns. See something weird in the code?\n\nFor PRs, if they are substantial changes, please open an issue to\ndiscuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html)\nof the manual.\n\nIssues that are being worked on are labeled with the [in progress][] label.\nBefore starting work on those, you might want to talk about it in the issue tracker\nor in the [matrix][] channel.\n\nThe prioritized issues are those belonging to the [next milestone][milestone].\nThose issues are not set in stone and I'd be very happy to solve\nan issue an user has before scratching my own itch.\n\n[in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22\n[matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org\n[milestone]: https://github.com/ibizaman/selfhostblocks/milestones\nfirst.\n\n## Chat Support {#contributing-chat}\n\nCome hang out in the [Matrix channel](https://matrix.to/#/%23selfhostblocks%3Amatrix.org). :)\n\n## Upstream Changes {#contributing-upstream}\n\nOne important goal of SHB is to be the smallest amount of code above what is available in\n[nixpkgs](https://github.com/NixOS/nixpkgs). It should be the minimum necessary to make packages\navailable there conform with the contracts. This way, there are less chance of breakage when nixpkgs\ngets updated. I intend to upstream to nixpkgs as much of those as makes sense.\n\n## Run tests {#contributing-runtests}\n\nRun all tests:\n\n```bash\n$ nix flake check\n# or\n$ nix run github:Mic92/nix-fast-build -- --skip-cached --flake \".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)\"\n```\n\nRun one group of tests:\n\n```bash\n$ nix build .#checks.${system}.modules\n$ nix build .#checks.${system}.vm_postgresql_peerAuth\n```\n\n### Playwright Tests {#contributing-playwright-tests}\n\nIf the test includes playwright tests, you can see the playwright trace with:\n\n```bash\n$ nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip\n```\n\n### Debug Tests {#contributing-debug-tests}\n\nRun the test in driver interactive mode:\n\n```bash\n$ nix run .#checks.${system}.vm_postgresql_peerAuth.driverInteractive\n```\n\nWhen you get to the shell, start the server and/or client with one of the following commands:\n\n```bash\nserver.start()\nclient.start()\nstart_all()\n```\n\nTo run the test from the shell, use `test_script()`.\nNote that if the test script ends in error,\nthe shell will exit and you will need to restart the VMs.\n\nAfter the shell started, you will see lines like so:\n\n```\nSSH backdoor enabled, the machines can be accessed like this:\nNote: this requires systemd-ssh-proxy(1) to be enabled (default on NixOS 25.05 and newer).\n    client:  ssh -o User=root vsock/3\n    server:  ssh -o User=root vsock/4\n```\n\nWith the following command, you can directly access the server's nginx instance with your browser at `http://localhost:8000`:\n\n```bash\nssh-keygen -R vsock/4; ssh -o User=root -L 8000:localhost:80 vsock/4\n```\n\n## Upload test results to CI {#contributing-upload}\n\nGithub actions do now have hardware acceleration, so running them there is not slow anymore. If\nneeded, the tests results can still be pushed to cachix so they can be reused in CI.\n\nAfter running the `nix-fast-build` command from the previous section, run:\n\n```bash\n$ find . -type l -name \"result-vm_*\" | xargs readlink | nix run nixpkgs#cachix -- push selfhostblocks\n```\n\n## Upload package to CI {#contributing-upload-package}\n\nIn the rare case where a package must be built but cannot in CI,\nfor example because of not enough memory,\nyou can push the package directly to the cache with:\n\n```bash\nnix build .#checks.x86_64-linux.vm_karakeep_backup.nodes.server.services.karakeep.package\nreadlink result | nix run nixpkgs#cachix -- push selfhostblocks\n\n```\n\n## Deploy using colmena {#contributing-deploy-colmena}\n\n```bash\n$ nix run nixpkgs#colmena -- apply\n```\n\n## Use a local version of selfhostblocks {#contributing-localversion}\n\nThis works with any flake input you have. Either, change the `.url` field directly in you `flake.nix`:\n\n```nix\nselfhostblocks.url = \"/home/me/projects/selfhostblocks\";\n```\n\nOr override on the command line:\n\n```bash\n$ nix flake lock --override-input selfhostblocks ../selfhostblocks\n```\n\nI usually combine the override snippet above with deploying:\n\n```bash\n$ nix flake lock --override-input selfhostblocks ../selfhostblocks && nix run nixpkgs#colmena -- apply\n```\n\n## Diff changes {#contributing-diff}\n\nFirst, 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.\n\n### What is deployed {#contributing-diff-deployed}\n\nTo know what is deployed, either just stash the changes you made and run `build`:\n\n```bash\n$ nix run nixpkgs#colmena -- build\n...\nBuilt \"/nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git\"\n```\n\nOr ask the target machine:\n\n```bash\n$ nix run nixpkgs#colmena -- exec -v readlink -f /run/current-system\nbaryum | /nix/store/77n1hwhgmr9z0x3gs8z2g6cfx8gkr4nm-nixos-system-baryum-23.11pre-git\n```\n\n### What will get deployed {#contributing-diff-todeploy}\n\nAssuming you made some changes, then instead of deploying with `apply`, just `build`:\n\n```bash\n$ nix run nixpkgs#colmena -- build\n...\nBuilt \"/nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git\"\n```\n\n### Get the full diff {#contributing-diff-full}\n\nWith `nix-diff`:\n\n```\n$ nix run nixpkgs#nix-diff -- \\\n  /nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git \\\n  /nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git \\\n  --color always | less\n```\n\n### Get version bumps {#contributing-diff-version}\n\nA nice summary of version changes can be produced with:\n\n```bash\n$ nix run nixpkgs#nvd -- diff \\\n  /nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git \\\n  /nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git \\\n```\n\n## Generate random secret {#contributing-gensecret}\n\n```bash\n$ nix run nixpkgs#openssl -- rand -hex 64\n```\n\n## Write code {#contributing-code}\n\n```{=include=} chapters html:into-file=//service-implementation-guide.html\nservice-implementation-guide.md\n```\n\n## Links that helped {#contributing-links}\n\nWhile creating NixOS tests:\n\n- https://www.haskellforall.com/2020/11/how-to-use-nixos-for-lightweight.html\n- https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests\n\nWhile creating an XML config generator for Radarr:\n\n- https://stackoverflow.com/questions/4906977/how-can-i-access-environment-variables-in-python\n- https://stackoverflow.com/questions/7771011/how-can-i-parse-read-and-use-json-in-python\n- https://github.com/NixOS/nixpkgs/blob/master/pkgs/build-support/writers/scripts.nix\n- https://stackoverflow.com/questions/43837691/how-to-package-a-single-python-script-with-nix\n- https://ryantm.github.io/nixpkgs/languages-frameworks/python/#python\n- https://ryantm.github.io/nixpkgs/hooks/python/#setup-hook-python\n- https://ryantm.github.io/nixpkgs/builders/trivial-builders/\n- https://discourse.nixos.org/t/basic-flake-run-existing-python-bash-script/19886\n- https://docs.python.org/3/tutorial/inputoutput.html\n- https://pypi.org/project/json2xml/\n- https://www.geeksforgeeks.org/serialize-python-dictionary-to-xml/\n- https://nixos.org/manual/nix/stable/language/builtins.html#builtins-toXML\n- https://github.com/NixOS/nixpkgs/blob/master/pkgs/pkgs-lib/formats.nix\n"
  },
  {
    "path": "docs/default.nix",
    "content": "# Taken nearly verbatim from https://github.com/nix-community/home-manager/pull/4673\n# Read these docs online at https://shb.skarabox.com.\n{\n  pkgs,\n  buildPackages,\n  lib,\n  nmdsrc,\n  stdenv,\n  documentation-highlighter,\n  nixos-render-docs,\n\n  release,\n  allModules,\n\n  version ? builtins.readFile ../VERSION,\n  substituteVersionIn,\n\n  modules,\n}:\n\nlet\n  shbPath = toString ./..;\n\n  gitHubDeclaration =\n    user: repo: subpath:\n    let\n      urlRef = \"main\";\n      end = if subpath == \"\" then \"\" else \"/\" + subpath;\n    in\n    {\n      url = \"https://github.com/${user}/${repo}/blob/${urlRef}${end}\";\n      name = \"<${repo}${end}>\";\n    };\n\n  ghRoot = (gitHubDeclaration \"ibizaman\" \"selfhostblocks\" \"\").url;\n\n  buildOptionsDocs =\n    {\n      modules,\n      filterOptionPath ? null,\n    }:\n    args:\n    let\n      config = {\n        _module.check = false;\n        _module.args = { };\n        system.stateVersion = \"22.11\";\n      };\n\n      utils = import \"${pkgs.path}/nixos/lib/utils.nix\" {\n        inherit config lib;\n        pkgs = null;\n      };\n\n      eval = lib.evalModules {\n        inherit modules;\n\n        specialArgs = {\n          inherit utils;\n        };\n      };\n\n      options = lib.setAttrByPath filterOptionPath (lib.getAttrFromPath filterOptionPath eval.options);\n    in\n    buildPackages.nixosOptionsDoc (\n      {\n        inherit options;\n\n        transformOptions =\n          opt:\n          opt\n          // {\n            # Clean up declaration sites to not refer to the Home Manager\n            # source tree.\n            declarations = map (\n              decl:\n              gitHubDeclaration \"ibizaman\" \"selfhostblocks\" (\n                lib.removePrefix \"/\" (lib.removePrefix shbPath (toString decl))\n              )\n            ) opt.declarations;\n          };\n      }\n      // builtins.removeAttrs args [ \"includeModuleSystemOptions\" ]\n    );\n\n  scrubbedModule = {\n    _module.args.pkgs = lib.mkForce (nmd.scrubDerivations \"pkgs\" pkgs);\n    _module.check = false;\n  };\n\n  allOptionsDocs =\n    paths:\n    (buildOptionsDocs\n      {\n        modules = paths ++ allModules ++ [ scrubbedModule ];\n        filterOptionPath = [ \"shb\" ];\n      }\n      {\n        variablelistId = \"selfhostblocks-options\";\n      }\n    ).optionsJSON;\n\n  individualModuleOptionsDocs =\n    filterOptionPath: paths:\n    (buildOptionsDocs\n      {\n        modules = paths ++ [ scrubbedModule ];\n        inherit filterOptionPath;\n      }\n      {\n        variablelistId = \"selfhostblocks-options\";\n      }\n    ).optionsJSON;\n\n  nmd = import nmdsrc {\n    inherit lib;\n    # The DocBook output of `nixos-render-docs` doesn't have the change\n    # `nmd` uses to work around the broken stylesheets in\n    # `docbook-xsl-ns`, so we restore the patched version here.\n    pkgs = pkgs // {\n      docbook-xsl-ns = pkgs.docbook-xsl-ns.override { withManOptDedupPatch = true; };\n    };\n  };\n\n  outputPath = \"share/doc/selfhostblocks\";\n\n  manpage-urls = pkgs.writeText \"manpage-urls.json\" \"{}\";\nin\nstdenv.mkDerivation {\n  name = \"self-host-blocks-manual\";\n\n  nativeBuildInputs = [ nixos-render-docs ];\n\n  # We include the parent so we get the documentation inside the root\n  # modules/ and demo/ folders.\n  src = ./..;\n\n  buildPhase = ''\n    cd docs\n\n    mkdir -p demo\n    cp -t . -r ../demo\n    cp -t . -r ../modules\n\n    mkdir -p out/media\n    mkdir -p out/highlightjs\n    mkdir -p out/static\n\n    cp -t out/highlightjs \\\n      ${documentation-highlighter}/highlight.pack.js \\\n      ${documentation-highlighter}/LICENSE \\\n      ${documentation-highlighter}/mono-blue.css \\\n      ${documentation-highlighter}/loader.js\n\n    cp -t out/static \\\n      ${nmdsrc}/static/style.css \\\n      ${nmdsrc}/static/highlightjs/tomorrow-night.min.css \\\n      ${nmdsrc}/static/highlightjs/highlight.min.js \\\n      ${nmdsrc}/static/highlightjs/highlight.load.js\n\n  ''\n  + lib.concatStringsSep \"\\n\" (\n    map (m: ''\n      substituteInPlace ${m} --replace '@VERSION@' ${version}\n    '') substituteVersionIn\n  )\n  + ''\n    substituteInPlace ./options.md \\\n      --replace \\\n        '@OPTIONS_JSON@' \\\n        ${\n          allOptionsDocs [\n            (pkgs.path + \"/nixos/modules/services/misc/forgejo.nix\")\n          ]\n        }/share/doc/nixos/options.json\n  ''\n  + lib.concatStringsSep \"\\n\" (\n    lib.mapAttrsToList (\n      name: cfg':\n      let\n        cfg = if builtins.isAttrs cfg' then cfg' else { module = cfg'; };\n        module = if builtins.isList cfg.module then cfg.module else [ cfg.module ];\n        optionRoot =\n          cfg.optionRoot or [\n            \"shb\"\n            (lib.last (lib.splitString \"/\" name))\n          ];\n      in\n      ''\n        substituteInPlace ./modules/${name}/docs/default.md \\\n          --replace-fail \\\n            '@OPTIONS_JSON@' \\\n            ${individualModuleOptionsDocs optionRoot module}/share/doc/nixos/options.json\n      ''\n    ) modules\n  )\n  + ''\n    find . -name \"*.md\" -print0 | \\\n      while IFS= read -r -d ''' f; do\n        substituteInPlace \"''${f}\" \\\n          --replace-quiet \\\n            '@REPO@' \\\n            \"${ghRoot}\" 2>/dev/null\n      done\n\n    nixos-render-docs manual html \\\n      --manpage-urls ${manpage-urls} \\\n      --redirects ./redirects.json \\\n      --media-dir media \\\n      --revision ${lib.trivial.revisionWithDefault release} \\\n      --stylesheet static/style.css \\\n      --stylesheet static/tomorrow-night.min.css \\\n      --script static/highlight.min.js \\\n      --script static/highlight.load.js \\\n      --toc-depth 1 \\\n      --section-toc-depth 1 \\\n      manual.md \\\n      out/index.html\n  '';\n\n  installPhase = ''\n    dest=\"$out/${outputPath}\"\n    mkdir -p \"$(dirname \"$dest\")\"\n    mv out \"$dest\"\n    mkdir -p $out/nix-support/\n    echo \"doc manual $dest index.html\" >> $out/nix-support/hydra-build-products\n  '';\n}\n"
  },
  {
    "path": "docs/demos.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Demos {#demos}\n\nThese demos are showcasing what Self Host Blocks can do. They deploy a block or a service on a VM on\nyour local machine with minimal manual steps.\n\n```{=include=} chapters html:into-file=//demo-homeassistant.html\ndemo/homeassistant/README.md\n```\n\n```{=include=} chapters html:into-file=//demo-nextcloud.html\ndemo/nextcloud/README.md\n```\n"
  },
  {
    "path": "docs/generate-redirects-nixos-render-docs.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nGenerate redirects.json by scanning actual HTML files produced by nixos-render-docs.\n\nThis script implements a runtime patching mechanism to automatically generate a\ncomplete redirects.json file by scanning generated HTML files for real anchor\nlocations, eliminating manual maintenance and ensuring accuracy.\n\nARCHITECTURE OVERVIEW:\nThe script works by monkey-patching nixos-render-docs at runtime to:\n1. Disable redirect validation during HTML generation\n2. Generate HTML documentation normally\n3. Scan all generated HTML files to extract anchor IDs and their file locations\n4. Apply filtering logic to exclude system-generated anchors\n5. Generate and write redirects.json with accurate mappings\n\nKEY COMPONENTS:\n- Runtime patching: Modifies nixos-render-docs behavior without source changes\n- HTML scanning: Extracts anchor IDs using regex pattern matching\n- Filtering: Excludes NixOS options (opt-*) and extra options (selfhostblock*)\n- Output generation: Creates both debug information and production redirects.json\n\nIMPORTANT NOTES:\n- Uses atexit handler to ensure output is generated even if process is interrupted\n- Patches are applied on module import, making this a side-effect import\n- Error handling preserves original validation behavior in case of failure\n\"\"\"\n\nimport sys\nimport json\nimport atexit\nimport os\nimport re\n\n# Global storage for anchor-to-file mappings discovered during HTML scanning\n# Structure: {anchor_id: html_filename}\nfile_target_mapping = {}\n\ndef scan_html_files(output_dir, html_files):\n    \"\"\"\n    Scan HTML files to extract anchor IDs and build anchor-to-file mappings.\n    \n    Discovers all HTML files in output_dir and extracts id attributes to populate\n    the global file_target_mapping. Filters out NixOS system options during scanning.\n    \n    Args:\n        output_dir: Directory containing generated HTML files\n        html_files: Unused parameter (always discovers files from filesystem)\n    \"\"\"\n    # Always discover HTML files from the output directory\n    if not os.path.exists(output_dir):\n        print(f\"DEBUG: Output directory {output_dir} does not exist\", file=sys.stderr)\n        return\n    \n    html_files = [f for f in os.listdir(output_dir) if f.endswith('.html')]\n    print(f\"DEBUG: Discovered {len(html_files)} HTML files in {output_dir}\", file=sys.stderr)\n    \n    # Process each HTML file to extract anchor IDs\n    for html_file in html_files:\n        html_path = os.path.join(output_dir, html_file)\n        try:\n            with open(html_path, 'r', encoding='utf-8') as f:\n                html_content = f.read()\n            \n            # Extract all id attributes using regex pattern matching\n            # Matches: id=\"anchor-name\" and captures anchor-name\n            anchor_matches = re.findall(r'id=\"([^\"]+)\"', html_content)\n            \n            # Filter and record anchor mappings\n            non_opt_count = 0\n            for anchor_id in anchor_matches:\n                # Skip NixOS system option anchors (opt-* prefix)\n                if not anchor_id.startswith('opt-'):\n                    file_target_mapping[anchor_id] = html_file\n                    non_opt_count += 1\n            \n            if non_opt_count > 0:\n                print(f\"Found {non_opt_count} anchors in {html_file}\", file=sys.stderr)\n                \n        except Exception as e:\n            # Log errors but continue processing other files\n            print(f\"Failed to scan {html_path}: {e}\", file=sys.stderr)\n\ndef output_collected_refs():\n    \"\"\"\n    Generate and write the final redirects.json file from collected anchor mappings.\n    \n    This function is registered as an atexit handler to ensure output is generated\n    even if the process is interrupted. It processes the global file_target_mapping\n    to create the final redirects file with appropriate filtering.\n    \n    Output files:\n        - out/redirects.json: Production redirects mapping\n    \"\"\"\n    import os\n    \n    # Generate redirects from discovered HTML anchor mappings\n    if file_target_mapping:\n        print(f\"Creating redirects from {len(file_target_mapping)} HTML mappings\", file=sys.stderr)\n        redirects = {}\n        filtered_count = 0\n        \n        # Apply filtering logic to exclude system-generated anchors\n        for anchor_id, html_file in file_target_mapping.items():\n            # Filter out:\n            # - opt-*: NixOS system options \n            # - selfhostblock*: Extra options from this project\n            if not anchor_id.startswith('opt-') and not anchor_id.startswith('selfhostblock'):\n                redirects[anchor_id] = [f\"{html_file}#{anchor_id}\"]\n            else:\n                filtered_count += 1\n        \n        print(f\"Generated {len(redirects)} redirects (filtered out {filtered_count} system options)\", file=sys.stderr)\n    else:\n        # Fallback case - should not occur during normal operation\n        print(\"Warning: No HTML mappings available\", file=sys.stderr)\n        redirects = {}\n    \n    # Ensure output directory exists\n    os.makedirs('out', exist_ok=True)\n    \n    # Write production redirects file\n    try:\n        redirects_file = 'out/redirects.json'\n        \n        with open(redirects_file, 'w') as f:\n            json.dump(redirects, f, indent=2, sort_keys=True)\n        \n        print(f\"Generated redirects.json with {len(redirects)} redirects\", file=sys.stderr)\n        \n    except Exception as e:\n        print(f\"Failed to write redirects.json: {e}\", file=sys.stderr)\n\n# Register output generation to run on process exit\natexit.register(output_collected_refs)\n\ndef apply_patches():\n    \"\"\"\n    Apply runtime monkey patches to nixos-render-docs modules.\n    \n    This function modifies the behavior of nixos-render-docs by:\n    1. Hooking into the HTML generation CLI command\n    2. Temporarily disabling redirect validation during HTML generation\n    3. Scanning generated HTML files to extract anchor mappings\n    4. Restoring original validation behavior\n    \n    The patching approach allows us to extract anchor information without\n    modifying the nixos-render-docs source code directly.\n    \n    Raises:\n        ImportError: If nixos-render-docs modules cannot be imported\n    \"\"\"\n    try:\n        # Import required nixos-render-docs modules\n        import nixos_render_docs.html as html_module\n        import nixos_render_docs.redirects as redirects_module\n        import nixos_render_docs.manual as manual_module\n        \n        # Store reference to original HTML CLI function\n        original_run_cli_html = manual_module._run_cli_html\n        \n        def patched_run_cli_html(args):\n            \"\"\"\n            Patched version of _run_cli_html that disables validation and scans output.\n            \n            This wrapper function:\n            1. Temporarily disables redirect validation to prevent errors\n            2. Runs normal HTML generation\n            3. Scans generated HTML files for anchor mappings\n            4. Restores original validation behavior\n            \"\"\"\n            print(\"Generating HTML documentation...\", file=sys.stderr)\n            \n            # Temporarily disable redirect validation\n            original_validate = redirects_module.Redirects.validate\n            redirects_module.Redirects.validate = lambda self, targets: None\n            \n            try:\n                # Run original HTML generation\n                result = original_run_cli_html(args)\n                \n                # Determine output directory from CLI arguments\n                if hasattr(args, 'outfile') and args.outfile:\n                    output_dir = os.path.dirname(args.outfile)\n                else:\n                    output_dir = '.'\n                \n                # Scan generated HTML files for anchor mappings\n                scan_html_files(output_dir, None)\n                print(f\"Scanned {len(file_target_mapping)} anchor mappings\", file=sys.stderr)\n                \n            finally:\n                # Always restore original validation function\n                redirects_module.Redirects.validate = original_validate\n            \n            return result\n        \n        # Replace the original function with our patched version\n        manual_module._run_cli_html = patched_run_cli_html\n                \n        print(\"Applied patches to nixos-render-docs\", file=sys.stderr)\n        \n    except ImportError as e:\n        print(f\"Failed to apply patches: {e}\", file=sys.stderr)\n\n# Apply patches immediately when this module is imported\n# This ensures the patches are active before nixos-render-docs CLI runs\napply_patches()"
  },
  {
    "path": "docs/manual.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Self Host Blocks Manual {#self-host-blocks-manual}\n\n## Version @VERSION@\n\n\n```{=include=} preface\npreface.md\n```\n\n```{=include=} chapters html:into-file=//usage.html\nusage.md\n```\n\n```{=include=} chapters html:into-file=//services.html\nservices.md\n```\n\n```{=include=} chapters html:into-file=//contracts.html\ncontracts.md\n```\n\n```{=include=} chapters html:into-file=//blocks.html\nblocks.md\n```\n\n```{=include=} chapters html:into-file=//recipes.html\nrecipes.md\n```\n\n```{=include=} chapters html:into-file=//demos.html\ndemos.md\n```\n\n```{=include=} chapters html:into-file=//contributing.html\ncontributing.md\n```\n\n```{=include=} appendix html:into-file=//options.html\noptions.md\n```\n"
  },
  {
    "path": "docs/options.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# All Options {#all-options}\n\n```{=include=} options\nid-prefix: opt-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "docs/preface.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Preface {#preface}\n\n::: {.note}\nSelf Host Blocks is hosted on [GitHub](https://github.com/ibizaman/selfhostblocks).\nIf you encounter problems or bugs then please report them on the [issue\ntracker](https://github.com/ibizaman/selfhostblocks/issues).\n\nFeel free to join the dedicated Matrix room\n[matrix.org#selfhostblocks](https://matrix.to/#/#selfhostblocks:matrix.org).\n:::\n\nSelfHostBlocks is:\n\n- Your escape from the cloud, for privacy and data sovereignty enthusiast. [Why?](#preface-why-self-hosting)\n- A groupware to self-host [all your data](#preface-services): documents, pictures, calendars, contacts, etc.\n- An opinionated NixOS server management OS for a [safe self-hosting experience](#preface-features).\n- A NixOS distribution making sure all services build and work correctly thanks to NixOS VM tests.\n- A collection of NixOS modules standardizing options so configuring services [look the same](#preface-unified-interfaces).\n- A testing ground for [contracts](#preface-contracts) which intents to make nixpkgs modules more modular.\n- [Upstreaming][] as much as possible.\n\n[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\n\n## Why Self-Hosting {#preface-why-self-hosting}\n\nIt is obvious by now that\na deep dependency on proprietary service providers - \"the cloud\" -\nis a significant liability.\nOne aspect often talked about is privacy\nwhich is inherently not guaranteed when using a proprietary service\nand is a valid concern.\nA more punishing issue is having your account closed or locked\nwithout prior warning\nWhen that happens,\nyou get an instantaneous sinking feeling in your stomach\nat the realization you lost access to your data,\npossibly without recourse.\n\nHosting services yourself is the obvious alternative\nto alleviate those concerns\nbut it tends to require a lot of technical skills and time.\nSelfHostBlocks (together with its sibling project [Skarabox][])\naims to lower the bar to self-hosting,\nand provides an opinionated server management system based on NixOS modules\nembedding best practices.\nContrary to other server management projects,\nits main focus is ease of long term maintenance\nbefore ease of installation.\nTo achieve this, it provides building blocks to setup services.\nSome are already provided out of the box,\nand customizing or adding additional ones is done easily.\n\nThe building blocks fit nicely together thanks to [contracts](#contracts)\nwhich SelfHostBlocks sets out to introduce into nixpkgs.\nThis will increase modularity, code reuse\nand empower end users to assemble components\nthat fit together to build their server.\n\n## Usage {#preface-usage}\n\n> **Caution:** You should know that although I am using everything in this repo for my personal\n> production server, this is really just a one person effort for now and there are most certainly\n> bugs that I didn't discover yet.\n\nTo get started using SelfHostBlocks, the following snippet is enough:\n\n```nix\n{\n  inputs.selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n\n  outputs = { selfhostblocks, ... }: let\n    system = \"x86_64-linux\";\n    nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n  in\n    nixosConfigurations = {\n      myserver = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          selfhostblocks.nixosModules.default\n          ./configuration.nix\n        ];\n      };\n    };\n}\n```\n\nSelfHostBlocks provides its own patched nixpkgs, so you are required to use it\notherwise evaluation can quickly break.\n[The usage section](https://shb.skarabox.com/usage.html) of the manual has\nmore details and goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][]\nand also how to handle secrets management with [SOPS][].\n\n[Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena\n[nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild\n[deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs\n[SOPS]: https://shb.skarabox.com/usage.html#usage-secrets\n\nThen, to actually configure services, you can choose which one interests you in\nthe [services section](https://shb.skarabox.com/services.html) of the manual.\n\nThe [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases.\n\nHead over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org)\nfor any remaining question, or just to say hi :)\n\n### Installation From Scratch {#preface-usage-installation-from-scratch}\n\nI do recommend for this my sibling project [Skarabox][]\nwhich bootstraps a new server and sets up a few tools:\n\n- Create a bootable ISO, installable on an USB key.\n- Handles one or two (in raid 1) SSDs for root partition.\n- Handles two (in raid 1) or more hard drives for data partition.\n- [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) to install NixOS headlessly.\n- [disko](https://github.com/nix-community/disko) to format the drives using native ZFS encryption with remote unlocking through ssh.\n- [sops-nix](https://github.com/Mic92/sops-nix) to handle secrets.\n- [deploy-rs](https://github.com/serokell/deploy-rs) to deploy updates.\n\n[Skarabox]:  https://github.com/ibizaman/skarabox\n\n## Features {#preface-features}\n\nSelfHostBlocks provides building blocks that take care of common self-hosting needs:\n\n- Backup for all services.\n- Automatic creation of ZFS datasets per service.\n- LDAP and SSO integration for most services.\n- Monitoring with Grafana and Prometheus stack with provided dashboards.\n- Automatic reverse proxy and certificate management for HTTPS.\n- VPN and proxy tunneling services.\n\nGreat care is taken to make the proposed stack robust.\nThis translates into a test suite comprised of automated NixOS VM tests\nwhich includes playwright tests to verify some important workflow\nlike logging in.\n\nThis test suite also serves as a guaranty that all services provided by SelfHostBlocks\nall evaluate, build and work correctly together. It works similarly as a distribution but here it's all [automated](#preface-updates).\n\nAlso, the stack fits together nicely thanks to [contracts](#preface-contracts).\n\n### Services {#preface-services}\n\n[Provided services](https://shb.skarabox.com/services.html) are:\n\n- Nextcloud\n- Audiobookshelf\n- Deluge + *arr stack\n- Forgejo\n- Grocy\n- Hledger\n- Home-Assistant\n- Jellyfin\n- Karakeep\n- Open WebUI\n- Pinchflat\n- Vaultwarden\n\nLike explained above, those services all benefit from\nout of the box backup,\nLDAP and SSO integration,\nmonitoring with Grafana,\nreverse proxy and certificate management\nand VPN integration for the *arr suite.\n\nSome services do not have an entry yet in the manual.\nTo know options for those, the only way for now\nis to go to the [All Options][] section of the manual.\n\n[All Options]: https://shb.skarabox.com/options.html\n\n### Blocks {#preface-blocks}\n\nThe services above rely on the following [common blocks][]\nwhich altogether provides a solid foundation for self-hosting services:\n\n- Authelia\n- BorgBackup\n- Davfs\n- LDAP\n- Monitoring (Grafana - Prometheus - Loki stack)\n- Nginx\n- PostgreSQL\n- Restic\n- Sops\n- SSL\n- Tinyproxy\n- VPN\n- ZFS\n\nThose blocks can be used with services\nnot provided by SelfHostBlocks as shown [in the manual][common blocks].\n\n[common blocks]: https://shb.skarabox.com/blocks.html\n\nThe manual also provides documentation for each individual blocks.\n\n### Unified Interfaces {#preface-unified-interfaces}\n\nThanks to the blocks,\nSelfHostBlocks provides an unified configuration interface\nfor the services it provides.\n\nCompare the configuration for Nextcloud and Forgejo.\nThe following snippets focus on similitudes and assume the relevant blocks - like secrets - are configured off-screen.\nIt also does not show specific options for each service.\nThese are still complete snippets that configure HTTPS,\nsubdomain serving the service, LDAP and SSO integration.\n\n```nix\nshb.nextcloud = {\n  enable = true;\n  subdomain = \"nextcloud\";\n  domain = \"example.com\";\n\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  apps.ldap = {\n    enable = true;\n    host = \"127.0.0.1\";\n    port = config.shb.lldap.ldapPort;\n    dcdomain = config.shb.lldap.dcdomain;\n    adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap/admin_password\".result;\n  };\n  apps.sso = {\n    enable = true;\n    endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n    secret.result = config.shb.sops.secret.\"nextcloud/sso/secret\".result;\n    secretForAuthelia.result = config.shb.sops.secret.\"nextcloud/sso/secretForAuthelia\".result;\n  };\n};\n```\n\n```nix\nshb.forgejo = {\n  enable = true;\n  subdomain = \"forgejo\";\n  domain = \"example.com\";\n\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  ldap = {\n    enable = true;\n    host = \"127.0.0.1\";\n    port = config.shb.lldap.ldapPort;\n    dcdomain = config.shb.lldap.dcdomain;\n    adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap/admin_password\".result;\n  };\n\n  sso = {\n    enable = true;\n    endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n    secret.result = config.shb.sops.secret.\"forgejo/sso/secret\".result;\n    secretForAuthelia.result = config.shb.sops.secret.\"forgejo/sso/secretForAuthelia\".result;\n  };\n};\n```\n\nAs you can see, they are pretty similar!\nThis makes setting up a new service pretty easy and intuitive.\n\nSelfHostBlocks provides an ever growing list of [services](#preface-services)\nthat are configured in the same way.\n\n### Contracts {#preface-contracts}\n\nTo make building blocks that fit nicely together,\nSelfHostBlocks pioneers [contracts][] which allows you, the final user,\nto be more in control of which piece goes where.\nThis lets you choose, for example,\nany reverse proxy you want or any database you want,\nwithout requiring work from maintainers of the services you want to self host.\n\nAn [RFC][] exists to upstream this concept into `nixpkgs`.\nThe [manual][contracts] also provides an explanation of the why and how of contracts.\n\nAlso, two videos exist of me presenting the topic,\nthe first at [NixCon North America in spring of 2024][NixConNA2024]\nand the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024].\n\n[contracts]: https://shb.skarabox.com/contracts.html\n[RFC]: https://github.com/NixOS/rfcs/pull/189\n[NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM\n[NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc\n\n### Interfacing With Other OSes {#preface-interface}\n\nThanks to [contracts](#contracts), one can interface NixOS\nwith systems on other OSes.\nThe [RFC][] explains how that works.\n\n### Sitting on the Shoulders of a Giant {#preface-giants}\n\nBy using SelfHostBlocks, you get all the benefits of NixOS\nwhich are, for self hosted applications specifically:\n\n- declarative configuration;\n- atomic configuration rollbacks;\n- real programming language to define configurations;\n- create your own higher level abstractions on top of SelfHostBlocks;\n- integration with the rest of nixpkgs;\n- much fewer \"works on my machine\" type of issues.\n\n### Automatic Updates {#preface-updates}\n\nSelfHostBlocks follows nixpkgs unstable branch closely.\nThere is a GitHub action running every couple of days that updates\nthe `nixpkgs` input in the root `flakes.nix`,\nruns the tests and merges the PR automatically\nif the tests pass.\n\nA release is then made every few commits,\nwhenever deemed sensible.\nOn your side, to update I recommend pinning to a release\nwith the following command,\nreplacing the RELEASE with the one you want:\n\n```bash\nRELEASE=0.2.4\nnix flake update \\\n  --override-input selfhostblocks github:ibizaman/selfhostblocks/$RELEASE \\\n  selfhostblocks\n```\n\n### Demos {#preface-demos}\n\nDemos that start and deploy a service\non a Virtual Machine on your computer are located\nunder the [demo](./demo/) folder.\n\nThese show the onboarding experience you would get\nif you deployed one of the services on your own server.\n\n## Roadmap {#preface-roadmap}\n\nCurrently, the Nextcloud and Vaultwarden services\nand the SSL and backup blocks\nare the most advanced and most documented.\n\nDocumenting all services and blocks will be done\nas I make all blocks and services use the contracts.\n\nUpstreaming changes is also on the roadmap.\n\nCheck the [issues][] and the [milestones]() to see planned work.\nFeel free to add more or to contribute!\n\n[issues]: (https://github.com/ibizaman/selfhostblocks/issues)\n[milestones]: https://github.com/ibizaman/selfhostblocks/milestones\n\nAll blocks and services have NixOS tests.\nAlso, I am personally using all the blocks and services in this project, so they do work to some extent.\n\n## Community {#preface-community}\n\nThis project has been the main focus\nof my (non work) life for the past 3 year now\nand I intend to continue working on this for a long time.\n\nAll issues and PRs are welcome:\n\n- Use this project. Something does not make sense? Something's not working?\n- Documentation. Something is not clear?\n- New services. Have one of your preferred service not integrated yet?\n- Better patterns. See something weird in the code?\n\nFor PRs, if they are substantial changes, please open an issue to\ndiscuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html)\nof the manual.\n\nIssues that are being worked on are labeled with the [in progress][] label.\nBefore starting work on those, you might want to talk about it in the issue tracker\nor in the [matrix][] channel.\n\nThe prioritized issues are those belonging to the [next milestone][milestone].\nThose issues are not set in stone and I'd be very happy to solve\nan issue an user has before scratching my own itch.\n\n[in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22\n[matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org\n[milestone]: https://github.com/ibizaman/selfhostblocks/milestones\n\nOne aspect that's close to my heart is I intent to make SelfHostBlocks the lightest layer on top of nixpkgs as\npossible. I want to upstream as much as possible. I will still take some time to experiment here but\nwhen I'm satisfied with how things look, I'll upstream changes.\n\n## Funding {#preface-funding}\n\nI was lucky to [obtain a grant][nlnet] from NlNet which is an European fund,\nunder [NGI Zero Core][NGI0],\nto work on this project.\nThis also funds the contracts RFC.\n\nGo apply for a grant too!\n\n[nlnet]: https://nlnet.nl/project/SelfHostBlocks\n[NGI0]: https://nlnet.nl/core/\n\n## License {#preface-license}\n\nI'm following the [Nextcloud](https://github.com/nextcloud/server) license which is AGPLv3.\nSee [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.\n"
  },
  {
    "path": "docs/recipes/dnsServer.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Self-Host a DNS server {#recipes-dnsServer}\n\nThis recipe will show how to setup [dnsmasq][] as a local DNS server\nthat forwards all queries to your own domain `example.com` to a local IP - your server running SelfHostBlocks for example.\n\n[dnsmasq]: https://dnsmasq.org/doc.html\n\nOther DNS queries will be forwarded to an external DNS server\nusing [DNSSEC][] to encrypt your queries.\n\n[DNSSEC]: https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions\n\nFor this to work, you must configure the DHCP server of your network\nto set the DNS server to the IP of the host where the DNS server is running.\nUsually, your ISP's router can do this but probably easier is to disable completely that DHCP server\nand also self-host the DHCP server.\nThis recipe shows how to do that too.\n\n## Why {#recipes-dnsServer-why}\n\n_You want to hide your DNS queries from your ISP or other prying eyes._\n\nEven if you use HTTPS to access an URL,\nDNS queries are by default made in plain text.\nCrazy, right?\nSo, even if the actual communication is encrypted,\neveryone can see which site you're trying to access.\nUsing DNSSEC means encrypting the traffic to your preferred external DNS server.\nOf course, that server will see what domain names you're trying to resolve,\nbut at least intermediary hops will not be able to anymore.\n\n_You want more control on which DNS queries can be made._\n\nSelf-hosting your own DNS server means you can block some domains or subdomains.\nThis is done in practice by instructing your DNS server\nto fail resolving some domains or subdomains.\nWant to block Facebook for every host in the house?\nThat's the way to go.\n\nSome routers allow this level of fine-tuning but if not,\nself-hosting your own DNS server is the way to go.\n\n## Drawbacks {#recipes-dnsServer-drawbacks}\n\nAlthough it has some nice advantages,\nself-hosting your own DNS server has one major drawback:\nif it goes down, the whole household will be impacted.\nBy experience, it takes up to 5 minutes for others to notice something is wrong with internet.\n\nSo be wary when you deploy a new config.\n\n## Recipe {#recipes-dnsServer-recipe}\n\nThe following snippet:\n\n- Opens UDP port 53 in the firewall which is the ubiquitous (and hardcoded, crazy I know) port for DNS queries.\n- Disables the default DNS resolver.\n- Sets up dnsmasq as the DNS server.\n- Optionally sets up dnsmasq as the DHCP server.\n- Answers all DNS requests to your domain with the internal IP of the server.\n- Forwards all other DNS requests to an external DNS server using DNSSEC.\n  This is done using [stubby][].\n\n[stubby]: https://dnsprivacy.org/dns_privacy_daemon_-_stubby/\n\nFor more information about options, read the dnsmasq [manual][].\n\n[manual]: https://dnsmasq.org/docs/dnsmasq-man.html\n\n```nix\nlet\n  # Replace these values with what matches your network.\n  domain = \"example.com\";\n  serverIP = \"192.168.1.30\";\n\n  # This port is used internally for dnsmasq to talk to stubby on the loopback interface.\n  # Only change this if that port is already taken.\n  stubbyPort = 53000;\nin\n{\n  networking.firewall.allowedUDPPorts = [ 53 ];\n\n  services.resolved.enable = false;\n  services.dnsmasq = {\n    enable = true;\n    settings = {\n      inherit domain;\n\n      # Redirect queries to the stubby instance.\n      server = [\n        \"127.0.0.1#${stubbyPort}\"\n        \"::1#${stubbyPort}\"\n      ];\n      # We do trust our own instance of stubby\n      # so we can proxy DNSSEC stuff.\n      # I'm not sure how useful this is.\n      proxy-dnssec = true;\n\n      # Log all queries.\n      # This produces a lot of log lines\n      # and looking at those can be scary!\n      log-queries = true;\n\n      # Do not look at /etc/resolv.conf\n      no-resolv = true;\n\n      # Do not forward externally reverse DNS lookups for internal IPs.\n      bogus-priv = true;\n\n      address = [\n        \"/.${domain}/${serverIP}\"\n        # You can redirect anything anywhere too.\n        \"/pikvm.${domain}/192.168.1.31\"\n      ];\n    };\n  };\n\n  services.stubby = {\n    enable = true;\n    # It's a bit weird but default values comes from the examples settings hosted at\n    # https://github.com/getdnsapi/stubby/blob/develop/stubby.yml.example\n    settings = pkgs.stubby.passthru.settingsExample // {\n      listen_addresses = [\n        \"127.0.0.1@${stubbyPort}\"\n        \"0::1@${stubbyPort}\"\n      ];\n\n      # For more example of good DNS resolvers,\n      # head to https://dnsprivacy.org/public_resolvers/\n      #\n      # The digest comes from https://nixos.wiki/wiki/Encrypted_DNS#Stubby\n      upstream_recursive_servers = [\n        {\n          address_data = \"9.9.9.9\";\n          tls_auth_name = \"dns.quad9.net\";\n          tls_pubkey_pinset = [\n            {\n              digest = \"sha256\";\n              value = \"i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I=\";\n            }\n          ];\n        }\n        {\n          address_data = \"149.112.112.112\";\n          tls_auth_name = \"dns.quad9.net\";\n          tls_pubkey_pinset = [\n            {\n              digest = \"sha256\";\n              value = \"i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I=\";\n            }\n          ];\n        }\n      ];\n    };\n  };\n}\n```\n\nOptionally, to use dnsmasq as the DHCP server too,\nuse the following snippet:\n\n```nix\nservices.dnsmasq = {\n  settings = {\n    # When switching DNS server, accept old leases from previous server.\n    dhcp-authoritative = true;\n\n    # Adapt to your needs\n    # <ip-from>,<ip-to>,<mask>,<lease-ttl>\n    dhcp-range = \"192.168.1.101,192.168.1.150,255.255.255.0,6h\";\n\n    # Static DNS leases if needed.\n    # Choose an IP outside of the DHCP range\n    # <mac-address>,<DNS name>,<ip>,<lease-ttl>\n    dhcp-host = [\n      \"12:34:56:78:9a:bc,server,192.168.1.50,infinite\"\n    ];\n\n    # Set default route to the router that can acccess the internet.\n    dhcp-option = [\n      \"3,192.168.1.1\"\n    ];\n  };\n};\n```\n"
  },
  {
    "path": "docs/recipes/exposeService.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Expose a service {#recipes-exposeService}\n\nLet's see how one can use most of the blocks provided by SelfHostBlocks to make a service\naccessible through a reverse proxy with LDAP and SSO integration as well as backing up\nthis service and creating a ZFS dataset to store the service's data.\n\nWe'll use an hypothetical well made service found under `services.awesome` as our example.\nWe're purposely not using a real service to avoid needing to deal with uninteresting particularities.\n\n## Service setup {#recipes-exposeService-service}\n\nLet's say our domain name is `example.com`,\nand we want to reach our service under the `awesome` subdomain:\n\n```nix\nlet\n  domain = \"example.com\";\n  subdomain = \"awesome\";\n  fqdn = \"${subdomain}.${domain}\";\n  listenPort = 9000;\n  dataDir = \"/var/lib/awesome\";\n  ldapGroup = \"awesome_user\";\nin\n```\n\nWe then `enable` the service and explicitly set the `listenPort` and `dataDir`,\nassuming those options exist:\n\n```nix\nservices.awesome = {\n  enable = true;\n  inherit dataDir listenPort;\n};\n```\n\n## SSL Certificate {#recipes-exposeService-ssl}\n\nRequesting an SSL certificate from Let's Encrypt is done by adding an entry to\nthe `extraDomains` option:\n\n```nix\nshb.certs.certs.letsencrypt.${domain}.extraDomains = [ fqdn ];\n```\n\nThis assumes the `shb.certs` block has been configured:\n\n```nix\nshb.certs.certs.letsencrypt.${domain} = {\n  inherit domain;\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"admin@${domain}\";\n};\n```\n\n## LDAP group {#recipes-exposeService-ldap}\n\nWe want only users of the group `calibre_user` to be able to access this subdomain.\nThe following snippet creates the LDAP group:\n\n```nix\nshb.lldap.ensureGroups = {\n  calibre_user = {};\n};\n```\n\n## Reverse Proxy with Forward Auth {#recipes-exposeService-nginx}\n\nIf our service does not integrate with OIDC, we can still protect it with SSO\nwith forward authentication by letting the reverse proxy handle authentication.\nThis is done by adding an entry to `shb.nginx.vhosts`:\n\n```nix\nshb.nginx.vhosts = [\n  {\n    inherit subdomain domain;\n    ssl = config.shb.certs.certs.letsencrypt.${domain};\n    upstream = \"http://127.0.0.1:${toString listenPort}\";\n    authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    autheliaRules = [{\n      policy = \"one_factor\";\n      subject = [ \"group:${ldapGroup}\" ];\n    }];\n  }\n];\n```\n\n## ZFS support {#recipes-exposeService-zfs}\n\nIf you use ZFS, you can use SelfHostBlocks to create a dataset for you:\n\n```nix\nshb.zfs.datasets.\"safe/awesome\".path = config.services.awesome.dataDir;\n```\n\n## Debugging {#recipes-exposeService-debug}\n\nUsually, the log level of the service can be increased with some option they provide.\n\nWith SelfHostBlocks, you can also introspect any HTTP service by adding an\n`mitmdump` instance between the reverse proxy and the `awesome` service:\n\n```nix\nshb.mitmdump.awesome = {\n  inherit listenPort;\n  upstreamPort = listenPort + 1;\n};\nservices.awesome.listenPort = lib.mkForce (listenPort + 1);\n```\n\nThis creates a `mitmdump-awesome.service` systemd service which prints the requests' and responses' headers and bodies.\n\n## Backup {#recipes-exposeService-backup}\n\nThe following snippet uses the `shb.restic` block to backup the `services.awesome.dataDir` directory:\n\n```nix\nshb.restic.instances.awesome = {\n  request.user = \"awesome\";\n  request.sourceDirectories = [ dataDir ];\n  settings.enable = true;\n  settings.passphrase.result = config.shb.sops.secret.awesome.result;\n  settings.repository.path = config.services.awesome.dataDir;\n};\n\nshb.sops.secret.\"awesome\" = {\n  request = config.shb.restic.instances.awesome.settings.passphrase.request;\n};\n```\n\n## Impermanence {#recipes-exposeService-impermanence}\n\nTo save the data folder in an impermanence setup, add:\n\n```nix\n{\n  shb.zfs.datasets.\"safe/awesome\".path = config.services.awesome.dataDir;\n}\n```\n\n## Application Dashboard {#recipes-exposeService-applicationdashboard}\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.MyServices.services.Awesome = {\n    sortOrder = 1;\n    dashboard.request = {\n      externalUrl = \"https://${fqdn}\";\n      internalUrl = \"http://127.0.0.1:${toString listenPort}\";\n    };\n  };\n}\n```\n"
  },
  {
    "path": "docs/recipes/serveStaticPages.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Serve Static Pages {#recipes-serveStaticPages}\n\nThis recipe shows how to use SelfHostBlocks blocks to serve static web pages using the Nginx reverse proxy with SSL termination.\n\nIn 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.\n\n```nix\nlet\n  name = \"my-website\";\n  subdomain = name;\n  domain = \"example.com\";\n  fqdn = \"${subdomain}.${domain}\";\n  user = \"me\";\nin\n```\n\nWe also assume the static web pages are owned and updated by the user named `me`.\n\n## ZFS dataset {#recipes-serveStaticPages-zfs}\n\nWe can create a ZFS dataset with:\n\n```nix\nshb.zfs.datasets.\"safe/${name}\".path = \"/srv/${name}\";\n```\n\n## SSL Certificate {#recipes-serveStaticPages-ssl}\n\nRequesting an SSL certificate from Let's Encrypt is done by adding an entry to\nthe `extraDomains` option:\n\n```nix\nshb.certs.certs.letsencrypt.${domain}.extraDomains = [ fqdn ];\n```\n\nThis assumes the `shb.certs` block has been configured:\n\n```nix\nshb.certs.certs.letsencrypt.${domain} = {\n  inherit domain;\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"admin@${domain}\";\n};\n```\n\n## Reverse Proxy {#recipes-serveStaticPages-nginx}\n\nFirst, we make the parent directory owned by the user which will upload them and `nginx`:\n\n```nix\nsystemd.tmpfiles.rules = lib.mkBefore [\n  \"d '/srv/${name}' 0750 ${user} nginx - -\"\n];\n```\n\nNow, we can setup nginx. The following snippet serves files from the `/srv/${name}/` directory.\n\n```nix\nservices.nginx.enable = true;\n\nservices.nginx.virtualHosts.\"skarabox.${domain}\" = {\n  forceSSL = true;\n  sslCertificate = config.shb.certs.certs.letsencrypt.\"${domain}\".paths.cert;\n  sslCertificateKey = config.shb.certs.certs.letsencrypt.\"${domain}\".paths.key;\n  locations.\"/\" = {\n    root = \"/srv/${name}/\";\n    extraConfig = ''\n      add_header Strict-Transport-Security \"max-age=63072000; includeSubDomains; preload\";\n      add_header Cache-Control \"max-age=604800, stale-while-revalidate=86400, stale-if-error=86400, must-revalidate, public\";\n    '';\n  };\n};\n```\n"
  },
  {
    "path": "docs/recipes.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Recipes {#recipes}\n\nThis section of the manual gives you easy to follow recipes for common use cases. \n\n```{=include=} chapters html:into-file=//recipes-dnsServer.html\nrecipes/dnsServer.md\n```\n\n```{=include=} chapters html:into-file=//recipes-exposeService.html\nrecipes/exposeService.md\n```\n\n```{=include=} chapters html:into-file=//recipes-serveStaticPages.html\nrecipes/serveStaticPages.md\n```\n"
  },
  {
    "path": "docs/redirects.json",
    "content": "{\n  \"adding-new-service-documentation\": [\n    \"service-implementation-guide.html#adding-new-service-documentation\"\n  ],\n  \"all-options\": [\n    \"options.html#all-options\"\n  ],\n  \"analyze-existing-services\": [\n    \"service-implementation-guide.html#analyze-existing-services\"\n  ],\n  \"api-health-check\": [\n    \"service-implementation-guide.html#api-health-check\"\n  ],\n  \"authentication-integration\": [\n    \"service-implementation-guide.html#authentication-integration\"\n  ],\n  \"authentication-integration-pitfalls\": [\n    \"service-implementation-guide.html#authentication-integration-pitfalls\"\n  ],\n  \"automated-redirect-generation\": [\n    \"service-implementation-guide.html#automated-redirect-generation\"\n  ],\n  \"best-practices-summary\": [\n    \"service-implementation-guide.html#best-practices-summary\"\n  ],\n  \"block-ssl\": [\n    \"blocks-ssl.html#block-ssl\"\n  ],\n  \"block-ssl-debug\": [\n    \"blocks-ssl.html#block-ssl-debug\"\n  ],\n  \"block-ssl-impl-lets-encrypt\": [\n    \"blocks-ssl.html#block-ssl-impl-lets-encrypt\"\n  ],\n  \"block-ssl-impl-self-signed\": [\n    \"blocks-ssl.html#block-ssl-impl-self-signed\"\n  ],\n  \"block-ssl-options\": [\n    \"blocks-ssl.html#block-ssl-options\"\n  ],\n  \"block-ssl-tests\": [\n    \"blocks-ssl.html#block-ssl-tests\"\n  ],\n  \"block-ssl-usage\": [\n    \"blocks-ssl.html#block-ssl-usage\"\n  ],\n  \"blocks\": [\n    \"blocks.html#blocks\"\n  ],\n  \"blocks-authelia\": [\n    \"blocks-authelia.html#blocks-authelia\"\n  ],\n  \"blocks-authelia-forward-auth\": [\n    \"blocks-authelia.html#blocks-authelia-forward-auth\"\n  ],\n  \"blocks-authelia-oidc\": [\n    \"blocks-authelia.html#blocks-authelia-oidc\"\n  ],\n  \"blocks-authelia-options\": [\n    \"blocks-authelia.html#blocks-authelia-options\"\n  ],\n  \"blocks-authelia-options-shb.authelia.autheliaUser\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.autheliaUser\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dashboard\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dashboard.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dashboard.request.externalUrl\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request.externalUrl\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dashboard.request.internalUrl\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request.internalUrl\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dashboard.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.dcdomain\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.dcdomain\"\n  ],\n  \"blocks-authelia-options-shb.authelia.debug\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.debug\"\n  ],\n  \"blocks-authelia-options-shb.authelia.domain\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.domain\"\n  ],\n  \"blocks-authelia-options-shb.authelia.enable\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.enable\"\n  ],\n  \"blocks-authelia-options-shb.authelia.extraDefinitions\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraDefinitions\"\n  ],\n  \"blocks-authelia-options-shb.authelia.extraOidcAuthorizationPolicies\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcAuthorizationPolicies\"\n  ],\n  \"blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies\"\n  ],\n  \"blocks-authelia-options-shb.authelia.extraOidcScopes\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcScopes\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ldapHostname\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ldapHostname\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ldapPort\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ldapPort\"\n  ],\n  \"blocks-authelia-options-shb.authelia.mount\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.mount\"\n  ],\n  \"blocks-authelia-options-shb.authelia.mount.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.mount.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.mountRedis\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.mountRedis\"\n  ],\n  \"blocks-authelia-options-shb.authelia.mountRedis.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.mountRedis.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.authorization_policy\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.authorization_policy\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.claims_policy\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.claims_policy\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.client_id\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_id\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.client_name\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_name\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.client_secret\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.client_secret.source\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret.source\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.client_secret.transform\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret.transform\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.public\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.public\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.redirect_uris\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.redirect_uris\"\n  ],\n  \"blocks-authelia-options-shb.authelia.oidcClients._.scopes\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.scopes\"\n  ],\n  \"blocks-authelia-options-shb.authelia.port\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.port\"\n  ],\n  \"blocks-authelia-options-shb.authelia.rules\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.rules\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.jwtSecret.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.sessionSecret.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.group\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.group\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.mode\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.mode\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.owner\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.owner\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.restartUnits\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.restartUnits\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result\"\n  ],\n  \"blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result.path\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result.path\"\n  ],\n  \"blocks-authelia-options-shb.authelia.smtp\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.smtp\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ssl\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ssl.paths\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ssl.paths.cert\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths.cert\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ssl.paths.key\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths.key\"\n  ],\n  \"blocks-authelia-options-shb.authelia.ssl.systemdService\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.systemdService\"\n  ],\n  \"blocks-authelia-options-shb.authelia.subdomain\": [\n    \"blocks-authelia.html#blocks-authelia-options-shb.authelia.subdomain\"\n  ],\n  \"blocks-authelia-shb-oidc\": [\n    \"blocks-authelia.html#blocks-authelia-shb-oidc\"\n  ],\n  \"blocks-authelia-tests\": [\n    \"blocks-authelia.html#blocks-authelia-tests\"\n  ],\n  \"blocks-authelia-troubleshooting\": [\n    \"blocks-authelia.html#blocks-authelia-troubleshooting\"\n  ],\n  \"blocks-authelia-usage-configuration\": [\n    \"blocks-authelia.html#blocks-authelia-usage-configuration\"\n  ],\n  \"blocks-borgbackup\": [\n    \"blocks-borgbackup.html#blocks-borgbackup\"\n  ],\n  \"blocks-borgbackup-contract-provider\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-contract-provider\"\n  ],\n  \"blocks-borgbackup-maintenance\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-maintenance\"\n  ],\n  \"blocks-borgbackup-maintenance-troubleshooting\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-maintenance-troubleshooting\"\n  ],\n  \"blocks-borgbackup-monitoring\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-monitoring\"\n  ],\n  \"blocks-borgbackup-options\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.borgServer\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.borgServer\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.request\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupCmd\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupCmd\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupName\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupName\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.request.restoreCmd\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.restoreCmd\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.request.user\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.user\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.result\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.result.backupService\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result.backupService\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.result.restoreScript\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result.restoreScript\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.consistency\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.consistency\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.enable\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.enable\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.limitUploadKiBs\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.limitUploadKiBs\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.group\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.group\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.mode\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.mode\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.owner\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.owner\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.restartUnits\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.restartUnits\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result.path\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result.path\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.path\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.path\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.source\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.source\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.transform\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.transform\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.timerConfig\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.timerConfig\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.retention\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.retention\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.stateDir\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.stateDir\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.enableDashboard\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.enableDashboard\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.excludePatterns\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.excludePatterns\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.afterBackup\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.afterBackup\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.beforeBackup\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.beforeBackup\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.sourceDirectories\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.sourceDirectories\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.request.user\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.user\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.result\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.result.backupService\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result.backupService\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.result.restoreScript\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result.restoreScript\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.consistency\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.consistency\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.enable\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.enable\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.limitUploadKiBs\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.limitUploadKiBs\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.group\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.group\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.mode\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.mode\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.owner\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.owner\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.restartUnits\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.restartUnits\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result.path\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result.path\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.path\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.path\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.source\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.source\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.transform\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.transform\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.timerConfig\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.timerConfig\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.retention\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.retention\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.stateDir\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.stateDir\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.performance\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.performance.ioPriority\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.ioPriority\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.performance.ioSchedulingClass\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.ioSchedulingClass\"\n  ],\n  \"blocks-borgbackup-options-shb.borgbackup.performance.niceness\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.niceness\"\n  ],\n  \"blocks-borgbackup-tests\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-tests\"\n  ],\n  \"blocks-borgbackup-usage\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-usage\"\n  ],\n  \"blocks-borgbackup-usage-multiple\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-usage-multiple\"\n  ],\n  \"blocks-borgbackup-usage-provider-contract\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-usage-provider-contract\"\n  ],\n  \"blocks-borgbackup-usage-provider-manual\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-usage-provider-manual\"\n  ],\n  \"blocks-borgbackup-usage-provider-remote\": [\n    \"blocks-borgbackup.html#blocks-borgbackup-usage-provider-remote\"\n  ],\n  \"blocks-category-authentication\": [\n    \"blocks.html#blocks-category-authentication\"\n  ],\n  \"blocks-category-backup\": [\n    \"blocks.html#blocks-category-backup\"\n  ],\n  \"blocks-category-database\": [\n    \"blocks.html#blocks-category-database\"\n  ],\n  \"blocks-category-introspection\": [\n    \"blocks.html#blocks-category-introspection\"\n  ],\n  \"blocks-category-network\": [\n    \"blocks.html#blocks-category-network\"\n  ],\n  \"blocks-category-secrets\": [\n    \"blocks.html#blocks-category-secrets\"\n  ],\n  \"blocks-lldap\": [\n    \"blocks-lldap.html#blocks-lldap\"\n  ],\n  \"blocks-lldap-features\": [\n    \"blocks-lldap.html#blocks-lldap-features\"\n  ],\n  \"blocks-lldap-manage-groups\": [\n    \"blocks-lldap.html#blocks-lldap-manage-groups\"\n  ],\n  \"blocks-lldap-manage-users\": [\n    \"blocks-lldap.html#blocks-lldap-manage-users\"\n  ],\n  \"blocks-lldap-options\": [\n    \"blocks-lldap.html#blocks-lldap-options\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.excludePatterns\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.excludePatterns\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.hooks\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.hooks.afterBackup\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks.afterBackup\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.hooks.beforeBackup\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks.beforeBackup\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.sourceDirectories\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.sourceDirectories\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.request.user\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.user\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.result\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.result.backupService\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result.backupService\"\n  ],\n  \"blocks-lldap-options-shb.lldap.backup.result.restoreScript\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result.restoreScript\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dashboard\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dashboard.request\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dashboard.request.externalUrl\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request.externalUrl\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dashboard.request.internalUrl\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request.internalUrl\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dashboard.result\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.result\"\n  ],\n  \"blocks-lldap-options-shb.lldap.dcdomain\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.dcdomain\"\n  ],\n  \"blocks-lldap-options-shb.lldap.debug\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.debug\"\n  ],\n  \"blocks-lldap-options-shb.lldap.domain\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.domain\"\n  ],\n  \"blocks-lldap-options-shb.lldap.enable\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.enable\"\n  ],\n  \"blocks-lldap-options-shb.lldap.enforceGroups\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceGroups\"\n  ],\n  \"blocks-lldap-options-shb.lldap.enforceUserMemberships\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceUserMemberships\"\n  ],\n  \"blocks-lldap-options-shb.lldap.enforceUsers\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceUsers\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroups\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureGroups._name_.name\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups._name_.name\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUserFields._name_.name\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.name\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.email\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.email\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.groups\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.groups\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.id\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.id\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.request\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.request.group\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.group\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.request.mode\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.mode\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.request.owner\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.owner\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.request.restartUnits\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.restartUnits\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.result\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.result\"\n  ],\n  \"blocks-lldap-options-shb.lldap.jwtSecret.result.path\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.result.path\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapPort\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapPort\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.request\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.request.group\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.group\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.request.mode\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.mode\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.request.owner\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.owner\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.request.restartUnits\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.restartUnits\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.result\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.result\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ldapUserPassword.result.path\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.result.path\"\n  ],\n  \"blocks-lldap-options-shb.lldap.mount\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.mount\"\n  ],\n  \"blocks-lldap-options-shb.lldap.mount.path\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.mount.path\"\n  ],\n  \"blocks-lldap-options-shb.lldap.restrictAccessIPRange\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.restrictAccessIPRange\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ssl\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ssl.paths\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ssl.paths.cert\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths.cert\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ssl.paths.key\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths.key\"\n  ],\n  \"blocks-lldap-options-shb.lldap.ssl.systemdService\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.systemdService\"\n  ],\n  \"blocks-lldap-options-shb.lldap.subdomain\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.subdomain\"\n  ],\n  \"blocks-lldap-options-shb.lldap.webUIListenPort\": [\n    \"blocks-lldap.html#blocks-lldap-options-shb.lldap.webUIListenPort\"\n  ],\n  \"blocks-lldap-tests\": [\n    \"blocks-lldap.html#blocks-lldap-tests\"\n  ],\n  \"blocks-lldap-troubleshooting\": [\n    \"blocks-lldap.html#blocks-lldap-troubleshooting\"\n  ],\n  \"blocks-lldap-usage\": [\n    \"blocks-lldap.html#blocks-lldap-usage\"\n  ],\n  \"blocks-lldap-usage-applicationdashboard\": [\n    \"blocks-lldap.html#blocks-lldap-usage-applicationdashboard\"\n  ],\n  \"blocks-lldap-usage-configuration\": [\n    \"blocks-lldap.html#blocks-lldap-usage-configuration\"\n  ],\n  \"blocks-lldap-usage-restrict-access-by-ip\": [\n    \"blocks-lldap.html#blocks-lldap-usage-restrict-access-by-ip\"\n  ],\n  \"blocks-lldap-usage-ssl\": [\n    \"blocks-lldap.html#blocks-lldap-usage-ssl\"\n  ],\n  \"blocks-mitmdump\": [\n    \"blocks-mitmdump.html#blocks-mitmdump\"\n  ],\n  \"blocks-mitmdump-addons\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-addons\"\n  ],\n  \"blocks-mitmdump-addons-logger\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-addons-logger\"\n  ],\n  \"blocks-mitmdump-example\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-example\"\n  ],\n  \"blocks-mitmdump-options\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.addons\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.addons\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.after\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.after\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.enabledAddons\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.enabledAddons\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.extraArgs\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.extraArgs\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.listenHost\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.listenHost\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.listenPort\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.listenPort\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.package\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.package\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.serviceName\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.serviceName\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamHost\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamHost\"\n  ],\n  \"blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamPort\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamPort\"\n  ],\n  \"blocks-mitmdump-tests\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-tests\"\n  ],\n  \"blocks-mitmdump-usage\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-usage\"\n  ],\n  \"blocks-mitmdump-usage-anywhere\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-usage-anywhere\"\n  ],\n  \"blocks-mitmdump-usage-https\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-usage-https\"\n  ],\n  \"blocks-mitmdump-usage-logging\": [\n    \"blocks-mitmdump.html#blocks-mitmdump-usage-logging\"\n  ],\n  \"blocks-monitoring\": [\n    \"blocks-monitoring.html#blocks-monitoring\"\n  ],\n  \"blocks-monitoring-backup\": [\n    \"blocks-monitoring.html#blocks-monitoring-backup\"\n  ],\n  \"blocks-monitoring-backup-alerts\": [\n    \"blocks-monitoring.html#blocks-monitoring-backup-alerts\"\n  ],\n  \"blocks-monitoring-backup-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-backup-dashboard\"\n  ],\n  \"blocks-monitoring-budget-alerts\": [\n    \"blocks-monitoring.html#blocks-monitoring-budget-alerts\"\n  ],\n  \"blocks-monitoring-deluge-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-deluge-dashboard\"\n  ],\n  \"blocks-monitoring-error-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-error-dashboard\"\n  ],\n  \"blocks-monitoring-nextcloud-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-nextcloud-dashboard\"\n  ],\n  \"blocks-monitoring-options\": [\n    \"blocks-monitoring.html#blocks-monitoring-options\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.request.group\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.group\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.request.mode\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.mode\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.request.owner\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.owner\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.request.restartUnits\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.restartUnits\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.adminPassword.result.path\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.result.path\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.contactPoints\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.contactPoints\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboard.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboard.request.externalUrl\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request.externalUrl\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboard.request.internalUrl\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request.internalUrl\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboard.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.dashboards\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboards\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.debugLog\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.debugLog\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.domain\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.domain\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.enable\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.enable\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.grafanaPort\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.grafanaPort\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ldap\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ldap.adminGroup\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap.adminGroup\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ldap.userGroup\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap.userGroup\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.lokiMajorVersion\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.lokiMajorVersion\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.lokiPort\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.lokiPort\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.orgId\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.orgId\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.prometheusPort\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.prometheusPort\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.externalUrl\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.externalUrl\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.internalUrl\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.internalUrl\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.enable\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.enable\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.scrutiny.subdomain\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.request.group\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.group\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.request.mode\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.mode\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.request.owner\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.owner\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.request.restartUnits\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.restartUnits\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.secretKey.result.path\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.result.path\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.from_address\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.from_address\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.from_name\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.from_name\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.host\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.host\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.passwordFile\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.passwordFile\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.port\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.port\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.smtp.username\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.username\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ssl\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ssl.paths\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ssl.paths.cert\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths.cert\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ssl.paths.key\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths.key\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.ssl.systemdService\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.systemdService\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.authEndpoint\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.authEndpoint\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.authorization_policy\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.authorization_policy\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.clientID\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.clientID\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.enable\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.enable\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.group\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.group\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.mode\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.mode\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.owner\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.owner\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.restartUnits\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.restartUnits\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result.path\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result.path\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.group\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.group\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.mode\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.mode\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.owner\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.owner\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.restartUnits\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.restartUnits\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result.path\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result.path\"\n  ],\n  \"blocks-monitoring-options-shb.monitoring.subdomain\": [\n    \"blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.subdomain\"\n  ],\n  \"blocks-monitoring-performance-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-performance-dashboard\"\n  ],\n  \"blocks-monitoring-provisioning\": [\n    \"blocks-monitoring.html#blocks-monitoring-provisioning\"\n  ],\n  \"blocks-monitoring-ssl\": [\n    \"blocks-monitoring.html#blocks-monitoring-ssl\"\n  ],\n  \"blocks-monitoring-ssl-alerts\": [\n    \"blocks-monitoring.html#blocks-monitoring-ssl-alerts\"\n  ],\n  \"blocks-monitoring-ssl-dashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-ssl-dashboard\"\n  ],\n  \"blocks-monitoring-usage\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage\"\n  ],\n  \"blocks-monitoring-usage-applicationdashboard\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage-applicationdashboard\"\n  ],\n  \"blocks-monitoring-usage-configuration\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage-configuration\"\n  ],\n  \"blocks-monitoring-usage-log-optimization\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage-log-optimization\"\n  ],\n  \"blocks-monitoring-usage-scrutiny\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage-scrutiny\"\n  ],\n  \"blocks-monitoring-usage-smtp\": [\n    \"blocks-monitoring.html#blocks-monitoring-usage-smtp\"\n  ],\n  \"blocks-nginx\": [\n    \"blocks-nginx.html#blocks-nginx\"\n  ],\n  \"blocks-nginx-options\": [\n    \"blocks-nginx.html#blocks-nginx-options\"\n  ],\n  \"blocks-nginx-options-shb.nginx.accessLog\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.accessLog\"\n  ],\n  \"blocks-nginx-options-shb.nginx.debugLog\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.debugLog\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.authEndpoint\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.authEndpoint\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.autheliaRules\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.autheliaRules\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.domain\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.domain\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.extraConfig\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.extraConfig\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.ssl\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.ssl.paths\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.cert\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.cert\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.key\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.key\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.ssl.systemdService\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.systemdService\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.subdomain\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.subdomain\"\n  ],\n  \"blocks-nginx-options-shb.nginx.vhosts._.upstream\": [\n    \"blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.upstream\"\n  ],\n  \"blocks-nginx-usage\": [\n    \"blocks-nginx.html#blocks-nginx-usage\"\n  ],\n  \"blocks-nginx-usage-accesslog\": [\n    \"blocks-nginx.html#blocks-nginx-usage-accesslog\"\n  ],\n  \"blocks-nginx-usage-debuglog\": [\n    \"blocks-nginx.html#blocks-nginx-usage-debuglog\"\n  ],\n  \"blocks-nginx-usage-extraconfig\": [\n    \"blocks-nginx.html#blocks-nginx-usage-extraconfig\"\n  ],\n  \"blocks-nginx-usage-forwardauth\": [\n    \"blocks-nginx.html#blocks-nginx-usage-forwardauth\"\n  ],\n  \"blocks-nginx-usage-shbforwardauth\": [\n    \"blocks-nginx.html#blocks-nginx-usage-shbforwardauth\"\n  ],\n  \"blocks-nginx-usage-ssl\": [\n    \"blocks-nginx.html#blocks-nginx-usage-ssl\"\n  ],\n  \"blocks-nginx-usage-upstream\": [\n    \"blocks-nginx.html#blocks-nginx-usage-upstream\"\n  ],\n  \"blocks-postgresql\": [\n    \"blocks-postgresql.html#blocks-postgresql\"\n  ],\n  \"blocks-postgresql-contract-databasebackup\": [\n    \"blocks-postgresql.html#blocks-postgresql-contract-databasebackup\"\n  ],\n  \"blocks-postgresql-contract-databasebackup-all\": [\n    \"blocks-postgresql.html#blocks-postgresql-contract-databasebackup-all\"\n  ],\n  \"blocks-postgresql-ensures\": [\n    \"blocks-postgresql.html#blocks-postgresql-ensures\"\n  ],\n  \"blocks-postgresql-options\": [\n    \"blocks-postgresql.html#blocks-postgresql-options\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.request\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.request.backupCmd\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.backupCmd\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.request.backupName\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.backupName\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.request.restoreCmd\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.restoreCmd\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.request.user\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.user\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.result\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.result.backupService\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result.backupService\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.databasebackup.result.restoreScript\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result.restoreScript\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.debug\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.debug\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.enableTCPIP\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.enableTCPIP\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.ensures\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.ensures._.database\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.database\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.ensures._.passwordFile\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.passwordFile\"\n  ],\n  \"blocks-postgresql-options-shb.postgresql.ensures._.username\": [\n    \"blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.username\"\n  ],\n  \"blocks-postgresql-tests\": [\n    \"blocks-postgresql.html#blocks-postgresql-tests\"\n  ],\n  \"blocks-postgresql-usage\": [\n    \"blocks-postgresql.html#blocks-postgresql-usage\"\n  ],\n  \"blocks-restic\": [\n    \"blocks-restic.html#blocks-restic\"\n  ],\n  \"blocks-restic-contract-provider\": [\n    \"blocks-restic.html#blocks-restic-contract-provider\"\n  ],\n  \"blocks-restic-maintenance\": [\n    \"blocks-restic.html#blocks-restic-maintenance\"\n  ],\n  \"blocks-restic-maintenance-troubleshooting\": [\n    \"blocks-restic.html#blocks-restic-maintenance-troubleshooting\"\n  ],\n  \"blocks-restic-monitoring\": [\n    \"blocks-restic.html#blocks-restic-monitoring\"\n  ],\n  \"blocks-restic-options\": [\n    \"blocks-restic.html#blocks-restic-options\"\n  ],\n  \"blocks-restic-options-shb.restic.databases\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.request\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.request.backupCmd\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.backupCmd\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.request.backupName\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.backupName\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.request.restoreCmd\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.restoreCmd\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.request.user\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.user\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.result\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.result.backupService\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result.backupService\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.result.restoreScript\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result.restoreScript\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.enable\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.enable\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.limitDownloadKiBs\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.limitDownloadKiBs\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.limitUploadKiBs\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.limitUploadKiBs\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.group\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.group\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.mode\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.mode\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.owner\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.owner\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.restartUnits\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.restartUnits\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result.path\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result.path\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository.path\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.path\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.source\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.source\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.transform\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.transform\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.repository.timerConfig\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.timerConfig\"\n  ],\n  \"blocks-restic-options-shb.restic.databases._name_.settings.retention\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.retention\"\n  ],\n  \"blocks-restic-options-shb.restic.enableDashboard\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.enableDashboard\"\n  ],\n  \"blocks-restic-options-shb.restic.instances\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.excludePatterns\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.excludePatterns\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.hooks\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.hooks.afterBackup\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks.afterBackup\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.hooks.beforeBackup\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks.beforeBackup\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.sourceDirectories\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.sourceDirectories\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.request.user\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.user\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.result\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.result.backupService\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result.backupService\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.result.restoreScript\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result.restoreScript\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.enable\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.enable\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.limitDownloadKiBs\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.limitDownloadKiBs\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.limitUploadKiBs\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.limitUploadKiBs\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.group\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.group\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.mode\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.mode\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.owner\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.owner\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.restartUnits\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.restartUnits\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result.path\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result.path\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository.path\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.path\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.source\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.source\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.transform\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.transform\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.repository.timerConfig\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.timerConfig\"\n  ],\n  \"blocks-restic-options-shb.restic.instances._name_.settings.retention\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.retention\"\n  ],\n  \"blocks-restic-options-shb.restic.performance\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.performance\"\n  ],\n  \"blocks-restic-options-shb.restic.performance.ioPriority\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.performance.ioPriority\"\n  ],\n  \"blocks-restic-options-shb.restic.performance.ioSchedulingClass\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.performance.ioSchedulingClass\"\n  ],\n  \"blocks-restic-options-shb.restic.performance.niceness\": [\n    \"blocks-restic.html#blocks-restic-options-shb.restic.performance.niceness\"\n  ],\n  \"blocks-restic-tests\": [\n    \"blocks-restic.html#blocks-restic-tests\"\n  ],\n  \"blocks-restic-usage\": [\n    \"blocks-restic.html#blocks-restic-usage\"\n  ],\n  \"blocks-restic-usage-multiple\": [\n    \"blocks-restic.html#blocks-restic-usage-multiple\"\n  ],\n  \"blocks-restic-usage-provider-contract\": [\n    \"blocks-restic.html#blocks-restic-usage-provider-contract\"\n  ],\n  \"blocks-restic-usage-provider-manual\": [\n    \"blocks-restic.html#blocks-restic-usage-provider-manual\"\n  ],\n  \"blocks-restic-usage-provider-remote\": [\n    \"blocks-restic.html#blocks-restic-usage-provider-remote\"\n  ],\n  \"blocks-sops\": [\n    \"blocks-sops.html#blocks-sops\"\n  ],\n  \"blocks-sops-contract-provider\": [\n    \"blocks-sops.html#blocks-sops-contract-provider\"\n  ],\n  \"blocks-sops-options\": [\n    \"blocks-sops.html#blocks-sops-options\"\n  ],\n  \"blocks-sops-options-shb.sops.secret\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.request\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.request.group\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.group\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.request.mode\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.mode\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.request.owner\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.owner\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.request.restartUnits\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.restartUnits\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.result\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.result\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.result.path\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.result.path\"\n  ],\n  \"blocks-sops-options-shb.sops.secret._name_.settings\": [\n    \"blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.settings\"\n  ],\n  \"blocks-sops-usage\": [\n    \"blocks-sops.html#blocks-sops-usage\"\n  ],\n  \"blocks-sops-usage-manual\": [\n    \"blocks-sops.html#blocks-sops-usage-manual\"\n  ],\n  \"blocks-sops-usage-requester\": [\n    \"blocks-sops.html#blocks-sops-usage-requester\"\n  ],\n  \"blocks-ssl-debug-lets-encrypt\": [\n    \"blocks-ssl.html#blocks-ssl-debug-lets-encrypt\"\n  ],\n  \"blocks-ssl-monitoring\": [\n    \"blocks-ssl.html#blocks-ssl-monitoring\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned._name_.name\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.name\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.cert\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.cert\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.key\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.key\"\n  ],\n  \"blocks-ssl-options-shb.certs.cas.selfsigned._name_.systemdService\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.systemdService\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.additionalEnvironment\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.additionalEnvironment\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.adminEmail\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.adminEmail\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.afterAndWants\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.afterAndWants\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.credentialsFile\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.credentialsFile\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.debug\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.debug\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsProvider\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsProvider\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsResolver\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsResolver\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.domain\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.domain\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.extraDomains\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.extraDomains\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.group\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.group\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.makeAvailableToUser\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.makeAvailableToUser\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.cert\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.cert\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.key\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.key\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.reloadServices\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.reloadServices\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.stagingServer\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.stagingServer\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.letsencrypt._name_.systemdService\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.systemdService\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.cert\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.cert\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.key\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.key\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.systemdService\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.systemdService\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.domain\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.domain\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.extraDomains\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.extraDomains\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.group\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.group\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.cert\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.cert\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.key\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.key\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.reloadServices\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.reloadServices\"\n  ],\n  \"blocks-ssl-options-shb.certs.certs.selfsigned._name_.systemdService\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.systemdService\"\n  ],\n  \"blocks-ssl-options-shb.certs.enableDashboard\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.enableDashboard\"\n  ],\n  \"blocks-ssl-options-shb.certs.systemdService\": [\n    \"blocks-ssl.html#blocks-ssl-options-shb.certs.systemdService\"\n  ],\n  \"build-time-validation\": [\n    \"service-implementation-guide.html#build-time-validation\"\n  ],\n  \"check-nixos-integration\": [\n    \"service-implementation-guide.html#check-nixos-integration\"\n  ],\n  \"common-pitfalls-and-solutions\": [\n    \"service-implementation-guide.html#common-pitfalls-and-solutions\"\n  ],\n  \"complete-shb-service\": [\n    \"service-implementation-guide.html#complete-shb-service\"\n  ],\n  \"complete-workflow\": [\n    \"service-implementation-guide.html#complete-workflow\"\n  ],\n  \"configuration-issues\": [\n    \"service-implementation-guide.html#configuration-issues\"\n  ],\n  \"configuration-management\": [\n    \"service-implementation-guide.html#configuration-management\"\n  ],\n  \"contract-backup\": [\n    \"contracts-backup.html#contract-backup\"\n  ],\n  \"contract-backup-options\": [\n    \"contracts-backup.html#contract-backup-options\"\n  ],\n  \"contract-backup-providers\": [\n    \"contracts-backup.html#contract-backup-providers\"\n  ],\n  \"contract-backup-requesters\": [\n    \"contracts-backup.html#contract-backup-requesters\"\n  ],\n  \"contract-backup-usage\": [\n    \"contracts-backup.html#contract-backup-usage\"\n  ],\n  \"contract-dashboard\": [\n    \"contracts-dashboard.html#contract-dashboard\"\n  ],\n  \"contract-dashboard-options\": [\n    \"contracts-dashboard.html#contract-dashboard-options\"\n  ],\n  \"contract-dashboard-providers\": [\n    \"contracts-dashboard.html#contract-dashboard-providers\"\n  ],\n  \"contract-databasebackup\": [\n    \"contracts-databasebackup.html#contract-databasebackup\"\n  ],\n  \"contract-databasebackup-options\": [\n    \"contracts-databasebackup.html#contract-databasebackup-options\"\n  ],\n  \"contract-databasebackup-providers\": [\n    \"contracts-databasebackup.html#contract-databasebackup-providers\"\n  ],\n  \"contract-databasebackup-requesters\": [\n    \"contracts-databasebackup.html#contract-databasebackup-requesters\"\n  ],\n  \"contract-databasebackup-usage\": [\n    \"contracts-databasebackup.html#contract-databasebackup-usage\"\n  ],\n  \"contract-secret\": [\n    \"contracts-secret.html#contract-secret\"\n  ],\n  \"contract-secret-motivation\": [\n    \"contracts-secret.html#contract-secret-motivation\"\n  ],\n  \"contract-secret-options\": [\n    \"contracts-secret.html#contract-secret-options\"\n  ],\n  \"contract-secret-usage\": [\n    \"contracts-secret.html#contract-secret-usage\"\n  ],\n  \"contract-secret-usage-enduser\": [\n    \"contracts-secret.html#contract-secret-usage-enduser\"\n  ],\n  \"contract-secret-usage-provider\": [\n    \"contracts-secret.html#contract-secret-usage-provider\"\n  ],\n  \"contract-secret-usage-requester\": [\n    \"contracts-secret.html#contract-secret-usage-requester\"\n  ],\n  \"contract-ssl\": [\n    \"contracts-ssl.html#contract-ssl\"\n  ],\n  \"contract-ssl-impl-custom\": [\n    \"contracts-ssl.html#contract-ssl-impl-custom\"\n  ],\n  \"contract-ssl-impl-shb\": [\n    \"contracts-ssl.html#contract-ssl-impl-shb\"\n  ],\n  \"contract-ssl-options\": [\n    \"contracts-ssl.html#contract-ssl-options\"\n  ],\n  \"contract-ssl-usage\": [\n    \"contracts-ssl.html#contract-ssl-usage\"\n  ],\n  \"contracts\": [\n    \"contracts.html#contracts\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.excludePatterns\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.excludePatterns\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.hooks\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.hooks.afterBackup\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks.afterBackup\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.hooks.beforeBackup\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks.beforeBackup\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.sourceDirectories\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.sourceDirectories\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.request.user\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.user\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.result\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.result\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.result.backupService\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.result.backupService\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.result.restoreScript\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.result.restoreScript\"\n  ],\n  \"contracts-backup-options-shb.contracts.backup.settings\": [\n    \"contracts-backup.html#contracts-backup-options-shb.contracts.backup.settings\"\n  ],\n  \"contracts-concept\": [\n    \"contracts.html#contracts-concept\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard.request\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard.request.externalUrl\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request.externalUrl\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard.request.internalUrl\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request.internalUrl\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard.result\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.result\"\n  ],\n  \"contracts-dashboard-options-shb.contracts.dashboard.settings\": [\n    \"contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.settings\"\n  ],\n  \"contracts-dashboard-usage\": [\n    \"contracts-dashboard.html#contracts-dashboard-usage\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.request\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.request.backupCmd\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.backupCmd\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.request.backupName\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.backupName\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.request.restoreCmd\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.restoreCmd\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.request.user\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.user\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.result\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.result.backupService\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result.backupService\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.result.restoreScript\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result.restoreScript\"\n  ],\n  \"contracts-databasebackup-options-shb.contracts.databasebackup.settings\": [\n    \"contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.settings\"\n  ],\n  \"contracts-nixpkgs\": [\n    \"contracts.html#contracts-nixpkgs\"\n  ],\n  \"contracts-provided\": [\n    \"contracts.html#contracts-provided\"\n  ],\n  \"contracts-schema\": [\n    \"contracts.html#contracts-schema\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.request\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.request\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.request.group\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.group\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.request.mode\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.mode\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.request.owner\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.owner\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.request.restartUnits\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.restartUnits\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.result\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.result\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.result.path\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.result.path\"\n  ],\n  \"contracts-secret-options-shb.contracts.secret.settings\": [\n    \"contracts-secret.html#contracts-secret-options-shb.contracts.secret.settings\"\n  ],\n  \"contracts-ssl-options-shb.contracts.ssl\": [\n    \"contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl\"\n  ],\n  \"contracts-ssl-options-shb.contracts.ssl.paths\": [\n    \"contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths\"\n  ],\n  \"contracts-ssl-options-shb.contracts.ssl.paths.cert\": [\n    \"contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths.cert\"\n  ],\n  \"contracts-ssl-options-shb.contracts.ssl.paths.key\": [\n    \"contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths.key\"\n  ],\n  \"contracts-ssl-options-shb.contracts.ssl.systemdService\": [\n    \"contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.systemdService\"\n  ],\n  \"contracts-test\": [\n    \"contracts.html#contracts-test\"\n  ],\n  \"contracts-videos\": [\n    \"contracts.html#contracts-videos\"\n  ],\n  \"contracts-why\": [\n    \"contracts.html#contracts-why\"\n  ],\n  \"contributing\": [\n    \"contributing.html#contributing\"\n  ],\n  \"contributing-chat\": [\n    \"contributing.html#contributing-chat\"\n  ],\n  \"contributing-code\": [\n    \"contributing.html#contributing-code\"\n  ],\n  \"contributing-debug-tests\": [\n    \"contributing.html#contributing-debug-tests\"\n  ],\n  \"contributing-deploy-colmena\": [\n    \"contributing.html#contributing-deploy-colmena\"\n  ],\n  \"contributing-diff\": [\n    \"contributing.html#contributing-diff\"\n  ],\n  \"contributing-diff-deployed\": [\n    \"contributing.html#contributing-diff-deployed\"\n  ],\n  \"contributing-diff-full\": [\n    \"contributing.html#contributing-diff-full\"\n  ],\n  \"contributing-diff-todeploy\": [\n    \"contributing.html#contributing-diff-todeploy\"\n  ],\n  \"contributing-diff-version\": [\n    \"contributing.html#contributing-diff-version\"\n  ],\n  \"contributing-gensecret\": [\n    \"contributing.html#contributing-gensecret\"\n  ],\n  \"contributing-links\": [\n    \"contributing.html#contributing-links\"\n  ],\n  \"contributing-localversion\": [\n    \"contributing.html#contributing-localversion\"\n  ],\n  \"contributing-playwright-tests\": [\n    \"contributing.html#contributing-playwright-tests\"\n  ],\n  \"contributing-runtests\": [\n    \"contributing.html#contributing-runtests\"\n  ],\n  \"contributing-upload\": [\n    \"contributing.html#contributing-upload\"\n  ],\n  \"contributing-upload-package\": [\n    \"contributing.html#contributing-upload-package\"\n  ],\n  \"contributing-upstream\": [\n    \"contributing.html#contributing-upstream\"\n  ],\n  \"create-comprehensive-tests\": [\n    \"service-implementation-guide.html#create-comprehensive-tests\"\n  ],\n  \"create-service-documentation\": [\n    \"service-implementation-guide.html#create-service-documentation\"\n  ],\n  \"create-service-module\": [\n    \"service-implementation-guide.html#create-service-module\"\n  ],\n  \"demo-homeassistant\": [\n    \"demo-homeassistant.html#demo-homeassistant\"\n  ],\n  \"demo-homeassistant-deploy\": [\n    \"demo-homeassistant.html#demo-homeassistant-deploy\"\n  ],\n  \"demo-homeassistant-deploy-basic\": [\n    \"demo-homeassistant.html#demo-homeassistant-deploy-basic\"\n  ],\n  \"demo-homeassistant-deploy-colmena\": [\n    \"demo-homeassistant.html#demo-homeassistant-deploy-colmena\"\n  ],\n  \"demo-homeassistant-deploy-ldap\": [\n    \"demo-homeassistant.html#demo-homeassistant-deploy-ldap\"\n  ],\n  \"demo-homeassistant-deploy-nixosrebuild\": [\n    \"demo-homeassistant.html#demo-homeassistant-deploy-nixosrebuild\"\n  ],\n  \"demo-homeassistant-files\": [\n    \"demo-homeassistant.html#demo-homeassistant-files\"\n  ],\n  \"demo-homeassistant-in-more-details\": [\n    \"demo-homeassistant.html#demo-homeassistant-in-more-details\"\n  ],\n  \"demo-homeassistant-secrets\": [\n    \"demo-homeassistant.html#demo-homeassistant-secrets\"\n  ],\n  \"demo-homeassistant-tips-deploy\": [\n    \"demo-homeassistant.html#demo-homeassistant-tips-deploy\"\n  ],\n  \"demo-homeassistant-tips-public-key-necessity\": [\n    \"demo-homeassistant.html#demo-homeassistant-tips-public-key-necessity\"\n  ],\n  \"demo-homeassistant-tips-ssh\": [\n    \"demo-homeassistant.html#demo-homeassistant-tips-ssh\"\n  ],\n  \"demo-homeassistant-tips-update-demo\": [\n    \"demo-homeassistant.html#demo-homeassistant-tips-update-demo\"\n  ],\n  \"demo-homeassistant-virtual-machine\": [\n    \"demo-homeassistant.html#demo-homeassistant-virtual-machine\"\n  ],\n  \"demo-nextcloud\": [\n    \"demo-nextcloud.html#demo-nextcloud\"\n  ],\n  \"demo-nextcloud-deploy\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy\"\n  ],\n  \"demo-nextcloud-deploy-basic\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy-basic\"\n  ],\n  \"demo-nextcloud-deploy-colmena\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy-colmena\"\n  ],\n  \"demo-nextcloud-deploy-ldap\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy-ldap\"\n  ],\n  \"demo-nextcloud-deploy-nixosrebuild\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy-nixosrebuild\"\n  ],\n  \"demo-nextcloud-deploy-sso\": [\n    \"demo-nextcloud.html#demo-nextcloud-deploy-sso\"\n  ],\n  \"demo-nextcloud-tips\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips\"\n  ],\n  \"demo-nextcloud-tips-deploy\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-deploy\"\n  ],\n  \"demo-nextcloud-tips-files\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-files\"\n  ],\n  \"demo-nextcloud-tips-public-key-necessity\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-public-key-necessity\"\n  ],\n  \"demo-nextcloud-tips-secrets\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-secrets\"\n  ],\n  \"demo-nextcloud-tips-ssh\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-ssh\"\n  ],\n  \"demo-nextcloud-tips-update-demo\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-update-demo\"\n  ],\n  \"demo-nextcloud-tips-virtual-machine\": [\n    \"demo-nextcloud.html#demo-nextcloud-tips-virtual-machine\"\n  ],\n  \"demos\": [\n    \"demos.html#demos\"\n  ],\n  \"external-exporter\": [\n    \"service-implementation-guide.html#external-exporter\"\n  ],\n  \"handle-unfree-dependencies\": [\n    \"service-implementation-guide.html#handle-unfree-dependencies\"\n  ],\n  \"how-redirects-work\": [\n    \"service-implementation-guide.html#how-redirects-work\"\n  ],\n  \"implementation-considerations\": [\n    \"service-implementation-guide.html#implementation-considerations\"\n  ],\n  \"implementation-steps\": [\n    \"service-implementation-guide.html#implementation-steps\"\n  ],\n  \"iterative-development-approach\": [\n    \"service-implementation-guide.html#iterative-development-approach\"\n  ],\n  \"local-testing\": [\n    \"service-implementation-guide.html#local-testing\"\n  ],\n  \"monitoring-failures\": [\n    \"service-implementation-guide.html#monitoring-failures\"\n  ],\n  \"monitoring-implementation\": [\n    \"service-implementation-guide.html#monitoring-implementation\"\n  ],\n  \"native-prometheus-metrics\": [\n    \"service-implementation-guide.html#native-prometheus-metrics\"\n  ],\n  \"nixpkgs-integration\": [\n    \"service-implementation-guide.html#nixpkgs-integration\"\n  ],\n  \"pre-implementation-research\": [\n    \"service-implementation-guide.html#pre-implementation-research\"\n  ],\n  \"preface\": [\n    \"index.html#preface\"\n  ],\n  \"preface-blocks\": [\n    \"index.html#preface-blocks\"\n  ],\n  \"preface-community\": [\n    \"index.html#preface-community\"\n  ],\n  \"preface-contracts\": [\n    \"index.html#preface-contracts\"\n  ],\n  \"preface-demos\": [\n    \"index.html#preface-demos\"\n  ],\n  \"preface-features\": [\n    \"index.html#preface-features\"\n  ],\n  \"preface-funding\": [\n    \"index.html#preface-funding\"\n  ],\n  \"preface-giants\": [\n    \"index.html#preface-giants\"\n  ],\n  \"preface-interface\": [\n    \"index.html#preface-interface\"\n  ],\n  \"preface-license\": [\n    \"index.html#preface-license\"\n  ],\n  \"preface-roadmap\": [\n    \"index.html#preface-roadmap\"\n  ],\n  \"preface-services\": [\n    \"index.html#preface-services\"\n  ],\n  \"preface-unified-interfaces\": [\n    \"index.html#preface-unified-interfaces\"\n  ],\n  \"preface-updates\": [\n    \"index.html#preface-updates\"\n  ],\n  \"preface-usage\": [\n    \"index.html#preface-usage\"\n  ],\n  \"preface-usage-installation-from-scratch\": [\n    \"index.html#preface-usage-installation-from-scratch\"\n  ],\n  \"preface-why-self-hosting\": [\n    \"index.html#preface-why-self-hosting\"\n  ],\n  \"quick-reference\": [\n    \"service-implementation-guide.html#quick-reference\"\n  ],\n  \"recipes\": [\n    \"recipes.html#recipes\"\n  ],\n  \"recipes-dnsServer\": [\n    \"recipes-dnsServer.html#recipes-dnsServer\"\n  ],\n  \"recipes-dnsServer-drawbacks\": [\n    \"recipes-dnsServer.html#recipes-dnsServer-drawbacks\"\n  ],\n  \"recipes-dnsServer-recipe\": [\n    \"recipes-dnsServer.html#recipes-dnsServer-recipe\"\n  ],\n  \"recipes-dnsServer-why\": [\n    \"recipes-dnsServer.html#recipes-dnsServer-why\"\n  ],\n  \"recipes-exposeService\": [\n    \"recipes-exposeService.html#recipes-exposeService\"\n  ],\n  \"recipes-exposeService-applicationdashboard\": [\n    \"recipes-exposeService.html#recipes-exposeService-applicationdashboard\"\n  ],\n  \"recipes-exposeService-backup\": [\n    \"recipes-exposeService.html#recipes-exposeService-backup\"\n  ],\n  \"recipes-exposeService-debug\": [\n    \"recipes-exposeService.html#recipes-exposeService-debug\"\n  ],\n  \"recipes-exposeService-impermanence\": [\n    \"recipes-exposeService.html#recipes-exposeService-impermanence\"\n  ],\n  \"recipes-exposeService-ldap\": [\n    \"recipes-exposeService.html#recipes-exposeService-ldap\"\n  ],\n  \"recipes-exposeService-nginx\": [\n    \"recipes-exposeService.html#recipes-exposeService-nginx\"\n  ],\n  \"recipes-exposeService-service\": [\n    \"recipes-exposeService.html#recipes-exposeService-service\"\n  ],\n  \"recipes-exposeService-ssl\": [\n    \"recipes-exposeService.html#recipes-exposeService-ssl\"\n  ],\n  \"recipes-exposeService-zfs\": [\n    \"recipes-exposeService.html#recipes-exposeService-zfs\"\n  ],\n  \"recipes-serveStaticPages\": [\n    \"recipes-serveStaticPages.html#recipes-serveStaticPages\"\n  ],\n  \"recipes-serveStaticPages-nginx\": [\n    \"recipes-serveStaticPages.html#recipes-serveStaticPages-nginx\"\n  ],\n  \"recipes-serveStaticPages-ssl\": [\n    \"recipes-serveStaticPages.html#recipes-serveStaticPages-ssl\"\n  ],\n  \"recipes-serveStaticPages-zfs\": [\n    \"recipes-serveStaticPages.html#recipes-serveStaticPages-zfs\"\n  ],\n  \"redirect-management\": [\n    \"service-implementation-guide.html#redirect-management\"\n  ],\n  \"redirect-patterns\": [\n    \"service-implementation-guide.html#redirect-patterns\"\n  ],\n  \"required-test-variants\": [\n    \"service-implementation-guide.html#required-test-variants\"\n  ],\n  \"resources\": [\n    \"service-implementation-guide.html#resources\"\n  ],\n  \"security-best-practices\": [\n    \"service-implementation-guide.html#security-best-practices\"\n  ],\n  \"self-host-blocks-manual\": [\n    \"index.html#self-host-blocks-manual\"\n  ],\n  \"service-implementation-guide\": [\n    \"service-implementation-guide.html#service-implementation-guide\"\n  ],\n  \"services\": [\n    \"services.html#services\"\n  ],\n  \"services-arr\": [\n    \"services-arr.html#services-arr\"\n  ],\n  \"services-arr-features\": [\n    \"services-arr.html#services-arr-features\"\n  ],\n  \"services-arr-options\": [\n    \"services-arr.html#services-arr-options\"\n  ],\n  \"services-arr-options-shb.arr.bazarr\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.domain\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.enable\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings.LogLevel\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings.LogLevel\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ssl\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.bazarr.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.bazarr.subdomain\"\n  ],\n  \"services-arr-options-shb.arr.jackett\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett\"\n  ],\n  \"services-arr-options-shb.arr.jackett.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.jackett.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.jackett.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.jackett.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.domain\"\n  ],\n  \"services-arr-options-shb.arr.jackett.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.enable\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.FlareSolverrUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.FlareSolverrUrl\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.OmdbApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.OmdbApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.OmdbApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ProxyPort\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyPort\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ProxyType\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyType\"\n  ],\n  \"services-arr-options-shb.arr.jackett.settings.ProxyUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyUrl\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ssl\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.jackett.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.jackett.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.jackett.subdomain\"\n  ],\n  \"services-arr-options-shb.arr.lidarr\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.domain\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.enable\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings.LogLevel\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings.LogLevel\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ssl\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.lidarr.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.lidarr.subdomain\"\n  ],\n  \"services-arr-options-shb.arr.radarr\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr\"\n  ],\n  \"services-arr-options-shb.arr.radarr.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.radarr.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.radarr.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.radarr.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.domain\"\n  ],\n  \"services-arr-options-shb.arr.radarr.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.enable\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.AnalyticsEnabled\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.AnalyticsEnabled\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.LogLevel\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.LogLevel\"\n  ],\n  \"services-arr-options-shb.arr.radarr.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ssl\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.radarr.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.radarr.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.radarr.subdomain\"\n  ],\n  \"services-arr-options-shb.arr.readarr\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr\"\n  ],\n  \"services-arr-options-shb.arr.readarr.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.readarr.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.readarr.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.readarr.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.domain\"\n  ],\n  \"services-arr-options-shb.arr.readarr.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.enable\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings.LogLevel\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings.LogLevel\"\n  ],\n  \"services-arr-options-shb.arr.readarr.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ssl\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.readarr.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.readarr.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.readarr.subdomain\"\n  ],\n  \"services-arr-options-shb.arr.sonarr\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.authEndpoint\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.authEndpoint\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.excludePatterns\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.excludePatterns\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.hooks\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.hooks.afterBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks.afterBackup\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.hooks.beforeBackup\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.sourceDirectories\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.sourceDirectories\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.request.user\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.user\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.result\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.result.backupService\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.result.backupService\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.backup.result.restoreScript\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.backup.result.restoreScript\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dashboard\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dashboard\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dashboard.request\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dashboard.request.externalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request.externalUrl\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dashboard.request.internalUrl\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request.internalUrl\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dashboard.result\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.result\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.dataDir\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.dataDir\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.domain\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.domain\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.enable\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.enable\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ldapUserGroup\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ldapUserGroup\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings.ApiKey\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings.ApiKey.source\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey.source\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings.ApiKey.transform\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey.transform\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings.LogLevel\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings.LogLevel\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.settings.Port\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.settings.Port\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ssl\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ssl\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ssl.paths\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ssl.paths.cert\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths.cert\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ssl.paths.key\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths.key\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.ssl.systemdService\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.ssl.systemdService\"\n  ],\n  \"services-arr-options-shb.arr.sonarr.subdomain\": [\n    \"services-arr.html#services-arr-options-shb.arr.sonarr.subdomain\"\n  ],\n  \"services-arr-usage\": [\n    \"services-arr.html#services-arr-usage\"\n  ],\n  \"services-arr-usage-apikeys\": [\n    \"services-arr.html#services-arr-usage-apikeys\"\n  ],\n  \"services-arr-usage-applicationdashboard\": [\n    \"services-arr.html#services-arr-usage-applicationdashboard\"\n  ],\n  \"services-arr-usage-configuration\": [\n    \"services-arr.html#services-arr-usage-configuration\"\n  ],\n  \"services-arr-usage-jackett-proxy\": [\n    \"services-arr.html#services-arr-usage-jackett-proxy\"\n  ],\n  \"services-authelia-features\": [\n    \"blocks-authelia.html#services-authelia-features\"\n  ],\n  \"services-authelia-usage\": [\n    \"blocks-authelia.html#services-authelia-usage\"\n  ],\n  \"services-authelia-usage-applicationdashboard\": [\n    \"blocks-authelia.html#services-authelia-usage-applicationdashboard\"\n  ],\n  \"services-category-ai\": [\n    \"services.html#services-category-ai\"\n  ],\n  \"services-category-automation\": [\n    \"services.html#services-category-automation\"\n  ],\n  \"services-category-code\": [\n    \"services.html#services-category-code\"\n  ],\n  \"services-category-dashboard\": [\n    \"services.html#services-category-dashboard\"\n  ],\n  \"services-category-documents\": [\n    \"services.html#services-category-documents\"\n  ],\n  \"services-category-emails\": [\n    \"services.html#services-category-emails\"\n  ],\n  \"services-category-finance\": [\n    \"services.html#services-category-finance\"\n  ],\n  \"services-category-media\": [\n    \"services.html#services-category-media\"\n  ],\n  \"services-category-passwords\": [\n    \"services.html#services-category-passwords\"\n  ],\n  \"services-firefly-iii\": [\n    \"services-firefly-iii.html#services-firefly-iii\"\n  ],\n  \"services-firefly-iii-certs\": [\n    \"services-firefly-iii.html#services-firefly-iii-certs\"\n  ],\n  \"services-firefly-iii-database-inspection\": [\n    \"services-firefly-iii.html#services-firefly-iii-database-inspection\"\n  ],\n  \"services-firefly-iii-declarative-ldap\": [\n    \"services-firefly-iii.html#services-firefly-iii-declarative-ldap\"\n  ],\n  \"services-firefly-iii-features\": [\n    \"services-firefly-iii.html#services-firefly-iii-features\"\n  ],\n  \"services-firefly-iii-impermanence\": [\n    \"services-firefly-iii.html#services-firefly-iii-impermanence\"\n  ],\n  \"services-firefly-iii-mobile\": [\n    \"services-firefly-iii.html#services-firefly-iii-mobile\"\n  ],\n  \"services-firefly-iii-options\": [\n    \"services-firefly-iii.html#services-firefly-iii-options\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.appKey.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.excludePatterns\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.excludePatterns\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.hooks\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.afterBackup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.afterBackup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.beforeBackup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.sourceDirectories\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.sourceDirectories\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.request.user\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.user\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.result.backupService\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result.backupService\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.backup.result.restoreScript\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result.restoreScript\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dashboard\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dashboard.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dashboard.request.externalUrl\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request.externalUrl\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dashboard.request.internalUrl\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request.internalUrl\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dashboard.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.dbPassword.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.debug\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.debug\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.domain\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.domain\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.enable\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.enable\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.impermanence\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.impermanence\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.enable\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.enable\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.importer.subdomain\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.subdomain\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ldap\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ldap.userGroup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap.userGroup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.siteOwnerEmail\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.siteOwnerEmail\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.from_address\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.from_address\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.host\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.host\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.password.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.port\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.port\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.smtp.username\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.username\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ssl\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ssl.paths\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ssl.paths.cert\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths.cert\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ssl.paths.key\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths.key\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.ssl.systemdService\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.systemdService\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.adminGroup\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.adminGroup\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.authEndpoint\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.authEndpoint\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.authorization_policy\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.authorization_policy\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.clientID\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.clientID\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.enable\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.enable\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.port\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.port\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.provider\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.provider\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secret.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.group\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.group\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.mode\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.mode\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.owner\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.owner\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.restartUnits\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.restartUnits\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result.path\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result.path\"\n  ],\n  \"services-firefly-iii-options-shb.firefly-iii.subdomain\": [\n    \"services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.subdomain\"\n  ],\n  \"services-firefly-iii-usage\": [\n    \"services-firefly-iii.html#services-firefly-iii-usage\"\n  ],\n  \"services-firefly-iii-usage-applicationdashboard\": [\n    \"services-firefly-iii.html#services-firefly-iii-usage-applicationdashboard\"\n  ],\n  \"services-firefly-iii-usage-backup\": [\n    \"services-firefly-iii.html#services-firefly-iii-usage-backup\"\n  ],\n  \"services-firefly-iii-usage-configuration\": [\n    \"services-firefly-iii.html#services-firefly-iii-usage-configuration\"\n  ],\n  \"services-forgejo\": [\n    \"services-forgejo.html#services-forgejo\"\n  ],\n  \"services-forgejo-debug\": [\n    \"services-forgejo.html#services-forgejo-debug\"\n  ],\n  \"services-forgejo-features\": [\n    \"services-forgejo.html#services-forgejo-features\"\n  ],\n  \"services-forgejo-options\": [\n    \"services-forgejo.html#services-forgejo-options\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.excludePatterns\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.excludePatterns\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.hooks\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.hooks.afterBackup\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks.afterBackup\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.hooks.beforeBackup\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.sourceDirectories\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.sourceDirectories\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.request.user\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.user\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.result.backupService\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result.backupService\"\n  ],\n  \"services-forgejo-options-shb.forgejo.backup.result.restoreScript\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result.restoreScript\"\n  ],\n  \"services-forgejo-options-shb.forgejo.dashboard\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard\"\n  ],\n  \"services-forgejo-options-shb.forgejo.dashboard.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.dashboard.request.externalUrl\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request.externalUrl\"\n  ],\n  \"services-forgejo-options-shb.forgejo.dashboard.request.internalUrl\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request.internalUrl\"\n  ],\n  \"services-forgejo-options-shb.forgejo.dashboard.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.databasePassword.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.result.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.debug\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.debug\"\n  ],\n  \"services-forgejo-options-shb.forgejo.domain\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.domain\"\n  ],\n  \"services-forgejo-options-shb.forgejo.enable\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.enable\"\n  ],\n  \"services-forgejo-options-shb.forgejo.hostPackages\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.hostPackages\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminGroup\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminGroup\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminName\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminName\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.adminPassword.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.result.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.dcdomain\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.dcdomain\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.enable\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.enable\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.host\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.host\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.port\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.port\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.provider\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.provider\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.userGroup\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.userGroup\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ldap.waitForSystemdServices\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.waitForSystemdServices\"\n  ],\n  \"services-forgejo-options-shb.forgejo.localActionRunner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.localActionRunner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.mount\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.mount\"\n  ],\n  \"services-forgejo-options-shb.forgejo.mount.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.mount.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.repositoryRoot\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.repositoryRoot\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.from_address\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.from_address\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.host\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.host\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.password.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.result.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.port\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.port\"\n  ],\n  \"services-forgejo-options-shb.forgejo.smtp.username\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.username\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ssl\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ssl\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ssl.paths\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ssl.paths.cert\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths.cert\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ssl.paths.key\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths.key\"\n  ],\n  \"services-forgejo-options-shb.forgejo.ssl.systemdService\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.systemdService\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.authorization_policy\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.authorization_policy\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.clientID\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.clientID\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.enable\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.enable\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.endpoint\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.endpoint\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.provider\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.provider\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecret.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.result.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result.path\"\n  ],\n  \"services-forgejo-options-shb.forgejo.subdomain\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.subdomain\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.email\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.email\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.isAdmin\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.isAdmin\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.request\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.request.group\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.group\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.request.mode\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.mode\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.request.owner\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.owner\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.request.restartUnits\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.restartUnits\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.result\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.result\"\n  ],\n  \"services-forgejo-options-shb.forgejo.users._name_.password.result.path\": [\n    \"services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.result.path\"\n  ],\n  \"services-forgejo-usage\": [\n    \"services-forgejo.html#services-forgejo-usage\"\n  ],\n  \"services-forgejo-usage-applicationdashboard\": [\n    \"services-forgejo.html#services-forgejo-usage-applicationdashboard\"\n  ],\n  \"services-forgejo-usage-backup\": [\n    \"services-forgejo.html#services-forgejo-usage-backup\"\n  ],\n  \"services-forgejo-usage-configuration\": [\n    \"services-forgejo.html#services-forgejo-usage-configuration\"\n  ],\n  \"services-forgejo-usage-extra-settings\": [\n    \"services-forgejo.html#services-forgejo-usage-extra-settings\"\n  ],\n  \"services-forgejo-usage-https\": [\n    \"services-forgejo.html#services-forgejo-usage-https\"\n  ],\n  \"services-forgejo-usage-ldap\": [\n    \"services-forgejo.html#services-forgejo-usage-ldap\"\n  ],\n  \"services-forgejo-usage-smtp\": [\n    \"services-forgejo.html#services-forgejo-usage-smtp\"\n  ],\n  \"services-forgejo-usage-sso\": [\n    \"services-forgejo.html#services-forgejo-usage-sso\"\n  ],\n  \"services-home-assistant\": [\n    \"services-home-assistant.html#services-home-assistant\"\n  ],\n  \"services-home-assistant-debug\": [\n    \"services-home-assistant.html#services-home-assistant-debug\"\n  ],\n  \"services-home-assistant-features\": [\n    \"services-home-assistant.html#services-home-assistant-features\"\n  ],\n  \"services-home-assistant-options\": [\n    \"services-home-assistant.html#services-home-assistant-options\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.excludePatterns\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.excludePatterns\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.hooks\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.hooks.afterBackup\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks.afterBackup\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.hooks.beforeBackup\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.sourceDirectories\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.sourceDirectories\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.request.user\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.user\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.result\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.result.backupService\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result.backupService\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.backup.result.restoreScript\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result.restoreScript\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.country\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.country\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.latitude\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.latitude\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.longitude\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.longitude\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.name\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.name\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.time_zone\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.time_zone\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.config.unit_system\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.unit_system\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.dashboard\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.dashboard.request\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.dashboard.request.externalUrl\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request.externalUrl\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.dashboard.request.internalUrl\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request.internalUrl\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.dashboard.result\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.result\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.domain\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.domain\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.enable\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.enable\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap.enable\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.enable\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap.host\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.host\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap.keepDefaultAuth\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.keepDefaultAuth\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap.port\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.port\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ldap.userGroup\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.userGroup\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ssl\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ssl.paths\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ssl.paths.cert\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths.cert\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ssl.paths.key\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths.key\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.ssl.systemdService\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.systemdService\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.subdomain\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.subdomain\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.voice\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.voice.speech-to-text\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.speech-to-text\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.voice.text-to-speech\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.text-to-speech\"\n  ],\n  \"services-home-assistant-options-shb.home-assistant.voice.wakeword\": [\n    \"services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.wakeword\"\n  ],\n  \"services-home-assistant-usage\": [\n    \"services-home-assistant.html#services-home-assistant-usage\"\n  ],\n  \"services-home-assistant-usage-applicationdashboard\": [\n    \"services-home-assistant.html#services-home-assistant-usage-applicationdashboard\"\n  ],\n  \"services-home-assistant-usage-backup\": [\n    \"services-home-assistant.html#services-home-assistant-usage-backup\"\n  ],\n  \"services-home-assistant-usage-configuration\": [\n    \"services-home-assistant.html#services-home-assistant-usage-configuration\"\n  ],\n  \"services-home-assistant-usage-custom-components\": [\n    \"services-home-assistant.html#services-home-assistant-usage-custom-components\"\n  ],\n  \"services-home-assistant-usage-custom-lovelace-modules\": [\n    \"services-home-assistant.html#services-home-assistant-usage-custom-lovelace-modules\"\n  ],\n  \"services-home-assistant-usage-extra-components\": [\n    \"services-home-assistant.html#services-home-assistant-usage-extra-components\"\n  ],\n  \"services-home-assistant-usage-extra-groups\": [\n    \"services-home-assistant.html#services-home-assistant-usage-extra-groups\"\n  ],\n  \"services-home-assistant-usage-extra-packages\": [\n    \"services-home-assistant.html#services-home-assistant-usage-extra-packages\"\n  ],\n  \"services-home-assistant-usage-https\": [\n    \"services-home-assistant.html#services-home-assistant-usage-https\"\n  ],\n  \"services-home-assistant-usage-ldap\": [\n    \"services-home-assistant.html#services-home-assistant-usage-ldap\"\n  ],\n  \"services-home-assistant-usage-music-assistant\": [\n    \"services-home-assistant.html#services-home-assistant-usage-music-assistant\"\n  ],\n  \"services-home-assistant-usage-sso\": [\n    \"services-home-assistant.html#services-home-assistant-usage-sso\"\n  ],\n  \"services-home-assistant-usage-voice\": [\n    \"services-home-assistant.html#services-home-assistant-usage-voice\"\n  ],\n  \"services-homepage\": [\n    \"services-homepage.html#services-homepage\"\n  ],\n  \"services-homepage-features\": [\n    \"services-homepage.html#services-homepage-features\"\n  ],\n  \"services-homepage-options\": [\n    \"services-homepage.html#services-homepage-options\"\n  ],\n  \"services-homepage-options-shb.homepage.domain\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.domain\"\n  ],\n  \"services-homepage-options-shb.homepage.enable\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.enable\"\n  ],\n  \"services-homepage-options-shb.homepage.ldap\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ldap\"\n  ],\n  \"services-homepage-options-shb.homepage.ldap.userGroup\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ldap.userGroup\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.name\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.name\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.group\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.group\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.mode\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.mode\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.owner\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.owner\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.restartUnits\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.restartUnits\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result.path\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result.path\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.externalUrl\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.externalUrl\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.internalUrl\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.internalUrl\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.result\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.result\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.name\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.name\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.sortOrder\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.sortOrder\"\n  ],\n  \"services-homepage-options-shb.homepage.servicesGroups._name_.sortOrder\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.sortOrder\"\n  ],\n  \"services-homepage-options-shb.homepage.ssl\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ssl\"\n  ],\n  \"services-homepage-options-shb.homepage.ssl.paths\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ssl.paths\"\n  ],\n  \"services-homepage-options-shb.homepage.ssl.paths.cert\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ssl.paths.cert\"\n  ],\n  \"services-homepage-options-shb.homepage.ssl.paths.key\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ssl.paths.key\"\n  ],\n  \"services-homepage-options-shb.homepage.ssl.systemdService\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.ssl.systemdService\"\n  ],\n  \"services-homepage-options-shb.homepage.sso\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.sso\"\n  ],\n  \"services-homepage-options-shb.homepage.sso.authEndpoint\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.sso.authEndpoint\"\n  ],\n  \"services-homepage-options-shb.homepage.sso.authorization_policy\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.sso.authorization_policy\"\n  ],\n  \"services-homepage-options-shb.homepage.sso.enable\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.sso.enable\"\n  ],\n  \"services-homepage-options-shb.homepage.subdomain\": [\n    \"services-homepage.html#services-homepage-options-shb.homepage.subdomain\"\n  ],\n  \"services-homepage-usage\": [\n    \"services-homepage.html#services-homepage-usage\"\n  ],\n  \"services-homepage-usage-custom\": [\n    \"services-homepage.html#services-homepage-usage-custom\"\n  ],\n  \"services-homepage-usage-main\": [\n    \"services-homepage.html#services-homepage-usage-main\"\n  ],\n  \"services-homepage-usage-service\": [\n    \"services-homepage.html#services-homepage-usage-service\"\n  ],\n  \"services-homepage-usage-widget\": [\n    \"services-homepage.html#services-homepage-usage-widget\"\n  ],\n  \"services-jellyfin\": [\n    \"services-jellyfin.html#services-jellyfin\"\n  ],\n  \"services-jellyfin-certs\": [\n    \"services-jellyfin.html#services-jellyfin-certs\"\n  ],\n  \"services-jellyfin-debug\": [\n    \"services-jellyfin.html#services-jellyfin-debug\"\n  ],\n  \"services-jellyfin-declarative-ldap\": [\n    \"services-jellyfin.html#services-jellyfin-declarative-ldap\"\n  ],\n  \"services-jellyfin-features\": [\n    \"services-jellyfin.html#services-jellyfin-features\"\n  ],\n  \"services-jellyfin-impermanence\": [\n    \"services-jellyfin.html#services-jellyfin-impermanence\"\n  ],\n  \"services-jellyfin-options\": [\n    \"services-jellyfin.html#services-jellyfin-options\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.request.group\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.group\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.request.mode\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.mode\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.request.owner\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.owner\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.request.restartUnits\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.restartUnits\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.password.result.path\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.result.path\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.admin.username\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.username\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.excludePatterns\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.excludePatterns\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.hooks\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.hooks.afterBackup\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks.afterBackup\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.hooks.beforeBackup\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.sourceDirectories\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.sourceDirectories\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.request.user\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.user\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.result.backupService\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result.backupService\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.backup.result.restoreScript\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result.restoreScript\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.dashboard\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.dashboard.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.dashboard.request.externalUrl\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request.externalUrl\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.dashboard.request.internalUrl\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request.internalUrl\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.dashboard.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.debug\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.debug\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.domain\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.domain\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.enable\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.enable\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminGroup\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminGroup\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.group\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.group\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.mode\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.mode\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.owner\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.owner\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.restartUnits\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.restartUnits\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result.path\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result.path\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.dcdomain\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.dcdomain\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.enable\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.enable\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.host\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.host\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.plugin\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.plugin\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.port\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.port\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ldap.userGroup\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.userGroup\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.plugins\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.plugins\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.port\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.port\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ssl\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ssl.paths\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ssl.paths.cert\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths.cert\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ssl.paths.key\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths.key\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.ssl.systemdService\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.systemdService\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.authorization_policy\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.authorization_policy\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.clientID\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.clientID\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.enable\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.enable\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.endpoint\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.endpoint\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.plugin\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.plugin\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.provider\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.provider\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.group\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.group\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.mode\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.mode\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.owner\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.owner\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.restartUnits\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.restartUnits\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result.path\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result.path\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.group\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.group\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.mode\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.mode\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.owner\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.owner\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.restartUnits\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.restartUnits\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result.path\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result.path\"\n  ],\n  \"services-jellyfin-options-shb.jellyfin.subdomain\": [\n    \"services-jellyfin.html#services-jellyfin-options-shb.jellyfin.subdomain\"\n  ],\n  \"services-jellyfin-usage\": [\n    \"services-jellyfin.html#services-jellyfin-usage\"\n  ],\n  \"services-jellyfin-usage-applicationdashboard\": [\n    \"services-jellyfin.html#services-jellyfin-usage-applicationdashboard\"\n  ],\n  \"services-jellyfin-usage-backup\": [\n    \"services-jellyfin.html#services-jellyfin-usage-backup\"\n  ],\n  \"services-jellyfin-usage-configuration\": [\n    \"services-jellyfin.html#services-jellyfin-usage-configuration\"\n  ],\n  \"services-karakeep\": [\n    \"services-karakeep.html#services-karakeep\"\n  ],\n  \"services-karakeep-features\": [\n    \"services-karakeep.html#services-karakeep-features\"\n  ],\n  \"services-karakeep-ollama\": [\n    \"services-karakeep.html#services-karakeep-ollama\"\n  ],\n  \"services-karakeep-options\": [\n    \"services-karakeep.html#services-karakeep-options\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.excludePatterns\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.excludePatterns\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.hooks\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.hooks.afterBackup\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks.afterBackup\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.hooks.beforeBackup\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.sourceDirectories\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.sourceDirectories\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.request.user\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.user\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.result.backupService\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result.backupService\"\n  ],\n  \"services-karakeep-options-shb.karakeep.backup.result.restoreScript\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result.restoreScript\"\n  ],\n  \"services-karakeep-options-shb.karakeep.dashboard\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard\"\n  ],\n  \"services-karakeep-options-shb.karakeep.dashboard.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.dashboard.request.externalUrl\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request.externalUrl\"\n  ],\n  \"services-karakeep-options-shb.karakeep.dashboard.request.internalUrl\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request.internalUrl\"\n  ],\n  \"services-karakeep-options-shb.karakeep.dashboard.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.domain\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.domain\"\n  ],\n  \"services-karakeep-options-shb.karakeep.enable\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.enable\"\n  ],\n  \"services-karakeep-options-shb.karakeep.environment\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.environment\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ldap\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ldap\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ldap.userGroup\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ldap.userGroup\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.group\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.group\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.mode\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.mode\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.owner\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.owner\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.restartUnits\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.restartUnits\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.meilisearchMasterKey.result.path\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.result.path\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.request.group\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.group\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.request.mode\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.mode\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.request.owner\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.owner\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.request.restartUnits\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.restartUnits\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.nextauthSecret.result.path\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.result.path\"\n  ],\n  \"services-karakeep-options-shb.karakeep.port\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.port\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ssl\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ssl\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ssl.paths\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ssl.paths.cert\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths.cert\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ssl.paths.key\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths.key\"\n  ],\n  \"services-karakeep-options-shb.karakeep.ssl.systemdService\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.systemdService\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.authEndpoint\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.authEndpoint\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.authorization_policy\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.authorization_policy\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.clientID\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.clientID\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.enable\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.enable\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.request.group\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.group\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.request.mode\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.mode\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.request.owner\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.owner\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.request.restartUnits\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.restartUnits\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecret.result.path\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.result.path\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.group\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.group\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.mode\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.mode\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.owner\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.owner\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.restartUnits\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.restartUnits\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result\"\n  ],\n  \"services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result.path\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result.path\"\n  ],\n  \"services-karakeep-options-shb.karakeep.subdomain\": [\n    \"services-karakeep.html#services-karakeep-options-shb.karakeep.subdomain\"\n  ],\n  \"services-karakeep-usage\": [\n    \"services-karakeep.html#services-karakeep-usage\"\n  ],\n  \"services-karakeep-usage-applicationdashboard\": [\n    \"services-karakeep.html#services-karakeep-usage-applicationdashboard\"\n  ],\n  \"services-karakeep-usage-configuration\": [\n    \"services-karakeep.html#services-karakeep-usage-configuration\"\n  ],\n  \"services-mailserver\": [\n    \"services-mailserver.html#services-mailserver\"\n  ],\n  \"services-mailserver-applicationdashboard\": [\n    \"services-mailserver.html#services-mailserver-applicationdashboard\"\n  ],\n  \"services-mailserver-certs\": [\n    \"services-mailserver.html#services-mailserver-certs\"\n  ],\n  \"services-mailserver-debug\": [\n    \"services-mailserver.html#services-mailserver-debug\"\n  ],\n  \"services-mailserver-debug-auth\": [\n    \"services-mailserver.html#services-mailserver-debug-auth\"\n  ],\n  \"services-mailserver-debug-folder-mapping\": [\n    \"services-mailserver.html#services-mailserver-debug-folder-mapping\"\n  ],\n  \"services-mailserver-debug-folders\": [\n    \"services-mailserver.html#services-mailserver-debug-folders\"\n  ],\n  \"services-mailserver-debug-local-folders\": [\n    \"services-mailserver.html#services-mailserver-debug-local-folders\"\n  ],\n  \"services-mailserver-debug-ports\": [\n    \"services-mailserver.html#services-mailserver-debug-ports\"\n  ],\n  \"services-mailserver-debug-systemd\": [\n    \"services-mailserver.html#services-mailserver-debug-systemd\"\n  ],\n  \"services-mailserver-declarative-ldap\": [\n    \"services-mailserver.html#services-mailserver-declarative-ldap\"\n  ],\n  \"services-mailserver-impermanence\": [\n    \"services-mailserver.html#services-mailserver-impermanence\"\n  ],\n  \"services-mailserver-mobile\": [\n    \"services-mailserver.html#services-mailserver-mobile\"\n  ],\n  \"services-mailserver-options\": [\n    \"services-mailserver.html#services-mailserver-options\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.request.group\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.group\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.request.mode\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.mode\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.request.owner\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.owner\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.request.restartUnits\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.restartUnits\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminPassword.result.path\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.result.path\"\n  ],\n  \"services-mailserver-options-shb.mailserver.adminUsername\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.adminUsername\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.excludePatterns\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.excludePatterns\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.hooks\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.hooks.afterBackup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks.afterBackup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.hooks.beforeBackup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.sourceDirectories\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.sourceDirectories\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.request.user\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.user\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.result.backupService\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result.backupService\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backup.result.restoreScript\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result.restoreScript\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.excludePatterns\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.excludePatterns\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.hooks\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.afterBackup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.afterBackup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.beforeBackup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.beforeBackup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.sourceDirectories\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.sourceDirectories\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.request.user\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.user\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.result.backupService\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result.backupService\"\n  ],\n  \"services-mailserver-options-shb.mailserver.backupDKIM.result.restoreScript\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result.restoreScript\"\n  ],\n  \"services-mailserver-options-shb.mailserver.dashboard\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard\"\n  ],\n  \"services-mailserver-options-shb.mailserver.dashboard.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.dashboard.request.externalUrl\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request.externalUrl\"\n  ],\n  \"services-mailserver-options-shb.mailserver.dashboard.request.internalUrl\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request.internalUrl\"\n  ],\n  \"services-mailserver-options-shb.mailserver.dashboard.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.domain\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.domain\"\n  ],\n  \"services-mailserver-options-shb.mailserver.enable\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.enable\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.host\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.host\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialDrafts\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialDrafts\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialJunk\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialJunk\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialSent\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialSent\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialTrash\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialTrash\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.group\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.group\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.mode\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.mode\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.owner\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.owner\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.restartUnits\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.restartUnits\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result.path\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result.path\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.port\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.port\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.sslType\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.sslType\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.timeout\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.timeout\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.accounts._name_.username\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.username\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.debug\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.debug\"\n  ],\n  \"services-mailserver-options-shb.mailserver.imapSync.syncTimer\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.syncTimer\"\n  ],\n  \"services-mailserver-options-shb.mailserver.impermanence\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.impermanence\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.account\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.account\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminName\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminName\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.request.group\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.group\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.request.mode\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.mode\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.request.owner\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.owner\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.request.restartUnits\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.restartUnits\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.adminPassword.result.path\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.result.path\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.dcdomain\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.dcdomain\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.enable\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.enable\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.host\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.host\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.port\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.port\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ldap.userGroup\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.userGroup\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.host\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.host\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.request\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.request.group\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.group\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.request.mode\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.mode\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.request.owner\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.owner\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.request.restartUnits\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.restartUnits\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.result\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.result\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.password.result.path\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.result.path\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.port\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.port\"\n  ],\n  \"services-mailserver-options-shb.mailserver.smtpRelay.username\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.username\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ssl\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ssl.paths\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ssl.paths.cert\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths.cert\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ssl.paths.key\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths.key\"\n  ],\n  \"services-mailserver-options-shb.mailserver.ssl.systemdService\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.systemdService\"\n  ],\n  \"services-mailserver-options-shb.mailserver.subdomain\": [\n    \"services-mailserver.html#services-mailserver-options-shb.mailserver.subdomain\"\n  ],\n  \"services-mailserver-usage\": [\n    \"services-mailserver.html#services-mailserver-usage\"\n  ],\n  \"services-mailserver-usage-backup\": [\n    \"services-mailserver.html#services-mailserver-usage-backup\"\n  ],\n  \"services-mailserver-usage-disk-layout\": [\n    \"services-mailserver.html#services-mailserver-usage-disk-layout\"\n  ],\n  \"services-mailserver-usage-ldap\": [\n    \"services-mailserver.html#services-mailserver-usage-ldap\"\n  ],\n  \"services-mailserver-usage-secrets\": [\n    \"services-mailserver.html#services-mailserver-usage-secrets\"\n  ],\n  \"services-monitoring-features\": [\n    \"blocks-monitoring.html#services-monitoring-features\"\n  ],\n  \"services-nextcloudserver\": [\n    \"services-nextcloud.html#services-nextcloudserver\"\n  ],\n  \"services-nextcloudserver-dashboard\": [\n    \"services-nextcloud.html#services-nextcloudserver-dashboard\"\n  ],\n  \"services-nextcloudserver-debug\": [\n    \"services-nextcloud.html#services-nextcloudserver-debug\"\n  ],\n  \"services-nextcloudserver-demo\": [\n    \"services-nextcloud.html#services-nextcloudserver-demo\"\n  ],\n  \"services-nextcloudserver-features\": [\n    \"services-nextcloud.html#services-nextcloudserver-features\"\n  ],\n  \"services-nextcloudserver-options\": [\n    \"services-nextcloud.html#services-nextcloudserver-options\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.request.group\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.group\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.request.mode\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.mode\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.request.owner\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.owner\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.request.restartUnits\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.restartUnits\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.adminPass.result.path\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.result.path\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.alwaysApplyExpensiveMigrations\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.alwaysApplyExpensiveMigrations\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.externalStorage\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.directory\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.directory\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.mountName\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.mountName\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminName\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminName\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.group\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.group\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.mode\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.mode\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.owner\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.owner\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.restartUnits\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.restartUnits\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result.path\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result.path\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.configID\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.configID\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.dcdomain\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.dcdomain\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.host\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.host\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.port\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.port\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.memories\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.memories.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.memories.photosPath\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.photosPath\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.memories.vaapi\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.vaapi\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.jwtSecretFile\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.jwtSecretFile\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.localNetworkIPRange\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.localNetworkIPRange\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.cert\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.cert\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.key\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.key\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.systemdService\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.systemdService\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.subdomain\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.subdomain\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.debug\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.debug\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.recommendedSettings\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.recommendedSettings\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.recognize\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.recognize\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.recognize.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.recognize.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.clientID\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.clientID\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.endpoint\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.endpoint\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.fallbackDefaultAuth\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.fallbackDefaultAuth\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.port\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.port\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.provider\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.provider\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.group\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.group\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.mode\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.mode\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.owner\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.owner\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.restartUnits\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.restartUnits\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result.path\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result.path\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.group\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.group\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.mode\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.mode\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.owner\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.owner\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.restartUnits\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.restartUnits\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result.path\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result.path\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.autoDisableMaintenanceModeOnStart\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.autoDisableMaintenanceModeOnStart\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.excludePatterns\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.excludePatterns\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.hooks\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.afterBackup\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.afterBackup\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.beforeBackup\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.sourceDirectories\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.sourceDirectories\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.request.user\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.user\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.result.backupService\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result.backupService\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.backup.result.restoreScript\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result.restoreScript\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dashboard\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dashboard.request\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dashboard.request.externalUrl\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request.externalUrl\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dashboard.request.internalUrl\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request.internalUrl\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dashboard.result\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.result\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.dataDir\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dataDir\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.debug\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.debug\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.defaultPhoneRegion\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.defaultPhoneRegion\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.domain\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.domain\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.enableDashboard\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.enableDashboard\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.externalFqdn\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.externalFqdn\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.extraApps\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.extraApps\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.initialAdminUsername\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.initialAdminUsername\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.maxUploadSize\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.maxUploadSize\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.mountPointServices\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.mountPointServices\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.phpFpmPoolSettings\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPoolSettings\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.enable\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.enable\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.port\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.port\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.port\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.port\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.postgresSettings\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.postgresSettings\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.ssl\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.ssl.paths\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.ssl.paths.cert\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths.cert\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.ssl.paths.key\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths.key\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.ssl.systemdService\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.systemdService\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.subdomain\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.subdomain\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.tracing\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.tracing\"\n  ],\n  \"services-nextcloudserver-options-shb.nextcloud.version\": [\n    \"services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.version\"\n  ],\n  \"services-nextcloudserver-server-usage-appdata\": [\n    \"services-nextcloud.html#services-nextcloudserver-server-usage-appdata\"\n  ],\n  \"services-nextcloudserver-server-usage-monitoring\": [\n    \"services-nextcloud.html#services-nextcloudserver-server-usage-monitoring\"\n  ],\n  \"services-nextcloudserver-server-usage-tracing\": [\n    \"services-nextcloud.html#services-nextcloudserver-server-usage-tracing\"\n  ],\n  \"services-nextcloudserver-usage\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage\"\n  ],\n  \"services-nextcloudserver-usage-applicationdashboard\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-applicationdashboard\"\n  ],\n  \"services-nextcloudserver-usage-backup\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-backup\"\n  ],\n  \"services-nextcloudserver-usage-basic\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-basic\"\n  ],\n  \"services-nextcloudserver-usage-externalstorage\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-externalstorage\"\n  ],\n  \"services-nextcloudserver-usage-https\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-https\"\n  ],\n  \"services-nextcloudserver-usage-ldap\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-ldap\"\n  ],\n  \"services-nextcloudserver-usage-memories\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-memories\"\n  ],\n  \"services-nextcloudserver-usage-mount-point\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-mount-point\"\n  ],\n  \"services-nextcloudserver-usage-oidc\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-oidc\"\n  ],\n  \"services-nextcloudserver-usage-onlyoffice\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-onlyoffice\"\n  ],\n  \"services-nextcloudserver-usage-phpfpm\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-phpfpm\"\n  ],\n  \"services-nextcloudserver-usage-postgres\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-postgres\"\n  ],\n  \"services-nextcloudserver-usage-previewgenerator\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-previewgenerator\"\n  ],\n  \"services-nextcloudserver-usage-recognize\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-recognize\"\n  ],\n  \"services-nextcloudserver-usage-version\": [\n    \"services-nextcloud.html#services-nextcloudserver-usage-version\"\n  ],\n  \"services-open-webui\": [\n    \"services-open-webui.html#services-open-webui\"\n  ],\n  \"services-open-webui-features\": [\n    \"services-open-webui.html#services-open-webui-features\"\n  ],\n  \"services-open-webui-ollama\": [\n    \"services-open-webui.html#services-open-webui-ollama\"\n  ],\n  \"services-open-webui-options\": [\n    \"services-open-webui.html#services-open-webui-options\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.excludePatterns\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.excludePatterns\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.hooks\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.hooks.afterBackup\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks.afterBackup\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.hooks.beforeBackup\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.sourceDirectories\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.sourceDirectories\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.request.user\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.user\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.result\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.result.backupService\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result.backupService\"\n  ],\n  \"services-open-webui-options-shb.open-webui.backup.result.restoreScript\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result.restoreScript\"\n  ],\n  \"services-open-webui-options-shb.open-webui.dashboard\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard\"\n  ],\n  \"services-open-webui-options-shb.open-webui.dashboard.request\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request\"\n  ],\n  \"services-open-webui-options-shb.open-webui.dashboard.request.externalUrl\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request.externalUrl\"\n  ],\n  \"services-open-webui-options-shb.open-webui.dashboard.request.internalUrl\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request.internalUrl\"\n  ],\n  \"services-open-webui-options-shb.open-webui.dashboard.result\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.result\"\n  ],\n  \"services-open-webui-options-shb.open-webui.domain\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.domain\"\n  ],\n  \"services-open-webui-options-shb.open-webui.enable\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.enable\"\n  ],\n  \"services-open-webui-options-shb.open-webui.environment\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.environment\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ldap\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ldap\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ldap.adminGroup\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ldap.adminGroup\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ldap.userGroup\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ldap.userGroup\"\n  ],\n  \"services-open-webui-options-shb.open-webui.port\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.port\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ssl\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ssl\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ssl.paths\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ssl.paths.cert\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths.cert\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ssl.paths.key\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths.key\"\n  ],\n  \"services-open-webui-options-shb.open-webui.ssl.systemdService\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.systemdService\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.authEndpoint\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.authEndpoint\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.authorization_policy\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.authorization_policy\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.clientID\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.clientID\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.enable\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.enable\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.request\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.request.group\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.group\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.request.mode\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.mode\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.request.owner\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.owner\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.request.restartUnits\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.restartUnits\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.result\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.result\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecret.result.path\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.result.path\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.group\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.group\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.mode\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.mode\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.owner\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.owner\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.restartUnits\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.restartUnits\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result\"\n  ],\n  \"services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result.path\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result.path\"\n  ],\n  \"services-open-webui-options-shb.open-webui.subdomain\": [\n    \"services-open-webui.html#services-open-webui-options-shb.open-webui.subdomain\"\n  ],\n  \"services-open-webui-usage\": [\n    \"services-open-webui.html#services-open-webui-usage\"\n  ],\n  \"services-open-webui-usage-applicationdashboard\": [\n    \"services-open-webui.html#services-open-webui-usage-applicationdashboard\"\n  ],\n  \"services-open-webui-usage-backup\": [\n    \"services-open-webui.html#services-open-webui-usage-backup\"\n  ],\n  \"services-open-webui-usage-configuration\": [\n    \"services-open-webui.html#services-open-webui-usage-configuration\"\n  ],\n  \"services-pinchflat\": [\n    \"services-pinchflat.html#services-pinchflat\"\n  ],\n  \"services-pinchflat-features\": [\n    \"services-pinchflat.html#services-pinchflat-features\"\n  ],\n  \"services-pinchflat-options\": [\n    \"services-pinchflat.html#services-pinchflat-options\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.excludePatterns\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.excludePatterns\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.hooks\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.hooks.afterBackup\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks.afterBackup\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.hooks.beforeBackup\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.sourceDirectories\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.sourceDirectories\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.request.user\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.user\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.result\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.result.backupService\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result.backupService\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.backup.result.restoreScript\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result.restoreScript\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.dashboard\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.dashboard.request\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.dashboard.request.externalUrl\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request.externalUrl\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.dashboard.request.internalUrl\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request.internalUrl\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.dashboard.result\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.result\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.domain\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.domain\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.enable\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.enable\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ldap\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ldap.enable\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap.enable\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ldap.userGroup\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap.userGroup\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.mediaDir\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.mediaDir\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.port\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.port\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.request\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.request.group\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.group\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.request.mode\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.mode\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.request.owner\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.owner\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.request.restartUnits\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.restartUnits\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.result\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.result\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.secretKeyBase.result.path\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.result.path\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ssl\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ssl.paths\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ssl.paths.cert\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths.cert\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ssl.paths.key\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths.key\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.ssl.systemdService\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.systemdService\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.sso\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.sso.authEndpoint\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.authEndpoint\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.sso.authorization_policy\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.authorization_policy\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.sso.enable\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.enable\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.subdomain\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.subdomain\"\n  ],\n  \"services-pinchflat-options-shb.pinchflat.timeZone\": [\n    \"services-pinchflat.html#services-pinchflat-options-shb.pinchflat.timeZone\"\n  ],\n  \"services-pinchflat-usage\": [\n    \"services-pinchflat.html#services-pinchflat-usage\"\n  ],\n  \"services-pinchflat-usage-applicationdashboard\": [\n    \"services-pinchflat.html#services-pinchflat-usage-applicationdashboard\"\n  ],\n  \"services-pinchflat-usage-backup\": [\n    \"services-pinchflat.html#services-pinchflat-usage-backup\"\n  ],\n  \"services-pinchflat-usage-configuration\": [\n    \"services-pinchflat.html#services-pinchflat-usage-configuration\"\n  ],\n  \"services-vaultwarden\": [\n    \"services-vaultwarden.html#services-vaultwarden\"\n  ],\n  \"services-vaultwarden-backup\": [\n    \"services-vaultwarden.html#services-vaultwarden-backup\"\n  ],\n  \"services-vaultwarden-debug\": [\n    \"services-vaultwarden.html#services-vaultwarden-debug\"\n  ],\n  \"services-vaultwarden-features\": [\n    \"services-vaultwarden.html#services-vaultwarden-features\"\n  ],\n  \"services-vaultwarden-maintenance\": [\n    \"services-vaultwarden.html#services-vaultwarden-maintenance\"\n  ],\n  \"services-vaultwarden-options\": [\n    \"services-vaultwarden.html#services-vaultwarden-options\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.authEndpoint\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.authEndpoint\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.excludePatterns\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.excludePatterns\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.hooks\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.afterBackup\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.afterBackup\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.beforeBackup\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.beforeBackup\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.sourceDirectories\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.sourceDirectories\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.request.user\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.user\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.result\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.result.backupService\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result.backupService\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.backup.result.restoreScript\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result.restoreScript\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.dashboard\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.dashboard.request\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.dashboard.request.externalUrl\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request.externalUrl\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.dashboard.request.internalUrl\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request.internalUrl\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.dashboard.result\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.result\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.request\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.request.group\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.group\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.request.mode\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.mode\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.request.owner\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.owner\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.request.restartUnits\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.restartUnits\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.result\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.result\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.databasePassword.result.path\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.result.path\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.debug\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.debug\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.domain\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.domain\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.enable\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.enable\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.mount\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.mount\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.mount.path\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.mount.path\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.port\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.port\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.auth_mechanism\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.auth_mechanism\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.from_address\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.from_address\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.from_name\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.from_name\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.host\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.host\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.request\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.request.group\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.group\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.request.mode\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.mode\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.request.owner\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.owner\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.request.restartUnits\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.restartUnits\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.result\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.result\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.password.result.path\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.result.path\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.port\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.port\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.security\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.security\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.smtp.username\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.username\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.ssl\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.ssl.paths\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.ssl.paths.cert\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths.cert\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.ssl.paths.key\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths.key\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.ssl.systemdService\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.systemdService\"\n  ],\n  \"services-vaultwarden-options-shb.vaultwarden.subdomain\": [\n    \"services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.subdomain\"\n  ],\n  \"services-vaultwarden-usage\": [\n    \"services-vaultwarden.html#services-vaultwarden-usage\"\n  ],\n  \"services-vaultwarden-usage-applicationdashboard\": [\n    \"services-vaultwarden.html#services-vaultwarden-usage-applicationdashboard\"\n  ],\n  \"services-vaultwarden-usage-configuration\": [\n    \"services-vaultwarden.html#services-vaultwarden-usage-configuration\"\n  ],\n  \"services-vaultwarden-usage-https\": [\n    \"services-vaultwarden.html#services-vaultwarden-usage-https\"\n  ],\n  \"services-vaultwarden-usage-sso\": [\n    \"services-vaultwarden.html#services-vaultwarden-usage-sso\"\n  ],\n  \"services-vaultwarden-zfs\": [\n    \"services-vaultwarden.html#services-vaultwarden-zfs\"\n  ],\n  \"test-failures\": [\n    \"service-implementation-guide.html#test-failures\"\n  ],\n  \"testing-and-validation\": [\n    \"service-implementation-guide.html#testing-and-validation\"\n  ],\n  \"understand-target-service\": [\n    \"service-implementation-guide.html#understand-target-service\"\n  ],\n  \"update-flake-configuration\": [\n    \"service-implementation-guide.html#update-flake-configuration\"\n  ],\n  \"update-redirects-automatically\": [\n    \"service-implementation-guide.html#update-redirects-automatically\"\n  ],\n  \"usage\": [\n    \"usage.html#usage\"\n  ],\n  \"usage-examples\": [\n    \"usage.html#usage-examples\"\n  ],\n  \"usage-examples-colmena\": [\n    \"usage.html#usage-examples-colmena\"\n  ],\n  \"usage-examples-deploy-rs\": [\n    \"usage.html#usage-examples-deploy-rs\"\n  ],\n  \"usage-examples-nixosrebuild\": [\n    \"usage.html#usage-examples-nixosrebuild\"\n  ],\n  \"usage-flake\": [\n    \"usage.html#usage-flake\"\n  ],\n  \"usage-flake-autoupdate\": [\n    \"usage.html#usage-flake-autoupdate\"\n  ],\n  \"usage-flake-lib\": [\n    \"usage.html#usage-flake-lib\"\n  ],\n  \"usage-flake-modules\": [\n    \"usage.html#usage-flake-modules\"\n  ],\n  \"usage-flake-overlays\": [\n    \"usage.html#usage-flake-overlays\"\n  ],\n  \"usage-flake-patches\": [\n    \"usage.html#usage-flake-patches\"\n  ],\n  \"usage-flake-substituter\": [\n    \"usage.html#usage-flake-substituter\"\n  ],\n  \"usage-flake-tag\": [\n    \"usage.html#usage-flake-tag\"\n  ],\n  \"usage-flake-tag-explicit\": [\n    \"usage.html#usage-flake-tag-explicit\"\n  ],\n  \"usage-flake-tag-implicit\": [\n    \"usage.html#usage-flake-tag-implicit\"\n  ],\n  \"usage-flake-unfree\": [\n    \"usage.html#usage-flake-unfree\"\n  ],\n  \"usage-secrets\": [\n    \"usage.html#usage-secrets\"\n  ]\n}"
  },
  {
    "path": "docs/service-implementation-guide.md",
    "content": "# SelfHostBlocks Service Implementation Guide {#service-implementation-guide}\n\nThis 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.\n\n**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.\n\n## What Makes a \"Complete\" SHB Service {#complete-shb-service}\n\nAccording to the project maintainer's criteria, a service is considered fully supported if it includes:\n\n1. **SSL block integration** - HTTPS/TLS certificate management\n2. **Backup block integration** - Automated backup of service data\n3. **Monitoring integration** - Prometheus metrics and health checks\n4. **LDAP (LLDAP) integration** - Directory-based authentication\n5. **SSO (Authelia) integration** - Single sign-on authentication\n6. **Comprehensive tests** - All integration variants tested\n\n## Pre-Implementation Research {#pre-implementation-research}\n\n### 1. Analyze Existing Services {#analyze-existing-services}\n\nBefore starting, study existing services to understand patterns:\n\n```bash\n# Study service patterns\nls modules/services/          # List all services\ncat modules/services/deluge.nix   # Best practice example\ncat modules/services/vaultwarden.nix  # Another good example\n```\n\n**Key patterns to identify:**\n- Configuration structure and options\n- How contracts are used (SSL, backup, monitoring, secrets)\n- Authentication integration approaches\n- Service-specific settings and defaults\n\n### 2. Understand the Target Service {#understand-target-service}\n\nResearch the service you're implementing:\n- **Configuration format** (YAML, INI, JSON, etc.)\n- **Authentication methods** (built-in users, LDAP, OIDC/OAuth)\n- **API endpoints** (for monitoring/health checks)\n- **Data directories** (what needs backing up)\n- **Network requirements** (ports, protocols)\n- **Dependencies** (databases, external tools)\n\n### 3. Check NixOS Integration {#check-nixos-integration}\n\nVerify nixpkgs support:\n```bash\n# Check if NixOS service exists\nnix eval --impure --expr '(import <nixpkgs/nixos> { configuration = {...}: {}; }).options.services' --apply 'builtins.attrNames' --json | jq -r '.[]' | grep -i servicename\n# or search online: https://search.nixos.org/options?query=services.servicename\n```\n\nIf no nixpkgs integration exists, you may need to:\n- Package the service first\n- Use containerized approach\n- Request upstream nixpkgs integration\n\n## Implementation Steps {#implementation-steps}\n\n### 1. Create the Service Module {#create-service-module}\n\nLocation: `modules/services/servicename.nix`\n\n**Basic structure:**\n```nix\n{ config, pkgs, lib, ... }:\n\nlet\n  cfg = config.shb.servicename;\n  contracts = pkgs.callPackage ../contracts {};\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n  \n  # Choose appropriate format based on service config\n  settingsFormat = pkgs.formats.yaml {};  # or .ini, .json, etc.\nin\n{\n  options.shb.servicename = {\n    # Core options (always required)\n    enable = lib.mkEnableOption \"selfhostblocks.servicename\";\n    subdomain = lib.mkOption { ... };\n    domain = lib.mkOption { ... };\n    \n    # SSL integration (always include)\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr contracts.ssl.certs;\n      default = null;\n    };\n    \n    # Service-specific options\n    port = lib.mkOption { ... };\n    dataDir = lib.mkOption { ... };\n    settings = lib.mkOption {\n      type = lib.types.submodule {\n        freeformType = settingsFormat.type;\n        options = {\n          # Define key options with descriptions\n        };\n      };\n    };\n    \n    # Authentication options\n    authEndpoint = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = \"OIDC endpoint for SSO\";\n      default = null;\n    };\n    \n    ldap = lib.mkOption { ... };  # LDAP integration\n    users = lib.mkOption { ... }; # Local user management\n    \n    # Integration options\n    backup = lib.mkOption {\n      type = lib.types.submodule {\n        options = contracts.backup.mkRequester {\n          user = \"servicename\";\n          sourceDirectories = [ cfg.dataDir ];\n        };\n      };\n    };\n    \n    monitoring = lib.mkOption {\n      type = lib.types.nullOr (lib.types.submodule {\n        options = {\n          # Service-specific monitoring options\n        };\n      });\n      default = null;\n    };\n    \n    # System options\n    extraServiceConfig = lib.mkOption { ... };\n    logLevel = lib.mkOption { ... };\n  };\n\n  config = lib.mkIf cfg.enable (lib.mkMerge [\n    {\n      # Base service configuration\n      services.servicename = {\n        enable = true;\n        # Map SHB options to nixpkgs service options\n      };\n      \n      # Nginx reverse proxy\n      shb.nginx.vhosts = [{\n        inherit (cfg) subdomain domain ssl;\n        upstream = \"http://127.0.0.1:${toString cfg.port}\";\n        \n        # SSO integration\n        autheliaRules = lib.mkIf (cfg.authEndpoint != null) [\n          {\n            domain = fqdn;\n            policy = \"bypass\";\n            resources = [ \"^/api\" ];  # API endpoints\n          }\n          {\n            domain = fqdn;\n            policy = \"two_factor\";\n            resources = [ \"^.*\" ];    # Everything else\n          }\n        ];\n      }];\n      \n      # User/group setup\n      users.users.servicename = {\n        extraGroups = [ \"media\" ];  # If needed for file access\n      };\n      \n      # Directory permissions\n      systemd.tmpfiles.rules = [\n        \"d ${cfg.dataDir} 0755 servicename servicename - -\"\n      ];\n    }\n    \n    # Monitoring configuration (conditional)\n    (lib.mkIf (cfg.monitoring != null) {\n      services.prometheus.scrapeConfigs = [{\n        job_name = \"servicename\";\n        static_configs = [{\n          targets = [ \"127.0.0.1:${toString cfg.port}\" ];\n          labels = {\n            hostname = config.networking.hostName;\n            domain = cfg.domain;\n          };\n        }];\n        metrics_path = \"/metrics\";  # or appropriate endpoint\n        scrape_interval = \"30s\";\n      }];\n    })\n  ]);\n}\n```\n\n### 2. Key Implementation Considerations {#implementation-considerations}\n\n#### Configuration Management {#configuration-management}\n- **Use freeform settings** when possible: `freeformType = settingsFormat.type`\n- **Provide sensible defaults** for common options\n- **Use lib.mkDefault** for user-overridable settings\n- **Use lib.mkForce** for security-critical settings\n\n#### Authentication Integration {#authentication-integration}\n- **SSO (Authelia)**: Use `autheliaRules` with appropriate bypass policies\n- **LDAP**: Follow the patterns from existing services\n- **Local users**: Use SHB secret contracts for password management\n\n#### Security Best Practices {#security-best-practices}\n- **Bind to localhost**: Services should listen on `127.0.0.1` only\n- **Use nginx for TLS**: Don't configure TLS in the service itself\n- **Proper file permissions**: Use systemd.tmpfiles.rules\n- **Secret management**: Always use SHB secret contracts\n\n### 3. Monitoring Implementation {#monitoring-implementation}\n\nChoose the appropriate monitoring approach:\n\n#### Option A: Native Prometheus Metrics {#native-prometheus-metrics}\nIf the service supports Prometheus natively:\n```nix\nservices.prometheus.scrapeConfigs = [{\n  job_name = \"servicename\";\n  static_configs = [{ targets = [ \"127.0.0.1:${toString cfg.port}\" ]; }];\n  metrics_path = \"/metrics\";\n}];\n```\n\n#### Option B: API Health Check {#api-health-check}\nIf no native metrics, monitor API endpoints:\n```nix\nservices.prometheus.scrapeConfigs = [{\n  job_name = \"servicename\";\n  static_configs = [{ targets = [ \"127.0.0.1:${toString cfg.port}\" ]; }];\n  metrics_path = \"/api/status\";  # or appropriate endpoint\n}];\n```\n\n#### Option C: External Exporter {#external-exporter}\nFor services requiring dedicated exporters (like Deluge):\n```nix\nservices.prometheus.exporters.servicename = {\n  enable = true;\n  # exporter-specific configuration\n};\n```\n\n### 4. Create Comprehensive Tests {#create-comprehensive-tests}\n\nLocation: `test/services/servicename.nix`\n\n**Test structure:**\n```nix\n{ pkgs, ... }:\nlet\n  testLib = pkgs.callPackage ../common.nix {};\n  \n  # Common test scripts\n  commonTestScript = testLib.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.servicename.ssl);\n    waitForServices = { ... }: [ \"nginx.service\" \"servicename.service\" ];\n    waitForPorts = { node, ... }: [ node.config.services.servicename.port ];\n    \n    # Service-specific connectivity test\n    extraScript = { node, proto_fqdn, ... }: ''\n      with subtest(\"service connectivity\"):\n          response = curl(client, \"\", \"${proto_fqdn}/api/health\")\n          # Add service-specific checks\n    '';\n  };\n  \n  # Monitoring test script\n  prometheusTestScript = { nodes, ... }: ''\n    server.wait_for_open_port(${toString nodes.server.config.services.servicename.port})\n    with subtest(\"prometheus monitoring\"):\n        # Test the actual monitoring endpoint\n        response = server.succeed(\"curl -sSf http://localhost:${port}/metrics\")\n        # Validate response format\n  '';\n  \n  # Base configuration\n  basic = { config, ... }: {\n    imports = [\n      testLib.baseModule\n      ../../modules/services/servicename.nix\n    ];\n    \n    shb.servicename = {\n      enable = true;\n      inherit (config.test) domain subdomain;\n      # Basic configuration\n    };\n  };\n  \nin {\n  # Test variants (all 6 required)\n  basic = lib.shb.test.runNixOSTest { ... };\n  backup = lib.shb.test.runNixOSTest { ... };\n  https = lib.shb.test.runNixOSTest { ... };\n  ldap = lib.shb.test.runNixOSTest { ... };\n  monitoring = lib.shb.test.runNixOSTest { ... };\n  sso = lib.shb.test.runNixOSTest { ... };\n}\n```\n\n#### Required Test Variants {#required-test-variants}\n\n1. **basic**: Core functionality without authentication\n2. **backup**: Tests backup integration\n3. **https**: Tests SSL/TLS integration  \n4. **ldap**: Tests LDAP authentication\n5. **monitoring**: Tests Prometheus integration\n6. **sso**: Tests Authelia SSO integration\n\n### 5. Update Flake Configuration {#update-flake-configuration}\n\nAdd to `flake.nix`:\n\n```nix\nallModules = [\n  # ... existing modules\n  modules/services/servicename.nix\n];\n```\n\n```nix\nchecks = {\n  # ... existing checks\n  // (vm_test \"servicename\" ./test/services/servicename.nix)\n};\n```\n\n### 6. Create Service Documentation {#create-service-documentation}\n\nCreate comprehensive documentation for the new service:\n\n**Location**: `modules/services/servicename/docs/default.md`\n\n```markdown\n# ServiceName Service {\\#services-servicename}\n\nBrief description of what the service does.\n\n## Features {\\#services-servicename-features}\n\n- Feature 1\n- Feature 2\n\n## Usage {\\#services-servicename-usage}\n\n### Basic Configuration {\\#services-servicename-basic}\n\nshb.servicename = {\n  enable = true;\n  domain = \"example.com\";\n  subdomain = \"servicename\";\n};\n\n### SSL Configuration {\\#services-servicename-ssl}\n\nshb.servicename.ssl.paths = {\n  cert = /path/to/cert;\n  key = /path/to/key;\n};\n\n## Options Reference {\\#services-servicename-options}\n\n{=include=} options\nid-prefix: services-servicename-options-\nlist-id: selfhostblocks-servicename-options\nsource: @OPTIONS_JSON@\n```\n\n**Important**: Use consistent heading ID patterns:\n- Service overview: `{\\#services-servicename}`  \n- Features: `{\\#services-servicename-features}`\n- Usage sections: `{\\#services-servicename-basic}`, `{\\#services-servicename-ssl}`, etc.\n- Options: `{\\#services-servicename-options}`\n\nNote: Replace `servicename` with your actual service name (e.g., `nzbget`, `jellyfin`).\n\nFor the `@OPTIONS_JSON@` to work, a line must be added\nin the `flake.nix` file:\n\n```nix\npackages.manualHtml = pkgs.callPackage ./docs {\n  modules = {\n    \"blocks/authelia\" = ./modules/blocks/authelia.nix;\n    // Add line and keep in alphabetical order.\n  };\n};\n```\n\n### 7. Update Redirects Automatically {#update-redirects-automatically}\n\nAfter creating documentation, generate the required redirects:\n\n```bash\n# Scan documentation and add missing redirects\nnix run .#update-redirects\n\n# Review the changes\ngit diff docs/redirects.json\n\n# The tool will show what redirects were added\n```\n\nThe automation will:\n- Find all heading IDs in your documentation\n- Generate appropriate redirect entries\n- Add them to `docs/redirects.json`\n- Follow established naming patterns\n\n### 8. Handle Unfree Dependencies {#handle-unfree-dependencies}\n\nIf the service requires unfree packages:\n\n```nix\n# In flake.nix\nconfig = {\n  allowUnfree = true;\n  permittedInsecurePackages = [\n    # List any required insecure packages\n  ];\n};\n```\n\nUpdate CI workflow if needed:\n```yaml\n# In .github/workflows/build.yaml\n- name: Setup Nix\n  uses: cachix/install-nix-action@v31\n  with:\n    extra_nix_config: |\n      allow-unfree = true\n```\n\n## Testing and Validation {#testing-and-validation}\n\n### Local Testing {#local-testing}\n```bash\n# Test redirect automation\nnix run .#update-redirects\n\n# Test all service variants (replace ${system} with your system, e.g., x86_64-linux)\nnix build .#checks.${system}.vm_servicename_basic\nnix build .#checks.${system}.vm_servicename_backup\nnix build .#checks.${system}.vm_servicename_https\nnix build .#checks.${system}.vm_servicename_ldap\nnix build .#checks.${system}.vm_servicename_monitoring\nnix build .#checks.${system}.vm_servicename_sso\n\n# Or run all tests (as recommended in docs/contributing.md)\nnix flake check\n\n# For interactive testing and debugging, see docs/contributing.md:\n# nix run .#checks.${system}.vm_servicename_basic.driverInteractive\n\n# Test documentation build (includes redirect validation)\nnix build .#manualHtml\n```\n\nTo continuously rebuild the documentation of file change, run the following command.\nTo exit, you'll need to do Ctrl-C twice in a row.\n\n```bash\nnix run .#manualHtml-watch\n```\n\n### Iterative Development Approach {#iterative-development-approach}\n\n1. **Start with basic functionality** - get core service working\n2. **Add SSL integration** - enable HTTPS\n3. **Add backup integration** - ensure data protection\n4. **Add monitoring** - implement health checks\n5. **Add authentication** - LDAP and SSO integration\n6. **Create documentation** - write service documentation with heading IDs\n7. **Update redirects** - run `nix run .#update-redirects` to generate redirects\n8. **Comprehensive testing** - all 6 test variants\n9. **Final validation** - ensure documentation builds correctly\n\n## Common Pitfalls and Solutions {#common-pitfalls-and-solutions}\n\n### Configuration Issues {#configuration-issues}\n- **Problem**: Service doesn't start due to config validation\n- **Solution**: Use `lib.mkDefault` for user settings, `lib.mkForce` for security settings\n\n### Authentication Integration {#authentication-integration-pitfalls}\n- **Problem**: SSO redirect loops or access denied\n- **Solution**: Check `autheliaRules` bypass patterns for API endpoints\n\n### Monitoring Failures {#monitoring-failures}\n- **Problem**: Prometheus scraping fails with 404\n- **Solution**: Verify the actual API endpoints the service provides\n\n### Test Failures {#test-failures}\n- **Problem**: VM tests timeout or fail connectivity\n- **Solution**: Check `waitForServices` and `waitForPorts` configurations\n\n### Nixpkgs Integration {#nixpkgs-integration}\n- **Problem**: Service options don't match SHB needs\n- **Solution**: Map SHB options to nixpkgs options, use `extraConfig` for overrides\n\n## Best Practices Summary {#best-practices-summary}\n\n1. **Follow existing patterns** - study deluge.nix and vaultwarden.nix\n2. **Use freeform configuration** - maximum flexibility with typed key options\n3. **Implement all contracts** - SSL, backup, monitoring, secrets\n4. **Test comprehensively** - all 6 integration variants\n5. **Security first** - localhost binding, proper permissions, secret management\n6. **Document thoroughly** - clear descriptions for all options\n7. **Iterative development** - build complexity gradually\n8. **CI/CD validation** - ensure all tests pass before submission\n\n## Redirect Management {#redirect-management}\n\nSelfHostBlocks 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.\n\n### Automated Redirect Generation {#automated-redirect-generation}\n\nSelfHostBlocks includes an automated redirect management tool that leverages the official `nixos-render-docs` ecosystem:\n\n```bash\n# Generate fresh redirects from HTML documentation\nnix run .#update-redirects\n```\n\nThis tool:\n- **Generates HTML documentation** using `nixos-render-docs` with redirect collection enabled\n- **Scans actual HTML files** for anchor IDs to ensure perfect accuracy\n- **Creates fresh redirects** from scratch by mapping anchors to their real file locations\n- **Filters system-generated anchors** (excludes `opt-*` and `selfhostblock*` entries)\n- **Provides interactive confirmation** before updating `docs/redirects.json`\n\n### How Redirects Work {#how-redirects-work}\n\n1. **nixos-render-docs validation**: During documentation builds, `nixos-render-docs` automatically validates that all heading IDs have corresponding redirect entries\n2. **Automated maintenance**: The `update-redirects` tool automatically maintains `redirects.json` by:\n   - Building HTML documentation with patched `nixos-render-docs`\n   - Scanning generated HTML files for actual anchor IDs and their file locations\n   - Creating accurate redirect mappings without guesswork or pattern matching\n3. **Manual override**: You can still manually edit `docs/redirects.json` for special cases\n\n### Redirect Patterns {#redirect-patterns}\n\nThe automation follows these patterns when mapping headings to redirect targets:\n\n| Heading ID | Source File | Redirect Target | \n|------------|-------------|-----------------|\n| `services-nzbget-basic` | `modules/services/nzbget/docs/default.md` | `[\"services-nzbget.html#services-nzbget-basic\"]` |\n| `blocks-monitoring` | `modules/blocks/monitoring/docs/default.md` | `[\"blocks-monitoring.html#blocks-monitoring\"]` |\n| `demo-nextcloud` | `demo/nextcloud/README.md` | `[\"demo-nextcloud.html#demo-nextcloud\"]` |\n| `contracts` | `docs/contracts.md` | `[\"contracts.html#contracts\"]` |\n\nNote: Redirects always include the anchor link (`#heading-id`) to jump to the specific heading within the target page.\n\n### Adding New Service Documentation {#adding-new-service-documentation}\n\nWhen implementing a new service, the redirect workflow is now automated:\n\n1. **Write documentation** with heading IDs:\n   ```markdown\n   # NewService {\\#services-newservice}\n   ## Basic Configuration {\\#services-newservice-basic}\n   ```\n\n2. **Update redirects automatically**:\n   ```bash\n   nix run .#update-redirects\n   ```\n\n3. **Review and commit** the changes:\n   ```bash\n   git add docs/redirects.json modules/services/newservice/docs/default.md\n   git commit -m \"Add newservice documentation\"\n   ```\n\n### Build-time Validation {#build-time-validation}\n\nThe documentation build process will fail if:\n- Any documentation heading ID lacks a corresponding redirect entry\n- Redirect targets point to non-existent content  \n- There are formatting errors in the redirects file\n\nThis ensures documentation links remain functional when content is moved or reorganized.\n\n## Resources {#resources}\n\n- **Contributing guide**: `docs/contributing.md` for authoritative development workflows and testing procedures\n- **Existing services**: `modules/services/` for patterns and implementation examples\n- **Contracts documentation**: `modules/contracts/` for understanding integration interfaces\n- **Test framework**: `test/common.nix` for testing utilities and patterns\n- **NixOS options**: https://search.nixos.org/options for upstream service options\n- **SHB documentation**: Generated docs showing existing service patterns\n- **Redirect automation**: `nix run .#update-redirects` for automated redirect management\n- **nixos-render-docs**: Built-in redirect validation and documentation generation\n\n## Quick Reference {#quick-reference}\n\n### Complete Workflow {#complete-workflow}\n```bash\n# 1. Implement service module\nvim modules/services/SERVICENAME.nix\n\n# 2. Create tests  \nvim test/services/SERVICENAME.nix\n\n# 3. Update flake\nvim flake.nix  # Add to allModules and checks\n\n# 4. Write documentation\nvim modules/services/SERVICENAME/docs/default.md\n\n# 5. Generate redirects\nnix run .#update-redirects\n\n# 6. Test everything  \nnix flake check  # Run all tests (recommended)\n# Or test specific variants:\n# nix build .#checks.${system}.vm_SERVICENAME_basic\nnix build .#manualHtml\n\n# 7. Commit changes\ngit add .\ngit commit -m \"Add SERVICENAME with full integration\"\n```\n\nThis guide provides a complete roadmap for implementing production-ready SelfHostBlocks services that meet the project's quality standards.\n"
  },
  {
    "path": "docs/services.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n# Services {#services}\n\nServices are usually web applications that SHB help you self-host some of your data.\nConfiguration of those is purposely made more opinionated than the upstream nixpkgs modules\nin exchange for an uniformized configuration experience.\nThat is possible thanks to the extensive use of blocks provided by SHB.\n\n::: {.note}\nNot all services are yet documented. You can find all available services [in the repository](@REPO@/modules/services).\n:::\n\nThe following table summarizes for each documented service what features it provides. More\ninformation is provided in the respective manual sections.\n\n| Service                     | Backup | Reverse Proxy | SSO | LDAP  | Monitoring | Profiling |\n|-----------------------------|--------|---------------|-----|-------|------------|-----------|\n| [*Arr][]                    | Y (1)  | Y             | Y   | Y (4) | Y (2)      | N         |\n| [Firefly-iii][]             | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n| [Forgejo][]                 | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n| [Home-Assistant][]          | Y (1)  | Y             | N   | Y     | Y (2)      | N         |\n| [Homepage][]                | Y (1)  | Y             | N   | Y     | Y (2)      | N         |\n| [Jellyfin][]                | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n| [Karakeep][]                | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n| [Nextcloud Server][]        | Y (1)  | Y             | Y   | Y     | Y (2)      | P (3)     |\n| [Open WebUI][]              | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n| [Pinchflat][]               | Y      | Y             | Y   | Y (4) | Y (5)      | N         |\n| [Simple NixOS Mailserver][] | Y      | Y             | N   | Y     | Y          | N         |\n| [Vaultwarden][]             | Y (1)  | Y             | Y   | Y     | Y (2)      | N         |\n\nLegend: **N**: no but WIP; **P**: partial; **Y**: yes\n\n1. Database and data files are backed up separately.\n   This could lead to backups not being in sync.\n   Any idea on how to fix this is welcomed!\n2. Dashboard is common to all services.\n3. Works but the traces are not exported to Grafana yet.\n4. Uses LDAP indirectly through forward auth.\n\n[*Arr]: services-arr.html\n[Firefly-iii]: services-firefly-iii.html\n[Forgejo]: services-forgejo.html\n[Home-Assistant]: services-home-assistant.html\n[Homepage]: services-homepage.html\n[Jellyfin]: services-jellyfin.html\n[Karakeep]: services-karakeep.html\n[Nextcloud Server]: services-nextcloud.html\n[Open WebUI]: services-open-webui.html\n[Pinchflat]: services-pinchflat.html\n[Simple NixOS Mailserver]: services-mailserver.html\n[Vaultwarden]: services-vaultwarden.html\n\n## Dashboard {#services-category-dashboard}\n\n```{=include=} chapters html:into-file=//services-homepage.html\nmodules/services/homepage/docs/default.md\n```\n\n## Documents {#services-category-documents}\n\n```{=include=} chapters html:into-file=//services-nextcloud.html\nmodules/services/nextcloud-server/docs/default.md\n```\n\n## Emails {#services-category-emails}\n\n```{=include=} chapters html:into-file=//services-mailserver.html\nmodules/services/mailserver/docs/default.md\n```\n\n## Passwords {#services-category-passwords}\n\n```{=include=} chapters html:into-file=//services-vaultwarden.html\nmodules/services/vaultwarden/docs/default.md\n```\n\n## Automation {#services-category-automation}\n\n```{=include=} chapters html:into-file=//services-home-assistant.html\nmodules/services/home-assistant/docs/default.md\n```\n\n## AI {#services-category-ai}\n\n```{=include=} chapters html:into-file=//services-karakeep.html\nmodules/services/karakeep/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//services-open-webui.html\nmodules/services/open-webui/docs/default.md\n```\n\n## Code {#services-category-code}\n\n```{=include=} chapters html:into-file=//services-forgejo.html\nmodules/services/forgejo/docs/default.md\n```\n\n## Media {#services-category-media}\n\n```{=include=} chapters html:into-file=//services-arr.html\nmodules/services/arr/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//services-jellyfin.html\nmodules/services/jellyfin/docs/default.md\n```\n\n```{=include=} chapters html:into-file=//services-pinchflat.html\nmodules/services/pinchflat/docs/default.md\n```\n\n## Finance {#services-category-finance}\n\n```{=include=} chapters html:into-file=//services-firefly-iii.html\nmodules/services/firefly-iii/docs/default.md\n```\n"
  },
  {
    "path": "docs/usage.md",
    "content": "<!-- Read these docs at https://shb.skarabox.com -->\n\n# Usage {#usage}\n\n## Flake {#usage-flake}\n\nSelf Host Blocks (SHB) is available as a flake. It also uses its own `pkgs.lib` and\n`nixpkgs` and it is required to use the provided ones as input for your\ndeployments, otherwise you might end up blocked when SHB patches a\nmodule, function or package. The following snippet is thus required to use Self\nHost Blocks:\n\n```nix\n{\n  inputs.selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n\n  outputs = { selfhostblocks, ... }: let\n    system = \"x86_64-linux\";\n    nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n  in\n    nixosConfigurations = {\n      myserver = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          selfhostblocks.nixosModules.default\n          ./configuration.nix\n        ];\n      };\n    };\n}\n```\n\n::: {.note}\nIn case somehow this documentation became stale,\nlook at the examples in [`./demo/minimal/flake.nix`](@REPO@/demo/minimal/flake.nix)\nwhich provides examples tested in CI - so assured to always be up to date -\non how to use SHB.\n:::\n\n### Modules {#usage-flake-modules}\n\nThe `default` module imports all modules except the SOPS module.\nThat module is only needed if you want to use [sops-nix](#usage-secrets) to manage secrets.\n\nYou can also import each module individually.\nYou might want to do this to only import SHB overlays if you actually intend to use them.\nImporting the `nextcloud` module for example will anyway transitively import needed support modules\nso you can't go wrong:\n\n```diff\n         modules = [\n-          selfhostblocks.nixosModules.default\n+          selfhostblocks.nixosModules.nextcloud\n           ./configuration.nix\n         ];\n```\n\nTo list all modules, run:\n\n```bash\n$ nix flake show github:ibizaman/selfhostblocks --allow-import-from-derivation\n\n...\n\n├───nixosModules\n│   ├───arr: NixOS module\n│   ├───audiobookshelf: NixOS module\n│   ├───authelia: NixOS module\n│   ├───borgbackup: NixOS module\n│   ├───davfs: NixOS module\n│   ├───default: NixOS module\n│   ├───deluge: NixOS module\n│   ├───forgejo: NixOS module\n│   ├───grocy: NixOS module\n│   ├───hardcodedsecret: NixOS module\n│   ├───hledger: NixOS module\n│   ├───home-assistant: NixOS module\n│   ├───immich: NixOS module\n│   ├───jellyfin: NixOS module\n│   ├───karakeep: NixOS module\n│   ├───lib: NixOS module\n│   ├───lldap: NixOS module\n│   ├───mitmdump: NixOS module\n│   ├───monitoring: NixOS module\n│   ├───nextcloud-server: NixOS module\n│   ├───nginx: NixOS module\n│   ├───open-webui: NixOS module\n│   ├───paperless: NixOS module\n│   ├───pinchflat: NixOS module\n│   ├───postgresql: NixOS module\n│   ├───restic: NixOS module\n│   ├───sops: NixOS module\n│   ├───ssl: NixOS module\n│   ├───tinyproxy: NixOS module\n│   ├───vaultwarden: NixOS module\n│   ├───vpn: NixOS module\n│   └───zfs: NixOS module\n\n...\n```\n\n### Patches {#usage-flake-patches}\n\nTo add your own patches on top of the patches provided by SHB,\nyou can remove the `patchedNixpkgs` line and instead apply the patches yourself:\n\n```diff\n-    nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n+    pkgs = import selfhostblocks.inputs.nixpkgs {\n+      inherit system;\n+    };\n+    nixpkgs' = pkgs.applyPatches {\n+      name = \"nixpkgs-patched\";\n+      src = selfhostblocks.inputs.nixpkgs;\n+      patches = selfhostblocks.lib.${system}.patches;\n+    };\n+    nixosSystem' = import \"${nixpkgs'}/nixos/lib/eval-config.nix\";\n   in\n     nixosConfigurations = {\n-      myserver = nixpkgs'.nixosSystem {\n+      myserver = nixosSystem' {\n```\n\n### Overlays {#usage-flake-overlays}\n\nSHB applies its own overlays using `nixpkgs.overlays`.\nEach module provided by SHB set that option if needed.\n\nIf you don't want to have those overlays applied for modules you don't intend to use SHB for,\nyou will want to avoid importing the `default` module\nand instead import only the module for the services or blocks you intend to use,\nlike shows in the [Modules](#usage-flake-modules) section.\n\n### Substituter {#usage-flake-substituter}\n\nYou can also use the public cache as a substituter with:\n\n```nix\nnix.settings.trusted-public-keys = [\n  \"selfhostblocks.cachix.org-1:H5h6Uj188DObUJDbEbSAwc377uvcjSFOfpxyCFP7cVs=\"\n];\n\nnix.settings.substituters = [\n  \"https://selfhostblocks.cachix.org\"\n];\n```\n\n### Unfree {#usage-flake-unfree}\n\nSHB does not necessarily attempt to provide only free packages.\nCurrently, the only module using unfree modules is the [Open WebUI](@REPO@/modules/services/open-webui.nix) one.\n\nTo be able to use that module, you can follow the [nixpkgs manual](https://nixos.org/manual/nixpkgs/stable/#sec-allow-unfree)\nand set either:\n\n```nix\n{\n  nixpkgs.config.allowUnfree = true;\n}\n```\n\nor the option `nixpkgs.config.allowUnfreePredicate`.\n\n### Tag Updates {#usage-flake-tag}\n\nTo pin SHB to a release/tag, you can either use an implicit or explicit way.\n\n#### Implicit {#usage-flake-tag-implicit}\n\nHere, use the usual `inputs` form:\n\n```nix\n{\n  inputs.selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n}\n```\n\nthen use the `flake update --override-input` command:\n\n```bash\nnix flake update selfhostblocks \\\n  --override-input selfhostblocks github:ibizaman/selfhostblocks/@VERSION@\n```\n\nNote that running `nix flake update` will update the version of SHB to the latest from the main branch,\ncanceling the override you just did above.\nSo beware when running that command.\n\n#### Explicit {#usage-flake-tag-explicit}\n\nHere, set the version in the input directly:\n\n```nix\n{\n  inputs.selfhostblocks.url = \"github:ibizaman/selfhostblocks?ref=@VERSION@\";\n}\n```\n\nNote that running `nix flake update` in this case will not update SHB,\nyou must update the tag explicitly then run `nix flake update`.\n\n### Auto Updates {#usage-flake-autoupdate}\n\nTo avoid burden on the maintainers to keep `nixpkgs` input updated with\nupstream, the [GitHub repository][repo] for SHB updates the\n`nixpkgs` input every couple days, and verifies all tests pass before\nautomatically merging the new `nixpkgs` version. The setup is explained in\n[this blog post][automerge].\n\n[repo]: https://github.com/ibizaman/selfhostblocks\n[automerge]: https://blog.tiserbox.com/posts/2023-12-25-automated-flake-lock-update-pull-requests-and-merging.html\n\n### Lib {#usage-flake-lib}\n\nThe `selfhostblocks.nixosModules.lib` module\nadds a module argument called `shb` by setting the\n`_module.args.shb` option.\nIt is imported by nearly all other SHB modules\nbut you could still import it on its own\nif you want to access SHB's functions and no other module.\n\nThe library of functions is also available under the traditional\n`selfhostblocks.lib` flake output.\n\nThe functions layout is, in pseudo-code:\n\n- `shb.*` all functions from [`./lib/default.nix`](@REPO@/lib/default.nix).\n- `shb.contracts.*` all functions from [`./modules/contracts/default.nix`](@REPO@/modules/contracts/default.nix).\n- `shb.test.*` all functions from [`./test/common.nix`](@REPO@/test/common.nix).\n\n## Example Deployments {#usage-examples}\n\n### With Nixos-Rebuild {#usage-examples-nixosrebuild}\n\nThe following snippets show how to deploy SHB using the standard\ndeployment system [nixos-rebuild][nixos-rebuild].\n\n[nixos-rebuild]: https://nixos.org/manual/nixos/stable/#sec-changing-config\n\n```nix\n{\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: let\n    system = \"x86_64-linux\";\n    nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n  in {\n    nixosConfigurations = {\n      machine = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          selfhostblocks.nixosModules.default\n        ];\n      };\n    };\n  };\n}\n```\n\nThe above snippet assumes one machine to deploy to, so `nixpkgs` is defined\nexclusively by the `selfhostblocks` input. It is more likely that you have\nmultiple machines, some not using SHB, then you can do the following:\n\n```nix\n{\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs/nixos-unstable\";\n\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: {\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n    in\n      nixosConfigurations = {\n        machine1 = nixpkgs.lib.nixosSystem {\n        };\n\n        machine2 = nixpkgs'.lib.nixosSystem {\n          system = \"x86_64-linux\";\n          modules = [\n            selfhostblocks.nixosModules.default\n          ];\n        };\n      };\n  };\n}\n```\n\nIn the above snippet, `machine1` will use the `nixpkgs` version from your inputs\nwhile `machine2` will use the `nixpkgs` version from `selfhostblocks`.\n\n### With Colmena {#usage-examples-colmena}\n\nThe following snippets show how to deploy SHB using the deployment\nsystem [Colmena][Colmena].\n\n[colmena]: https://colmena.cli.rs\n\n```nix\n{\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: {\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n      pkgs' = import nixpkgs' {\n        inherit system;\n      };\n    in\n      colmena = {\n        meta = {\n          nixpkgs = pkgs';\n        };\n\n        machine = { selfhostblocks, ... }: {\n          imports = [\n            selfhostblocks.nixosModules.default\n          ];\n        };\n      };\n  };\n}\n```\n\nThe above snippet assumes one machine to deploy to, so `nixpkgs` is defined\nexclusively by the `selfhostblocks` input. It is more likely that you have\nmultiple machines, some not using SHB, in this case you can use the\n`colmena.meta.nodeNixpkgs` option:\n\n```nix\n{\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs/nixos-unstable\";\n\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: {\n    let\n      system = \"x86_64-linux\";\n      pkgs = import nixpkgs {\n        inherit system;\n      };\n\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n      pkgs' = import nixpkgs' {\n        inherit system;\n      };\n    in\n      colmena = {\n        meta = {\n          nixpkgs = pkgs;\n\n          nodeNixpkgs = {\n            machine2 = pkgs';\n          };\n        };\n\n        machine1 = ...;\n\n        machine2 = { selfhostblocks, ... }: {\n          imports = [\n            selfhostblocks.nixosModules.default\n          ];\n\n          # Machine specific configuration goes here.\n        };\n      };\n  };\n}\n```\n\nIn the above snippet, `machine1` will use the `nixpkgs` version from your inputs\nwhile `machine2` will use the `nixpkgs` version from `selfhostblocks`.\n\n### With Deploy-rs {#usage-examples-deploy-rs}\n\nThe following snippets show how to deploy SHB using the deployment\nsystem [deploy-rs][deploy-rs].\n\n[deploy-rs]: https://github.com/serokell/deploy-rs\n\n```nix\n{\n  inputs = {\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: {\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n      pkgs' = import nixpkgs' {\n        inherit system;\n      };\n\n      deployPkgs = import selfhostblocks.inputs.nixpkgs {\n        inherit system;\n        overlays = [\n          deploy-rs.overlay\n          (self: super: {\n            deploy-rs = {\n              inherit (pkgs') deploy-rs;\n              lib = super.deploy-rs.lib;\n            };\n          })\n        ];\n      };\n    in\n      nixosModules.machine = {\n        imports = [\n          selfhostblocks.nixosModules.default\n        ];\n      };\n\n      nixosConfigurations.machine = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          self.nixosModules.machine\n        ];\n      };\n\n      deploy.nodes.machine = {\n        hostname = ...;\n        sshUser = ...;\n        sshOpts = [ ... ];\n        profiles = {\n          system = {\n            user = \"root\";\n            path = deployPkgs.deploy-rs.lib.activate.nixos self.nixosConfigurations.machine;\n          };\n        };\n      };\n\n      # From https://github.com/serokell/deploy-rs?tab=readme-ov-file#overall-usage\n      checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib;\n  };\n}\n```\n\nThe above snippet assumes one machine to deploy to, so `nixpkgs` is defined\nexclusively by the `selfhostblocks` input. It is more likely that you have\nmultiple machines, some not using SHB, in this case you can do:\n\n```nix\n{\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs/nixos-unstable\";\n\n    selfhostblocks.url = \"github:ibizaman/selfhostblocks\";\n  };\n\n  outputs = { self, selfhostblocks }: {\n    let\n      system = \"x86_64-linux\";\n      nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;\n      pkgs' = import nixpkgs' {\n        inherit system;\n      };\n\n      deployPkgs = import selfhostblocks.inputs.nixpkgs {\n        inherit system;\n        overlays = [\n          deploy-rs.overlay\n          (self: super: {\n            deploy-rs = {\n              inherit (pkgs') deploy-rs;\n              lib = super.deploy-rs.lib;\n            };\n          })\n        ];\n      };\n    in\n      nixosModules.machine1 = {\n        # ...\n      };\n\n      nixosModules.machine2 = {\n        imports = [\n          selfhostblocks.nixosModules.default\n        ];\n      };\n\n      nixosConfigurations.machine1 = nixpkgs.lib.nixosSystem {\n        inherit system;\n        modules = [\n          self.nixosModules.machine1\n        ];\n      };\n\n      nixosConfigurations.machine2 = nixpkgs'.nixosSystem {\n        inherit system;\n        modules = [\n          self.nixosModules.machine2\n        ];\n      };\n\n      deploy.nodes.machine1 = {\n        hostname = ...;\n        sshUser = ...;\n        sshOpts = [ ... ];\n        profiles = {\n          system = {\n            user = \"root\";\n            path = deployPkgs.deploy-rs.lib.activate.nixos self.nixosConfigurations.machine1;\n          };\n        };\n      };\n\n      deploy.nodes.machine2 = # Similar here\n\n      # From https://github.com/serokell/deploy-rs?tab=readme-ov-file#overall-usage\n      checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib;\n  };\n}\n```\n\nIn the above snippet, `machine1` will use the `nixpkgs` version from your inputs\nwhile `machine2` will use the `nixpkgs` version from `selfhostblocks`.\n\n## Secrets with sops-nix {#usage-secrets}\n\nThis section complements the official\n[sops-nix](https://github.com/Mic92/sops-nix) guide.\n\nManaging secrets is an important aspect of deploying. You cannot store your\nsecrets in nix directly because they get stored unencrypted and you don't want\nthat. We need to use another system that encrypts secrets when storing in the\nnix store and then decrypts them on the target host upon system activation.\n`sops-nix` is one of such system.\n\nSops-nix works by encrypting the secrets file with at least 2 keys. Your private\nkey and a private key from the target host. This way, you can edit the secrets\nand the target host can decrypt the secrets. Separating the keys this way is\ngood practice because it reduces the impact of having one being compromised.\n\nOne way to setup secrets management using `sops-nix`:\n\n1. Create your own private key that will be located in `keys.txt`. The public\n   key will be printed on stdout.\n   ```bash\n   $ nix shell nixpkgs#age --command age-keygen -o keys.txt\n   Public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\n   ```\n2. Get the target host's public key. We will use the key derived from the ssh\n   key of the host.\n   ```bash\n   $ nix shell nixpkgs#ssh-to-age --command \\\n       sh -c 'ssh-keyscan -t ed25519 -4 <target_host> | ssh-to-age'\n   # localhost:2222 SSH-2.0-OpenSSH_9.6\n   age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8\n   ```\n3. Create a `sops.yaml` file that explains how sops-nix should encrypt the - yet\n   to be created - `secrets.yaml` file. You can be creative here, but a basic\n   snippet is:\n   ```bash\n   keys:\n     - &me age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\n     - &target age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8\n   creation_rules:\n     - path_regex: secrets.yaml$\n       key_groups:\n       - age:\n         - *me\n         - *target\n   ```\n4. Create a `secrets.yaml` file that will contain the encrypted secrets as a\n   Yaml file:\n   ```bash\n   $ SOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \\\n     secrets.yaml\n   ```\n   This will open your preferred editor. An example of yaml file is the\n   following (secrets are elided for brevity):\n   ```yaml\n   nextcloud:\n     adminpass: 43bb4b...\n     onlyoffice:\n       jwt_secret: 3a10fce3...\n   ```\n   The actual file on your filesystem will look like so, again with data elided:\n   ```yaml\n   nextcloud:\n     adminpass: ENC[AES256_GCM,data:Tt99...GY=,tag:XlAqRYidkOMRZAPBsoeEMw==,type:str]\n     onlyoffice:\n       jwt_secret: ENC[AES256_GCM,data:f87a...Yg=,tag:Y1Vg2WqDnJbl1Xg2B6W1Hg==,type:str]\n   sops:\n     kms: []\n     gcp_kms: []\n     azure_kv: []\n     hc_vault: []\n     age:\n       - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7\n         enc: |\n           -----BEGIN AGE ENCRYPTED FILE-----\n           YWdl...6g==\n           -----END AGE ENCRYPTED FILE-----\n       - recipient: age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8\n         enc: |\n           -----BEGIN AGE ENCRYPTED FILE-----\n           YWdl...RA==\n           -----END AGE ENCRYPTED FILE-----\n     lastmodified: \"2024-01-28T06:07:02Z\"\n     mac: ENC[AES256_GCM,data:lDJh...To=,tag:Opon9lMZBv5S7rRhkGFuQQ==,type:str]\n     pgp: []\n     unencrypted_suffix: _unencrypted\n     version: 3.8.1\n   ```\n\n   To actually create random secrets, you can use:\n   ```bash\n   $ nix run nixpkgs#openssl -- rand -hex 64\n   ```\n5. Use `sops-nix` module in nix:\n   ```bash\n   imports = [\n     inputs.sops-nix.nixosModules.default\n     inputs.selfhostblocks.nixosModules.sops\n   ];\n   ```\n   Import also the `sops` module provided by SHB.\n6. Set default sops file:\n   ```bash\n   sops.defaultSopsFile = ./secrets.yaml;\n   ```\n   Setting the default this way makes all sops instances use that same file.\n7. Reference the secrets in nix:\n   ```nix\n   shb.sops.secret.\"nextcloud/adminpass\".request = config.shb.nextcloud.adminPass.request;\n   shb.nextcloud.adminPass.result = config.shb.sops.secret.\"nextcloud/adminpass\".result;\n   ```\n   The above snippet uses the [secrets contract](./contracts-secret.html) and\n   [sops block](./blocks-sops.html) to ease the configuration.\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"SelfHostBlocks module\";\n\n  inputs = {\n    nixpkgs.url = \"github:nixos/nixpkgs/nixos-unstable\";\n    nix-flake-tests.url = \"github:antifuchs/nix-flake-tests\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n    nmdsrc = {\n      url = \"git+https://git.sr.ht/~rycee/nmd\";\n      flake = false;\n    };\n  };\n\n  outputs =\n    inputs@{\n      self,\n      nixpkgs,\n      nix-flake-tests,\n      flake-utils,\n      nmdsrc,\n      ...\n    }:\n    let\n      shbPatches =\n        system:\n        nixpkgs.legacyPackages.${system}.lib.optionals\n          (system == \"x86_64-linux\" || system == \"aarch64-linux\")\n          [\n            # Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.\n            ./patches/lldap.patch\n            ./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch\n\n            # Leaving commented out as an example.\n            # (originPkgs.fetchpatch {\n            #   url = \"https://github.com/NixOS/nixpkgs/pull/317107.patch\";\n            #   hash = \"sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk=\";\n            # })\n          ];\n\n      patchNixpkgs =\n        {\n          nixpkgs,\n          patches,\n          system,\n        }:\n        nixpkgs.legacyPackages.${system}.applyPatches {\n          name = \"nixpkgs-patched\";\n          src = nixpkgs;\n          inherit patches;\n        };\n      patchedNixpkgs =\n        system:\n        let\n          patched = patchNixpkgs {\n            nixpkgs = inputs.nixpkgs;\n            patches = shbPatches system;\n            inherit system;\n          };\n        in\n        patched\n        // {\n          nixosSystem = args: import \"${patched}/nixos/lib/eval-config.nix\" args;\n        };\n      pkgs' =\n        system:\n        import (patchedNixpkgs system) {\n          inherit system;\n          config.allowUnfree = true;\n        };\n    in\n    flake-utils.lib.eachDefaultSystem (\n      system:\n      let\n        pkgs = pkgs' system;\n\n        # The contract dummies are used to show options for contracts.\n        contractDummyModules = [\n          modules/contracts/backup/dummyModule.nix\n          modules/contracts/dashboard/dummyModule.nix\n          modules/contracts/databasebackup/dummyModule.nix\n          modules/contracts/secret/dummyModule.nix\n          modules/contracts/ssl/dummyModule.nix\n        ];\n      in\n      {\n        formatter = pkgs.nixfmt-tree;\n\n        packages.manualHtml = pkgs.callPackage ./docs {\n          inherit nmdsrc;\n          allModules =\n            self.nixosModules.default.imports\n            ++ [\n              self.nixosModules.sops\n            ]\n            ++ contractDummyModules;\n          release = builtins.readFile ./VERSION;\n\n          substituteVersionIn = [\n            \"./manual.md\"\n            \"./usage.md\"\n          ];\n          modules = {\n            \"blocks/authelia\" = ./modules/blocks/authelia.nix;\n            \"blocks/borgbackup\" = ./modules/blocks/borgbackup.nix;\n            \"blocks/lldap\" = ./modules/blocks/lldap.nix;\n            \"blocks/ssl\" = {\n              module = ./modules/blocks/ssl.nix;\n              optionRoot = [\n                \"shb\"\n                \"certs\"\n              ];\n            };\n            \"blocks/mitmdump\" = ./modules/blocks/mitmdump.nix;\n            \"blocks/monitoring\" = ./modules/blocks/monitoring.nix;\n            \"blocks/nginx\" = ./modules/blocks/nginx.nix;\n            \"blocks/postgresql\" = ./modules/blocks/postgresql.nix;\n            \"blocks/restic\" = ./modules/blocks/restic.nix;\n            \"blocks/sops\" = ./modules/blocks/sops.nix;\n            \"services/arr\" = ./modules/services/arr.nix;\n            \"services/firefly-iii\" = ./modules/services/firefly-iii.nix;\n            \"services/forgejo\" = [\n              ./modules/services/forgejo.nix\n              (pkgs.path + \"/nixos/modules/services/misc/forgejo.nix\")\n            ];\n            \"services/home-assistant\" = ./modules/services/home-assistant.nix;\n            \"services/homepage\" = ./modules/services/homepage.nix;\n            \"services/jellyfin\" = ./modules/services/jellyfin.nix;\n            \"services/karakeep\" = ./modules/services/karakeep.nix;\n            \"services/mailserver\" = ./modules/services/mailserver.nix;\n            \"services/nextcloud-server\" = {\n              module = ./modules/services/nextcloud-server.nix;\n              optionRoot = [\n                \"shb\"\n                \"nextcloud\"\n              ];\n            };\n            \"services/open-webui\" = ./modules/services/open-webui.nix;\n            \"services/pinchflat\" = ./modules/services/pinchflat.nix;\n            \"services/vaultwarden\" = ./modules/services/vaultwarden.nix;\n            \"contracts/backup\" = {\n              module = ./modules/contracts/backup/dummyModule.nix;\n              optionRoot = [\n                \"shb\"\n                \"contracts\"\n                \"backup\"\n              ];\n            };\n            \"contracts/dashboard\" = {\n              module = ./modules/contracts/dashboard/dummyModule.nix;\n              optionRoot = [\n                \"shb\"\n                \"contracts\"\n                \"dashboard\"\n              ];\n            };\n            \"contracts/databasebackup\" = {\n              module = ./modules/contracts/databasebackup/dummyModule.nix;\n              optionRoot = [\n                \"shb\"\n                \"contracts\"\n                \"databasebackup\"\n              ];\n            };\n            \"contracts/secret\" = {\n              module = ./modules/contracts/secret/dummyModule.nix;\n              optionRoot = [\n                \"shb\"\n                \"contracts\"\n                \"secret\"\n              ];\n            };\n            \"contracts/ssl\" = {\n              module = ./modules/contracts/ssl/dummyModule.nix;\n              optionRoot = [\n                \"shb\"\n                \"contracts\"\n                \"ssl\"\n              ];\n            };\n          };\n        };\n\n        # Documentation redirect generation tool - scans HTML files for anchor mappings\n        packages.generateRedirects =\n          let\n            # Python patch to inject redirect collector\n            pythonPatch = pkgs.writeText \"nixos-render-docs-patch.py\" ''\n              # Load redirect collector patch\n              try:\n                  import sys, os\n                  sys.path.insert(0, os.path.dirname(__file__) + '/..')\n                  import missing_refs_collector\n              except Exception as e:\n                  print(f\"Warning: Failed to load redirect collector: {e}\", file=sys.stderr)\n            '';\n\n            # Patched nixos-render-docs that collects redirects during HTML generation\n            nixos-render-docs-patched = pkgs.writeShellApplication {\n              name = \"nixos-render-docs\";\n              runtimeInputs = [ pkgs.nixos-render-docs ];\n              text = ''\n                TEMP_DIR=$(mktemp -d); trap 'rm -rf \"$TEMP_DIR\"' EXIT\n\n                cp -r ${pkgs.nixos-render-docs}/${pkgs.python3.sitePackages}/nixos_render_docs \"$TEMP_DIR/\"\n                chmod -R +w \"$TEMP_DIR\"\n                cp ${./docs/generate-redirects-nixos-render-docs.py} \"$TEMP_DIR/missing_refs_collector.py\"\n                echo '{}' > \"$TEMP_DIR/empty_redirects.json\"\n                cat ${pythonPatch} >> \"$TEMP_DIR/nixos_render_docs/__init__.py\"\n\n                ARGS=()\n                while [[ $# -gt 0 ]]; do\n                  case $1 in\n                    --redirects) ARGS+=(\"$1\" \"$TEMP_DIR/empty_redirects.json\"); shift 2 ;;\n                    *) ARGS+=(\"$1\"); shift ;;\n                  esac\n                done\n\n                export PYTHONPATH=\"$TEMP_DIR:''${PYTHONPATH:-}\"\n                nixos-render-docs \"''${ARGS[@]}\"\n              '';\n            };\n          in\n          (self.packages.${system}.manualHtml.override {\n            nixos-render-docs = nixos-render-docs-patched;\n          }).overrideAttrs\n            (old: {\n              installPhase = ''\n                ${old.installPhase}\n                ln -sf share/doc/selfhostblocks/redirects.json $out/redirects.json\n              '';\n            });\n\n        packages.manualHtml-watch = pkgs.writeShellApplication {\n          name = \"manualHtml-watch\";\n          runtimeInputs = [\n            pkgs.findutils\n            pkgs.entr\n          ];\n          text = ''\n            while sleep 1; do\n              find . -name \"*.nix\" -o -name \"*.md\" \\\n                | entr -d sh -c '(nix run --offline .#update-redirects && nix build --offline .#manualHtml)' || :\n            done\n          '';\n        };\n\n        lib = (pkgs.callPackage ./lib { }) // {\n          test = pkgs.callPackage ./test/common.nix { };\n          contracts = pkgs.callPackage ./modules/contracts {\n            shb = self.lib.${system};\n          };\n          patches = shbPatches system;\n          inherit patchNixpkgs;\n          patchedNixpkgs = patchedNixpkgs system;\n        };\n\n        # To see the traces, run:\n        #   nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip\n        packages.playwright = pkgs.callPackage (\n          {\n            stdenvNoCC,\n            makeWrapper,\n            playwright,\n          }:\n          stdenvNoCC.mkDerivation {\n            name = \"playwright\";\n\n            src = playwright;\n\n            nativeBuildInputs = [\n              makeWrapper\n            ];\n\n            # No quotes around the value for LLDAP_PASSWORD because we want the value to not be enclosed in quotes.\n            installPhase = ''\n              makeWrapper ${pkgs.python3Packages.playwright}/bin/playwright $out/bin/playwright \\\n                --set PLAYWRIGHT_BROWSERS_PATH ${pkgs.playwright-driver.browsers} \\\n                --set PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS true\n            '';\n          }\n        ) { };\n\n        # Run \"nix run .#update-redirects\" to regenerate docs/redirects.json\n        apps.update-redirects = {\n          type = \"app\";\n          program = \"${\n            pkgs.writeShellApplication {\n              name = \"update-redirects\";\n              runtimeInputs = [\n                pkgs.nix\n                pkgs.jq\n              ];\n              text = ''\n                echo \"=== SelfHostBlocks Redirects Updater ===\"\n                echo \"Generating fresh ./docs/redirects.json...\"\n\n                nix build .#generateRedirects || { echo \"Error: Failed to generate redirects\" >&2; exit 1; }\n                [[ -f result/redirects.json ]] || { echo \"Error: Generated redirects file not found\" >&2; exit 1; }\n\n                echo \"Generated $(jq 'keys | length' result/redirects.json) redirects\"\n\n                [[ -f docs/redirects.json ]] && cp docs/redirects.json docs/redirects.json.backup && echo \"Created backup\"\n                cp result/redirects.json docs/redirects.json\n                echo \"  Updated docs/redirects.json\"\n                echo \"To verify: nix build .#manualHtml\"\n              '';\n            }\n          }/bin/update-redirects\";\n        };\n      }\n    )\n    // flake-utils.lib.eachSystem [ \"x86_64-linux\" ] (\n      system:\n      let\n        pkgs = pkgs' system;\n      in\n      {\n        checks =\n          let\n            inherit (pkgs.lib)\n              foldl\n              foldlAttrs\n              mergeAttrs\n              ;\n\n            importFiles =\n              files:\n              map (\n                m:\n                pkgs.callPackage m {\n                  shb = self.lib.${system};\n                }\n              ) files;\n\n            mergeTests = foldl mergeAttrs { };\n\n            flattenAttrs =\n              root: attrset:\n              foldlAttrs (\n                acc: name: value:\n                acc\n                // {\n                  \"${root}_${name}\" = value;\n                }\n              ) { } attrset;\n\n            vm_test =\n              name: path:\n              flattenAttrs \"vm_${name}\" (\n                removeAttrs\n                  (pkgs.callPackage path {\n                    shb = self.lib.${system};\n                  })\n                  [\n                    \"override\"\n                    \"overrideDerivation\"\n                  ]\n              );\n          in\n          (\n            {\n              modules = self.lib.${system}.check {\n                inherit pkgs;\n                tests = mergeTests (importFiles [\n                  ./test/modules/davfs.nix\n                  # TODO: Make this not use IFD\n                  ./test/modules/lib.nix\n                ]);\n              };\n\n              # TODO: Make this not use IFD\n              lib = nix-flake-tests.lib.check {\n                inherit pkgs;\n                tests = pkgs.callPackage ./test/modules/lib.nix {\n                  shb = self.lib.${system};\n                };\n              };\n            }\n            // (vm_test \"arr\" ./test/services/arr.nix)\n            // (vm_test \"audiobookshelf\" ./test/services/audiobookshelf.nix)\n            // (vm_test \"deluge\" ./test/services/deluge.nix)\n            // (vm_test \"firefly-iii\" ./test/services/firefly-iii.nix)\n            // (vm_test \"forgejo\" ./test/services/forgejo.nix)\n            // (vm_test \"grocy\" ./test/services/grocy.nix)\n            // (vm_test \"hledger\" ./test/services/hledger.nix)\n            // (vm_test \"immich\" ./test/services/immich.nix)\n            // (vm_test \"homeassistant\" ./test/services/home-assistant.nix)\n            // (vm_test \"homepage\" ./test/services/homepage.nix)\n            // (vm_test \"jellyfin\" ./test/services/jellyfin.nix)\n            // (vm_test \"karakeep\" ./test/services/karakeep.nix)\n            // (vm_test \"nextcloud\" ./test/services/nextcloud.nix)\n            // (vm_test \"open-webui\" ./test/services/open-webui.nix)\n            // (vm_test \"paperless\" ./test/services/paperless.nix)\n            // (vm_test \"pinchflat\" ./test/services/pinchflat.nix)\n            // (vm_test \"vaultwarden\" ./test/services/vaultwarden.nix)\n\n            // (vm_test \"authelia\" ./test/blocks/authelia.nix)\n            // (vm_test \"borgbackup\" ./test/blocks/borgbackup.nix)\n            // (vm_test \"lldap\" ./test/blocks/lldap.nix)\n            // (vm_test \"lib\" ./test/blocks/lib.nix)\n            // (vm_test \"mitmdump\" ./test/blocks/mitmdump.nix)\n            // (vm_test \"monitoring\" ./test/blocks/monitoring.nix)\n            // (vm_test \"postgresql\" ./test/blocks/postgresql.nix)\n            // (vm_test \"restic\" ./test/blocks/restic.nix)\n            // (vm_test \"ssl\" ./test/blocks/ssl.nix)\n\n            // (vm_test \"contracts-backup\" ./test/contracts/backup.nix)\n            // (vm_test \"contracts-databasebackup\" ./test/contracts/databasebackup.nix)\n            // (vm_test \"contracts-secret\" ./test/contracts/secret.nix)\n          );\n\n      }\n    )\n    // {\n      herculesCI.ciSystems = [ \"x86_64-linux\" ];\n\n      nixosModules.default = {\n        imports = [\n          # blocks\n          self.nixosModules.authelia\n          self.nixosModules.borgbackup\n          self.nixosModules.davfs\n          self.nixosModules.hardcodedsecret\n          self.nixosModules.lldap\n          self.nixosModules.mitmdump\n          self.nixosModules.monitoring\n          self.nixosModules.nginx\n          self.nixosModules.postgresql\n          self.nixosModules.restic\n          self.nixosModules.ssl\n          self.nixosModules.tinyproxy\n          self.nixosModules.vpn\n          self.nixosModules.zfs\n\n          # services\n          self.nixosModules.arr\n          self.nixosModules.audiobookshelf\n          self.nixosModules.deluge\n          self.nixosModules.firefly-iii\n          self.nixosModules.forgejo\n          self.nixosModules.grocy\n          self.nixosModules.hledger\n          self.nixosModules.immich\n          self.nixosModules.home-assistant\n          self.nixosModules.homepage\n          self.nixosModules.jellyfin\n          self.nixosModules.karakeep\n          self.nixosModules.mailserver\n          self.nixosModules.nextcloud-server\n          self.nixosModules.open-webui\n          self.nixosModules.pinchflat\n          self.nixosModules.paperless\n          self.nixosModules.vaultwarden\n        ];\n      };\n\n      nixosModules.lib = lib/module.nix;\n\n      nixosModules.authelia = modules/blocks/authelia.nix;\n      nixosModules.borgbackup = modules/blocks/borgbackup.nix;\n      nixosModules.davfs = modules/blocks/davfs.nix;\n      nixosModules.hardcodedsecret = modules/blocks/hardcodedsecret.nix;\n      nixosModules.lldap = modules/blocks/lldap.nix;\n      nixosModules.mitmdump = modules/blocks/mitmdump.nix;\n      nixosModules.monitoring = modules/blocks/monitoring.nix;\n      nixosModules.nginx = modules/blocks/nginx.nix;\n      nixosModules.postgresql = modules/blocks/postgresql.nix;\n      nixosModules.restic = modules/blocks/restic.nix;\n      nixosModules.ssl = modules/blocks/ssl.nix;\n      nixosModules.sops = modules/blocks/sops.nix;\n      nixosModules.tinyproxy = modules/blocks/tinyproxy.nix;\n      nixosModules.vpn = modules/blocks/vpn.nix;\n      nixosModules.zfs = modules/blocks/zfs.nix;\n\n      nixosModules.arr = modules/services/arr.nix;\n      nixosModules.audiobookshelf = modules/services/audiobookshelf.nix;\n      nixosModules.deluge = modules/services/deluge.nix;\n      nixosModules.firefly-iii = modules/services/firefly-iii.nix;\n      nixosModules.forgejo = modules/services/forgejo.nix;\n      nixosModules.grocy = modules/services/grocy.nix;\n      nixosModules.hledger = modules/services/hledger.nix;\n      nixosModules.immich = modules/services/immich.nix;\n      nixosModules.home-assistant = modules/services/home-assistant.nix;\n      nixosModules.homepage = modules/services/homepage.nix;\n      nixosModules.jellyfin = modules/services/jellyfin.nix;\n      nixosModules.karakeep = modules/services/karakeep.nix;\n      nixosModules.mailserver = modules/services/mailserver.nix;\n      nixosModules.nextcloud-server = modules/services/nextcloud-server.nix;\n      nixosModules.open-webui = modules/services/open-webui.nix;\n      nixosModules.paperless = modules/services/paperless.nix;\n      nixosModules.pinchflat = modules/services/pinchflat.nix;\n      nixosModules.vaultwarden = modules/services/vaultwarden.nix;\n    };\n}\n"
  },
  {
    "path": "lib/default.nix",
    "content": "{ pkgs, lib }:\nlet\n  inherit (builtins) isAttrs hasAttr;\n  inherit (lib) any concatMapStringsSep concatStringsSep;\n  shb = rec {\n    # Replace secrets in a file.\n    # - userConfig is an attrset that will produce a config file.\n    # - resultPath is the location the config file should have on the filesystem.\n    # - generator is a function taking two arguments name and value and returning path in the nix\n    #   nix store where the\n    replaceSecrets =\n      {\n        userConfig,\n        resultPath,\n        generator,\n        user ? null,\n        permissions ? \"u=r,g=r,o=\",\n      }:\n      let\n        configWithTemplates = withReplacements userConfig;\n\n        nonSecretConfigFile = generator \"template\" configWithTemplates;\n\n        replacements = getReplacements userConfig;\n      in\n      replaceSecretsScript {\n        file = nonSecretConfigFile;\n        inherit resultPath replacements;\n        inherit user permissions;\n      };\n\n    replaceSecretsFormatAdapter = format: format.generate;\n    replaceSecretsGeneratorAdapter =\n      generator: name: value:\n      pkgs.writeText \"generator \" (generator value);\n    toEnvVar = replaceSecretsGeneratorAdapter (\n      v: (lib.generators.toINIWithGlobalSection { } { globalSection = v; })\n    );\n\n    template =\n      file: newPath: replacements:\n      replaceSecretsScript {\n        inherit file replacements;\n        resultPath = newPath;\n      };\n\n    genReplacement =\n      secret:\n      let\n        t =\n          {\n            transform ? null,\n            ...\n          }:\n          if isNull transform then x: x else transform;\n      in\n      lib.attrsets.nameValuePair (secretName secret.name) ((t secret) \"$(cat ${toString secret.source})\");\n\n    replaceSecretsScript =\n      {\n        file,\n        resultPath,\n        replacements,\n        user ? null,\n        permissions ? \"u=r,g=r,o=\",\n      }:\n      let\n        templatePath = resultPath + \".template\";\n\n        # We check that the files containing the secrets have the\n        # correct permissions for us to read them in this separate\n        # step. Otherwise, the $(cat ...) commands inside the sed\n        # replacements could fail but not fail individually but\n        # not fail the whole script.\n        checkPermissions = concatMapStringsSep \"\\n\" (\n          pattern: \"cat ${pattern.source} > /dev/null\"\n        ) replacements;\n\n        sedPatterns = concatMapStringsSep \" \" (pattern: \"-e \\\"s|${pattern.name}|${pattern.value}|\\\"\") (\n          map genReplacement replacements\n        );\n\n        sedCmd = if replacements == [ ] then \"cat\" else \"${pkgs.gnused}/bin/sed ${sedPatterns}\";\n      in\n      ''\n        set -euo pipefail\n\n        ${checkPermissions}\n\n        mkdir -p $(dirname ${templatePath})\n        ln -fs ${file} ${templatePath}\n        rm -f ${resultPath}\n        touch ${resultPath}\n      ''\n      + (lib.optionalString (user != null) ''\n        chown ${user} ${resultPath}\n      '')\n      + ''\n        ${sedCmd} ${templatePath} > ${resultPath}\n        chmod ${permissions} ${resultPath}\n      '';\n\n    secretFileType = lib.types.submodule {\n      options = {\n        source = lib.mkOption {\n          type = lib.types.path;\n          description = \"File containing the value.\";\n        };\n\n        transform = lib.mkOption {\n          type = lib.types.raw;\n          description = \"An optional function to transform the secret.\";\n          default = null;\n          example = lib.literalExpression ''\n            v: \"prefix-$${v}-suffix\"\n          '';\n        };\n      };\n    };\n\n    secretName =\n      names: \"%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: \"_\" + s) names)}%\";\n\n    withReplacements =\n      attrs:\n      let\n        valueOrReplacement =\n          name: value: if !(builtins.isAttrs value && value ? \"source\") then value else secretName name;\n      in\n      mapAttrsRecursiveCond (v: !v ? \"source\") valueOrReplacement attrs;\n\n    getReplacements =\n      attrs:\n      let\n        addNameField =\n          name: value:\n          if !(builtins.isAttrs value && value ? \"source\") then value else value // { name = name; };\n\n        secretsWithName = mapAttrsRecursiveCond (v: !v ? \"source\") addNameField attrs;\n      in\n      collect (v: builtins.isAttrs v && v ? \"source\") secretsWithName;\n\n    # Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists.\n    mapAttrsRecursiveCond =\n      # A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set.\n      cond:\n      # A function, given a list of attribute names and a value, returns a new value.\n      f:\n      # Attribute set or list to recursively map over.\n      set:\n      let\n        recurse =\n          path: val:\n          if builtins.isAttrs val && cond val then\n            lib.attrsets.mapAttrs (n: v: recurse (path ++ [ n ]) v) val\n          else if builtins.isList val && cond val then\n            lib.lists.imap0 (i: v: recurse (path ++ [ (builtins.toString i) ]) v) val\n          else\n            f path val;\n      in\n      recurse [ ] set;\n\n    # Like lib.attrsets.collect but also recurses on lists.\n    collect =\n      # Given an attribute's value, determine if recursion should stop.\n      pred:\n      # The attribute set to recursively collect.\n      attrs:\n      if pred attrs then\n        [ attrs ]\n      else if builtins.isAttrs attrs then\n        lib.lists.concatMap (collect pred) (lib.attrsets.attrValues attrs)\n      else if builtins.isList attrs then\n        lib.lists.concatMap (collect pred) attrs\n      else\n        [ ];\n\n    indent =\n      i: str:\n      lib.concatMapStringsSep \"\\n\" (x: (lib.strings.replicate i \" \") + x) (lib.splitString \"\\n\" str);\n\n    # Generator for XML\n    formatXML =\n      {\n        enclosingRoot ? null,\n      }:\n      {\n        type =\n          with lib.types;\n          let\n            valueType =\n              nullOr (oneOf [\n                bool\n                int\n                float\n                str\n                path\n                (attrsOf valueType)\n                (listOf valueType)\n              ])\n              // {\n                description = \"XML value\";\n              };\n          in\n          valueType;\n\n        generate =\n          name: value:\n          pkgs.callPackage (\n            { runCommand, python3 }:\n            runCommand \"config\"\n              {\n                value = builtins.toJSON (if enclosingRoot == null then value else { ${enclosingRoot} = value; });\n                passAsFile = [ \"value\" ];\n              }\n              (\n                pkgs.writers.writePython3 \"dict2xml\"\n                  {\n                    libraries = with python3.pkgs; [\n                      python\n                      dict2xml\n                    ];\n                  }\n                  ''\n                    import os\n                    import json\n                    from dict2xml import dict2xml\n\n                    with open(os.environ[\"valuePath\"]) as f:\n                        content = json.loads(f.read())\n                        if content is None:\n                            print(\"Could not parse env var valuePath as json\")\n                            os.exit(2)\n                        with open(os.environ[\"out\"], \"w\") as out:\n                            out.write(dict2xml(content))\n                  ''\n              )\n          ) { };\n\n      };\n\n    parseXML =\n      xml:\n      let\n        xmlToJsonFile = pkgs.callPackage (\n          { runCommand, python3 }:\n          runCommand \"config\"\n            {\n              inherit xml;\n              passAsFile = [ \"xml\" ];\n            }\n            (\n              pkgs.writers.writePython3 \"xml2json\"\n                {\n                  libraries = with python3.pkgs; [ python ];\n                }\n                ''\n                  import os\n                  import json\n                  from collections import ChainMap\n                  from xml.etree import ElementTree\n\n\n                  def xml_to_dict_recursive(root):\n                      all_descendants = list(root)\n                      if len(all_descendants) == 0:\n                          return {root.tag: root.text}\n                      else:\n                          merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))\n                          return {root.tag: dict(merged_dict)}\n\n\n                  with open(os.environ[\"xmlPath\"]) as f:\n                      root = ElementTree.XML(f.read())\n                      xml = xml_to_dict_recursive(root)\n                      j = json.dumps(xml)\n\n                      with open(os.environ[\"out\"], \"w\") as out:\n                          out.write(j)\n                ''\n            )\n        ) { };\n      in\n      builtins.fromJSON (builtins.readFile xmlToJsonFile);\n\n    renameAttrName =\n      attrset: from: to:\n      (lib.attrsets.filterAttrs (name: v: name == from) attrset)\n      // {\n        ${to} = attrset.${from};\n      };\n\n    # Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix\n    # with a nicer diff display function.\n    check =\n      { pkgs, tests }:\n      let\n        formatValue =\n          val:\n          if (builtins.isList val || builtins.isAttrs val) then\n            builtins.toJSON val\n          else\n            builtins.toString val;\n\n        resultToString =\n          {\n            name,\n            expected,\n            result,\n          }:\n          builtins.readFile (\n            pkgs.runCommand \"nix-flake-tests-error\"\n              {\n                expected = formatValue expected;\n                result = formatValue result;\n                passAsFile = [\n                  \"expected\"\n                  \"result\"\n                ];\n              }\n              ''\n                echo \"${name} failed (- expected, + result)\" > $out\n                cp ''${expectedPath} ''${expectedPath}.json\n                cp ''${resultPath} ''${resultPath}.json\n                ${pkgs.deepdiff}/bin/deep diff ''${expectedPath}.json ''${resultPath}.json >> $out\n              ''\n          );\n\n        results = pkgs.lib.runTests tests;\n      in\n      if results != [ ] then\n        builtins.throw (concatStringsSep \"\\n\" (map resultToString (lib.traceValSeq results)))\n      else\n        pkgs.runCommand \"nix-flake-tests-success\" { } \"echo > $out\";\n\n    genConfigOutOfBandSystemd =\n      {\n        config,\n        configLocation,\n        generator,\n        user ? null,\n        permissions ? \"u=r,g=r,o=\",\n      }:\n      {\n        loadCredentials = getLoadCredentials \"source\" config;\n        preStart = lib.mkBefore (replaceSecrets {\n          userConfig = updateToLoadCredentials \"source\" \"$CREDENTIALS_DIRECTORY\" config;\n          resultPath = configLocation;\n          inherit generator;\n          inherit user permissions;\n        });\n      };\n\n    updateToLoadCredentials =\n      sourceField: rootDir: attrs:\n      let\n        hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;\n\n        valueOrLoadCredential =\n          path: value:\n          if !(hasPlaceholderField value) then\n            value\n          else\n            value // { ${sourceField} = rootDir + \"/\" + concatStringsSep \"_\" path; };\n      in\n      mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) valueOrLoadCredential attrs;\n\n    getLoadCredentials =\n      sourceField: attrs:\n      let\n        hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;\n\n        addPathField =\n          path: value: if !(hasPlaceholderField value) then value else value // { inherit path; };\n\n        secretsWithPath = mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) addPathField attrs;\n\n        allSecrets = collect (v: hasPlaceholderField v) secretsWithPath;\n\n        genLoadCredentials = secret: \"${concatStringsSep \"_\" secret.path}:${secret.${sourceField}}\";\n      in\n      map genLoadCredentials allSecrets;\n\n    anyNotNull = any (x: x != null);\n\n    mkJellyfinPlugin =\n      {\n        pname,\n        version,\n        hash,\n        url,\n      }:\n      pkgs.callPackage (\n        { stdenv, fetchzip }:\n        stdenv.mkDerivation (finalAttrs: {\n          inherit pname version;\n\n          src = fetchzip {\n            inherit url hash;\n            stripRoot = false;\n          };\n\n          dontBuild = true;\n\n          installPhase = ''\n            mkdir $out\n            cp -r . $out\n          '';\n        })\n      ) { };\n\n    update =\n      attr: fn: attrset:\n      attrset // { ${attr} = fn attrset.${attr}; };\n\n  };\nin\nshb\n// {\n  homepage = pkgs.callPackage ./homepage.nix { inherit shb; };\n}\n"
  },
  {
    "path": "lib/homepage.nix",
    "content": "{ lib, shb }:\nlet\n  sort =\n    attr: vs:\n    map (v: { ${v.name} = v.${attr}; }) (\n      lib.sortOn (v: v.sortOrder) (lib.mapAttrsToList (n: v: v // { name = n; }) vs)\n    );\n\n  slufigy = builtins.replaceStrings [ \"-\" ] [ \"_\" ];\n\n  mkService =\n    groupName: serviceName:\n    {\n      request,\n      ...\n    }:\n    apiKey: settings:\n    lib.recursiveUpdate (\n      {\n        href = request.externalUrl;\n        siteMonitor = if (request.internalUrl == null) then null else request.internalUrl;\n        icon = \"sh-${lib.toLower serviceName}\";\n      }\n      // lib.optionalAttrs (apiKey != null) {\n        widget = {\n          # Duplicating because widgets call the api key various names\n          # and duplicating is a hacky but easy solution.\n          key = \"{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}\";\n          password = \"{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}\";\n          type = lib.toLower serviceName;\n          url = if (request.internalUrl != null) then request.internalUrl else request.externalUrl;\n        };\n      }\n    ) settings;\n\n  asServiceGroup =\n    cfg:\n    sort \"services\" (\n      lib.mapAttrs (\n        groupName: groupCfg:\n        shb.update \"services\" (\n          services:\n          sort \"dashboard\" (\n            lib.mapAttrs (\n              serviceName: serviceCfg:\n              shb.update \"dashboard\" (\n                dashboard:\n                (mkService groupName serviceName) dashboard serviceCfg.apiKey (serviceCfg.settings or { })\n              ) serviceCfg\n            ) services\n          )\n        ) groupCfg\n      ) cfg\n    );\n\n  allKeys =\n    cfg:\n    let\n      flat = lib.flatten (\n        lib.mapAttrsToList (\n          groupName: groupCfg:\n          lib.mapAttrsToList (\n            serviceName: serviceCfg:\n            lib.optionalAttrs (serviceCfg.apiKey != null) {\n              inherit serviceName groupName;\n              inherit (serviceCfg.apiKey.result) path;\n            }\n          ) groupCfg.services\n        ) cfg\n      );\n\n      flatWithApiKey = builtins.filter (v: v != { }) flat;\n    in\n    builtins.listToAttrs (\n      map (\n        {\n          groupName,\n          serviceName,\n          path,\n        }:\n        lib.nameValuePair \"${slufigy groupName}_${slufigy serviceName}\" path\n      ) flatWithApiKey\n    );\nin\n{\n  inherit\n    allKeys\n    asServiceGroup\n    mkService\n    sort\n    ;\n}\n"
  },
  {
    "path": "lib/module.nix",
    "content": "{ pkgs, lib, ... }:\nlet\n  shb = (import ./default.nix { inherit pkgs lib; });\nin\n{\n  _module.args.shb = shb // {\n    test = pkgs.callPackage ../test/common.nix { };\n    contracts = pkgs.callPackage ../modules/contracts { inherit shb; };\n  };\n}\n"
  },
  {
    "path": "modules/blocks/authelia/docs/default.md",
    "content": "# Authelia Block {#blocks-authelia}\n\nDefined in [`/modules/blocks/authelia.nix`](@REPO@/modules/blocks/authelia.nix).\n\nThis block sets up an [Authelia][] service for Single-Sign On integration.\n\n[Authelia]: https://www.authelia.com/\n\nCompared to the upstream nixpkgs module, this module is tightly integrated\nwith SHB which allows easy configuration of SSO with [OIDC integration](#blocks-authelia-shb-oidc)\nas well as some extensive [troubleshooting](#blocks-authelia-troubleshooting) features.\n\nNote that forward authentication is configured with the [nginx block](blocks-nginx.html#blocks-nginx-usage-shbforwardauth).\n\n## Features {#services-authelia-features}\n\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-authelia-usage-applicationdashboard)\n\n## Usage {#services-authelia-usage}\n\n### Initial Configuration {#blocks-authelia-usage-configuration}\n\nAuthelia cannot work without SSL and LDAP.\nSo setting up the Authelia block requires to setup the [SSL block][] first\nand the [LLDAP block][] first.\n\n[SSL block]: blocks-ssl.html\n[LLDAP block]: blocks-lldap.html\n\nSSL is required to encrypt the communication and LDAP is used to handle users and group assignments.\nAuthelia will allow access to a given resource only if the user that is authenticated\nis a member of the corresponding LDAP group.\n\nAfterwards, assuming the LDAP service runs on the same machine,\nthe Authelia configuration can be done with:\n\n```nix\nshb.authelia = {\n  enable = true;\n  domain = \"example.com\";\n  subdomain = \"auth\";\n  ssl = config.shb.certs.certs.letsencrypt.\"example.com\";\n\n  ldapHostname = \"127.0.0.1\";\n  ldapPort = config.shb.lldap.ldapPort;\n  dcdomain = config.shb.lldap.dcdomain;\n\n  smtp = {\n    host = \"smtp.eu.mailgun.org\";\n    port = 587;\n    username = \"postmaster@mg.example.com\";\n    from_address = \"authelia@example.com\";\n    password.result = config.shb.sops.secret.\"authelia/smtp_password\".result;\n  };\n\n  secrets = {\n    jwtSecret.result = config.shb.sops.secret.\"authelia/jwt_secret\".result;\n    ldapAdminPassword.result = config.shb.sops.secret.\"authelia/ldap_admin_password\".result;\n    sessionSecret.result = config.shb.sops.secret.\"authelia/session_secret\".result;\n    storageEncryptionKey.result = config.shb.sops.secret.\"authelia/storage_encryption_key\".result;\n    identityProvidersOIDCHMACSecret.result = config.shb.sops.secret.\"authelia/hmac_secret\".result;\n    identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret.\"authelia/private_key\".result;\n  };\n};\n\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"auth.example.com\" ];\n\nshb.sops.secret.\"authelia/jwt_secret\".request = config.shb.authelia.secrets.jwtSecret.request;\nshb.sops.secret.\"authelia/ldap_admin_password\" = {\n  request = config.shb.authelia.secrets.ldapAdminPassword.request;\n  settings.key = \"lldap/user_password\";\n};\nshb.sops.secret.\"authelia/session_secret\".request = config.shb.authelia.secrets.sessionSecret.request;\nshb.sops.secret.\"authelia/storage_encryption_key\".request = config.shb.authelia.secrets.storageEncryptionKey.request;\nshb.sops.secret.\"authelia/hmac_secret\".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;\nshb.sops.secret.\"authelia/private_key\".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;\nshb.sops.secret.\"authelia/smtp_password\".request = config.shb.authelia.smtp.password.request;\n```\n\nThis assumes secrets are setup with SOPS\nas mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\nIt's a bit annoying to setup all those secrets but it's only necessary once.\nUse `nix run nixpkgs#openssl -- rand -hex 64` to generate them.\n\nCrucially, the `shb.authelia.secrets.ldapAdminPasswordFile` must be the same\nas the `shb.lldap.ldapUserPassword` defined for the [LLDAP block][].\nThis is done using Sops' `key` option.\n\n### Application Dashboard {#services-authelia-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#blocks-authelia-options-shb.authelia.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Admin.services.Authelia = {\n    sortOrder = 2;\n    dashboard.request = config.shb.authelia.dashboard.request;\n  };\n}\n```\n\n## SHB OIDC integration {#blocks-authelia-shb-oidc}\n\nFor services [provided by SelfHostBlocks][services] that handle [OIDC integration][OIDC],\nintegrating with this block is done by configuring the service itself\nand linking it to this Authelia block through the `endpoint` option\nand by sharing a secret:\n\n[services]: services.html\n[OIDC]: https://openid.net/developers/how-connect-works/\n\n```nix\nshb.<service>.sso = {\n  enable = true;\n  endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n  secret.result = config.shb.sops.secret.\"<service>/sso/secret\".result;\n  secretForAuthelia.result = config.shb.sops.secret.\"<service>/sso/secretForAuthelia\".result;\n};\n\nshb.sops.secret.\"<service>/sso/secret\".request = config.shb.<service>.sso.secret.request;\nshb.sops.secret.\"<service>/sso/secretForAuthelia\" = {\n  request = config.shb.<service>.sso.secretForAuthelia.request;\n  settings.key = \"<service>/sso/secret\";\n};\n```\n\nTo share a secret between the service and Authelia,\nwe generate a secret with `nix run nixpkgs#openssl -- rand -hex 64` under `<service>/sso/secret`\nthen we ask Sops to use the same password for `<service>/sso/secretForAuthelia`\nthanks to the `settings.key` option.\nThe difference between both secrets is one if owned by the `authelia` user\nwhile the other is owned by the user of the `<service`> we are configuring.\n\n## OIDC Integration {#blocks-authelia-oidc}\n\nTo integrate a service handling OIDC integration not provided by SelfHostBlocks with this Authelia block,\nthe necessary configuration is:\n\n```nix\nshb.authelia.oidcClients = [\n  {\n    client_id = \"<service>\";\n    client_secret.source = config.shb.sops.secret.\"<service>/sso/secretForAuthelia\".response.path;\n    scopes = [ \"openid\" \"email\" \"profile\" ];\n    redirect_uris = [\n      \"<provided by service documentation>\"\n    ];\n  }\n];\n\nshb.sops.secret.\"<service>/sso/secret\".request = {\n  owner = \"<service_user>\";\n};\nshb.sops.secret.\"<service>/sso/secretForAuthelia\" = {\n  request.owner = \"authelia\";\n  settings.key = \"<service>/sso/secret\";\n};\n```\n\nAs in the previous section, we create a shared secret using Sops'\n`settings.key` option.\n\nThe configuration for the service itself is much dependent on the service itself.\nFor example for [open-webui][], the configuration looks like so:\n\n[open-webui]: https://search.nixos.org/options?query=services.open-webui\n\n```nix\nservices.open-webui.environment = {\n  ENABLE_SIGNUP = \"False\";\n  WEBUI_AUTH = \"True\";\n  ENABLE_FORWARD_USER_INFO_HEADERS = \"True\";\n  ENABLE_OAUTH_SIGNUP = \"True\";\n  OAUTH_UPDATE_PICTURE_ON_LOGIN = \"True\";\n  OAUTH_CLIENT_ID = \"open-webui\";\n  OAUTH_CLIENT_SECRET = \"<raw secret>\";\n  OPENID_PROVIDER_URL = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}/.well-known/openid-configuration\";\n  OAUTH_PROVIDER_NAME = \"Single Sign-On\";\n  OAUTH_SCOPES = \"openid email profile\";\n  OAUTH_ALLOWED_ROLES = \"open-webui_user\";\n  OAUTH_ADMIN_ROLES = \"open-webui_admin\";\n  ENABLE_OAUTH_ROLE_MANAGEMENT = \"True\";\n};\n\nshb.authelia.oidcClients = [\n  {\n    client_id = \"open-webui\";\n    client_secret.source = config.shb.sops.secret.\"open-webui/sso/secretForAuthelia\".response.path;\n    scopes = [ \"openid\" \"email\" \"profile\" ];\n    redirect_uris = [\n      \"<provided by service documentation>\"\n    ];\n  }\n];\n\nshb.sops.secret.\"open-webui/sso/secret\".request = {\n  owner = \"open-webui\";\n};\nshb.sops.secret.\"open-webui/sso/secretForAuthelia\" = {\n  request.owner = \"authelia\";\n  settings.key = \"open-webui/sso/secret\";\n};\n```\n\nHere, there is no way to give a path for the `OAUTH_CLIENT_SECRET`,\nwe are obligated to pass the raw secret which is a very bad idea.\nThere are ways around this but they are out of scope for this section.\nInspiration can be taken from SelfHostBlocks' source code.\n\nTo access the UI, we will need to create an `open-webui_user` and\n`open-webui_admin` LDAP group and assign our user to it.\n\n## Forward Auth {#blocks-authelia-forward-auth}\n\nForward authentication is provided by the [nginx block](blocks-nginx.html#blocks-nginx-usage-ssl).\n\n## Troubleshooting {#blocks-authelia-troubleshooting}\n\nSet the [debug][opt-debug] option to `true` to:\n\n[opt-debug]: #blocks-authelia-options-shb.authelia.debug\n\n- Set logging level to `\"debug\"`.\n- Add an [shb.mitmdump][] instance in front of Authelia\n  which prints all requests and responses headers and body\n  to the systemd service `mitmdump-authelia-${config.shb.authelia.subdomain}.${config.shb.authelia.domain}.service`.\n\n[shb.mitmdump]: ./blocks-mitmdump.html\n\n## Tests {#blocks-authelia-tests}\n\nSpecific integration tests are defined in [`/test/blocks/authelia.nix`](@REPO@/test/blocks/authelia.nix).\n\n## Options Reference {#blocks-authelia-options}\n\n```{=include=} options\nid-prefix: blocks-authelia-options-\nlist-id: selfhostblocks-block-authelia-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/authelia.nix",
    "content": "{\n  config,\n  options,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.authelia;\n  opt = options.shb.authelia;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n  fqdnWithPort = if isNull cfg.port then fqdn else \"${fqdn}:${toString cfg.port}\";\n\n  autheliaCfg = config.services.authelia.instances.${fqdn};\n\n  inherit (lib) hasPrefix;\n\n  listenPort = if cfg.debug then 9090 else 9091;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ./lldap.nix\n    ./mitmdump.nix\n    ./postgresql.nix\n  ];\n\n  options.shb.authelia = {\n    enable = lib.mkEnableOption \"selfhostblocks.authelia\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Authelia will be served.\";\n      example = \"auth\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Authelia will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    port = lib.mkOption {\n      description = \"If given, adds a port to the `<subdomain>.<domain>` endpoint.\";\n      type = lib.types.nullOr lib.types.port;\n      default = null;\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    ldapHostname = lib.mkOption {\n      type = lib.types.str;\n      description = \"Hostname of the LDAP authentication backend.\";\n      example = \"ldap.example.com\";\n    };\n\n    ldapPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port of the LDAP authentication backend.\";\n      example = \"389\";\n    };\n\n    dcdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"dc domain for ldap.\";\n      example = \"dc=mydomain,dc=com\";\n    };\n\n    autheliaUser = lib.mkOption {\n      type = lib.types.str;\n      description = \"System user for this Authelia instance.\";\n      default = \"authelia\";\n    };\n\n    secrets = lib.mkOption {\n      description = \"Secrets needed by Authelia\";\n      type = lib.types.submodule {\n        options = {\n          jwtSecret = lib.mkOption {\n            description = \"JWT secret.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n          ldapAdminPassword = lib.mkOption {\n            description = \"LDAP admin user password.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n          sessionSecret = lib.mkOption {\n            description = \"Session secret.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n          storageEncryptionKey = lib.mkOption {\n            description = \"Storage encryption key. Must be >= 20 characters.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n          identityProvidersOIDCHMACSecret = lib.mkOption {\n            description = \"Identity provider OIDC HMAC secret. Must be >= 40 characters.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n          identityProvidersOIDCIssuerPrivateKey = lib.mkOption {\n            description = ''\n              Identity provider OIDC issuer private key.\n\n              Generate one with `nix run nixpkgs#openssl -- genrsa -out keypair.pem 2048`\n            '';\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = cfg.autheliaUser;\n                restartUnits = [ \"authelia-${opt.subdomain}.${opt.domain}.service\" ];\n              };\n            };\n          };\n        };\n      };\n    };\n\n    extraOidcClaimsPolicies = lib.mkOption {\n      description = \"Extra OIDC claims policies.\";\n      type = lib.types.attrsOf lib.types.attrs;\n      default = { };\n    };\n\n    extraOidcScopes = lib.mkOption {\n      description = \"Extra OIDC scopes.\";\n      type = lib.types.attrsOf lib.types.attrs;\n      default = { };\n    };\n\n    extraOidcAuthorizationPolicies = lib.mkOption {\n      description = \"Extra OIDC authorization policies.\";\n      type = lib.types.attrsOf lib.types.attrs;\n      default = { };\n    };\n\n    extraDefinitions = lib.mkOption {\n      description = \"Extra definitions.\";\n      type = lib.types.attrsOf lib.types.attrs;\n      default = { };\n    };\n\n    oidcClients = lib.mkOption {\n      description = \"OIDC clients\";\n      default = [\n        {\n          client_id = \"dummy_client\";\n          client_name = \"Dummy Client so Authelia can start\";\n          client_secret.source = pkgs.writeText \"dummy.secret\" \"dummy_client_secret\";\n          public = false;\n          authorization_policy = \"one_factor\";\n          redirect_uris = [ ];\n        }\n      ];\n      type = lib.types.listOf (\n        lib.types.submodule {\n          freeformType = lib.types.attrsOf lib.types.anything;\n\n          options = {\n            client_id = lib.mkOption {\n              type = lib.types.str;\n              description = \"Unique identifier of the OIDC client.\";\n            };\n\n            client_name = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"Human readable description of the OIDC client.\";\n              default = null;\n            };\n\n            client_secret = lib.mkOption {\n              type = shb.secretFileType;\n              description = ''\n                File containing the shared secret with the OIDC client.\n\n                Generate with:\n\n                ```\n                nix run nixpkgs#authelia -- \\\n                    crypto hash generate pbkdf2 \\\n                    --variant sha512 \\\n                    --random \\\n                    --random.length 72 \\\n                    --random.charset rfc3986\n                ```\n              '';\n            };\n\n            public = lib.mkOption {\n              type = lib.types.bool;\n              description = \"If the OIDC client is public or not.\";\n              default = false;\n              apply = v: if v then \"true\" else \"false\";\n            };\n\n            authorization_policy = lib.mkOption {\n              type = lib.types.enum (\n                [\n                  \"one_factor\"\n                  \"two_factor\"\n                ]\n                ++ lib.attrNames cfg.extraOidcAuthorizationPolicies\n              );\n              description = \"Require one factor (password) or two factor (device) authentication.\";\n              default = \"one_factor\";\n            };\n\n            redirect_uris = lib.mkOption {\n              type = lib.types.listOf lib.types.str;\n              description = \"List of uris that are allowed to be redirected to.\";\n            };\n\n            scopes = lib.mkOption {\n              type = lib.types.listOf lib.types.str;\n              description = \"Scopes to ask for. See https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims\";\n              example = [\n                \"openid\"\n                \"profile\"\n                \"email\"\n                \"groups\"\n              ];\n              default = [ ];\n            };\n\n            claims_policy = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = ''\n                Claim policy.\n\n                Defaults to 'default' to provide a backwards compatible experience.\n                Read [this document](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter) for more information.\n              '';\n              default = \"default\";\n            };\n          };\n        }\n      );\n    };\n\n    smtp = lib.mkOption {\n      description = ''\n        If a string is given, writes notifications to the given path.Otherwise, send notifications\n        by smtp.\n\n        https://www.authelia.com/configuration/notifications/introduction/\n      '';\n      default = \"/tmp/authelia-notifications\";\n      type = lib.types.oneOf [\n        lib.types.str\n        (lib.types.nullOr (\n          lib.types.submodule {\n            options = {\n              from_address = lib.mkOption {\n                type = lib.types.str;\n                description = \"SMTP address from which the emails originate.\";\n                example = \"authelia@mydomain.com\";\n              };\n              from_name = lib.mkOption {\n                type = lib.types.str;\n                description = \"SMTP name from which the emails originate.\";\n                default = \"Authelia\";\n              };\n              scheme = lib.mkOption {\n                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.\";\n                type = lib.types.enum [\n                  \"smtp\"\n                  \"submission\"\n                  \"submissions\"\n                ];\n                default = \"smtp\";\n              };\n              host = lib.mkOption {\n                type = lib.types.str;\n                description = \"SMTP host to send the emails to.\";\n              };\n              port = lib.mkOption {\n                type = lib.types.port;\n                description = \"SMTP port to send the emails to.\";\n                default = 25;\n              };\n              username = lib.mkOption {\n                type = lib.types.str;\n                description = \"Username to connect to the SMTP host.\";\n              };\n              password = lib.mkOption {\n                description = \"File containing the password to connect to the SMTP host.\";\n                type = lib.types.submodule {\n                  options = shb.contracts.secret.mkRequester {\n                    mode = \"0400\";\n                    owner = cfg.autheliaUser;\n                    restartUnits = [ \"authelia-${fqdn}.service\" ];\n                  };\n                };\n              };\n            };\n          }\n        ))\n      ];\n    };\n\n    rules = lib.mkOption {\n      type = lib.types.listOf lib.types.anything;\n      description = \"Rule based clients\";\n      default = [ ];\n    };\n\n    mount = lib.mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"authelia\" = {\n          poolName = \"root\";\n        } // config.shb.authelia.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = \"/var/lib/authelia-authelia.${cfg.domain}\";\n      };\n      defaultText = {\n        path = \"/var/lib/authelia-authelia.example.com\";\n      };\n    };\n\n    mountRedis = lib.mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration for Redis. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"redis-authelia\" = {\n          poolName = \"root\";\n        } // config.shb.authelia.mountRedis;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = \"/var/lib/redis-authelia\";\n      };\n    };\n\n    debug = lib.mkOption {\n      type = lib.types.bool;\n      default = false;\n      description = ''\n        Set logging level to debug and add a mitmdump instance\n        to see exactly what Authelia receives and sends back.\n      '';\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.authelia.subdomain}.\\${config.shb.authelia.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString listenPort}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    assertions = [\n      {\n        assertion = builtins.length cfg.oidcClients > 0;\n        message = \"Must have at least one oidc client otherwise Authelia refuses to start.\";\n      }\n      {\n        assertion = !(hasPrefix \"ldap://\" cfg.ldapHostname);\n        message = \"LDAP hostname should be the bare host name and not start with ldap://\";\n      }\n    ];\n\n    # Overriding the user name so we don't allow any weird characters anywhere. For example, postgres users do not accept the '.'.\n    users = {\n      groups.${autheliaCfg.user} = { };\n      users.${autheliaCfg.user} = {\n        isSystemUser = true;\n        group = autheliaCfg.user;\n      };\n    };\n\n    services.authelia.instances.${fqdn} = {\n      enable = true;\n      user = cfg.autheliaUser;\n\n      secrets = {\n        jwtSecretFile = cfg.secrets.jwtSecret.result.path;\n        storageEncryptionKeyFile = cfg.secrets.storageEncryptionKey.result.path;\n        sessionSecretFile = cfg.secrets.sessionSecret.result.path;\n        oidcIssuerPrivateKeyFile = cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path;\n        oidcHmacSecretFile = cfg.secrets.identityProvidersOIDCHMACSecret.result.path;\n      };\n      # See https://www.authelia.com/configuration/methods/secrets/\n      environmentVariables = {\n        AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = toString cfg.secrets.ldapAdminPassword.result.path;\n        # Not needed since we use peer auth.\n        # AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE = \"/run/secrets/authelia/postgres_password\";\n        AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = lib.mkIf (!(builtins.isString cfg.smtp)) (\n          toString cfg.smtp.password.result.path\n        );\n        X_AUTHELIA_CONFIG_FILTERS = \"template\";\n      };\n      settings = {\n        server.address = \"tcp://127.0.0.1:${toString listenPort}\";\n\n        # Inspired from https://github.com/lldap/lldap/blob/7d1f5abc137821c500de99c94f7579761fc949d8/example_configs/authelia_config.yml\n        authentication_backend = {\n          refresh_interval = \"5m\";\n          # We allow password reset and change because the ldap user we use allows it.\n          password_reset.disable = \"false\";\n          password_change.disable = \"false\";\n          ldap = {\n            implementation = \"lldap\";\n            address = \"ldap://${cfg.ldapHostname}:${toString cfg.ldapPort}\";\n            timeout = \"5s\";\n            start_tls = \"false\";\n            base_dn = cfg.dcdomain;\n            # TODO: use user with less privilege and with lldap_password_manager group to be able to change passwords.\n            user = \"uid=admin,ou=people,${cfg.dcdomain}\";\n          };\n        };\n        totp = {\n          disable = \"false\";\n          issuer = fqdnWithPort;\n          algorithm = \"sha1\";\n          digits = \"6\";\n          period = \"30\";\n          skew = \"1\";\n          secret_size = \"32\";\n        };\n        # Inspired from https://www.authelia.com/configuration/session/introduction/ and https://www.authelia.com/configuration/session/redis\n        session = {\n          name = \"authelia_session\";\n          cookies = [\n            {\n              domain = if isNull cfg.port then cfg.domain else \"${cfg.domain}:${toString cfg.port}\";\n              authelia_url = \"https://${cfg.subdomain}.${cfg.domain}\";\n            }\n          ];\n          same_site = \"lax\";\n          expiration = \"1h\";\n          inactivity = \"5m\";\n          remember_me = \"1M\";\n          redis = {\n            host = config.services.redis.servers.authelia.unixSocket;\n            port = 0;\n          };\n        };\n        storage = {\n          postgres = {\n            address = \"unix:///run/postgresql\";\n            username = autheliaCfg.user;\n            database = autheliaCfg.user;\n            # Uses peer auth for local users, so we don't need a password.\n            password = \"test\";\n          };\n        };\n        notifier = {\n          filesystem = lib.mkIf (builtins.isString cfg.smtp) {\n            filename = cfg.smtp;\n          };\n          smtp = lib.mkIf (!(builtins.isString cfg.smtp)) {\n            address = \"${cfg.smtp.scheme}://${cfg.smtp.host}:${toString cfg.smtp.port}\";\n            username = cfg.smtp.username;\n            sender = \"${cfg.smtp.from_name} <${cfg.smtp.from_address}>\";\n            subject = \"[Authelia] {title}\";\n            startup_check_address = \"test@authelia.com\";\n          };\n        };\n        access_control = {\n          default_policy = \"deny\";\n          networks = [\n            {\n              name = \"internal\";\n              networks = [\n                \"10.0.0.0/8\"\n                \"172.16.0.0/12\"\n                \"192.168.0.0/18\"\n              ];\n            }\n          ];\n          rules = [\n            {\n              domain = fqdnWithPort;\n              policy = \"bypass\";\n              resources = [\n                \"^/api/.*\"\n              ];\n            }\n          ]\n          ++ cfg.rules;\n        };\n        telemetry = {\n          metrics = {\n            enabled = true;\n            address = \"tcp://127.0.0.1:9959\";\n          };\n        };\n\n        log.level = if cfg.debug then \"debug\" else \"info\";\n      }\n      // {\n        identity_providers.oidc = {\n          claims_policies = {\n            # This default claim should go away at some point.\n            # https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter\n            default.id_token = [\n              \"email\"\n              \"preferred_username\"\n              \"name\"\n              \"groups\"\n            ];\n          }\n          // cfg.extraOidcClaimsPolicies;\n          scopes = cfg.extraOidcScopes;\n          authorization_policies = cfg.extraOidcAuthorizationPolicies;\n        };\n      }\n      // lib.optionalAttrs (cfg.extraDefinitions != { }) {\n        definitions = cfg.extraDefinitions;\n      };\n\n      settingsFiles = [ \"/var/lib/authelia-${fqdn}/oidc_clients.yaml\" ];\n    };\n\n    systemd.services.\"authelia-${fqdn}\".preStart =\n      let\n        mkCfg =\n          clients:\n          shb.replaceSecrets {\n            userConfig = {\n              identity_providers.oidc.clients = clients;\n            };\n            resultPath = \"/var/lib/authelia-${fqdn}/oidc_clients.yaml\";\n            generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });\n          };\n      in\n      lib.mkBefore (\n        mkCfg cfg.oidcClients\n        + ''\n          ${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'\n        ''\n      );\n\n    services.nginx.virtualHosts.${fqdn} = {\n      forceSSL = !(isNull cfg.ssl);\n      sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n      sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n      # Taken from https://github.com/authelia/authelia/issues/178\n      # TODO: merge with config from https://matwick.ca/authelia-nginx-sso/\n      locations.\"/\".extraConfig = ''\n        add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n        add_header X-Content-Type-Options nosniff;\n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-XSS-Protection \"1; mode=block\";\n        add_header X-Robots-Tag \"noindex, nofollow, nosnippet, noarchive\";\n        add_header X-Download-Options noopen;\n        add_header X-Permitted-Cross-Domain-Policies none;\n\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-Host $http_host;\n        proxy_set_header X-Forwarded-Uri $request_uri;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_cache_bypass $http_upgrade;\n\n        proxy_pass http://127.0.0.1:9091;\n        proxy_intercept_errors on;\n        if ($request_method !~ ^(POST)$){\n            error_page 401 = /error/401;\n            error_page 403 = /error/403;\n            error_page 404 = /error/404;\n        }\n      '';\n\n      locations.\"/api/verify\".extraConfig = ''\n        add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n        add_header X-Content-Type-Options nosniff;\n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-XSS-Protection \"1; mode=block\";\n        add_header X-Robots-Tag \"noindex, nofollow, nosnippet, noarchive\";\n        add_header X-Download-Options noopen;\n        add_header X-Permitted-Cross-Domain-Policies none;\n\n        proxy_set_header Host $http_x_forwarded_host;\n        proxy_pass http://127.0.0.1:9091;\n      '';\n    };\n\n    # I would like this to live outside of the Authelia module.\n    # This will require a reverse proxy contract.\n    # Actually, not sure a full reverse proxy contract is needed.\n    shb.mitmdump.instances.\"authelia-${fqdn}\" = lib.mkIf cfg.debug {\n      listenPort = 9091;\n      upstreamPort = 9090;\n      after = [ \"authelia-${fqdn}.service\" ];\n      enabledAddons = [ config.shb.mitmdump.addons.logger ];\n      extraArgs = [\n        \"--set\"\n        \"verbose_pattern=/api\"\n      ];\n    };\n\n    services.redis.servers.authelia = {\n      enable = true;\n      user = autheliaCfg.user;\n    };\n\n    shb.postgresql.ensures = [\n      {\n        username = autheliaCfg.user;\n        database = autheliaCfg.user;\n      }\n    ];\n\n    services.prometheus.scrapeConfigs = [\n      {\n        job_name = \"authelia\";\n        static_configs = [\n          {\n            targets = [ \"127.0.0.1:9959\" ];\n            labels = {\n              \"hostname\" = config.networking.hostName;\n              \"domain\" = cfg.domain;\n            };\n          }\n        ];\n      }\n    ];\n\n    systemd.targets.\"authelia-${fqdn}\" =\n      let\n        services = [\n          \"authelia-${fqdn}.service\"\n        ]\n        ++ lib.optionals cfg.debug [\n          config.shb.mitmdump.instances.\"authelia-${fqdn}\".serviceName\n        ];\n      in\n      {\n        after = services;\n        requires = services;\n\n        wantedBy = [ \"multi-user.target\" ];\n      };\n  };\n}\n"
  },
  {
    "path": "modules/blocks/backup/dashboard/Backups.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"target\": {\n          \"limit\": 100,\n          \"matchAny\": false,\n          \"tags\": [],\n          \"type\": \"dashboard\"\n        },\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 10,\n  \"links\": [\n    {\n      \"asDropdown\": false,\n      \"icon\": \"question\",\n      \"includeVars\": false,\n      \"keepTime\": false,\n      \"tags\": [],\n      \"targetBlank\": false,\n      \"title\": \"Help\",\n      \"tooltip\": \"\",\n      \"type\": \"link\",\n      \"url\": \"https://shb.skarabox.com/blocks-monitoring.html\"\n    }\n  ],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"fixedColor\": \"green\",\n            \"mode\": \"fixed\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"footer\": {\n              \"reducers\": []\n            },\n            \"inspect\": false\n          },\n          \"decimals\": 0,\n          \"mappings\": [],\n          \"noValue\": \"0\",\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"% failed\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"unit\",\n                \"value\": \"percentunit\"\n              },\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"mode\": \"gradient\",\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"mode\": \"continuous-GrYlRd\"\n                }\n              },\n              {\n                \"id\": \"max\",\n                \"value\": 1\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"total\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"mode\": \"gradient\",\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"mode\": \"thresholds\"\n                }\n              },\n              {\n                \"id\": \"thresholds\",\n                \"value\": {\n                  \"mode\": \"absolute\",\n                  \"steps\": [\n                    {\n                      \"color\": \"red\",\n                      \"value\": 0\n                    },\n                    {\n                      \"color\": \"transparent\",\n                      \"value\": 1\n                    }\n                  ]\n                }\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byType\",\n              \"options\": \"string\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.minWidth\",\n                \"value\": 150\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byType\",\n              \"options\": \"number\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 100\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 11,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"enablePagination\": true,\n        \"frozenColumns\": {},\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"increase(systemd_unit_state{name=~\\\"[[job]].service\\\", state=\\\"activating\\\"}[7d])\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": false,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"increase(systemd_unit_state{name=~\\\"[[job]].service\\\", state=\\\"failed\\\"}[7d])\",\n          \"hide\": false,\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": false,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Backup Jobs in the Past Week\",\n      \"transformations\": [\n        {\n          \"id\": \"labelsToFields\",\n          \"options\": {\n            \"mode\": \"columns\"\n          }\n        },\n        {\n          \"id\": \"merge\",\n          \"options\": {}\n        },\n        {\n          \"id\": \"groupingToMatrix\",\n          \"options\": {\n            \"columnField\": \"state\",\n            \"rowField\": \"name\",\n            \"valueField\": \"Value\"\n          }\n        },\n        {\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"alias\": \"total\",\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"activating\"\n                }\n              },\n              \"operator\": \"+\",\n              \"right\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"failed\"\n                }\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"include\": [\n                \"activating\",\n                \"failed\"\n              ],\n              \"reducer\": \"sum\"\n            }\n          }\n        },\n        {\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"alias\": \"% failed\",\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"failed\"\n                }\n              },\n              \"operator\": \"/\",\n              \"right\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"total\"\n                }\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"reducer\": \"sum\"\n            }\n          }\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": true,\n                \"field\": \"total\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": true,\n                \"field\": \"failed\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {\n              \"activating\": \"success\",\n              \"name\\\\state\": \"Job\"\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineStyle\": {\n              \"fill\": \"solid\"\n            },\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              }\n            ]\n          },\n          \"unit\": \"dateTimeFromNow\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 0\n      },\n      \"id\": 15,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"sortBy\": \"Last *\",\n          \"sortDesc\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"systemd_timer_next_trigger_seconds{name=~\\\"$job.timer\\\"} * 1000\",\n          \"format\": \"time_series\",\n          \"instant\": false,\n          \"legendFormat\": \"{{name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Schedule\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"fixed\"\n          },\n          \"custom\": {\n            \"axisPlacement\": \"auto\",\n            \"fillOpacity\": 70,\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineWidth\": 0,\n            \"spanNulls\": false\n          },\n          \"mappings\": [\n            {\n              \"options\": {\n                \"1\": {\n                  \"color\": \"green\",\n                  \"index\": 0,\n                  \"text\": \"Running\"\n                }\n              },\n              \"type\": \"value\"\n            }\n          ],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"#EAB839\",\n                \"value\": 0\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 9,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 8\n      },\n      \"id\": 13,\n      \"options\": {\n        \"alignValue\": \"left\",\n        \"legend\": {\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"mergeValues\": true,\n        \"rowHeight\": 0.9,\n        \"showValue\": \"never\",\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"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)\",\n          \"format\": \"time_series\",\n          \"instant\": false,\n          \"key\": \"Q-e1d5c07a-8dcc-4f34-aa5c-cdebcbdda322-0\",\n          \"legendFormat\": \"{{name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Backups Jobs\",\n      \"type\": \"state-timeline\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMax\": 1,\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"percentunit\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 17\n      },\n      \"id\": 1,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(node_disk_io_time_seconds_total{device=~\\\"sd.*\\\"}[2m])\",\n          \"legendFormat\": \"{{device}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Disk IO Time\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 2,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 17\n      },\n      \"id\": 16,\n      \"options\": {\n        \"code\": {\n          \"language\": \"plaintext\",\n          \"showLineNumbers\": false,\n          \"showMiniMap\": false\n        },\n        \"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.\",\n        \"mode\": \"markdown\"\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"title\": \"\",\n      \"type\": \"text\"\n    },\n    {\n      \"datasource\": {\n        \"default\": false,\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 30,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 19\n      },\n      \"id\": 3,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableInfiniteScrolling\": false,\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": true,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"direction\": \"backward\",\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=~\\\"$job.*.service\\\"}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Logs - $job\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"dashed\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"min\": 0,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              }\n            ]\n          },\n          \"unit\": \"bytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"rel\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"percentunit\"\n              },\n              {\n                \"id\": \"max\",\n                \"value\": 1.1\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"abs\"\n            },\n            \"properties\": []\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 25\n      },\n      \"id\": 8,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"1 - node_filesystem_avail_bytes{device!~\\\"ramfs|tmpfs|none\\\", mountpoint=~\\\"$mountpoints\\\"} / node_filesystem_size_bytes{device!~\\\"ramfs|tmpfs|none\\\", mountpoint=~\\\"$mountpoints\\\"}\",\n          \"hide\": true,\n          \"legendFormat\": \"{{mountpoint}}\",\n          \"range\": true,\n          \"refId\": \"rel\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"node_filesystem_size_bytes{device!~\\\"ramfs|tmpfs|none\\\", mountpoint=~\\\"$mountpoints\\\"} - node_filesystem_avail_bytes{device!~\\\"ramfs|tmpfs|none\\\", mountpoint=~\\\"$mountpoints\\\"}\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{mountpoint}}\",\n          \"range\": true,\n          \"refId\": \"abs\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"node_filesystem_size_bytes{device!~\\\"ramfs|tmpfs|none\\\", mountpoint=~\\\"$mountpoints\\\"}\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{mountpoint}}\",\n          \"range\": true,\n          \"refId\": \"max\"\n        }\n      ],\n      \"title\": \"Disk Usage\",\n      \"transformations\": [\n        {\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"alias\": \"max\",\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"/srv/backup\"\n                }\n              },\n              \"operator\": \"*\",\n              \"right\": {\n                \"fixed\": \"1.1\"\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"reducer\": \"sum\"\n            },\n            \"replaceFields\": false,\n            \"unary\": {\n              \"fieldName\": \"/srv/backup\",\n              \"operator\": \"percent\"\n            }\n          }\n        },\n        {\n          \"id\": \"configFromData\",\n          \"options\": {\n            \"applyTo\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"abs\"\n            },\n            \"configRefId\": \"max\",\n            \"mappings\": [\n              {\n                \"fieldName\": \"/srv/backup\",\n                \"handlerArguments\": {\n                  \"threshold\": {\n                    \"color\": \"red\"\n                  }\n                },\n                \"handlerKey\": \"threshold1\"\n              },\n              {\n                \"fieldName\": \"max\",\n                \"handlerKey\": \"max\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"max\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMax\": 1,\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"percentunit\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 33\n      },\n      \"id\": 5,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"avg(rate(node_cpu_seconds_total{mode!=\\\"idle\\\"}[2m])) by (instance, mode)\",\n          \"legendFormat\": \"{{instance}} -- {{mode}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"CPU\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMax\": 1,\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": 3600000,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"dashed+area\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"bytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"perc\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"max\",\n                \"value\": 1\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"percentunit\"\n              },\n              {\n                \"id\": \"custom.hideFrom\",\n                \"value\": {\n                  \"legend\": false,\n                  \"tooltip\": false,\n                  \"viz\": false\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 41\n      },\n      \"id\": 14,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"builder\",\n          \"expr\": \"avg by(instance) (node_memory_MemAvailable_bytes)\",\n          \"fullMetaSearch\": false,\n          \"hide\": true,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{instance}} - total\",\n          \"range\": true,\n          \"refId\": \"available\",\n          \"useBackend\": false\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"builder\",\n          \"expr\": \"avg by(instance) (node_memory_MemTotal_bytes)\",\n          \"fullMetaSearch\": false,\n          \"hide\": true,\n          \"includeNullMetadata\": true,\n          \"instant\": false,\n          \"legendFormat\": \"{{instance}} - available\",\n          \"range\": true,\n          \"refId\": \"total\",\n          \"useBackend\": false\n        },\n        {\n          \"datasource\": {\n            \"name\": \"Expression\",\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"$available / $total\",\n          \"hide\": false,\n          \"refId\": \"perc\",\n          \"type\": \"math\"\n        }\n      ],\n      \"title\": \"Memory\",\n      \"type\": \"timeseries\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"10s\",\n  \"schemaVersion\": 42,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": [\n            \"All\"\n          ],\n          \"value\": [\n            \"$__all\"\n          ]\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"label_values(systemd_unit_state{name=~\\\".*backup.*\\\", name=~\\\".*.service\\\", name!~\\\".*restore.*\\\", name!~\\\".*pre.service\\\"},name)\",\n        \"includeAll\": true,\n        \"label\": \"Job\",\n        \"multi\": true,\n        \"name\": \"job\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(systemd_unit_state{name=~\\\".*backup.*\\\", name=~\\\".*.service\\\", name!~\\\".*restore.*\\\", name!~\\\".*pre.service\\\"},name)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"/(?<value>.*).service/\",\n        \"sort\": 1,\n        \"type\": \"query\"\n      },\n      {\n        \"current\": {\n          \"text\": [\n            \"/srv/backup\"\n          ],\n          \"value\": [\n            \"/srv/backup\"\n          ]\n        },\n        \"definition\": \"label_values(node_filesystem_avail_bytes,mountpoint)\",\n        \"includeAll\": true,\n        \"multi\": true,\n        \"name\": \"mountpoints\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(node_filesystem_avail_bytes,mountpoint)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-3h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Backups\",\n  \"uid\": \"f05500d0-15ed-4719-b68d-fb898ca13cc8\",\n  \"version\": 62\n}\n"
  },
  {
    "path": "modules/blocks/borgbackup/docs/default.md",
    "content": "# Borgbackup Block {#blocks-borgbackup}\n\nDefined in [`/modules/blocks/borgbackup.nix`](@REPO@/modules/blocks/borgbackup.nix).\n\nThis block sets up a backup job using [BorgBackup][].\n\n[borgbackup]: https://www.borgbackup.org/\n\n## Provider Contracts {#blocks-borgbackup-contract-provider}\n\nThis block provides the following contracts:\n\n- [backup contract](contracts-backup.html) under the [`shb.borgbackup.instances`][instances] option.\n  It is tested with [contract tests][backup contract tests].\n- [database backup contract](contracts-databasebackup.html) under the [`shb.borgbackup.databases`][databases] option.\n  It is tested with [contract tests][database backup contract tests].\n\n[instances]: #blocks-borgbackup-options-shb.borgbackup.instances\n[databases]: #blocks-borgbackup-options-shb.borgbackup.databases\n[backup contract tests]: @REPO@/test/contracts/backup.nix\n[database backup contract tests]: @REPO@/test/contracts/databasebackup.nix\n\nAs requested by those two contracts, when setting up a backup with BorgBackup,\na backup Systemd service and a [restore script](#blocks-borgbackup-maintenance) are provided.\n\n## Usage {#blocks-borgbackup-usage}\n\nThe following examples assume usage of the [sops block][] to provide secrets\nalthough any blocks providing the [secrets contract][] works too.\n\n[sops block]: ./blocks-sops.html\n[secrets contract]: ./contracts-secrets.html\n\n### One folder backed up manually {#blocks-borgbackup-usage-provider-manual}\n\nThe following snippet shows how to configure\nthe backup of 1 folder to 1 repository.\nWe assume that the folder `/var/lib/myfolder` of the service `myservice` must be backed up.\n\n```nix\nshb.borgbackup.instances.\"myservice\" = {\n  request = {\n    user = \"myservice\";\n\n    sourceDirectories = [\n      \"/var/lib/myfolder\"\n    ];\n  };\n\n  settings = {\n    enable = true;\n\n    passphrase.result = config.shb.sops.secret.\"passphrase\".result;\n\n    repository = {\n      path = \"/srv/backups/myservice\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n    };\n\n    retention = {\n      within = \"1d\";\n      hourly = 24;\n      daily = 7;\n      weekly = 4;\n      monthly = 6;\n    };\n  };\n};\n\nshb.sops.secret.\"passphrase\".request =\n  config.shb.borgbackup.instances.\"myservice\".settings.passphrase.request;\n```\n\n### One folder backed up with contract {#blocks-borgbackup-usage-provider-contract}\n\nWith the same example as before but assuming the `myservice` service\nhas a `myservice.backup` option that is a requester for the backup contract,\nthe snippet above becomes:\n\n```nix\nshb.borgbackup.instances.\"myservice\" = {\n  request = config.myservice.backup;\n\n  settings = {\n    enable = true;\n\n    passphrase.result = config.shb.sops.secret.\"passphrase\".result;\n\n    repository = {\n      path = \"/srv/backups/myservice\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n    };\n\n    retention = {\n      within = \"1d\";\n      hourly = 24;\n      daily = 7;\n      weekly = 4;\n      monthly = 6;\n    };\n  };\n};\n\nshb.sops.secret.\"passphrase\".request =\n  config.shb.borgbackup.instances.\"myservice\".settings.passphrase.request;\n```\n\n### One folder backed up to S3 {#blocks-borgbackup-usage-provider-remote}\n\nHere we will only highlight the differences with the previous configuration.\n\nThis assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/).\n\n```diff\n  shb.test.backup.instances.myservice = {\n\n    repository = {\n-     path = \"/srv/pool1/backups/myfolder\";\n+     path = \"s3:s3.us-west-000.backblazeb2.com/backups/myfolder\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n\n+     extraSecrets = {\n+       AWS_ACCESS_KEY_ID.source=\"<path/to/access_key_id>\";\n+       AWS_SECRET_ACCESS_KEY.source=\"<path/to/secret_access_key>\";\n+     };\n    };\n  }\n```\n\n### Multiple directories to multiple destinations {#blocks-borgbackup-usage-multiple}\n\nThe following snippet shows how to configure backup of any number of folders to 3 repositories,\neach happening at different times to avoid I/O contention.\n\nWe will also make sure to be able to re-use as much as the configuration as possible.\n\nA few assumptions:\n- 2 hard drive pools used for backup are mounted respectively on `/srv/pool1` and `/srv/pool2`.\n- You have a backblaze account.\n\nFirst, let's define a variable to hold all the repositories we want to back up to:\n\n```nix\nrepos = [\n  {\n    path = \"/srv/pool1/backups\";\n    timerConfig = {\n      OnCalendar = \"00:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n  {\n    path = \"/srv/pool2/backups\";\n    timerConfig = {\n      OnCalendar = \"08:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n  {\n    path = \"s3:s3.us-west-000.backblazeb2.com/backups\";\n    timerConfig = {\n      OnCalendar = \"16:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n];\n```\n\nCompared to the previous examples, we do not include the name of what we will back up in the\nrepository paths.\n\nNow, let's define a function to create a backup configuration. It will take a list of repositories,\na name identifying the backup and a list of folders to back up.\n\n```nix\nbackupcfg = repositories: name: sourceDirectories {\n  enable = true;\n\n  backend = \"borgbackup\";\n\n  keySopsFile = ../secrets/backup.yaml;\n\n  repositories = builtins.map (r: {\n    path = \"${r.path}/${name}\";\n    inherit (r) timerConfig;\n  }) repositories;\n\n  inherit sourceDirectories;\n\n  retention = {\n    within = \"1d\";\n    hourly = 24;\n    daily = 7;\n    weekly = 4;\n    monthly = 6;\n  };\n\n  environmentFile = true;\n};\n```\n\nNow, we can define multiple backup jobs to backup different folders:\n\n```nix\nshb.test.backup.instances.myfolder1 = backupcfg repos [\"/var/lib/myfolder1\"];\nshb.test.backup.instances.myfolder2 = backupcfg repos [\"/var/lib/myfolder2\"];\n```\n\nThe difference between the above snippet and putting all the folders into one configuration (shown\nbelow) is the former splits the backups into sub-folders on the repositories.\n\n```nix\nshb.test.backup.instances.all = backupcfg repos [\"/var/lib/myfolder1\" \"/var/lib/myfolder2\"];\n```\n\n## Monitoring {#blocks-borgbackup-monitoring}\n\nA generic dashboard for all backup solutions is provided.\nSee [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter.\n\n## Maintenance {#blocks-borgbackup-maintenance}\n\nOne command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets.\n\nThe restore script has all the secrets needed to access the repo,\nit will run `sudo` automatically\nand the user running it needs to have correct permissions for privilege escalation\n\nIn the [multiple directories example](#blocks-borgbackup-usage-multiple) above, the following 6 helpers are provided in the `$PATH`:\n\n```bash\nborgbackup-job-myfolder1_srv_pool1_backups\nborgbackup-job-myfolder1_srv_pool2_backups\nborgbackup-job-myfolder1_s3_s3.us-west-000.backblazeb2.com_backups\nborgbackup-job-myfolder2_srv_pool1_backups\nborgbackup-job-myfolder2_srv_pool2_backups\nborgbackup-job-myfolder2_s3_s3.us-west-000.backblazeb2.com_backups\n```\n\nDiscovering those is easy thanks to tab-completion.\n\nOne can then restore a backup from a given repository with:\n\n```bash\nborgbackup-job-myfolder1_srv_pool1_backups restore latest\n```\n\n### Troubleshooting {#blocks-borgbackup-maintenance-troubleshooting}\n\nIn case something bad happens with a backup, the [official documentation](https://borgbackup.readthedocs.io/en/stable/077_troubleshooting.html) has a lot of tips.\n\n## Tests {#blocks-borgbackup-tests}\n\nSpecific integration tests are defined in [`/test/blocks/borgbackup.nix`](@REPO@/test/blocks/borgbackup.nix).\n\n## Options Reference {#blocks-borgbackup-options}\n\n```{=include=} options\nid-prefix: blocks-borgbackup-options-\nlist-id: selfhostblocks-block-borgbackup-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/borgbackup.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  utils,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.borgbackup;\n\n  inherit (lib)\n    concatStringsSep\n    filterAttrs\n    flatten\n    literalExpression\n    optionals\n    listToAttrs\n    mapAttrsToList\n    mkOption\n    mkMerge\n    ;\n  inherit (lib)\n    mkIf\n    nameValuePair\n    optionalAttrs\n    removePrefix\n    ;\n  inherit (lib.types)\n    attrsOf\n    int\n    oneOf\n    nonEmptyStr\n    nullOr\n    str\n    submodule\n    ;\n\n  commonOptions =\n    {\n      name,\n      prefix,\n      config,\n      ...\n    }:\n    {\n      enable = lib.mkEnableOption ''\n        SelfHostBlocks' BorgBackup block;\n\n        A disabled instance will not backup data anymore\n        but still provides the helper tool to restore snapshots\n      '';\n\n      passphrase = lib.mkOption {\n        description = \"Encryption key for the backup repository.\";\n        type = lib.types.submodule {\n          options = shb.contracts.secret.mkRequester {\n            mode = \"0400\";\n            owner = config.request.user;\n            ownerText = \"[shb.borgbackup.${prefix}.<name>.request.user](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.request.user)\";\n            restartUnits = [ (fullName name config.settings.repository) ];\n            restartUnitsText = \"[ [shb.borgbackup.${prefix}.<name>.settings.repository](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.settings.repository) ]\";\n          };\n        };\n      };\n\n      repository = lib.mkOption {\n        description = \"Repository to send the backups to.\";\n        type = submodule {\n          options = {\n            path = mkOption {\n              type = str;\n              description = \"Repository location\";\n            };\n\n            secrets = mkOption {\n              type = attrsOf shb.secretFileType;\n              default = { };\n              description = ''\n                Secrets needed to access the repository where the backups will be stored.\n\n                See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example\n                and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets.\n\n              '';\n              example = literalExpression ''\n                {\n                  AWS_ACCESS_KEY_ID.source = <path/to/secret>;\n                  AWS_SECRET_ACCESS_KEY.source = <path/to/secret>;\n                }\n              '';\n            };\n\n            timerConfig = mkOption {\n              type = attrsOf utils.systemdUtils.unitOptions.unitOption;\n              default = {\n                OnCalendar = \"daily\";\n                Persistent = true;\n              };\n              description = \"When to run the backup. See {manpage}`systemd.timer(5)` for details.\";\n              example = {\n                OnCalendar = \"00:05\";\n                RandomizedDelaySec = \"5h\";\n                Persistent = true;\n              };\n            };\n          };\n        };\n      };\n\n      retention = lib.mkOption {\n        description = \"Retention options. See {command}`borg help prune` for the available options.\";\n        type = attrsOf (oneOf [\n          int\n          nonEmptyStr\n        ]);\n        default = {\n          within = \"1d\";\n          hourly = 24;\n          daily = 7;\n          weekly = 4;\n          monthly = 6;\n        };\n      };\n\n      consistency = lib.mkOption {\n        description = \"Consistency frequency options.\";\n        type = lib.types.attrsOf lib.types.nonEmptyStr;\n        default = { };\n        example = {\n          repository = \"2 weeks\";\n          archives = \"1 month\";\n        };\n      };\n\n      limitUploadKiBs = mkOption {\n        type = nullOr int;\n        description = \"Limit upload bandwidth to the given KiB/s amount.\";\n        default = null;\n        example = 8000;\n      };\n\n      stateDir = mkOption {\n        type = nullOr lib.types.str;\n        description = ''\n          Override the directory in which {command}`borg` stores its\n          configuration and cache. By default it uses the user's\n          home directory but is some cases this can cause conflicts.\n        '';\n        default = null;\n      };\n    };\n\n  repoSlugName = name: builtins.replaceStrings [ \"/\" \":\" ] [ \"_\" \"_\" ] (removePrefix \"/\" name);\n  fullName = name: repository: \"borgbackup-job-${name}_${repoSlugName repository.path}\";\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/monitoring.nix\n  ];\n\n  options.shb.borgbackup = {\n    enableDashboard = lib.mkEnableOption \"the Backups SHB dashboard\" // {\n      default = true;\n    };\n\n    instances = mkOption {\n      description = \"Files to backup following the [backup contract](./shb.contracts-backup.html).\";\n      default = { };\n      type = attrsOf (\n        submodule (\n          { name, config, ... }:\n          {\n            options = shb.contracts.backup.mkProvider {\n              settings = mkOption {\n                description = ''\n                  Settings specific to the BorgBackup provider.\n                '';\n\n                type = submodule {\n                  options = commonOptions {\n                    inherit name config;\n                    prefix = \"instances\";\n                  };\n                };\n              };\n\n              resultCfg = {\n                restoreScript = fullName name config.settings.repository;\n                restoreScriptText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}\";\n\n                backupService = \"${fullName name config.settings.repository}.service\";\n                backupServiceText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}.service\";\n              };\n            };\n          }\n        )\n      );\n    };\n\n    databases = mkOption {\n      description = \"Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html).\";\n      default = { };\n      type = attrsOf (\n        submodule (\n          { name, config, ... }:\n          {\n            options = shb.contracts.databasebackup.mkProvider {\n              settings = mkOption {\n                description = ''\n                  Settings specific to the BorgBackup provider.\n                '';\n\n                type = submodule {\n                  options = commonOptions {\n                    inherit name config;\n                    prefix = \"databases\";\n                  };\n                };\n              };\n\n              resultCfg = {\n                restoreScript = fullName name config.settings.repository;\n                restoreScriptText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}\";\n\n                backupService = \"${fullName name config.settings.repository}.service\";\n                backupServiceText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}.service\";\n              };\n            };\n          }\n        )\n      );\n    };\n\n    borgServer = lib.mkOption {\n      description = \"Add borgbackup package to `environment.systemPackages` so external backups can use this server as a remote.\";\n      default = false;\n      example = true;\n      type = lib.types.bool;\n    };\n\n    # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23\n    performance = lib.mkOption {\n      description = \"Reduce performance impact of backup jobs.\";\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          niceness = lib.mkOption {\n            type = lib.types.ints.between (-20) 19;\n            description = \"nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process\";\n            default = 15;\n          };\n          ioSchedulingClass = lib.mkOption {\n            type = lib.types.enum [\n              \"idle\"\n              \"best-effort\"\n              \"realtime\"\n            ];\n            description = \"ionice scheduling class, defaults to best-effort IO.\";\n            default = \"best-effort\";\n          };\n          ioPriority = lib.mkOption {\n            type = lib.types.nullOr (lib.types.ints.between 0 7);\n            description = \"ionice priority, defaults to 7 for lowest priority IO.\";\n            default = 7;\n          };\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf (cfg.instances != { } || cfg.databases != { }) (\n    let\n      enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances;\n      enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases;\n    in\n    lib.mkMerge [\n      {\n        environment.systemPackages =\n          optionals (cfg.borgServer || enabledInstances != { } || enabledDatabases != { })\n            [\n              pkgs.borgbackup\n            ];\n      }\n      {\n        services.borgbackup.jobs =\n          let\n            mkJob = name: instance: {\n              \"${name}_${repoSlugName instance.settings.repository.path}\" = {\n                inherit (instance.request) user;\n\n                repo = instance.settings.repository.path;\n\n                paths = instance.request.sourceDirectories;\n\n                encryption.mode = \"repokey-blake2\";\n                # We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down.\n                encryption.passCommand = \"cat ${instance.settings.passphrase.result.path}\";\n\n                doInit = true;\n                failOnWarnings = true;\n                stateDir = instance.settings.stateDir;\n\n                persistentTimer = instance.settings.repository.timerConfig.Persistent or false;\n                startAt = \"\"; # Some non-empty string value tricks the upstream module in creating the systemd timer.\n\n                prune.keep = instance.settings.retention;\n\n                preHook = concatStringsSep \"\\n\" instance.request.hooks.beforeBackup;\n\n                postHook = concatStringsSep \"\\n\" instance.request.hooks.afterBackup;\n\n                extraArgs = (\n                  optionals (instance.settings.limitUploadKiBs != null) [\n                    \"--upload-ratelimit=${toString instance.settings.limitUploadKiBs}\"\n                  ]\n                );\n\n                exclude = instance.request.excludePatterns;\n              };\n            };\n          in\n          mkMerge (mapAttrsToList mkJob enabledInstances);\n      }\n      {\n        services.borgbackup.jobs =\n          let\n            mkJob = name: instance: {\n              \"${name}_${repoSlugName instance.settings.repository.path}\" = {\n                inherit (instance.request) user;\n\n                repo = instance.settings.repository.path;\n\n                dumpCommand = lib.getExe (\n                  pkgs.writeShellApplication {\n                    name = \"dump-command\";\n                    text = instance.request.backupCmd;\n                  }\n                );\n\n                encryption.mode = \"repokey-blake2\";\n                # We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down.\n                encryption.passCommand = \"cat ${instance.settings.passphrase.result.path}\";\n\n                doInit = true;\n                failOnWarnings = true;\n                stateDir = instance.settings.stateDir;\n\n                persistentTimer = instance.settings.repository.timerConfig.Persistent or false;\n                startAt = \"\"; # Some non-empty list value that tricks upstream in creating the systemd timer.\n\n                prune.keep = instance.settings.retention;\n\n                extraArgs = (\n                  optionals (instance.settings.limitUploadKiBs != null) [\n                    \"--upload-ratelimit=${toString instance.settings.limitUploadKiBs}\"\n                  ]\n                );\n              };\n            };\n          in\n          mkMerge (mapAttrsToList mkJob enabledDatabases);\n      }\n      {\n        systemd.timers =\n          let\n            mkTimer = name: instance: {\n              ${fullName name instance.settings.repository} = {\n                timerConfig = lib.mkForce instance.settings.repository.timerConfig;\n              };\n            };\n          in\n          mkMerge (mapAttrsToList mkTimer (enabledInstances // enabledDatabases));\n      }\n      {\n        systemd.services =\n          let\n            mkSettings =\n              name: instance:\n              let\n                serviceName = fullName name instance.settings.repository;\n              in\n              {\n                ${serviceName} = mkMerge [\n                  {\n                    serviceConfig = {\n                      # Makes the systemd service wait for the backup to be done before changing state to inactive.\n                      Type = \"oneshot\";\n                      Nice = lib.mkForce cfg.performance.niceness;\n                      IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass;\n                      IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority;\n                      # BindReadOnlyPaths = instance.sourceDirectories;\n                    };\n                  }\n                  (optionalAttrs (instance.settings.repository.secrets != { }) {\n                    serviceConfig.EnvironmentFile = [\n                      \"/run/secrets_borgbackup/${serviceName}\"\n                    ];\n                    after = [ \"${serviceName}-pre.service\" ];\n                    requires = [ \"${serviceName}-pre.service\" ];\n                  })\n                ];\n\n                \"${serviceName}-pre\" = mkIf (instance.settings.repository.secrets != { }) (\n                  let\n                    script = shb.genConfigOutOfBandSystemd {\n                      config = instance.settings.repository.secrets;\n                      configLocation = \"/run/secrets_borgbackup/${serviceName}\";\n                      generator = shb.toEnvVar;\n                      user = instance.request.user;\n                    };\n                  in\n                  {\n                    script = script.preStart;\n                    serviceConfig.Type = \"oneshot\";\n                    serviceConfig.LoadCredential = script.loadCredentials;\n                  }\n                );\n              };\n          in\n          mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases)));\n      }\n      {\n        systemd.services =\n          let\n            mkEnv =\n              name: instance:\n              nameValuePair \"${fullName name instance.settings.repository}_restore_gen\" {\n                enable = true;\n                wantedBy = [ \"multi-user.target\" ];\n                serviceConfig.Type = \"oneshot\";\n                script = (\n                  shb.replaceSecrets {\n                    userConfig = instance.settings.repository.secrets // {\n                      BORG_PASSCOMMAND = ''\"cat ${instance.settings.passphrase.result.path}\"'';\n                      BORG_REPO = instance.settings.repository.path;\n                    };\n                    resultPath = \"/run/secrets_borgbackup_env/${fullName name instance.settings.repository}\";\n                    generator = shb.toEnvVar;\n                    user = instance.request.user;\n                  }\n                );\n              };\n          in\n          listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases)));\n      }\n      {\n        environment.systemPackages =\n          let\n            mkBorgBackupBinary =\n              name: instance:\n              pkgs.writeShellApplication {\n                name = fullName name instance.settings.repository;\n                text = ''\n                  usage() {\n                    echo \"$0 restore latest\"\n                  }\n\n                  if ! [ \"$1\" = \"restore\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  if ! [ \"$1\" = \"latest\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  sudocmd() {\n                    sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} \"$@\"\n                  }\n\n                  set -a\n                  # shellcheck disable=SC1090\n                  source <(sudocmd cat \"/run/secrets_borgbackup_env/${fullName name instance.settings.repository}\")\n                  set +a\n\n                  archive=\"$(sudocmd borg list --short \"$BORG_REPO\" | tail -n 1)\"\n                  echo \"Will restore archive $archive\"\n\n                  (cd / && sudocmd ${pkgs.borgbackup}/bin/borg extract \"$BORG_REPO\"::\"$archive\")\n                '';\n              };\n          in\n          flatten (mapAttrsToList mkBorgBackupBinary cfg.instances);\n      }\n      {\n        environment.systemPackages =\n          let\n            mkBorgBackupBinary =\n              name: instance:\n              pkgs.writeShellApplication {\n                name = fullName name instance.settings.repository;\n                text = ''\n                  usage() {\n                    echo \"$0 restore latest\"\n                  }\n\n                  if ! [ \"$1\" = \"restore\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  if ! [ \"$1\" = \"latest\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  sudocmd() {\n                    sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} \"$@\"\n                  }\n\n                  set -a\n                  # shellcheck disable=SC1090\n                  source <(sudocmd cat \"/run/secrets_borgbackup_env/${fullName name instance.settings.repository}\")\n                  set +a\n\n                  archive=\"$(sudocmd borg list --short \"$BORG_REPO\" | tail -n 1)\"\n                  echo \"Will restore archive $archive\"\n\n                  sudocmd sh -c \"${pkgs.borgbackup}/bin/borg extract $BORG_REPO::$archive --stdout | ${instance.request.restoreCmd}\"\n                '';\n              };\n          in\n          flatten (mapAttrsToList mkBorgBackupBinary cfg.databases);\n      }\n\n      (lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) {\n        shb.monitoring.dashboards = [\n          ./backup/dashboard/Backups.json\n        ];\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/blocks/davfs.nix",
    "content": "{ config, lib, ... }:\n\nlet\n  cfg = config.shb.davfs;\nin\n{\n  options.shb.davfs = {\n    mounts = lib.mkOption {\n      description = \"List of mounts.\";\n      default = [ ];\n      type = lib.types.listOf (\n        lib.types.submodule {\n          options = {\n            remoteUrl = lib.mkOption {\n              type = lib.types.str;\n              description = \"Webdav endpoint to connect to.\";\n              example = \"https://my.domain.com/dav\";\n            };\n\n            mountPoint = lib.mkOption {\n              type = lib.types.str;\n              description = \"Mount point to mount the webdav endpoint on.\";\n              example = \"/mnt\";\n            };\n\n            username = lib.mkOption {\n              type = lib.types.str;\n              description = \"Username to connect to the webdav endpoint.\";\n            };\n\n            passwordFile = lib.mkOption {\n              type = lib.types.str;\n              description = \"Password to connect to the webdav endpoint.\";\n            };\n\n            uid = lib.mkOption {\n              type = lib.types.nullOr lib.types.int;\n              description = \"User owner of the mount point.\";\n              example = 1000;\n              default = null;\n            };\n\n            gid = lib.mkOption {\n              type = lib.types.nullOr lib.types.int;\n              description = \"Group owner of the mount point.\";\n              example = 1000;\n              default = null;\n            };\n\n            fileMode = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"File creation mode\";\n              example = \"0664\";\n              default = null;\n            };\n\n            directoryMode = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"Directory creation mode\";\n              example = \"2775\";\n              default = null;\n            };\n\n            automount = lib.mkOption {\n              type = lib.types.bool;\n              description = \"Create a systemd automount unit\";\n              default = true;\n            };\n          };\n        }\n      );\n    };\n  };\n\n  config = {\n    services.davfs2.enable = builtins.length cfg.mounts > 0;\n\n    systemd.mounts =\n      let\n        mkMountCfg = c: {\n          enable = true;\n          description = \"Webdav mount point\";\n          after = [ \"network-online.target\" ];\n          wants = [ \"network-online.target\" ];\n\n          what = c.remoteUrl;\n          where = c.mountPoint;\n          options = lib.concatStringsSep \",\" (\n            (lib.optional (!(isNull c.uid)) \"uid=${toString c.uid}\")\n            ++ (lib.optional (!(isNull c.gid)) \"gid=${toString c.gid}\")\n            ++ (lib.optional (!(isNull c.fileMode)) \"file_mode=${toString c.fileMode}\")\n            ++ (lib.optional (!(isNull c.directoryMode)) \"dir_mode=${toString c.directoryMode}\")\n          );\n          type = \"davfs\";\n          mountConfig.TimeoutSec = 15;\n        };\n      in\n      map mkMountCfg cfg.mounts;\n  };\n}\n"
  },
  {
    "path": "modules/blocks/hardcodedsecret.nix",
    "content": "{\n  config,\n  lib,\n  pkgs,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.hardcodedsecret;\n\n  inherit (lib) mapAttrs' mkOption nameValuePair;\n  inherit (lib.types)\n    attrsOf\n    nullOr\n    str\n    submodule\n    ;\n  inherit (pkgs) writeText;\nin\n{\n  imports = [\n    ../../lib/module.nix\n  ];\n\n  options.shb.hardcodedsecret = mkOption {\n    default = { };\n    description = ''\n      Hardcoded secrets. These should only be used in tests.\n    '';\n    example = lib.literalExpression ''\n      {\n        mySecret = {\n          request = {\n            user = \"me\";\n            mode = \"0400\";\n            restartUnits = [ \"myservice.service\" ];\n          };\n          settings.content = \"My Secret\";\n        };\n      }\n    '';\n    type = attrsOf (\n      submodule (\n        { name, ... }:\n        {\n          options = shb.contracts.secret.mkProvider {\n            settings = mkOption {\n              description = ''\n                Settings specific to the hardcoded secret module.\n\n                Give either `content` or `source`.\n              '';\n\n              type = submodule {\n                options = {\n                  content = mkOption {\n                    type = nullOr str;\n                    description = ''\n                      Content of the secret as a string.\n\n                      This will be stored in the nix store and should only be used for testing or maybe in dev.\n                    '';\n                    default = null;\n                  };\n\n                  source = mkOption {\n                    type = nullOr str;\n                    description = ''\n                      Source of the content of the secret as a path in the nix store.\n                    '';\n                    default = null;\n                  };\n                };\n              };\n            };\n\n            resultCfg = {\n              path = \"/run/hardcodedsecrets/hardcodedsecret_${name}\";\n            };\n          };\n        }\n      )\n    );\n  };\n\n  config = {\n    system.activationScripts = mapAttrs' (\n      n: cfg':\n      let\n        source =\n          if cfg'.settings.source != null then\n            cfg'.settings.source\n          else\n            writeText \"hardcodedsecret_${n}_content\" cfg'.settings.content;\n      in\n      nameValuePair \"hardcodedsecret_${n}\" ''\n        mkdir -p \"$(dirname \"${cfg'.result.path}\")\"\n        touch \"${cfg'.result.path}\"\n        chmod ${cfg'.request.mode} \"${cfg'.result.path}\"\n        chown ${cfg'.request.owner}:${cfg'.request.group} \"${cfg'.result.path}\"\n        cp ${source} \"${cfg'.result.path}\"\n      ''\n    ) cfg;\n  };\n}\n"
  },
  {
    "path": "modules/blocks/lldap/docs/default.md",
    "content": "# LLDAP Block {#blocks-lldap}\n\nDefined in [`/modules/blocks/lldap.nix`](@REPO@/modules/blocks/lldap.nix).\n\nThis block sets up an [LLDAP][] service for user and group management\nacross services.\n\n[LLDAP]: https://github.com/lldap/lldap\n\n## Features {#blocks-lldap-features}\n\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-lldap-usage-applicationdashboard)\n\n## Usage {#blocks-lldap-usage}\n\n### Initial Configuration {#blocks-lldap-usage-configuration}\n\n```nix\nshb.lldap = {\n  enable = true;\n  subdomain = \"ldap\";\n  domain = \"example.com\";\n  dcdomain = \"dc=example,dc=com\";\n\n  ldapPort = 3890;\n  webUIListenPort = 17170;\n\n  jwtSecret.result = config.shb.sops.secret.\"lldap/jwt_secret\".result;\n  ldapUserPassword.result = config.shb.sops.secret.\"lldap/user_password\".result;\n};\nshb.sops.secret.\"lldap/jwt_secret\".request = config.shb.lldap.jwtSecret.request;\nshb.sops.secret.\"lldap/user_password\".request = config.shb.lldap.ldapUserPassword.request;\n```\n\nThis assumes secrets are setup with SOPS\nas mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\n\n### SSL {#blocks-lldap-usage-ssl}\n\nUsing SSL is an important security practice, like always.\nUsing the [SSL block][], the configuration to add to the one above is:\n\n[SSL block]: blocks-ssl.html\n\n```nix\nshb.certs.certs.letsencrypt.${domain}.extraDomains = [\n  \"${config.shb.lldap.subdomain}.${config.shb.lldap.domain}\"\n];\n\nshb.lldap.ssl = config.shb.certs.certs.letsencrypt.${config.shb.lldap.domain};\n```\n\n### Restrict Access By IP {#blocks-lldap-usage-restrict-access-by-ip}\n\nFor added security, you can restrict access to the LLDAP UI\nby adding the following line:\n\n```nix\nshb.lldap.restrictAccessIPRange = \"192.168.50.0/24\";\n```\n\n### Application Dashboard {#blocks-lldap-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#blocks-lldap-options-shb.lldap.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Admin.services.LLDAP = {\n    sortOrder = 2;\n    dashboard.request = config.shb.lldap.dashboard.request;\n  };\n}\n```\n\n## Manage Groups {#blocks-lldap-manage-groups}\n\nThe following snippet will create group named \"family\" if it does not exist yet.\nAlso, all other groups will be deleted and only the \"family\" group will remain.\n\nNote that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist.\n\nIf you want existing groups not declared in the `shb.lldap.ensureGroups` to be deleted,\nset [`shb.lldap.enforceGroups`](#blocks-lldap-options-shb.lldap.enforceGroups) to `true`.\n\n```nix\n{\n  shb.lldap.ensureGroups = {\n   family = {};\n  };\n}\n```\n\nChanging the configuration to the following will add a new group \"friends\":\n\n```nix\n{\n  shb.lldap.ensureGroups = {\n    family = {};\n    friends = {};\n  };\n}\n```\n\nSwitching back the configuration to the previous one will delete the group \"friends\":\n\n```nix\n{\n  shb.lldap.ensureGroups = {\n    family = {};\n  };\n}\n```\n\nCustom fields can be added to groups as long as they are added to the `ensureGroupFields` field:\n\n```nix\nshb.lldap = {\n  ensureGroupFields = {\n    mygroupattribute = {\n      attributeType = \"STRING\";\n    };\n  };\n\n  ensureGroups = {\n    family = {\n      mygroupattribute = \"Managed by NixOS\";\n    };\n  };\n};\n```\n\n## Manage Users {#blocks-lldap-manage-users}\n\nThe following snippet creates a user and makes it a member of the \"family\" group.\n\nNote the following behavior:\n\n- New users will be created following the `shb.lldap.ensureUsers` option.\n- Existing users will be updated, their password included, if they are mentioned in the `shb.lldap.ensureUsers` option.\n- Existing users not declared in the `shb.lldap.ensureUsers` will be left as-is.\n- User memberships to groups not declared in their respective `shb.lldap.ensureUsers.<name>.groups`.\n\nIf you want existing users not declared in the `shb.lldap.ensureUsers` to be deleted,\nset [`shb.lldap.enforceUsers`](#blocks-lldap-options-shb.lldap.enforceUsers) to `true`.\n\nIf you want memberships to groups not declared in the respective\n`shb.lldap.ensureUsers.<name>.groups` option to be deleted,\nset [`shb.lldap.enforceUserMemberships`](#blocks-lldap-options-shb.lldap.enforceUserMemberships) `true`.\n\n```nix\n{\n  shb.lldap.ensureUsers = {\n    dad = {\n      email = \"dad@example.com\";\n      displayName = \"Dad\";\n      firstName = \"First Name\";\n      lastName = \"Last Name\";\n      groups = [ \"family\" ];\n      password.result = config.shb.sops.secret.\"dad\".result;\n    };\n  };\n\n  shb.sops.secret.\"dad\".request =\n    config.shb.lldap.ensureUsers.dad.password.request;\n}\n```\n\nThe password field assumes usage of the [sops block][] to provide secrets\nalthough any blocks providing the [secrets contract][] works too.\n\n[sops block]: blocks-sops.html\n[secrets contract]: contracts-secrets.html\n\nThe user is still editable through the UI.\nThat being said, any change will be overwritten next time the configuration is applied.\n\n## Troubleshooting {#blocks-lldap-troubleshooting}\n\nTo increase logging verbosity and see the trace of the GraphQL queries, add:\n\n```nix\nshb.lldap.debug = true;\n```\n\nNote that verbosity is truly verbose here\nso you will want to revert this at some point.\n\nTo see the logs, then run `journalctl -u lldap.service`.\n\nSetting the `debug` option to `true` will also\nadd an [shb.mitmdump][] instance in front of the LLDAP [web UI port](#blocks-lldap-options-shb.lldap.webUIListenPort)\nwhich prints all requests and responses headers and body\nto the systemd service `mitmdump-lldap.service`. Note the you won't\nsee the query done using something like `ldapsearch` since those\ngo through the [`LDAP` port](#blocks-lldap-options-shb.lldap.ldapPort).\n\n[shb.mitmdump]: ./blocks-mitmdump.html\n\n## Tests {#blocks-lldap-tests}\n\nSpecific integration tests are defined in [`/test/blocks/lldap.nix`](@REPO@/test/blocks/lldap.nix).\n\n## Options Reference {#blocks-lldap-options}\n\n```{=include=} options\nid-prefix: blocks-lldap-options-\nlist-id: selfhostblocks-block-lldap-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/lldap.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.lldap;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  inherit (lib) mkOption types;\n\n  ensureFormat = pkgs.formats.json { };\n\n  ensureFieldsOptions = name: {\n    name = mkOption {\n      type = types.str;\n      description = \"Name of the field.\";\n      default = name;\n    };\n\n    attributeType = mkOption {\n      type = types.enum [\n        \"STRING\"\n        \"INTEGER\"\n        \"JPEG\"\n        \"DATE_TIME\"\n      ];\n      description = \"Attribute type.\";\n    };\n\n    isEditable = mkOption {\n      type = types.bool;\n      description = \"Is field editable.\";\n      default = true;\n    };\n\n    isList = mkOption {\n      type = types.bool;\n      description = \"Is field a list.\";\n      default = false;\n    };\n\n    isVisible = mkOption {\n      type = types.bool;\n      description = \"Is field visible in UI.\";\n      default = true;\n    };\n  };\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ./mitmdump.nix\n\n    (lib.mkRenamedOptionModule [ \"shb\" \"ldap\" ] [ \"shb\" \"lldap\" ])\n  ];\n\n  options.shb.lldap = {\n    enable = lib.mkEnableOption \"the LDAP service\";\n\n    dcdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"dc domain to serve.\";\n      example = \"dc=mydomain,dc=com\";\n    };\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which the LDAP service will be served.\";\n      example = \"grafana\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Domain under which the LDAP service will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ldapPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port on which the server listens for the LDAP protocol.\";\n      default = 3890;\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    webUIListenPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port on which the web UI is exposed.\";\n      default = 17170;\n    };\n\n    ldapUserPassword = lib.mkOption {\n      description = \"LDAP admin user secret. Must be >= 8 characters.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0440\";\n          owner = \"lldap\";\n          group = \"lldap\";\n          restartUnits = [ \"lldap.service\" ];\n        };\n      };\n    };\n\n    jwtSecret = lib.mkOption {\n      description = \"JWT secret.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0440\";\n          owner = \"lldap\";\n          group = \"lldap\";\n          restartUnits = [ \"lldap.service\" ];\n        };\n      };\n    };\n\n    restrictAccessIPRange = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = \"Set a local network range to restrict access to the UI to only those IPs.\";\n      example = \"192.168.1.1/24\";\n      default = null;\n    };\n\n    debug = lib.mkOption {\n      description = \"Enable debug logging.\";\n      type = lib.types.bool;\n      default = false;\n    };\n\n    mount = lib.mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"ldap\" = {\n          poolName = \"root\";\n        } // config.shb.lldap.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = \"/var/lib/lldap\";\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          # TODO: is there a workaround that avoid needing to use root?\n          # root because otherwise we cannot access the private StateDiretory\n          user = \"root\";\n          # /private because the systemd service uses DynamicUser=true\n          sourceDirectories = [\n            \"/var/lib/private/lldap\"\n          ];\n        };\n      };\n    };\n\n    ensureUsers = mkOption {\n      description = ''\n        Create the users defined here on service startup.\n\n        If `enforceUsers` option is `true`, the groups\n        users belong to must be present in the `ensureGroups` option.\n\n        Non-default options must be added to the `ensureGroupFields` option.\n      '';\n      default = { };\n      type = types.attrsOf (\n        types.submodule (\n          { name, ... }:\n          {\n            freeformType = ensureFormat.type;\n\n            options = {\n              id = mkOption {\n                type = types.str;\n                description = \"Username.\";\n                default = name;\n              };\n\n              email = mkOption {\n                type = types.str;\n                description = \"Email.\";\n              };\n\n              password = mkOption {\n                description = \"Password.\";\n                type = lib.types.submodule {\n                  options = shb.contracts.secret.mkRequester {\n                    mode = \"0440\";\n                    owner = \"lldap\";\n                    group = \"lldap\";\n                    restartUnits = [ \"lldap.service\" ];\n                  };\n                };\n              };\n\n              displayName = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Display name.\";\n              };\n\n              firstName = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"First name.\";\n              };\n\n              lastName = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Last name.\";\n              };\n\n              avatar_file = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)\";\n              };\n\n              avatar_url = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)\";\n              };\n\n              gravatar_avatar = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Get avatar from Gravatar using the email.\";\n              };\n\n              weser_avatar = mkOption {\n                type = types.nullOr types.str;\n                default = null;\n                description = \"Convert avatar retrieved by gravatar or the URL.\";\n              };\n\n              groups = mkOption {\n                type = types.listOf types.str;\n                default = [ ];\n                description = \"Groups the user would be a member of (all the groups must be specified in group config files).\";\n              };\n            };\n          }\n        )\n      );\n    };\n\n    ensureGroups = mkOption {\n      description = ''\n        Create the groups defined here on service startup.\n\n        Non-default options must be added to the `ensureGroupFields` option.\n      '';\n      default = { };\n      type = types.attrsOf (\n        types.submodule (\n          { name, ... }:\n          {\n            freeformType = ensureFormat.type;\n\n            options = {\n              name = mkOption {\n                type = types.str;\n                description = \"Name of the group.\";\n                default = name;\n              };\n            };\n          }\n        )\n      );\n    };\n\n    ensureUserFields = mkOption {\n      description = \"Extra fields for users\";\n      default = { };\n      type = types.attrsOf (\n        types.submodule (\n          { name, ... }:\n          {\n            options = ensureFieldsOptions name;\n          }\n        )\n      );\n    };\n\n    ensureGroupFields = mkOption {\n      description = \"Extra fields for groups\";\n      default = { };\n      type = types.attrsOf (\n        types.submodule (\n          { name, ... }:\n          {\n            options = ensureFieldsOptions name;\n          }\n        )\n      );\n    };\n\n    enforceUsers = mkOption {\n      description = \"Delete users not set declaratively.\";\n      type = types.bool;\n      default = false;\n    };\n\n    enforceUserMemberships = mkOption {\n      description = \"Remove users from groups not set declaratively.\";\n      type = types.bool;\n      default = false;\n    };\n\n    enforceGroups = mkOption {\n      description = \"Remove groups not set declaratively.\";\n      type = types.bool;\n      default = false;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.lldap.subdomain}.\\${config.shb.lldap.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.webUIListenPort}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n\n    services.nginx = {\n      enable = true;\n\n      virtualHosts.${fqdn} = {\n        forceSSL = !(isNull cfg.ssl);\n        sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n        sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n        locations.\"/\" = {\n          extraConfig = ''\n            proxy_set_header Host $host;\n          ''\n          + (\n            if isNull cfg.restrictAccessIPRange then\n              \"\"\n            else\n              ''\n                allow ${cfg.restrictAccessIPRange};\n                deny all;\n              ''\n          );\n          proxyPass = \"http://${toString config.services.lldap.settings.http_host}:${toString config.shb.lldap.webUIListenPort}/\";\n        };\n      };\n    };\n\n    users.users.lldap = {\n      name = \"lldap\";\n      group = \"lldap\";\n      isSystemUser = true;\n    };\n    users.groups.lldap = { };\n\n    services.lldap = {\n      enable = true;\n\n      inherit (cfg) enforceUsers enforceUserMemberships enforceGroups;\n\n      environment = {\n        RUST_LOG = lib.mkIf cfg.debug \"debug\";\n      };\n\n      settings = {\n        http_url = \"https://${fqdn}\";\n        http_host = \"127.0.0.1\";\n        http_port = if !cfg.debug then cfg.webUIListenPort else cfg.webUIListenPort + 1;\n\n        ldap_host = \"127.0.0.1\";\n        ldap_port = cfg.ldapPort; # Would be great to be able to inspect this but it requires tcpdump instead of mitmproxy.\n        ldap_base_dn = cfg.dcdomain;\n\n        ldap_user_pass_file = toString cfg.ldapUserPassword.result.path;\n        force_ldap_user_pass_reset = \"always\";\n        jwt_secret_file = toString cfg.jwtSecret.result.path;\n\n        verbose = cfg.debug;\n      };\n\n      inherit (cfg) ensureGroups ensureUserFields ensureGroupFields;\n      ensureUsers = lib.mapAttrs (\n        n: v:\n        (lib.removeAttrs v [ \"password\" ])\n        // {\n          \"password_file\" = toString v.password.result.path;\n        }\n      ) cfg.ensureUsers;\n    };\n\n    shb.mitmdump.instances.\"lldap-web\" = lib.mkIf cfg.debug {\n      listenPort = config.shb.lldap.webUIListenPort;\n      upstreamPort = config.shb.lldap.webUIListenPort + 1;\n      after = [ \"lldap.service\" ];\n      enabledAddons = [ config.shb.mitmdump.addons.logger ];\n      extraArgs = [\n        \"--set\"\n        \"verbose_pattern=/api\"\n      ];\n    };\n  };\n}\n"
  },
  {
    "path": "modules/blocks/mitmdump/docs/default.md",
    "content": "# Mitmdump Block {#blocks-mitmdump}\n\nDefined in [`/modules/blocks/mitmdump.nix`](@REPO@/modules/blocks/mitmdump.nix).\n\nThis block sets up an [Mitmdump][] service in [reverse proxy][] mode.\nIn other words, you can put this block between a client and a server to inspect all the network traffic.\n\n[Mitmdump]: https://plattner.me/mp-docs/#mitmdump\n[reverse proxy]: https://plattner.me/mp-docs/concepts-modes/#reverse-proxy\n\nMultiple instances of mitmdump all listening on different ports\nand proxying to different upstream servers can be created.\n\nThe systemd service is made so it is started only when the mitmdump instance\nhas started listening on the expected port.\n\nAlso, addons can be enabled with the `enabledAddons` option.\n\n## Usage {#blocks-mitmdump-usage}\n\nPut mitmdump in front of a HTTP server listening on port 8000 on the same machine:\n\n```nix\nshb.mitmdump.instances.\"my-instance\" = {\n  listenPort = 8001;\n  upstreamHost = \"http://127.0.0.1\";\n  upstreamPort = 8000;\n  after = [ \"server.service\" ];\n};\n```\n\n`upstreamHost` has its default value here and can be left out.\n\nPut mitmdump in front of a HTTP server listening on port 8000 on another machine:\n\n```nix\nshb.mitmdump.instances.\"my-instance\" = {\n  listenPort = 8001;\n  upstreamHost = \"http://otherhost\";\n  upstreamPort = 8000;\n  after = [ \"server.service\" ];\n};\n```\n\n### Handle Upstream TLS {#blocks-mitmdump-usage-https}\n\nReplace `http` with `https` if the server expects an HTTPS connection.\n\n### Accept Connections from Anywhere {#blocks-mitmdump-usage-anywhere}\n\nBy default, `mitmdump` is configured to listen only for connections from localhost.\nAdd `listenHost=0.0.0.0` to make `mitmdump` accept connections from anywhere.\n\n### Extra Logging {#blocks-mitmdump-usage-logging}\n\nTo print request and response bodies and more, increase the logging with:\n\n```nix\nextraArgs = [\n    \"--set\" \"flow_detail=3\"\n    \"--set\" \"content_view_lines_cutoff=2000\"\n];\n```\n\nThe default `flow_details` is 1. See the [manual][] for more explanations on the option.\n\n[manual]: (https://docs.mitmproxy.org/stable/concepts/options/#flow_detail)\n\nThis will change the verbosity for all requests and responses.\nIf you need more fine grained logging, configure instead the [Logger Addon][].\n\n[Logger Addon]: #blocks-mitmdump-addons-logger\n\n## Addons {#blocks-mitmdump-addons}\n\nAll provided addons can be found under the `shb.mitmproxy.addons` option.\n\nTo enable one for an instance, add it to the `enabledAddons` option. For example:\n\n```nix\nshb.mitmdump.instances.\"my-instance\" = {\n    enabledAddons = [ config.shb.mitmdump.addons.logger ]\n}\n```\n\n### Fine Grained Logger {#blocks-mitmdump-addons-logger}\n\nThe Fine Grained Logger addon is found under `shb.mitmproxy.addons.logger`.\nEnabling this addon will add the `mitmdump` option `verbose_pattern` which takes a regex and if it matches,\nprints the request and response headers and body.\nIf it does not match, it will just print the response status.\n\nFor example, with the `extraArgs`:\n\n```nix\nextraArgs = [\n  \"--set\" \"verbose_pattern=/verbose\"\n];\n```\n\nA `GET` request to `/notverbose` will print something similar to:\n\n```\nmitmdump[972]: 127.0.0.1:53586: GET http://127.0.0.1:8000/notverbose HTTP/1.1\nmitmdump[972]:      << HTTP/1.0 200 OK 16b\n```\n\nWhile a `GET` request to `/verbose` will print something similar to:\n\n```\nmitmdump[972]: [22:42:58.840]\nmitmdump[972]: RequestHeaders:\nmitmdump[972]:     Host: 127.0.0.1:8000\nmitmdump[972]:     User-Agent: curl/8.14.1\nmitmdump[972]:     Accept: */*\nmitmdump[972]: RequestBody:\nmitmdump[972]: Status:          200\nmitmdump[972]: ResponseHeaders:\nmitmdump[972]:     Server: BaseHTTP/0.6 Python/3.13.4\nmitmdump[972]:     Date: Sun, 03 Aug 2025 22:42:58 GMT\nmitmdump[972]:     Content-Type: text/plain\nmitmdump[972]:     Content-Length: 13\nmitmdump[972]: ResponseBody:    test2/verbose\nmitmdump[972]: 127.0.0.1:53602: GET http://127.0.0.1:8000/verbose HTTP/1.1\nmitmdump[972]:      << HTTP/1.0 200 OK 13b\n```\n\n## Example {#blocks-mitmdump-example}\n\nLet's assume a server is listening on port 8000\nwhich responds a plain text response `test1`\nand its related systemd service is named `test1.service`.\nSorry, creative naming is not my forte.\n\nLet's put an mitmdump instance in front of it, like so:\n\n```nix\nshb.mitmdump.instances.\"test1\" = {\n  listenPort = 8001;\n  upstreamPort = 8000;\n  after = [ \"test1.service\" ];\n  extraArgs = [\n    \"--set\" \"flow_detail=3\"\n    \"--set\" \"content_view_lines_cutoff=2000\"\n  ];\n};\n```\n\nThis creates an `mitmdump-test1.service` systemd service.\nWe can then use `journalctl -u mitmdump-test1.service` to see the output.\n\nIf we make a `curl` request to it: `curl -v http://127.0.0.1:8001`,\nwe will get the following output:\n\n```\nmitmdump-test1[971]: 127.0.0.1:40878: GET http://127.0.0.1:8000/ HTTP/1.1\nmitmdump-test1[971]:     Host: 127.0.0.1:8000\nmitmdump-test1[971]:     User-Agent: curl/8.14.1\nmitmdump-test1[971]:     Accept: */*\nmitmdump-test1[971]:  << HTTP/1.0 200 OK 5b\nmitmdump-test1[971]:     Server: BaseHTTP/0.6 Python/3.13.4\nmitmdump-test1[971]:     Date: Thu, 31 Jul 2025 20:55:16 GMT\nmitmdump-test1[971]:     Content-Type: text/plain\nmitmdump-test1[971]:     Content-Length: 5\nmitmdump-test1[971]:     test1\n```\n\n## Tests {#blocks-mitmdump-tests}\n\nSpecific integration tests are defined in [`/test/blocks/mitmdump.nix`](@REPO@/test/blocks/mitmdump.nix).\n\n## Options Reference {#blocks-mitmdump-options}\n\n```{=include=} options\nid-prefix: blocks-mitmdump-options-\nlist-id: selfhostblocks-block-mitmdump-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/mitmdump.nix",
    "content": "{\n  config,\n  lib,\n  pkgs,\n  ...\n}:\nlet\n  inherit (lib)\n    mapAttrs'\n    mkOption\n    nameValuePair\n    types\n    ;\n  inherit (types)\n    attrsOf\n    listOf\n    port\n    submodule\n    str\n    ;\n\n  cfg = config.shb.mitmdump;\n\n  mitmdumpScript =\n    pkgs.writers.writePython3Bin \"mitmdump\"\n      {\n        libraries =\n          let\n            p = pkgs.python3Packages;\n          in\n          [\n            p.systemd-python\n            p.mitmproxy\n          ];\n        flakeIgnore = [ \"E501\" ];\n      }\n      ''\n        from systemd.daemon import notify\n        import argparse\n        import logging\n        import os\n        import subprocess\n        import socket\n        import sys\n        import time\n\n\n        logging.basicConfig(level=logging.INFO, format='%(message)s')\n\n\n        def wait_for_port(host, port, timeout=10):\n            deadline = time.time() + timeout\n            while time.time() < deadline:\n                try:\n                    with socket.create_connection((host, port), timeout=0.5):\n                        return True\n                except Exception:\n                    time.sleep(0.1)\n            return False\n\n\n        def flatten(xss):\n            return [x for xs in xss for x in xs]\n\n\n        parser = argparse.ArgumentParser()\n        parser.add_argument(\"--listen_host\", default=\"127.0.0.1\", help=\"Host mitmdump will listen on\")\n        parser.add_argument(\"--listen_port\", required=True, help=\"Port mitmdump will listen on\")\n        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\")\n        parser.add_argument(\"--upstream_port\", required=True, help=\"Port mitmdump will connect to for upstream\")\n        args, rest = parser.parse_known_args()\n\n        MITMDUMP_BIN = os.environ.get(\"MITMDUMP_BIN\")\n        if MITMDUMP_BIN is None:\n            raise Exception(\"MITMDUMP_BIN env var must be set to the path of the mitmdump binary\")\n\n        logging.info(f\"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.\")\n        wait_for_port(args.upstream_host, args.upstream_port, timeout=10)\n        logging.info(f\"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.\")\n\n        proc = subprocess.Popen(\n            [\n                MITMDUMP_BIN,\n                \"--listen-host\", args.listen_host,\n                \"-p\", args.listen_port,\n                \"--mode\", f\"reverse:{args.upstream_host}:{args.upstream_port}\",\n            ] + rest,\n            stdout=sys.stdout,\n            stderr=sys.stderr,\n        )\n\n        logging.info(f\"Waiting for mitmdump instance to start on port {args.listen_port}.\")\n        if wait_for_port(\"127.0.0.1\", args.listen_port, timeout=10):\n            logging.info(f\"Mitmdump is started on port {args.listen_port}.\")\n            notify(\"READY=1\")\n        else:\n            proc.terminate()\n            exit(1)\n\n        proc.wait()\n      '';\n\n  logger = toString (\n    pkgs.writers.writeText \"loggerAddon.py\" ''\n      import logging\n      from collections.abc import Sequence\n      from mitmproxy import ctx, http\n      import re\n\n\n      logger = logging.getLogger(__name__)\n\n\n      class RegexLogger:\n          def __init__(self):\n              self.verbose_patterns = None\n\n          def load(self, loader):\n              loader.add_option(\n                  name=\"verbose_pattern\",\n                  typespec=Sequence[str],\n                  default=[],\n                  help=\"Regex patterns for verbose logging\",\n              )\n\n          def response(self, flow: http.HTTPFlow):\n              if self.verbose_patterns is None:\n                  self.verbose_patterns = [re.compile(p) for p in ctx.options.verbose_pattern]\n\n              matched = any(p.search(flow.request.path) for p in self.verbose_patterns)\n              if matched:\n                  logger.info(format_flow(flow))\n\n\n      def format_flow(flow: http.HTTPFlow) -> str:\n          return (\n              \"\\n\"\n              \"RequestHeaders:\\n\"\n              f\"    {format_headers(flow.request.headers.items())}\\n\"\n              f\"RequestBody:     {flow.request.get_text()}\\n\"\n              f\"Status:          {flow.response.data.status_code}\\n\"\n              \"ResponseHeaders:\\n\"\n              f\"    {format_headers(flow.response.headers.items())}\\n\"\n              f\"ResponseBody:    {flow.response.get_text()}\\n\"\n          )\n\n\n      def format_headers(headers) -> str:\n          return \"\\n    \".join(k + \": \" + v for k, v in headers)\n\n\n      addons = [RegexLogger()]\n    ''\n  );\nin\n{\n  options.shb.mitmdump = {\n    addons = mkOption {\n      type = attrsOf str;\n      default = [ ];\n      description = ''\n        Addons available to the be added to the mitmdump instance.\n\n        To enabled them, add them to the `enabledAddons` option.\n      '';\n    };\n\n    instances = mkOption {\n      default = { };\n      description = \"Mitmdump instance.\";\n      type = attrsOf (\n        submodule (\n          { name, ... }:\n          {\n            options = {\n              package = lib.mkPackageOption pkgs \"mitmproxy\" { };\n\n              serviceName = mkOption {\n                type = str;\n                description = ''\n                  Name of the mitmdump system service.\n                '';\n                default = \"mitmdump-${name}.service\";\n                readOnly = true;\n              };\n\n              listenHost = mkOption {\n                type = str;\n                default = \"127.0.0.1\";\n                description = ''\n                  Host the mitmdump instance will connect on.\n                '';\n              };\n\n              listenPort = mkOption {\n                type = port;\n                description = ''\n                  Port the mitmdump instance will listen on.\n\n                  The upstream port from the client's perspective.\n                '';\n              };\n\n              upstreamHost = mkOption {\n                type = str;\n                default = \"http://127.0.0.1\";\n                description = ''\n                  Host the mitmdump instance will connect to.\n\n                  If only an IP or domain is provided,\n                  mitmdump will default to connect using HTTPS.\n                  If this is not wanted, prefix the IP or domain with the 'http://' protocol.\n                '';\n              };\n\n              upstreamPort = mkOption {\n                type = port;\n                description = ''\n                  Port the mitmdump instance will connect to.\n\n                  The port the server is listening on.\n                '';\n              };\n\n              after = mkOption {\n                type = listOf str;\n                default = [ ];\n                description = ''\n                  Systemd services that must be started before this mitmdump proxy instance.\n\n                  You are guaranteed the mitmdump is listening on the `listenPort`\n                  when its systemd service has started.\n                '';\n              };\n\n              enabledAddons = mkOption {\n                type = listOf str;\n                default = [ ];\n                description = ''\n                  Addons to enable on this mitmdump instance.\n                '';\n                example = lib.literalExpression \"[ config.shb.mitmdump.addons.logger ]\";\n              };\n\n              extraArgs = mkOption {\n                type = listOf str;\n                default = [ ];\n                description = ''\n                  Extra arguments to pass to the mitmdump instance.\n\n                  See upstream [manual](https://docs.mitmproxy.org/stable/concepts/options/#flow_detail) for all possible options.\n                '';\n                example = lib.literalExpression ''[ \"--set\" \"verbose_pattern=/api\" ]'';\n              };\n            };\n          }\n        )\n      );\n    };\n  };\n\n  config = {\n    systemd.services = mapAttrs' (\n      name: cfg':\n      nameValuePair \"mitmdump-${name}\" {\n        environment = {\n          \"HOME\" = \"/var/lib/private/mitmdump-${name}\";\n          \"MITMDUMP_BIN\" = \"${cfg'.package}/bin/mitmdump\";\n        };\n        serviceConfig = {\n          Type = \"notify\";\n          Restart = \"on-failure\";\n          StandardOutput = \"journal\";\n          StandardError = \"journal\";\n\n          DynamicUser = true;\n          WorkingDirectory = \"/var/lib/mitmdump-${name}\";\n          StateDirectory = \"mitmdump-${name}\";\n\n          ExecStart =\n            let\n              addons = lib.concatMapStringsSep \" \" (addon: \"-s ${addon}\") cfg'.enabledAddons;\n              extraArgs = lib.concatStringsSep \" \" cfg'.extraArgs;\n            in\n            \"${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}\";\n        };\n        requires = cfg'.after;\n        after = cfg'.after;\n        wantedBy = [ \"multi-user.target\" ];\n      }\n    ) cfg.instances;\n\n    shb.mitmdump.addons = {\n      inherit logger;\n    };\n  };\n}\n"
  },
  {
    "path": "modules/blocks/monitoring/dashboards/Errors.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 8,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"light-red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.99\n              }\n            ]\n          },\n          \"unit\": \"reqps\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 12,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"mean\",\n            \"max\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(server_name) (rate({unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | server_name =~ \\\"[[server_name]].*\\\" [$__auto]))\",\n          \"legendFormat\": \"{{server_name}}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Rate of Requests\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"indexByName\": {\n              \"body_bytes_sent\": 9,\n              \"bytes_sent\": 8,\n              \"gzip_ration\": 11,\n              \"post\": 12,\n              \"referrer\": 10,\n              \"remote_addr\": 3,\n              \"remote_user\": 6,\n              \"request\": 4,\n              \"request_length\": 7,\n              \"request_time\": 15,\n              \"server_name\": 2,\n              \"status\": 1,\n              \"time_local\": 0,\n              \"upstream_addr\": 13,\n              \"upstream_connect_time\": 17,\n              \"upstream_header_time\": 18,\n              \"upstream_response_time\": 16,\n              \"upstream_status\": 14,\n              \"user_agent\": 5\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": 0.5,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"dashed+area\"\n            }\n          },\n          \"mappings\": [],\n          \"max\": 1.01,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"light-red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.99\n              }\n            ]\n          },\n          \"unit\": \"percentunit\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 7\n      },\n      \"id\": 9,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\",\n            \"min\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"(sum by(server_name) (count_over_time({unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | 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>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | server_name =~ \\\"[[server_name]].*\\\" [7d])))\",\n          \"legendFormat\": \"{{server_name}}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"5XX Requests Error Budgets\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"indexByName\": {\n              \"body_bytes_sent\": 9,\n              \"bytes_sent\": 8,\n              \"gzip_ration\": 11,\n              \"post\": 12,\n              \"referrer\": 10,\n              \"remote_addr\": 3,\n              \"remote_user\": 6,\n              \"request\": 4,\n              \"request_length\": 7,\n              \"request_time\": 15,\n              \"server_name\": 2,\n              \"status\": 1,\n              \"time_local\": 0,\n              \"upstream_addr\": 13,\n              \"upstream_connect_time\": 17,\n              \"upstream_header_time\": 18,\n              \"upstream_response_time\": 16,\n              \"upstream_status\": 14,\n              \"user_agent\": 5\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"max\": 1.01,\n          \"min\": 0,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"light-red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.99\n              }\n            ]\n          },\n          \"unit\": \"percentunit\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 7\n      },\n      \"id\": 10,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\",\n            \"min\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"(sum by(server_name) (count_over_time({unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | 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>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | server_name =~ \\\"[[server_name]].*\\\" [7d])))\",\n          \"legendFormat\": \"{{server_name}}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"4XX Requests Error Budgets\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"indexByName\": {\n              \"body_bytes_sent\": 9,\n              \"bytes_sent\": 8,\n              \"gzip_ration\": 11,\n              \"post\": 12,\n              \"referrer\": 10,\n              \"remote_addr\": 3,\n              \"remote_user\": 6,\n              \"request\": 4,\n              \"request_length\": 7,\n              \"request_time\": 15,\n              \"server_name\": 2,\n              \"status\": 1,\n              \"time_local\": 0,\n              \"upstream_addr\": 13,\n              \"upstream_connect_time\": 17,\n              \"upstream_header_time\": 18,\n              \"upstream_response_time\": 16,\n              \"upstream_status\": 14,\n              \"user_agent\": 5\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 14\n      },\n      \"id\": 8,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableLogDetails\": true,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": true,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=~\\\"[[service]].*\\\"}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Log Errors\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"filterable\": false,\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"Time\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 167\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 14,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 22\n      },\n      \"id\": 7,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"enablePagination\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"10.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | status =~ \\\"5..\\\" | server_name =~ \\\"[[server_name]].*\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"5XX Requests Errors\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"keepTime\": false,\n            \"replace\": false,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"id\": true,\n              \"labels\": true,\n              \"time_local\": true,\n              \"tsNs\": true\n            },\n            \"indexByName\": {\n              \"Line\": 21,\n              \"Time\": 0,\n              \"body_bytes_sent\": 10,\n              \"bytes_sent\": 9,\n              \"gzip_ration\": 12,\n              \"id\": 23,\n              \"labels\": 20,\n              \"post\": 13,\n              \"referrer\": 11,\n              \"remote_addr\": 4,\n              \"remote_user\": 7,\n              \"request\": 5,\n              \"request_length\": 8,\n              \"request_time\": 16,\n              \"server_name\": 3,\n              \"status\": 2,\n              \"time_local\": 1,\n              \"tsNs\": 22,\n              \"upstream_addr\": 14,\n              \"upstream_connect_time\": 18,\n              \"upstream_header_time\": 19,\n              \"upstream_response_time\": 17,\n              \"upstream_status\": 15,\n              \"user_agent\": 6\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"filterable\": false,\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\"\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 14,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 36\n      },\n      \"id\": 11,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"enablePagination\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true\n      },\n      \"pluginVersion\": \"10.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | status =~ \\\"4..\\\" | server_name =~ \\\"[[server_name]].*\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"4XX Requests Errors\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"indexByName\": {\n              \"body_bytes_sent\": 9,\n              \"bytes_sent\": 8,\n              \"gzip_ration\": 11,\n              \"post\": 12,\n              \"referrer\": 10,\n              \"remote_addr\": 3,\n              \"remote_user\": 6,\n              \"request\": 4,\n              \"request_length\": 7,\n              \"request_time\": 15,\n              \"server_name\": 2,\n              \"status\": 1,\n              \"time_local\": 0,\n              \"upstream_addr\": 13,\n              \"upstream_connect_time\": 17,\n              \"upstream_header_time\": 18,\n              \"upstream_response_time\": 16,\n              \"upstream_status\": 14,\n              \"user_agent\": 5\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"\",\n  \"schemaVersion\": 40,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"allValue\": \".+\",\n        \"current\": {\n          \"text\": \"All\",\n          \"value\": \"$__all\"\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"query_result(max by (name) (node_systemd_unit_state))\",\n        \"includeAll\": true,\n        \"multi\": true,\n        \"name\": \"service\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 3,\n          \"query\": \"query_result(max by (name) (node_systemd_unit_state))\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"/name=\\\"(?<value>.*)\\\\.service\\\"/\",\n        \"sort\": 1,\n        \"type\": \"query\"\n      },\n      {\n        \"current\": {\n          \"text\": \".+\",\n          \"value\": \".+\"\n        },\n        \"name\": \"server_name\",\n        \"options\": [\n          {\n            \"selected\": true,\n            \"text\": \".+\",\n            \"value\": \".+\"\n          }\n        ],\n        \"query\": \".+\",\n        \"type\": \"textbox\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-6h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Errors\",\n  \"uid\": \"d66242cf-71e8-417c-8ef7-51b0741545df\",\n  \"version\": 32,\n  \"weekStart\": \"\"\n}\n"
  },
  {
    "path": "modules/blocks/monitoring/dashboards/Health.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 4,\n      \"panels\": [],\n      \"repeat\": \"hostname\",\n      \"title\": \"${hostname}\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"footer\": {\n              \"reducers\": []\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 4,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 3,\n      \"maxDataPoints\": 400,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"showHeader\": true\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"disableTextWrap\": false,\n          \"editorMode\": \"builder\",\n          \"expr\": \"node_os_info\",\n          \"fullMetaSearch\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"A\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"OS Versions\",\n      \"transformations\": [\n        {\n          \"id\": \"labelsToFields\",\n          \"options\": {\n            \"keepLabels\": [\n              \"build_id\",\n              \"domain\",\n              \"hostname\",\n              \"id\",\n              \"instance\",\n              \"job\",\n              \"name\",\n              \"pretty_name\",\n              \"version\",\n              \"version_codename\",\n              \"version_id\"\n            ],\n            \"mode\": \"columns\"\n          }\n        },\n        {\n          \"id\": \"groupBy\",\n          \"options\": {\n            \"fields\": {\n              \"Time\": {\n                \"aggregations\": [\n                  \"firstNotNull\"\n                ],\n                \"operation\": \"aggregate\"\n              },\n              \"pretty_name\": {\n                \"aggregations\": [],\n                \"operation\": \"groupby\"\n              }\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 1\n      },\n      \"id\": 1,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\",\n            \"max\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"node_hwmon_temp_celsius{hostname=~\\\"$hostname\\\"}\",\n          \"instant\": false,\n          \"legendFormat\": \"{{chip}} - {{sensor}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Temperature\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"axisPlacement\": \"auto\",\n            \"fillOpacity\": 70,\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"lineWidth\": 0\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 4,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 9\n      },\n      \"id\": 5,\n      \"interval\": \"1m\",\n      \"maxDataPoints\": 200,\n      \"options\": {\n        \"colWidth\": 1,\n        \"legend\": {\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"rowHeight\": 0.8,\n        \"showValue\": \"never\",\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"none\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"repeat\": \"hostname\",\n      \"repeatDirection\": \"h\",\n      \"targets\": [\n        {\n          \"editorMode\": \"code\",\n          \"expr\": \"node_zfs_zpool_state{hostname=~\\\"$hostname\\\", state=\\\"online\\\"} > 0\",\n          \"legendFormat\": \"{{zpool}} - {{state}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"ZFS Pools\",\n      \"type\": \"status-history\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"line+area\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 604808\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 13\n      },\n      \"id\": 2,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"ssl_certificate_expiry_seconds\",\n          \"legendFormat\": \"{{exported_hostname}}: {{subject}} {{path}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Certificate Remaining Validity\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"years\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 13\n      },\n      \"id\": 7,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"builder\",\n          \"expr\": \"scrutiny_smart_power_on_hours{hostname=~\\\"$hostname\\\"} / (24 * 365)\",\n          \"legendFormat\": \"{{device_name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Operating Years\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"continuous-YlRd\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMax\": 100,\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineStyle\": {\n              \"fill\": \"solid\"\n            },\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"percent\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 21\n      },\n      \"id\": 6,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\",\n            \"max\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true,\n          \"width\": 400\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"disableTextWrap\": false,\n          \"editorMode\": \"builder\",\n          \"expr\": \"sum by(hostname, domain, mountpoint, device) (node_filesystem_free_bytes{hostname=~\\\"$hostname\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": true,\n          \"includeNullMetadata\": false,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"A\",\n          \"useBackend\": false\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"builder\",\n          \"expr\": \"sum by(hostname, domain, mountpoint, device) (node_filesystem_size_bytes{hostname=~\\\"$hostname\\\"})\",\n          \"hide\": true,\n          \"instant\": false,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"name\": \"Expression\",\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"(1 - $A / $B) * 100\",\n          \"refId\": \"Disk Full\",\n          \"type\": \"math\"\n        }\n      ],\n      \"title\": \"Filesystem Disk Usage\",\n      \"transformations\": [\n        {\n          \"id\": \"joinByField\",\n          \"options\": {\n            \"byField\": \"Time\",\n            \"mode\": \"outer\"\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 21\n      },\n      \"id\": 9,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"builder\",\n          \"expr\": \"scrutiny_smart_power_on_hours{hostname=~\\\"$hostname\\\"}\",\n          \"legendFormat\": \"{{device_name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Operating Years\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"footer\": {\n              \"reducers\": []\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 29\n      },\n      \"id\": 8,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"showHeader\": true\n      },\n      \"pluginVersion\": \"12.4.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"builder\",\n          \"exemplar\": false,\n          \"expr\": \"scrutiny_device_info{hostname=~\\\"$hostname\\\"}\",\n          \"format\": \"table\",\n          \"instant\": true,\n          \"legendFormat\": \"{{device_name}}\",\n          \"range\": false,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Disk Info\",\n      \"transformations\": [\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Time\": true,\n              \"Value\": true,\n              \"__name__\": true,\n              \"domain\": true,\n              \"hostname\": true,\n              \"instance\": true,\n              \"job\": true,\n              \"wwn\": false\n            },\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    }\n  ],\n  \"preload\": false,\n  \"schemaVersion\": 42,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": \"baryum\",\n          \"value\": \"baryum\"\n        },\n        \"definition\": \"label_values(up,hostname)\",\n        \"name\": \"hostname\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(up,hostname)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"regexApplyTo\": \"value\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-30m\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"browser\",\n  \"title\": \"Node Health\",\n  \"uid\": \"edhuvl28vpjwge\",\n  \"version\": 25,\n  \"weekStart\": \"\"\n}\n"
  },
  {
    "path": "modules/blocks/monitoring/dashboards/Performance.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 6,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 12,\n      \"panels\": [],\n      \"title\": \"Node\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": 3600000,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"mbytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"decimals\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"dark-red\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    10,\n                    10\n                  ],\n                  \"fill\": \"dash\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"green\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 10\n              },\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"auto\"\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 20,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"full stall time\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum(node_memory_MemTotal_bytes{instance=\\\"127.0.0.1:9112\\\"}) / 1000000 - sum(netdata_mem_available_MiB_average{instance=~\\\"$instance\\\"})\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"remaining\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{instance=~\\\"$instance\\\", service_name=~\\\"$service\\\", dimension=\\\"ram\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"Memory\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": 3600000,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"mbytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"decimals\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"dark-red\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    10,\n                    10\n                  ],\n                  \"fill\": \"dash\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"green\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 10\n              },\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"auto\"\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 1\n      },\n      \"id\": 23,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"full stall time\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum(node_memory_SwapFree_bytes{instance=~\\\"127.0.0.1:9112\\\"}) / 1000000\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"remaining\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{hostname=~\\\"$hostname\\\", service_name=~\\\"$service\\\", dimension=\\\"swap\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"Swap\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": 3600000,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"percent\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"orange\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 34\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 9\n      },\n      \"id\": 22,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"sum\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_cpu_some_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"some stall time\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_cpu_utilization_percentage_average{hostname=~\\\"$hostname\\\", service_name=~\\\"$service\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"CPU\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": 3600000,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"Kibits\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 12\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"orange\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 17\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 9\n      },\n      \"id\": 21,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"sum\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_io_full_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"full stall time\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_io_some_pressure_stall_time_ms_average{instance=~\\\"$instance\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"some stall time\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{instance=~\\\"$instance\\\", service_name=~\\\"$service\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"Disk I/O\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"Kibits\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 17\n      },\n      \"id\": 18,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"sum\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_disk_io_KiB_persec_average{hostname=~\\\"$hostname\\\", chart=~\\\"disk.sd.+\\\"}\",\n          \"instant\": false,\n          \"legendFormat\": \"{{device}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Disk I/O\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 25\n      },\n      \"id\": 4,\n      \"panels\": [],\n      \"title\": \"Network Requests\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"custom\": {\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            }\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 26\n      },\n      \"id\": 17,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"calculate\": false,\n        \"cellGap\": 1,\n        \"color\": {\n          \"exponent\": 0.5,\n          \"fill\": \"dark-orange\",\n          \"mode\": \"scheme\",\n          \"reverse\": false,\n          \"scale\": \"exponential\",\n          \"scheme\": \"RdBu\",\n          \"steps\": 62\n        },\n        \"exemplars\": {\n          \"color\": \"rgba(255,0,255,0.7)\"\n        },\n        \"filterValues\": {\n          \"le\": 1e-9\n        },\n        \"legend\": {\n          \"show\": true\n        },\n        \"rowsFrame\": {\n          \"layout\": \"auto\"\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"showColorScale\": false,\n          \"yHistogram\": false\n        },\n        \"yAxis\": {\n          \"axisPlacement\": \"left\",\n          \"decimals\": 0,\n          \"reverse\": false,\n          \"unit\": \"s\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | request_time > 100\",\n          \"legendFormat\": \"{{server_name}}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Slow Requests Histogram > 100ms\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"keepTime\": false,\n            \"replace\": false,\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"body_bytes_sent\": true,\n              \"bytes_sent\": true,\n              \"gzip_ration\": true,\n              \"id\": true,\n              \"labels\": true,\n              \"post\": true,\n              \"referrer\": true,\n              \"remote_addr\": true,\n              \"remote_user\": true,\n              \"request\": true,\n              \"request_length\": true,\n              \"server_name\": true,\n              \"status\": true,\n              \"time_local\": true,\n              \"tsNs\": true,\n              \"upstream_addr\": true,\n              \"upstream_connect_time\": true,\n              \"upstream_header_time\": true,\n              \"upstream_response_time\": true,\n              \"upstream_status\": true,\n              \"user_agent\": true\n            },\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        },\n        {\n          \"id\": \"convertFieldType\",\n          \"options\": {\n            \"conversions\": [\n              {\n                \"destinationType\": \"number\",\n                \"targetField\": \"request_time\"\n              }\n            ],\n            \"fields\": {}\n          }\n        },\n        {\n          \"id\": \"heatmap\",\n          \"options\": {\n            \"xBuckets\": {\n              \"mode\": \"size\",\n              \"value\": \"\"\n            },\n            \"yBuckets\": {\n              \"mode\": \"size\",\n              \"scale\": {\n                \"log\": 2,\n                \"type\": \"log\"\n              },\n              \"value\": \"\"\n            }\n          }\n        }\n      ],\n      \"type\": \"heatmap\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"points\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"ms\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 26\n      },\n      \"id\": 2,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\",\n            \"variance\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | request_time > 100\",\n          \"legendFormat\": \"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Requests > 100ms\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"keepTime\": true,\n            \"replace\": true,\n            \"source\": \"labels\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"body_bytes_sent\": true,\n              \"bytes_sent\": true,\n              \"gzip_ration\": true,\n              \"job\": true,\n              \"line\": true,\n              \"post\": true,\n              \"referrer\": true,\n              \"remote_addr\": true,\n              \"remote_user\": true,\n              \"request\": true,\n              \"request_length\": true,\n              \"status\": true,\n              \"time_local\": true,\n              \"unit\": true,\n              \"upstream_addr\": true,\n              \"upstream_connect_time\": true,\n              \"upstream_header_time\": true,\n              \"upstream_response_time\": true,\n              \"upstream_status\": true,\n              \"user_agent\": true\n            },\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        },\n        {\n          \"id\": \"convertFieldType\",\n          \"options\": {\n            \"conversions\": [\n              {\n                \"dateFormat\": \"\",\n                \"destinationType\": \"number\",\n                \"targetField\": \"request_time\"\n              }\n            ],\n            \"fields\": {}\n          }\n        },\n        {\n          \"id\": \"partitionByValues\",\n          \"options\": {\n            \"fields\": [\n              \"server_name\"\n            ]\n          }\n        },\n        {\n          \"id\": \"renameByRegex\",\n          \"options\": {\n            \"regex\": \"request_time (.*)\",\n            \"renamePattern\": \"$1\"\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"filterable\": false,\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 34\n      },\n      \"id\": 3,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"enablePagination\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | request_time > 1\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Network Requests Above 1s\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"Line\"\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 42\n      },\n      \"id\": 7,\n      \"panels\": [],\n      \"title\": \"Databases\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"duration_ms\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 100\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"unit\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 150\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 43\n      },\n      \"id\": 6,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"postgresql.service\\\"} | regexp \\\".*duration: (?P<duration_ms>[0-9.]+) ms (?P<statement>.*)\\\" | duration_ms > 500 | __error__ != \\\"LabelFilterErr\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Slow DB Queries\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"replace\": true,\n            \"source\": \"labels\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"job\": true\n            },\n            \"indexByName\": {\n              \"duration_ms\": 0,\n              \"job\": 1,\n              \"statement\": 3,\n              \"unit\": 2\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"1m\",\n  \"schemaVersion\": 40,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"allValue\": \".*\",\n        \"current\": {\n          \"text\": [\n            \"baryum\"\n          ],\n          \"value\": [\n            \"baryum\"\n          ]\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"label_values(netdata_systemd_service_unit_state_state_average,hostname)\",\n        \"includeAll\": false,\n        \"multi\": true,\n        \"name\": \"hostname\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(netdata_systemd_service_unit_state_state_average,hostname)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      },\n      {\n        \"allValue\": \".*\",\n        \"current\": {\n          \"text\": [\n            \"All\"\n          ],\n          \"value\": [\n            \"$__all\"\n          ]\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"label_values(netdata_systemd_service_unit_state_state_average,unit_name)\",\n        \"includeAll\": true,\n        \"multi\": true,\n        \"name\": \"service\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(netdata_systemd_service_unit_state_state_average,unit_name)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-30m\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Performance\",\n  \"uid\": \"e01156bf-cdba-42eb-9845-a401dd634d41\",\n  \"version\": 82,\n  \"weekStart\": \"\"\n}\n"
  },
  {
    "path": "modules/blocks/monitoring/dashboards/Scraping_Jobs.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 5,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 5,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableInfiniteScrolling\": false,\n        \"enableLogDetails\": true,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"direction\": \"backward\",\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=~\\\"prometheus-.*-exporter.service\\\"}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Exporter Logs\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"fixedColor\": \"red\",\n            \"mode\": \"shades\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 4,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(net_conntrack_dialer_conn_failed_total{hostname=~\\\"$hostname\\\"}[2m]) > 0\",\n          \"instant\": false,\n          \"legendFormat\": \"{{dialer_name}} - {{reason}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Errors\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"fixedColor\": \"red\",\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"axisPlacement\": \"auto\",\n            \"fillOpacity\": 70,\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineWidth\": 0,\n            \"spanNulls\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 1\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 8\n      },\n      \"id\": 3,\n      \"options\": {\n        \"alignValue\": \"center\",\n        \"legend\": {\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"mergeValues\": true,\n        \"rowHeight\": 0.9,\n        \"showValue\": \"never\",\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"prometheus_sd_discovered_targets{hostname=~\\\"$hostname\\\"}\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{config}}\",\n          \"range\": true,\n          \"refId\": \"All\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"label_replace(increase((sum by(dialer_name) (net_conntrack_dialer_conn_failed_total{hostname=~\\\"$hostname\\\"}))[15m:1m]), \\\"config\\\", \\\"$1\\\", \\\"dialer_name\\\", \\\"(.*)\\\")\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{dialer_name}}\",\n          \"range\": true,\n          \"refId\": \"Failed\"\n        }\n      ],\n      \"title\": \"Scraping jobs\",\n      \"transformations\": [\n        {\n          \"id\": \"labelsToFields\",\n          \"options\": {\n            \"keepLabels\": [\n              \"config\"\n            ],\n            \"mode\": \"columns\"\n          }\n        },\n        {\n          \"id\": \"merge\",\n          \"options\": {}\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"prometheus_sd_discovered_targets\": true\n            },\n            \"indexByName\": {},\n            \"renameByName\": {\n              \"prometheus_sd_discovered_targets\": \"\"\n            }\n          }\n        },\n        {\n          \"id\": \"partitionByValues\",\n          \"options\": {\n            \"fields\": [\n              \"config\"\n            ]\n          }\n        }\n      ],\n      \"type\": \"state-timeline\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"\",\n  \"schemaVersion\": 42,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": \"baryum\",\n          \"value\": \"baryum\"\n        },\n        \"definition\": \"label_values(up,hostname)\",\n        \"includeAll\": false,\n        \"name\": \"hostname\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(up,hostname)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-6h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Scraping Jobs\",\n  \"uid\": \"debb763d-77aa-47bd-9290-2e02583c8ed2\",\n  \"version\": 24\n}\n"
  },
  {
    "path": "modules/blocks/monitoring/docs/default.md",
    "content": "# Monitoring Block {#blocks-monitoring}\n\nDefined in [`/modules/blocks/monitoring.nix`](@REPO@/modules/blocks/monitoring.nix).\n\nThis block sets up the monitoring stack for Self Host Blocks. It is composed of:\n\n- Grafana as the dashboard frontend.\n- Prometheus as the database for metrics.\n- Loki as the database for logs.\n\n## Features {#services-monitoring-features}\n\n- Declarative [LDAP](#blocks-monitoring-options-shb.monitoring.ldap) Configuration.\n  - Needed LDAP groups are created automatically.\n- Declarative [SSO](#blocks-monitoring-options-shb.monitoring.sso) Configuration.\n  - When SSO is enabled, login with user and password is disabled.\n  - Registration is enabled through SSO.\n- Access through [subdomain](#blocks-monitoring-options-shb.monitoring.subdomain) using reverse proxy.\n- Access through [HTTPS](#blocks-monitoring-options-shb.monitoring.ssl) using reverse proxy.\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-monitoring-usage-applicationdashboard)\n- Out of the box integration with [Scrutiny](https://github.com/AnalogJ/scrutiny) service for Hard Drives monitoring. [Manual](#blocks-monitoring-usage-scrutiny)\n\n## Usage {#blocks-monitoring-usage}\n\n### Initial Configuration {#blocks-monitoring-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\n{\n  shb.monitoring = {\n    enable = true;\n    subdomain = \"grafana\";\n    inherit domain;\n    contactPoints = [ \"me@example.com\" ];\n    adminPassword.result = config.shb.sops.secret.\"monitoring/admin_password\".result;\n    secretKey.result = config.shb.sops.secret.\"monitoring/secret_key\".result;\n\n    sso = {\n      enable = true;\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n      sharedSecret.result = config.shb.sops.secret.\"monitoring/oidcSecret\".result;\n      sharedSecretForAuthelia.result = config.shb.sops.secret.\"monitoring/oidcAutheliaSecret\".result;\n    };\n  };\n  \n  shb.sops.secret.\"monitoring/admin_password\".request = config.shb.monitoring.adminPassword.request;\n  shb.sops.secret.\"monitoring/secret_key\".request = config.shb.monitoring.secretKey.request;\n  shb.sops.secret.\"monitoring/oidcSecret\".request = config.shb.monitoring.sso.sharedSecret.request;\n  shb.sops.secret.\"monitoring/oidcAutheliaSecret\" = {\n    request = config.shb.monitoring.sso.sharedSecretForAuthelia.request;\n    settings.key = \"monitoring/oidcSecret\";\n  };\n};\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nWith that, Grafana, Prometheus, Loki and Promtail are setup! You can access `Grafana` at\n`grafana.example.com` with user `admin` and the password from the sops key `monitoring/admin_password`.\n\nThe [user](#blocks-monitoring-options-shb.monitoring.ldap.userGroup)\nand [admin](#blocks-monitoring-options-shb.monitoring.ldap.adminGroup)\nLDAP groups are created automatically.\n\n### SMTP {#blocks-monitoring-usage-smtp}\n\nI recommend adding an SMTP server configuration so you receive alerts by email:\n\n```nix\nshb.monitoring.smtp = {\n  from_address = \"grafana@$example.com\";\n  from_name = \"Grafana\";\n  host = \"smtp.mailgun.org\";\n  port = 587;\n  username = \"postmaster@mg.example.com\";\n  passwordFile = config.sops.secrets.\"monitoring/smtp\".path;\n};\n\nsops.secrets.\"monitoring/secret_key\" = {\n  sopsFile = ./secrets.yaml;\n  mode = \"0400\";\n  owner = \"grafana\";\n  group = \"grafana\";\n  restartUnits = [ \"grafana.service\" ];\n};\n```\n\n### Log Optimization {#blocks-monitoring-usage-log-optimization}\n\nSince all logs are now stored in Loki, you can probably reduce the systemd journal retention\ntime with:\n\n```nix\n# See https://www.freedesktop.org/software/systemd/man/journald.conf.html#SystemMaxUse=\nservices.journald.extraConfig = ''\nSystemMaxUse=2G\nSystemKeepFree=4G\nSystemMaxFileSize=100M\nMaxFileSec=day\n'';\n```\n\nOther options are accessible through the upstream services modules.\nYou might for example want to update the metrics retention time with:\n\n```nix\nservices.prometheus.retentionTime = \"60d\";\n```\n\n### Application Dashboard {#blocks-monitoring-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#blocks-monitoring-options-shb.monitoring.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Admin.services.Grafana = {\n    sortOrder = 10;\n    dashboard.request = config.shb.monitoring.dashboard.request;\n  };\n}\n```\n\nThere is also an integration for the scrutiny service, see next section.\n\n### Scrutiny {#blocks-monitoring-usage-scrutiny}\n\nIntegration with the [Scrutiny](https://github.com/AnalogJ/scrutiny) service is enabled by default and setup automatically.\n\nThe web interface will be served under the [scrutiny.subdomain](#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain) option.\nIf you don't want the web interface, set the option to `null`.\n\nFor integration with the [dashboard contract](contracts-dashboard.html):\n\n```nix\n{\n  shb.homepage.servicesGroups.Admin.services.Scrutiny = {\n    sortOrder = 11;\n    dashboard.request = config.shb.monitoring.scrutiny.dashboard.request;\n  };\n}\n```\n\n## Provisioning {#blocks-monitoring-provisioning}\n\nSelf Host Blocks will create automatically the following resources:\n\n- For Grafana:\n  - datasources\n  - dashboards\n  - contact points\n  - notification policies\n  - alerts\n- For Prometheus, the following exporters and related scrapers:\n  - node\n  - smartctl\n  - nginx\n- For Loki, the following exporters and related scrapers:\n  - systemd\n\nThose resources are namespaced as appropriate under the Self Host Blocks namespace:\n\n![](./assets/folder.png)\n\n## Errors Dashboard {#blocks-monitoring-error-dashboard}\n\nThis dashboard is meant to be the first stop to understand why a service is misbehaving.\n\n![](./assets/dashboards_Errors_1.png)\n![](./assets/dashboards_Errors_2.png)\n\nThe yellow and red dashed vertical bars correspond to the\n[Requests Error Budget Alert](#blocks-monitoring-budget-alerts) firing.\n\n## Performance Dashboard {#blocks-monitoring-performance-dashboard}\n\nThis dashboard is meant to be the first stop to understand why a service is performing poorly.\n\n![Performance Dashboard Top Part](./assets/dashboards_Performance_1.png)\n![Performance Dashboard Middle Part](./assets/dashboards_Performance_2.png)\n![Performance Dashboard Bottom Part](./assets/dashboards_Performance_3.png)\n\n## Nextcloud Dashboard {#blocks-monitoring-nextcloud-dashboard}\n\nSee [Nextcloud service](./services-nextcloud.html#services-nextcloudserver-dashboard) manual.\n\n## Deluge Dashboard {#blocks-monitoring-deluge-dashboard}\n\nThis dashboard is used to monitor a [deluge](./services-deluge.html) instance.\n\n![Deluge Dashboard Top Part](./assets/dashboards_Deluge_1.png)\n![Deluge Dashboard Bottom Part](./assets/dashboards_Deluge_2.png)\n\n## Backups Dashboard and Alert {#blocks-monitoring-backup}\n\nThis dashboard shows Restic and BorgBackup backup jobs, or any job with \"backup\" in the systemd service name.\n\n### Dashboard {#blocks-monitoring-backup-dashboard}\n\nVariables:\n\n- The \"Job\" variable allows to select one or more backup jobs. \"All\" is the default.\n- The \"mountpoints\" variable allows to select only relevant mountpoints for backup. \"All\" is the default.\n\nThe most important graphs are the first three:\n\n- \"Backup Jobs in the Past Week\": Shows stats on all backup jobs that ran in the past.\n  It is sorted by the \"Failed\" column in descending order.\n  This way, one can directly see when a job has failures.\n- \"Schedule\": Shows when a job will run.\n  The unit is \"Datetime from Now\" meaning it shows when a job ran or will run relative to the current time.\n  An annotation will show up when the \"Late Backups\" alert fired or resolved.\n- \"Backup jobs\": Shows when a backup job ran.\n  Normally, jobs running for less than 15 seconds will not show up in the graph.\n  We crafted a query that still shows them but the length is 15 seconds, even if the backup job\n  took less time to run.\n\n![Backups Dashboard Top Part](./assets/dashboards_Backups_1.png)\n![Backups Dashboard Middle Part](./assets/dashboards_Backups_2.png)\n![Backups Dashboard Bottom Part](./assets/dashboards_Backups_3.png)\n\n### Alerts {#blocks-monitoring-backup-alerts}\n\n- The \"Late Backups\" alert will fire if a backup job did not run at all in the last 24 hours\n  or if all runs were failures in the last 24 hours.\n  It will show up as annotations in the \"Schedule\" panel of the dashboard.\n\n![Late Backups Alert Firing](./assets/alert_rules_LateBackups_1.png)\n![Backups Alert Showing Up In Dashboard](./assets/dashboards_Backups_alert.png)\n\n## Requests Error Budget Alert {#blocks-monitoring-budget-alerts}\n\nThis alert will fire when the ratio between number of requests getting a 5XX response from a service\nand the total requests to that service exceeds 1%.\n\n![Error Dashboard Top Part](./assets/alert_rules_5xx_1.png)\n![Error Dashboard Bottom Part](./assets/alert_rules_5xx_2.png)\n\n## SSL Certificates Dashboard and Alert {#blocks-monitoring-ssl}\n\nThis dashboard shows Let's Encrypt renewal and setup jobs,\nor any job starting with \"acme-\" in the systemd service name.\n\n### Dashboard {#blocks-monitoring-ssl-dashboard}\n\nVariables:\n\n- The \"Job\" variable allows to focus on one or more certificate. \"All\" is the default.\n\nGraphs:\n\n- \"Certificate Remaining Validity\": Shows in how long will certificates expire.\n  It shows all files under `/var/lib/acme`.\n  An annotation will show up when the \"Certificate Did Not Renew\" alert fired or resolved.\n- \"Schedule\": Shows when a job will run.\n  The unit is \"Datetime from Now\" meaning it shows when a job ran or will run relative to the current time.\n- \"Jobs in the Past Week\": Shows stats on all renewal jobs that ran in the past.\n  It is sorted by the \"Failed\" column in descending order.\n  This way, one can directly see when a job has failures.\n  Note, the stats is not accurate because detecting jobs taking taking less than 15 seconds\n  is not supported well.\n- \"Job Runs\": Shows when a renewal job ran.\n  Normally, jobs running for less than 15 seconds will not show up in the graph.\n  We crafted a query that still shows them but the length is 100 seconds, even if the job\n  took less time to run.\n\n![SSL Dashboard No Filter](./assets/dashboards_SSL_all.png)\n![SSL Dashboard Filter Failing](./assets/dashboards_SSL_fail.png)\n\n### Alerts {#blocks-monitoring-ssl-alerts}\n\n- The \"Certificate Did Not Renew\" alert will fire if a backup job did not run at all in the last 24 hours\n    or if all runs were failures in the last 24 hours.\n  It will show up as annotations in the \"Schedule\" panel of the dashboard.\n\n![Late SSL Jobs Alert Firing](./assets/alert_rules_LateSSL_1.png)\n\n## Options Reference {#blocks-monitoring-options}\n\n```{=include=} options\nid-prefix: blocks-monitoring-options-\nlist-id: selfhostblocks-blocks-monitoring-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/monitoring/rules.json",
    "content": "[\n  {\n    \"uid\": \"f5246fa3-163f-4eae-9e1d-5b0fe2af0509\",\n    \"title\": \"5XX Requests Error Budgets Under 99%\",\n    \"condition\": \"threshold\",\n    \"data\": [\n      {\n        \"refId\": \"A\",\n        \"queryType\": \"range\",\n        \"relativeTimeRange\": {\n          \"from\": 21600,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"cd6cc53e-840c-484d-85f7-96fede324006\",\n        \"model\": {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"(sum by(server_name) (count_over_time({unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | status =~ \\\"[1234]..\\\" | server_name =~ \\\".*\\\" [1h])) / sum by(server_name) (count_over_time({unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | server_name =~ \\\".*\\\" [1h])))\",\n          \"intervalMs\": 1000,\n          \"legendFormat\": \"{{server_name}}\",\n          \"maxDataPoints\": 43200,\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      },\n      {\n        \"refId\": \"last\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [],\n                \"type\": \"gt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"B\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"A\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"reducer\": \"last\",\n          \"refId\": \"last\",\n          \"type\": \"reduce\"\n        }\n      },\n      {\n        \"refId\": \"threshold\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [\n                  0.99\n                ],\n                \"type\": \"lt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"C\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"last\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"refId\": \"threshold\",\n          \"type\": \"threshold\"\n        }\n      }\n    ],\n    \"dasboardUid\": \"d66242cf-71e8-417c-8ef7-51b0741545df\",\n    \"panelId\": 9,\n    \"noDataState\": \"OK\",\n    \"execErrState\": \"Error\",\n    \"for\": \"20m\",\n    \"annotations\": {\n      \"__dashboardUid__\": \"d66242cf-71e8-417c-8ef7-51b0741545df\",\n      \"__panelId__\": \"9\",\n      \"description\": \"\",\n      \"runbook_url\": \"\",\n      \"summary\": \"The error budget for a service for the last 1 hour is under 99%\"\n    },\n    \"labels\": {\n      \"role\": \"sysadmin\"\n    },\n    \"isPaused\": false\n  },\n  {\n    \"uid\": \"ee817l3a88s1sd\",\n    \"title\": \"Certificate Did Not Renew\",\n    \"condition\": \"C\",\n    \"data\": [\n      {\n        \"refId\": \"A\",\n        \"relativeTimeRange\": {\n          \"from\": 1800,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\",\n        \"model\": {\n          \"adhocFilters\": [],\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"min by(subject) (ssl_certificate_expiry_seconds)\",\n          \"interval\": \"\",\n          \"intervalMs\": 15000,\n          \"legendFormat\": \"{{exported_hostname}}: {{subject}} {{path}}\",\n          \"maxDataPoints\": 43200,\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      },\n      {\n        \"refId\": \"B\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [],\n                \"type\": \"gt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"B\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"A\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"reducer\": \"last\",\n          \"refId\": \"B\",\n          \"type\": \"reduce\"\n        }\n      },\n      {\n        \"refId\": \"C\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [\n                  604800\n                ],\n                \"type\": \"lt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"C\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"B\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"refId\": \"C\",\n          \"type\": \"threshold\"\n        }\n      }\n    ],\n    \"dashboardUid\": \"ae818js0bvw8wb\",\n    \"panelId\": 3,\n    \"noDataState\": \"NoData\",\n    \"execErrState\": \"Error\",\n    \"for\": \"20m\",\n    \"annotations\": {\n      \"__dashboardUid__\": \"ae818js0bvw8wb\",\n      \"__panelId__\": \"3\",\n      \"description\": \"The expiry date of the certificate is 1 week from now.\",\n      \"summary\": \"Certificate did not renew on time.\"\n    },\n    \"labels\": {\n      \"role\": \"sysadmin\"\n    },\n    \"isPaused\": false\n  },\n  {\n    \"uid\": \"df4doj5pomhvkf\",\n    \"title\": \"Late Backups\",\n    \"condition\": \"C\",\n    \"data\": [\n      {\n        \"refId\": \"A\",\n        \"relativeTimeRange\": {\n          \"from\": 10800,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\",\n        \"model\": {\n          \"adhocFilters\": [],\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"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)\",\n          \"instant\": false,\n          \"interval\": \"\",\n          \"intervalMs\": 15000,\n          \"legendFormat\": \"{{name}}\",\n          \"maxDataPoints\": 43200,\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      },\n      {\n        \"refId\": \"B\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [],\n                \"type\": \"gt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"B\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"A\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"reducer\": \"last\",\n          \"refId\": \"B\",\n          \"type\": \"reduce\"\n        }\n      },\n      {\n        \"refId\": \"C\",\n        \"relativeTimeRange\": {\n          \"from\": 0,\n          \"to\": 0\n        },\n        \"datasourceUid\": \"__expr__\",\n        \"model\": {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [\n                  0\n                ],\n                \"type\": \"gt\"\n              },\n              \"operator\": {\n                \"type\": \"and\"\n              },\n              \"query\": {\n                \"params\": [\n                  \"C\"\n                ]\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"B\",\n          \"intervalMs\": 1000,\n          \"maxDataPoints\": 43200,\n          \"refId\": \"C\",\n          \"type\": \"threshold\"\n        }\n      }\n    ],\n    \"dashboardUid\": \"f05500d0-15ed-4719-b68d-fb898ca13cc8\",\n    \"panelId\": 15,\n    \"noDataState\": \"OK\",\n    \"execErrState\": \"Error\",\n    \"annotations\": {\n      \"__dashboardUid__\": \"f05500d0-15ed-4719-b68d-fb898ca13cc8\",\n      \"__panelId__\": \"15\",\n      \"summary\": \"A backup did not run in the last 24 hours.\"\n    },\n    \"labels\": {\n      \"role\": \"sysadmin\"\n    },\n    \"isPaused\": false\n  }\n]\n"
  },
  {
    "path": "modules/blocks/monitoring.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.monitoring;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  commonLabels = {\n    hostname = config.networking.hostName;\n    domain = cfg.domain;\n  };\n\n  roleClaim = \"grafana_groups\";\n  oauthScopes = [\n    \"openid\"\n    \"email\"\n    \"profile\"\n    \"groups\"\n    \"${roleClaim}\"\n  ];\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/authelia.nix\n    ../blocks/lldap.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.monitoring = {\n    enable = lib.mkEnableOption \"selfhostblocks.monitoring\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Grafana will be served.\";\n      example = \"grafana\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Grafana will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    grafanaPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port where Grafana listens to HTTP requests.\";\n      default = 3000;\n    };\n\n    prometheusPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port where Prometheus listens to HTTP requests.\";\n      default = 3001;\n    };\n\n    lokiPort = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port where Loki listens to HTTP requests.\";\n      default = 3002;\n    };\n\n    lokiMajorVersion = lib.mkOption {\n      type = lib.types.enum [\n        2\n        3\n      ];\n      description = ''\n        Switching from version 2 to 3 requires manual intervention\n        https://grafana.com/docs/loki/latest/setup/upgrade/#main--unreleased. So this let's the user\n        upgrade at their own pace.\n      '';\n      default = 2;\n    };\n\n    debugLog = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Set to true to enable debug logging of the infrastructure serving Grafana.\";\n      default = false;\n      example = true;\n    };\n\n    orgId = lib.mkOption {\n      type = lib.types.int;\n      description = \"Org ID where all self host blocks related config will be stored.\";\n      default = 1;\n    };\n\n    dashboards = lib.mkOption {\n      type = lib.types.listOf lib.types.path;\n      description = \"Dashboards to provision under 'Self Host Blocks' folder.\";\n      default = [ ];\n    };\n\n    contactPoints = lib.mkOption {\n      type = lib.types.listOf lib.types.str;\n      description = \"List of email addresses to send alerts to\";\n      default = [ ];\n    };\n\n    scrutiny = {\n      enable = lib.mkEnableOption \"scrutiny service\" // {\n        default = true;\n      };\n      subdomain = lib.mkOption {\n        type = lib.types.nullOr lib.types.str;\n        description = ''\n          If a string, this will be the subdomain under which the scrutiny web interface will be servced.\n\n          If null, the web interface will not be served and only the prometheus metrics will be accessible.\n        '';\n        default = \"scrutiny\";\n      };\n      dashboard = lib.mkOption {\n        description = ''\n          Dashboard contract consumer\n        '';\n        default = { };\n        type = lib.types.submodule {\n          options = shb.contracts.dashboard.mkRequester {\n            externalUrl = \"https://${cfg.scrutiny.subdomain}.${cfg.domain}\";\n            externalUrlText = \"https://\\${config.shb.monitoring.scrutiny.subdomain}.\\${config.shb.monitoring.domain}\";\n            internalUrl = \"http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}\";\n            internalUrlText = \"https://127.0.0.1.\\${config.services.scrutiny.settings.web.listen.port}\";\n          };\n        };\n      };\n    };\n\n    adminPassword = lib.mkOption {\n      description = \"Initial admin password.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = \"grafana\";\n          group = \"grafana\";\n          restartUnits = [ \"grafana.service\" ];\n        };\n      };\n    };\n\n    secretKey = lib.mkOption {\n      description = \"Secret key used for signing.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = \"grafana\";\n          group = \"grafana\";\n          restartUnits = [ \"grafana.service\" ];\n        };\n      };\n    };\n\n    smtp = lib.mkOption {\n      description = \"SMTP options.\";\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            from_address = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP address from which the emails originate.\";\n              example = \"vaultwarden@mydomain.com\";\n            };\n            from_name = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP name from which the emails originate.\";\n              default = \"Grafana\";\n            };\n            host = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP host to send the emails to.\";\n            };\n            port = lib.mkOption {\n              type = lib.types.port;\n              description = \"SMTP port to send the emails to.\";\n              default = 25;\n            };\n            username = lib.mkOption {\n              type = lib.types.str;\n              description = \"Username to connect to the SMTP host.\";\n            };\n            passwordFile = lib.mkOption {\n              type = lib.types.str;\n              description = \"File containing the password to connect to the SMTP host.\";\n            };\n          };\n        }\n      );\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        Setup LDAP integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to login to Grafana.\";\n            default = \"monitoring_user\";\n          };\n          adminGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be admins in Grafana.\";\n            default = \"monitoring_admin\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            default = null;\n            description = \"Endpoint to the SSO provider.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = lib.mkOption {\n            type = lib.types.str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"grafana\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = lib.types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = lib.mkOption {\n            description = \"OIDC shared secret for Grafana.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                owner = \"grafana\";\n                restartUnits = [\n                  \"grafana.service\"\n                ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret for Authelia. Must be the same as `sharedSecret`\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                ownerText = \"config.shb.authelia.autheliaUser\";\n                owner = config.shb.authelia.autheliaUser;\n              };\n            };\n          };\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.monitoring.subdomain}.\\${config.shb.monitoring.domain}\";\n          internalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          internalUrlText = \"https://\\${config.shb.monitoring.subdomain}.\\${config.shb.monitoring.domain}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkMerge [\n    (lib.mkIf cfg.enable {\n      assertions = [\n        {\n          assertion = builtins.length cfg.contactPoints > 0;\n          message = \"Must have at least one contact point for alerting\";\n        }\n      ];\n\n      shb.postgresql.ensures = [\n        {\n          username = \"grafana\";\n          database = \"grafana\";\n        }\n      ];\n\n      services.grafana = {\n        enable = true;\n\n        settings = {\n          database = {\n            host = \"/run/postgresql\";\n            user = \"grafana\";\n            name = \"grafana\";\n            type = \"postgres\";\n            # Uses peer auth for local users, so we don't need a password.\n            # Here's the syntax anyway for future refence:\n            # password = \"$__file{/run/secrets/homeassistant/dbpass}\";\n          };\n\n          security = {\n            secret_key = \"$__file{${cfg.secretKey.result.path}}\";\n            disable_initial_admin_creation = false; # Enable when LDAP support is configured.\n            admin_password = \"$__file{${cfg.adminPassword.result.path}}\"; # Remove when LDAP support is configured.\n          };\n\n          server = {\n            http_addr = \"127.0.0.1\";\n            http_port = cfg.grafanaPort;\n            domain = fqdn;\n            root_url = \"https://${fqdn}\";\n            router_logging = cfg.debugLog;\n          };\n\n          smtp = lib.mkIf (!(isNull cfg.smtp)) {\n            enabled = true;\n            inherit (cfg.smtp) from_address from_name;\n            host = \"${cfg.smtp.host}:${toString cfg.smtp.port}\";\n            user = cfg.smtp.username;\n            password = \"$__file{${cfg.smtp.passwordFile}}\";\n          };\n        };\n      };\n    })\n\n    (lib.mkIf cfg.enable {\n      shb.monitoring.dashboards = [\n        ./monitoring/dashboards/Errors.json\n        ./monitoring/dashboards/Performance.json\n        ./monitoring/dashboards/Scraping_Jobs.json\n      ];\n\n      services.grafana.provision = {\n        dashboards.settings = lib.mkIf (cfg.dashboards != [ ]) {\n          apiVersion = 1;\n          providers = [\n            {\n              folder = \"Self Host Blocks\";\n              options.path = pkgs.symlinkJoin {\n                name = \"dashboards\";\n                paths = map (p: pkgs.runCommand \"dashboard\" { } \"mkdir $out; cp ${p} $out\") cfg.dashboards;\n              };\n              allowUiUpdates = true;\n              disableDeletion = true;\n            }\n          ];\n        };\n        datasources.settings = {\n          apiVersion = 1;\n          datasources = [\n            {\n              inherit (cfg) orgId;\n              name = \"Prometheus\";\n              type = \"prometheus\";\n              url = \"http://127.0.0.1:${toString config.services.prometheus.port}\";\n              uid = \"df80f9f5-97d7-4112-91d8-72f523a02b09\";\n              isDefault = true;\n              version = 1;\n            }\n            {\n              inherit (cfg) orgId;\n              name = \"Loki\";\n              type = \"loki\";\n              url = \"http://127.0.0.1:${toString config.services.loki.configuration.server.http_listen_port}\";\n              uid = \"cd6cc53e-840c-484d-85f7-96fede324006\";\n              version = 1;\n            }\n          ];\n          deleteDatasources = [\n            {\n              inherit (cfg) orgId;\n              name = \"Prometheus\";\n            }\n            {\n              inherit (cfg) orgId;\n              name = \"Loki\";\n            }\n          ];\n        };\n        alerting.contactPoints.settings = {\n          apiVersion = 1;\n          contactPoints = [\n            {\n              inherit (cfg) orgId;\n              name = \"grafana-default-email\";\n              receivers = lib.optionals ((builtins.length cfg.contactPoints) > 0) [\n                {\n                  uid = \"sysadmin\";\n                  type = \"email\";\n                  settings.addresses = lib.concatStringsSep \";\" cfg.contactPoints;\n                }\n              ];\n            }\n          ];\n        };\n        alerting.policies.settings = {\n          apiVersion = 1;\n          policies = [\n            {\n              inherit (cfg) orgId;\n              receiver = \"grafana-default-email\";\n              group_by = [\n                \"grafana_folder\"\n                \"alertname\"\n              ];\n              group_wait = \"30s\";\n              group_interval = \"5m\";\n              repeat_interval = \"4h\";\n            }\n          ];\n          # resetPolicies seems to happen after setting the above policies, effectively rolling back\n          # any updates.\n        };\n        alerting.rules.settings =\n          let\n            rules = builtins.fromJSON (builtins.readFile ./monitoring/rules.json);\n          in\n          {\n            apiVersion = 1;\n            groups = [\n              {\n                inherit (cfg) orgId;\n                name = \"SysAdmin\";\n                folder = \"Self Host Blocks\";\n                interval = \"10m\";\n                inherit rules;\n              }\n            ];\n            # deleteRules seems to happen after creating the above rules, effectively rolling back\n            # any updates.\n          };\n      };\n    })\n\n    (lib.mkIf cfg.enable {\n      services.prometheus = {\n        enable = true;\n        port = cfg.prometheusPort;\n        globalConfig = {\n          scrape_interval = \"15s\";\n        };\n      };\n\n      services.loki = {\n        enable = true;\n        dataDir = \"/var/lib/loki\";\n        package =\n          if cfg.lokiMajorVersion == 3 then\n            pkgs.grafana-loki\n          else\n            # Comes from https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4\n            (pkgs.grafana-loki.overrideAttrs (\n              finalAttrs: previousAttrs: rec {\n                version = \"2.9.6\";\n\n                src = pkgs.fetchFromGitHub {\n                  owner = \"grafana\";\n                  repo = \"loki\";\n                  rev = \"v${version}\";\n                  hash = \"sha256-79hK7axHf6soku5DvdXkE/0K4WKc4pnS9VMbVc1FS2I=\";\n                };\n\n                subPackages = [\n                  \"cmd/loki\"\n                  \"cmd/loki-canary\"\n                  \"clients/cmd/promtail\"\n                  \"cmd/logcli\"\n                  # Removes \"cmd/lokitool\"\n                ];\n\n                ldflags =\n                  let\n                    t = \"github.com/grafana/loki/pkg/util/build\";\n                  in\n                  [\n                    \"-s\"\n                    \"-w\"\n                    \"-X ${t}.Version=${version}\"\n                    \"-X ${t}.BuildUser=nix@nixpkgs\"\n                    \"-X ${t}.BuildDate=unknown\"\n                    \"-X ${t}.Branch=unknown\"\n                    \"-X ${t}.Revision=unknown\"\n                  ];\n              }\n            ));\n        configuration = {\n          auth_enabled = false;\n\n          server.http_listen_port = cfg.lokiPort;\n\n          ingester = {\n            lifecycler = {\n              address = \"127.0.0.1\";\n              ring = {\n                kvstore.store = \"inmemory\";\n                replication_factor = 1;\n              };\n              final_sleep = \"0s\";\n            };\n            chunk_idle_period = \"5m\";\n            chunk_retain_period = \"30s\";\n          };\n\n          schema_config = {\n            configs = [\n              {\n                from = \"2018-04-15\";\n                store = \"boltdb\";\n                object_store = \"filesystem\";\n                schema = \"v9\";\n                index.prefix = \"index_\";\n                index.period = \"168h\";\n              }\n            ];\n          };\n\n          storage_config = {\n            boltdb.directory = \"/tmp/loki/index\";\n            filesystem.directory = \"/tmp/loki/chunks\";\n          };\n\n          limits_config = {\n            enforce_metric_name = false;\n            reject_old_samples = true;\n            reject_old_samples_max_age = \"168h\";\n          };\n\n          chunk_store_config = {\n            max_look_back_period = 0;\n          };\n\n          table_manager = {\n            chunk_tables_provisioning = {\n              inactive_read_throughput = 0;\n              inactive_write_throughput = 0;\n              provisioned_read_throughput = 0;\n              provisioned_write_throughput = 0;\n            };\n            index_tables_provisioning = {\n              inactive_read_throughput = 0;\n              inactive_write_throughput = 0;\n              provisioned_read_throughput = 0;\n              provisioned_write_throughput = 0;\n            };\n            retention_deletes_enabled = false;\n            retention_period = 0;\n          };\n        };\n      };\n\n      services.promtail = {\n        enable = true;\n        configuration = {\n          server = {\n            http_listen_port = 9080;\n            grpc_listen_port = 0;\n          };\n\n          positions.filename = \"/tmp/positions.yaml\";\n\n          client.url = \"http://localhost:${toString config.services.loki.configuration.server.http_listen_port}/api/prom/push\";\n\n          scrape_configs = [\n            {\n              job_name = \"systemd\";\n              journal = {\n                json = false;\n                max_age = \"12h\";\n                path = \"/var/log/journal\";\n                # matches = \"_TRANSPORT=kernel\";\n                labels = {\n                  domain = cfg.domain;\n                  hostname = config.networking.hostName;\n                  job = \"systemd-journal\";\n                };\n              };\n              relabel_configs = [\n                {\n                  source_labels = [ \"__journal__systemd_unit\" ];\n                  target_label = \"unit\";\n                }\n              ];\n            }\n          ];\n        };\n      };\n\n      services.nginx = {\n        enable = true;\n\n        virtualHosts.${fqdn} = {\n          forceSSL = !(isNull cfg.ssl);\n          sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n          sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n          locations.\"/\" = {\n            proxyPass = \"http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}\";\n            proxyWebsockets = true;\n            extraConfig = ''\n              proxy_set_header Host $host;\n            '';\n          };\n        };\n      };\n    })\n\n    (lib.mkIf cfg.enable {\n      services.prometheus.scrapeConfigs = [\n        {\n          job_name = \"node\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.node.port}\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n        {\n          job_name = \"netdata\";\n          metrics_path = \"/api/v1/allmetrics\";\n          params.format = [ \"prometheus\" ];\n          honor_labels = true;\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:19999\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n        {\n          job_name = \"smartctl\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.smartctl.port}\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n        {\n          job_name = \"prometheus_internal\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString config.services.prometheus.port}\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n        {\n          job_name = \"systemd\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.systemd.port}\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n      ]\n      ++ (lib.lists.optional config.services.nginx.enable {\n        job_name = \"nginx\";\n        static_configs = [\n          {\n            targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.nginx.port}\" ];\n            labels = commonLabels;\n          }\n        ];\n        # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.redis.servers) > 0) {\n        #     job_name = \"redis\";\n        #     static_configs = [\n        #       {\n        #         targets = [\"127.0.0.1:${toString config.services.prometheus.exporters.redis.port}\"];\n        #       }\n        #     ];\n        # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) {\n        #     job_name = \"openvpn\";\n        #     static_configs = [\n        #       {\n        #         targets = [\"127.0.0.1:${toString config.services.prometheus.exporters.openvpn.port}\"];\n        #       }\n        #     ];\n      })\n      ++ (lib.optional config.services.dnsmasq.enable {\n        job_name = \"dnsmasq\";\n        static_configs = [\n          {\n            targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.dnsmasq.port}\" ];\n            labels = commonLabels;\n          }\n        ];\n      });\n      services.prometheus.exporters.nginx = lib.mkIf config.services.nginx.enable {\n        enable = true;\n        port = 9111;\n        listenAddress = \"127.0.0.1\";\n        scrapeUri = \"http://localhost:80/nginx_status\";\n      };\n      services.prometheus.exporters.node = {\n        enable = true;\n        # https://github.com/prometheus/node_exporter#collectors\n        enabledCollectors = [\n          \"arp\"\n          \"cpu\"\n          \"cpufreq\"\n          \"diskstats\"\n          \"dmi\"\n          \"edac\"\n          \"entropy\"\n          \"filefd\"\n          \"filesystem\"\n          \"hwmon\"\n          \"loadavg\"\n          \"meminfo\"\n          \"netclass\"\n          \"netdev\"\n          \"netstat\"\n          \"nvme\"\n          \"os\"\n          \"pressure\"\n          \"rapl\"\n          \"schedstat\"\n          \"stat\"\n          \"thermal_zone\"\n          \"time\"\n          \"uname\"\n          \"vmstat\"\n          \"zfs\"\n\n          # Disabled by default\n          \"cgroups\"\n          \"drm\"\n          \"ethtool\"\n          \"logind\"\n          \"wifi\"\n        ];\n        port = 9112;\n        listenAddress = \"127.0.0.1\";\n      };\n      # https://github.com/nixos/nixpkgs/commit/12c26aca1fd55ab99f831bedc865a626eee39f80\n      # TODO: remove when https://github.com/NixOS/nixpkgs/pull/205165 is merged\n      services.udev.extraRules = ''\n        SUBSYSTEM==\"nvme\", KERNEL==\"nvme[0-9]*\", GROUP=\"disk\"\n      '';\n      services.prometheus.exporters.smartctl = {\n        enable = true;\n        port = 9115;\n        listenAddress = \"127.0.0.1\";\n      };\n      # services.prometheus.exporters.redis = lib.mkIf (builtins.length (lib.attrNames config.services.redis.servers) > 0) {\n      #   enable = true;\n      #   port = 9119;\n      #   listenAddress = \"127.0.0.1\";\n      # };\n      # services.prometheus.exporters.openvpn = lib.mkIf (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) {\n      #   enable = true;\n      #   port = 9121;\n      #   listenAddress = \"127.0.0.1\";\n      #   statusPaths = lib.mapAttrsToList (name: _config: \"/tmp/openvpn/${name}.status\") config.services.openvpn.servers;\n      # };\n      services.prometheus.exporters.dnsmasq = lib.mkIf config.services.dnsmasq.enable {\n        enable = true;\n        port = 9211;\n        listenAddress = \"127.0.0.1\";\n      };\n      services.prometheus.exporters.systemd = {\n        enable = true;\n        port = 9116;\n        listenAddress = \"127.0.0.1\";\n      };\n      services.nginx.statusPage = lib.mkDefault config.services.nginx.enable;\n      services.netdata = {\n        enable = true;\n        config = {\n          # web.mode = \"none\";\n          # web.\"bind to\" = \"127.0.0.1:19999\";\n          global = {\n            \"debug log\" = \"syslog\";\n            \"access log\" = \"syslog\";\n            \"error log\" = \"syslog\";\n          };\n        };\n      };\n\n      nixpkgs.overlays = [\n        (final: prev: {\n          prometheus-systemd-exporter = prev.prometheus-systemd-exporter.overrideAttrs {\n            src = final.fetchFromGitHub {\n              owner = \"ibizaman\";\n              repo = prev.prometheus-systemd-exporter.pname;\n              # rev = \"v${prev.prometheus-systemd-exporter.version}\";\n              rev = \"next_timer\";\n              sha256 = \"sha256-jzkh/616tsJbNxFtZ0xbdBQc16TMIYr9QOkPaeQw8xA=\";\n            };\n\n            vendorHash = \"sha256-4hsQ1417jLNOAqGkfCkzrmEtYR4YLLW2j0CiJtPg6GI=\";\n          };\n        })\n      ];\n    })\n    (lib.mkIf (cfg.enable && cfg.sso.enable) {\n      shb.lldap.ensureGroups = {\n        ${cfg.ldap.userGroup} = { };\n        ${cfg.ldap.adminGroup} = { };\n      };\n\n      shb.authelia.extraDefinitions = {\n        user_attributes.${roleClaim}.expression =\n          # Roles are: None, Viewer, Editor, Admin, GrafanaAdmin\n          ''\"${cfg.ldap.adminGroup}\" in groups ? \"Admin\" : (\"${cfg.ldap.userGroup}\" in groups ? \"Editor\" : \"Invalid\")'';\n      };\n      shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {\n        custom_claims = {\n          \"${roleClaim}\" = { };\n        };\n      };\n      shb.authelia.extraOidcScopes.\"${roleClaim}\" = {\n        claims = [ \"${roleClaim}\" ];\n      };\n\n      services.grafana.settings.\"auth.generic_oauth\" = {\n        enabled = true;\n        name = \"Authelia\";\n        icon = \"signin\";\n        client_id = cfg.sso.clientID;\n        client_secret = \"$__file{${cfg.sso.sharedSecret.result.path}}\";\n        scopes = oauthScopes;\n        empty_scopes = false;\n        allow_sign_up = true;\n        auto_login = true;\n        auth_url = \"${cfg.sso.authEndpoint}/api/oidc/authorization\";\n        token_url = \"${cfg.sso.authEndpoint}/api/oidc/token\";\n        # 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\n        api_url = \"${cfg.sso.authEndpoint}/api/oidc/userinfo\";\n        login_attribute_path = \"preferred_username\";\n        groups_attribute_path = \"groups\";\n        name_attribute_path = \"name\";\n        use_pkce = true;\n        allow_assign_grafana_admin = true;\n        skip_org_role_sync = false;\n        role_attribute_path = roleClaim;\n        role_attribute_strict = true;\n      };\n\n      shb.authelia.oidcClients = [\n        {\n          client_id = cfg.sso.clientID;\n          client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n          claims_policy = \"${roleClaim}\";\n          scopes = oauthScopes;\n          authorization_policy = cfg.sso.authorization_policy;\n          redirect_uris = [\n            \"https://${cfg.subdomain}.${cfg.domain}/login/generic_oauth\"\n          ];\n          require_pkce = true;\n          pkce_challenge_method = \"S256\";\n          response_types = [ \"code\" ];\n          token_endpoint_auth_method = \"client_secret_basic\";\n        }\n      ];\n    })\n\n    (lib.mkIf (cfg.enable && cfg.scrutiny.enable) {\n      services.scrutiny = {\n        enable = true;\n\n        openFirewall = false;\n\n        # This src includes Prometheus metrics exporter.\n        package = pkgs.scrutiny.overrideAttrs ({\n          src = pkgs.fetchFromGitHub {\n            owner = \"ibizaman\";\n            repo = \"scrutiny\";\n            rev = \"7ff9a0530d3e54dd1323c2de34f32be330bfb48c\";\n            hash = \"sha256-dE4HuZzaGZKBEkzXwBLQL3h+D55tJMm/EOTpr3wqGAI=\";\n          };\n\n          vendorHash = \"sha256-j3aGTeHNTr/FoVfFLwASkS96Ks0B/Ka9hPuLAKGZECs=\";\n        });\n\n        settings = {\n          web = {\n            metrics.enabled = true; # Enables Prometheus exporter\n            listenHost = \"127.0.0.1\";\n          };\n        };\n\n        collector = {\n          enable = true;\n        };\n      };\n\n      services.prometheus.scrapeConfigs = [\n        {\n          job_name = \"scrutiny\";\n          metrics_path = \"/api/metrics\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}\" ];\n              labels = commonLabels;\n            }\n          ];\n        }\n      ];\n\n      shb.monitoring.dashboards = [\n        ./monitoring/dashboards/Health.json\n      ];\n\n      shb.nginx.vhosts = lib.mkIf (cfg.scrutiny.subdomain != null) [\n        (\n          {\n            inherit (cfg) domain ssl;\n            subdomain = cfg.scrutiny.subdomain;\n\n            upstream = \"http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}\";\n            autheliaRules = lib.optionals (cfg.sso.enable) [\n              {\n                domain = \"${cfg.subdomain}.${cfg.domain}\";\n                policy = cfg.sso.authorization_policy;\n                subject = [\n                  \"group:${cfg.ldap.userGroup}\"\n                  \"group:${cfg.ldap.adminGroup}\"\n                ];\n              }\n            ];\n          }\n          // lib.optionalAttrs cfg.sso.enable {\n            inherit (cfg.sso) authEndpoint;\n          }\n        )\n      ];\n    })\n  ];\n}\n"
  },
  {
    "path": "modules/blocks/nginx/docs/default.md",
    "content": "# Nginx Block {#blocks-nginx}\n\nDefined in [`/modules/blocks/nginx.nix`](@REPO@/modules/blocks/nginx.nix).\n\nThis block sets up a [Nginx](https://nginx.org/) instance.\n\nIt complements the upstream nixpkgs with some authentication and debugging improvements as shows in the Usage section.\n\n## Usage {#blocks-nginx-usage}\n\n### Access Logging {#blocks-nginx-usage-accesslog}\n\nJSON access logging is enabled with the [`shb.nginx.accessLog`](#blocks-nginx-options-shb.nginx.accessLog) option:\n\n```nix\n{\n  shb.nginx.accessLog = true;\n}\n```\n\nLooking at the systemd logs (`journalctl -fu nginx`) will show for example:\n\n```json\nnginx[969]: server nginx:\n  {\n    \"remote_addr\":\"192.168.1.1\",\n    \"remote_user\":\"-\",\n    \"time_local\":\"29/Dec/2025:14:22:41 +0000\",\n    \"request\":\"POST /api/firstfactor HTTP/2.0\",\n    \"request_length\":\"264\",\n    \"server_name\":\"auth_example_com\",\n    \"status\":\"200\",\n    \"bytes_sent\":\"855\",\n    \"body_bytes_sent\":\"60\",\n    \"referrer\":\"-\",\n    \"user_agent\":\"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0\",\n    \"gzip_ration\":\"-\",\n    \"post\":\"{\\x22username\\x22:\\x22charlie\\x22,\\x22password\\x22:\\x22CharliePassword\\x22,\\x22keepMeLoggedIn\\x22:false,\\x22targetURL\\x22:\\x22https://f.example.com/\\x22,\\x22requestMethod\\x22:null}\",\n    \"upstream_addr\":\"127.0.0.1:9091\",\n    \"upstream_status\":\"200\",\n    \"request_time\":\"0.873\",\n    \"upstream_response_time\":\"0.873\",\n    \"upstream_connect_time\":\"0.001\",\n    \"upstream_header_time\":\"0.872\"\n  }\n```\n\nThis _will_ log the body of POST queries so it should only be enabled for debug logging.\n\n### Debug Logging {#blocks-nginx-usage-debuglog}\n\nDebug logging is enabled with the [`shb.nginx.debugLog`](#blocks-nginx-options-shb.nginx.debugLog) option:\n\n```nix\n{\n  shb.nginx.debugLog = true;\n}\n```\n\nIf enabled, it sets:\n\n```\nerror_log stderr warn;\n```\n\n### Virtual Host Upstream Proxy {#blocks-nginx-usage-upstream}\n\nEasy upstream proxy setup is done with the [`shb.nginx.vhosts.*.upstream`](#blocks-nginx-options-shb.nginx.vhosts._.upstream) option:\n\n```nix\n{\n  shb.nginx.vhosts = [\n    {\n      domain = \"example.com\";\n      subdomain = \"mysubdomain\";\n      upstream = \"http://127.0.0.1:9090\";\n    }\n  ];\n}\n```\n\nThis will set also a few headers.\nSome are shown here and others please see in the [nginx](@REPO@/modules/blocks/nginx.nix) module:\n\n- `Host` = `$host`;\n- `X-Real-IP` = `$remote_addr`;\n- `X-Forwarded-For` = `$proxy_add_x_forwarded_for`;\n- `X-Forwarded-Proto` = `$scheme`;\n\n### Virtual Host SSL Generator Contract Integration {#blocks-nginx-usage-ssl}\n\nThis module integrates with the [SSL Generator Contract](./contracts-ssl.html)\nto setup HTTPs with the [`shb.nginx.vhosts.*.ssl`](#blocks-nginx-options-shb.nginx.vhosts._.ssl) option:\n\n```nix\n{\n  shb.nginx.vhosts = [\n    {\n      domain = \"example.com\";\n      subdomain = \"mysubdomain\";\n      ssl = config.shb.certs.certs.letsencrypt.${domain};;\n    }\n  ];\n\n  shb.certs.certs.letsencrypt.${domain} = {\n    inherit domain;\n  };\n}\n```\n\n### Virtual Host SHB Forward Authentication {#blocks-nginx-usage-shbforwardauth}\n\nFor services provided by SelfHostBlocks that do not handle [OIDC integration][OIDC],\nthis block can provide [forward authentication][] which still allows the service\nto still be protected by an SSO server.\n\n[OIDC]: blocks-authelia.html#blocks-authelia-shb-oidc\n\nThe user could still be required to authenticate to the service itself,\nalthough some services can automatically users authorized by Authelia.\n\n[forward authentication]: https://doc.traefik.io/traefik/middlewares/http/forwardauth/\n\nIntegrating with this block is done with the following code:\n\n```nix\nshb.<services>.authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n```\n\n### Virtual Host Forward Authentication {#blocks-nginx-usage-forwardauth}\n\nForward authentication is when Nginx talks with the SSO service directly\nand the user is authenticated before reaching the upstream application.\n\nThe SSO service responds with the username, group and more information about the user.\nThis is then forwarded to the upstream application by Nginx.\n\nNote that _every_ request is authenticated this way with the SSO server\nso it involves more hops than a direct [OIDC integration](blocks-authelia.html#blocks-authelia-shb-oidc).\n\n```nix\n{\n  shb.nginx.vhosts = [\n    {\n      domain = \"example.com\";\n      subdomain = \"mysubdomain\";\n      authEndpoint = \"authelia.example.com\";\n      autheliaRules = [\n        [\n          # Protect /admin endpoint with 2FA\n          # and only allow access to admin users.\n          {\n            domain = \"myapp.example.com\";\n            policy = \"two_factor\";\n            subject = [ \"group:service_admin\" ];\n            resources = [\n              \"^/admin\"\n            ];\n          }\n          # Leave /api endpoint open - assumes an API key is used to protect it.\n          {\n            domain = \"myapp.example.com\";\n            policy = \"bypass\";\n            resources = [\n              \"^/api\"\n            ];\n          },\n          # Protect rest of app with 1FA\n          # and allow access to normal and admin users.\n          {\n            domain = \"myapp.example.com\";\n            policy = \"one_factor\";\n            subject = [\"group:service_user\"];\n          },\n        ]\n      ];\n    }\n  ];\n}\n```\n\nIf PHP is used with fastCGI,\nextra headers must be added by enabling the [`shb.nginx.vhosts.*.phpForwardAuth`](#blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth) option.\n\n### Virtual Host Extra Config {#blocks-nginx-usage-extraconfig}\n\nTo add extra configuration to a virtual host,\nuse the [`shb.nginx.vhosts.*.extraConfig`](#blocks-nginx-options-shb.nginx.vhosts._.extraConfig) option.\nThis can be used to add headers, for example:\n\n```nix\n{\n  shb.nginx.vhosts = [\n    {\n      domain = \"example.com\";\n      subdomain = \"mysubdomain\";\n      extraConfig = ''\n        add_header Strict-Transport-Security \"max-age=63072000; includeSubDomains; preload\";\n      '';\n    }\n  ];\n}\n```\n\n## Options Reference {#blocks-nginx-options}\n\n```{=include=} options\nid-prefix: blocks-nginx-options-\nlist-id: selfhostblocks-block-nginx-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/nginx.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.nginx;\n\n  fqdn = c: \"${c.subdomain}.${c.domain}\";\n\n  vhostConfig = lib.types.submodule {\n    options = {\n      subdomain = lib.mkOption {\n        type = lib.types.str;\n        description = \"Subdomain which must be protected.\";\n        example = \"subdomain\";\n      };\n\n      domain = lib.mkOption {\n        type = lib.types.str;\n        description = \"Domain of the subdomain.\";\n        example = \"mydomain.com\";\n      };\n\n      ssl = lib.mkOption {\n        description = \"Path to SSL files\";\n        type = lib.types.nullOr shb.contracts.ssl.certs;\n        default = null;\n      };\n\n      upstream = lib.mkOption {\n        type = lib.types.nullOr lib.types.str;\n        description = \"Upstream url to be protected.\";\n        default = null;\n        example = \"http://127.0.0.1:1234\";\n      };\n\n      authEndpoint = lib.mkOption {\n        type = lib.types.nullOr lib.types.str;\n        description = \"Optional auth endpoint for SSO.\";\n        default = null;\n        example = \"https://authelia.example.com\";\n      };\n\n      autheliaRules = lib.mkOption {\n        type = lib.types.listOf (lib.types.attrsOf lib.types.anything);\n        default = [ ];\n        description = \"Authelia rule configuration\";\n        example = lib.literalExpression ''\n          [\n            # Protect /admin endpoint with 2FA\n            # and only allow access to admin users.\n            {\n              domain = \"myapp.example.com\";\n              policy = \"two_factor\";\n              subject = [ \"group:service_admin\" ];\n              resources = [\n                \"^/admin\"\n              ];\n            }\n            # Leave /api endpoint open - assumes an API key is used to protect it.\n            {\n              domain = \"myapp.example.com\";\n              policy = \"bypass\";\n              resources = [\n                \"^/api\"\n              ];\n            },\n            # Protect rest of app with 1FA\n            # and allow access to normal and admin users.\n            {\n              domain = \"myapp.example.com\";\n              policy = \"one_factor\";\n              subject = [\"group:service_user\"];\n            },\n          ]\n        '';\n      };\n\n      phpForwardAuth = lib.mkOption {\n        type = lib.types.bool;\n        default = false;\n        description = \"Authelia rule configuration\";\n      };\n\n      extraConfig = lib.mkOption {\n        type = lib.types.lines;\n        default = \"\";\n        description = \"Extra config to add to the root / location. Strings separated by newlines.\";\n      };\n    };\n  };\nin\n{\n  imports = [\n    ./authelia.nix\n  ];\n\n  options.shb.nginx = {\n    accessLog = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Log all requests\";\n      default = false;\n      example = true;\n    };\n\n    debugLog = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Verbose debug of internal. This will print what servers were matched and why.\";\n      default = false;\n      example = true;\n    };\n\n    vhosts = lib.mkOption {\n      description = \"Endpoints to be protected by authelia.\";\n      type = lib.types.listOf vhostConfig;\n      default = [ ];\n    };\n  };\n\n  config = {\n    networking.firewall.allowedTCPPorts = [\n      80\n      443\n    ];\n\n    services.nginx.enable = true;\n    services.nginx.logError = lib.mkIf cfg.debugLog \"stderr warn\";\n    services.nginx.appendHttpConfig = lib.mkIf cfg.accessLog ''\n      log_format apm\n        '{'\n        '\"remote_addr\":\"$remote_addr\",'\n        '\"remote_user\":\"$remote_user\",'\n        '\"time_local\":\"$time_local\",'\n        '\"request\":\"$request\",'\n        '\"request_length\":\"$request_length\",'\n        '\"server_name\":\"$server_name\",'\n        '\"status\":\"$status\",'\n        '\"bytes_sent\":\"$bytes_sent\",'\n        '\"body_bytes_sent\":\"$body_bytes_sent\",'\n        '\"referrer\":\"$http_referrer\",'\n        '\"user_agent\":\"$http_user_agent\",'\n        '\"gzip_ration\":\"$gzip_ratio\",'\n        '\"post\":\"$request_body\",'\n        '\"upstream_addr\":\"$upstream_addr\",'\n        '\"upstream_status\":\"$upstream_status\",'\n        '\"request_time\":\"$request_time\",'\n        '\"upstream_response_time\":\"$upstream_response_time\",'\n        '\"upstream_connect_time\":\"$upstream_connect_time\",'\n        '\"upstream_header_time\":\"$upstream_header_time\"'\n        '}';\n\n      access_log syslog:server=unix:/dev/log apm;\n    '';\n\n    services.nginx.virtualHosts =\n      let\n        vhostCfg = c: {\n          ${fqdn c} = {\n            forceSSL = !(isNull c.ssl);\n            sslCertificate = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.cert;\n            sslCertificateKey = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.key;\n\n            # Taken from https://github.com/authelia/authelia/issues/178\n            locations.\"/\".extraConfig =\n              lib.optionalString (c.upstream != null) ''\n                add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n                add_header X-Content-Type-Options nosniff;\n                add_header X-Frame-Options \"SAMEORIGIN\";\n                add_header X-XSS-Protection \"1; mode=block\";\n                add_header X-Robots-Tag \"noindex, nofollow, nosnippet, noarchive\";\n                add_header X-Download-Options noopen;\n                add_header X-Permitted-Cross-Domain-Policies none;\n\n                proxy_set_header Host $host;\n                proxy_set_header X-Real-IP $remote_addr;\n                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n                proxy_set_header X-Forwarded-Proto $scheme;\n                proxy_http_version 1.1;\n                proxy_set_header Upgrade $http_upgrade;\n                proxy_set_header Connection \"upgrade\";\n                proxy_cache_bypass $http_upgrade;\n\n                proxy_pass ${c.upstream};\n              ''\n              + c.extraConfig\n              + lib.optionalString (c.authEndpoint != null) ''\n                auth_request /authelia;\n                auth_request_set $user $upstream_http_remote_user;\n                auth_request_set $groups $upstream_http_remote_groups;\n                proxy_set_header X-Forwarded-User $user;\n                proxy_set_header X-Forwarded-Groups $groups;\n                # TODO: Are those needed?\n                # auth_request_set $name $upstream_http_remote_name;\n                # auth_request_set $email $upstream_http_remote_email;\n                # proxy_set_header Remote-Name $name;\n                # proxy_set_header Remote-Email $email;\n                # TODO: Would be nice to have this working, I think.\n                # set $new_cookie $http_cookie;\n                # if ($http_cookie ~ \"(.*)(?:^|;)\\s*example\\.com\\.session\\.id=[^;]+(.*)\") {\n                #     set $new_cookie $1$2;\n                # }\n                # proxy_set_header Cookie $new_cookie;\n\n                auth_request_set $redirect $scheme://$http_host$request_uri;\n                error_page 401 =302 ${c.authEndpoint}?rd=$redirect;\n                error_page 403 = ${c.authEndpoint}/error/403;\n              '';\n\n            locations.\"~ \\\\.php$\".extraConfig = lib.mkIf (c.phpForwardAuth) ''\n              fastcgi_param HTTP_X_FORWARDED_USER   $user;\n              fastcgi_param HTTP_X_FORWARDED_GROUPS $groups;\n            '';\n\n            # Virtual endpoint created by nginx to forward auth requests.\n            locations.\"/authelia\".extraConfig = lib.mkIf (c.authEndpoint != null) ''\n              internal;\n              proxy_pass ${c.authEndpoint}/api/verify;\n\n              proxy_set_header X-Forwarded-Host $host;\n              proxy_set_header X-Original-URI $request_uri;\n              proxy_set_header X-Original-URL $scheme://$host$request_uri;\n              proxy_set_header X-Forwarded-For $remote_addr;\n              proxy_set_header X-Forwarded-Proto $scheme;\n              proxy_set_header Content-Length \"\";\n              proxy_pass_request_body off;\n              # TODO: Would be nice to be able to enable this.\n              # proxy_ssl_verify on;\n              # proxy_ssl_trusted_certificate \"/etc/ssl/certs/DST_Root_CA_X3.pem\";\n              proxy_ssl_protocols TLSv1.2;\n              proxy_ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';\n              proxy_ssl_verify_depth 2;\n              proxy_ssl_server_name on;\n            '';\n          };\n        };\n      in\n      lib.mkMerge (map vhostCfg cfg.vhosts);\n\n    shb.authelia.rules =\n      let\n        authConfig = c: map (r: r // { domain = fqdn c; }) c.autheliaRules;\n      in\n      lib.flatten (map authConfig cfg.vhosts);\n\n    security.acme.defaults.reloadServices = [\n      \"nginx.service\"\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/blocks/postgresql/docs/default.md",
    "content": "# PostgreSQL Block {#blocks-postgresql}\n\nDefined in [`/modules/blocks/postgresql.nix`](@REPO@/modules/blocks/postgresql.nix).\n\nThis block sets up a [PostgreSQL][] database.\n\n[postgresql]: https://www.postgresql.org/\n\nCompared to the upstream nixpkgs module, this module also sets up:\n\n- Enabling TCP/IP login and also accepting password authentication from localhost with [`shb.postgresql.enableTCPIP`](#blocks-postgresql-options-shb.postgresql.enableTCPIP).\n- 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).\n- Debug logging with `auto_explain` and `pg_stat_statements` with [`shb.postgresql.debug`](#blocks-postgresql-options-shb.postgresql.debug).\n\n## Usage {#blocks-postgresql-usage}\n\n### Ensure User and Database {#blocks-postgresql-ensures}\n\nEnsure a database and user exists:\n\n```nix\nshb.postgresql.ensures = [\n  {\n    username = \"firefly-iii\";\n    database = \"firefly-iii\";\n  }\n];\n```\n\nAlso set up the database password from a file path:\n\n```nix\nshb.postgresql.ensures = [\n  {\n    username = \"firefly-iii\";\n    database = \"firefly-iii\";\n    passwordFile = \"/run/secrets/firefly-iii_db_password\";\n  }\n];\n```\n\n### Database Backup Requester Contracts {#blocks-postgresql-contract-databasebackup}\n\nThis block can be backed up using the [database backup](contracts-databasebackup.html) contract.\n\nContract integration tests are defined in [`/test/contracts/databasebackup.nix`](@REPO@/test/contracts/databasebackup.nix).\n\n#### Backing up All Databases {#blocks-postgresql-contract-databasebackup-all}\n\n```nix\n{\n  my.backup.provider.\"postgresql\" = {\n    request = config.shb.postgresql.databasebackup;\n\n    settings = {\n      // Specific options for the backup provider.\n    };\n  };\n}\n```\n\n## Tests {#blocks-postgresql-tests}\n\nSpecific integration tests are defined in [`/test/blocks/postgresql.nix`](@REPO@/test/blocks/postgresql.nix).\n\n## Options Reference {#blocks-postgresql-options}\n\n```{=include=} options\nid-prefix: blocks-postgresql-options-\nlist-id: selfhostblocks-block-postgresql-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/postgresql.nix",
    "content": "{\n  config,\n  lib,\n  pkgs,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.postgresql;\n\n  upgrade-script =\n    old: new:\n    let\n      oldStr = builtins.toString old;\n      newStr = builtins.toString new;\n\n      oldPkg = pkgs.${\"postgresql_${oldStr}\"};\n      newPkg = pkgs.${\"postgresql_${newStr}\"};\n    in\n    pkgs.writeScriptBin \"upgrade-pg-cluster-${oldStr}-${newStr}\" ''\n      set -eux\n      # XXX it's perhaps advisable to stop all services that depend on postgresql\n      systemctl stop postgresql\n\n      export NEWDATA=\"/var/lib/postgresql/${newPkg.psqlSchema}\"\n      export NEWBIN=\"${newPkg}/bin\"\n\n      export OLDDATA=\"/var/lib/postgresql/${oldPkg.psqlSchema}\"\n      export OLDBIN=\"${oldPkg}/bin\"\n\n      install -d -m 0700 -o postgres -g postgres \"$NEWDATA\"\n      cd \"$NEWDATA\"\n      sudo -u postgres $NEWBIN/initdb -D \"$NEWDATA\"\n\n      sudo -u postgres $NEWBIN/pg_upgrade \\\n        --old-datadir \"$OLDDATA\" --new-datadir \"$NEWDATA\" \\\n        --old-bindir $OLDBIN --new-bindir $NEWBIN \\\n        \"$@\"\n    '';\nin\n{\n  imports = [\n    ../../lib/module.nix\n  ];\n\n  options.shb.postgresql = {\n    debug = lib.mkOption {\n      type = lib.types.bool;\n      description = ''\n        Enable debugging options.\n\n        Currently enables shared_preload_libraries = \"auto_explain, pg_stat_statements\"\n\n        See https://www.postgresql.org/docs/current/pgstatstatements.html'';\n      default = false;\n    };\n    enableTCPIP = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Enable TCP/IP connection on given port.\";\n      default = false;\n    };\n\n    databasebackup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.databasebackup.mkRequester {\n          user = \"postgres\";\n\n          backupName = \"postgres.sql\";\n\n          backupCmd = ''\n            ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable\n          '';\n\n          restoreCmd = ''\n            ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres\n          '';\n        };\n      };\n    };\n\n    ensures = lib.mkOption {\n      description = \"List of username, database and/or passwords that should be created.\";\n      type = lib.types.listOf (\n        lib.types.submodule {\n          options = {\n            username = lib.mkOption {\n              type = lib.types.str;\n              description = \"Postgres user name.\";\n            };\n\n            database = lib.mkOption {\n              type = lib.types.str;\n              description = \"Postgres database.\";\n            };\n\n            passwordFile = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"Optional password file for the postgres user. If not given, only peer auth is accepted for this user, otherwise password auth is allowed.\";\n              default = null;\n              example = \"/run/secrets/postgresql/password\";\n            };\n          };\n        }\n      );\n      default = [ ];\n    };\n  };\n\n  config =\n    let\n      commonConfig = {\n        systemd.services.postgresql.serviceConfig.Restart = \"always\";\n\n        services.postgresql.settings = {\n        };\n      };\n\n      tcpConfig = {\n        services.postgresql.enableTCPIP = true;\n        services.postgresql.authentication = lib.mkOverride 10 ''\n          #type database DBuser origin-address auth-method\n          local all      all    peer\n          # ipv4\n          host  all      all    127.0.0.1/32   password\n          # ipv6\n          host  all      all    ::1/128        password\n        '';\n      };\n\n      dbConfig = ensureCfgs: {\n        services.postgresql.enable = lib.mkDefault ((builtins.length ensureCfgs) > 0);\n        services.postgresql.ensureDatabases = map ({ database, ... }: database) ensureCfgs;\n        services.postgresql.ensureUsers = map (\n          { username, database, ... }:\n          {\n            name = username;\n            ensureDBOwnership = true;\n            ensureClauses.login = true;\n          }\n        ) ensureCfgs;\n      };\n\n      pwdConfig = ensureCfgs: {\n        systemd.services.postgresql-setup.script = lib.mkAfter (\n          let\n            prefix = ''\n              psql -tA <<'EOF'\n                DO $$\n                DECLARE password TEXT;\n                BEGIN\n            '';\n            suffix = ''\n                END $$;\n              EOF\n            '';\n            exec =\n              { username, passwordFile, ... }:\n              ''\n                password := trim(both from replace(pg_read_file('${passwordFile}'), E'\\n', '''));\n                EXECUTE format('ALTER ROLE \"${username}\" WITH PASSWORD '''%s''';', password);\n              '';\n            cfgsWithPasswords = builtins.filter (cfg: cfg.passwordFile != null) ensureCfgs;\n          in\n          if (builtins.length cfgsWithPasswords) == 0 then\n            \"\"\n          else\n            prefix + (lib.concatStrings (map exec cfgsWithPasswords)) + suffix\n        );\n      };\n\n      debugConfig =\n        enableDebug:\n        lib.mkIf enableDebug {\n          services.postgresql.settings.shared_preload_libraries = \"auto_explain, pg_stat_statements\";\n        };\n    in\n    lib.mkMerge ([\n      commonConfig\n      (dbConfig cfg.ensures)\n      (pwdConfig cfg.ensures)\n      (lib.mkIf cfg.enableTCPIP tcpConfig)\n      (debugConfig cfg.debug)\n      {\n        environment.systemPackages = lib.mkIf config.services.postgresql.enable [\n          (upgrade-script 15 16)\n          (upgrade-script 16 17)\n        ];\n      }\n    ]);\n}\n"
  },
  {
    "path": "modules/blocks/restic/docs/default.md",
    "content": "# Restic Block {#blocks-restic}\n\nDefined in [`/modules/blocks/restic.nix`](@REPO@/modules/blocks/restic.nix).\n\nThis block sets up a backup job using [Restic][].\n\n[restic]: https://restic.net/\n\n## Provider Contracts {#blocks-restic-contract-provider}\n\n\nThis block provides the following contracts:\n\n- [backup contract](contracts-backup.html) under the [`shb.restic.instances`][instances] option.\n  It is tested with [contract tests][backup contract tests].\n- [database backup contract](contracts-databasebackup.html) under the [`shb.restic.databases`][databases] option.\n  It is tested with [contract tests][database backup contract tests].\n\n[instances]: #blocks-restic-options-shb.restic.instances\n[databases]: #blocks-restic-options-shb.restic.databases\n[backup contract tests]: @REPO@/test/contracts/backup.nix\n[database backup contract tests]: @REPO@/test/contracts/databasebackup.nix\n\nAs requested by those two contracts, when setting up a backup with Restic,\na backup Systemd service and a [restore script](#blocks-restic-maintenance) are provided.\n\n## Usage {#blocks-restic-usage}\n\n\nThe following examples assume usage of the [sops block][] to provide secrets\nalthough any blocks providing the [secrets contract][] works too.\n\n[sops block]: ./blocks-sops.html\n[secrets contract]: ./contracts-secrets.html\n\n### One folder backed up manually {#blocks-restic-usage-provider-manual}\n\n\nThe following snippet shows how to configure\nthe backup of 1 folder to 1 repository.\nWe assume that the folder `/var/lib/myfolder` of the service `myservice` must be backed up.\n\n```nix\nshb.restic.instances.\"myservice\" = {\n  request = {\n    user = \"myservice\";\n\n    sourceDirectories = [\n      \"/var/lib/myfolder\"\n    ];\n  };\n\n  settings = {\n    enable = true;\n\n    passphrase.result = config.shb.sops.secret.\"passphrase\".result;\n\n    repository = {\n      path = \"/srv/backups/myservice\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n    };\n\n    retention = {\n      keep_within = \"1d\";\n      keep_hourly = 24;\n      keep_daily = 7;\n      keep_weekly = 4;\n      keep_monthly = 6;\n    };\n  };\n};\n\nshb.sops.secret.\"passphrase\".request =\n  config.shb.restic.instances.\"myservice\".settings.passphrase.request;\n```\n\n### One folder backed up with contract {#blocks-restic-usage-provider-contract}\n\nWith the same example as before but assuming the `myservice` service\nhas a `myservice.backup` option that is a requester for the backup contract,\nthe snippet above becomes:\n\n```nix\nshb.restic.instances.\"myservice\" = {\n  request = config.myservice.backup.request;\n\n  settings = {\n    enable = true;\n\n    passphrase.result = config.shb.sops.secret.\"passphrase\".result;\n\n    repository = {\n      path = \"/srv/backups/myservice\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n    };\n\n    retention = {\n      keep_within = \"1d\";\n      keep_hourly = 24;\n      keep_daily = 7;\n      keep_weekly = 4;\n      keep_monthly = 6;\n    };\n  };\n};\n\nshb.sops.secret.\"passphrase\".request =\n  config.shb.restic.instances.\"myservice\".settings.passphrase.request;\n```\n\n### One folder backed up to S3 {#blocks-restic-usage-provider-remote}\n\nHere we will only highlight the differences with the previous configuration.\n\nThis assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/).\n\n```diff\n  shb.test.backup.instances.myservice = {\n\n    repository = {\n-     path = \"/srv/pool1/backups/myfolder\";\n+     path = \"s3:s3.us-west-000.backblazeb2.com/backups/myfolder\";\n      timerConfig = {\n        OnCalendar = \"00:00:00\";\n        RandomizedDelaySec = \"3h\";\n      };\n\n+     extraSecrets = {\n+       AWS_ACCESS_KEY_ID.source=\"<path/to/access_key_id>\";\n+       AWS_SECRET_ACCESS_KEY.source=\"<path/to/secret_access_key>\";\n+     };\n    };\n  }\n```\n\n### Multiple directories to multiple destinations {#blocks-restic-usage-multiple}\n\nThe following snippet shows how to configure backup of any number of folders to 3 repositories,\neach happening at different times to avoid I/O contention.\n\nWe will also make sure to be able to re-use as much as the configuration as possible.\n\nA few assumptions:\n- 2 hard drive pools used for backup are mounted respectively on `/srv/pool1` and `/srv/pool2`.\n- You have a backblaze account.\n\nFirst, let's define a variable to hold all the repositories we want to back up to:\n\n```nix\nrepos = [\n  {\n    path = \"/srv/pool1/backups\";\n    timerConfig = {\n      OnCalendar = \"00:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n  {\n    path = \"/srv/pool2/backups\";\n    timerConfig = {\n      OnCalendar = \"08:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n  {\n    path = \"s3:s3.us-west-000.backblazeb2.com/backups\";\n    timerConfig = {\n      OnCalendar = \"16:00:00\";\n      RandomizedDelaySec = \"3h\";\n    };\n  }\n];\n```\n\nCompared to the previous examples, we do not include the name of what we will back up in the\nrepository paths.\n\nNow, let's define a function to create a backup configuration. It will take a list of repositories,\na name identifying the backup and a list of folders to back up.\n\n```nix\nbackupcfg = repositories: name: sourceDirectories {\n  enable = true;\n\n  backend = \"restic\";\n\n  keySopsFile = ../secrets/backup.yaml;\n\n  repositories = builtins.map (r: {\n    path = \"${r.path}/${name}\";\n    inherit (r) timerConfig;\n  }) repositories;\n\n  inherit sourceDirectories;\n\n  retention = {\n    keep_within = \"1d\";\n    keep_hourly = 24;\n    keep_daily = 7;\n    keep_weekly = 4;\n    keep_monthly = 6;\n  };\n\n  environmentFile = true;\n};\n```\n\nNow, we can define multiple backup jobs to backup different folders:\n\n```nix\nshb.test.backup.instances.myfolder1 = backupcfg repos [\"/var/lib/myfolder1\"];\nshb.test.backup.instances.myfolder2 = backupcfg repos [\"/var/lib/myfolder2\"];\n```\n\nThe difference between the above snippet and putting all the folders into one configuration (shown\nbelow) is the former splits the backups into sub-folders on the repositories.\n\n```nix\nshb.test.backup.instances.all = backupcfg repos [\"/var/lib/myfolder1\" \"/var/lib/myfolder2\"];\n```\n\n## Monitoring {#blocks-restic-monitoring}\n\nA generic dashboard for all backup solutions is provided.\nSee [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter.\n\n## Maintenance {#blocks-restic-maintenance}\n\nOne command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets.\n\nThe restore script has all the secrets needed to access the repo,\nit will run `sudo` automatically\nand the user running it needs to have correct permissions for privilege escalation\n\nIn the [multiple directories example](#blocks-restic-usage-multiple) above, the following 6 helpers are provided in the `$PATH`:\n\n```bash\nrestic-myfolder1_srv_pool1_backups\nrestic-myfolder1_srv_pool2_backups\nrestic-myfolder1_s3_s3.us-west-000.backblazeb2.com_backups\nrestic-myfolder2_srv_pool1_backups\nrestic-myfolder2_srv_pool2_backups\nrestic-myfolder2_s3_s3.us-west-000.backblazeb2.com_backups\n```\n\nDiscovering those is easy thanks to tab-completion.\n\nOne can then restore a backup from a given repository with:\n\n```bash\nrestic-myfolder1_srv_pool1_backups restore latest\n```\n\n### Troubleshooting {#blocks-restic-maintenance-troubleshooting}\n\nIn case something bad happens with a backup, the [official documentation](https://restic.readthedocs.io/en/stable/077_troubleshooting.html) has a lot of tips.\n\n## Tests {#blocks-restic-tests}\n\nSpecific integration tests are defined in [`/test/blocks/restic.nix`](@REPO@/test/blocks/restic.nix).\n\n## Options Reference {#blocks-restic-options}\n\n```{=include=} options\nid-prefix: blocks-restic-options-\nlist-id: selfhostblocks-block-restic-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/restic/dummyModule.nix",
    "content": "{ lib, ... }:\n{\n}\n"
  },
  {
    "path": "modules/blocks/restic.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  utils,\n  ...\n}:\n\nlet\n  cfg = config.shb.restic;\n\n  inherit (lib)\n    concatStringsSep\n    filterAttrs\n    flatten\n    literalExpression\n    optionals\n    listToAttrs\n    mapAttrsToList\n    mkEnableOption\n    mkOption\n    mkMerge\n    ;\n  inherit (lib)\n    hasPrefix\n    mkIf\n    nameValuePair\n    optionalAttrs\n    removePrefix\n    ;\n  inherit (lib.types)\n    attrsOf\n    enum\n    int\n    ints\n    oneOf\n    nonEmptyStr\n    nullOr\n    str\n    submodule\n    ;\n\n  commonOptions =\n    {\n      name,\n      prefix,\n      config,\n      ...\n    }:\n    {\n      enable = mkEnableOption ''\n        SelfHostBlocks' Restic block\n\n        A disabled instance will not backup data anymore\n        but still provides the helper tool to restore snapshots\n      '';\n\n      passphrase = lib.mkOption {\n        description = \"Encryption key for the backup repository.\";\n        type = lib.types.submodule {\n          options = shb.contracts.secret.mkRequester {\n            mode = \"0400\";\n            owner = config.request.user;\n            ownerText = \"[shb.restic.${prefix}.<name>.request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)\";\n            restartUnits = [ \"${fullName name config.settings.repository}.service\" ];\n            restartUnitsText = \"[ [shb.restic.${prefix}.<name>.settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]\";\n          };\n        };\n      };\n\n      repository = mkOption {\n        description = \"Repositories to back this instance to.\";\n        type = submodule {\n          options = {\n            path = mkOption {\n              type = str;\n              description = \"Repository location\";\n            };\n\n            secrets = mkOption {\n              type = attrsOf shb.secretFileType;\n              default = { };\n              description = ''\n                Secrets needed to access the repository where the backups will be stored.\n\n                See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example\n                and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets.\n\n              '';\n              example = literalExpression ''\n                {\n                  AWS_ACCESS_KEY_ID.source = <path/to/secret>;\n                  AWS_SECRET_ACCESS_KEY.source = <path/to/secret>;\n                }\n              '';\n            };\n\n            timerConfig = mkOption {\n              type = attrsOf utils.systemdUtils.unitOptions.unitOption;\n              default = {\n                OnCalendar = \"daily\";\n                Persistent = true;\n              };\n              description = \"When to run the backup. See {manpage}`systemd.timer(5)` for details.\";\n              example = {\n                OnCalendar = \"00:05\";\n                RandomizedDelaySec = \"5h\";\n                Persistent = true;\n              };\n            };\n          };\n        };\n      };\n\n      retention = mkOption {\n        description = \"For how long to keep backup files.\";\n        type = attrsOf (oneOf [\n          int\n          nonEmptyStr\n        ]);\n        default = {\n          keep_within = \"1d\";\n          keep_hourly = 24;\n          keep_daily = 7;\n          keep_weekly = 4;\n          keep_monthly = 6;\n        };\n      };\n\n      limitUploadKiBs = mkOption {\n        type = nullOr int;\n        description = \"Limit upload bandwidth to the given KiB/s amount.\";\n        default = null;\n        example = 8000;\n      };\n\n      limitDownloadKiBs = mkOption {\n        type = nullOr int;\n        description = \"Limit download bandwidth to the given KiB/s amount.\";\n        default = null;\n        example = 8000;\n      };\n    };\n\n  repoSlugName = name: builtins.replaceStrings [ \"/\" \":\" ] [ \"_\" \"_\" ] (removePrefix \"/\" name);\n  fullName = name: repository: \"restic-backups-${name}_${repoSlugName repository.path}\";\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/monitoring.nix\n  ];\n\n  options.shb.restic = {\n    enableDashboard = lib.mkEnableOption \"the Backups SHB dashboard\" // {\n      default = true;\n    };\n\n    instances = mkOption {\n      description = \"Files to backup following the [backup contract](./shb.contracts-backup.html).\";\n      default = { };\n      type = attrsOf (\n        submodule (\n          { name, config, ... }:\n          {\n            options = shb.contracts.backup.mkProvider {\n              settings = mkOption {\n                description = ''\n                  Settings specific to the Restic provider.\n                '';\n\n                type = submodule {\n                  options = commonOptions {\n                    inherit name config;\n                    prefix = \"instances\";\n                  };\n                };\n              };\n\n              resultCfg = {\n                restoreScript = fullName name config.settings.repository;\n                restoreScriptText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}\";\n\n                backupService = \"${fullName name config.settings.repository}.service\";\n                backupServiceText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}.service\";\n              };\n            };\n          }\n        )\n      );\n    };\n\n    databases = mkOption {\n      description = \"Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html).\";\n      default = { };\n      type = attrsOf (\n        submodule (\n          { name, config, ... }:\n          {\n            options = shb.contracts.databasebackup.mkProvider {\n              settings = mkOption {\n                description = ''\n                  Settings specific to the Restic provider.\n                '';\n\n                type = submodule {\n                  options = commonOptions {\n                    inherit name config;\n                    prefix = \"databases\";\n                  };\n                };\n              };\n\n              resultCfg = {\n                restoreScript = fullName name config.settings.repository;\n                restoreScriptText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}\";\n\n                backupService = \"${fullName name config.settings.repository}.service\";\n                backupServiceText = \"${fullName \"<name>\" { path = \"path/to/repository\"; }}.service\";\n              };\n            };\n          }\n        )\n      );\n    };\n\n    # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23\n    performance = mkOption {\n      description = \"Reduce performance impact of backup jobs.\";\n      default = { };\n      type = submodule {\n        options = {\n          niceness = mkOption {\n            type = ints.between (-20) 19;\n            description = \"nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process\";\n            default = 15;\n          };\n          ioSchedulingClass = mkOption {\n            type = enum [\n              \"idle\"\n              \"best-effort\"\n              \"realtime\"\n            ];\n            description = \"ionice scheduling class, defaults to best-effort IO. Only used for `restic backup`, `restic forget` and `restic check` commands.\";\n            default = \"best-effort\";\n          };\n          ioPriority = mkOption {\n            type = nullOr (ints.between 0 7);\n            description = \"ionice priority, defaults to 7 for lowest priority IO. Only used for `restic backup`, `restic forget` and `restic check` commands.\";\n            default = 7;\n          };\n        };\n      };\n    };\n  };\n\n  config = mkIf (cfg.instances != { } || cfg.databases != { }) (\n    let\n      enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances;\n      enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases;\n    in\n    mkMerge [\n      {\n        environment.systemPackages = optionals (enabledInstances != { } || enabledDatabases != { }) [\n          pkgs.restic\n        ];\n      }\n      {\n        # Create repository if it is a local path.\n        systemd.tmpfiles.rules =\n          let\n            mkSettings =\n              name: instance:\n              optionals (hasPrefix \"/\" instance.settings.repository.path) [\n                \"d '${instance.settings.repository.path}' 0750 ${instance.request.user} root - -\"\n              ];\n          in\n          flatten (mapAttrsToList mkSettings (cfg.instances // cfg.databases));\n      }\n      {\n        services.restic.backups =\n          let\n            mkSettings = name: instance: {\n              \"${name}_${repoSlugName instance.settings.repository.path}\" = {\n                inherit (instance.request) user;\n\n                repository = instance.settings.repository.path;\n\n                paths = instance.request.sourceDirectories;\n\n                passwordFile = toString instance.settings.passphrase.result.path;\n\n                initialize = true;\n\n                inherit (instance.settings.repository) timerConfig;\n\n                pruneOpts = mapAttrsToList (\n                  name: value: \"--${builtins.replaceStrings [ \"_\" ] [ \"-\" ] name} ${builtins.toString value}\"\n                ) instance.settings.retention;\n\n                backupPrepareCommand = concatStringsSep \"\\n\" instance.request.hooks.beforeBackup;\n\n                backupCleanupCommand = concatStringsSep \"\\n\" instance.request.hooks.afterBackup;\n\n                extraBackupArgs =\n                  (optionals (instance.settings.limitUploadKiBs != null) [\n                    \"--limit-upload=${toString instance.settings.limitUploadKiBs}\"\n                  ])\n                  ++ (optionals (instance.settings.limitDownloadKiBs != null) [\n                    \"--limit-download=${toString instance.settings.limitDownloadKiBs}\"\n                  ]);\n              }\n              // optionalAttrs (builtins.length instance.request.excludePatterns > 0) {\n                exclude = instance.request.excludePatterns;\n              };\n            };\n          in\n          mkMerge (flatten (mapAttrsToList mkSettings enabledInstances));\n      }\n      {\n        services.restic.backups =\n          let\n            mkSettings = name: instance: {\n              \"${name}_${repoSlugName instance.settings.repository.path}\" = {\n                inherit (instance.request) user;\n\n                repository = instance.settings.repository.path;\n\n                dynamicFilesFrom = \"echo\";\n\n                passwordFile = toString instance.settings.passphrase.result.path;\n\n                initialize = true;\n\n                inherit (instance.settings.repository) timerConfig;\n\n                pruneOpts = mapAttrsToList (\n                  name: value: \"--${builtins.replaceStrings [ \"_\" ] [ \"-\" ] name} ${builtins.toString value}\"\n                ) instance.settings.retention;\n\n                extraBackupArgs =\n                  (optionals (instance.settings.limitUploadKiBs != null) [\n                    \"--limit-upload=${toString instance.settings.limitUploadKiBs}\"\n                  ])\n                  ++ (optionals (instance.settings.limitDownloadKiBs != null) [\n                    \"--limit-download=${toString instance.settings.limitDownloadKiBs}\"\n                  ])\n                  ++ (\n                    let\n                      cmd = pkgs.writeShellScriptBin \"dump.sh\" instance.request.backupCmd;\n                    in\n                    [\n                      \"--stdin-filename ${instance.request.backupName} --stdin-from-command -- ${cmd}/bin/dump.sh\"\n                    ]\n                  );\n              };\n            };\n          in\n          mkMerge (flatten (mapAttrsToList mkSettings enabledDatabases));\n      }\n      {\n        systemd.services =\n          let\n            mkSettings =\n              name: instance:\n              let\n                serviceName = fullName name instance.settings.repository;\n              in\n              {\n                ${serviceName} = mkMerge [\n                  {\n                    serviceConfig = {\n                      Nice = cfg.performance.niceness;\n                      IOSchedulingClass = cfg.performance.ioSchedulingClass;\n                      IOSchedulingPriority = cfg.performance.ioPriority;\n                      # BindReadOnlyPaths = instance.sourceDirectories;\n                    };\n                  }\n                  (optionalAttrs (instance.settings.repository.secrets != { }) {\n                    serviceConfig.EnvironmentFile = [\n                      \"/run/secrets_restic/${serviceName}\"\n                    ];\n                    after = [ \"${serviceName}-pre.service\" ];\n                    requires = [ \"${serviceName}-pre.service\" ];\n                  })\n                ];\n\n                \"${serviceName}-pre\" = mkIf (instance.settings.repository.secrets != { }) (\n                  let\n                    script = shb.genConfigOutOfBandSystemd {\n                      config = instance.settings.repository.secrets;\n                      configLocation = \"/run/secrets_restic/${serviceName}\";\n                      generator = shb.toEnvVar;\n                      user = instance.request.user;\n                    };\n                  in\n                  {\n                    script = script.preStart;\n                    serviceConfig.Type = \"oneshot\";\n                    serviceConfig.LoadCredential = script.loadCredentials;\n                  }\n                );\n              };\n          in\n          mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases)));\n      }\n      {\n        systemd.services =\n          let\n            mkEnv =\n              name: instance:\n              nameValuePair \"${fullName name instance.settings.repository}_restore_gen\" {\n                enable = true;\n                wantedBy = [ \"multi-user.target\" ];\n                serviceConfig.Type = \"oneshot\";\n                script = (\n                  shb.replaceSecrets {\n                    userConfig = instance.settings.repository.secrets // {\n                      RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path;\n                      RESTIC_REPOSITORY = instance.settings.repository.path;\n                    };\n                    resultPath = \"/run/secrets_restic_env/${fullName name instance.settings.repository}\";\n                    generator = shb.toEnvVar;\n                    user = instance.request.user;\n                  }\n                );\n              };\n          in\n          listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases)));\n      }\n      {\n        environment.systemPackages =\n          let\n            mkResticBinary =\n              name: instance:\n              pkgs.writeShellApplication {\n                name = fullName name instance.settings.repository;\n                text = ''\n                  usage() {\n                    echo \"$0 restore latest\"\n                  }\n\n                  if ! [ \"$1\" = \"restore\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  if ! [ \"$1\" = \"latest\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  sudocmd() {\n                    sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} \"$@\"\n                  }\n\n                  set -a\n                  # shellcheck disable=SC1090\n                  source <(sudocmd cat \"/run/secrets_restic_env/${fullName name instance.settings.repository}\")\n                  set +a\n\n                  echo \"Will restore archive 'latest'\"\n\n                  sudocmd ${pkgs.restic}/bin/restic restore latest --target /\n                '';\n              };\n          in\n          flatten (mapAttrsToList mkResticBinary cfg.instances);\n      }\n      {\n        environment.systemPackages =\n          let\n            mkResticBinary =\n              name: instance:\n              pkgs.writeShellApplication {\n                name = fullName name instance.settings.repository;\n                text = ''\n                  usage() {\n                    echo \"$0 restore latest\"\n                  }\n\n                  if ! [ \"$1\" = \"restore\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  if ! [ \"$1\" = \"latest\" ]; then\n                    usage\n                    exit 1\n                  fi\n                  shift\n\n                  sudocmd() {\n                    sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} \"$@\"\n                  }\n\n                  set -a\n                  # shellcheck disable=SC1090\n                  source <(sudocmd cat \"/run/secrets_restic_env/${fullName name instance.settings.repository}\")\n                  set +a\n\n                  echo \"Will restore archive 'latest'\"\n\n                  sudocmd sh -c \"${pkgs.restic}/bin/restic dump latest ${instance.request.backupName} | ${instance.request.restoreCmd}\"\n                '';\n              };\n          in\n          flatten (mapAttrsToList mkResticBinary cfg.databases);\n      }\n\n      (lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) {\n        shb.monitoring.dashboards = [\n          ./backup/dashboard/Backups.json\n        ];\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/blocks/sops/docs/default.md",
    "content": "# SOPS Block {#blocks-sops}\n\nDefined in [`/modules/blocks/sops.nix`](@REPO@/modules/blocks/sops.nix).\n\nThis block sets up a [sops-nix][] secret.\n\nIt is only a small layer on top of `sops-nix` options\nto adapt it to the [secret contract](./contracts-secret.html).\n\n[sops-nix]: https://github.com/Mic92/sops-nix\n\n## Provider Contracts {#blocks-sops-contract-provider}\n\nThis block provides the following contracts:\n\n- [secret contract][] under the [`shb.sops.secret`][secret] option.\n  It is not yet tested with [contract tests][secret contract tests] but it is used extensively on several machines.\n\n[secret]: #blocks-sops-options-shb.sops.secret\n[secret contract]: contracts-secret.html\n[secret contract tests]: @REPO@/test/contracts/secret.nix\n\nAs requested by the contract, when asking for a secret with the `shb.sops` module,\nthe path where the secret will be located can be found under the [`shb.sops.secret.<name>.result`][result] option.\n\n[result]: #blocks-sops-options-shb.sops.secret._name_.result\n\n## Usage {#blocks-sops-usage}\n\nFirst, a file with encrypted secrets must be created by following the [secrets setup section](usage.html#usage-secrets).\n\n### With Requester Module {#blocks-sops-usage-requester}\n\nThis example shows how to use this sops block\nto fulfill the request of a module using the [secret contract][] under the option `services.mymodule.mysecret`.\n\n```nix\nshb.sops.secret.\"mymodule/mysecret\".request = config.services.mymodule.mysecret.request;\nservices.mymodule.mysecret.result = config.shb.sops.secret.\"mymodule/mysecret\".result;\n```\n\n### Manual Module {#blocks-sops-usage-manual}\n\nThe provider module can be used on its own, without a requester module:\n\n```nix\nshb.sops.secret.\"mymodule/mysecret\".request = {\n  mode = \"0400\";\n  owner = \"owner\";\n};\nservices.mymodule.mysecret.path = config.sops.secret.\"mymodule/mysecret\".result.path;\n```\n\n## Options Reference {#blocks-sops-options}\n\n```{=include=} options\nid-prefix: blocks-sops-options-\nlist-id: selfhostblocks-block-sops-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/sops.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\nlet\n  inherit (lib) mapAttrs mkOption;\n  inherit (lib.types) attrsOf anything submodule;\n\n  cfg = config.shb.sops;\nin\n{\n  imports = [\n    ../../lib/module.nix\n  ];\n\n  options.shb.sops = {\n    secret = mkOption {\n      description = \"Secret following the [secret contract](./contracts-secret.html).\";\n      default = { };\n      type = attrsOf (\n        submodule (\n          { name, options, ... }:\n          {\n            options = shb.contracts.secret.mkProvider {\n              settings = mkOption {\n                description = ''\n                  Settings specific to the Sops provider.\n\n                  This is a passthrough option to set [sops-nix options](https://github.com/Mic92/sops-nix/blob/master/modules/sops/default.nix).\n\n                  Note though that the `mode`, `owner`, `group`, and `restartUnits`\n                  are managed by the [shb.sops.secret.<name>.request](#blocks-sops-options-shb.sops.secret._name_.request) option.\n                '';\n\n                type = attrsOf anything;\n                default = { };\n              };\n\n              resultCfg = {\n                path = \"/run/secrets/${name}\";\n                pathText = \"/run/secrets/<name>\";\n              };\n            };\n          }\n        )\n      );\n    };\n  };\n\n  config = {\n    sops.secrets =\n      let\n        mkSecret = n: secretCfg: secretCfg.request // secretCfg.settings;\n      in\n      mapAttrs mkSecret cfg.secret;\n  };\n}\n"
  },
  {
    "path": "modules/blocks/ssl/dashboard/SSL.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 16,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": 0,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"line+area\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 604808\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 3,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true,\n          \"sortBy\": \"Last *\",\n          \"sortDesc\": false\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"min by(exported_hostname, subject, path) (ssl_certificate_expiry_seconds{subject=~\\\"CN=$job\\\"})\",\n          \"legendFormat\": \"{{exported_hostname}}: {{subject}} {{path}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Certificate Remaining Validity\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineStyle\": {\n              \"fill\": \"solid\"\n            },\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"never\",\n            \"showValues\": false,\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": 0\n              }\n            ]\n          },\n          \"unit\": \"dateTimeFromNow\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 0\n      },\n      \"id\": 5,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"sortBy\": \"Last *\",\n          \"sortDesc\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"systemd_timer_next_trigger_seconds{name=~\\\"acme-renew-$job.timer\\\"} * 1000\",\n          \"format\": \"time_series\",\n          \"instant\": false,\n          \"legendFormat\": \"{{name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Schedule\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"fixedColor\": \"green\",\n            \"mode\": \"fixed\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"footer\": {\n              \"reducers\": []\n            },\n            \"inspect\": false\n          },\n          \"decimals\": 0,\n          \"mappings\": [],\n          \"noValue\": \"0\",\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"green\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"% failed\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"unit\",\n                \"value\": \"percentunit\"\n              },\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"mode\": \"gradient\",\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"mode\": \"continuous-GrYlRd\"\n                }\n              },\n              {\n                \"id\": \"max\",\n                \"value\": 1\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"total\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.cellOptions\",\n                \"value\": {\n                  \"mode\": \"gradient\",\n                  \"type\": \"color-background\"\n                }\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"mode\": \"thresholds\"\n                }\n              },\n              {\n                \"id\": \"thresholds\",\n                \"value\": {\n                  \"mode\": \"absolute\",\n                  \"steps\": [\n                    {\n                      \"color\": \"red\",\n                      \"value\": 0\n                    },\n                    {\n                      \"color\": \"transparent\",\n                      \"value\": 1\n                    }\n                  ]\n                }\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byType\",\n              \"options\": \"string\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.minWidth\",\n                \"value\": 150\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byType\",\n              \"options\": \"number\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 100\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 8\n      },\n      \"id\": 4,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"enablePagination\": true,\n        \"frozenColumns\": {},\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"increase(systemd_unit_state{name=~\\\"acme-(order-renew-)?[[job]].service\\\", state=\\\"activating\\\"}[7d])\",\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": false,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"increase(systemd_unit_state{name=~\\\"acme-(order-renew-)?[[job]].service\\\", state=\\\"failed\\\"}[7d])\",\n          \"hide\": false,\n          \"instant\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": false,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"Jobs in the Past Week\",\n      \"transformations\": [\n        {\n          \"id\": \"labelsToFields\",\n          \"options\": {\n            \"mode\": \"columns\"\n          }\n        },\n        {\n          \"id\": \"merge\",\n          \"options\": {}\n        },\n        {\n          \"id\": \"groupingToMatrix\",\n          \"options\": {\n            \"columnField\": \"state\",\n            \"rowField\": \"name\",\n            \"valueField\": \"Value\"\n          }\n        },\n        {\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"alias\": \"total\",\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"activating\"\n                }\n              },\n              \"operator\": \"+\",\n              \"right\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"failed\"\n                }\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"include\": [\n                \"activating\",\n                \"failed\"\n              ],\n              \"reducer\": \"sum\"\n            }\n          }\n        },\n        {\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"alias\": \"% failed\",\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"failed\"\n                }\n              },\n              \"operator\": \"/\",\n              \"right\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"total\"\n                }\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"reducer\": \"sum\"\n            }\n          }\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": true,\n                \"field\": \"total\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": true,\n                \"field\": \"failed\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {},\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {\n              \"activating\": \"success\",\n              \"name\\\\state\": \"Job\"\n            }\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 2,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 8\n      },\n      \"id\": 7,\n      \"options\": {\n        \"code\": {\n          \"language\": \"plaintext\",\n          \"showLineNumbers\": false,\n          \"showMiniMap\": false\n        },\n        \"content\": \"If the log panel is empty, it may be because the amount of lines is too high. Try filtering a few jobs first.\",\n        \"mode\": \"markdown\"\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"title\": \"\",\n      \"type\": \"text\"\n    },\n    {\n      \"datasource\": {\n        \"default\": false,\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 14,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 10\n      },\n      \"id\": 8,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableInfiniteScrolling\": false,\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": true,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": true\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"direction\": \"backward\",\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=~\\\"acme-(order-renew-)?($job).service\\\"}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Logs - $job\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"The job duration is not accurate. Jobs taking less than 15s to run will sometimes appear as taking 100s.\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"fixed\"\n          },\n          \"custom\": {\n            \"axisPlacement\": \"auto\",\n            \"fillOpacity\": 70,\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineWidth\": 0,\n            \"spanNulls\": false\n          },\n          \"mappings\": [\n            {\n              \"options\": {\n                \"1\": {\n                  \"color\": \"green\",\n                  \"index\": 0,\n                  \"text\": \"Running\"\n                }\n              },\n              \"type\": \"value\"\n            }\n          ],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"#EAB839\",\n                \"value\": 0\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 16\n      },\n      \"id\": 6,\n      \"options\": {\n        \"alignValue\": \"left\",\n        \"legend\": {\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"mergeValues\": true,\n        \"rowHeight\": 0.9,\n        \"showValue\": \"never\",\n        \"tooltip\": {\n          \"hideZeros\": false,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"12.2.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"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)\",\n          \"format\": \"time_series\",\n          \"hide\": false,\n          \"instant\": false,\n          \"key\": \"Q-e1d5c07a-8dcc-4f34-aa5c-cdebcbdda322-0\",\n          \"legendFormat\": \"{{name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Job Runs\",\n      \"type\": \"state-timeline\"\n    }\n  ],\n  \"preload\": false,\n  \"schemaVersion\": 42,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": [\n            \"All\"\n          ],\n          \"value\": [\n            \"$__all\"\n          ]\n        },\n        \"definition\": \"label_values(systemd_unit_state{name=~\\\"acme-renew-.*.timer\\\"},name)\",\n        \"includeAll\": true,\n        \"label\": \"Job\",\n        \"multi\": true,\n        \"name\": \"job\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(systemd_unit_state{name=~\\\"acme-renew-.*.timer\\\"},name)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"/acme-renew-(?<value>.*).timer/\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-6h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"browser\",\n  \"title\": \"SSL Certificates\",\n  \"uid\": \"ae818js0bvw8wb\",\n  \"version\": 25\n}\n"
  },
  {
    "path": "modules/blocks/ssl/docs/default.md",
    "content": "# SSL Generator Block {#block-ssl}\n\nThis NixOS module is a block that implements the [SSL certificate generator](contracts-ssl.html) contract.\n\nIt is implemented by:\n- [`shb.certs.cas.selfsigned`][10] and [`shb.certs.certs.selfsigned`][11]: Generates self-signed certificates,\n  including self-signed CA thanks to the [certtool][1] package.\n- [`shb.certs.certs.letsencrypt`][12]: Requests certificates from [Let's Encrypt][2].\n\n[1]: https://search.nixos.org/packages?channel=23.11&show=gnutls&from=0&size=50&sort=relevance&type=packages&query=certtool\n[2]: https://letsencrypt.org/\n\n[10]: blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned\n[11]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned\n[12]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt\n\n## Self-Signed Certificates {#block-ssl-impl-self-signed}\n\nDefined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix).\n\nTo use self-signed certificates, we must first generate at least one Certificate Authority (CA):\n\n```nix\nshb.certs.cas.selfsigned.myca = {\n  name = \"My CA\";\n};\n```\n\nEvery CA defined this way will be concatenated into the file `/etc/ssl/certs/ca-certificates.cert`\nwhich means those CAs and all certificates generated by those CAs will be automatically trusted.\n\nWe can then generate one or more certificates signed by that CA:\n\n```nix\nshb.certs.certs.selfsigned = {\n  \"example.com\" = {\n    ca = config.shb.certs.cas.selfsigned.myca;\n\n    domain = \"example.com\";\n    group = \"nginx\";\n    reloadServices = [ \"nginx.service\" ];\n  };\n  \"www.example.com\" = {\n    ca = config.shb.certs.cas.selfsigned.myca;\n\n    domain = \"www.example.com\";\n    group = \"nginx\";\n  };\n};\n```\n\nThe group has been chosen to be `nginx` to be consistent with the examples further down in this\ndocument.\n\n## Let's Encrypt {#block-ssl-impl-lets-encrypt}\n\nDefined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix).\n\nWe can ask Let's Encrypt to generate a certificate with:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\" = {\n  domain = \"example.com\";\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  dnsProvider = \"linode\";\n  adminEmail = \"admin@example.com\";\n  credentialsFile = /path/to/secret/file;\n  additionalEnvironment = {\n    LINODE_HTTP_TIMEOUT = \"10\";\n    LINODE_POLLING_INTERVAL = \"10\";\n    LINODE_PROPAGATION_TIMEOUT = \"240\";\n  };\n};\n```\n\nThe credential file's content would be a key-value pair:\n\n```yaml\nLINODE_TOKEN=XYZ...\n```\n\nIf you use one subdomain per service,\nasking for certificates for a subdomain is done with:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"nextcloud.${domain}\" ];\n```\n\nFor other providers, see the [official instruction](https://go-acme.github.io/lego/dns/).\n\n## Usage {#block-ssl-usage}\n\nTo use either a self-signed certificates or a Let's Encrypt generated one, we can reference the path\nwhere the certificate and the private key are located:\n\n```nix\nconfig.shb.certs.certs.<implementation>.<name>.paths.cert\nconfig.shb.certs.certs.<implementation>.<name>.paths.key\nconfig.shb.certs.certs.<implementation>.<name>.systemdService\n```\n\nFor example:\n\n```nix\nconfig.shb.certs.certs.selfsigned.\"example.com\".paths.cert\nconfig.shb.certs.certs.selfsigned.\"example.com\".paths.key\nconfig.shb.certs.certs.selfsigned.\"example.com\".systemdService\n```\n\nThe full CA bundle is generated by the following Systemd service, running after each individual\ngenerator finished:\n\n```nix\nconfig.shb.certs.systemdService\n```\n\nSee also the [SSL certificate generator usage](contracts-ssl.html#ssl-contract-usage) for a more detailed usage\nexample.\n\n## Monitoring {#blocks-ssl-monitoring}\n\nA dashboard for SSL certificates is provided.\nSee [SSL Certificates Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-ssl) section in the monitoring chapter.\n\n## Debug {#block-ssl-debug}\n\nEach CA and Cert is generated by a systemd service whose name can be seen in the `systemdService`\noption. You can then see the latest errors messages using `journalctl`.\n\n### Let's Encrypt debug {#blocks-ssl-debug-lets-encrypt}\n\nSince the SHB SSL block uses the [`security.acme`][] module under the hood,\nknowing how that one works can become required if something goes wrong.\n\nFor each domain and subdomain, noted as `fqdn` hereunder,\nthe following systemd timers and services are created:\n\n- `acme-renew-${fqdn}.timer` triggers the `acme-order-renew-${fqdn}.service` service every day.\n- `acme-${fqdn}.service` (re)generate the initial self-signed certificate,\n   only if the following job never succeeded at least once yet.\n- `acme-order-renew-${fqdn}.service` asks for a new certificate\n   only if the certificate will expire in the next 30 days.\n   Has logic to only renew if the list of domains has not changed.\n\nAlso, a global service named `acme-setup.service` is created\n\n[`security.acme`]: https://nixos.org/manual/nixos/stable/#module-security-acme\n\n## Tests {#block-ssl-tests}\n\nThe self-signed implementation is tested in [`/tests/vm/ssl.nix`](@REPO@/tests/vm/ssl.nix).\n\n## Options Reference {#block-ssl-options}\n\n```{=include=} options\nid-prefix: blocks-ssl-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/blocks/ssl.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.certs;\n\n  inherit (builtins) dirOf;\n  inherit (lib)\n    flatten\n    mapAttrsToList\n    optionalAttrs\n    optionals\n    unique\n    ;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ./monitoring.nix\n  ];\n\n  options.shb.certs = {\n    systemdService = lib.mkOption {\n      description = ''\n        Systemd oneshot service used to generate the Certificate Authority bundle.\n      '';\n      type = lib.types.str;\n      default = \"shb-ca-bundle.service\";\n    };\n    enableDashboard = lib.mkEnableOption \"the SSL SHB dashboard\" // {\n      default = true;\n    };\n    cas.selfsigned = lib.mkOption {\n      description = \"Generate a self-signed Certificate Authority.\";\n      default = { };\n      type = lib.types.attrsOf (\n        lib.types.submodule (\n          { config, ... }:\n          {\n            options = {\n              name = lib.mkOption {\n                type = lib.types.str;\n                description = ''\n                  Certificate Authority Name. You can put what you want here, it will be displayed by the\n                  browser.\n                '';\n                default = \"Self Host Blocks Certificate\";\n              };\n\n              paths = lib.mkOption {\n                description = ''\n                  Paths where CA certs will be located.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = shb.contracts.ssl.certs-paths;\n                default = {\n                  key = \"/var/lib/certs/cas/${config._module.args.name}.key\";\n                  cert = \"/var/lib/certs/cas/${config._module.args.name}.cert\";\n                };\n              };\n\n              systemdService = lib.mkOption {\n                description = ''\n                  Systemd oneshot service used to generate the certs.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = lib.types.str;\n                default = \"shb-certs-ca-${config._module.args.name}.service\";\n              };\n            };\n          }\n        )\n      );\n    };\n    certs.selfsigned = lib.mkOption {\n      description = \"Generate self-signed certificates signed by a Certificate Authority.\";\n      default = { };\n      type = lib.types.attrsOf (\n        lib.types.submodule (\n          { config, ... }:\n          {\n            options = {\n              ca = lib.mkOption {\n                type = lib.types.nullOr shb.contracts.ssl.cas;\n                description = ''\n                  CA used to generate this certificate. Only used for self-signed.\n\n                  This contract input takes the contract output of the `shb.certs.cas` SSL block.\n                '';\n                default = null;\n              };\n\n              domain = lib.mkOption {\n                type = lib.types.str;\n                description = ''\n                  Domain to generate a certificate for. This can be a wildcard domain like\n                  `*.example.com`.\n                '';\n                example = \"example.com\";\n              };\n\n              extraDomains = lib.mkOption {\n                type = lib.types.listOf lib.types.str;\n                description = ''\n                  Other domains to generate a certificate for.\n                '';\n                default = [ ];\n                example = lib.literalExpression ''\n                  [\n                    \"sub1.example.com\"\n                    \"sub2.example.com\"\n                  ]\n                '';\n              };\n\n              group = lib.mkOption {\n                type = lib.types.str;\n                description = ''\n                  Unix group owning this certificate.\n                '';\n                default = \"root\";\n                example = \"nginx\";\n              };\n\n              paths = lib.mkOption {\n                description = ''\n                  Paths where certs will be located.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = shb.contracts.ssl.certs-paths;\n                default = {\n                  key = \"/var/lib/certs/selfsigned/${config._module.args.name}.key\";\n                  cert = \"/var/lib/certs/selfsigned/${config._module.args.name}.cert\";\n                };\n              };\n\n              systemdService = lib.mkOption {\n                description = ''\n                  Systemd oneshot service used to generate the certs.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = lib.types.str;\n                default = \"shb-certs-cert-selfsigned-${config._module.args.name}.service\";\n              };\n\n              reloadServices = lib.mkOption {\n                description = ''\n                  The list of systemd services to call `systemctl try-reload-or-restart` on.\n                '';\n                type = lib.types.listOf lib.types.str;\n                default = [ ];\n                example = [ \"nginx.service\" ];\n              };\n            };\n          }\n        )\n      );\n    };\n\n    certs.letsencrypt = lib.mkOption {\n      description = \"Generate certificates signed by [Let's Encrypt](https://letsencrypt.org/).\";\n      default = { };\n      type = lib.types.attrsOf (\n        lib.types.submodule (\n          { config, ... }:\n          {\n            options = {\n              domain = lib.mkOption {\n                type = lib.types.str;\n                description = ''\n                  Domain to generate a certificate for. This can be a wildcard domain like\n                  `*.example.com`.\n                '';\n                example = \"example.com\";\n              };\n\n              extraDomains = lib.mkOption {\n                type = lib.types.listOf lib.types.str;\n                description = ''\n                  Other domains to generate a certificate for.\n                '';\n                default = [ ];\n                example = lib.literalExpression ''\n                  [\n                    \"sub1.example.com\"\n                    \"sub2.example.com\"\n                  ]\n                '';\n              };\n\n              paths = lib.mkOption {\n                description = ''\n                  Paths where certs will be located.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = shb.contracts.ssl.certs-paths;\n                default = {\n                  key = \"/var/lib/acme/${config._module.args.name}/key.pem\";\n                  cert = \"/var/lib/acme/${config._module.args.name}/cert.pem\";\n                };\n              };\n\n              group = lib.mkOption {\n                type = lib.types.nullOr lib.types.str;\n                description = ''\n                  Unix group owning this certificate.\n                '';\n                default = \"acme\";\n                example = \"nginx\";\n              };\n\n              systemdService = lib.mkOption {\n                description = ''\n                  Systemd oneshot service used to generate the certs.\n\n                  This option implements the SSL Generator contract.\n                '';\n                type = lib.types.str;\n                default = \"shb-certs-cert-letsencrypt-${config._module.args.name}.service\";\n              };\n\n              afterAndWants = lib.mkOption {\n                description = ''\n                  Systemd service(s) that must start successfully before attempting to reach acme.\n                '';\n                type = lib.types.listOf lib.types.str;\n                default = [ ];\n                example = lib.literalExpression ''\n                  [ \"dnsmasq.service\" ]\n                '';\n              };\n\n              reloadServices = lib.mkOption {\n                description = ''\n                  The list of systemd services to call `systemctl try-reload-or-restart` on.\n                '';\n                type = lib.types.listOf lib.types.str;\n                default = [ ];\n                example = [ \"nginx.service\" ];\n              };\n\n              dnsProvider = lib.mkOption {\n                description = ''\n                  DNS provider to use.\n\n                  See https://go-acme.github.io/lego/dns/ for the list of supported providers.\n\n                  If null is given, use instead the reverse proxy to validate the domain.\n                '';\n                type = lib.types.nullOr lib.types.str;\n                default = null;\n                example = \"linode\";\n              };\n\n              dnsResolver = lib.mkOption {\n                description = \"IP of a DNS server used to resolve hostnames.\";\n                type = lib.types.str;\n                default = \"8.8.8.8\";\n              };\n\n              credentialsFile = lib.mkOption {\n                type = lib.types.nullOr lib.types.path;\n                description = ''\n                  Credentials file location for the chosen DNS provider.\n\n                  The content of this file must expose environment variables as written in the\n                  [documentation](https://go-acme.github.io/lego/dns/) of each DNS provider.\n\n                  For example, if the documentation says the credential must be located in the environment\n                  variable DNSPROVIDER_TOKEN, then the file content must be:\n\n                  DNSPROVIDER_TOKEN=xyz\n\n                  You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead.\n                '';\n                example = \"/run/secrets/ssl\";\n                default = null;\n              };\n\n              additionalEnvironment = lib.mkOption {\n                type = lib.types.attrsOf lib.types.str;\n                default = { };\n                description = ''\n                  Additional environment variables used to configure the DNS provider.\n\n                  For secrets, use shb.ssl.credentialsFile instead.\n\n                  See the chosen provider's [documentation](https://go-acme.github.io/lego/dns/) for\n                  available options.\n                '';\n                example = lib.literalExpression ''\n                  {\n                    DNSPROVIDER_TIMEOUT = \"10\";\n                    DNSPROVIDER_PROPAGATION_TIMEOUT = \"240\";\n                  }\n                '';\n              };\n\n              makeAvailableToUser = lib.mkOption {\n                type = lib.types.nullOr lib.types.str;\n                description = ''\n                  Make all certificates available to given user.\n                '';\n                default = null;\n              };\n\n              adminEmail = lib.mkOption {\n                description = \"Admin email in case certificate retrieval goes wrong.\";\n                type = lib.types.str;\n              };\n\n              stagingServer = lib.mkOption {\n                description = \"User Let's Encrypt's staging server.\";\n                type = lib.types.bool;\n                default = false;\n              };\n\n              debug = lib.mkOption {\n                description = \"Enable debug logging\";\n                type = lib.types.bool;\n                default = false;\n              };\n            };\n          }\n        )\n      );\n    };\n  };\n\n  config =\n    let\n      serviceName = lib.strings.removeSuffix \".service\";\n    in\n    lib.mkMerge [\n      # Generic assertions\n      {\n        assertions = lib.flatten (\n          lib.mapAttrsToList (\n            _name: certCfg:\n            (\n              let\n                domainInExtraDomains =\n                  (lib.lists.findFirstIndex (x: x == certCfg.domain) null certCfg.extraDomains) != null;\n                firstDuplicateDomain =\n                  (\n                    l:\n                    let\n                      sorted = lib.sort (x: y: x < y) l;\n                      maybeDupe = lib.lists.removePrefix (lib.lists.commonPrefix (lib.uniqueStrings sorted) sorted) sorted;\n                    in\n                    if maybeDupe == [ ] then null else lib.head maybeDupe\n                  )\n                    certCfg.extraDomains;\n              in\n              [\n                {\n                  assertion = !domainInExtraDomains;\n                  message = \"Error in SHB option for domain ${certCfg.domain}: do not repeat the domain name in the `extraDomain` option.\";\n                }\n                {\n                  assertion = firstDuplicateDomain == null;\n                  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}.\";\n                }\n              ]\n            )\n          ) (cfg.certs.selfsigned // cfg.certs.letsencrypt)\n        );\n      }\n      # Config for self-signed CA.\n      {\n        systemd.services = lib.mapAttrs' (\n          _name: caCfg:\n          lib.nameValuePair (serviceName caCfg.systemdService) {\n            wantedBy = [ \"multi-user.target\" ];\n            wants = [ config.shb.certs.systemdService ];\n            before = [ config.shb.certs.systemdService ];\n            serviceConfig.Type = \"oneshot\";\n            serviceConfig.RuntimeDirectory = serviceName caCfg.systemdService;\n            # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix\n            script = ''\n              cd $RUNTIME_DIRECTORY\n\n              cat >ca.template <<EOF\n              organization = \"${caCfg.name}\"\n              cn = \"${caCfg.name}\"\n              expiration_days = 365\n              ca\n              cert_signing_key\n              crl_signing_key\n              EOF\n\n              keyfile=\"${caCfg.paths.key}\"\n              keydir=\"$(dirname -- \"$keyfile\")\"\n              mkdir -p \"$keydir\"\n              [ -f \"$keyfile\" ] || ${pkgs.gnutls}/bin/certtool \\\n                --generate-privkey \\\n                --key-type rsa \\\n                --sec-param High \\\n                --outfile \"$keyfile\"\n              chmod 666 \"$keyfile\"\n\n              certfile=\"${caCfg.paths.cert}\"\n              certdir=\"$(dirname -- \"$certfile\")\"\n              mkdir -p \"$certdir\"\n              [ -f \"$certfile\" ] || ${pkgs.gnutls}/bin/certtool \\\n                --generate-self-signed \\\n                --load-privkey \"$keyfile\" \\\n                --template ca.template \\\n                --outfile \"$certfile\"\n              chmod 666 \"$certfile\"\n            '';\n          }\n        ) cfg.cas.selfsigned;\n      }\n      # Config for self-signed CA bundle.\n      {\n        systemd.services.${serviceName config.shb.certs.systemdService} = (\n          lib.mkIf (cfg.cas.selfsigned != { }) {\n            wantedBy = [ \"multi-user.target\" ];\n            serviceConfig.Type = \"oneshot\";\n            script = ''\n              mkdir -p /etc/ssl/certs\n\n              # This file is automatically idempotent since the source files used are idempotent.\n\n              rm -f /etc/ssl/certs/ca-bundle.crt\n              rm -f /etc/ssl/certs/ca-certificates.crt\n\n              cat /etc/static/ssl/certs/ca-bundle.crt > /etc/ssl/certs/ca-bundle.crt\n              cat /etc/static/ssl/certs/ca-bundle.crt > /etc/ssl/certs/ca-certificates.crt\n              for file in ${\n                lib.concatStringsSep \" \" (mapAttrsToList (_name: caCfg: caCfg.paths.cert) cfg.cas.selfsigned)\n              }; do\n                  cat \"$file\" >> /etc/ssl/certs/ca-bundle.crt\n                  cat \"$file\" >> /etc/ssl/certs/ca-certificates.crt\n              done\n            '';\n          }\n        );\n      }\n      # Config for self-signed cert.\n      {\n        systemd.services = lib.mapAttrs' (\n          _name: certCfg:\n          lib.nameValuePair (serviceName certCfg.systemdService) {\n            after = [ certCfg.ca.systemdService ];\n            requires = [ certCfg.ca.systemdService ];\n            wantedBy = [ \"multi-user.target\" ];\n            serviceConfig.RuntimeDirectory = serviceName certCfg.systemdService;\n            # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix\n            script =\n              let\n                extraDnsNames = lib.strings.concatStringsSep \"\\n\" (map (n: \"dns_name = ${n}\") certCfg.extraDomains);\n                chmod = cert: ''\n                  chown root:${certCfg.group} \"${cert}\"\n                  chmod 640 \"${cert}\"\n                '';\n              in\n              ''\n                cd $RUNTIME_DIRECTORY\n\n                # server cert template\n                cat >server.template <<EOF\n                organization = \"An example company\"\n                cn = \"${certCfg.domain}\"\n                expiration_days = 30\n                dns_name = \"${certCfg.domain}\"\n                ${extraDnsNames}\n                encryption_key\n                signing_key\n                EOF\n\n                keyfile=\"${certCfg.paths.key}\"\n                keydir=\"$(dirname -- \"$keyfile\")\"\n                mkdir -p \"$keydir\"\n                [ -f \"$keyfile\" ] || ${pkgs.gnutls}/bin/certtool \\\n                  --generate-privkey \\\n                  --key-type rsa \\\n                  --sec-param High \\\n                  --outfile \"$keyfile\"\n                ${chmod \"$keyfile\"}\n\n                certfile=\"${certCfg.paths.cert}\"\n                certdir=\"$(dirname -- \"$certfile\")\"\n                mkdir -p \"$certdir\"\n                [ -f \"$certfile\" ] || ${pkgs.gnutls}/bin/certtool \\\n                  --generate-certificate \\\n                  --load-privkey \"$keyfile\" \\\n                  --load-ca-privkey \"${certCfg.ca.paths.key}\" \\\n                  --load-ca-certificate \"${certCfg.ca.paths.cert}\" \\\n                  --template server.template \\\n                  --outfile \"$certfile\"\n                ${chmod \"$certfile\"}\n              '';\n\n            postStart = lib.optionalString (certCfg.reloadServices != [ ]) ''\n              systemctl --no-block try-reload-or-restart ${lib.escapeShellArgs certCfg.reloadServices}\n            '';\n\n            serviceConfig.Type = \"oneshot\";\n            # serviceConfig.User = \"nextcloud\";\n          }\n        ) cfg.certs.selfsigned;\n      }\n      # Config for Let's Encrypt cert.\n      {\n        users.users = lib.mkMerge (\n          mapAttrsToList (name: certCfg: {\n            ${certCfg.makeAvailableToUser}.extraGroups = lib.mkIf (!(isNull certCfg.makeAvailableToUser)) [\n              config.security.acme.defaults.group\n            ];\n          }) cfg.certs.letsencrypt\n        );\n\n        security.acme.acceptTerms = lib.mkIf (cfg.certs.letsencrypt != { }) true;\n\n        security.acme.certs =\n          let\n            extraDomainsCfg =\n              certCfg:\n              map (name: {\n                \"${name}\" = {\n                  email = certCfg.adminEmail;\n                  enableDebugLogs = certCfg.debug;\n                  server = lib.mkIf certCfg.stagingServer \"https://acme-staging-v02.api.letsencrypt.org/directory\";\n                };\n              }) certCfg.extraDomains;\n          in\n          lib.mkMerge (\n            flatten (\n              mapAttrsToList (\n                name: certCfg:\n                [\n                  {\n                    \"${name}\" = {\n                      extraDomainNames = certCfg.extraDomains;\n                      email = certCfg.adminEmail;\n                      enableDebugLogs = certCfg.debug;\n                      server = lib.mkIf certCfg.stagingServer \"https://acme-staging-v02.api.letsencrypt.org/directory\";\n                    }\n                    // lib.optionalAttrs (certCfg.dnsProvider != null) {\n                      inherit (certCfg) dnsProvider dnsResolver;\n                      inherit (certCfg) group reloadServices;\n                      credentialsFile = certCfg.credentialsFile;\n                    };\n                  }\n                ]\n                ++ lib.optionals (certCfg.dnsProvider == null) (extraDomainsCfg certCfg)\n              ) cfg.certs.letsencrypt\n            )\n          );\n\n        services.nginx =\n          let\n            extraDomainsCfg =\n              extraDomains:\n              map (name: {\n                virtualHosts.\"${name}\" = {\n                  # addSSL = true;\n                  enableACME = true;\n                };\n              }) extraDomains;\n          in\n          lib.mkMerge (\n            flatten (\n              mapAttrsToList (\n                name: certCfg:\n                lib.optionals (certCfg.dnsProvider == null) (\n                  [\n                    {\n                      virtualHosts.\"${name}\" = {\n                        # addSSL = true;\n                        enableACME = true;\n                      };\n                    }\n                  ]\n                  ++ extraDomainsCfg certCfg.extraDomains\n                )\n              ) cfg.certs.letsencrypt\n            )\n          );\n\n        systemd.services =\n          let\n            extraDomainsCfg =\n              certCfg:\n              flatten (\n                map (\n                  name:\n                  lib.optionals (certCfg.additionalEnvironment != { } && certCfg.dnsProvider == null) [\n                    {\n                      \"acme-${name}\".environment = certCfg.additionalEnvironment;\n                    }\n                  ]\n                  ++ lib.optionals (certCfg.afterAndWants != [ ] && certCfg.dnsProvider == null) [\n                    {\n                      \"acme-${name}\" = {\n                        after = certCfg.afterAndWants;\n                        wants = certCfg.afterAndWants;\n                      };\n                    }\n                  ]\n                ) certCfg.extraDomains\n              );\n          in\n          lib.mkMerge (\n            flatten (\n              mapAttrsToList (\n                name: certCfg:\n                lib.optionals (certCfg.additionalEnvironment != { } && certCfg.dnsProvider == null) [\n                  {\n                    \"acme-${certCfg.domain}\".environment = certCfg.additionalEnvironment;\n                  }\n                ]\n                ++ lib.optionals (certCfg.afterAndWants != [ ] && certCfg.dnsProvider == null) [\n                  {\n                    \"acme-${certCfg.domain}\" = {\n                      after = certCfg.afterAndWants;\n                      wants = certCfg.afterAndWants;\n                    };\n                  }\n                ]\n                ++ lib.optionals (certCfg.dnsProvider == null) (extraDomainsCfg certCfg)\n              ) cfg.certs.letsencrypt\n            )\n          );\n\n        services.prometheus.exporters.node-cert = optionalAttrs (cfg.certs.letsencrypt != { }) {\n          enable = true;\n          listenAddress = \"127.0.0.1\";\n          user = \"acme\";\n          paths =\n            let\n              pathCfg =\n                name: certCfg:\n                let\n                  mainDomainPaths = map dirOf [\n                    certCfg.paths.cert\n                    certCfg.paths.key\n                  ];\n                  # Not sure this will work for all cases.\n                  mainPath = dirOf (dirOf certCfg.paths.cert);\n                  extraDomainsPath = map (x: \"${mainPath}/${x}\") certCfg.extraDomains;\n                in\n                mainDomainPaths ++ extraDomainsPath;\n            in\n            unique (flatten (mapAttrsToList pathCfg cfg.certs.letsencrypt));\n        };\n\n        services.prometheus.scrapeConfigs =\n          let\n            scrapeCfg = name: certCfg: [\n              {\n                job_name = \"node-cert-${name}\";\n                static_configs = [\n                  {\n                    targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.node-cert.port}\" ];\n                    labels = {\n                      \"hostname\" = config.networking.hostName;\n                      \"domain\" = certCfg.domain;\n                    };\n                  }\n                ];\n              }\n            ];\n          in\n          optionals (cfg.certs.letsencrypt != { }) (flatten (mapAttrsToList scrapeCfg cfg.certs.letsencrypt));\n      }\n      (lib.mkIf (cfg.enableDashboard && (cfg.certs.selfsigned != { } || cfg.certs.letsencrypt != { })) {\n        shb.monitoring.dashboards = [\n          ./ssl/dashboard/SSL.json\n        ];\n      })\n    ];\n}\n"
  },
  {
    "path": "modules/blocks/tinyproxy.nix",
    "content": "# Inspired from https://github.com/NixOS/nixpkgs/pull/231152 but made it so we can have multiple instances.\n{\n  config,\n  lib,\n  pkgs,\n  ...\n}:\n\nwith lib;\n\nlet\n  cfg = config.shb.tinyproxy;\n\n  mkValueStringTinyproxy =\n    with lib;\n    v:\n    if true == v then\n      \"yes\"\n    else if false == v then\n      \"no\"\n    else\n      generators.mkValueStringDefault { } v;\n\n  mkKeyValueTinyproxy =\n    {\n      mkValueString ? mkValueStringDefault { },\n    }:\n    sep: k: v:\n    if null == v then \"\" else \"${lib.strings.escape [ sep ] k}${sep}${mkValueString v}\";\n\n  settingsFormat = (\n    pkgs.formats.keyValue {\n      mkKeyValue = mkKeyValueTinyproxy {\n        mkValueString = mkValueStringTinyproxy;\n      } \" \";\n      listsAsDuplicateKeys = true;\n    }\n  );\n\n  configFile = name: cfg: settingsFormat.generate \"tinyproxy-${name}.conf\" cfg.settings;\nin\n{\n  options =\n    let\n      instanceOption = types.submodule {\n        options = {\n          enable = mkEnableOption \"Tinyproxy daemon\";\n\n          package = mkPackageOption pkgs \"tinyproxy\" { };\n\n          dynamicBindFile = mkOption {\n            description = ''\n              File holding the IP to bind to.\n            '';\n            default = \"\";\n          };\n\n          settings = mkOption {\n            description = ''\n              Configuration for [tinyproxy](https://tinyproxy.github.io/).\n            '';\n            default = { };\n            example = literalExpression ''\n              {\n                          Port 8888;\n                          Listen 127.0.0.1;\n                          Timeout 600;\n                          Allow 127.0.0.1;\n                          Anonymous = ['\"Host\"' '\"Authorization\"'];\n                          ReversePath = '\"/example/\" \"http://www.example.com/\"';\n                          }'';\n            type = types.submodule (\n              { name, ... }:\n              {\n                freeformType = settingsFormat.type;\n                options = {\n                  Listen = mkOption {\n                    type = types.str;\n                    default = \"127.0.0.1\";\n                    description = ''\n                      Specify which address to listen to.\n                    '';\n                  };\n                  Port = mkOption {\n                    type = types.int;\n                    default = 8888;\n                    description = ''\n                      Specify which port to listen to.\n                    '';\n                  };\n                  Anonymous = mkOption {\n                    type = types.listOf types.str;\n                    default = [ ];\n                    description = ''\n                      If an `Anonymous` keyword is present, then anonymous proxying is enabled. The\n                      headers listed with `Anonymous` are allowed through, while all others are denied.\n                      If no Anonymous keyword is present, then all headers are allowed through. You must\n                      include quotes around the headers.\n                    '';\n                  };\n                  Filter = mkOption {\n                    type = types.nullOr types.path;\n                    default = null;\n                    description = ''\n                      Tinyproxy supports filtering of web sites based on URLs or domains. This option\n                      specifies the location of the file containing the filter rules, one rule per line.\n                    '';\n                  };\n                };\n              }\n            );\n          };\n        };\n      };\n    in\n    {\n      shb.tinyproxy = mkOption {\n        description = \"Tinyproxy instances.\";\n        default = { };\n        type = types.attrsOf instanceOption;\n      };\n    };\n\n  config = {\n    systemd.services =\n      let\n        instanceConfig =\n          name: c:\n          mkIf c.enable {\n            \"tinyproxy-${name}\" = {\n              description = \"TinyProxy daemon - instance ${name}\";\n              after = [ \"network.target\" ];\n              wantedBy = [ \"multi-user.target\" ];\n              serviceConfig = {\n                User = \"tinyproxy\";\n                Group = \"tinyproxy\";\n                Type = \"simple\";\n                ExecStart = \"${getExe c.package} -d -c /etc/tinyproxy/${name}.conf\";\n                ExecReload = \"${pkgs.coreutils}/bin/kill -SIGHUP $MAINPID\";\n                KillSignal = \"SIGINT\";\n                TimeoutStopSec = \"30s\";\n                Restart = \"on-failure\";\n                RestartSec = \"1s\";\n                RestartSteps = \"3\";\n                RestartMaxDelaySec = \"10s\";\n                ConfigurationDirectory = \"tinyproxy\";\n              };\n              preStart = concatStringsSep \"\\n\" (\n                [\n                  \"cat ${configFile name c} > /etc/tinyproxy/${name}.conf\"\n                ]\n                ++ optionals (c.dynamicBindFile != \"\") [\n                  \"echo -n 'Bind ' >> /etc/tinyproxy/${name}.conf\"\n                  \"cat ${c.dynamicBindFile} >> /etc/tinyproxy/${name}.conf\"\n                ]\n              );\n            };\n          };\n      in\n      mkMerge (mapAttrsToList instanceConfig cfg);\n\n    users.users.tinyproxy = {\n      group = \"tinyproxy\";\n      isSystemUser = true;\n    };\n    users.groups.tinyproxy = { };\n  };\n\n  meta.maintainers = with maintainers; [ tcheronneau ];\n}\n"
  },
  {
    "path": "modules/blocks/vpn.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  ...\n}:\n\nlet\n  cfg = config.shb.vpn;\n\n  quoteEach = lib.concatMapStrings (x: ''\"${x}\"'');\n\n  nordvpnConfig =\n    {\n      name,\n      dev,\n      authFile,\n      remoteServerIP,\n      dependentServices ? [ ],\n    }:\n    ''\n      client\n      dev ${dev}\n      proto tcp\n      remote ${remoteServerIP} 443\n      resolv-retry infinite\n      remote-random\n      nobind\n      tun-mtu 1500\n      tun-mtu-extra 32\n      mssfix 1450\n      persist-key\n      persist-tun\n      ping 15\n      ping-restart 0\n      ping-timer-rem\n      reneg-sec 0\n      comp-lzo no\n\n      status /tmp/openvpn/${name}.status\n\n      remote-cert-tls server\n\n      auth-user-pass ${authFile}\n      verb 3\n      pull\n      fast-io\n      cipher AES-256-CBC\n      auth SHA512\n\n      script-security 2\n      route-noexec\n      route-up ${routeUp name dependentServices}/bin/routeUp.sh\n      down ${routeDown name dependentServices}/bin/routeDown.sh\n\n      <ca>\n      -----BEGIN CERTIFICATE-----\n      MIIFCjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQ0FADA5MQswCQYDVQQGEwJQQTEQ\n      MA4GA1UEChMHTm9yZFZQTjEYMBYGA1UEAxMPTm9yZFZQTiBSb290IENBMB4XDTE2\n      MDEwMTAwMDAwMFoXDTM1MTIzMTIzNTk1OVowOTELMAkGA1UEBhMCUEExEDAOBgNV\n      BAoTB05vcmRWUE4xGDAWBgNVBAMTD05vcmRWUE4gUm9vdCBDQTCCAiIwDQYJKoZI\n      hvcNAQEBBQADggIPADCCAgoCggIBAMkr/BYhyo0F2upsIMXwC6QvkZps3NN2/eQF\n      kfQIS1gql0aejsKsEnmY0Kaon8uZCTXPsRH1gQNgg5D2gixdd1mJUvV3dE3y9FJr\n      XMoDkXdCGBodvKJyU6lcfEVF6/UxHcbBguZK9UtRHS9eJYm3rpL/5huQMCppX7kU\n      eQ8dpCwd3iKITqwd1ZudDqsWaU0vqzC2H55IyaZ/5/TnCk31Q1UP6BksbbuRcwOV\n      skEDsm6YoWDnn/IIzGOYnFJRzQH5jTz3j1QBvRIuQuBuvUkfhx1FEwhwZigrcxXu\n      MP+QgM54kezgziJUaZcOM2zF3lvrwMvXDMfNeIoJABv9ljw969xQ8czQCU5lMVmA\n      37ltv5Ec9U5hZuwk/9QO1Z+d/r6Jx0mlurS8gnCAKJgwa3kyZw6e4FZ8mYL4vpRR\n      hPdvRTWCMJkeB4yBHyhxUmTRgJHm6YR3D6hcFAc9cQcTEl/I60tMdz33G6m0O42s\n      Qt/+AR3YCY/RusWVBJB/qNS94EtNtj8iaebCQW1jHAhvGmFILVR9lzD0EzWKHkvy\n      WEjmUVRgCDd6Ne3eFRNS73gdv/C3l5boYySeu4exkEYVxVRn8DhCxs0MnkMHWFK6\n      MyzXCCn+JnWFDYPfDKHvpff/kLDobtPBf+Lbch5wQy9quY27xaj0XwLyjOltpiST\n      LWae/Q4vAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqG\n      SIb3DQEBDQUAA4ICAQC9fUL2sZPxIN2mD32VeNySTgZlCEdVmlq471o/bDMP4B8g\n      nQesFRtXY2ZCjs50Jm73B2LViL9qlREmI6vE5IC8IsRBJSV4ce1WYxyXro5rmVg/\n      k6a10rlsbK/eg//GHoJxDdXDOokLUSnxt7gk3QKpX6eCdh67p0PuWm/7WUJQxH2S\n      DxsT9vB/iZriTIEe/ILoOQF0Aqp7AgNCcLcLAmbxXQkXYCCSB35Vp06u+eTWjG0/\n      pyS5V14stGtw+fA0DJp5ZJV4eqJ5LqxMlYvEZ/qKTEdoCeaXv2QEmN6dVqjDoTAo\n      k0t5u4YRXzEVCfXAC3ocplNdtCA72wjFJcSbfif4BSC8bDACTXtnPC7nD0VndZLp\n      +RiNLeiENhk0oTC+UVdSc+n2nJOzkCK0vYu0Ads4JGIB7g8IB3z2t9ICmsWrgnhd\n      NdcOe15BincrGA8avQ1cWXsfIKEjbrnEuEk9b5jel6NfHtPKoHc9mDpRdNPISeVa\n      wDBM1mJChneHt59Nh8Gah74+TM1jBsw4fhJPvoc7Atcg740JErb904mZfkIEmojC\n      VPhBHVQ9LHBAdM8qFI2kRK0IynOmAZhexlP/aT/kpEsEPyaZQlnBn3An1CRz8h0S\n      PApL8PytggYKeQmRhl499+6jLxcZ2IegLfqq41dzIjwHwTMplg+1pKIOVojpWA==\n      -----END CERTIFICATE-----\n      </ca>\n      key-direction 1\n      <tls-auth>\n      #\n      # 2048 bit OpenVPN static key\n      #\n      -----BEGIN OpenVPN Static key V1-----\n      e685bdaf659a25a200e2b9e39e51ff03\n      0fc72cf1ce07232bd8b2be5e6c670143\n      f51e937e670eee09d4f2ea5a6e4e6996\n      5db852c275351b86fc4ca892d78ae002\n      d6f70d029bd79c4d1c26cf14e9588033\n      cf639f8a74809f29f72b9d58f9b8f5fe\n      fc7938eade40e9fed6cb92184abb2cc1\n      0eb1a296df243b251df0643d53724cdb\n      5a92a1d6cb817804c4a9319b57d53be5\n      80815bcfcb2df55018cc83fc43bc7ff8\n      2d51f9b88364776ee9d12fc85cc7ea5b\n      9741c4f598c485316db066d52db4540e\n      212e1518a9bd4828219e24b20d88f598\n      a196c9de96012090e333519ae18d3509\n      9427e7b372d348d352dc4c85e18cd4b9\n      3f8a56ddb2e64eb67adfc9b337157ff4\n      -----END OpenVPN Static key V1-----\n      </tls-auth>\n    '';\n\n  routeUp =\n    name: dependentServices:\n    pkgs.writeShellApplication {\n      name = \"routeUp.sh\";\n\n      runtimeInputs = [\n        pkgs.iproute2\n        pkgs.systemd\n        pkgs.nettools\n      ];\n\n      text = ''\n        echo \"Running route-up...\"\n\n        echo \"dev=''${dev:?}\"\n        echo \"ifconfig_local=''${ifconfig_local:?}\"\n        echo \"route_vpn_gateway=''${route_vpn_gateway:?}\"\n\n        set -x\n\n        ip rule\n        ip rule add from \"''${ifconfig_local:?}/32\" table ${name}\n        ip rule add to \"''${route_vpn_gateway:?}/32\" table ${name}\n        ip rule\n\n        ip route list table ${name} || :\n        retVal=$?\n        if [ $retVal -eq 2 ]; then\n          echo \"table is empty\"\n        elif [ $retVal -ne 0 ]; then\n          exit 1\n        fi\n        ip route add default via \"''${route_vpn_gateway:?}\" dev \"''${dev:?}\" table ${name}\n        ip route flush cache\n        ip route list table ${name} || :\n        retVal=$?\n        if [ $retVal -eq 2 ]; then\n          echo \"table is empty\"\n        elif [ $retVal -ne 0 ]; then\n          exit 1\n        fi\n\n        echo \"''${ifconfig_local:?}\" > /run/openvpn/${name}/ifconfig_local\n\n        dependencies=(${quoteEach dependentServices})\n        for i in \"''${dependencies[@]}\"; do\n            systemctl restart \"$i\" || :\n        done\n\n        echo \"Running route-up DONE\"\n      '';\n    };\n\n  routeDown =\n    name: dependentServices:\n    pkgs.writeShellApplication {\n      name = \"routeDown.sh\";\n\n      runtimeInputs = [\n        pkgs.iproute2\n        pkgs.systemd\n        pkgs.nettools\n        pkgs.coreutils\n      ];\n\n      text = ''\n        echo \"Running route-down...\"\n\n        echo \"dev=''${dev:?}\"\n        echo \"ifconfig_local=''${ifconfig_local:?}\"\n        echo \"route_vpn_gateway=''${route_vpn_gateway:?}\"\n\n        set -x\n\n        ip rule\n        ip rule del from \"''${ifconfig_local:?}/32\" table ${name}\n        ip rule del to \"''${route_vpn_gateway:?}/32\" table ${name}\n        ip rule\n\n        # This will probably fail because the dev is already gone.\n        ip route list table ${name} || :\n        retVal=$?\n        if [ $retVal -eq 2 ]; then\n          echo \"table is empty\"\n        elif [ $retVal -ne 0 ]; then\n          exit 1\n        fi\n        ip route del default via \"''${route_vpn_gateway:?}\" dev \"''${dev:?}\" table ${name} || :\n        ip route flush cache\n        ip route list table ${name} || :\n        retVal=$?\n        if [ $retVal -eq 2 ]; then\n          echo \"table is empty\"\n        elif [ $retVal -ne 0 ]; then\n          exit 1\n        fi\n\n        rm /run/openvpn/${name}/ifconfig_local\n\n        dependencies=(${quoteEach dependentServices})\n        for i in \"''${dependencies[@]}\"; do\n            systemctl stop \"$i\" || :\n        done\n\n        echo \"Running route-down DONE\"\n      '';\n    };\nin\n{\n  options =\n    let\n      instanceOption = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"OpenVPN config\";\n\n          package = lib.mkPackageOption pkgs \"openvpn\" { };\n\n          provider = lib.mkOption {\n            description = \"VPN provider, if given uses ready-made configuration.\";\n            type = lib.types.nullOr (lib.types.enum [ \"nordvpn\" ]);\n            default = null;\n          };\n\n          dev = lib.mkOption {\n            description = \"Name of the interface.\";\n            type = lib.types.str;\n            example = \"tun0\";\n          };\n\n          routingNumber = lib.mkOption {\n            description = \"Unique number used to route packets.\";\n            type = lib.types.int;\n            example = 10;\n          };\n\n          remoteServerIP = lib.mkOption {\n            description = \"IP of the VPN server to connect to.\";\n            type = lib.types.str;\n          };\n\n          authFile = lib.mkOption {\n            description = \"Location of file holding authentication secrets for provider.\";\n            type = lib.types.anything;\n          };\n\n          proxyPort = lib.mkOption {\n            description = \"If not null, sets up a proxy that listens on the given port and sends traffic to the VPN.\";\n            type = lib.types.nullOr lib.types.int;\n            default = null;\n          };\n        };\n      };\n    in\n    {\n      shb.vpn = lib.mkOption {\n        description = \"OpenVPN instances.\";\n        default = { };\n        type = lib.types.attrsOf instanceOption;\n      };\n    };\n\n  config = {\n    services.openvpn.servers =\n      let\n        instanceConfig =\n          name: c:\n          lib.mkIf c.enable {\n            ${name} = {\n              autoStart = true;\n\n              up = \"mkdir -p /run/openvpn/${name}\";\n\n              config = nordvpnConfig {\n                inherit name;\n                inherit (c) dev remoteServerIP authFile;\n                dependentServices = lib.optional (c.proxyPort != null) \"tinyproxy-${name}.service\";\n              };\n            };\n          };\n      in\n      lib.mkMerge (lib.mapAttrsToList instanceConfig cfg);\n\n    systemd.tmpfiles.rules = map (name: \"d /tmp/openvpn/${name}.status 0700 root root\") (\n      lib.attrNames cfg\n    );\n\n    networking.iproute2.enable = true;\n    networking.iproute2.rttablesExtraConfig = lib.concatStringsSep \"\\n\" (\n      lib.mapAttrsToList (name: c: \"${toString c.routingNumber} ${name}\") cfg\n    );\n\n    shb.tinyproxy =\n      let\n        instanceConfig =\n          name: c:\n          lib.mkIf (c.enable && c.proxyPort != null) {\n            ${name} = {\n              enable = true;\n              # package = pkgs.tinyproxy.overrideAttrs (old: {\n              #   withDebug = false;\n              #   patches = old.patches ++ [\n              #     (pkgs.fetchpatch {\n              #       name = \"\";\n              #       url = \"https://github.com/tinyproxy/tinyproxy/pull/494/commits/2532ba09896352b31f3538d7819daa1fc3f829f1.patch\";\n              #       sha256 = \"sha256-Q0MkHnttW8tH3+hoCt9ACjHjmmZQgF6pC/menIrU0Co=\";\n              #     })\n              #   ];\n              # });\n              dynamicBindFile = \"/run/openvpn/${name}/ifconfig_local\";\n              settings = {\n                Port = c.proxyPort;\n                Listen = \"127.0.0.1\";\n                Syslog = \"On\";\n                LogLevel = \"Info\";\n                Allow = [\n                  \"127.0.0.1\"\n                  \"::1\"\n                ];\n                ViaProxyName = ''\"tinyproxy\"'';\n              };\n            };\n          };\n      in\n      lib.mkMerge (lib.mapAttrsToList instanceConfig cfg);\n  };\n}\n"
  },
  {
    "path": "modules/blocks/zfs.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  ...\n}:\n\nlet\n  cfg = config.shb.zfs;\nin\n{\n  options.shb.zfs = {\n    defaultPoolName = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      default = null;\n      description = \"ZFS pool name datasets should be created on if no pool name is given in the dataset.\";\n    };\n\n    datasets = lib.mkOption {\n      description = ''\n        ZFS Datasets.\n\n        Each entry in the attrset will be created and mounted in the given path.\n        The attrset name is the dataset name.\n\n        This block implements the following contracts:\n          - mount\n      '';\n      default = { };\n      example = lib.literalExpression ''\n        shb.zfs.\"safe/postgresql\".path = \"/var/lib/postgresql\";\n      '';\n      type = lib.types.attrsOf (\n        lib.types.submodule {\n          options = {\n            enable = lib.mkEnableOption \"shb.zfs.datasets\";\n\n            poolName = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              default = null;\n              description = \"ZFS pool name this dataset should be created on. Overrides the defaultPoolName.\";\n            };\n\n            path = lib.mkOption {\n              type = lib.types.str;\n              description = \"Path this dataset should be mounted on. If the string 'none' is given, the dataset will not be mounted.\";\n            };\n\n            mode = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"If non null, unix mode to apply to the dataset root folder.\";\n              default = null;\n              example = \"ug=rwx,g+s\";\n            };\n\n            owner = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"If non null, unix user to apply to the dataset root folder.\";\n              default = null;\n              example = \"syncthing\";\n            };\n\n            group = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = \"If non null, unix group to apply to the dataset root folder.\";\n              default = null;\n              example = \"syncthing\";\n            };\n\n            defaultACLs = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              description = ''\n                If non null, default ACL to set on the dataset root folder.\n\n                Executes \"setfacl -d -m $acl $path\"\n              '';\n              default = null;\n              example = \"g:syncthing:rwX\";\n            };\n          };\n        }\n      );\n    };\n  };\n\n  config = {\n    assertions = [\n      {\n        assertion =\n          lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets)\n          -> cfg.defaultPoolName != null;\n        message = \"Cannot have both datasets.poolName and defaultPoolName set to null\";\n      }\n    ];\n\n    system.activationScripts = lib.mapAttrs' (\n      name: cfg':\n      let\n        dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + \"/\" + name;\n      in\n      lib.attrsets.nameValuePair \"zfsCreate-${name}\" {\n        text = ''\n          ${pkgs.zfs}/bin/zfs list ${dataset} > /dev/null 2>&1 \\\n            || ${pkgs.zfs}/bin/zfs create \\\n               -o mountpoint=none \\\n               ${dataset} || :\n\n          [ \"$(${pkgs.zfs}/bin/zfs get -H mountpoint -o value ${dataset})\" = ${cfg'.path} ] \\\n            || ${pkgs.zfs}/bin/zfs set \\\n               mountpoint=\"${cfg'.path}\" \\\n               ${dataset}\n\n        ''\n        + lib.optionalString (cfg'.path != \"none\" && cfg'.mode != null) ''\n          chmod \"${cfg'.mode}\" \"${cfg'.path}\"\n        ''\n        + lib.optionalString (cfg'.path != \"none\" && cfg'.owner != null) ''\n          chown \"${cfg'.owner}\" \"${cfg'.path}\"\n        ''\n        + lib.optionalString (cfg'.path != \"none\" && cfg'.group != null) ''\n          chown :\"${cfg'.group}\" \"${cfg'.path}\"\n        ''\n        + lib.optionalString (cfg'.path != \"none\" && cfg'.defaultACLs != null) ''\n          ${pkgs.acl}/bin/setfacl -d -m \"${cfg'.defaultACLs}\" \"${cfg'.path}\"\n        '';\n      }\n    ) cfg.datasets;\n  };\n}\n"
  },
  {
    "path": "modules/contracts/backup/docs/default.md",
    "content": "# Backup Contract {#contract-backup}\n\nThis NixOS contract represents a backup job\nthat will backup one or more files or directories\non a regular schedule.\n\nIt is a contract between a service that has files to be backed up\nand a service that backs up files.\n\n## Contract Reference {#contract-backup-options}\n\nThese are all the options that are expected to exist for this contract to be respected.\n\n```{=include=} options\nid-prefix: contracts-backup-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n\n## Usage {#contract-backup-usage}\n\nA service that can be backed up will provide a `backup` option.\nSuch a service is a `requester` providing a `request` for a module `provider` of this contract. \n\nWhat this option defines is, from the user perspective - that is _you_ - an implementation detail\nbut it will at least define what directories to backup,\nthe user to backup with\nand possibly hooks to run before or after the backup job runs.\n\nHere is an example module defining such a `backup` option:\n\n```nix\n{\n  options = {\n    myservice.backup = mkOption {\n      type = lib.types.submodule {\n        options = contracts.backup.request;\n      };\n      default = {\n        user = \"myservice\";\n        sourceDirectories = [\n          \"/var/lib/myservice\"\n        ];\n      };\n    };\n  };\n};\n```\n\nNow, on the other side we have a service that uses this `backup` option and actually backs up files.\nThis service is a `provider` of this contract and will provide a `result` option.\n\nLet's assume such a module is available under the `backupservice` option\nand that one can create multiple backup instances under `backupservice.instances`.\nThen, to actually backup the `myservice` service, one would write:\n\n```nix\nbackupservice.instances.myservice = {\n  request = myservice.backup;\n  \n  settings = {\n    enable = true;\n\n    repository = {\n      path = \"/srv/backup/myservice\";\n    };\n\n    # ... Other options specific to backupservice like scheduling.\n  };\n};\n```\n\nIt is advised to backup files to different location, to improve redundancy.\nThanks to using contracts, this can be made easily either with the same `backupservice`:\n\n```nix\nbackupservice.instances.myservice_2 = {\n  request = myservice.backup;\n  \n  settings = {\n    enable = true;\n  \n    repository = {\n      path = \"<remote path>\";\n    };\n  };\n};\n```\n\nOr with another module `backupservice_2`!\n\n## Providers of the Backup Contract {#contract-backup-providers}\n\n- [Restic block](blocks-restic.html).\n- [Borgbackup block](blocks-borgbackup.html) [WIP].\n\n## Requester Blocks and Services {#contract-backup-requesters}\n\n- <!-- [ -->Audiobookshelf<!-- ](services-audiobookshelf.html). --> (no manual yet)\n- <!-- [ -->Deluge<!--](services-deluge.html). --> (no manual yet)\n- <!-- [ -->Grocy<!--](services-grocy.html). --> (no manual yet)\n- <!-- [ -->Hledger<!--](services-hledger.html). --> (no manual yet)\n- <!-- [ -->Home Assistant<!--](services-home-assistant.html). --> (no manual yet)\n- <!-- [ -->Jellyfin<!--](services-jellyfin.html). --> (no manual yet)\n- <!-- [ -->LLDAP<!--](blocks-ldap.html). --> (no manual yet)\n- [Nextcloud](services-nextcloud.html#services-nextcloudserver-usage-backup).\n- [Vaultwarden](services-vaultwarden.html#services-vaultwarden-backup).\n- <!-- [ -->*arr<!--](services-arr.html). --> (no manual yet)\n"
  },
  {
    "path": "modules/contracts/backup/dummyModule.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\nin\n{\n  imports = [\n    ../../../lib/module.nix\n  ];\n\n  options.shb.contracts.backup = mkOption {\n    description = ''\n      Contract for backing up files\n      between a requester module and a provider module.\n\n      The requester communicates to the provider\n      what files to backup\n      through the `request` options.\n\n      The provider reads from the `request` options\n      and backs up the requested files.\n      It communicates to the requester what script is used\n      to backup and restore the files\n      through the `result` options.\n    '';\n\n    type = submodule {\n      options = shb.contracts.backup.contract;\n    };\n  };\n}\n"
  },
  {
    "path": "modules/contracts/backup/test.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  inherit (lib)\n    concatMapStringsSep\n    getAttrFromPath\n    mkIf\n    optionalAttrs\n    setAttrByPath\n    ;\nin\n{\n  name,\n  providerRoot,\n  modules ? [ ],\n  username ? \"me\",\n  sourceDirectories ? [\n    \"/opt/files/A\"\n    \"/opt/files/B\"\n  ],\n  settings, # { repository, config } -> attrset\n  extraConfig ? null, # { username, config } -> attrset\n}:\nshb.test.runNixOSTest {\n  inherit name;\n\n  nodes.machine =\n    { config, ... }:\n    {\n      imports = [ shb.test.baseImports ] ++ modules;\n\n      config = lib.mkMerge [\n        (setAttrByPath providerRoot {\n          request = {\n            inherit sourceDirectories;\n            user = username;\n          };\n          settings = settings {\n            inherit config;\n            repository = \"/opt/repos/${name}\";\n          };\n        })\n        (mkIf (username != \"root\") {\n          users.users.${username} = {\n            isSystemUser = true;\n            extraGroups = [ \"sudoers\" ];\n            group = \"root\";\n          };\n        })\n        (optionalAttrs (extraConfig != null) (extraConfig {\n          inherit username config;\n        }))\n      ];\n    };\n\n  extraPythonPackages = p: [ p.dictdiffer ];\n  skipTypeCheck = true;\n\n  testScript =\n    { nodes, ... }:\n    let\n      provider = (getAttrFromPath providerRoot nodes.machine).result;\n    in\n    ''\n      from dictdiffer import diff\n\n      username = \"${username}\"\n      sourceDirectories = [ ${concatMapStringsSep \", \" (x: ''\"${x}\"'') sourceDirectories} ]\n\n      def list_files(dir):\n          files_and_content = {}\n\n          files = machine.succeed(f\"\"\"find {dir} -type f\"\"\").split(\"\\n\")[:-1]\n\n          for f in files:\n              content = machine.succeed(f\"\"\"cat {f}\"\"\").strip()\n              files_and_content[f] = content\n\n          return files_and_content\n\n      def assert_files(dir, files):\n          result = list(diff(list_files(dir), files))\n          if len(result) > 0:\n              raise Exception(\"Unexpected files:\", result)\n\n      with subtest(\"Create initial content\"):\n          for path in sourceDirectories:\n              machine.succeed(f\"\"\"\n                  mkdir -p {path}\n                  echo repo_fileA_1 > {path}/fileA\n                  echo repo_fileB_1 > {path}/fileB\n\n                  chown {username}: -R {path}\n                  chmod go-rwx -R {path}\n              \"\"\")\n\n          for path in sourceDirectories:\n              assert_files(path, {\n                  f'{path}/fileA': 'repo_fileA_1',\n                  f'{path}/fileB': 'repo_fileB_1',\n              })\n\n      with subtest(\"First backup in repo\"):\n          print(machine.succeed(\"systemctl cat ${provider.backupService}\"))\n          machine.succeed(\"systemctl start ${provider.backupService}\")\n\n      with subtest(\"New content\"):\n          for path in sourceDirectories:\n              machine.succeed(f\"\"\"\n                echo repo_fileA_2 > {path}/fileA\n                echo repo_fileB_2 > {path}/fileB\n                \"\"\")\n\n              assert_files(path, {\n                  f'{path}/fileA': 'repo_fileA_2',\n                  f'{path}/fileB': 'repo_fileB_2',\n              })\n\n      with subtest(\"Delete content\"):\n          for path in sourceDirectories:\n              machine.succeed(f\"\"\"rm -r {path}/*\"\"\")\n\n              assert_files(path, {})\n\n      with subtest(\"Restore initial content from repo\"):\n          machine.succeed(\"\"\"${provider.restoreScript} restore latest\"\"\")\n\n          for path in sourceDirectories:\n              assert_files(path, {\n                  f'{path}/fileA': 'repo_fileA_1',\n                  f'{path}/fileB': 'repo_fileB_1',\n              })\n    '';\n}\n"
  },
  {
    "path": "modules/contracts/backup.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib)\n    concatStringsSep\n    literalMD\n    mkOption\n    optionalAttrs\n    optionalString\n    ;\n  inherit (lib.types)\n    listOf\n    nonEmptyListOf\n    submodule\n    str\n    ;\n  inherit (shb) anyNotNull;\nin\n{\n  mkRequest =\n    {\n      user ? \"\",\n      userText ? null,\n      sourceDirectories ? [ \"/var/lib/example\" ],\n      sourceDirectoriesText ? null,\n      excludePatterns ? [ ],\n      excludePatternsText ? null,\n      beforeBackup ? [ ],\n      beforeBackupText ? null,\n      afterBackup ? [ ],\n      afterBackupText ? null,\n    }:\n    mkOption {\n      description = ''\n        Request part of the backup contract.\n\n        Options set by the requester module\n        enforcing how to backup files.\n      '';\n\n      default = {\n        inherit user sourceDirectories excludePatterns;\n        hooks = {\n          inherit beforeBackup afterBackup;\n        };\n      };\n\n      defaultText =\n        optionalString\n          (anyNotNull [\n            userText\n            sourceDirectoriesText\n            excludePatternsText\n            beforeBackupText\n            afterBackupText\n          ])\n          (literalMD ''\n            {\n              user = ${if userText != null then userText else user};\n              sourceDirectories = ${\n                if sourceDirectoriesText != null then\n                  sourceDirectoriesText\n                else\n                  \"[ \" + concatStringsSep \" \" sourceDirectories + \" ]\"\n              };\n              excludePatterns = ${\n                if excludePatternsText != null then\n                  excludePatternsText\n                else\n                  \"[ \" + concatStringsSep \" \" excludePatterns + \" ]\"\n              };\n              hooks.beforeBackup = ${\n                if beforeBackupText != null then\n                  beforeBackupText\n                else\n                  \"[ \" + concatStringsSep \" \" beforeBackup + \" ]\"\n              };\n              hooks.afterBackup = ${\n                if afterBackupText != null then afterBackupText else \"[ \" + concatStringsSep \" \" afterBackup + \" ]\"\n              };\n            };\n          '');\n\n      type = submodule {\n        options = {\n          user =\n            mkOption {\n              description = ''\n                Unix user doing the backups.\n              '';\n              type = str;\n              example = \"vaultwarden\";\n              default = user;\n            }\n            // optionalAttrs (userText != null) {\n              defaultText = literalMD userText;\n            };\n\n          sourceDirectories =\n            mkOption {\n              description = \"Directories to backup.\";\n              type = nonEmptyListOf str;\n              example = \"/var/lib/vaultwarden\";\n              default = sourceDirectories;\n            }\n            // optionalAttrs (sourceDirectoriesText != null) {\n              defaultText = literalMD sourceDirectoriesText;\n            };\n\n          excludePatterns =\n            mkOption {\n              description = \"File patterns to exclude.\";\n              type = listOf str;\n              default = excludePatterns;\n            }\n            // optionalAttrs (excludePatternsText != null) {\n              defaultText = literalMD excludePatternsText;\n            };\n\n          hooks = mkOption {\n            description = \"Hooks to run around the backup.\";\n            default = { };\n            type = submodule {\n              options = {\n                beforeBackup =\n                  mkOption {\n                    description = \"Hooks to run before backup.\";\n                    type = listOf str;\n                    default = beforeBackup;\n                  }\n                  // optionalAttrs (beforeBackupText != null) {\n                    defaultText = literalMD beforeBackupText;\n                  };\n\n                afterBackup =\n                  mkOption {\n                    description = \"Hooks to run after backup.\";\n                    type = listOf str;\n                    default = afterBackup;\n                  }\n                  // optionalAttrs (afterBackupText != null) {\n                    defaultText = literalMD afterBackupText;\n                  };\n              };\n            };\n          };\n        };\n      };\n    };\n\n  mkResult =\n    {\n      restoreScript ? \"restore\",\n      restoreScriptText ? null,\n      backupService ? \"backup.service\",\n      backupServiceText ? null,\n    }:\n    mkOption {\n      description = ''\n        Result part of the backup contract.\n\n        Options set by the provider module that indicates the name of the backup and restore scripts.\n      '';\n      default = {\n        inherit restoreScript backupService;\n      };\n\n      defaultText =\n        optionalString\n          (anyNotNull [\n            restoreScriptText\n            backupServiceText\n          ])\n          (literalMD ''\n            {\n              restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript};\n              backupService = ${if backupServiceText != null then backupServiceText else backupService};\n            }\n          '');\n\n      type = submodule {\n        options = {\n          restoreScript =\n            mkOption {\n              description = ''\n                Name of script that can restore the database.\n                One can then list snapshots with:\n\n                ```bash\n                $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots\n                ```\n\n                And restore the database with:\n\n                ```bash\n                $ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest\n                ```\n              '';\n              type = str;\n              default = restoreScript;\n            }\n            // optionalAttrs (restoreScriptText != null) {\n              defaultText = literalMD restoreScriptText;\n            };\n\n          backupService =\n            mkOption {\n              description = ''\n                Name of service backing up the database.\n\n                This script can be ran manually to backup the database:\n\n                ```bash\n                $ systemctl start ${if backupServiceText != null then backupServiceText else backupService}\n                ```\n              '';\n              type = str;\n              default = backupService;\n            }\n            // optionalAttrs (backupServiceText != null) {\n              defaultText = literalMD backupServiceText;\n            };\n        };\n      };\n    };\n}\n"
  },
  {
    "path": "modules/contracts/dashboard/docs/default.md",
    "content": "# Dashboard Contract {#contract-dashboard}\n\nThis NixOS contract is used for user-facing services\nthat want to be displayed on a dashboard.\n\nIt is a contract between a service that can be accessed through an URL\nand a service that wants to show a list of those services.\n\n## Providers {#contract-dashboard-providers}\n\nThe providers of this contract in SHB are:\n\n<!-- Somehow generate this list -->\n\n- The homepage service under its [shb.homepage.servicesGroups.<name>.services.<name>.dashboard](#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard) option.\n\n## Usage {#contracts-dashboard-usage}\n\nA service that can be shown on a dashboard will provide a `dashboard` option.\n\nHere is an example module defining such a requester option for this dashboard contract:\n\n```nix\n{\n  options = {\n    myservice.dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${config.myservice.subdomain}.${config.myservice.domain}\";\n          internalUrl = \"http://127.0.0.1:${config.myservice.port}\";\n        };\n      };\n    };\n  };\n};\n```\n\nThen, plug both consumer and provider together in the `config`:\n\n```nix\n{\n  config = {\n    <provider-module> = {\n      dashboard.request = config.myservice.dashboard.request;\n    };\n  };\n}\n```\n\nAnd that's it for the contract part.\nFor more specific details on each provider, go to their respective manual pages.\n\n## Contract Reference {#contract-dashboard-options}\n\nThese are all the options that are expected to exist for this contract to be respected.\n\n```{=include=} options\nid-prefix: contracts-dashboard-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/contracts/dashboard/dummyModule.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\nin\n{\n  imports = [\n    ../../../lib/module.nix\n  ];\n\n  options.shb.contracts.dashboard = mkOption {\n    description = ''\n      Contract for user-facing services that want to \n      be displayed on a dashboard.\n\n      The requester communicates to the provider\n      how to access the service\n      through the `request` options.\n\n      The provider reads from the `request` options\n      and configures what is necessary on its side\n      to show the service and check its availability.\n      It does not communicate back to the requester.\n    '';\n\n    type = submodule {\n      options = shb.contracts.dashboard.contract;\n    };\n  };\n}\n"
  },
  {
    "path": "modules/contracts/dashboard.nix",
    "content": "{ lib, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types)\n    nullOr\n    submodule\n    str\n    ;\nin\n{\n  mkRequest =\n    {\n      serviceName ? \"\",\n      externalUrl ? \"\",\n      externalUrlText ? null,\n      internalUrl ? null,\n      internalUrlText ? null,\n      apiKey ? null,\n    }:\n    mkOption {\n      description = ''\n        Request part of the dashboard contract.\n      '';\n      default = { };\n      type = submodule {\n        options = {\n          externalUrl =\n            mkOption {\n              description = ''\n                URL at which the service can be accessed.\n\n                This URL should go through the reverse proxy.\n              '';\n              type = str;\n              default = externalUrl;\n              example = \"https://jellyfin.example.com\";\n            }\n            // (lib.optionalAttrs (externalUrlText != null) {\n              defaultText = externalUrlText;\n            });\n\n          internalUrl =\n            mkOption {\n              description = ''\n                URL at which the service can be accessed directly.\n\n                This URL should bypass the reverse proxy.\n                It can be used for example to ping the service\n                and making sure it is up and running correctly.\n              '';\n              type = nullOr str;\n              default = internalUrl;\n              example = \"http://127.0.0.1:8081\";\n            }\n            // (lib.optionalAttrs (internalUrlText != null) {\n              defaultText = internalUrlText;\n            });\n        };\n      };\n    };\n\n  mkResult =\n    {\n    }:\n    mkOption {\n      description = ''\n        Result part of the dashboard contract.\n\n        No option is provided here.\n      '';\n      default = { };\n      type = submodule {\n        options = {\n        };\n      };\n    };\n}\n"
  },
  {
    "path": "modules/contracts/databasebackup/docs/default.md",
    "content": "# Database Backup Contract {#contract-databasebackup}\n\nThis NixOS contract represents a backup job\nthat will backup everything in one database\non a regular schedule.\n\nIt is a contract between a service that has database dumps to be backed up\nand a service that backs up databases dumps.\n\n## Contract Reference {#contract-databasebackup-options}\n\nThese are all the options that are expected to exist for this contract to be respected.\n\n```{=include=} options\nid-prefix: contracts-databasebackup-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n\n## Usage {#contract-databasebackup-usage}\n\nA database that can be backed up will provide a `databasebackup` option.\nSuch a service is a `requester` providing a `request` for a module `provider` of this contract. \n\nWhat this option defines is, from the user perspective - that is _you_ - an implementation detail\nbut it will at least define how to create a database dump,\nthe user to backup with\nand how to restore from a database dump.\n\nHere is an example module defining such a `databasebackup` option:\n\n```nix\n{\n  options = {\n    myservice.databasebackup = mkOption {\n      type = contracts.databasebackup.request;\n      default = {\n        user = \"myservice\";\n        backupCmd = ''\n          ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable\n        '';\n        restoreCmd = ''\n          ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres\n        '';\n      };\n    };\n  };\n};\n```\n\nNow, on the other side we have a service that uses this `backup` option and actually backs up files.\nThis service is a `provider` of this contract and will provide a `result` option.\n\nLet's assume such a module is available under the `databasebackupservice` option\nand that one can create multiple backup instances under `databasebackupservice.instances`.\nThen, to actually backup the `myservice` service, one would write:\n\n```nix\ndatabasebackupservice.instances.myservice = {\n  request = myservice.databasebackup;\n  \n  settings = {\n    enable = true;\n\n    repository = {\n      path = \"/srv/backup/myservice\";\n    };\n\n    # ... Other options specific to backupservice like scheduling.\n  };\n};\n```\n\nIt is advised to backup files to different location, to improve redundancy.\nThanks to using contracts, this can be made easily either with the same `databasebackupservice`:\n\n```nix\ndatabasebackupservice.instances.myservice_2 = {\n  request = myservice.backup;\n  \n  settings = {\n    enable = true;\n  \n    repository = {\n      path = \"<remote path>\";\n    };\n  };\n};\n```\n\nOr with another module `databasebackupservice_2`!\n\n## Providers of the Database Backup Contract {#contract-databasebackup-providers}\n\n- [Restic block](blocks-restic.html).\n- [Borgbackup block](blocks-borgbackup.html) [WIP].\n\n## Requester Blocks and Services {#contract-databasebackup-requesters}\n\n- [PostgreSQL](blocks-postgresql.html#blocks-postgresql-contract-databasebackup).\n"
  },
  {
    "path": "modules/contracts/databasebackup/dummyModule.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\nin\n{\n  imports = [\n    ../../../lib/module.nix\n  ];\n\n  options.shb.contracts.databasebackup = mkOption {\n    description = ''\n      Contract for database backup between a requester module\n      and a provider module.\n\n      The requester communicates to the provider\n      how to backup the database\n      through the `request` options.\n\n      The provider reads from the `request` options\n      and backs up the database as requested.\n      It communicates to the requester what script is used\n      to backup and restore the database\n      through the `result` options.\n    '';\n\n    type = submodule {\n      options = shb.contracts.databasebackup.contract;\n    };\n  };\n}\n"
  },
  {
    "path": "modules/contracts/databasebackup/test.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  inherit (lib)\n    getAttrFromPath\n    mkIf\n    optionalAttrs\n    setAttrByPath\n    ;\nin\n{\n  name,\n  requesterRoot,\n  providerRoot,\n  extraConfig ? null, # { config, database } -> attrset\n  modules ? [ ],\n  database ? \"me\",\n  settings, # { repository, config } -> attrset\n}:\nshb.test.runNixOSTest {\n  inherit name;\n\n  nodes.machine =\n    { config, ... }:\n    {\n      imports = [ shb.test.baseImports ] ++ modules;\n      config = lib.mkMerge [\n        (setAttrByPath providerRoot {\n          request = (getAttrFromPath requesterRoot config).request;\n          settings = settings {\n            inherit config;\n            repository = \"/opt/repos/database\";\n          };\n        })\n        (mkIf (database != \"root\") {\n          users.users.${database} = {\n            isSystemUser = true;\n            extraGroups = [ \"sudoers\" ];\n            group = \"root\";\n          };\n        })\n        (optionalAttrs (extraConfig != null) (extraConfig {\n          inherit config database;\n        }))\n      ];\n    };\n\n  testScript =\n    { nodes, ... }:\n    let\n      provider = getAttrFromPath providerRoot nodes.machine;\n    in\n    ''\n      import csv\n\n      start_all()\n      machine.wait_for_unit(\"postgresql.service\")\n      machine.wait_for_open_port(5432)\n\n      def peer_cmd(cmd, db=\"me\"):\n          return \"sudo -u ${database} psql -U ${database} {db} --csv --command \\\"{cmd}\\\"\".format(cmd=cmd, db=db)\n\n      def query(query):\n          res = machine.succeed(peer_cmd(query))\n          return list(dict(l) for l in csv.DictReader(res.splitlines()))\n\n      def cmp_tables(a, b):\n          for i in range(max(len(a), len(b))):\n              diff = set(a[i]) ^ set(b[i])\n              if len(diff) > 0:\n                  raise Exception(i, diff)\n\n      table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]\n\n      with subtest(\"create fixture\"):\n          machine.succeed(peer_cmd(\"CREATE TABLE test (name text, count int)\"))\n          machine.succeed(peer_cmd(\"INSERT INTO test VALUES ('car', 1), ('lollipop', 2)\"))\n\n          res = query(\"SELECT * FROM test\")\n          cmp_tables(res, table)\n\n      with subtest(\"backup\"):\n          print(machine.succeed(\"systemctl cat ${provider.result.backupService}\"))\n          print(machine.succeed(\"ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase\"))\n          machine.succeed(\"systemctl start ${provider.result.backupService}\")\n\n      with subtest(\"drop database\"):\n          machine.succeed(peer_cmd(\"DROP DATABASE ${database}\", db=\"postgres\"))\n          machine.fail(peer_cmd(\"SELECT * FROM test\"))\n\n      with subtest(\"restore\"):\n          print(machine.succeed(\"readlink -f $(type ${provider.result.restoreScript})\"))\n          machine.succeed(\"${provider.result.restoreScript} restore latest \")\n\n      with subtest(\"check restoration\"):\n          res = query(\"SELECT * FROM test\")\n          cmp_tables(res, table)\n    '';\n}\n"
  },
  {
    "path": "modules/contracts/databasebackup.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib)\n    mkOption\n    literalExpression\n    literalMD\n    optionalAttrs\n    optionalString\n    ;\n  inherit (lib.types) submodule str;\n  inherit (shb) anyNotNull;\nin\n{\n  mkRequest =\n    {\n      user ? \"root\",\n      userText ? null,\n      backupName ? \"dump\",\n      backupNameText ? null,\n      backupCmd ? \"\",\n      backupCmdText ? null,\n      restoreCmd ? \"\",\n      restoreCmdText ? null,\n    }:\n    mkOption {\n      description = ''\n        Request part of the database backup contract.\n\n        Options set by the requester module\n        enforcing how to backup files.\n      '';\n\n      default = {\n        inherit\n          user\n          backupName\n          backupCmd\n          restoreCmd\n          ;\n      };\n\n      defaultText =\n        optionalString\n          (anyNotNull [\n            userText\n            backupNameText\n            backupCmdText\n            restoreCmdText\n          ])\n          (literalMD ''\n            {\n              user = ${if userText != null then userText else user};\n              backupName = ${if backupNameText != null then backupNameText else backupName};\n              backupCmd = ${if backupCmdText != null then backupCmdText else backupCmd};\n              restoreCmd = ${if restoreCmdText != null then restoreCmdText else restoreCmd};\n            }\n          '');\n\n      type = submodule {\n        options = {\n          user =\n            mkOption {\n              description = ''\n                Unix user doing the backups.\n\n                This should be an admin user having access to all databases.\n              '';\n              type = str;\n              example = \"postgres\";\n              default = user;\n            }\n            // optionalAttrs (userText != null) {\n              defaultText = literalMD userText;\n            };\n\n          backupName =\n            mkOption {\n              description = \"Name of the backup in the repository.\";\n              type = str;\n              example = \"postgresql.sql\";\n              default = backupName;\n            }\n            // optionalAttrs (backupNameText != null) {\n              defaultText = literalMD backupNameText;\n            };\n\n          backupCmd =\n            mkOption {\n              description = \"Command that produces the database dump on stdout.\";\n              type = str;\n              example = literalExpression ''\n                ''${pkgs.postgresql}/bin/pg_dumpall | ''${pkgs.gzip}/bin/gzip --rsyncable\n              '';\n              default = backupCmd;\n            }\n            // optionalAttrs (backupCmdText != null) {\n              defaultText = literalMD backupCmdText;\n            };\n\n          restoreCmd =\n            mkOption {\n              description = \"Command that reads the database dump on stdin and restores the database.\";\n              type = str;\n              example = literalExpression ''\n                ''${pkgs.gzip}/bin/gunzip | ''${pkgs.postgresql}/bin/psql postgres\n              '';\n              default = restoreCmd;\n            }\n            // optionalAttrs (restoreCmdText != null) {\n              defaultText = literalMD restoreCmdText;\n            };\n        };\n      };\n    };\n\n  mkResult =\n    {\n      restoreScript ? \"restore\",\n      restoreScriptText ? null,\n      backupService ? \"backup.service\",\n      backupServiceText ? null,\n    }:\n    mkOption {\n      description = ''\n        Result part of the database backup contract.\n\n        Options set by the provider module that indicates the name of the backup and restore scripts.\n      '';\n      default = {\n        inherit restoreScript backupService;\n      };\n\n      defaultText =\n        optionalString\n          (anyNotNull [\n            restoreScriptText\n            backupServiceText\n          ])\n          (literalMD ''\n            {\n              restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript};\n              backupService = ${if backupServiceText != null then backupServiceText else backupService};\n            }\n          '');\n\n      type = submodule {\n        options = {\n          restoreScript =\n            mkOption {\n              description = ''\n                Name of script that can restore the database.\n                One can then list snapshots with:\n\n                ```bash\n                $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots\n                ```\n\n                And restore the database with:\n\n                ```bash\n                $ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest\n                ```\n              '';\n              type = str;\n              default = restoreScript;\n            }\n            // optionalAttrs (restoreScriptText != null) {\n              defaultText = literalMD restoreScriptText;\n            };\n\n          backupService =\n            mkOption {\n              description = ''\n                Name of service backing up the database.\n\n                This script can be ran manually to backup the database:\n\n                ```bash\n                $ systemctl start ${if backupServiceText != null then backupServiceText else backupService}\n                ```\n              '';\n              type = str;\n              default = backupService;\n            }\n            // optionalAttrs (backupServiceText != null) {\n              defaultText = literalMD backupServiceText;\n            };\n        };\n      };\n    };\n}\n"
  },
  {
    "path": "modules/contracts/default.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  inherit (lib) mkOption optionalAttrs;\n  inherit (lib.types) anything;\n\n  mkContractFunctions =\n    {\n      mkRequest,\n      mkResult,\n    }:\n    {\n      mkRequester = requestCfg: {\n        request = mkRequest requestCfg;\n\n        result = mkResult { };\n      };\n\n      mkProvider =\n        {\n          resultCfg,\n          settings ? { },\n        }:\n        {\n          request = mkRequest { };\n\n          result = mkResult resultCfg;\n        }\n        // optionalAttrs (settings != { }) { inherit settings; };\n\n      contract = {\n        request = mkRequest { };\n\n        result = mkResult { };\n\n        settings = mkOption {\n          description = ''\n            Optional attribute set with options specific to the provider.\n          '';\n          type = anything;\n        };\n      };\n    };\n\n  importContract =\n    module:\n    let\n      importedModule = pkgs.callPackage module {\n        shb = shb // {\n          inherit contracts;\n        };\n      };\n    in\n    mkContractFunctions {\n      inherit (importedModule) mkRequest mkResult;\n    };\n\n  contracts = {\n    databasebackup = importContract ./databasebackup.nix;\n    dashboard = importContract ./dashboard.nix;\n    backup = importContract ./backup.nix;\n    mount = pkgs.callPackage ./mount.nix { };\n    secret = importContract ./secret.nix;\n    ssl = pkgs.callPackage ./ssl.nix { };\n    test = {\n      secret = pkgs.callPackage ./secret/test.nix { inherit shb; };\n      databasebackup = pkgs.callPackage ./databasebackup/test.nix { inherit shb; };\n      backup = pkgs.callPackage ./backup/test.nix { inherit shb; };\n    };\n  };\nin\ncontracts\n"
  },
  {
    "path": "modules/contracts/mount.nix",
    "content": "{ lib, ... }:\nlib.types.submodule {\n  freeformType = lib.types.anything;\n\n  options = {\n    path = lib.mkOption {\n      type = lib.types.str;\n      description = \"Path to be mounted.\";\n    };\n  };\n}\n"
  },
  {
    "path": "modules/contracts/secret/docs/default.md",
    "content": "# Secret Contract {#contract-secret}\n\nThis NixOS contract represents a secret file\nthat must be created out of band - from outside the nix store -\nand that must be placed in an expected location with expected permission.\n\nMore formally, this contract is made between a requester module - the one needing a secret -\nand a provider module - the one creating the secret and making it available.\n\n## Motivation {#contract-secret-motivation}\n\nLet's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile`\nwith a secret managed by [sops-nix][].\n\n[ldap-module]: TODO\n[sops-nix]: TODO\n\nWithout the secret contract, configuring the option would look like so:\n\n```nix\nsops.secrets.\"ldap/user_password\" = {\n  mode = \"0440\";\n  owner = \"lldap\";\n  group = \"lldap\";\n  restartUnits = [ \"lldap.service\" ];\n  sopsFile = ./secrets.yaml;\n};\n\nshb.lldap.userPassword.result = config.sops.secrets.\"ldap/user_password\".result;\n```\n\nThe problem this contract intends to fix is how to ensure\nthe end user knows what values to give to the\n`mode`, `owner`, `group` and `restartUnits` options?\n\nIf lucky, the documentation of the option would tell them\nor more likely, they will need to figure it out by looking\nat the module source code.\nNot a great user experience.\n\nNow, with this contract, a layer on top of `sops` is added which is found under `shb.sops`.\nThe configuration then becomes:\n\n```nix\nshb.sops.secret.\"ldap/user_password\" = {\n  request = config.shb.lldap.userPassword.request;\n  settings.sopsFile = ./secrets.yaml;\n};\n\nshb.lldap.userPassword.result = config.shb.sops.secret.\"ldap/user_password\".result;\n```\n\nThe issue is now gone as the responsibility falls\non the module maintainer\nfor describing how the secret should be provided.\n\nIf taking advantage of the `sops.defaultSopsFile` option like so:\n\n```nix\nsops.defaultSopsFile = ./secrets.yaml;\n```\n\nThen the snippet above is even more simplified:\n\n```nix\nshb.sops.secret.\"ldap/user_password\".request = config.shb.lldap.userPassword.request;\n\nshb.lldap.userPassword.result = config.shb.sops.secret.\"ldap/user_password\".result;\n```\n\n## Contract Reference {#contract-secret-options}\n\nThese are all the options that are expected to exist for this contract to be respected.\n\n```{=include=} options\nid-prefix: contracts-secret-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n\n## Usage {#contract-secret-usage}\n\nA contract involves 3 parties:\n\n- The implementer of a requester module.\n- The implementer of a provider module.\n- The end user which sets up the requester module and picks a provider implementation.\n\nThe usage of this contract is similarly separated into 3 sections.\n\n### Requester Module {#contract-secret-usage-requester}\n\nHere is an example module requesting two secrets through the `secret` contract.\n\n```nix\n{ config, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\nin\n{\n  options = {\n    myservice = mkOption {\n      type = submodule {\n        options = {\n          adminPassword = contracts.secret.mkRequester {\n            owner = \"myservice\";\n            group = \"myservice\";\n            mode = \"0440\";\n            restartUnits = [ \"myservice.service\" ];\n          };\n          databasePassword = contracts.secret.mkRequester {\n            owner = \"myservice\";\n            # group defaults to \"root\"\n            # mode defaults to \"0400\"\n            restartUnits = [ \"myservice.service\" \"mysql.service\" ];\n          };\n        };\n      };\n    };\n  };\n\n  config = {\n    // Do something with the secrets, available at:\n    // config.myservice.adminPassword.result.path\n    // config.myservice.databasePassword.result.path\n  };\n};\n```\n\n### Provider Module {#contract-secret-usage-provider}\n\nNow, on the other side, we have a module that uses those options and provides a secret.\nLet's assume such a module is available under the `secretservice` option\nand that one can create multiple instances.\n\n```nix\n{ config, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) attrsOf submodule;\n\n  contracts = pkgs.callPackage ./contracts {};\nin\n{\n  options.secretservice.secret = mkOption {\n    description = \"Secret following the secret contract.\";\n    default = {};\n    type = attrsOf (submodule ({ name, options, ... }: {\n      options = contracts.secret.mkProvider {\n        settings = mkOption {\n          description = ''\n            Settings specific to the secrets provider.\n          '';\n\n          type = submodule {\n            options = {\n              secretFile = lib.mkOption {\n                description = \"File containing the encrypted secret.\";\n                type = lib.types.path;\n              };\n            };\n          };\n        };\n\n        resultCfg = {\n          path = \"/run/secrets/${name}\";\n          pathText = \"/run/secrets/<name>\";\n        };\n      };\n    }));\n  };\n\n  config = {\n    // ...\n  };\n}\n```\n\n### End User {#contract-secret-usage-enduser}\n\nThe end user's responsibility is now to do some plumbing.\n\nThey will setup the provider module - here `secretservice` - with the options set by the requester module,\nwhile also setting other necessary options to satisfy the provider service.\nAnd then they will give back the result to the requester module `myservice`.\n\n```nix\nsecretservice.secret.\"adminPassword\" = {\n  request = myservice.adminPasswor\".request;\n  settings.secretFile = ./secret.yaml;\n};\nmyservice.adminPassword.result = secretservice.secret.\"adminPassword\".result;\n\nsecretservice.secret.\"databasePassword\" = {\n  request = myservice.databasePassword.request;\n  settings.secretFile = ./secret.yaml;\n};\nmyservice.databasePassword.result = secretservice.service.\"databasePassword\".result;\n```\n\nAssuming the `secretservice` module accepts default options,\nthe above snippet could be reduced to:\n\n```nix\nsecretservice.default.secretFile = ./secret.yaml;\n\nsecretservice.secret.\"adminPassword\".request = myservice.adminPasswor\".request;\nmyservice.adminPassword.result = secretservice.secret.\"adminPassword\".result;\n\nsecretservice.secret.\"databasePassword\".request = myservice.databasePassword.request;\nmyservice.databasePassword.result = secretservice.service.\"databasePassword\".result;\n```\n\nThe plumbing of request from the requester to the provider\nand then the result from the provider back to the requester\nis quite explicit in this snippet.\n"
  },
  {
    "path": "modules/contracts/secret/dummyModule.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib) mkOption;\n  inherit (lib.types) submodule;\nin\n{\n  imports = [\n    ../../../lib/module.nix\n  ];\n\n  options.shb.contracts.secret = mkOption {\n    description = ''\n      Contract for secrets between a requester module\n      and a provider module.\n\n      The requester communicates to the provider\n      some properties the secret should have\n      through the `request.*` options.\n\n      The provider reads from the `request.*` options\n      and creates the secret as requested.\n      It then communicates to the requester where the secret can be found\n      through the `result.*` options.\n    '';\n    type = submodule {\n      options = shb.contracts.secret.contract;\n    };\n  };\n}\n"
  },
  {
    "path": "modules/contracts/secret/test.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  inherit (lib) getAttrFromPath setAttrByPath;\n  inherit (lib) mkIf;\nin\n{\n  name,\n  configRoot,\n  settingsCfg, # str -> attrset\n  modules ? [ ],\n  owner ? \"root\",\n  group ? \"root\",\n  mode ? \"0400\",\n  restartUnits ? [ \"myunit.service\" ],\n}:\nshb.test.runNixOSTest {\n  name = \"secret_${name}_${owner}_${group}_${mode}\";\n\n  nodes.machine =\n    { config, ... }:\n    {\n      imports = [ shb.test.baseImports ] ++ modules;\n      config = lib.mkMerge [\n        (setAttrByPath configRoot {\n          A = {\n            request = {\n              inherit\n                owner\n                group\n                mode\n                restartUnits\n                ;\n            };\n            settings = settingsCfg \"secretA\";\n          };\n        })\n        (mkIf (owner != \"root\") {\n          users.users.${owner}.isNormalUser = true;\n        })\n        (mkIf (group != \"root\") {\n          users.groups.${group} = { };\n        })\n      ];\n    };\n\n  testScript =\n    { nodes, ... }:\n    let\n      result = (getAttrFromPath configRoot nodes.machine).\"A\".result;\n    in\n    ''\n      owner = machine.succeed(\"stat -c '%U' ${result.path}\").strip()\n      print(f\"Got owner {owner}\")\n      if owner != \"${owner}\":\n          raise Exception(f\"Owner should be '${owner}' but got '{owner}'\")\n\n      group = machine.succeed(\"stat -c '%G' ${result.path}\").strip()\n      print(f\"Got group {group}\")\n      if group != \"${group}\":\n          raise Exception(f\"Group should be '${group}' but got '{group}'\")\n\n      mode = str(int(machine.succeed(\"stat -c '%a' ${result.path}\").strip()))\n      print(f\"Got mode {mode}\")\n      wantedMode = str(int(\"${mode}\"))\n      if mode != wantedMode:\n          raise Exception(f\"Mode should be '{wantedMode}' but got '{mode}'\")\n\n      content = machine.succeed(\"cat ${result.path}\").strip()\n      print(f\"Got content {content}\")\n      if content != \"secretA\":\n          raise Exception(f\"Content should be 'secretA' but got '{content}'\")\n    '';\n}\n"
  },
  {
    "path": "modules/contracts/secret.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib)\n    concatStringsSep\n    literalMD\n    mkOption\n    optionalAttrs\n    optionalString\n    ;\n  inherit (lib.types) listOf submodule str;\n  inherit (shb) anyNotNull;\nin\n{\n  mkRequest =\n    {\n      mode ? \"0400\",\n      modeText ? null,\n      owner ? \"root\",\n      ownerText ? null,\n      group ? \"root\",\n      groupText ? null,\n      restartUnits ? [ ],\n      restartUnitsText ? null,\n    }:\n    mkOption {\n      description = ''\n        Request part of the secret contract.\n\n        Options set by the requester module\n        enforcing some properties the secret should have.\n      '';\n\n      default = {\n        inherit\n          mode\n          owner\n          group\n          restartUnits\n          ;\n      };\n\n      defaultText =\n        optionalString\n          (anyNotNull [\n            modeText\n            ownerText\n            groupText\n            restartUnitsText\n          ])\n          (literalMD ''\n            {\n              mode = ${if modeText != null then modeText else mode};\n              owner = ${if ownerText != null then ownerText else owner};\n              group = ${if groupText != null then groupText else group};\n              restartUnits = ${\n                if restartUnitsText != null then\n                  restartUnitsText\n                else\n                  \"[ \" + concatStringsSep \" \" restartUnits + \" ]\"\n              };\n            }\n          '');\n\n      type = submodule {\n        options = {\n          mode =\n            mkOption {\n              description = ''\n                Mode of the secret file.\n              '';\n              type = str;\n              default = mode;\n            }\n            // optionalAttrs (modeText != null) {\n              defaultText = literalMD modeText;\n            };\n\n          owner = mkOption (\n            {\n              description = ''\n                Linux user owning the secret file.\n              '';\n              type = str;\n              default = owner;\n            }\n            // optionalAttrs (ownerText != null) {\n              defaultText = literalMD ownerText;\n            }\n          );\n\n          group =\n            mkOption {\n              description = ''\n                Linux group owning the secret file.\n              '';\n              type = str;\n              default = group;\n            }\n            // optionalAttrs (groupText != null) {\n              defaultText = literalMD groupText;\n            };\n\n          restartUnits = mkOption (\n            {\n              description = ''\n                Systemd units to restart after the secret is updated.\n              '';\n              type = listOf str;\n              default = restartUnits;\n            }\n            // optionalAttrs (restartUnitsText != null) {\n              defaultText = literalMD restartUnitsText;\n            }\n          );\n        };\n      };\n    };\n\n  mkResult =\n    {\n      path ? \"/run/secrets/secret\",\n      pathText ? null,\n    }:\n    mkOption (\n      {\n        description = ''\n          Result part of the secret contract.\n\n          Options set by the provider module that indicates where the secret can be found.\n        '';\n        default = {\n          inherit path;\n        };\n        type = submodule {\n          options = {\n            path =\n              mkOption {\n                type = lib.types.path;\n                description = ''\n                  Path to the file containing the secret generated out of band.\n\n                  This path will exist after deploying to a target host,\n                  it is not available through the nix store.\n                '';\n                default = path;\n              }\n              // optionalAttrs (pathText != null) {\n                defaultText = pathText;\n              };\n          };\n        };\n      }\n      // optionalAttrs (pathText != null) {\n        defaultText = {\n          path = pathText;\n        };\n      }\n    );\n}\n"
  },
  {
    "path": "modules/contracts/ssl/docs/default.md",
    "content": "# SSL Generator Contract {#contract-ssl}\n\nThis NixOS contract represents an SSL certificate generator. This contract is used to decouple\ngenerating an SSL certificate from using it. In practice, you can swap generators without updating\nmodules depending on it.\n\n## Contract Reference {#contract-ssl-options}\n\nThese are all the options that are expected to exist for this contract to be respected.\n\n```{=include=} options\nid-prefix: contracts-ssl-options-\nlist-id: selfhostblocks-options\nsource: @OPTIONS_JSON@\n```\n\n## Usage {#contract-ssl-usage}\n\nLet's assume a module implementing this contract is available under the `ssl` variable:\n\n```nix\nlet\n  ssl = <...>;\nin\n```\n\nTo use this module, we can reference the path where the certificate and the private key are located with:\n\n```nix\nssl.paths.cert\nssl.paths.key\n```\n\nWe can then configure Nginx to use those certificates:\n\n```nix\nservices.nginx.virtualHosts.\"example.com\" = {\n  onlySSL = true;\n  sslCertificate = ssl.paths.cert;\n  sslCertificateKey = ssl.paths.key;\n\n  locations.\"/\".extraConfig = ''\n    add_header Content-Type text/plain;\n    return 200 'It works!';\n  '';\n};\n```\n\nTo make sure the Nginx webserver can find the generated file, we will make it wait for the\ncertificate to the generated:\n\n```nix\nsystemd.services.nginx = {\n  after = [ ssl.systemdService ];\n  requires = [ ssl.systemdService ];\n};\n```\n\n## Provided Implementations {#contract-ssl-impl-shb}\n\nMultiple implementation are provided out of the box at [SSL block](blocks-ssl.html).\n\n## Custom Implementation {#contract-ssl-impl-custom}\n\nTo implement this contract, you must create a module that respects this contract. The following\nsnippet shows an example.\n\n```nix\n{ lib, ... }:\n{\n  options.my.generator = {\n    paths = lib.mkOption {\n      description = ''\n        Paths where certs will be located.\n\n        This option implements the SSL Generator contract.\n      '';\n      type = contracts.ssl.certs-paths;\n      default = {\n        key = \"/var/lib/my_generator/key.pem\";\n        cert = \"/var/lib/my_generator/cert.pem\";\n      };\n    };\n\n    systemdService = lib.mkOption {\n      description = ''\n        Systemd oneshot service used to generate the certs.\n\n        This option implements the SSL Generator contract.\n      '';\n      type = lib.types.str;\n      default = \"my-generator.service\";\n    };\n\n    # Other options needed for this implementation\n  };\n\n  config = {\n    # custom implementation goes here\n  };\n}\n```\n\nYou can then create an instance of this generator:\n\n```nix\n{\n  my.generator = ...;\n}\n```\n\nAnd use it whenever a module expects something implementing this SSL generator contract:\n\n```nix\n{ config, ... }:\n{\n  my.service.ssl = config.my.generator;\n}\n```\n"
  },
  {
    "path": "modules/contracts/ssl/dummyModule.nix",
    "content": "{ lib, shb, ... }:\n{\n  imports = [\n    ../../../lib/module.nix\n  ];\n\n  options.shb.contracts.ssl = lib.mkOption {\n    description = \"Contract for SSL Certificate generator.\";\n    type = shb.contracts.ssl.certs;\n  };\n}\n"
  },
  {
    "path": "modules/contracts/ssl.nix",
    "content": "{ lib, ... }:\nrec {\n  certs-paths = lib.types.submodule {\n    freeformType = lib.types.anything;\n\n    options = {\n      cert = lib.mkOption {\n        type = lib.types.path;\n        description = \"Path to the cert file.\";\n      };\n      key = lib.mkOption {\n        type = lib.types.path;\n        description = \"Path to the key file.\";\n      };\n    };\n  };\n  cas = lib.types.submodule {\n    freeformType = lib.types.anything;\n\n    options = {\n      paths = lib.mkOption {\n        description = ''\n          Paths where the files for the CA will be located.\n\n          This option is the contract output of the `shb.certs.cas` SSL block.\n        '';\n        type = certs-paths;\n      };\n\n      systemdService = lib.mkOption {\n        description = ''\n          Systemd oneshot service used to generate the CA. Ends with the `.service` suffix.\n\n          Use this if downstream services must wait for the certificates to be generated before\n          starting.\n        '';\n        type = lib.types.str;\n        example = \"ca-generator.service\";\n      };\n    };\n  };\n  certs = lib.types.submodule {\n    freeformType = lib.types.anything;\n\n    options = {\n      paths = lib.mkOption {\n        description = ''\n          Paths where the files for the certificate will be located.\n\n          This option is the contract output of the `shb.certs.certs` SSL block.\n        '';\n        type = certs-paths;\n      };\n\n      systemdService = lib.mkOption {\n        description = ''\n          Systemd oneshot service used to generate the certificate. Ends with the `.service` suffix.\n\n          Use this if downstream services must wait for the certificates to be generated before\n          starting.\n        '';\n        type = lib.types.str;\n        example = \"cert-generator.service\";\n      };\n    };\n  };\n}\n"
  },
  {
    "path": "modules/services/arr/docs/default.md",
    "content": "# *Arr Service {#services-arr}\n\nDefined in [`/modules/services/arr.nix`](@REPO@/modules/services/arr.nix).\n\nThis NixOS module sets up multiple [Servarr](https://wiki.servarr.com/) services.\n## Features {#services-arr-features}\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner\nLDAP and SSO integration as well as the API key.\n\n## Usage {#services-arr-usage}\n\n### Initial Configuration {#services-arr-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\n{\n  shb.certs.certs.letsencrypt.${domain}.extraDomains = [\n    \"moviesdl.${domain}\"\n    \"seriesdl.${domain}\"\n    \"subtitlesdl.${domain}\"\n    \"booksdl.${domain}\"\n    \"musicdl.${domain}\"\n    \"indexer.${domain}\"\n  ];\n\n  shb.arr = {\n    radarr = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.${domain};\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n    sonarr = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.\"${domain}\";\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n    bazarr = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.\"${domain}\";\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n    readarr = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.\"${domain}\";\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n    lidarr = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.\"${domain}\";\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n    jackett = {\n      inherit domain;\n      enable = true;\n      ssl = config.shb.certs.certs.letsencrypt.\"${domain}\";\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n  };\n}\n```\n\nThe user and admin LDAP groups are created automatically.\n\n### API Keys {#services-arr-usage-apikeys}\n\nThe API keys for each arr service can be created declaratively.\n\nFirst, generate one secret for each service with `nix run nixpkgs#openssl -- rand -hex 64`\nand store it in your secrets file (for example the SOPS file).\n\nThen, add the API key to each service:\n\n```nix\n{\n  shb.arr = {\n    radarr = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"radarr/apikey\".result.path;\n      };\n    };\n    sonarr = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"sonarr/apikey\".result.path;\n      };\n    };\n    bazarr = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"bazarr/apikey\".result.path;\n      };\n    };\n    readarr = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"readarr/apikey\".result.path;\n      };\n    };\n    lidarr = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"lidarr/apikey\".result.path;\n      };\n    };\n    jackett = {\n      settings = {\n        ApiKey.source = config.shb.sops.secret.\"jackett/apikey\".result.path;\n      };\n    };\n  };\n\n  shb.sops.secret.\"radarr/apikey\".request = {\n    mode = \"0440\";\n    owner = \"radarr\";\n    group = \"radarr\";\n    restartUnits = [ \"radarr.service\" ];\n  };\n  shb.sops.secret.\"sonarr/apikey\".request = {\n    mode = \"0440\";\n    owner = \"sonarr\";\n    group = \"sonarr\";\n    restartUnits = [ \"sonarr.service\" ];\n  };\n  shb.sops.secret.\"bazarr/apikey\".request = {\n    mode = \"0440\";\n    owner = \"bazarr\";\n    group = \"bazarr\";\n    restartUnits = [ \"bazarr.service\" ];\n  };\n  shb.sops.secret.\"readarr/apikey\".request = {\n    mode = \"0440\";\n    owner = \"readarr\";\n    group = \"readarr\";\n    restartUnits = [ \"readarr.service\" ];\n  };\n  shb.sops.secret.\"lidarr/apikey\".request = {\n    mode = \"0440\";\n    owner = \"lidarr\";\n    group = \"lidarr\";\n    restartUnits = [ \"lidarr.service\" ];\n  };\n  shb.sops.secret.\"jackett/apikey\".request = {\n    mode = \"0440\";\n    owner = \"jackett\";\n    group = \"jackett\";\n    restartUnits = [ \"jackett.service\" ];\n  };\n}\n```\n\n### Application Dashboard {#services-arr-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the various dashboard options.\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Radarr = {\n    sortOrder = 10;\n    dashboard.request = config.shb.arr.radarr.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"radarr/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"radarr/homepageApiKey\" = {\n    settings.key = \"radarr/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Radarr.apiKey.request;\n  };\n  shb.homepage.servicesGroups.Media.services.Sonarr = {\n    sortOrder = 11;\n    dashboard.request = config.shb.arr.sonarr.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"sonarr/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"sonarr/homepageApiKey\" = {\n    settings.key = \"sonarr/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Sonarr.apiKey.request;\n  };\n  shb.homepage.servicesGroups.Media.services.Bazarr = {\n    sortOrder = 12;\n    dashboard.request = config.shb.arr.bazarr.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"bazarr/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"bazarr/homepageApiKey\" = {\n    settings.key = \"bazarr/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Bazarr.apiKey.request;\n  };\n  shb.homepage.servicesGroups.Media.services.Readarr = {\n    sortOrder = 13;\n    dashboard.request = config.shb.arr.readarr.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"readarr/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"readarr/homepageApiKey\" = {\n    settings.key = \"readarr/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Readarr.apiKey.request;\n  };\n  shb.homepage.servicesGroups.Media.services.Lidarr = {\n    sortOrder = 14;\n    dashboard.request = config.shb.arr.lidarr.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"lidarr/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"lidarr/homepageApiKey\" = {\n    settings.key = \"lidarr/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Lidarr.apiKey.request;\n  };\n  shb.homepage.servicesGroups.Media.services.Jackett = {\n    sortOrder = 15;\n    dashboard.request = config.shb.arr.jackett.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"jackett/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"jackett/homepageApiKey\" = {\n    settings.key = \"jackett/apikey\";\n    request = config.shb.homepage.servicesGroups.Media.services.Jackett.apiKey.request;\n  };\n}\n```\n\nThis example reuses the API keys generated declaratively from the previous section.\n\n### Jackett Proxy {#services-arr-usage-jackett-proxy}\n\nThe Jackett service can be made to use a proxy with:\n\n```nix\n{\n  shb.arr.jackett = {\n    settings = {\n      ProxyType = \"0\";\n      ProxyUrl = \"127.0.0.1:1234\";\n    };\n  };\n};\n```\n\n## Options Reference {#services-arr-options}\n\n```{=include=} options\nid-prefix: services-arr-options-\nlist-id: selfhostblocks-service-arr-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/arr.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.arr;\n\n  apps = {\n    radarr = {\n      settingsFormat = shb.formatXML { enclosingRoot = \"Config\"; };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for radarr.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.radarr.settingsFormat.type;\n            options = {\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              LogLevel = lib.mkOption {\n                type = lib.types.enum [\n                  \"debug\"\n                  \"info\"\n                ];\n                description = \"Log level.\";\n                default = \"info\";\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which radarr listens to incoming requests.\";\n                default = 7878;\n              };\n              AnalyticsEnabled = lib.mkOption {\n                type = lib.types.bool;\n                description = \"Wether to send anonymous data or not.\";\n                default = false;\n              };\n              BindAddress = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"127.0.0.1\";\n              };\n              UrlBase = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"\";\n              };\n              EnableSsl = lib.mkOption {\n                type = lib.types.bool;\n                internal = true;\n                default = false;\n              };\n              AuthenticationMethod = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"External\";\n              };\n              AuthenticationRequired = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"Enabled\";\n              };\n            };\n          };\n        };\n      };\n    };\n    sonarr = {\n      settingsFormat = shb.formatXML { enclosingRoot = \"Config\"; };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for sonarr.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.sonarr.settingsFormat.type;\n            options = {\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              LogLevel = lib.mkOption {\n                type = lib.types.enum [\n                  \"debug\"\n                  \"info\"\n                ];\n                description = \"Log level.\";\n                default = \"info\";\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which sonarr listens to incoming requests.\";\n                default = 8989;\n              };\n              BindAddress = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"127.0.0.1\";\n              };\n              UrlBase = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"\";\n              };\n              EnableSsl = lib.mkOption {\n                type = lib.types.bool;\n                internal = true;\n                default = false;\n              };\n              AuthenticationMethod = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"External\";\n              };\n              AuthenticationRequired = lib.mkOption {\n                type = lib.types.str;\n                internal = true;\n                default = \"Enabled\";\n              };\n            };\n          };\n        };\n      };\n    };\n    bazarr = {\n      settingsFormat = shb.formatXML { enclosingRoot = \"Config\"; };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for bazarr.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.bazarr.settingsFormat.type;\n            options = {\n              LogLevel = lib.mkOption {\n                type = lib.types.enum [\n                  \"debug\"\n                  \"info\"\n                ];\n                description = \"Log level.\";\n                default = \"info\";\n              };\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which bazarr listens to incoming requests.\";\n                default = 6767;\n                readOnly = true;\n              };\n            };\n          };\n        };\n      };\n    };\n    readarr = {\n      settingsFormat = shb.formatXML { enclosingRoot = \"Config\"; };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for readarr.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.readarr.settingsFormat.type;\n            options = {\n              LogLevel = lib.mkOption {\n                type = lib.types.enum [\n                  \"debug\"\n                  \"info\"\n                ];\n                description = \"Log level.\";\n                default = \"info\";\n              };\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which readarr listens to incoming requests.\";\n                default = 8787;\n              };\n            };\n          };\n        };\n      };\n    };\n    lidarr = {\n      settingsFormat = shb.formatXML { enclosingRoot = \"Config\"; };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for lidarr.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.lidarr.settingsFormat.type;\n            options = {\n              LogLevel = lib.mkOption {\n                type = lib.types.enum [\n                  \"debug\"\n                  \"info\"\n                ];\n                description = \"Log level.\";\n                default = \"info\";\n              };\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which lidarr listens to incoming requests.\";\n                default = 8686;\n              };\n            };\n          };\n        };\n      };\n    };\n    jackett = {\n      settingsFormat = pkgs.formats.json { };\n      moreOptions = {\n        settings = lib.mkOption {\n          description = \"Specific options for jackett.\";\n          default = { };\n          type = lib.types.submodule {\n            freeformType = apps.jackett.settingsFormat.type;\n            options = {\n              ApiKey = lib.mkOption {\n                type = shb.secretFileType;\n                description = \"Path to api key secret file.\";\n              };\n              FlareSolverrUrl = lib.mkOption {\n                type = lib.types.nullOr lib.types.str;\n                description = \"FlareSolverr endpoint.\";\n                default = null;\n              };\n              OmdbApiKey = lib.mkOption {\n                type = lib.types.nullOr shb.secretFileType;\n                description = \"File containing the Open Movie Database API key.\";\n                default = null;\n              };\n              ProxyType = lib.mkOption {\n                type = lib.types.enum [\n                  \"-1\"\n                  \"0\"\n                  \"1\"\n                  \"2\"\n                ];\n                default = \"-1\";\n                description = ''\n                  -1 = disabled\n                  0 = HTTP\n                  1 = SOCKS4\n                  2 = SOCKS5\n                '';\n              };\n              ProxyUrl = lib.mkOption {\n                type = lib.types.nullOr lib.types.str;\n                description = \"URL of the proxy. Ignored if ProxyType is set to -1\";\n                default = null;\n              };\n              ProxyPort = lib.mkOption {\n                type = lib.types.nullOr lib.types.port;\n                description = \"Port of the proxy. Ignored if ProxyType is set to -1\";\n                default = null;\n              };\n              Port = lib.mkOption {\n                type = lib.types.port;\n                description = \"Port on which jackett listens to incoming requests.\";\n                default = 9117;\n                readOnly = true;\n              };\n              AllowExternal = lib.mkOption {\n                type = lib.types.bool;\n                internal = true;\n                default = false;\n              };\n              UpdateDisabled = lib.mkOption {\n                type = lib.types.bool;\n                internal = true;\n                default = true;\n              };\n            };\n          };\n        };\n      };\n    };\n  };\n\n  vhosts =\n    {\n      extraBypassResources ? [ ],\n    }:\n    c: {\n      inherit (c)\n        subdomain\n        domain\n        authEndpoint\n        ssl\n        ;\n\n      upstream = \"http://127.0.0.1:${toString c.settings.Port}\";\n      autheliaRules = lib.optionals (!(isNull c.authEndpoint)) [\n        {\n          domain = \"${c.subdomain}.${c.domain}\";\n          policy = \"bypass\";\n          resources = extraBypassResources ++ [\n            \"^/api.*\"\n            \"^/feed.*\"\n          ];\n        }\n        {\n          domain = \"${c.subdomain}.${c.domain}\";\n          policy = \"two_factor\";\n          subject = [ \"group:${c.ldapUserGroup}\" ];\n        }\n      ];\n    };\n\n  appOption =\n    name: c:\n    lib.nameValuePair name (\n      lib.mkOption {\n        description = \"Configuration for ${name}\";\n        default = { };\n        type = lib.types.submodule {\n          options = {\n            enable = lib.mkEnableOption name;\n\n            subdomain = lib.mkOption {\n              type = lib.types.str;\n              description = \"Subdomain under which ${name} will be served.\";\n              example = name;\n            };\n\n            domain = lib.mkOption {\n              type = lib.types.str;\n              description = \"Domain under which ${name} will be served.\";\n              example = \"example.com\";\n            };\n\n            dataDir = lib.mkOption {\n              type = lib.types.str;\n              description = \"Directory where ${name} stores data.\";\n              default = \"/var/lib/${name}\";\n            };\n\n            ssl = lib.mkOption {\n              description = \"Path to SSL files\";\n              type = lib.types.nullOr shb.contracts.ssl.certs;\n              default = null;\n            };\n\n            ldapUserGroup = lib.mkOption {\n              description = ''\n                LDAP group a user must belong to be able to login.\n\n                Note that all users are admins too.\n              '';\n              type = lib.types.str;\n              default = \"arr_user\";\n            };\n\n            authEndpoint = lib.mkOption {\n              type = lib.types.nullOr lib.types.str;\n              default = null;\n              description = \"Endpoint to the SSO provider. Leave null to not have SSO configured.\";\n              example = \"https://authelia.example.com\";\n            };\n\n            backup = lib.mkOption {\n              description = ''\n                Backup configuration.\n              '';\n              default = { };\n              type = lib.types.submodule {\n                options = shb.contracts.backup.mkRequester {\n                  user = name;\n                  sourceDirectories = [\n                    cfg.${name}.dataDir\n                  ];\n                  excludePatterns = [\n                    \".db-shm\"\n                    \".db-wal\"\n                    \".mono\"\n                  ];\n                };\n              };\n            };\n\n            dashboard = lib.mkOption {\n              description = ''\n                Dashboard contract consumer\n              '';\n              default = { };\n              type = lib.types.submodule {\n                options = shb.contracts.dashboard.mkRequester {\n                  externalUrl = \"https://${cfg.${name}.subdomain}.${cfg.${name}.domain}\";\n                  externalUrlText = \"https://\\${config.shb.arr.${name}.subdomain}.\\${config.shb.arr.${name}.domain}\";\n                  internalUrl = \"http://127.0.0.1:${toString cfg.${name}.settings.Port}\";\n                };\n              };\n            };\n          }\n          // (c.moreOptions or { });\n        };\n      }\n    );\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n    ../blocks/lldap.nix\n  ];\n\n  options.shb.arr = lib.listToAttrs (lib.mapAttrsToList appOption apps);\n\n  config = lib.mkMerge [\n    (lib.mkIf cfg.radarr.enable (\n      let\n        cfg' = cfg.radarr;\n        isSSOEnabled = !(isNull cfg'.authEndpoint);\n      in\n      {\n        services.nginx.enable = true;\n\n        services.radarr = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n        };\n\n        systemd.services.radarr.preStart = shb.replaceSecrets {\n          userConfig =\n            cfg'.settings\n            // (lib.optionalAttrs isSSOEnabled {\n              AuthenticationRequired = \"DisabledForLocalAddresses\";\n              AuthenticationMethod = \"External\";\n            });\n          resultPath = \"${cfg'.dataDir}/config.xml\";\n          generator = shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat;\n        };\n\n        shb.nginx.vhosts = [ (vhosts { } cfg') ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.sonarr.enable (\n      let\n        cfg' = cfg.sonarr;\n        isSSOEnabled = !(isNull cfg'.authEndpoint);\n      in\n      {\n        systemd.tmpfiles.rules = [\n          \"d ${cfg'.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.user}\"\n        ];\n\n        services.nginx.enable = true;\n\n        services.sonarr = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n        };\n        users.users.sonarr = {\n          extraGroups = [ \"media\" ];\n        };\n\n        systemd.services.sonarr.preStart = shb.replaceSecrets {\n          userConfig =\n            cfg'.settings\n            // (lib.optionalAttrs isSSOEnabled {\n              AuthenticationRequired = \"DisabledForLocalAddresses\";\n              AuthenticationMethod = \"External\";\n            });\n          resultPath = \"${cfg'.dataDir}/config.xml\";\n          generator = apps.sonarr.settingsFormat.generate;\n        };\n\n        shb.nginx.vhosts = [ (vhosts { } cfg') ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.bazarr.enable (\n      let\n        cfg' = cfg.bazarr;\n        isSSOEnabled = !(isNull cfg'.authEndpoint);\n      in\n      {\n        services.bazarr = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n          listenPort = cfg'.settings.Port;\n        };\n        users.users.bazarr = {\n          extraGroups = [ \"media\" ];\n        };\n        # This is actually not working. Bazarr uses a config file in dataDir/config/config.yaml\n        # which includes all configuration so we must somehow merge our declarative config with it.\n        # It's doable but will take some time. Help is welcomed.\n        #\n        # systemd.services.bazarr.preStart = shb.replaceSecrets {\n        #   userConfig =\n        #     cfg'.settings\n        #     // (lib.optionalAttrs isSSOEnabled {\n        #       AuthenticationRequired = \"DisabledForLocalAddresses\";\n        #       AuthenticationMethod = \"External\";\n        #     });\n        #   resultPath = \"${cfg'.dataDir}/config.xml\";\n        #   generator = apps.bazarr.settingsFormat.generate;\n        # };\n\n        shb.nginx.vhosts = [ (vhosts { } cfg') ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.readarr.enable (\n      let\n        cfg' = cfg.readarr;\n        isSSOEnabled = !(isNull cfg'.authEndpoint);\n      in\n      {\n        services.readarr = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n        };\n        users.users.readarr = {\n          extraGroups = [ \"media\" ];\n        };\n        systemd.services.readarr.preStart = shb.replaceSecrets {\n          userConfig =\n            cfg'.settings\n            // (lib.optionalAttrs isSSOEnabled {\n              AuthenticationRequired = \"DisabledForLocalAddresses\";\n              AuthenticationMethod = \"External\";\n            });\n          resultPath = \"${cfg'.dataDir}/config.xml\";\n          generator = apps.readarr.settingsFormat.generate;\n        };\n\n        shb.nginx.vhosts = [ (vhosts { } cfg') ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.lidarr.enable (\n      let\n        cfg' = cfg.lidarr;\n        isSSOEnabled = !(isNull cfg'.authEndpoint);\n      in\n      {\n        services.lidarr = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n        };\n        users.users.lidarr = {\n          extraGroups = [ \"media\" ];\n        };\n        systemd.services.lidarr.preStart = shb.replaceSecrets {\n          userConfig =\n            cfg'.settings\n            // (lib.optionalAttrs isSSOEnabled {\n              AuthenticationRequired = \"DisabledForLocalAddresses\";\n              AuthenticationMethod = \"External\";\n            });\n          resultPath = \"${cfg'.dataDir}/config.xml\";\n          generator = apps.lidarr.settingsFormat.generate;\n        };\n\n        shb.nginx.vhosts = [ (vhosts { } cfg') ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.jackett.enable (\n      let\n        cfg' = cfg.jackett;\n      in\n      {\n        services.jackett = {\n          enable = true;\n          dataDir = cfg'.dataDir;\n        };\n        # TODO: avoid implicitly relying on the media group\n        users.users.jackett = {\n          extraGroups = [ \"media\" ];\n        };\n        systemd.services.jackett.preStart = shb.replaceSecrets {\n          userConfig = shb.renameAttrName cfg'.settings \"ApiKey\" \"APIKey\";\n          resultPath = \"${cfg'.dataDir}/ServerConfig.json\";\n          generator = apps.jackett.settingsFormat.generate;\n        };\n\n        shb.nginx.vhosts = [\n          (vhosts {\n            extraBypassResources = [ \"^/dl.*\" ];\n          } cfg')\n        ];\n\n        shb.lldap.ensureGroups = {\n          ${cfg'.ldapUserGroup} = { };\n        };\n      }\n    ))\n  ];\n}\n"
  },
  {
    "path": "modules/services/audiobookshelf/docs/default.md",
    "content": "# Audiobookshelf Service {#services-audiobookshelf}\n\nDefined in [`/modules/services/audiobookshelf.nix`](@REPO@/modules/services/audiobookshelf.nix).\n\nThis NixOS module is a service that sets up a [Audiobookshelf](https://www.audiobookshelf.org/) instance.\n\n## Features {#services-audiobookshelf-features}\n\n- Declarative selection of listening port.\n- Access through [subdomain](#services-audiobookshelf-options-shb.audiobookshelf.subdomain) using reverse proxy. [Manual](#services-audiobookshelf-usage-configuration).\n- Access through [HTTPS](#services-audiobookshelf-options-shb.audiobookshelf.ssl) using reverse proxy. [Manual](#services-audiobookshelf-usage-https).\n- Declarative [SSO](#services-audiobookshelf-options-shb.audiobookshelf.sso) configuration (Manual setup in app required). [Manual](#services-audiobookshelf-usage-sso).\n- [Backup](#services-audiobookshelf-options-shb.audiobookshelf.backup) through the [backup block](./blocks-backup.html). [Manual](#services-audiobookshelf-usage-backup).\n\n## Usage {#services-audiobookshelf-usage}\n\n### Login\n\nUpon first login, Audiobookshelf will ask you to create a root user. This user will be used to\nset up [SSO]{#services-audiobookshelf-usage-sso}, or to provision admin privileges to other users.\n\n### With SSO Support {#services-audiobookshelf-usage-sso}\n\n:::: {.note}\nSome manual setup in the app is required.\n::::\n\nWe will use the [SSO block][] provided by Self Host Blocks.\nAssuming it [has been set already][SSO block setup], add the following configuration:\n\n[SSO block]: blocks-sso.html\n[SSO block setup]: blocks-sso.html#blocks-sso-global-setup\n\n```nix\nshb.audiobookshelf.sso = {\n  enable = true;\n  endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n  secretFile = <path/to/oidcJellyfinSharedSecret>;\n  secretFileForAuthelia = <path/to/oidcJellyfinSharedSecret>;\n};\n```\n\nThe `shb.audiobookshelf.sso.secretFile` and `shb.audiobookshelf.sso.secretFileForAuthelia` options\nmust have the same content. The former is a file that must be owned by the `audiobookshelf` user while\nthe latter must be owned by the `authelia` user. I want to avoid needing to define the same secret\ntwice with a future secrets SHB block.\n\nIn the Audiobookshelf app, you can now log in with your Audiobookshelf root user and go to\n\"Settings->Authentication\", and then enable \"OpenID Connect Authentication\" and enter your\n\"Issuer URL\" (e.g. `https://auth.example.com`). Then click the \"Auto-populate\" button. Next, paste\nin the client secret (from `secrets.yaml`). Then set up \"Client ID\" to be `audiobookshelf`. Make\nsure to also select `None` in \"Subfolder for Redirect URLs\". Then make sure to tick \"Auto Register\".\nYou can also tick \"Auto Launch\" to make Audiobookshelf automatically redirect users to the SSO\nsign-in page instead. This can later be circumvented by accessing\n`https://<your-domain>/login?autoLaunch=0`, if you're having SSO issues.\nFinally, set \"Group Claim\" to `audiobookshelf_groups`. This enables Audiobookshelf to allow access\nonly to users belonging to `userGroup` (default `audiobookshelf_user`), and to grant admin\nprivileges to members of `adminUserGroup` (default `audiobookshelf_admin`).\n\nSave the settings and restart the Audiobookshelf service (`systemctl restart audiobookshelf.service`).\n\nYou should now be able to log in with users belonging to either of the aforementioned allowed groups.\n"
  },
  {
    "path": "modules/services/audiobookshelf.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.audiobookshelf;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  roleClaim = \"audiobookshelf_groups\";\nin\n{\n  options.shb.audiobookshelf = {\n    enable = lib.mkEnableOption \"selfhostblocks.audiobookshelf\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which audiobookshelf will be served.\";\n      example = \"abs\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which audiobookshelf will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    webPort = lib.mkOption {\n      type = lib.types.int;\n      description = \"Audiobookshelf web port\";\n      default = 8113;\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    extraServiceConfig = lib.mkOption {\n      type = lib.types.attrsOf lib.types.str;\n      description = \"Extra configuration given to the systemd service file.\";\n      default = { };\n      example = lib.literalExpression ''\n        {\n          MemoryHigh = \"512M\";\n          MemoryMax = \"900M\";\n        }\n      '';\n    };\n\n    sso = lib.mkOption {\n      description = \"SSO configuration.\";\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO\";\n\n          provider = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC provider name\";\n            default = \"Authelia\";\n          };\n\n          endpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC endpoint for SSO\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = lib.mkOption {\n            type = lib.types.str;\n            description = \"Client ID for the OIDC endpoint\";\n            default = \"audiobookshelf\";\n          };\n\n          adminUserGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC admin group\";\n            default = \"audiobookshelf_admin\";\n          };\n\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC user group\";\n            default = \"audiobookshelf_user\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = lib.types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = lib.mkOption {\n            description = \"OIDC shared secret for Audiobookshelf.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"audiobookshelf\";\n                group = \"audiobookshelf\";\n                restartUnits = [ \"audiobookshelfd.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret for Authelia.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                ownerText = \"config.shb.authelia.autheliaUser\";\n                owner = config.shb.authelia.autheliaUser;\n              };\n            };\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"audiobookshelf\";\n          sourceDirectories = [\n            \"/var/lib/audiobookshelf\"\n          ];\n        };\n      };\n    };\n\n    logLevel = lib.mkOption {\n      type = lib.types.nullOr (\n        lib.types.enum [\n          \"critical\"\n          \"error\"\n          \"warning\"\n          \"info\"\n          \"debug\"\n        ]\n      );\n      description = \"Enable logging.\";\n      default = false;\n      example = true;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.audiobookshelf.subdomain}.\\${config.shb.audiobookshelf.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.webPort}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable (\n    lib.mkMerge [\n      {\n\n        services.audiobookshelf = {\n          enable = true;\n          openFirewall = true;\n          dataDir = \"audiobookshelf\";\n          host = \"127.0.0.1\";\n          port = cfg.webPort;\n        };\n\n        services.nginx.enable = true;\n        services.nginx.virtualHosts.\"${fqdn}\" = {\n          http2 = true;\n          forceSSL = !(isNull cfg.ssl);\n          sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n          sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n          # https://github.com/advplyr/audiobookshelf#nginx-reverse-proxy\n          extraConfig = ''\n            set $audiobookshelf 127.0.0.1;\n            location / {\n                 proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;\n                 proxy_set_header  X-Forwarded-Proto $scheme;\n                 proxy_set_header  Host              $host;\n                 proxy_set_header Upgrade            $http_upgrade;\n                 proxy_set_header Connection         \"upgrade\";\n\n                 proxy_http_version                  1.1;\n\n                 proxy_pass                          http://$audiobookshelf:${builtins.toString cfg.webPort};\n                 proxy_redirect                      http:// https://;\n               }\n          '';\n        };\n\n        shb.authelia.extraDefinitions = {\n          user_attributes.${roleClaim}.expression =\n            ''\"${cfg.sso.adminUserGroup}\" in groups ? [\"admin\"] : (\"${cfg.sso.userGroup}\" in groups ? [\"user\"] : [\"\"])'';\n        };\n\n        shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {\n          custom_claims = {\n            \"${roleClaim}\" = { };\n          };\n        };\n\n        shb.authelia.extraOidcScopes.\"${roleClaim}\" = {\n          claims = [ \"${roleClaim}\" ];\n        };\n\n        shb.authelia.oidcClients = lib.lists.optionals cfg.sso.enable [\n          {\n            client_id = cfg.sso.clientID;\n            client_name = \"Audiobookshelf\";\n            client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n            claims_policy = \"${roleClaim}\";\n            public = false;\n            authorization_policy = cfg.sso.authorization_policy;\n            redirect_uris = [\n              \"https://${cfg.subdomain}.${cfg.domain}/auth/openid/callback\"\n              \"https://${cfg.subdomain}.${cfg.domain}/auth/openid/mobile-redirect\"\n            ];\n            scopes = [\n              \"openid\"\n              \"profile\"\n              \"email\"\n              \"groups\"\n              \"${roleClaim}\"\n            ];\n            require_pkce = true;\n            pkce_challenge_method = \"S256\";\n            userinfo_signed_response_alg = \"none\";\n            token_endpoint_auth_method = \"client_secret_basic\";\n          }\n        ];\n      }\n      {\n        systemd.services.audiobookshelfd.serviceConfig = cfg.extraServiceConfig;\n      }\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/deluge/dashboard/Torrents.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"datasource\",\n          \"uid\": \"grafana\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"target\": {\n          \"limit\": 100,\n          \"matchAny\": false,\n          \"tags\": [],\n          \"type\": \"dashboard\"\n        },\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 12,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 15,\n      \"panels\": [],\n      \"title\": \"Torrent\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"decimals\": 1,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"min\": 0,\n          \"thresholds\": {\n            \"mode\": \"percentage\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          },\n          \"unit\": \"bytes\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 5,\n        \"w\": 3,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 19,\n      \"maxPerRow\": 3,\n      \"options\": {\n        \"minVizHeight\": 75,\n        \"minVizWidth\": 75,\n        \"orientation\": \"auto\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showThresholdLabels\": false,\n        \"showThresholdMarkers\": true,\n        \"sizing\": \"auto\",\n        \"text\": {}\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"repeat\": \"mountpoint\",\n      \"repeatDirection\": \"v\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"node_filesystem_size_bytes{hostname=~\\\"$hostname\\\",mountpoint=\\\"$mountpoint\\\"} - node_filesystem_free_bytes{hostname=~\\\"$hostname\\\",mountpoint=\\\"$mountpoint\\\"}\",\n          \"instant\": false,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"node_filesystem_size_bytes{hostname=~\\\"$hostname\\\",mountpoint=\\\"$mountpoint\\\"}\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"conditions\": [\n            {\n              \"evaluator\": {\n                \"params\": [\n                  0,\n                  0\n                ],\n                \"type\": \"gt\"\n              },\n              \"query\": {\n                \"params\": []\n              },\n              \"reducer\": {\n                \"params\": [],\n                \"type\": \"last\"\n              },\n              \"type\": \"query\"\n            }\n          ],\n          \"datasource\": {\n            \"name\": \"Expression\",\n            \"type\": \"__expr__\",\n            \"uid\": \"__expr__\"\n          },\n          \"expression\": \"$B*0.95\",\n          \"hide\": false,\n          \"refId\": \"D\",\n          \"type\": \"math\"\n        }\n      ],\n      \"title\": \"$mountpoint Used Space\",\n      \"transformations\": [\n        {\n          \"id\": \"configFromData\",\n          \"options\": {\n            \"applyTo\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"configRefId\": \"B\",\n            \"mappings\": [\n              {\n                \"fieldName\": \"Time\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"device\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"domain\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"fstype\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"hostname\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"instance\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"job\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"mountpoint\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"{__name__=\\\"node_filesystem_size_bytes\\\", device=\\\"data/movies\\\", fstype=\\\"zfs\\\", instance=\\\"127.0.0.1:9112\\\", job=\\\"node\\\", mountpoint=\\\"/srv/movies\\\"}\",\n                \"handlerKey\": \"max\"\n              },\n              {\n                \"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\\\"}\",\n                \"handlerKey\": \"max\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"configFromData\",\n          \"options\": {\n            \"applyTo\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"configRefId\": \"D\",\n            \"mappings\": [\n              {\n                \"fieldName\": \"D {__name__=\\\"node_filesystem_size_bytes\\\", device=\\\"data/movies\\\", fstype=\\\"zfs\\\", instance=\\\"127.0.0.1:9112\\\", job=\\\"node\\\", mountpoint=\\\"/srv/movies\\\"}\",\n                \"handlerArguments\": {\n                  \"threshold\": {\n                    \"color\": \"red\"\n                  }\n                },\n                \"handlerKey\": \"threshold1\"\n              }\n            ]\n          }\n        }\n      ],\n      \"type\": \"gauge\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"yellow\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 15,\n        \"w\": 4,\n        \"x\": 3,\n        \"y\": 1\n      },\n      \"id\": 17,\n      \"options\": {\n        \"colorMode\": \"none\",\n        \"graphMode\": \"area\",\n        \"justifyMode\": \"auto\",\n        \"orientation\": \"auto\",\n        \"percentChangeColorMode\": \"standard\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showPercentChange\": false,\n        \"textMode\": \"auto\",\n        \"wideLayout\": true\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": true,\n          \"expr\": \"deluge_torrents{hostname=~\\\"$hostname\\\"}\",\n          \"instant\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"{{state}}\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Torrent States\",\n      \"type\": \"stat\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"max\": 1,\n          \"min\": 0,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"percentunit\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 7,\n        \"w\": 17,\n        \"x\": 7,\n        \"y\": 1\n      },\n      \"id\": 23,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 350\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"deluge_torrent_done_total{hostname=~\\\"$hostname\\\",state=\\\"downloading\\\",name=~\\\"$torrent\\\"} / deluge_torrent_size_total{hostname=~\\\"$hostname\\\",state=\\\"downloading\\\",name=~\\\"$torrent\\\"}\",\n          \"legendFormat\": \"{{name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"In Progress Downloads\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [\n            {\n              \"options\": {\n                \"-1\": {\n                  \"index\": 0,\n                  \"text\": \"Never\"\n                }\n              },\n              \"type\": \"value\"\n            }\n          ],\n          \"max\": 86400,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"semi-dark-red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"semi-dark-green\",\n                \"value\": 0\n              },\n              {\n                \"color\": \"semi-dark-orange\",\n                \"value\": 86400\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 6,\n        \"x\": 7,\n        \"y\": 8\n      },\n      \"id\": 31,\n      \"options\": {\n        \"displayMode\": \"basic\",\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"maxVizHeight\": 300,\n        \"minVizHeight\": 16,\n        \"minVizWidth\": 8,\n        \"namePlacement\": \"auto\",\n        \"orientation\": \"horizontal\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showUnfilled\": true,\n        \"sizing\": \"auto\",\n        \"valueMode\": \"color\"\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"deluge_torrent_time_since_download{hostname=~\\\"$hostname\\\",state=\\\"downloading\\\",name=~\\\"$torrent\\\"}\",\n          \"instant\": true,\n          \"interval\": \"\",\n          \"legendFormat\": \"{{name}}\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Last Download\",\n      \"transformations\": [\n        {\n          \"id\": \"seriesToRows\",\n          \"options\": {}\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": false,\n                \"field\": \"Value\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"rowsToFields\",\n          \"options\": {\n            \"mappings\": [\n              {\n                \"fieldName\": \"Time\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"Value\",\n                \"handlerKey\": \"field.value\"\n              },\n              {\n                \"fieldName\": \"Metric\",\n                \"handlerKey\": \"field.name\"\n              }\n            ]\n          }\n        }\n      ],\n      \"type\": \"bargauge\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"mappings\": [\n            {\n              \"options\": {\n                \"1642487291\": {\n                  \"color\": \"semi-dark-red\",\n                  \"index\": 0,\n                  \"text\": \"Never\"\n                }\n              },\n              \"type\": \"value\"\n            }\n          ],\n          \"max\": 3600,\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"semi-dark-green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"#EAB839\",\n                \"value\": 86400\n              },\n              {\n                \"color\": \"semi-dark-red\",\n                \"value\": 1642487290\n              }\n            ]\n          },\n          \"unit\": \"s\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 6,\n        \"x\": 13,\n        \"y\": 8\n      },\n      \"id\": 29,\n      \"options\": {\n        \"displayMode\": \"basic\",\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"maxVizHeight\": 300,\n        \"minVizHeight\": 16,\n        \"minVizWidth\": 8,\n        \"namePlacement\": \"auto\",\n        \"orientation\": \"horizontal\",\n        \"reduceOptions\": {\n          \"calcs\": [\n            \"lastNotNull\"\n          ],\n          \"fields\": \"\",\n          \"values\": false\n        },\n        \"showUnfilled\": true,\n        \"sizing\": \"auto\",\n        \"valueMode\": \"color\"\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": false,\n          \"expr\": \"time()-deluge_torrent_last_seen_complete{hostname=~\\\"$hostname\\\",state=\\\"downloading\\\",name=~\\\"$torrent\\\"}\",\n          \"instant\": true,\n          \"interval\": \"\",\n          \"legendFormat\": \"{{name}}\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Last Seen Completed\",\n      \"transformations\": [\n        {\n          \"id\": \"seriesToRows\",\n          \"options\": {}\n        },\n        {\n          \"id\": \"sortBy\",\n          \"options\": {\n            \"fields\": {},\n            \"sort\": [\n              {\n                \"desc\": true,\n                \"field\": \"Value\"\n              }\n            ]\n          }\n        },\n        {\n          \"id\": \"rowsToFields\",\n          \"options\": {\n            \"mappings\": [\n              {\n                \"fieldName\": \"Time\",\n                \"handlerKey\": \"__ignore\"\n              },\n              {\n                \"fieldName\": \"Value\",\n                \"handlerKey\": \"field.value\"\n              },\n              {\n                \"fieldName\": \"Metric\",\n                \"handlerKey\": \"field.name\"\n              }\n            ]\n          }\n        }\n      ],\n      \"type\": \"bargauge\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"Bps\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 5,\n        \"x\": 19,\n        \"y\": 8\n      },\n      \"id\": 35,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"exemplar\": true,\n          \"expr\": \"avg by(device) (rate(node_network_receive_bytes_total{hostname=~\\\"$hostname\\\",device=~\\\"tun.*\\\"}[5m]))\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"interval\": \"\",\n          \"legendFormat\": \"in: {{ device }}\",\n          \"range\": true,\n          \"refId\": \"A\",\n          \"useBackend\": false\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": true,\n          \"expr\": \"-avg by(device) (rate(node_network_transmit_bytes_total{device=~\\\"tun.*\\\"}[5m]))\",\n          \"hide\": false,\n          \"interval\": \"\",\n          \"legendFormat\": \"out: {{ device }}\",\n          \"range\": true,\n          \"refId\": \"B\"\n        }\n      ],\n      \"title\": \"VPN Network I/O\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 16\n      },\n      \"id\": 9,\n      \"panels\": [],\n      \"title\": \"Services\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 4,\n        \"w\": 8,\n        \"x\": 0,\n        \"y\": 17\n      },\n      \"id\": 6,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"exemplar\": true,\n          \"expr\": \"netdata_systemd_service_unit_state_state_average{hostname=~\\\"$hostname\\\",unit_name=~\\\"deluged|delugeweb|openvpn.+\\\",dimension=\\\"active\\\"}\",\n          \"interval\": \"\",\n          \"legendFormat\": \"{{unit_name}}\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Services Up\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 9,\n        \"w\": 16,\n        \"x\": 8,\n        \"y\": 17\n      },\n      \"id\": 2,\n      \"options\": {\n        \"dedupStrategy\": \"exact\",\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"deluged.service\\\",level=~\\\"$level\\\"}\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Deluge Logs\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 5,\n        \"w\": 4,\n        \"x\": 0,\n        \"y\": 21\n      },\n      \"id\": 4,\n      \"options\": {\n        \"dedupStrategy\": \"exact\",\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": false,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"deluged.service\\\"} |= \\\"on_alert_external_ip\\\" | regexp \\\".+on_alert_external_ip: (?P<ip>.+)\\\" | line_format \\\"{{.ip}}\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Latest External IPs\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 5,\n        \"w\": 4,\n        \"x\": 4,\n        \"y\": 21\n      },\n      \"id\": 13,\n      \"options\": {\n        \"dedupStrategy\": \"exact\",\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": false,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=~\\\"openvpn.+.service\\\"} |= \\\"config -s listen_interface\\\" | pattern \\\"<_> listen_interface <ip>'\\\" | line_format \\\"{{.ip}}\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Latest Interface IPs\",\n      \"type\": \"logs\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 9,\n        \"w\": 16,\n        \"x\": 8,\n        \"y\": 26\n      },\n      \"id\": 7,\n      \"options\": {\n        \"dedupStrategy\": \"exact\",\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": true,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=~\\\"openvpn.+.service\\\",level=~\\\"$level\\\"}\",\n          \"legendFormat\": \"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"VPN Logs\",\n      \"type\": \"logs\"\n    }\n  ],\n  \"preload\": false,\n  \"refresh\": \"10s\",\n  \"schemaVersion\": 40,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": [\n            \"$__all\"\n          ],\n          \"value\": [\n            \"$__all\"\n          ]\n        },\n        \"hide\": 2,\n        \"includeAll\": true,\n        \"multi\": true,\n        \"name\": \"mountpoint\",\n        \"options\": [\n          {\n            \"selected\": false,\n            \"text\": \"/srv/movies\",\n            \"value\": \"/srv/movies\"\n          },\n          {\n            \"selected\": false,\n            \"text\": \"/srv/music\",\n            \"value\": \"/srv/music\"\n          },\n          {\n            \"selected\": false,\n            \"text\": \"/srv/series\",\n            \"value\": \"/srv/series\"\n          }\n        ],\n        \"query\": \"/srv/movies,/srv/music,/srv/series\",\n        \"type\": \"custom\"\n      },\n      {\n        \"current\": {\n          \"text\": \"baryum\",\n          \"value\": \"baryum\"\n        },\n        \"definition\": \"label_values(up,hostname)\",\n        \"name\": \"hostname\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(up,hostname)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      },\n      {\n        \"current\": {\n          \"text\": \"All\",\n          \"value\": \"$__all\"\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"deluge_torrent_done_total\",\n        \"includeAll\": true,\n        \"multi\": true,\n        \"name\": \"torrent\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 4,\n          \"query\": \"deluge_torrent_done_total\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 2,\n        \"regex\": \"/.*name=\\\"(?<text>[^\\\"]+)\\\".*/\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-1h\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"\",\n  \"title\": \"Torrents\",\n  \"uid\": \"Bg5L6T17k\",\n  \"version\": 22,\n  \"weekStart\": \"\"\n}\n"
  },
  {
    "path": "modules/services/deluge.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.deluge;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  authGenerator =\n    users:\n    let\n      genLine =\n        name:\n        {\n          password,\n          priority ? 10,\n        }:\n        \"${name}:${password}:${toString priority}\";\n\n      lines = lib.mapAttrsToList genLine users;\n    in\n    lib.concatStringsSep \"\\n\" lines;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n    ../blocks/monitoring.nix\n  ];\n\n  options.shb.deluge = {\n    enable = lib.mkEnableOption \"the SHB Deluge service\";\n\n    enableDashboard = lib.mkEnableOption \"the Torrents SHB monitoring dashboard\" // {\n      default = true;\n    };\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which deluge will be served.\";\n      example = \"ha\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which deluge will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    dataDir = lib.mkOption {\n      type = lib.types.str;\n      description = \"Path where all configuration and state is stored.\";\n      default = \"/var/lib/deluge\";\n    };\n\n    daemonPort = lib.mkOption {\n      type = lib.types.int;\n      description = \"Deluge daemon port\";\n      default = 58846;\n    };\n\n    daemonListenPorts = lib.mkOption {\n      type = lib.types.listOf lib.types.int;\n      description = \"Deluge daemon listen ports\";\n      default = [\n        6881\n        6889\n      ];\n    };\n\n    webPort = lib.mkOption {\n      type = lib.types.int;\n      description = \"Deluge web port\";\n      default = 8112;\n    };\n\n    proxyPort = lib.mkOption {\n      description = \"If not null, sets up a deluge to forward all traffic to the Proxy listening at that port.\";\n      type = lib.types.nullOr lib.types.int;\n      default = null;\n    };\n\n    outgoingInterface = lib.mkOption {\n      description = \"If not null, sets up a deluge to bind all outgoing traffic to the given interface.\";\n      type = lib.types.nullOr lib.types.str;\n      default = null;\n    };\n\n    settings = lib.mkOption {\n      description = \"Deluge operational settings.\";\n      type = lib.types.submodule {\n        options = {\n          downloadLocation = lib.mkOption {\n            type = lib.types.str;\n            description = \"Folder where torrents gets downloaded\";\n            example = \"/srv/torrents\";\n          };\n\n          max_active_limit = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Active Limit\";\n            default = 200;\n          };\n          max_active_downloading = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Active Downloading\";\n            default = 30;\n          };\n          max_active_seeding = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Active Seeding\";\n            default = 100;\n          };\n          max_connections_global = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Connections Global\";\n            default = 200;\n          };\n          max_connections_per_torrent = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Connections Per Torrent\";\n            default = 50;\n          };\n\n          max_download_speed = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Download Speed\";\n            default = 1000;\n          };\n          max_download_speed_per_torrent = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Download Speed Per Torrent\";\n            default = -1;\n          };\n\n          max_upload_slots_global = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Upload Slots Global\";\n            default = 100;\n          };\n          max_upload_slots_per_torrent = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Upload Slots Per Torrent\";\n            default = 4;\n          };\n          max_upload_speed = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Upload Speed\";\n            default = 200;\n          };\n          max_upload_speed_per_torrent = lib.mkOption {\n            type = lib.types.int;\n            description = \"Maximum Upload Speed Per Torrent\";\n            default = 50;\n          };\n\n          dont_count_slow_torrents = lib.mkOption {\n            type = lib.types.bool;\n            description = \"Do not count slow torrents towards any limits.\";\n            default = true;\n          };\n        };\n      };\n    };\n\n    extraServiceConfig = lib.mkOption {\n      type = lib.types.attrsOf lib.types.str;\n      description = \"Extra configuration given to the systemd service file.\";\n      default = { };\n      example = lib.literalExpression ''\n        {\n          MemoryHigh = \"512M\";\n          MemoryMax = \"900M\";\n        }\n      '';\n    };\n\n    authEndpoint = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = \"OIDC endpoint for SSO\";\n      default = null;\n      example = \"https://authelia.example.com\";\n    };\n\n    extraUsers = lib.mkOption {\n      description = \"Users having access to this deluge instance. Attrset of username to user options.\";\n      type = lib.types.attrsOf (\n        lib.types.submodule {\n          options = {\n            password = lib.mkOption {\n              type = shb.secretFileType;\n              description = \"File containing the user password.\";\n            };\n          };\n        }\n      );\n    };\n\n    localclientPassword = lib.mkOption {\n      description = \"Password for mandatory localclient user.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          owner = \"deluge\";\n          restartUnits = [ \"deluged.service\" ];\n        };\n      };\n    };\n\n    prometheusScraperPassword = lib.mkOption {\n      description = \"Password for prometheus scraper. Setting this option will activate the prometheus deluge exporter.\";\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = shb.contracts.secret.mkRequester {\n            owner = \"deluge\";\n            restartUnits = [\n              \"deluged.service\"\n              \"prometheus.service\"\n            ];\n          };\n        }\n      );\n      default = null;\n    };\n\n    enabledPlugins = lib.mkOption {\n      type = lib.types.listOf lib.types.str;\n      description = ''\n        Plugins to enable, can include those from additionalPlugins.\n\n        Label is automatically enabled if any of the `shb.arr.*` service is enabled.\n      '';\n      example = [ \"Label\" ];\n      default = [ ];\n    };\n\n    additionalPlugins = lib.mkOption {\n      type = lib.types.listOf lib.types.path;\n      description = \"Location of additional plugins. Each item in the list must be the path to the directory containing the plugin .egg file.\";\n      default = [ ];\n      example = lib.literalExpression ''\n        additionalPlugins = [\n          (pkgs.callPackage ({ python3, fetchFromGitHub }: python3.pkgs.buildPythonPackage {\n            name = \"deluge-autotracker\";\n            version = \"1.0.0\";\n            src = fetchFromGitHub {\n              owner = \"ibizaman\";\n              repo = \"deluge-autotracker\";\n              rev = \"cc40d816a497bbf1c2ebeb3d8b1176210548a3e6\";\n              sha256 = \"sha256-0LpVdv1fak2a5eX4unjhUcN7nMAl9fgpr3X+7XnQE6c=\";\n            } + \"/autotracker\";\n            doCheck = false;\n            format = \"other\";\n            nativeBuildInputs = [ python3.pkgs.setuptools ];\n            buildPhase = '''\n            mkdir \"$out\"\n            python3 setup.py install --install-lib \"$out\"\n            ''';\n            doInstallPhase = false;\n          }) {})\n        ];\n      '';\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"deluge\";\n          sourceDirectories = [\n            cfg.dataDir\n          ];\n        };\n      };\n    };\n\n    logLevel = lib.mkOption {\n      type = lib.types.nullOr (\n        lib.types.enum [\n          \"critical\"\n          \"error\"\n          \"warning\"\n          \"info\"\n          \"debug\"\n        ]\n      );\n      description = \"Enable logging.\";\n      default = null;\n      example = \"info\";\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${fqdn}\";\n          externalUrlText = \"https://\\${config.shb.deluge.subdomain}.\\${config.shb.deluge.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.webPort}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable (\n    lib.mkMerge [\n      {\n        services.deluge = {\n          enable = true;\n          declarative = true;\n          openFirewall = true;\n          inherit (cfg) dataDir;\n\n          config = {\n            download_location = cfg.settings.downloadLocation;\n            allow_remote = true;\n            daemon_port = cfg.daemonPort;\n            listen_ports = cfg.daemonListenPorts;\n            proxy = lib.optionalAttrs (cfg.proxyPort != null) {\n              force_proxy = true;\n              hostname = \"127.0.0.1\";\n              port = cfg.proxyPort;\n              proxy_hostnames = true;\n              proxy_peer_connections = true;\n              proxy_tracker_connections = true;\n              type = 4; # HTTP\n            };\n            outgoing_interface = cfg.outgoingInterface;\n\n            enabled_plugins =\n              cfg.enabledPlugins\n              ++ lib.optional (lib.any (x: x.enable) [\n                config.services.radarr\n                config.services.sonarr\n                config.services.bazarr\n                config.services.readarr\n                config.services.lidarr\n              ]) \"Label\";\n\n            inherit (cfg.settings)\n              max_active_limit\n              max_active_downloading\n              max_active_seeding\n              max_connections_global\n              max_connections_per_torrent\n\n              max_download_speed\n              max_download_speed_per_torrent\n\n              max_upload_slots_global\n              max_upload_slots_per_torrent\n              max_upload_speed\n              max_upload_speed_per_torrent\n\n              dont_count_slow_torrents\n              ;\n\n            new_release_check = false;\n          };\n\n          authFile = \"${cfg.dataDir}/.config/deluge/authTemplate\";\n\n          web.enable = true;\n          web.port = cfg.webPort;\n        };\n\n        systemd.services.deluged.preStart = lib.mkBefore (\n          shb.replaceSecrets {\n            userConfig =\n              cfg.extraUsers\n              // {\n                localclient.password.source = config.shb.deluge.localclientPassword.result.path;\n              }\n              // (lib.optionalAttrs (config.shb.deluge.prometheusScraperPassword != null) {\n                prometheus_scraper.password.source = config.shb.deluge.prometheusScraperPassword.result.path;\n              });\n            resultPath = \"${cfg.dataDir}/.config/deluge/authTemplate\";\n            generator = name: value: pkgs.writeText \"delugeAuth\" (authGenerator value);\n          }\n        );\n\n        systemd.services.deluged.serviceConfig.ExecStart = lib.mkForce (\n          lib.concatStringsSep \" \\\\\\n    \" (\n            [\n              \"${config.services.deluge.package}/bin/deluged\"\n              \"--do-not-daemonize\"\n              \"--config ${cfg.dataDir}/.config/deluge\"\n            ]\n            ++ (lib.optional (!(isNull cfg.logLevel)) \"-L ${cfg.logLevel}\")\n          )\n        );\n\n        systemd.tmpfiles.rules =\n          let\n            plugins = pkgs.symlinkJoin {\n              name = \"deluge-plugins\";\n              paths = cfg.additionalPlugins;\n            };\n          in\n          [\n            \"L+ ${cfg.dataDir}/.config/deluge/plugins - - - - ${plugins}\"\n          ];\n\n        shb.nginx.vhosts = [\n          (\n            {\n              inherit (cfg) subdomain domain ssl;\n              upstream = \"http://127.0.0.1:${toString config.services.deluge.web.port}\";\n              autheliaRules = lib.mkIf (cfg.authEndpoint != null) [\n                {\n                  domain = fqdn;\n                  policy = \"bypass\";\n                  resources = [\n                    \"^/json\"\n                  ];\n                }\n                {\n                  domain = fqdn;\n                  policy = \"two_factor\";\n                  subject = [ \"group:deluge_user\" ];\n                }\n              ];\n            }\n            // (lib.optionalAttrs (cfg.authEndpoint != null) {\n              inherit (cfg) authEndpoint;\n            })\n          )\n        ];\n      }\n      {\n        systemd.services.deluged.serviceConfig = cfg.extraServiceConfig;\n      }\n      (lib.mkIf (config.shb.deluge.prometheusScraperPassword != null) {\n        services.prometheus.exporters.deluge = {\n          enable = true;\n\n          delugeHost = \"127.0.0.1\";\n          delugePort = config.services.deluge.config.daemon_port;\n          delugeUser = \"prometheus_scraper\";\n          delugePasswordFile = config.shb.deluge.prometheusScraperPassword.result.path;\n          exportPerTorrentMetrics = true;\n        };\n\n        services.prometheus.scrapeConfigs = [\n          {\n            job_name = \"deluge\";\n            static_configs = [\n              {\n                targets = [ \"127.0.0.1:${toString config.services.prometheus.exporters.deluge.port}\" ];\n                labels = {\n                  \"hostname\" = config.networking.hostName;\n                  \"domain\" = cfg.domain;\n                };\n              }\n            ];\n          }\n        ];\n      })\n\n      (lib.mkIf (cfg.enable && cfg.enableDashboard) {\n        shb.monitoring.dashboards = [\n          ./deluge/dashboard/Torrents.json\n        ];\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/firefly-iii/docs/default.md",
    "content": "# Firefly-iii Service {#services-firefly-iii}\n\nDefined in [`/modules/services/firefly-iii.nix`](@REPO@/modules/services/firefly-iii.nix).\n\nThis NixOS module is a service that sets up a [Firefly-iii](https://www.firefly-iii.org/) instance.\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner,\nLDAP and SSO integration\nand has a nicer option for secrets.\nIt also sets up the Firefly-iii data importer service\nand nearly automatically links it to the Firefly-iii instance using a Personal Account Token.\nInstructions on how to do so is given in the next section.\n\n## Features {#services-firefly-iii-features}\n\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-firefly-iii-usage-applicationdashboard)\n\n## Usage {#services-firefly-iii-usage}\n\n### Initial Configuration {#services-firefly-iii-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\nshb.firefly-iii = {\n  enable = true;\n  debug = false;\n\n  appKey.result = config.shb.sops.secret.\"firefly-iii/appKey\".result;\n  dbPassword.result = config.shb.sops.secret.\"firefly-iii/dbPassword\".result;\n\n  domain = \"example.com\";\n  subdomain = \"firefly-iii\";\n  siteOwnerEmail = \"mail@example.com\";\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  smtp = {\n    host = \"smtp.eu.mailgun.org\";\n    port = 587;\n    username = \"postmaster@mg.example.com\";\n    from_address = \"firefly-iii@example.com\";\n    password.result = config.shb.sops.secret.\"firefly-iii/smtpPassword\".result;\n  };\n\n  sso = {\n    enable = true;\n    authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n  };\n\n  importer = {\n    # See note hereunder.\n    # firefly-iii-accessToken.result = config.shb.sops.secret.\"firefly-iii/importerAccessToken\".result;\n  };\n};\nshb.sops.secret.\"firefly-iii/appKey\".request = config.shb.firefly-iii.appKey.request;\nshb.sops.secret.\"firefly-iii/dbPassword\".request = config.shb.firefly-iii.dbPassword.request;\nshb.sops.secret.\"firefly-iii/smtpPassword\".request = config.shb.firefly-iii.smtp.password.request;\n# See not hereunder.\n# shb.sops.secret.\"firefly-iii/importerAccessToken\".request = config.shb.firefly-iii.importer.firefly-iii-accessToken.request;\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\nNote that for `appKey`, the secret length must be exactly 32 characters.\n\nThe [user](#services-firefly-iii-options-shb.firefly-iii.ldap.userGroup)\nand [admin](#services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup)\nLDAP groups are created automatically.\nOnly admin users have access to the Firefly-iii data importer.\nOn the Firefly-iii web UI, the first user to login will be the admin.\nWe cannot yet create multiple admins in the Firefly-iii web UI.\n\nOn first start, leave the `shb.firefly-iii.importer.firefly-iii-accessToken` option empty.\nTo fill it out and connect the data importer to the Firefly-iii instance,\nyou must first create a personal access token then fill that option and redeploy.\n\n### Backup {#services-firefly-iii-usage-backup}\n\nBacking up Firefly-iii using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"firefly-iii\" = {\n  request = config.shb.firefly-iii.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"firefly-iii\"` in the `instances` can be anything.\nThe `config.shb.firefly-iii.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Firefly-iii multiple times.\n\nYou will then need to configure more options like the `repository`,\nas explained in the [restic](blocks-restic.html) documentation.\n\n### Certificates {#services-firefly-iii-certs}\n\nFor Let's Encrypt certificates, add:\n\n```nix\n{\n  shb.certs.certs.letsencrypt.${domain}.extraDomains = [\n    \"${config.shb.firefly-iii.subdomain}.${config.shb.firefly-iii.domain}\"\n    \"${config.shb.firefly-iii.importer.subdomain}.${config.shb.firefly-iii.domain}\"\n  ];\n}\n```\n\n### Impermanence {#services-firefly-iii-impermanence}\n\nTo save the data folder in an impermanence setup, add:\n\n```nix\n{\n  shb.zfs.datasets.\"safe/firefly-iii\".path = config.shb.firefly-iii.impermanence;\n}\n```\n\n### Declarative LDAP {#services-firefly-iii-declarative-ldap}\n\nTo add a user `USERNAME` to the user and admin groups for Firefly-iii, add:\n\n```nix\nshb.lldap.ensureUsers.USERNAME.groups = [\n  config.shb.firefly-iii.ldap.userGroup\n  config.shb.firefly-iii.ldap.adminGroup\n];\n```\n\n### Application Dashboard {#services-firefly-iii-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-firefly-iii-options-shb.firefly-iii.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Finance.services.Firefly-iii = {\n    sortOrder = 1;\n    dashboard.request = config.shb.firefly-iii.dashboard.request;\n    settings.widget.type = \"firefly\";\n  };\n}\n```\n\nThe widget type needs to be set manually otherwise it is not displayed correctly.\n\nAn API key can be set to show extra info:\n\n```nix\n{\n  shb.homepage.servicesGroups.Finance.services.Firefly-iii = {\n    apiKey.result = config.shb.sops.secret.\"firefly-iii/homepageApiKey\".result;\n  };\n\n  shb.sops.secret.\"firefly-iii/homepageApiKey\".request =\n    config.shb.homepage.servicesGroups.Finance.services.Firefly-iii.apiKey.request;\n}\n```\n\n## Database Inspection {#services-firefly-iii-database-inspection}\n\nAccess the database with:\n\n```nix\nsudo -u firefly-iii psql\n```\n\nDump the database with:\n\n```nix\nsudo -u firefly-iii pg_dump --data-only --inserts firefly-iii > dump\n```\n\n## Mobile Apps {#services-firefly-iii-mobile}\n\nThis module was tested with the [Abacus iOS](https://github.com/victorbalssa/abacus) mobile app\nusing a Personal Account Token.\n\n## Options Reference {#services-firefly-iii-options}\n\n```{=include=} options\nid-prefix: services-firefly-iii-options-\nlist-id: selfhostblocks-service-firefly-iii-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/firefly-iii.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.firefly-iii;\nin\n{\n  imports = [\n    ../blocks/nginx.nix\n    ../blocks/lldap.nix\n\n    ../../lib/module.nix\n  ];\n\n  options.shb.firefly-iii = {\n    enable = lib.mkEnableOption \"SHB's firefly-iii module\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = ''\n        Subdomain under which firefly-iii will be served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      example = \"firefly-iii\";\n    };\n\n    domain = lib.mkOption {\n      description = ''\n        Domain under which firefly-iii is served.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      type = lib.types.str;\n      example = \"domain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    siteOwnerEmail = lib.mkOption {\n      description = \"Email of the site owner.\";\n      type = lib.types.str;\n      example = \"mail@example.com\";\n    };\n\n    impermanence = lib.mkOption {\n      description = ''\n        Path to save when using impermanence setup.\n      '';\n      type = lib.types.str;\n      default = config.services.firefly-iii.dataDir;\n      defaultText = \"services.firefly-iii.dataDir\";\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = config.services.firefly-iii.user;\n          userText = \"services.firefly-iii.user\";\n          sourceDirectories = [\n            config.services.firefly-iii.dataDir\n          ];\n          sourceDirectoriesText = ''\n            [\n              config.services.firefly-iii.dataDir\n            ]\n          '';\n        };\n      };\n    };\n\n    appKey = lib.mkOption {\n      description = \"Encryption key used for sessions. Must be 32 characters long exactly.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = config.services.firefly-iii.user;\n          ownerText = \"services.firefly-iii.user\";\n          restartUnits = [ \"firefly-iii-setup.service\" ];\n        };\n      };\n    };\n\n    dbPassword = lib.mkOption {\n      description = \"DB password.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0440\";\n          owner = config.services.firefly-iii.user;\n          ownerText = \"services.firefly-iii.user\";\n          group = \"postgres\";\n          restartUnits = [\n            \"postgresql.service\"\n            \"firefly-iii-setup.service\"\n          ];\n        };\n      };\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        LDAP Integration\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to login to Firefly-iii.\";\n            default = \"firefly-iii_user\";\n          };\n          adminGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to import data user the Firefly-iii data importer.\";\n            default = \"firefly-iii_admin\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        SSO Integration\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC endpoint for SSO.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          port = lib.mkOption {\n            description = \"If given, adds a port to the endpoint.\";\n            type = lib.types.nullOr lib.types.port;\n            default = null;\n          };\n\n          provider = lib.mkOption {\n            type = lib.types.enum [ \"Authelia\" ];\n            description = \"OIDC provider name, used for display.\";\n            default = \"Authelia\";\n          };\n\n          clientID = lib.mkOption {\n            type = lib.types.str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"firefly-iii\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = lib.types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          adminGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group admins must belong to to be able to login to Firefly-iii.\";\n            default = \"firefly-iii_admin\";\n          };\n\n          secret = lib.mkOption {\n            description = \"OIDC shared secret.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"firefly-iii\";\n                restartUnits = [ \"firefly-iii-setup.service\" ];\n              };\n            };\n          };\n\n          secretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret. Content must be the same as `secretFile` option.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"authelia\";\n              };\n            };\n          };\n        };\n      };\n    };\n\n    smtp = lib.mkOption {\n      description = ''\n        If set, send notifications through smtp.\n\n        https://docs.firefly-iii.org/how-to/firefly-iii/advanced/notifications/\n      '';\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            from_address = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP address from which the emails originate.\";\n              example = \"authelia@mydomain.com\";\n            };\n            host = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP host to send the emails to.\";\n            };\n            port = lib.mkOption {\n              type = lib.types.port;\n              description = \"SMTP port to send the emails to.\";\n              default = 25;\n            };\n            username = lib.mkOption {\n              type = lib.types.str;\n              description = \"Username to connect to the SMTP host.\";\n            };\n            password = lib.mkOption {\n              description = \"File containing the password to connect to the SMTP host.\";\n              type = lib.types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0400\";\n                  owner = config.services.firefly-iii.user;\n                  ownerText = \"services.firefly-iii.user\";\n                  restartUnits = [ \"firefly-iii-setup.service\" ];\n                };\n              };\n            };\n          };\n        }\n      );\n    };\n\n    debug = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Enable more verbose logging.\";\n      default = false;\n      example = true;\n    };\n\n    importer = lib.mkOption {\n      description = ''\n        Configuration for Firefly-iii data importer.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"Firefly-iii Data Importer.\" // {\n            default = true;\n          };\n\n          subdomain = lib.mkOption {\n            type = lib.types.str;\n            description = ''\n              Subdomain under which the firefly-iii data importer will be served.\n            '';\n            default = \"${cfg.subdomain}-importer\";\n            defaultText = lib.literalExpression \"\\${shb.firefly-iii.subdomain}-importer\";\n          };\n\n          firefly-iii-accessToken = lib.mkOption {\n            type = lib.types.nullOr (\n              lib.types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0400\";\n                  owner = config.services.firefly-iii-data-importer.user;\n                  ownerText = \"services.firefly-iii-data-importer.user\";\n                  restartUnits = [ \"firefly-iii-data-importer-setup.service\" ];\n                };\n              }\n            );\n            description = ''\n              Create a Personal Access Token then set then token in this option.\n            '';\n            default = null;\n          };\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.firefly-iii.subdomain}.\\${config.shb.firefly-iii.domain}\";\n          # This works thanks to the Personal Access Token.\n          internalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          internalUrlText = \"https://\\${config.shb.firefly-iii.subdomain}.\\${config.shb.firefly-iii.domain}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable (\n    lib.mkMerge [\n      {\n        services.firefly-iii = {\n          enable = true;\n          group = \"nginx\";\n\n          virtualHost = \"${cfg.subdomain}.${cfg.domain}\";\n\n          # https://github.com/firefly-iii/firefly-iii/blob/main/.env.example\n          settings = {\n            APP_ENV = \"production\";\n            APP_URL = \"https://${cfg.subdomain}.${cfg.domain}\";\n\n            APP_DEBUG = cfg.debug;\n            APP_LOG_LEVEL = if cfg.debug then \"debug\" else \"notice\";\n            LOG_CHANNEL = \"stdout\";\n\n            APP_KEY_FILE = cfg.appKey.result.path;\n            SITE_OWNER = cfg.siteOwnerEmail;\n            DB_CONNECTION = \"pgsql\";\n            DB_HOST = \"localhost\";\n            DB_PORT = config.services.postgresql.settings.port;\n            DB_DATABASE = \"firefly-iii\";\n            DB_USERNAME = \"firefly-iii\";\n            DB_PASSWORD_FILE = cfg.dbPassword.result.path;\n\n            # MAP_DEFAULT_LAT = \"51.983333\";\n            # MAP_DEFAULT_LONG = \"5.916667\";\n            # MAP_DEFAULT_ZOOM = \"6\";\n          };\n        };\n        shb.postgresql.enableTCPIP = true;\n        shb.postgresql.ensures = [\n          {\n            username = \"firefly-iii\";\n            database = \"firefly-iii\";\n            passwordFile = cfg.dbPassword.result.path;\n          }\n        ];\n\n        # This should be using a contract instead of setting the option directly.\n        shb.lldap = lib.mkIf config.shb.lldap.enable {\n          ensureGroups = {\n            ${cfg.ldap.userGroup} = { };\n            ${cfg.ldap.adminGroup} = { };\n          };\n        };\n\n        # We enable the firefly-iii nginx integration and merge it with SHB's nginx configuration.\n        services.firefly-iii.enableNginx = true;\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg) subdomain domain ssl;\n          }\n        ];\n      }\n      (lib.mkIf cfg.importer.enable {\n        services.firefly-iii-data-importer = {\n          enable = true;\n\n          virtualHost = \"${cfg.importer.subdomain}.${cfg.domain}\";\n\n          settings = {\n            FIREFLY_III_URL = \"https://${config.services.firefly-iii.virtualHost}\";\n          }\n          // lib.optionalAttrs (cfg.importer.firefly-iii-accessToken != null) {\n            FIREFLY_III_ACCESS_TOKEN_FILE = cfg.importer.firefly-iii-accessToken.result.path;\n          };\n        };\n\n        # We enable the firefly-iii-data-importer nginx integration and merge it with SHB's nginx configuration.\n        services.firefly-iii-data-importer.enableNginx = true;\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg) domain ssl;\n            subdomain = cfg.importer.subdomain;\n          }\n        ];\n      })\n      (lib.mkIf (cfg.smtp != null) {\n        services.firefly-iii.settings = {\n          MAIL_MAILER = \"smtp\";\n          MAIL_HOST = cfg.smtp.host;\n          MAIL_PORT = cfg.smtp.port;\n          MAIL_FROM = cfg.smtp.from_address;\n          MAIL_USERNAME = cfg.smtp.username;\n          MAIL_PASSWORD_FILE = cfg.smtp.password.result.path;\n          MAIL_ENCRYPTION = \"tls\";\n        };\n      })\n      (lib.mkIf cfg.sso.enable {\n        services.firefly-iii.settings = {\n          AUTHENTICATION_GUARD = \"remote_user_guard\";\n          AUTHENTICATION_GUARD_HEADER = \"HTTP_X_FORWARDED_USER\";\n        };\n\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg) subdomain domain ssl;\n            inherit (cfg.sso) authEndpoint;\n\n            phpForwardAuth = true;\n            autheliaRules = [\n              {\n                domain = \"${cfg.subdomain}.${cfg.domain}\";\n                policy = \"bypass\";\n                resources = [ \"^/api\" ];\n              }\n              {\n                domain = \"${cfg.subdomain}.${cfg.domain}\";\n                policy = cfg.sso.authorization_policy;\n                subject = [\n                  \"group:${cfg.ldap.userGroup}\"\n                  \"group:${cfg.ldap.adminGroup}\"\n                ];\n              }\n            ];\n          }\n        ];\n      })\n      (lib.mkIf (cfg.sso.enable && cfg.importer.enable) {\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg.importer) subdomain;\n            inherit (cfg) domain ssl;\n            inherit (cfg.sso) authEndpoint;\n\n            autheliaRules = [\n              {\n                domain = \"${cfg.importer.subdomain}.${cfg.domain}\";\n                policy = cfg.sso.authorization_policy;\n                subject = [ \"group:${cfg.ldap.adminGroup}\" ];\n              }\n            ];\n          }\n        ];\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/forgejo/docs/default.md",
    "content": "# Forgejo Service {#services-forgejo}\n\nDefined in [`/modules/services/forgejo.nix`](@REPO@/modules/services/forgejo.nix).\n\nThis NixOS module is a service that sets up a [Forgejo](https://forgejo.org/) instance.\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner,\nLDAP and SSO integration as well as one local runner.\n\n## Features {#services-forgejo-features}\n\n- Declarative creation of users, admin or not.\n- Also declarative [LDAP](#services-forgejo-options-shb.forgejo.ldap) Configuration. [Manual](#services-forgejo-usage-ldap).\n- Declarative [SSO](#services-forgejo-options-shb.forgejo.sso) Configuration. [Manual](#services-forgejo-usage-sso).\n- Declarative [local runner](#services-forgejo-options-shb.forgejo.localActionRunner) Configuration.\n- Access through [subdomain](#services-forgejo-options-shb.forgejo.subdomain) using reverse proxy. [Manual](#services-forgejo-usage-configuration).\n- Access through [HTTPS](#services-forgejo-options-shb.forgejo.ssl) using reverse proxy. [Manual](#services-forgejo-usage-configuration).\n- [Backup](#services-forgejo-options-shb.forgejo.sso) through the [backup block](./blocks-backup.html). [Manual](#services-forgejo-usage-backup).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-forgejo-usage-applicationdashboard)\n\n## Usage {#services-forgejo-usage}\n\n### Initial Configuration {#services-forgejo-usage-configuration}\n\nThe following snippet enables Forgejo and makes it available under the `forgejo.example.com` endpoint.\n\n```nix\nshb.forgejo = {\n  enable = true;\n  subdomain = \"forgejo\";\n  domain = \"example.com\";\n\n  users = {\n    \"theadmin\" = {\n      isAdmin = true;\n      email = \"theadmin@example.com\";\n      password.result = config.shb.sops.secret.forgejoAdminPassword.result;\n    };\n    \"theuser\" = {\n      email = \"theuser@example.com\";\n      password.result = config.shb.sops.secret.forgejoUserPassword.result;\n    };\n  };\n};\n\nshb.sops.secret.\"forgejo/admin/password\" = {\n  request = config.shb.forgejo.users.\"theadmin\".password.request;\n};\n\nshb.sops.secret.\"forgejo/user/password\" = {\n  request = config.shb.forgejo.users.\"theuser\".password.request;\n};\n```\n\nTwo users are created, `theadmin` and `theuser`,\nrespectively with the passwords `forgejo/admin/password`\nand `forgejo/user/password` from a SOPS file.\n\nThis assumes secrets are setup with SOPS\nas mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\n### Forgejo through HTTPS {#services-forgejo-usage-https}\n\n:::: {.note}\nWe will build upon the [Initial Configuration](#services-forgejo-usage-configuration) section,\nso please follow that first.\n::::\n\nIf the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up),\nthe instance will be reachable at `https://forgejo.example.com`.\n\nHere is an example with Let's Encrypt certificates, validated using the HTTP method:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\" = {\n  domain = \"example.com\";\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"myemail@mydomain.com\";\n};\n```\n\nThen you can tell Forgejo to use those certificates.\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"forgejo.example.com\" ];\n\nshb.forgejo = {\n  ssl = config.shb.certs.certs.letsencrypt.\"example.com\";\n};\n```\n\n### With LDAP Support {#services-forgejo-usage-ldap}\n\n:::: {.note}\nWe will build upon the [HTTPS](#services-forgejo-usage-https) section,\nso please follow that first.\n::::\n\nWe will use the [LLDAP block][] provided by Self Host Blocks.\nAssuming it [has been set already][LLDAP block setup], add the following configuration:\n\n[LLDAP block]: blocks-lldap.html\n[LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup\n\n```nix\nshb.forgejo.ldap = {\n  enable = true;\n  host = \"127.0.0.1\";\n  port = config.shb.lldap.ldapPort;\n  dcdomain = config.shb.lldap.dcdomain;\n  adminPassword.result = config.shb.sops.secret.\"forgejo/ldap/adminPassword\".result\n};\n\nshb.sops.secret.\"forgejo/ldap/adminPassword\" = {\n  request = config.shb.forgejo.ldap.adminPassword.request;\n  settings.key = \"ldap/userPassword\";\n};\n```\n\nThe `shb.forgejo.ldap.adminPasswordFile` must be the same\nas the `shb.lldap.ldapUserPasswordFile` which is achieved\nwith the `key` option.\nThe other secrets can be randomly generated with\n`nix run nixpkgs#openssl -- rand -hex 64`.\n\nAnd that's it.\nNow, go to the LDAP server at `http://ldap.example.com`,\ncreate the `forgejo_user` and `forgejo_admin` groups,\ncreate a user and add it to one or both groups.\nWhen that's done, go back to the Forgejo server at\n`http://forgejo.example.com` and login with that user.\n\n### With SSO Support {#services-forgejo-usage-sso}\n\n:::: {.note}\nWe will build upon the [LDAP](#services-forgejo-usage-ldap) section,\nso please follow that first.\n::::\n\nWe will use the [SSO block][] provided by Self Host Blocks.\nAssuming it [has been set already][SSO block setup], add the following configuration:\n\n[SSO block]: blocks-sso.html\n[SSO block setup]: blocks-sso.html#blocks-sso-global-setup\n\n```nix\nshb.forgejo.sso = {\n  enable = true;\n  endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n  secretFile = <path/to/oidcForgejoSharedSecret>;\n  secretFileForAuthelia = <path/to/oidcForgejoSharedSecret>;\n};\n```\n\nPassing the `ssl` option will auto-configure nginx to force SSL connections with the given\ncertificate.\n\nThe `shb.forgejo.sso.secretFile` and `shb.forgejo.sso.secretFileForAuthelia` options\nmust have the same content. The former is a file that must be owned by the `forgejo` user while\nthe latter must be owned by the `authelia` user. I want to avoid needing to define the same secret\ntwice with a future secrets SHB block.\n\n### SMTP {#services-forgejo-usage-smtp}\n\nTo send e-mails, notifications, define the SMTP settings like so:\n\n```nix\n{\n  services.forgejo = {\n    smtp = {\n      host = \"smtp.mailgun.org\";\n      port = 587;\n      username = \"postmaster@mg.${domain}\";\n      from_address = \"authelia@${domain}\";\n      password.result = config.shb.sops.secret.\"forgejo/smtpPassword\".result;\n    };\n  };\n\n  shb.sops.secret.\"forgejo/smtpPassword\" = {\n    request = config.shb.forgejo.smtp.password.request;\n  };\n}\n```\n\n### Backup {#services-forgejo-usage-backup}\n\nEvery hour, Forgejo takes a backup using the [built-in `dump` command](https://forgejo.org/docs/latest/admin/command-line/#dump).\nThis backup is ephemeral and should be moved in a permanent location.\nThis can be accomplished using the following config.\n\nBacking up Forgejo using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"forgejo\" = {\n  request = config.shb.forgejo.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"forgejo\"` in the `instances` can be anything.\nThe `config.shb.forgejo.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Forgejo multiple times.\n\n### Application Dashboard {#services-forgejo-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-forgejo-options-shb.forgejo.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Admin.services.Forgejo = {\n    sortOrder = 1;\n    dashboard.request = config.shb.forgejo.dashboard.request;\n  };\n}\n```\n\n### Extra Settings {#services-forgejo-usage-extra-settings}\n\nOther Forgejo settings can be accessed through the nixpkgs [stock service][].\n\n[stock service]: https://search.nixos.org/options?channel=24.05&from=0&size=50&sort=alpha_asc&type=packages&query=services.forgejo\n\n## Debug {#services-forgejo-debug}\n\nIn case of an issue, check the logs for systemd service `forgejo.service`.\n\nEnable verbose logging by setting the `shb.forgejo.debug` boolean to `true`.\n\nAccess the database with `sudo -u forgejo psql`.\n\n## Options Reference {#services-forgejo-options}\n\n```{=include=} options\nid-prefix: services-forgejo-options-\nlist-id: selfhostblocks-service-forgejo-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/forgejo.nix",
    "content": "{\n  config,\n  options,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.forgejo;\n\n  inherit (lib)\n    all\n    attrNames\n    concatMapStringsSep\n    getExe\n    lists\n    literalExpression\n    mapAttrsToList\n    mkBefore\n    mkEnableOption\n    mkForce\n    mkIf\n    mkMerge\n    mkOption\n    mkOverride\n    nameValuePair\n    optionalString\n    optionals\n    ;\n  inherit (lib.types)\n    attrsOf\n    bool\n    enum\n    listOf\n    nullOr\n    package\n    port\n    submodule\n    str\n    ;\nin\n{\n  imports = [\n    ../blocks/nginx.nix\n    ../blocks/lldap.nix\n\n    (lib.mkRemovedOptionModule [ \"shb\" \"forgejo\" \"adminPassword\" ] ''\n      Instead, define an admin user in shb.forgejo.users and give it the same password, like so:\n\n          shb.forgejo.users = {\n            \"forgejoadmin\" = {\n              isAdmin = true;\n              email = \"forgejoadmin@example.com\";\n              password.result = <path/to/password>;\n            };\n          };\n    '')\n  ];\n\n  options.shb.forgejo = {\n    enable = mkEnableOption \"selfhostblocks.forgejo\";\n\n    subdomain = mkOption {\n      type = str;\n      description = ''\n        Subdomain under which Forgejo will be served.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      example = \"forgejo\";\n    };\n\n    domain = mkOption {\n      description = ''\n        Domain under which Forgejo is served.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      type = str;\n      example = \"domain.com\";\n    };\n\n    ssl = mkOption {\n      description = \"Path to SSL files\";\n      type = nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    ldap = mkOption {\n      description = ''\n        LDAP Integration.\n      '';\n      default = { };\n      type = nullOr (submodule {\n        options = {\n          enable = mkEnableOption \"LDAP integration.\";\n\n          provider = mkOption {\n            type = enum [ \"LLDAP\" ];\n            description = \"LDAP provider name, used for display.\";\n            default = \"LLDAP\";\n          };\n\n          host = mkOption {\n            type = str;\n            description = ''\n              Host serving the LDAP server.\n            '';\n            default = \"127.0.0.1\";\n          };\n\n          port = mkOption {\n            type = port;\n            description = ''\n              Port of the service serving the LDAP server.\n            '';\n            default = 389;\n          };\n\n          dcdomain = mkOption {\n            type = str;\n            description = \"dc domain for ldap.\";\n            example = \"dc=mydomain,dc=com\";\n          };\n\n          adminName = mkOption {\n            type = str;\n            description = \"Admin user of the LDAP server. Cannot be reserved word 'admin'.\";\n            default = \"admin\";\n          };\n\n          adminPassword = mkOption {\n            description = \"LDAP admin password.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"forgejo\";\n                group = \"forgejo\";\n                restartUnits = [ \"forgejo.service\" ];\n              };\n            };\n          };\n\n          userGroup = mkOption {\n            type = str;\n            description = \"Group users must belong to be able to login.\";\n            default = \"forgejo_user\";\n          };\n\n          adminGroup = mkOption {\n            type = str;\n            description = \"Group users must belong to be admins.\";\n            default = \"forgejo_admin\";\n          };\n\n          waitForSystemdServices = mkOption {\n            type = listOf str;\n            default = [ ];\n            description = ''\n              List of systemd services to wait on before starting.\n              This is needed because forgejo will try a lookup on the LDAP instance\n              and will abort setting up LDAP if it can't reach it.\n            '';\n          };\n        };\n      });\n    };\n\n    sso = mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = submodule {\n        options = {\n          enable = mkEnableOption \"SSO integration.\";\n\n          provider = mkOption {\n            type = enum [ \"Authelia\" ];\n            description = \"OIDC provider name, used for display.\";\n            default = \"Authelia\";\n          };\n\n          endpoint = mkOption {\n            type = str;\n            description = \"OIDC endpoint for SSO.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = mkOption {\n            type = str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"forgejo\";\n          };\n\n          authorization_policy = mkOption {\n            type = enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = mkOption {\n            description = \"OIDC shared secret for Forgejo.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"forgejo\";\n                group = \"forgejo\";\n                restartUnits = [ \"forgejo.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = mkOption {\n            description = \"OIDC shared secret for Authelia.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"authelia\";\n              };\n            };\n          };\n        };\n      };\n    };\n\n    users = mkOption {\n      description = \"Users managed declaratively.\";\n      default = { };\n      type = attrsOf (submodule {\n        options = {\n          isAdmin = mkOption {\n            description = \"Set user as admin or not.\";\n            type = bool;\n            default = false;\n          };\n\n          email = mkOption {\n            description = ''\n              Email of user.\n\n              This is only set when the user is created, changing this later on will have no effect.\n            '';\n            type = str;\n          };\n\n          password = mkOption {\n            description = \"Forgejo admin user password.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"forgejo\";\n                group = \"forgejo\";\n                restartUnits = [ \"forgejo.service\" ];\n              };\n            };\n          };\n        };\n      });\n    };\n\n    databasePassword = mkOption {\n      description = \"File containing the Forgejo database password.\";\n      type = submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0440\";\n          owner = \"forgejo\";\n          group = \"forgejo\";\n          restartUnits = [ \"forgejo.service\" ];\n        };\n      };\n    };\n\n    repositoryRoot = mkOption {\n      type = nullOr str;\n      description = \"Path where to store the repositories. If null, uses the default under the Forgejo StateDir.\";\n      default = null;\n      example = \"/srv/forgejo\";\n    };\n\n    localActionRunner = mkOption {\n      type = bool;\n      default = true;\n      description = ''\n        Enable local action runner that runs for all labels.\n      '';\n    };\n\n    hostPackages = mkOption {\n      type = listOf package;\n      default = with pkgs; [\n        bash\n        coreutils\n        curl\n        gawk\n        gitMinimal\n        gnused\n        nodejs\n        wget\n      ];\n      defaultText = literalExpression ''\n        with pkgs; [\n          bash\n          coreutils\n          curl\n          gawk\n          gitMinimal\n          gnused\n          nodejs\n          wget\n        ]\n      '';\n      description = ''\n        List of packages, that are available to actions, when the runner is configured\n        with a host execution label.\n      '';\n    };\n\n    backup = mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = options.services.forgejo.user.value;\n          sourceDirectories = [\n            config.services.forgejo.dump.backupDir\n          ]\n          ++ optionals (cfg.repositoryRoot != null) [\n            cfg.repositoryRoot\n          ];\n        };\n      };\n    };\n\n    mount = mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"forgejo\" = {\n          poolName = \"root\";\n        } // config.shb.forgejo.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = config.services.forgejo.stateDir;\n      };\n    };\n\n    smtp = mkOption {\n      description = ''\n        Send notifications by smtp.\n      '';\n      default = null;\n      type = nullOr (submodule {\n        options = {\n          from_address = mkOption {\n            type = str;\n            description = \"SMTP address from which the emails originate.\";\n            example = \"authelia@mydomain.com\";\n          };\n          host = mkOption {\n            type = str;\n            description = \"SMTP host to send the emails to.\";\n          };\n          port = mkOption {\n            type = port;\n            description = \"SMTP port to send the emails to.\";\n            default = 25;\n          };\n          username = mkOption {\n            type = str;\n            description = \"Username to connect to the SMTP host.\";\n          };\n          password = mkOption {\n            description = \"File containing the password to connect to the SMTP host.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"forgejo\";\n                group = \"forgejo\";\n                restartUnits = [ \"forgejo.service\" ];\n              };\n            };\n          };\n        };\n      });\n    };\n\n    debug = mkOption {\n      description = \"Enable debug logging.\";\n      type = bool;\n      default = false;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.forgejo.subdomain}.\\${config.shb.forgejo.domain}\";\n          internalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          internalUrlText = \"https://\\${config.shb.forgejo.subdomain}.\\${config.shb.forgejo.domain}\";\n        };\n      };\n    };\n  };\n\n  config = mkMerge [\n    (mkIf cfg.enable {\n      services.forgejo = {\n        enable = true;\n        repositoryRoot = mkIf (cfg.repositoryRoot != null) cfg.repositoryRoot;\n        settings = {\n          server = {\n            DOMAIN = cfg.domain;\n            PROTOCOL = \"http+unix\";\n            ROOT_URL = \"https://${cfg.subdomain}.${cfg.domain}/\";\n          };\n\n          service.DISABLE_REGISTRATION = true;\n\n          log.LEVEL = if cfg.debug then \"Debug\" else \"Info\";\n\n          cron = {\n            ENABLE = true;\n            RUN_AT_START = true;\n            SCHEDULE = \"@every 1h\";\n          };\n        };\n      };\n\n      # 1 lower than default, to solve conflict between shb.postgresql and nixpkgs' forgejo module.\n      services.postgresql.enable = mkOverride 999 true;\n\n      # https://github.com/NixOS/nixpkgs/issues/258371#issuecomment-2271967113\n      systemd.services.forgejo.serviceConfig.Type = mkForce \"exec\";\n\n      shb.nginx.vhosts = [\n        {\n          inherit (cfg) domain subdomain ssl;\n          upstream = \"http://unix:${config.services.forgejo.settings.server.HTTP_ADDR}\";\n        }\n      ];\n    })\n\n    (mkIf cfg.enable {\n      services.forgejo.database = {\n        type = \"postgres\";\n\n        passwordFile = cfg.databasePassword.result.path;\n      };\n    })\n\n    (mkIf cfg.enable {\n      services.forgejo.dump = {\n        enable = true;\n        type = \"tar.gz\";\n        interval = \"hourly\";\n      };\n      systemd.services.forgejo-dump.preStart = \"rm -f ${config.services.forgejo.dump.backupDir}/*.tar.gz\";\n    })\n\n    # For Forgejo setup: https://github.com/lldap/lldap/blob/main/example_configs/gitea.md\n    # For cli info: https://docs.gitea.com/usage/command-line\n    # Security protocols in: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/services/auth/source/ldap/security_protocol.go#L27-L31\n    (mkIf (cfg.enable && cfg.ldap.enable != false) {\n      systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices;\n      systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices;\n\n      shb.lldap.ensureGroups = {\n        ${cfg.ldap.adminGroup} = { };\n        ${cfg.ldap.userGroup} = { };\n      };\n\n      # The delimiter in the `cut` command is a TAB!\n      systemd.services.forgejo.preStart =\n        let\n          provider = \"SHB-${cfg.ldap.provider}\";\n        in\n        ''\n          auth=\"${getExe config.services.forgejo.package} admin auth\"\n\n          echo \"Trying to find existing ldap configuration for ${provider}\"...\n          set +e -o pipefail\n          id=\"$($auth list | grep \"${provider}.*LDAP\" |  cut -d'\t' -f1)\"\n          found=$?\n          set -e +o pipefail\n\n          if [[ $found = 0 ]]; then\n            echo Found ldap configuration at id=$id, updating it if needed.\n            $auth update-ldap \\\n              --id                  $id \\\n              --name                ${provider} \\\n              --host                ${cfg.ldap.host} \\\n              --port                ${toString cfg.ldap.port} \\\n              --bind-dn             uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \\\n              --bind-password       $(tr -d '\\n' < ${cfg.ldap.adminPassword.result.path}) \\\n              --security-protocol   Unencrypted \\\n              --user-search-base    ou=people,${cfg.ldap.dcdomain} \\\n              --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)))' \\\n              --admin-filter        '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \\\n              --username-attribute  uid \\\n              --firstname-attribute givenName \\\n              --surname-attribute   sn \\\n              --email-attribute     mail \\\n              --avatar-attribute    jpegPhoto \\\n              --synchronize-users\n            echo \"Done updating LDAP configuration.\"\n          else\n            echo Did not find any ldap configuration, creating one with name ${provider}.\n            $auth add-ldap \\\n              --name                ${provider} \\\n              --host                ${cfg.ldap.host} \\\n              --port                ${toString cfg.ldap.port} \\\n              --bind-dn             uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \\\n              --bind-password       $(tr -d '\\n' < ${cfg.ldap.adminPassword.result.path}) \\\n              --security-protocol   Unencrypted \\\n              --user-search-base    ou=people,${cfg.ldap.dcdomain} \\\n              --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)))' \\\n              --admin-filter        '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \\\n              --username-attribute  uid \\\n              --firstname-attribute givenName \\\n              --surname-attribute   sn \\\n              --email-attribute     mail \\\n              --avatar-attribute    jpegPhoto \\\n              --synchronize-users\n            echo \"Done adding LDAP configuration.\"\n          fi\n        '';\n    })\n\n    # For Authelia to Forgejo integration: https://www.authelia.com/integration/openid-connect/gitea/\n    # For Forgejo config: https://forgejo.org/docs/latest/admin/config-cheat-sheet\n    # For cli info: https://docs.gitea.com/usage/command-line\n    (mkIf (cfg.enable && cfg.sso.enable != false) {\n      assertions = [\n        {\n          assertion = cfg.ldap.enable == true;\n          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.\";\n        }\n      ];\n\n      services.forgejo.settings = {\n        oauth2 = {\n          ENABLED = true;\n        };\n\n        openid = {\n          ENABLE_OPENID_SIGNIN = false;\n          ENABLE_OPENID_SIGNUP = true;\n          WHITELISTED_URIS = cfg.sso.endpoint;\n        };\n\n        service = {\n          # DISABLE_REGISTRATION = mkForce false;\n          # ALLOW_ONLY_EXTERNAL_REGISTRATION = false;\n          SHOW_REGISTRATION_BUTTON = false;\n        };\n      };\n\n      # The delimiter in the `cut` command is a TAB!\n      systemd.services.forgejo.preStart =\n        let\n          provider = \"SHB-${cfg.sso.provider}\";\n        in\n        ''\n          auth=\"${getExe config.services.forgejo.package} admin auth\"\n\n          echo \"Trying to find existing sso configuration for ${provider}\"...\n          set +e -o pipefail\n          id=\"$($auth list | grep \"${provider}.*OAuth2\" |  cut -d'\t' -f1)\"\n          found=$?\n          set -e +o pipefail\n\n          if [[ $found = 0 ]]; then\n            echo Found sso configuration at id=$id, updating it if needed.\n            $auth update-oauth \\\n              --id       $id \\\n              --name     ${provider} \\\n              --provider openidConnect \\\n              --key      forgejo \\\n              --secret   $(tr -d '\\n' < ${cfg.sso.sharedSecret.result.path}) \\\n              --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration\n          else\n            echo Did not find any sso configuration, creating one with name ${provider}.\n            $auth add-oauth \\\n              --name     ${provider} \\\n              --provider openidConnect \\\n              --key      forgejo \\\n              --secret   $(tr -d '\\n' < ${cfg.sso.sharedSecret.result.path}) \\\n              --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration\n          fi\n        '';\n\n      shb.authelia.oidcClients = lists.optionals (!(isNull cfg.sso)) [\n        (\n          let\n            provider = \"SHB-${cfg.sso.provider}\";\n          in\n          {\n            client_id = cfg.sso.clientID;\n            client_name = \"Forgejo\";\n            client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n            public = false;\n            authorization_policy = cfg.sso.authorization_policy;\n            redirect_uris = [ \"https://${cfg.subdomain}.${cfg.domain}/user/oauth2/${provider}/callback\" ];\n          }\n        )\n      ];\n    })\n\n    (mkIf cfg.enable {\n      assertions = [\n        {\n          assertion = all (u: u != \"admin\") (attrNames cfg.users);\n          message = \"Username cannot be 'admin'.\";\n        }\n      ];\n\n      systemd.services.forgejo.preStart = ''\n        admin=\"${getExe config.services.forgejo.package} admin user\"\n      ''\n      + concatMapStringsSep \"\\n\" (u: ''\n        if ! $admin list | grep \"${u.name}\"; then\n          $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})\"\n        else\n          $admin change-password --must-change-password=false --username \"${u.name}\" --password \"$(tr -d '\\n' < ${u.value.password.result.path})\"\n        fi\n      '') (mapAttrsToList nameValuePair cfg.users);\n    })\n\n    (mkIf (cfg.enable && cfg.smtp != null) {\n      services.forgejo.settings.mailer = {\n        ENABLED = true;\n        SMTP_ADDR = \"${cfg.smtp.host}:${toString cfg.smtp.port}\";\n        FROM = cfg.smtp.from_address;\n        USER = cfg.smtp.username;\n        PASSWD = cfg.smtp.password.result.path;\n      };\n    })\n\n    # https://wiki.nixos.org/wiki/Forgejo#Runner\n    (mkIf cfg.enable {\n      services.forgejo.settings.actions = {\n        ENABLED = true;\n        DEFAULT_ACTIONS_URL = \"github\";\n      };\n\n      services.gitea-actions-runner = mkIf cfg.localActionRunner {\n        package = pkgs.forgejo-runner;\n        instances.local = {\n          enable = true;\n          name = \"local\";\n          url =\n            let\n              protocol = if cfg.ssl != null then \"https\" else \"http\";\n            in\n            \"${protocol}://${cfg.subdomain}.${cfg.domain}\";\n          tokenFile = \"\"; # Empty variable to satisfy an assertion.\n          labels = [\n            # \"ubuntu-latest:docker://node:16-bullseye\"\n            # \"ubuntu-22.04:docker://node:16-bullseye\"\n            # \"ubuntu-20.04:docker://node:16-bullseye\"\n            # \"ubuntu-18.04:docker://node:16-buster\"\n            \"native:host\"\n          ];\n          inherit (cfg) hostPackages;\n        };\n      };\n\n      # This combined with the next statement takes care of\n      # automatically registering a forgejo runner.\n      systemd.services.forgejo.postStart = mkIf cfg.localActionRunner (mkBefore ''\n        ${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'\n        actions=\"${getExe config.services.forgejo.package} actions\"\n        echo -n TOKEN= > /run/forgejo/forgejo-runner-token\n        $actions generate-runner-token >> /run/forgejo/forgejo-runner-token\n      '');\n\n      systemd.services.gitea-runner-local.serviceConfig = {\n        # LoadCredential = \"TOKEN_FILE:/run/forgejo/forgejo-runner-token\";\n        # EnvironmentFile = [ \"$CREDENTIALS_DIRECTORY/TOKEN_FILE\" ];\n        EnvironmentFile = [ \"/run/forgejo/forgejo-runner-token\" ];\n      };\n\n      systemd.services.gitea-runner-local.wants = [ \"forgejo.service\" ];\n      systemd.services.gitea-runner-local.after = [ \"forgejo.service\" ];\n    })\n  ];\n}\n"
  },
  {
    "path": "modules/services/grocy.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.grocy;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\nin\n{\n  options.shb.grocy = {\n    enable = lib.mkEnableOption \"selfhostblocks.grocy\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which grocy will be served.\";\n      example = \"grocy\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which grocy will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    dataDir = lib.mkOption {\n      description = \"Folder where Grocy will store all its data.\";\n      type = lib.types.str;\n      default = \"/var/lib/grocy\";\n    };\n\n    currency = lib.mkOption {\n      type = lib.types.str;\n      description = \"ISO 4217 code for the currency to display.\";\n      default = \"USD\";\n      example = \"NOK\";\n    };\n\n    culture = lib.mkOption {\n      type = lib.types.enum [\n        \"de\"\n        \"en\"\n        \"da\"\n        \"en_GB\"\n        \"es\"\n        \"fr\"\n        \"hu\"\n        \"it\"\n        \"nl\"\n        \"no\"\n        \"pl\"\n        \"pt_BR\"\n        \"ru\"\n        \"sk_SK\"\n        \"sv_SE\"\n        \"tr\"\n      ];\n      default = \"en\";\n      description = ''\n        Display language of the frontend.\n      '';\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    extraServiceConfig = lib.mkOption {\n      type = lib.types.attrsOf lib.types.str;\n      description = \"Extra configuration given to the systemd service file.\";\n      default = { };\n      example = lib.literalExpression ''\n        {\n          MemoryHigh = \"512M\";\n          MemoryMax = \"900M\";\n        }\n      '';\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      readOnly = true;\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"grocy\";\n          sourceDirectories = [\n            cfg.dataDir\n          ];\n        };\n      };\n    };\n\n    logLevel = lib.mkOption {\n      type = lib.types.nullOr (\n        lib.types.enum [\n          \"critical\"\n          \"error\"\n          \"warning\"\n          \"info\"\n          \"debug\"\n        ]\n      );\n      description = \"Enable logging.\";\n      default = false;\n      example = true;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.grocy.subdomain}.\\${config.shb.grocy.domain}\";\n          internalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          internalUrlText = \"https://\\${config.shb.grocy.subdomain}.\\${config.shb.grocy.domain}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable (\n    lib.mkMerge [\n      {\n        services.grocy = {\n          enable = true;\n          hostName = fqdn;\n          nginx.enableSSL = !(isNull cfg.ssl);\n          dataDir = cfg.dataDir;\n          settings.currency = cfg.currency;\n          settings.culture = cfg.culture;\n        };\n\n        services.phpfpm.pools.grocy.group = lib.mkForce \"grocy\";\n\n        users.groups.grocy = { };\n        users.users.grocy.group = lib.mkForce \"grocy\";\n\n        services.nginx.virtualHosts.\"${fqdn}\" = {\n          enableACME = lib.mkForce false;\n          sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n          sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n        };\n      }\n      {\n        systemd.services.grocyd.serviceConfig = cfg.extraServiceConfig;\n      }\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/hledger.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.hledger;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\nin\n{\n  imports = [\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.hledger = {\n    enable = lib.mkEnableOption \"selfhostblocks.hledger\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Authelia will be served.\";\n      example = \"ha\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Authelia will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    dataDir = lib.mkOption {\n      description = \"Folder where Hledger will store all its data.\";\n      type = lib.types.str;\n      default = \"/var/lib/hledger\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    port = lib.mkOption {\n      type = lib.types.int;\n      description = \"HLedger port\";\n      default = 5000;\n    };\n\n    localNetworkIPRange = lib.mkOption {\n      type = lib.types.str;\n      description = \"Local network range, to restrict access to the UI to only those IPs.\";\n      default = null;\n      example = \"192.168.1.1/24\";\n    };\n\n    authEndpoint = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = \"OIDC endpoint for SSO\";\n      example = \"https://authelia.example.com\";\n      default = null;\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"hledger\";\n          sourceDirectories = [\n            cfg.dataDir\n          ];\n        };\n      };\n    };\n\n    extraArguments = lib.mkOption {\n      description = \"Extra arguments append to the hledger command.\";\n      default = [ \"--forecast\" ];\n      type = lib.types.listOf lib.types.str;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.hledger.subdomain}.\\${config.shb.hledger.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString config.services.hledger-web.port}\";\n          internalUrlText = \"http://127.0.0.1:\\${config.services.hledger-web.port}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    services.hledger-web = {\n      enable = true;\n      # Must be empty otherwise it repeats the fqdn, we get something like https://${fqdn}/${fqdn}/\n      baseUrl = \"\";\n\n      stateDir = cfg.dataDir;\n      journalFiles = [ \"hledger.journal\" ];\n\n      host = \"127.0.0.1\";\n      port = cfg.port;\n\n      allow = \"edit\";\n      extraOptions = cfg.extraArguments;\n    };\n\n    systemd.services.hledger-web = {\n      # If the hledger.journal file does not exist, hledger-web refuses to start, so we create an\n      # empty one if it does not exist yet..\n      preStart = ''\n        test -f /var/lib/hledger/hledger.journal || touch /var/lib/hledger/hledger.journal\n      '';\n      serviceConfig.StateDirectory = \"hledger\";\n    };\n\n    shb.nginx.vhosts = [\n      {\n        inherit (cfg)\n          subdomain\n          domain\n          authEndpoint\n          ssl\n          ;\n        upstream = \"http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}\";\n        autheliaRules = [\n          {\n            domain = fqdn;\n            policy = \"two_factor\";\n            subject = [ \"group:hledger_user\" ];\n          }\n        ];\n      }\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/home-assistant/docs/default.md",
    "content": "# Home-Assistant Service {#services-home-assistant}\n\nDefined in [`/modules/services/home-assistant.nix`](@REPO@/modules/services/home-assistant.nix).\n\nThis NixOS module is a service that sets up a [Home-Assistant](https://www.home-assistant.io/) instance.\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner\nLDAP and SSO integration.\n\n## Features {#services-home-assistant-features}\n\n- Declarative creation of users, admin or not.\n- Also declarative [LDAP](#services-home-assistant-options-shb.home-assistant.ldap) Configuration. [Manual](#services-home-assistant-usage-ldap).\n- Access through [subdomain](#services-home-assistant-options-shb.home-assistant.subdomain) using reverse proxy. [Manual](#services-home-assistant-usage-configuration).\n- Access through [HTTPS](#services-home-assistant-options-shb.home-assistant.ssl) using reverse proxy. [Manual](#services-home-assistant-usage-configuration).\n- [Backup](#services-home-assistant-options-shb.home-assistant.backup) through the [backup block](./blocks-backup.html). [Manual](#services-home-assistant-usage-backup).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-home-assistant-usage-applicationdashboard)\n\n- Not yet: declarative SSO.\n\n## Usage {#services-home-assistant-usage}\n\n### Initial Configuration {#services-home-assistant-usage-configuration}\n\nThe following snippet enables Home-Assistant and makes it available under the `ha.example.com` endpoint.\n\n```nix\nshb.home-assistant = {\n  enable = true;\n  subdomain = \"ha\";\n  domain = \"example.com\";\n\n  config = {\n    name = \"SelfHostBlocks - Home Assistant\";\n    country.source = config.shb.sops.secret.\"home-assistant/country\".result.path;\n    latitude.source = config.shb.sops.secret.\"home-assistant/latitude_home\".result.path;\n    longitude.source = config.shb.sops.secret.\"home-assistant/longitude_home\".result.path;\n    time_zone.source = config.shb.sops.secret.\"home-assistant/time_zone\".result.path;\n    unit_system = \"metric\";\n  };\n};\n\nshb.sops.secret.\"home-assistant/country\".request = {\n  mode = \"0440\";\n  owner = \"hass\";\n  group = \"hass\";\n  restartUnits = [ \"home-assistant.service\" ];\n};\nshb.sops.secret.\"home-assistant/latitude_home\".request = {\n  mode = \"0440\";\n  owner = \"hass\";\n  group = \"hass\";\n  restartUnits = [ \"home-assistant.service\" ];\n};\nshb.sops.secret.\"home-assistant/longitude_home\".request = {\n  mode = \"0440\";\n  owner = \"hass\";\n  group = \"hass\";\n  restartUnits = [ \"home-assistant.service\" ];\n};\nshb.sops.secret.\"home-assistant/time_zone\".request = {\n  mode = \"0440\";\n  owner = \"hass\";\n  group = \"hass\";\n  restartUnits = [ \"home-assistant.service\" ];\n};\n```\n\nThis assumes secrets are setup with SOPS\nas mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\n\nAny item in the `config` can be passed a secret, which means it will not appear\nin the `/nix/store` and instead be added to the config file out of band, here using sops.\nTo do that, append `.source` to the settings name and give it the path to the secret.\n\nI advise using secrets to set personally identifiable information,\nlike shown in the snippet. Especially if you share your repository publicly.\n\n### Home-Assistant through HTTPS {#services-home-assistant-usage-https}\n\n:::: {.note}\nWe will build upon the [Initial Configuration](#services-home-assistant-usage-configuration) section,\nso please follow that first.\n::::\n\nIf the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up),\nthe instance will be reachable at `https://ha.example.com`.\n\nHere is an example with Let's Encrypt certificates, validated using the HTTP method.\nFirst, set the global configuration for your domain:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\" = {\n  domain = \"example.com\";\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"myemail@mydomain.com\";\n};\n```\n\nThen you can tell Home-Assistant to use those certificates.\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"ha.example.com\" ];\n\nshb.home-assistant = {\n  ssl = config.shb.certs.certs.letsencrypt.\"example.com\";\n};\n```\n\n### With LDAP Support {#services-home-assistant-usage-ldap}\n\n:::: {.note}\nWe will build upon the [HTTPS](#services-home-assistant-usage-https) section,\nso please follow that first.\n::::\n\nWe will use the [LLDAP block][] provided by Self Host Blocks.\nAssuming it [has been set already][LLDAP block setup], add the following configuration:\n\n[LLDAP block]: blocks-lldap.html\n[LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup\n\n```nix\nshb.home-assistant.ldap\n  enable = true;\n  host = \"127.0.0.1\";\n  port = config.shb.lldap.webUIListenPort;\n  userGroup = \"homeassistant_user\";\n};\n```\n\nAnd that's it.\nNow, go to the LDAP server at `http://ldap.example.com`,\ncreate the `home-assistant_user` group,\ncreate a user and add it to one or both groups.\nWhen that's done, go back to the Home-Assistant server at\n`http://home-assistant.example.com` and login with that user.\n\n### With SSO Support {#services-home-assistant-usage-sso}\n\n:::: {.warning}\nThis is not implemented yet. Any contributions ([issue #12](https://github.com/ibizaman/selfhostblocks/issues/12)) are welcomed!\n::::\n\n### Backup {#services-home-assistant-usage-backup}\n\nBacking up Home-Assistant using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"home-assistant\" = {\n  request = config.shb.home-assistant.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"home-assistant\"` in the `instances` can be anything.\nThe `config.shb.home-assistant.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Home-Assistant multiple times.\n\nYou will then need to configure more options like the `repository`,\nas explained in the [restic](blocks-restic.html) documentation.\n\n### Application Dashboard {#services-home-assistant-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-home-assistant-options-shb.home-assistant.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Home.services.HomeAssistant = {\n    sortOrder = 1;\n    dashboard.request = config.shb.home-assistant.dashboard.request;\n    settings.icon = \"si-homeassistant\";\n  };\n}\n```\n\nThe icon needs to be set manually otherwise it is not displayed correctly.\n\nAn API key can be set to show extra info:\n\n```nix\n{\n  shb.homepage.servicesGroups.Home.services.HomeAssistant = {\n    apiKey.result = config.shb.sops.secret.\"home-assistant/homepageApiKey\".result;\n  };\n\n  shb.sops.secret.\"home-assistant/homepageApiKey\".request =\n    config.shb.homepage.servicesGroups.Home.services.HomeAssistant.apiKey.request;\n}\n```\n\nCustom widgets can be set using Home Assistant templating:\n\n```nix\n{\n  shb.homepage.servicesGroups.Home.services.HomeAssistant = {\n    settings.widget.custom = [\n      {\n        template = \"{{ states('sensor.power_consumption_power_consumption', with_unit=True, rounded=True) }}\";\n        label = \"energy now\";\n      }\n      {\n        state = \"sensor.power_consumption_daily_power_consumption\";\n        label = \"energy today\";\n      }\n    ];\n  };\n}\n```\n\n### Extra Components {#services-home-assistant-usage-extra-components}\n\nPackaged components can be found in the documentation of the corresponding option\n[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)\n\n[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\n\nWhen you find an interesting one add it to the option:\n\n```bash\nservices.home-assistant.extraComponents = [\n  \"backup\"\n  \"bluetooth\"\n  \"esphome\"\n\n  \"assist_pipeline\"\n  \"conversation\"\n  \"piper\"\n  \"wake_word\"\n  \"whisper\"\n  \"wyoming\"\n];\n```\n\nSome components are not available as extra components, but need to be added as cusotm components.\nIf the component is not packaged, you'll need to use a [custom component](#services-home-assistant-usage-custom-components).\n\n### Custom Components {#services-home-assistant-usage-custom-components}\n\n:::: {.note}\nI'm still confused for why is there a difference between custom components and extra components.\n::::\n\nAvailable custom components can be found by searching packages for [home-assistant-custom-components][].\n\n[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\n\nAdd them like so:\n\n```nix\nservices.home-assistant.customComponents = with pkgs.home-assistant-custom-components; [\n  adaptive_lighting\n];\n```\n\nTo add a not packaged component, you can get inspiration from existing [packaged components.\nTo help you package a custom component [nixpkgs code][component-packages.nix] to package it\nusing the `pkgs.buildHomeAssistantComponent` function.\n\n[component-packages.nix]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/servers/home-assistant/component-packages.nix\n\nWhen done, add it to the same `services.home-assistant.customComponents` option.\nAlso, don't hesitate to upstream it to nixpkgs.\n\n### Custom Lovelace Modules {#services-home-assistant-usage-custom-lovelace-modules}\n\nTo add custom Lovelace UI elements, add them to the `services.home-assistant.customLovelaceModules` option.\nAvailable custom components can be found by searching packages for [home-assistant-custom-lovelace-modules][].\n\n[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\n\n```nix\nservices.home-assistant.customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [\n  mini-graph-card\n  mini-media-player\n  hourly-weather\n  weather-card\n];\n```\n\n### Extra Packages {#services-home-assistant-usage-extra-packages}\n\nThis is really only needed if by mischance, one of the components added earlier\nfail because of a missing Python3 package when the home-assistant systemd service is started.\nUsually, the required module will be shown in the traceback.\nTo know to which nixpkgs package this Python3 package correspond,\nsearch for a package in the [python3XXPackages set][].\n\n[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\n\n```nix\nservices.home-assistant.extraPackages = python3Packages: with python3Packages; [\n  grpcio\n];\n```\n\n### Extra Groups {#services-home-assistant-usage-extra-groups}\n\nSome components need access to hardware components which mean the home-assistant user\n`hass` must be added to some Unix group.\nFor example, the `hass` user must be added to the `dialout` group for the Sonoff component.\n\nThere's no systematic way to know this apart reading the logs when a\nhome-assistant component fails to start.\n\n```nix\nusers.users.hass.extraGroups = [ \"dialout\" ];\n```\n\n### Voice {#services-home-assistant-usage-voice}\n\nText to speech (TTS) and speech to text (STT) can be added with the\nstock nixpkgs options. The most performance hungry one is STT.\nIf you don't have a good CPU or better a GPU, you won't be able\nto use medium to big models. From my own experience using a low-end\nCPU, voice is pretty much unusable like that, even with mini models.\n\nHere is the configuration I use on a low-end CPU:\n\n```nix\nshb.home-assistant.voice.text-to-speech = {\n  \"fr\" = {\n    enable = true;\n    voice = \"fr-siwis-medium\";\n    uri = \"tcp://0.0.0.0:10200\";\n    speaker = 0;\n  };\n  \"en\" = {\n    enable = true;\n    voice = \"en_GB-alba-medium\";\n    uri = \"tcp://0.0.0.0:10201\";\n    speaker = 0;\n  };\n};\nshb.home-assistant.voice.speech-to-text = {\n  \"tiny-fr\" = {\n    enable = true;\n    model = \"base-int8\";\n    language = \"fr\";\n    uri = \"tcp://0.0.0.0:10300\";\n    device = \"cpu\";\n  };\n  \"tiny-en\" = {\n    enable = true;\n    model = \"base-int8\";\n    language = \"en\";\n    uri = \"tcp://0.0.0.0:10301\";\n    device = \"cpu\";\n  };\n};\nsystemd.services.wyoming-faster-whisper-tiny-en.environment.\"HF_HUB_CACHE\" = \"/tmp\";\nsystemd.services.wyoming-faster-whisper-tiny-fr.environment.\"HF_HUB_CACHE\" = \"/tmp\";\nshb.home-assistant.voice.wakeword = {\n  enable = true;\n  uri = \"tcp://127.0.0.1:10400\";\n  preloadModels = [\n    \"ok_nabu\"\n  ];\n};\n```\n\n### Music Assistant {#services-home-assistant-usage-music-assistant}\n\nTo add Music Assistant under the `ma.example.com` domain\nwith two factor SSO authentication, use the following configuration.\nThis assumes the [SSL][] and [SSO][] blocks are configured.\n\n[SSL]: blocks-ssl.html\n[SSO]: blocks-sso.html\n\n```nix\nservices.music-assistant = {\n  enable = true;\n  providers = [\n    \"airplay\"\n    \"hass\"\n    \"hass_players\"\n    \"jellyfin\"\n    \"radiobrowser\"\n    \"sonos\"\n    \"spotify\"\n  ];\n};\n\nshb.nginx.vhosts = [\n  {\n    subdomain = \"ma\";\n    domain = \"example.com\";\n    ssl = config.shb.certs.certs.letsencrypt.${domain};\n    authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    upstream = \"http://127.0.0.1:8095\";\n    autheliaRules = [{\n      domain = \"ma.${domain}\";\n      policy = \"two_factor\";\n      subject = [\"group:music-assistant_user\"];\n    }];\n  }\n];\n```\n\n## Debug {#services-home-assistant-debug}\n\nIn case of an issue, check the logs for systemd service `home-assistant.service`.\n\nEnable verbose logging by setting the `shb.home-assistant.debug` boolean to `true`.\n\nAccess the database with `sudo -u home-assistant psql`.\n\n## Options Reference {#services-home-assistant-options}\n\n```{=include=} options\nid-prefix: services-home-assistant-options-\nlist-id: selfhostblocks-service-home-assistant-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/home-assistant.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.home-assistant;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  ldap_auth_script_repo = pkgs.fetchFromGitHub {\n    owner = \"lldap\";\n    repo = \"lldap\";\n    rev = \"7d1f5abc137821c500de99c94f7579761fc949d8\";\n    sha256 = \"sha256-8D+7ww70Ja6Qwdfa+7MpjAAHewtCWNf/tuTAExoUrg0=\";\n  };\n\n  ldap_auth_script = pkgs.writeShellScriptBin \"ldap_auth.sh\" ''\n    export PATH=${pkgs.gnused}/bin:${pkgs.curl}/bin:${pkgs.jq}/bin\n    exec ${pkgs.bash}/bin/bash ${ldap_auth_script_repo}/example_configs/lldap-ha-auth.sh $@\n  '';\n\n  # Filter secrets from config. Secrets are those of the form { source = <path>; }\n  secrets = lib.attrsets.filterAttrs (k: v: builtins.isAttrs v) cfg.config;\n\n  nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config);\n\n  configWithSecretsIncludes = nonSecrets // (lib.attrsets.mapAttrs (k: v: \"!secret ${k}\") secrets);\nin\n{\n  imports = [\n    ../../lib/module.nix\n  ];\n\n  options.shb.home-assistant = {\n    enable = lib.mkEnableOption \"selfhostblocks.home-assistant\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which home-assistant will be served.\";\n      example = \"ha\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which home-assistant will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    config = lib.mkOption {\n      description = \"See all available settings at https://www.home-assistant.io/docs/configuration/basic/\";\n      type = lib.types.submodule {\n        freeformType = lib.types.attrsOf lib.types.str;\n        options = {\n          name = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              shb.secretFileType\n            ];\n            description = \"Name of the Home Assistant instance.\";\n          };\n          country = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              shb.secretFileType\n            ];\n            description = \"Two letter country code where this instance is located.\";\n          };\n          latitude = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              shb.secretFileType\n            ];\n            description = \"Latitude where this instance is located.\";\n          };\n          longitude = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              shb.secretFileType\n            ];\n            description = \"Longitude where this instance is located.\";\n          };\n          time_zone = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              shb.secretFileType\n            ];\n            description = \"Timezone of this instance.\";\n            example = \"America/Los_Angeles\";\n          };\n          unit_system = lib.mkOption {\n            type = lib.types.oneOf [\n              lib.types.str\n              (lib.types.enum [\n                \"metric\"\n                \"us_customary\"\n              ])\n            ];\n            description = \"Unit system of this instance.\";\n            example = \"metric\";\n          };\n        };\n      };\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html)\n\n        Enabling this app will create a new LDAP configuration or update one that exists with\n        the given host.\n\n        Also, enabling LDAP will skip onboarding\n        otherwise Home Assistant gets into a cyclic lock.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"LDAP app.\";\n\n          host = lib.mkOption {\n            type = lib.types.str;\n            description = ''\n              Host serving the LDAP server.\n\n\n              If set, the Home Assistant auth will be disabled. To keep it, set\n              `keepDefaultAuth` to `true`.\n            '';\n            default = \"127.0.0.1\";\n          };\n\n          port = lib.mkOption {\n            type = lib.types.port;\n            description = ''\n              Port of the service serving the LDAP server.\n            '';\n            default = 389;\n          };\n\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to login to Nextcloud.\";\n            default = \"homeassistant_user\";\n          };\n\n          keepDefaultAuth = lib.mkOption {\n            type = lib.types.bool;\n            description = ''\n              Keep Home Assistant auth active, even if LDAP is configured. Usually, you want to enable\n              this to transfer existing users to LDAP and then you can disabled it.\n            '';\n            default = false;\n          };\n        };\n      };\n    };\n\n    voice = lib.mkOption {\n      description = \"Options related to voice service.\";\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          speech-to-text = lib.mkOption {\n            description = ''\n              Wyoming piper servers.\n\n              https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.piper.servers\n            '';\n            type = lib.types.attrsOf lib.types.anything;\n            default = { };\n          };\n          text-to-speech = lib.mkOption {\n            description = ''\n              Wyoming faster-whisper servers.\n\n              https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.faster-whisper.servers\n            '';\n            type = lib.types.attrsOf lib.types.anything;\n            default = { };\n          };\n          wakeword = lib.mkOption {\n            description = ''\n              Wyoming open wakework servers.\n\n              https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.openwakeword\n            '';\n            type = lib.types.anything;\n            default = {\n              enable = false;\n            };\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"hass\";\n          # No need for backup hooks as we use an hourly automation job in home assistant directly with a cron job.\n          sourceDirectories = [\n            \"/var/lib/hass/backups\"\n          ];\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.home-assistant.subdomain}.\\${config.shb.home-assistant.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString config.services.home-assistant.config.http.server_port}\";\n          internalUrlText = \"http://127.0.0.1:\\${config.services.home-assistant.config.http.server_port}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    services.home-assistant = {\n      enable = true;\n      # Find them at https://github.com/NixOS/nixpkgs/blob/master/pkgs/servers/home-assistant/component-packages.nix\n      extraComponents = [\n        # Components required to complete the onboarding\n        \"met\"\n        \"radio_browser\"\n      ];\n      configDir = \"/var/lib/hass\";\n      # If you can't find a component in component-packages.nix, you can add them manually with something similar to:\n      # extraPackages = python3Packages: [\n      #   (python3Packages.simplisafe-python.overrideAttrs (old: rec {\n      #     pname = \"simplisafe-python\";\n      #     version = \"5b003a9fa1abd00f0e9a0b99d3ee57c4c7c16bda\";\n      #     format = \"pyproject\";\n\n      #     src = pkgs.fetchFromGitHub {\n      #       owner = \"bachya\";\n      #       repo = pname;\n      #       rev = \"${version}\";\n      #       hash = \"sha256-Ij2e0QGYLjENi/yhFBQ+8qWEJp86cgwC9E27PQ5xNno=\";\n      #     };\n      #   }))\n      # ];\n      config = {\n        # Includes dependencies for a basic setup\n        # https://www.home-assistant.io/integrations/default_config/\n        default_config = { };\n        http = {\n          use_x_forwarded_for = true;\n          server_host = \"127.0.0.1\";\n          server_port = 8123;\n          trusted_proxies = \"127.0.0.1\";\n        };\n        logger.default = \"info\";\n        homeassistant = configWithSecretsIncludes // {\n          external_url = \"https://${cfg.subdomain}.${cfg.domain}\";\n          internal_url = \"https://${cfg.subdomain}.${cfg.domain}\";\n          auth_providers =\n            (lib.optionals (!cfg.ldap.enable || cfg.ldap.keepDefaultAuth) [\n              {\n                type = \"homeassistant\";\n              }\n            ])\n            ++ (lib.optionals cfg.ldap.enable [\n              {\n                type = \"command_line\";\n                command = ldap_auth_script + \"/bin/ldap_auth.sh\";\n                args = [\n                  \"http://${cfg.ldap.host}:${toString cfg.ldap.port}\"\n                  cfg.ldap.userGroup\n                ];\n                meta = true;\n              }\n            ]);\n        };\n        \"automation ui\" = \"!include automations.yaml\";\n        \"scene ui\" = \"!include scenes.yaml\";\n        \"script ui\" = \"!include scripts.yaml\";\n\n        \"automation manual\" = [\n          {\n            alias = \"Create Backup on Schedule\";\n            trigger = [\n              {\n                platform = \"time_pattern\";\n                minutes = \"5\";\n              }\n            ];\n            action = [\n              {\n                service = \"shell_command.delete_backups\";\n                data = { };\n              }\n              {\n                service = \"backup.create\";\n                data = { };\n              }\n            ];\n            mode = \"single\";\n          }\n        ];\n\n        shell_command = {\n          delete_backups = \"find ${config.services.home-assistant.configDir}/backups -type f -delete\";\n        };\n\n        conversation.intents = {\n          TellJoke = [\n            \"Tell [me] (a joke|something funny|a dad joke)\"\n            \"Raconte [moi] (une blague)\"\n          ];\n        };\n        sensor = [\n          {\n            name = \"random_joke\";\n            platform = \"rest\";\n            json_attributes = [\n              \"joke\"\n              \"id\"\n              \"status\"\n            ];\n            value_template = \"{{ value_json.joke }}\";\n            resource = \"https://icanhazdadjoke.com/\";\n            scan_interval = \"3600\";\n            headers.Accept = \"application/json\";\n          }\n        ];\n        intent_script.TellJoke = {\n          speech.text = ''{{ state_attr(\"sensor.random_joke\", \"joke\") }}'';\n          action = {\n            service = \"homeassistant.update_entity\";\n            entity_id = \"sensor.random_joke\";\n          };\n        };\n      };\n    };\n\n    services.wyoming.piper.servers = cfg.voice.text-to-speech;\n    services.wyoming.faster-whisper.servers = cfg.voice.speech-to-text;\n    services.wyoming.openwakeword = cfg.voice.wakeword;\n\n    services.nginx.virtualHosts.\"${fqdn}\" = {\n      http2 = true;\n\n      forceSSL = !(isNull cfg.ssl);\n      sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n      sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n      extraConfig = ''\n        proxy_buffering off;\n      '';\n      locations.\"/\" = {\n        proxyPass = \"http://${toString config.services.home-assistant.config.http.server_host}:${toString config.services.home-assistant.config.http.server_port}/\";\n        proxyWebsockets = true;\n      };\n    };\n\n    systemd.services.home-assistant.preStart =\n      (\n        let\n          # TODO: this probably does not work anymore\n          onboarding = pkgs.writeText \"onboarding\" ''\n            {\n              \"version\": 4,\n              \"minor_version\": 1,\n              \"key\": \"onboarding\",\n              \"data\": {\n                \"done\": [\n                  ${lib.optionalString cfg.ldap.enable ''\"user\",''}\n                  \"core_config\",\n                  \"analytics\"\n                ]\n              }\n            }\n          '';\n          storage = \"${config.services.home-assistant.configDir}\";\n          file = \"${storage}/.storage/onboarding\";\n        in\n        ''\n          if [ ! -f ${file} ]; then\n            mkdir -p ''$(dirname ${file}) && cp ${onboarding} ${file}\n          fi\n        ''\n      )\n      + (shb.replaceSecrets {\n        userConfig = cfg.config;\n        resultPath = \"${config.services.home-assistant.configDir}/secrets.yaml\";\n        generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });\n      });\n\n    systemd.tmpfiles.rules = [\n      \"f ${config.services.home-assistant.configDir}/automations.yaml 0755 hass hass\"\n      \"f ${config.services.home-assistant.configDir}/scenes.yaml      0755 hass hass\"\n      \"f ${config.services.home-assistant.configDir}/scripts.yaml     0755 hass hass\"\n      \"d /var/lib/hass/backups 0750 hass hass\"\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/homepage/docs/default.md",
    "content": "# Homepage Service {#services-homepage}\n\nDefined in [`/modules/services/homepage.nix`](@REPO@/modules/services/homepage.nix),\nfound in the `selfhostblocks.nixosModules.homepage` module.\nSee [the manual](usage.html#usage-flake) for how to import the module in your code.\n\nThis service sets up [Homepage Dashboard][] which provides\na highly customizable homepage Docker and service API integrations. \n\n![](./Screenshot.png)\n\n[Homepage Dashboard]: https://github.com/gethomepage/homepage\n\n## Features {#services-homepage-features}\n\n- Declarative SSO login through forward authentication.\n  Only users of the [Homepage LDAP user group][] can access the web UI.\n  This is enforced using the [Authelia block][] which integrates with the LLDAP block.\n- Access through [subdomain][] using the reverse proxy.\n  It is implemented with the [Nginx block][].\n- Access through [HTTPS][] using the reverse proxy.\n  It is implemented with the [SSL block][].\n- Integration with [secrets contract][] to set the API key for a widget.\n\n[Homepage LDAP user group]: #services-homepage-options-shb.homepage.ldap.userGroup\n[Authelia block]: blocks-authelia.html\n[subdomain]: #services-open-webui-options-shb.open-webui.subdomain\n[HTTPS]: #services-open-webui-options-shb.open-webui.ssl\n[Nginx block]: blocks-nginx.html\n[SSL block]: blocks-ssl.html\n[secrets contract]: contracts-secret.html\n\n::: {.note}\nThe service does not use state so no backup or impermanence integration is provided.\n:::\n\n## Usage {#services-homepage-usage}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n::: {.note}\nPart of the configuration is done through the `shb.homepage` option described here\nand the rest is done through the upstream [`services.homepage-dashboard`][] option.\n:::\n\n[`services.homepage-dashboard`]: https://search.nixos.org/options?query=services.homepage-dashboard\n\n### Main service configuration {#services-homepage-usage-main}\n\nThis part sets up the web UI and its integration with the other SHB services.\nIt also creates the various service groups which will hold each service.\nThe names are arbitrary and you can order them as you wish through the `sortOrder` option.\n\n```nix\n{\n  shb.certs.certs.letsencrypt.${domain}.extraDomains = [\n    \"${config.shb.homepage.subdomain}.${config.shb.homepage.domain}\"\n  ];\n  shb.homepage = {\n    enable = true;\n    subdomain = \"home\";\n    inherit domain;\n    ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n    sso = {\n      enable = true;\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n    };\n\n    servicesGroups = {\n      Home.sortOrder = 1;\n      Documents.sortOrder = 2;\n      Finance.sortOrder = 3;\n      Media.sortOrder = 4;\n      Admin.sortOrder = 5;\n    };\n  };\n\n  services.homepage-dashboard = {\n    settings = {\n      statusStyle = \"dot\";\n      disableIndexing = true;\n    };\n\n    widgets = [\n      {\n        datetime = {\n          locale = \"fr\";\n          format = {\n            dateStyle = \"long\";\n            timeStyle = \"long\";\n          };\n        };\n      }\n    ];\n  };\n}\n```\n\nThe [Homepage LDAP user group][] is created automatically and users can be added declaratively to the group with:.\n\n```nix\n{\n  shb.lldap.ensureUsers.${user}.groups = [\n    config.shb.homepage.ldap.userGroup\n  ];\n}\n```\n\n### Display SHB service {#services-homepage-usage-service}\n\nA service consumer of the dashboard contract provides a `dashboard` option that can be used like so:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    sortOrder = 2;\n    dashboard.request = config.shb.jellyfin.dashboard.request;\n  };\n}\n```\n\nBy default:\n\n  - The `serviceName` option comes from the attr name, here `Jellyfin`.\n  - The `icon` option comes from applying `toLower` on the attr name.\n  - The `siteMonitor` option is set only if `internalUrl` is set.\n\nThey 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):\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    sortOrder = 2;\n    dashboard.request = config.shb.<service>.dashboard.request;\n    settings = {\n      // custom options here.\n    };\n  };\n}\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\n### Display custom service {#services-homepage-usage-custom}\n\nTo display a service that does not provide a `dashboard` option like in the previous section, set the values of the request manually:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    sortOrder = 2;\n    dashboard.request = {\n      externalUrl = \"https://jellyfin.example.com\";\n      internalUrl = \"http://127.0.0.1:8081\";\n    };\n  };\n}\n```\n\n### Add API key for widget {#services-homepage-usage-widget}\n\nFor services [supporting a widget](https://gethomepage.dev/widgets/),\ncreate an API key through the service's web UI if available\nthen store it securely (using SOPS for example) and provide it through the\n`apiKey` option:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    sortOrder = 1;\n    dashboard.request = config.shb.jellyfin.dashboard.request;\n    apiKey.result = config.shb.sops.secret.\"jellyfin/homepageApiKey\".result;\n  };\n  shb.sops.secret.\"jellyfin/homepageApiKey\".request =\n    config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request;\n}\n```\n\nUnfortunately creating API keys declaratively is rarely supported by upstream services.\n\n## Options Reference {#services-homepage-options}\n\n```{=include=} options\nid-prefix: services-homepage-options-\nlist-id: selfhostblocks-service-homepage-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/homepage.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.homepage;\n\n  inherit (lib) types;\nin\n{\n  imports = [\n    ../../lib/module.nix\n\n    ../blocks/lldap.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.homepage = {\n    enable = lib.mkEnableOption \"the SHB homepage service\";\n\n    subdomain = lib.mkOption {\n      type = types.str;\n      description = ''\n        Subdomain under which homepage will be served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      example = \"homepage\";\n    };\n\n    domain = lib.mkOption {\n      description = ''\n        Domain under which homepage is served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      type = types.str;\n      example = \"domain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    servicesGroups = lib.mkOption {\n      description = \"Group of services that should be showed on the dashboard.\";\n      default = { };\n      type = types.attrsOf (\n        types.submodule (\n          { name, ... }:\n          {\n            options = {\n              name = lib.mkOption {\n                type = types.str;\n                description = \"Display name of the group. Defaults to the attr name.\";\n                default = name;\n              };\n              sortOrder = lib.mkOption {\n                description = ''\n                  Order in which groups will be shown.\n\n                  The rules are:\n\n                    - Lowest number is shown first.\n                    - Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order.\n                    - Default is null which means at the end.\n                '';\n                type = types.nullOr types.int;\n                default = null;\n              };\n              services = lib.mkOption {\n                description = \"Services that should be showed in the group on the dashboard.\";\n                default = { };\n                type = types.attrsOf (\n                  types.submodule (\n                    { name, ... }:\n                    {\n                      options = {\n                        name = lib.mkOption {\n                          type = types.str;\n                          description = \"Display name of the service. Defaults to the attr name.\";\n                          default = name;\n                        };\n                        sortOrder = lib.mkOption {\n                          type = types.nullOr types.int;\n                          description = ''\n                            Order in which groups will be shown.\n\n                            The rules are:\n\n                              - Lowest number is shown first.\n                              - Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order.\n                              - Default is null which means at the end.\n                          '';\n                          default = null;\n                        };\n                        dashboard = lib.mkOption {\n                          description = ''\n                            Provider of the dashboard contract.\n\n                            By default:\n\n                              - The `serviceName` option comes from the attr name.\n                              - The `icon` option comes from applying `toLower` on the attr name.\n                              - The `siteMonitor` option is set only if `internalUrl` is set.\n                          '';\n                          type = types.submodule {\n                            options = shb.contracts.dashboard.mkProvider {\n                              resultCfg = { };\n                            };\n                          };\n                        };\n                        apiKey = lib.mkOption {\n                          description = ''\n                            API key used to access the service.\n\n                            This can be used to get data from the service.\n                          '';\n                          default = null;\n                          type = types.nullOr (\n                            lib.types.submodule {\n                              options = shb.contracts.secret.mkRequester {\n                                owner = \"root\";\n                                restartUnits = [ \"homepage-dashboard.service\" ];\n                              };\n                            }\n                          );\n                        };\n                        settings = lib.mkOption {\n                          description = ''\n                            Extra options to pass to the homepage service.\n\n                            Check https://gethomepage.dev/configs/services/#icons\n                            if the default icon is not correct.\n\n                            And check https://gethomepage.dev/widgets\n                            if the default widget type is not correct.\n                          '';\n                          default = { };\n                          type = types.attrsOf types.anything;\n                          example = lib.literalExpression ''\n                            {\n                              icon = \"si-homeassistant\";\n                              widget.type = \"firefly\";\n                              widget.custom = [\n                                {\n                                  template = \"{{ states('sensor.total_power', with_unit=True, rounded=True) }}\";\n                                  label = \"energy now\";\n                                }\n                                {\n                                  state = \"sensor.total_power_today\";\n                                  label = \"energy today\";\n                                }\n                              ];\n                            }\n                          '';\n                        };\n                      };\n                    }\n                  )\n                );\n              };\n            };\n          }\n        )\n      );\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        Setup LDAP integration.\n      '';\n      default = { };\n      type = types.submodule {\n        options = {\n          userGroup = lib.mkOption {\n            type = types.str;\n            description = \"Group users must belong to be able to login.\";\n            default = \"homepage_user\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"Endpoint to the SSO provider.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    services.homepage-dashboard = {\n      enable = true;\n\n      allowedHosts = \"${cfg.subdomain}.${cfg.domain}\";\n\n      settings = {\n        baseUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n        startUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n        disableUpdateCheck = true;\n      };\n\n      bookmarks = [ ];\n\n      services = shb.homepage.asServiceGroup cfg.servicesGroups;\n\n      widgets = [ ];\n    };\n\n    systemd.services.homepage-dashboard.serviceConfig =\n      let\n        keys = shb.homepage.allKeys cfg.servicesGroups;\n      in\n      {\n        # LoadCredential = [\n        #   \"Media_Jellyfin:/path\"\n        # ];\n        LoadCredential = lib.mapAttrsToList (name: path: \"${name}:${path}\") keys;\n        # Environment = [\n        #   \"HOMEPAGE_FILE_Media_Jellyfin=%d/Media_Jellyfin\"\n        # ];\n        Environment = lib.mapAttrsToList (name: path: \"HOMEPAGE_FILE_${name}=%d/${name}\") keys;\n      };\n\n    # This should be using a contract instead of setting the option directly.\n    shb.lldap = lib.mkIf config.shb.lldap.enable {\n      ensureGroups = {\n        ${cfg.ldap.userGroup} = { };\n      };\n    };\n\n    shb.nginx.vhosts = [\n      (\n        {\n          inherit (cfg) subdomain domain ssl;\n\n          upstream = \"http://127.0.0.1:${toString config.services.homepage-dashboard.listenPort}/\";\n          extraConfig = ''\n            proxy_read_timeout 300s;\n            proxy_send_timeout 300s;\n          '';\n          autheliaRules = lib.optionals (cfg.sso.enable) [\n            {\n              domain = \"${cfg.subdomain}.${cfg.domain}\";\n              policy = cfg.sso.authorization_policy;\n              subject = [ \"group:${cfg.ldap.userGroup}\" ];\n            }\n          ];\n        }\n        // lib.optionalAttrs cfg.sso.enable {\n          inherit (cfg.sso) authEndpoint;\n        }\n      )\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/immich.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.immich;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n  protocol = if !(isNull cfg.ssl) then \"https\" else \"http\";\n\n  roleClaim = \"immich_user\";\n\n  # TODO: Quota management, see https://github.com/ibizaman/selfhostblocks/pull/523#discussion_r2309421694\n  #quotaClaim = \"immich_quota\";\n  scopes = [\n    \"openid\"\n    \"email\"\n    \"profile\"\n    \"groups\"\n    \"immich_scope\"\n  ];\n\n  dataFolder = cfg.mediaLocation;\n  ssoFqdnWithPort =\n    if isNull cfg.sso.port then cfg.sso.endpoint else \"${cfg.sso.endpoint}:${toString cfg.sso.port}\";\n  # Generate Immich configuration file only for SHB-managed settings\n  shbManagedSettings =\n    lib.optionalAttrs (cfg.settings != { }) cfg.settings\n    // lib.optionalAttrs (cfg.sso.enable) {\n      oauth = {\n        enabled = true;\n        issuerUrl = \"${ssoFqdnWithPort}\";\n        clientId = cfg.sso.clientID;\n        roleClaim = roleClaim;\n        clientSecret = {\n          source = cfg.sso.sharedSecret.result.path;\n        };\n        scope = builtins.concatStringsSep \" \" scopes;\n        storageLabelClaim = cfg.sso.storageLabelClaim;\n        #storageQuotaClaim = quotaClaim; # TODO (commented out, otherwise defaults to 0 bytes!)\n        defaultStorageQuota = 0;\n        buttonText = cfg.sso.buttonText;\n        autoRegister = cfg.sso.autoRegister;\n        autoLaunch = cfg.sso.autoLaunch;\n        passwordLogin = cfg.sso.passwordLogin;\n        mobileOverrideEnabled = false;\n        mobileRedirectUri = \"\";\n      };\n    }\n    // lib.optionalAttrs (cfg.smtp != null) {\n      notifications = {\n        smtp = {\n          enabled = true;\n          from = cfg.smtp.from;\n          replyTo = cfg.smtp.replyTo;\n          transport = {\n            host = cfg.smtp.host;\n            port = cfg.smtp.port;\n            username = cfg.smtp.username;\n            password = {\n              source = cfg.smtp.password.result.path;\n            };\n            ignoreTLS = cfg.smtp.ignoreTLS;\n            secure = cfg.smtp.secure;\n          };\n        };\n      };\n    };\n\n  configFile = \"/var/lib/immich/config.json\";\n\n  # Use SHB's replaceSecrets function for loading secrets at runtime\n  configSetupScript = lib.optionalString (cfg.sso.enable || cfg.smtp != null) (\n    shb.replaceSecrets {\n      userConfig = shbManagedSettings;\n      resultPath = configFile;\n      generator = shb.replaceSecretsFormatAdapter (pkgs.formats.json { });\n      user = \"immich\";\n      permissions = \"u=r,g=,o=\";\n    }\n  );\n  inherit (lib)\n    mkEnableOption\n    mkIf\n    lists\n    mkOption\n    optionals\n    ;\n  inherit (lib.types)\n    attrs\n    attrsOf\n    bool\n    enum\n    listOf\n    nullOr\n    port\n    submodule\n    str\n    path\n    ;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.immich = {\n    enable = mkEnableOption \"selfhostblocks.immich\";\n\n    subdomain = mkOption {\n      type = str;\n      description = ''\n        Subdomain under which Immich will be served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      example = \"photos\";\n    };\n\n    domain = mkOption {\n      description = ''\n        Domain under which Immich is served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      type = str;\n      example = \"example.com\";\n    };\n\n    port = mkOption {\n      description = ''\n        Port under which Immich will listen.\n      '';\n      type = port;\n      default = 2283;\n    };\n\n    publicProxyEnable = mkOption {\n      description = ''\n        Enable Immich Public Proxy service for sharing media publically.\n      '';\n      type = bool;\n      default = false;\n    };\n\n    publicProxyPort = mkOption {\n      description = ''\n        Port under which Immich Public Proxy will listen.\n      '';\n      type = port;\n      default = 2284;\n    };\n\n    ssl = mkOption {\n      description = \"Path to SSL files\";\n      type = nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    mediaLocation = mkOption {\n      description = \"Directory where Immich will store media files.\";\n      type = str;\n      default = \"/var/lib/immich\";\n    };\n\n    jwtSecretFile = mkOption {\n      description = ''\n        File containing Immich's JWT secret key for sessions.\n        This is required for secure session management.\n      '';\n      type = nullOr (submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = \"immich\";\n          restartUnits = [ \"immich-server.service\" ];\n        };\n      });\n      default = null;\n    };\n\n    mount = mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"immich\" = {\n          poolName = \"root\";\n        } // config.shb.immich.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = dataFolder;\n      };\n    };\n\n    backup = mkOption {\n      description = ''\n        Backup configuration for Immich media files and database.\n      '';\n      default = { };\n      type = submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"immich\";\n          sourceDirectories = [\n            dataFolder\n          ];\n          excludePatterns = [\n            \"*.tmp\"\n            \"cache/*\"\n            \"encoded-video/*\"\n          ];\n        };\n      };\n    };\n\n    accelerationDevices = mkOption {\n      description = ''\n        Hardware acceleration devices for Immich.\n        Set to null to allow access to all devices.\n        Set to empty list to disable hardware acceleration.\n      '';\n      type = nullOr (listOf path);\n      default = null;\n      example = [ \"/dev/dri\" ];\n    };\n\n    machineLearning = mkOption {\n      description = \"Machine learning configuration.\";\n      default = { };\n      type = submodule {\n        options = {\n          enable = mkOption {\n            description = \"Enable machine learning features.\";\n            type = bool;\n            default = true;\n          };\n\n          environment = mkOption {\n            description = \"Extra environment variables for machine learning service.\";\n            type = attrsOf str;\n            default = { };\n            example = {\n              MACHINE_LEARNING_WORKERS = \"2\";\n              MACHINE_LEARNING_WORKER_TIMEOUT = \"180\";\n            };\n          };\n        };\n      };\n    };\n\n    sso = mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = submodule {\n        options = {\n          enable = mkEnableOption \"SSO integration.\";\n\n          provider = mkOption {\n            type = enum [\n              \"Authelia\"\n              \"Keycloak\"\n              \"Generic\"\n            ];\n            description = \"OIDC provider name, used for display.\";\n            default = \"Authelia\";\n          };\n\n          endpoint = mkOption {\n            type = str;\n            description = \"OIDC endpoint for SSO.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = mkOption {\n            type = str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"immich\";\n          };\n\n          adminUserGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC admin group\";\n            default = \"immich_admin\";\n          };\n\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC user group\";\n            default = \"immich_user\";\n          };\n\n          port = mkOption {\n            description = \"If given, adds a port to the endpoint.\";\n            type = nullOr port;\n            default = null;\n          };\n\n          storageLabelClaim = mkOption {\n            type = str;\n            description = \"Claim to use for user storage label.\";\n            default = \"preferred_username\";\n          };\n\n          buttonText = mkOption {\n            type = str;\n            description = \"Text to display on the SSO login button.\";\n            default = \"Login with SSO\";\n          };\n\n          autoRegister = mkOption {\n            type = bool;\n            description = \"Automatically register new users from SSO provider.\";\n            default = true;\n          };\n\n          autoLaunch = mkOption {\n            type = bool;\n            description = \"Automatically redirect to SSO provider.\";\n            default = true;\n          };\n\n          passwordLogin = mkOption {\n            type = bool;\n            description = \"Enable password login.\";\n            default = true;\n          };\n\n          sharedSecret = mkOption {\n            description = \"OIDC shared secret for Immich.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"immich\";\n                group = \"immich\";\n                restartUnits = [ \"immich-server.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = mkOption {\n            description = \"OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"authelia\";\n              };\n            };\n            default = null;\n          };\n\n          authorization_policy = mkOption {\n            type = enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n        };\n      };\n    };\n\n    settings = mkOption {\n      type = attrs;\n      description = ''\n        Immich configuration settings.\n        Only specify settings that you want SHB to manage declaratively.\n        Other settings can be configured through Immich's admin UI.\n\n        See https://immich.app/docs/install/config-file/ for available options.\n      '';\n      default = { };\n      example = {\n        ffmpeg.crf = 23;\n        job.backgroundTask.concurrency = 5;\n        storageTemplate = {\n          enabled = true;\n          template = \"{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}\";\n        };\n      };\n    };\n\n    smtp = mkOption {\n      description = ''\n        SMTP configuration for sending notifications.\n      '';\n      default = null;\n      type = nullOr (submodule {\n        options = {\n          from = mkOption {\n            type = str;\n            description = \"SMTP address from which the emails originate.\";\n            example = \"noreply@example.com\";\n          };\n\n          replyTo = mkOption {\n            type = str;\n            description = \"Reply-to address for emails.\";\n            example = \"support@example.com\";\n          };\n\n          host = mkOption {\n            type = str;\n            description = \"SMTP host to send the emails to.\";\n            example = \"smtp.example.com\";\n          };\n\n          port = mkOption {\n            type = port;\n            description = \"SMTP port to send the emails to.\";\n            default = 587;\n          };\n\n          username = mkOption {\n            type = str;\n            description = \"Username to connect to the SMTP host.\";\n            example = \"smtp-user\";\n          };\n\n          password = mkOption {\n            description = \"File containing the password to connect to the SMTP host.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"immich\";\n                restartUnits = [ \"immich-server.service\" ];\n              };\n            };\n          };\n\n          ignoreTLS = mkOption {\n            type = bool;\n            description = \"Ignore TLS certificate errors.\";\n            default = false;\n          };\n\n          secure = mkOption {\n            type = bool;\n            description = \"Use secure connection (SSL/TLS).\";\n            default = false;\n          };\n        };\n      });\n    };\n\n    debug = mkOption {\n      type = bool;\n      description = \"Set to true to enable debug logging.\";\n      default = false;\n      example = true;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${fqdn}\";\n          externalUrlText = \"https://\\${config.shb.immich.subdomain}.\\${config.shb.immich.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = mkIf cfg.enable {\n    assertions = [\n      {\n        assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key);\n        message = \"SSL is enabled for Immich but no cert or key is provided.\";\n      }\n      {\n        assertion = cfg.sso.enable -> cfg.ssl != null;\n        message = \"To integrate SSO, SSL must be enabled, set the shb.immich.ssl option.\";\n      }\n    ];\n\n    # Configure Immich service\n    services.immich = {\n      enable = true;\n      host = \"127.0.0.1\";\n      port = cfg.port;\n      mediaLocation = cfg.mediaLocation;\n\n      # Hardware acceleration configuration\n      accelerationDevices = cfg.accelerationDevices;\n\n      # Database configuration defaults to Unix socket /run/postgresql\n\n      # Machine learning configuration\n      machine-learning = mkIf cfg.machineLearning.enable {\n        enable = true;\n        environment = cfg.machineLearning.environment;\n      };\n\n      # Environment configuration\n      environment = {\n        IMMICH_LOG_LEVEL = if cfg.debug then \"debug\" else \"log\";\n        REDIS_HOSTNAME = \"127.0.0.1\";\n        REDIS_PORT = \"6379\";\n        REDIS_DBINDEX = \"0\";\n      }\n      // lib.optionalAttrs (cfg.jwtSecretFile != null) {\n        JWT_SECRET_FILE = cfg.jwtSecretFile.result.path;\n      }\n      // lib.optionalAttrs (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) {\n        IMMICH_CONFIG_FILE = configFile;\n      };\n    };\n\n    services.immich-public-proxy = mkIf (cfg.publicProxyEnable) {\n      enable = true;\n      port = cfg.publicProxyPort;\n      immichUrl = \"https://${fqdn}\";\n    };\n\n    # Create basic directories for Immich\n    systemd.tmpfiles.rules = [\n      \"d /var/lib/immich 0700 immich immich\"\n    ];\n\n    # Configuration setup service - generates config only for SHB-managed settings\n    systemd.services.immich-setup-config =\n      mkIf (cfg.enable && (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null))\n        {\n          description = \"Setup Immich configuration for SHB-managed settings\";\n          wantedBy = [ \"multi-user.target\" ];\n          before = [ \"immich-server.service\" ];\n          after = [ \"network.target\" ];\n          serviceConfig = {\n            Type = \"oneshot\";\n            User = \"immich\";\n            Group = \"immich\";\n          };\n          script = ''\n            mkdir -p ${dataFolder}\n\n            # Generate config file with only SHB-managed settings\n            ${configSetupScript}\n          '';\n        };\n\n    # Add immich user to video and render groups for hardware acceleration\n    users.users.immich.extraGroups = optionals (cfg.accelerationDevices != [ ]) [\n      \"video\"\n      \"render\"\n    ];\n\n    # PostgreSQL extensions are automatically handled by the Immich service\n\n    # Redis is automatically configured by the Immich service\n\n    # Configure Nginx reverse proxy\n    shb.nginx.vhosts = [\n      {\n        inherit (cfg) subdomain domain ssl;\n        upstream = \"http://127.0.0.1:${toString cfg.port}\";\n        autheliaRules = lib.mkIf (cfg.sso.enable) [\n          {\n            domain = fqdn;\n            policy = \"bypass\";\n            resources = [\n              \"^/api.*\"\n              \"^/.well-known/immich\"\n              \"^/share.*\"\n              \"^/_app/immutable/.*\"\n            ];\n          }\n          {\n            domain = fqdn;\n            policy = cfg.sso.authorization_policy;\n            subject = [\n              \"group:immich_user\"\n              \"group:immich_admin\"\n            ];\n          }\n        ];\n        authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint;\n        extraConfig = ''\n          proxy_read_timeout 600s;\n          proxy_send_timeout 600s;\n          send_timeout 600s;\n          proxy_buffering off;\n        '';\n      }\n    ];\n\n    # Allow large uploads from mobile app\n    services.nginx.virtualHosts.\"${fqdn}\" = {\n      extraConfig = ''\n        client_max_body_size 50G;\n      '';\n      locations.\"^~ /share\" = {\n        recommendedProxySettings = true;\n        proxyPass = \"http://127.0.0.1:${toString cfg.publicProxyPort}\";\n      };\n    };\n\n    # Ensure services start in correct order\n    systemd.services.immich-server = {\n      after = [\n        \"postgresql.service\"\n        \"redis-immich.service\"\n      ]\n      ++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [\n        \"immich-setup-config.service\"\n      ];\n      requires = [\n        \"postgresql.service\"\n        \"redis-immich.service\"\n      ]\n      ++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [\n        \"immich-setup-config.service\"\n      ];\n    };\n\n    systemd.services.immich-machine-learning = mkIf cfg.machineLearning.enable {\n      after = [ \"immich-server.service\" ];\n    };\n\n    # Authelia integration for SSO\n    shb.authelia.extraDefinitions = {\n      # Immich expects all users that get a token to be granted access. So users can either be part of the\n      # \"admin\" group or the \"user\" group. Users that are not part of either should be blocked by\n      # the ID provider (Authelia).\n      user_attributes.${roleClaim}.expression =\n        ''\"${cfg.sso.adminUserGroup}\" in groups ? \"admin\" : \"user\"'';\n    };\n    shb.authelia.extraOidcClaimsPolicies.immich_policy = {\n      custom_claims = {\n        ${roleClaim} = { };\n      };\n    };\n    shb.authelia.extraOidcScopes.immich_scope = {\n      claims = [ roleClaim ];\n    };\n\n    shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == \"Authelia\") [\n      {\n        client_id = cfg.sso.clientID;\n        client_name = \"Immich\";\n        client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n        public = false;\n        authorization_policy = cfg.sso.authorization_policy;\n        claims_policy = \"immich_policy\";\n        token_endpoint_auth_method = \"client_secret_post\";\n        redirect_uris = [\n          \"${protocol}://${fqdn}/auth/login\"\n          \"${protocol}://${fqdn}/user-settings\"\n          \"app.immich:///oauth-callback\"\n        ];\n        inherit scopes;\n      }\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/jellyfin/docs/default.md",
    "content": "# Jellyfin Service {#services-jellyfin}\n\nDefined in [`/modules/services/jellyfin.nix`](@REPO@/modules/services/jellyfin.nix).\n\nThis NixOS module is a service that sets up a [Jellyfin](https://jellyfin.org/) instance.\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner:\n- the initial wizard with an admin user thanks to a custom Jellyfin CLI\n  and a custom restart logic to apply the changes from the CLI.\n- LDAP and SSO integration thanks to a custom declarative installation of plugins.\n\n## Features {#services-jellyfin-features}\n\n- Declarative creation of admin user.\n- Declarative selection of listening port.\n- Access through [subdomain](#services-jellyfin-options-shb.jellyfin.subdomain)\n  and [HTTPS](#services-jellyfin-options-shb.jellyfin.ssl) using reverse proxy. [Manual](#services-jellyfin-usage).\n- Declarative plugin installation. [Manual](#services-jellyfin-options-shb.jellyfin.plugins).\n- Declarative [LDAP](#services-jellyfin-options-shb.jellyfin.ldap) configuration.\n- Declarative [SSO](#services-jellyfin-options-shb.jellyfin.sso) configuration.\n- [Backup](#services-jellyfin-options-shb.jellyfin.backup) through the [backup block](./blocks-backup.html). [Manual](#services-jellyfin-usage-backup).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-jellyfin-usage-applicationdashboard)\n\n## Usage {#services-jellyfin-usage}\n\n### Initial Configuration {#services-jellyfin-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\nshb.jellyfin = {\n  enable = true;\n  subdomain = \"jellyfin\";\n  domain = \"example.com\";\n\n  admin = {\n    username = \"admin\";\n    password.result = config.shb.sops.secret.\"jellyfin/adminPassword\".result;\n  };\n\n  ldap = {\n    enable = true;\n    host = \"127.0.0.1\";\n    port = config.shb.lldap.ldapPort;\n    dcdomain = config.shb.lldap.dcdomain;\n    adminPassword.result = config.shb.sops.secret.\"jellyfin/ldap/adminPassword\".result\n  };\n\n  sso = {\n    enable = true;\n    endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n  \n    secretFile = config.shb.sops.secret.\"jellyfin/sso_secret\".result;\n    secretFileForAuthelia = config.shb.sops.secret.\"jellyfin/authelia/sso_secret\".result;\n  };\n};\n\nshb.sops.secret.\"jellyfin/adminPassword\".request = config.shb.jellyfin.admin.password.request;\n\nshb.sops.secret.\"jellyfin/ldap/adminPassword\".request = config.shb.jellyfin.ldap.adminPassword.request;\n\nshb.sops.secret.\"jellyfin/sso_secret\".request = config.shb.jellyfin.sso.sharedSecret.request;\nshb.sops.secret.\"jellyfin/authelia/sso_secret\" = {\n  request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request;\n  settings.key = \"jellyfin/sso_secret\";\n};\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nThe [user](#services-jellyfin-options-shb.jellyfin.ldap.userGroup)\nand [admin](#services-jellyfin-options-shb.jellyfin.ldap.adminGroup)\nLDAP groups are created automatically.\n\nThe `shb.jellyfin.sso.secretFile` and `shb.jellyfin.sso.secretFileForAuthelia` options\nmust have the same content. The former is a file that must be owned by the `jellyfin` user while\nthe latter must be owned by the `authelia` user. I want to avoid needing to define the same secret\ntwice with a future secrets SHB block.\n\n### Certificates {#services-jellyfin-certs}\n\nFor Let's Encrypt certificates, add:\n\n```nix\n{\n  shb.certs.certs.letsencrypt.${domain}.extraDomains = [\n    \"${config.shb.jellyfin.subdomain}.${config.shb.jellyfin.domain}\"\n  ];\n}\n```\n\n### Backup {#services-jellyfin-usage-backup}\n\nBacking up Jellyfin using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"jellyfin\" = {\n  request = config.shb.jellyfin.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"jellyfin\"` in the `instances` can be anything.\nThe `config.shb.jellyfin.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Jellyfin multiple times.\n\nYou will then need to configure more options like the `repository`,\nas explained in the [restic](blocks-restic.html) documentation.\n\n### Impermanence {#services-jellyfin-impermanence}\n\nTo save the data folder in an impermanence setup, add:\n\n```nix\n{\n  shb.zfs.datasets.\"safe/jellyfin\".path = config.shb.jellyfin.impermanence;\n}\n```\n\n### Declarative LDAP {#services-jellyfin-declarative-ldap}\n\nTo add a user `USERNAME` to the user and admin groups for jellyfin, add:\n\n```nix\nshb.lldap.ensureUsers.USERNAME.groups = [\n  config.shb.jellyfin.ldap.userGroup\n  config.shb.jellyfin.ldap.adminGroup\n];\n```\n\n### Application Dashboard {#services-jellyfin-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-jellyfin-options-shb.jellyfin.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    sortOrder = 1;\n    dashboard.request = config.shb.jellyfin.dashboard.request;\n  };\n}\n```\n\nAn API key can be set to show extra info:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Jellyfin = {\n    apiKey.result = config.shb.sops.secret.\"jellyfin/homepageApiKey\".result;\n  };\n\n  shb.sops.secret.\"jellyfin/homepageApiKey\".request =\n    config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request;\n}\n```\n\n## Debug {#services-jellyfin-debug}\n\nIn case of an issue, check the logs for systemd service `jellyfin.service`.\n\nEnable verbose logging by setting the `shb.jellyfin.debug` boolean to `true`.\n\n## Options Reference {#services-jellyfin-options}\n\n```{=include=} options\nid-prefix: services-jellyfin-options-\nlist-id: selfhostblocks-service-jellyfin-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/jellyfin.nix",
    "content": "{\n  config,\n  lib,\n  pkgs,\n  shb,\n  ...\n}:\n\nlet\n  inherit (lib) types;\n\n  cfg = config.shb.jellyfin;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  jellyfin = pkgs.buildDotnetModule rec {\n    pname = \"jellyfin\";\n    version = \"10.11.6\";\n\n    src = pkgs.fetchFromGitHub {\n      owner = \"ibizaman\";\n      repo = \"jellyfin\";\n      rev = \"c58ca41d9ee76d137be788cd6f2d089e288ad561\";\n      hash = \"sha256-gTHsz5qRT+9FjAqBb4hDBkHChYDU52snBWu6cQb10i4=\";\n    };\n\n    propagatedBuildInputs = [ pkgs.sqlite ];\n\n    projectFile = \"Jellyfin.Server/Jellyfin.Server.csproj\";\n    executables = [ \"jellyfin\" ];\n    nugetDeps = \"${pkgs.path}/pkgs/by-name/je/jellyfin/nuget-deps.json\";\n    runtimeDeps = [\n      pkgs.jellyfin-ffmpeg\n      pkgs.fontconfig\n      pkgs.freetype\n    ];\n    dotnet-sdk = pkgs.dotnetCorePackages.sdk_9_0;\n    dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_9_0;\n    dotnetBuildFlags = [ \"--no-self-contained\" ];\n\n    makeWrapperArgs = [\n      \"--append-flags\"\n      \"--ffmpeg=${pkgs.jellyfin-ffmpeg}/bin/ffmpeg\"\n      \"--append-flags\"\n      \"--webdir=${pkgs.jellyfin-web}/share/jellyfin-web\"\n    ];\n\n    passthru.tests = {\n      smoke-test = pkgs.nixosTests.jellyfin;\n    };\n\n    meta = with pkgs.lib; {\n      description = \"Free Software Media System\";\n      homepage = \"https://jellyfin.org/\";\n      # https://github.com/jellyfin/jellyfin/issues/610#issuecomment-537625510\n      license = licenses.gpl2Plus;\n      maintainers = with maintainers; [\n        nyanloutre\n        minijackson\n        purcell\n        jojosch\n      ];\n      mainProgram = \"jellyfin\";\n      platforms = dotnet-runtime.meta.platforms;\n    };\n  };\n\n  pluginName =\n    src:\n    let\n      meta = builtins.fromJSON (builtins.readFile \"${src}/meta.json\");\n    in\n    \"${meta.name}_${meta.version}\";\n\n  pluginNamePrefix =\n    src:\n    let\n      meta = builtins.fromJSON (builtins.readFile \"${src}/meta.json\");\n    in\n    \"${meta.name}\";\nin\n{\n  options.shb.jellyfin = {\n    enable = lib.mkEnableOption \"shb jellyfin\";\n\n    subdomain = lib.mkOption {\n      type = types.str;\n      description = \"Subdomain under which home-assistant will be served.\";\n      example = \"jellyfin\";\n    };\n\n    domain = lib.mkOption {\n      description = \"Domain to serve sites under.\";\n      type = types.str;\n      example = \"domain.com\";\n    };\n\n    port = lib.mkOption {\n      description = \"Listen on port.\";\n      type = types.port;\n      default = 8096;\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    debug = lib.mkOption {\n      description = \"Enable debug logging\";\n      type = types.bool;\n      default = false;\n    };\n\n    admin = lib.mkOption {\n      description = \"Default admin user info. Only needed if LDAP or SSO is not configured.\";\n      default = null;\n      type = types.nullOr (\n        types.submodule {\n          options = {\n            username = lib.mkOption {\n              description = \"Username of the default admin user.\";\n              type = types.str;\n              default = \"jellyfin\";\n            };\n            password = lib.mkOption {\n              description = \"Password of the default admin user.\";\n              type = types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0440\";\n                  owner = \"jellyfin\";\n                  group = \"jellyfin\";\n                  restartUnits = [ \"jellyfin.service\" ];\n                };\n              };\n            };\n          };\n        }\n      );\n    };\n\n    plugins = lib.mkOption {\n      description = ''\n        Install plugins declaratively.\n\n        The LDAP and SSO plugins will be added if their respective\n        shb.jellyfin.ldap.enable and shb.jellyfin.sso.enable options are set to true.\n\n        The interface for plugin creation is WIP.\n        Feel free to add yours following the examples from the LDAP and SSO plugins\n        but know that they may require some tweaks later on.\n        Notably, configuration is not yet handled by this option\n        so that will be added in the future.\n\n        Each plugin's meta.json must be writeable because Jellyfin appends some information\n        upon installing the plugin, like its active or disabled status.\n        SHB automatically enables the plugin\n        and deletes any plugin with the same prefix but other versions.\n        Note that SHB does not attempt to find which version is latest.\n        If twice the same plugin is added, the last one in the \"plugins\" list wins.\n      '';\n      default = [ ];\n      type = types.listOf types.package;\n    };\n\n    ldap = lib.mkOption {\n      description = \"LDAP configuration.\";\n      default = { };\n      type = types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"LDAP\";\n\n          plugin = lib.mkOption {\n            type = lib.types.package;\n            description = \"Pluging used for LDAP authentication.\";\n            default = shb.mkJellyfinPlugin (rec {\n              pname = \"jellyfin-plugin-ldapauth\";\n              version = \"22\";\n              url = \"https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip\";\n              hash = \"sha256-m2oD9woEuoSRiV9OeifAxZN7XQULMKS0Yq4TF+LjjpI=\";\n            });\n          };\n\n          host = lib.mkOption {\n            type = types.str;\n            description = \"Host serving the LDAP server.\";\n            example = \"127.0.0.1\";\n          };\n\n          port = lib.mkOption {\n            type = types.int;\n            description = \"Port where the LDAP server is listening.\";\n            example = 389;\n          };\n\n          dcdomain = lib.mkOption {\n            type = types.str;\n            description = \"DC domain for LDAP.\";\n            example = \"dc=mydomain,dc=com\";\n          };\n\n          userGroup = lib.mkOption {\n            type = types.str;\n            description = \"LDAP user group\";\n            default = \"jellyfin_user\";\n          };\n\n          adminGroup = lib.mkOption {\n            type = types.str;\n            description = \"LDAP admin group\";\n            default = \"jellyfin_admin\";\n          };\n\n          adminPassword = lib.mkOption {\n            description = \"LDAP admin password.\";\n            type = types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"jellyfin\";\n                group = \"jellyfin\";\n                restartUnits = [ \"jellyfin.service\" ];\n              };\n            };\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = \"SSO configuration.\";\n      default = { };\n      type = types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO\";\n\n          plugin = lib.mkOption {\n            type = lib.types.package;\n            description = \"Pluging used for SSO authentication.\";\n            default = shb.mkJellyfinPlugin (rec {\n              pname = \"jellyfin-plugin-sso\";\n              version = \"4.0.0.3\";\n              url = \"https://github.com/9p4/${pname}/releases/download/v${version}/sso-authentication_${version}.zip\";\n              hash = \"sha256-Jkuc+Ua7934iSutf/zTY1phTxaltUkfiujOkCi7BW8w=\";\n            });\n          };\n\n          provider = lib.mkOption {\n            type = types.str;\n            description = \"OIDC provider name\";\n            default = \"Authelia\";\n          };\n\n          endpoint = lib.mkOption {\n            type = types.str;\n            description = \"OIDC endpoint for SSO\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = lib.mkOption {\n            type = types.str;\n            description = \"Client ID for the OIDC endpoint\";\n            default = \"jellyfin\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = lib.mkOption {\n            description = \"OIDC shared secret for Jellyfin.\";\n            type = types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0440\";\n                owner = \"jellyfin\";\n                group = \"jellyfin\";\n                restartUnits = [ \"jellyfin.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret for Authelia.\";\n            type = types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                ownerText = \"config.shb.authelia.autheliaUser\";\n                owner = config.shb.authelia.autheliaUser;\n              };\n            };\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"jellyfin\";\n          sourceDirectories = [\n            config.services.jellyfin.dataDir\n          ];\n          sourceDirectoriesText = ''\n            [\n              \"services.jellyfin.dataDir\"\n            ]\n          '';\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${fqdn}\";\n          externalUrlText = \"https://\\${config.shb.jellyfin.subdomain}.\\${config.shb.jellyfin.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  imports = [\n    ../../lib/module.nix\n\n    (lib.mkRenamedOptionModule\n      [ \"shb\" \"jellyfin\" \"adminPassword\" ]\n      [ \"shb\" \"jellyfin\" \"admin\" \"password\" ]\n    )\n\n    # (lib.mkRenamedOptionModule\n    #   [ \"shb\" \"jellyfin\" \"sso\" \"userGroup\" ]\n    #   [ \"shb\" \"jellyfin\" \"ldap\" \"userGroup\" ]\n    # )\n\n    # (lib.mkRenamedOptionModule\n    #   [ \"shb\" \"jellyfin\" \"sso\" \"adminUserGroup\" ]\n    #   [ \"shb\" \"jellyfin\" \"ldap\" \"adminGroup\" ]\n    # )\n  ];\n\n  config = lib.mkIf cfg.enable {\n    assertions = [\n      {\n        assertion = (!cfg.ldap.enable && !cfg.sso.enable) -> cfg.admin != null;\n        message = \"Jellyfin admin user must be configured with shb.jellyfin.admin if LDAP or SSO integration are not configured.\";\n      }\n    ];\n\n    services.jellyfin.enable = true;\n    services.jellyfin.package = jellyfin;\n\n    networking.firewall = {\n      # from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery\n      allowedUDPPorts = [\n        1900\n        7359\n      ];\n    };\n\n    services.nginx.enable = true;\n\n    # Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex\n    services.nginx.virtualHosts.\"${fqdn}\" = {\n      forceSSL = !(isNull cfg.ssl);\n      sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n      sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n      http2 = true;\n\n      extraConfig = ''\n        # The default `client_max_body_size` is 1M, this might not be enough for some posters, etc.\n        client_max_body_size 20M;\n\n        # Some players don't reopen a socket and playback stops totally instead of resuming after an extended pause\n        send_timeout 100m;\n\n        # use a variable to store the upstream proxy\n        # in this example we are using a hostname which is resolved via DNS\n        # (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`)\n        set $jellyfin 127.0.0.1;\n        # resolver 127.0.0.1 valid=30;\n\n        #include /etc/letsencrypt/options-ssl-nginx.conf;\n        #ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;\n        #add_header Strict-Transport-Security \"max-age=31536000\" always;\n        #ssl_trusted_certificate /etc/letsencrypt/live/DOMAIN_NAME/chain.pem;\n        # Why this is important: https://blog.cloudflare.com/ocsp-stapling-how-cloudflare-just-made-ssl-30/\n        ssl_stapling on;\n        ssl_stapling_verify on;\n\n        # Security / XSS Mitigation Headers\n        # NOTE: X-Frame-Options may cause issues with the webOS app\n        add_header X-Frame-Options \"SAMEORIGIN\";\n        add_header X-XSS-Protection \"0\"; # Do NOT enable. This is obsolete/dangerous\n        add_header X-Content-Type-Options \"nosniff\";\n\n        # COOP/COEP. Disable if you use external plugins/images/assets\n        add_header Cross-Origin-Opener-Policy \"same-origin\" always;\n        add_header Cross-Origin-Embedder-Policy \"require-corp\" always;\n        add_header Cross-Origin-Resource-Policy \"same-origin\" always;\n\n        # Permissions policy. May cause issues on some clients\n        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;\n\n        # Tell browsers to use per-origin process isolation\n        add_header Origin-Agent-Cluster \"?1\" always;\n\n\n        # Content Security Policy\n        # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP\n        # Enforces https content and restricts JS/CSS to origin\n        # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted.\n        # NOTE: The default CSP headers may cause issues with the webOS app\n        #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'\";\n\n        # 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.\n        gzip on;\n        gzip_vary on;\n        gzip_min_length 1000;\n        gzip_proxied any;\n        gzip_types text/plain text/css text/xml application/xml text/javascript application/x-javascript image/svg+xml;\n        gzip_disable \"MSIE [1-6]\\.\";\n\n        location = / {\n            return 302 http://$host/web/;\n            #return 302 https://$host/web/;\n        }\n\n        location / {\n            # Proxy main Jellyfin traffic\n            proxy_pass http://$jellyfin:${toString cfg.port};\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header X-Forwarded-Protocol $scheme;\n            proxy_set_header X-Forwarded-Host $http_host;\n\n            # Disable buffering when the nginx proxy gets very resource heavy upon streaming\n            proxy_buffering off;\n        }\n\n        # location block for /web - This is purely for aesthetics so /web/#!/ works instead of having to go to /web/index.html/#!/\n        location = /web/ {\n            # Proxy main Jellyfin traffic\n            proxy_pass http://$jellyfin:${toString cfg.port}/web/index.html;\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header X-Forwarded-Protocol $scheme;\n            proxy_set_header X-Forwarded-Host $http_host;\n        }\n\n        location /socket {\n            # Proxy Jellyfin Websockets traffic\n            proxy_pass http://$jellyfin:${toString cfg.port};\n            proxy_http_version 1.1;\n            proxy_set_header Upgrade $http_upgrade;\n            proxy_set_header Connection \"upgrade\";\n            proxy_set_header Host $host;\n            proxy_set_header X-Real-IP $remote_addr;\n            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n            proxy_set_header X-Forwarded-Proto $scheme;\n            proxy_set_header X-Forwarded-Protocol $scheme;\n            proxy_set_header X-Forwarded-Host $http_host;\n        }\n      '';\n    };\n\n    services.prometheus.scrapeConfigs = [\n      {\n        job_name = \"jellyfin\";\n        static_configs = [\n          {\n            targets = [ \"127.0.0.1:${toString cfg.port}\" ];\n            labels = {\n              \"hostname\" = config.networking.hostName;\n              \"domain\" = cfg.domain;\n            };\n          }\n        ];\n      }\n    ];\n\n    # LDAP config but you need to install the plugin by hand\n\n    systemd.services.jellyfin.preStart =\n      let\n        ldapConfig = pkgs.writeText \"LDAP-Auth.xml\" ''\n          <?xml version=\"1.0\" encoding=\"utf-8\"?>\n          <PluginConfiguration xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n            <LdapServer>${cfg.ldap.host}</LdapServer>\n            <LdapPort>${builtins.toString cfg.ldap.port}</LdapPort>\n            <UseSsl>false</UseSsl>\n            <UseStartTls>false</UseStartTls>\n            <SkipSslVerify>false</SkipSslVerify>\n            <LdapBindUser>uid=admin,ou=people,${cfg.ldap.dcdomain}</LdapBindUser>\n            <LdapBindPassword>%SECRET_LDAP_PASSWORD%</LdapBindPassword>\n            <LdapBaseDn>ou=people,${cfg.ldap.dcdomain}</LdapBaseDn>\n            <LdapSearchFilter>(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})</LdapSearchFilter>\n            <LdapAdminBaseDn>ou=people,${cfg.ldap.dcdomain}</LdapAdminBaseDn>\n            <LdapAdminFilter>(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})</LdapAdminFilter>\n            <EnableLdapAdminFilterMemberUid>false</EnableLdapAdminFilterMemberUid>\n            <LdapSearchAttributes>uid, cn, mail, displayName</LdapSearchAttributes>\n            <LdapClientCertPath />\n            <LdapClientKeyPath />\n            <LdapRootCaPath />\n            <CreateUsersFromLdap>true</CreateUsersFromLdap>\n            <AllowPassChange>false</AllowPassChange>\n            <LdapUsernameAttribute>uid</LdapUsernameAttribute>\n            <LdapPasswordAttribute>userPassword</LdapPasswordAttribute>\n            <EnableAllFolders>true</EnableAllFolders>\n            <EnabledFolders />\n            <PasswordResetUrl />\n          </PluginConfiguration>\n        '';\n\n        # SchemeOverride is needed because of\n        # https://github.com/9p4/jellyfin-plugin-sso/issues/264\n        ssoConfig = pkgs.writeText \"SSO-Auth.xml\" ''\n          <?xml version=\"1.0\" encoding=\"utf-8\"?>\n          <PluginConfiguration xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n            <SamlConfigs />\n            <OidConfigs>\n              <item>\n                <key>\n                  <string>${cfg.sso.provider}</string>\n                </key>\n                <value>\n                  <PluginConfiguration>\n                    <SchemeOverride>https</SchemeOverride>\n                    <OidEndpoint>${cfg.sso.endpoint}</OidEndpoint>\n                    <OidClientId>${cfg.sso.clientID}</OidClientId>\n                    <OidSecret>%SECRET_SSO_SECRET%</OidSecret>\n                    <Enabled>true</Enabled>\n                    <EnableAuthorization>true</EnableAuthorization>\n                    <EnableAllFolders>true</EnableAllFolders>\n                    <EnabledFolders />\n                    <AdminRoles>\n                      <string>${cfg.ldap.adminGroup}</string>\n                    </AdminRoles>\n                    <Roles>\n                      <string>${cfg.ldap.userGroup}</string>\n                    </Roles>\n                    <EnableFolderRoles>false</EnableFolderRoles>\n                    <FolderRoleMappings />\n                    <RoleClaim>groups</RoleClaim>\n                    <OidScopes>\n                      <string>groups</string>\n                    </OidScopes>\n                    <CanonicalLinks />\n                    <DisablePushedAuthorization>true</DisablePushedAuthorization>\n                  </PluginConfiguration>\n                </value>\n              </item>\n            </OidConfigs>\n          </PluginConfiguration>\n        '';\n\n        brandingConfig = pkgs.writeText \"branding.xml\" ''\n          <?xml version=\"1.0\" encoding=\"utf-8\"?>\n          <BrandingOptions xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n            <LoginDisclaimer>&lt;a href=\"https://${cfg.subdomain}.${cfg.domain}/SSO/OID/p/${cfg.sso.provider}\" class=\"raised cancel block emby-button authentik-sso\"&gt;\n                Sign in with ${cfg.sso.provider}&amp;nbsp;\n                &lt;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\"&gt;\n              &lt;/a&gt;\n              &lt;a href=\"https://${cfg.subdomain}.${cfg.domain}/SSOViews/linking\" class=\"raised cancel block emby-button authentik-sso\"&gt;\n                Link ${cfg.sso.provider} config&amp;nbsp;\n              &lt;/a&gt;\n              &lt;a href=\"${cfg.sso.endpoint}\" class=\"raised cancel block emby-button authentik-sso\"&gt;\n                ${cfg.sso.provider} config&amp;nbsp;\n              &lt;/a&gt;\n            </LoginDisclaimer>\n            <CustomCss>\n              /* Hide this in lieu of authentik link */\n              .emby-button.block.btnForgotPassword {\n                 display: none;\n              }\n\n              /* Make links look like buttons */\n              a.raised.emby-button {\n                 padding: 0.9em 1em;\n                 color: inherit !important;\n              }\n\n              /* Let disclaimer take full width */\n              .disclaimerContainer {\n                 display: block;\n              }\n\n              /* Optionally, apply some styling to the `.authentik-sso` class, probably let users configure this */\n              .authentik-sso {\n                 /* idk set a background image or something lol */\n              }\n\n              .oauth-login-image {\n                  height: 24px;\n                  position: absolute;\n                  top: 12px;\n              }\n            </CustomCss>\n            <SplashscreenEnabled>true</SplashscreenEnabled>\n          </BrandingOptions>\n        '';\n\n        debugLogging = pkgs.writeText \"debugLogging.json\" ''\n          {\n            \"Serilog\": {\n              \"MinimumLevel\": {\n                \"Default\": \"Debug\",\n                \"Override\": {\n                  \"\": \"Debug\"\n                }\n              }\n            }\n          }\n        '';\n\n        networkConfig = pkgs.writeText \"\" ''\n          <?xml version=\"1.0\" encoding=\"utf-8\"?>\n          <NetworkConfiguration xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\n            <BaseUrl />\n            <EnableHttps>false</EnableHttps>\n            <RequireHttps>false</RequireHttps>\n            <CertificatePath />\n            <CertificatePassword />\n            <InternalHttpPort>${toString cfg.port}</InternalHttpPort>\n            <InternalHttpsPort>8920</InternalHttpsPort>\n            <PublicHttpPort>${toString cfg.port}</PublicHttpPort>\n            <PublicHttpsPort>8920</PublicHttpsPort>\n            <AutoDiscovery>true</AutoDiscovery>\n            <EnableUPnP>false</EnableUPnP>\n            <EnableIPv4>true</EnableIPv4>\n            <EnableIPv6>false</EnableIPv6>\n            <EnableRemoteAccess>false</EnableRemoteAccess>\n            <LocalNetworkSubnets />\n            <LocalNetworkAddresses />\n            <KnownProxies />\n            <IgnoreVirtualInterfaces>true</IgnoreVirtualInterfaces>\n            <VirtualInterfaceNames>\n              <string>veth</string>\n            </VirtualInterfaceNames>\n            <EnablePublishedServerUriByRequest>false</EnablePublishedServerUriByRequest>\n            <PublishedServerUriBySubnet />\n            <RemoteIPFilter />\n            <IsRemoteIPFilterBlacklist>false</IsRemoteIPFilterBlacklist>\n          </NetworkConfiguration>\n        '';\n      in\n      lib.strings.optionalString cfg.debug ''\n        if [ -f \"${config.services.jellyfin.configDir}/logging.json\" ] && [ ! -L \"${config.services.jellyfin.configDir}/logging.json\" ]; then\n          echo \"A ${config.services.jellyfin.configDir}/logging.json file exists already, this indicates probably an existing installation. Please remove it before continuing.\"\n          exit 1\n        fi\n        ln -fs \"${debugLogging}\" \"${config.services.jellyfin.configDir}/logging.json\"\n      ''\n      + (shb.replaceSecretsScript {\n        file = networkConfig;\n        # Write permissions are needed otherwise the jellyfin-cli tool will not work correctly.\n        permissions = \"u=rw,g=rw,o=\";\n        resultPath = \"${config.services.jellyfin.dataDir}/config/network.xml\";\n        replacements = [\n        ];\n      })\n      + lib.strings.optionalString cfg.ldap.enable (\n        (shb.replaceSecretsScript {\n          file = ldapConfig;\n          resultPath = \"${config.services.jellyfin.dataDir}/plugins/configurations/LDAP-Auth.xml\";\n          replacements = [\n            {\n              name = [ \"LDAP_PASSWORD\" ];\n              source = cfg.ldap.adminPassword.result.path;\n            }\n          ];\n        })\n      )\n      + lib.strings.optionalString cfg.sso.enable (\n        shb.replaceSecretsScript {\n          file = ssoConfig;\n          resultPath = \"${config.services.jellyfin.dataDir}/plugins/configurations/SSO-Auth.xml\";\n          replacements = [\n            {\n              name = [ \"SSO_SECRET\" ];\n              source = cfg.sso.sharedSecret.result.path;\n            }\n          ];\n        }\n      )\n      + lib.strings.optionalString cfg.sso.enable (\n        shb.replaceSecretsScript {\n          file = brandingConfig;\n          resultPath = \"${config.services.jellyfin.dataDir}/config/branding.xml\";\n          replacements = [\n          ];\n        }\n      )\n      + (\n        let\n          pluginInstallScript = p: ''\n            pluginDir=\"${config.services.jellyfin.dataDir}/plugins/${pluginName p}\"\n            mkdir -p \"$pluginDir\"\n            for f in \"${p}\"/*; do\n              ln -sf \"$f\" \"$pluginDir\"\n            done\n\n            rm \"$pluginDir/meta.json\"\n            ${pkgs.jq}/bin/jq \". + {\n              status: \\\"Active\\\",\n              autoUpdate: false,\n              assemblies: []\n            }\" \"${p}/meta.json\" > \"$pluginDir/meta.json\"\n\n            echo \"Disabling other versions of plugin ${pluginName p}\"\n            for p in \"${config.services.jellyfin.dataDir}/plugins/${pluginNamePrefix p}\"*; do\n              if [ \"$p\" = \"$pluginDir\" ]; then\n                continue\n              fi\n              echo \"Marking plugin $p as disabled\"\n              ${pkgs.jq}/bin/jq \". + {\n                status: \\\"Disabled\\\",\n              }\" \"$p/meta.json\" > \"$p/meta.json.new\"\n              mv \"$p/meta.json.new\" \"$p/meta.json\"\n            done\n          '';\n        in\n        lib.concatMapStringsSep \"\\n\" pluginInstallScript cfg.plugins\n      );\n\n    shb.jellyfin.plugins =\n      lib.optionals cfg.ldap.enable [ cfg.ldap.plugin ]\n      ++ lib.optionals cfg.sso.enable [ cfg.sso.plugin ];\n\n    systemd.tmpfiles.rules = lib.optionals cfg.ldap.enable [\n      \"d '${config.services.jellyfin.dataDir}/plugins' 0750 jellyfin jellyfin - -\"\n    ];\n\n    systemd.services.jellyfin.serviceConfig.ExecStartPost =\n      let\n        # We must always wait for the service to be fully initialized,\n        # even if we're planning on changing the config and restarting.\n        # And the service is not initialized until this URL returns a 200 and not a 503.\n        waitForCurl = pkgs.writeShellApplication {\n          name = \"waitForCurl\";\n          runtimeInputs = [ pkgs.curl ];\n          text = ''\n            URL=\"http://127.0.0.1:${toString cfg.port}/System/Info/Public\"\n            SLEEP_INTERVAL_SEC=2\n            TIMEOUT=60\n\n            start_time=$(date +%s)\n\n            echo \"Waiting for $URL to return HTTP 200...\"\n\n            while true; do\n                status_code=$(curl -s -o /dev/null -w \"%{http_code}\" \"$URL\" || true)\n                if [ \"$status_code\" = \"200\" ]; then\n                    echo \"Service is up (HTTP 200 received).\"\n                    exit 0\n                fi\n\n                now=$(date +%s)\n                elapsed=$(( now - start_time ))\n\n                if [ $elapsed -ge $TIMEOUT ]; then\n                    echo \"Timeout reached ($TIMEOUT seconds). Exiting with failure.\"\n                    exit 1\n                fi\n\n                echo \"Waiting for service... (status: $status_code), elapsed: ''${elapsed}s\"\n                sleep \"$SLEEP_INTERVAL_SEC\"\n            done\n\n            echo \"Finished waiting, curl returned a 200.\"\n          '';\n        };\n\n        # This file is used to know if the jellyfin service has been restarted\n        # because a new config just got written to.\n        #\n        # If the file does not exist, write the config, create the file then restart.\n        # If the file exists, do nothing and remove the file, resetting the state for the next time.\n        restartedFile = \"${config.services.jellyfin.dataDir}/shb-jellyfin-restarted\";\n\n        writeConfig = pkgs.writeShellApplication {\n          name = \"writeConfig\";\n          runtimeInputs = [ pkgs.systemd ];\n          text = ''\n            if ! [ -f \"${restartedFile}\" ]; then\n              ${lib.getExe config.services.jellyfin.package} config \\\n                --datadir='${config.services.jellyfin.dataDir}' \\\n                --configdir='${config.services.jellyfin.configDir}' \\\n                --cachedir='${config.services.jellyfin.cacheDir}' \\\n                --logdir='${config.services.jellyfin.logDir}' \\\n                --username=${cfg.admin.username} \\\n                --password-file=${cfg.admin.password.result.path} \\\n                --enable-remote-access=true \\\n                --write\n            fi\n          '';\n        };\n\n        restartJellyfinOnce = pkgs.writeShellApplication {\n          name = \"restartJellyfin\";\n          runtimeInputs = [ pkgs.systemd ];\n          text = ''\n            if [ -f \"${restartedFile}\" ]; then\n              echo \"jellyfin.service has been restarted\"\n              rm \"${restartedFile}\"\n            else\n              echo \"Restarting jellyfin.service\"\n              echo \"This file is used by SelfHostBlocks to know when to restart jellyfin\" > \"${restartedFile}\"\n              systemctl reload-or-restart jellyfin.service\n            fi\n          '';\n        };\n      in\n      lib.optionals (cfg.admin != null) [\n        (lib.getExe waitForCurl)\n\n        (lib.getExe writeConfig)\n\n        # The '+' is to get elevated privileges to be able to restart the service.\n        \"+${lib.getExe restartJellyfinOnce}\"\n      ];\n\n    systemd.services.jellyfin.serviceConfig.TimeoutStartSec = 300;\n\n    shb.authelia.oidcClients = lib.optionals cfg.sso.enable [\n      {\n        client_id = cfg.sso.clientID;\n        client_name = \"Jellyfin\";\n        client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n        public = false;\n        authorization_policy = cfg.sso.authorization_policy;\n        redirect_uris = [\n          \"https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}\"\n          \"https://${cfg.subdomain}.${cfg.domain}/sso/OID/redirect/${cfg.sso.provider}\"\n        ];\n        require_pkce = true;\n        pkce_challenge_method = \"S256\";\n        userinfo_signed_response_alg = \"none\";\n        # Jellyfin SSO plugin uses client_secret_post for token exchange\n        token_endpoint_auth_method = \"client_secret_post\";\n        # Required OIDC scopes for Authelia to return group claims\n        scopes = [\n          \"openid\"\n          \"profile\"\n          \"email\"\n          \"groups\"\n        ];\n      }\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/karakeep/docs/default.md",
    "content": "# Karakeep {#services-karakeep}\n\nDefined in [`/modules/blocks/karakeep.nix`](@REPO@/modules/blocks/karakeep.nix),\nfound in the `selfhostblocks.nixosModules.karakeep` module.\nSee [the manual](usage.html#usage-flake) for how to import the module in your code.\n\nThis service sets up [Karakeep][] which is a bookmarking service powered by LLMs.\nIt integrates well with [Ollama][].\n\n[Karakeep]: https://github.com/karakeep-app/karakeep\n[Ollama]: https://ollama.com/\n\n## Features {#services-karakeep-features}\n\n- Declarative [LDAP](#services-karakeep-options-shb.karakeep.ldap) Configuration.\n  - Needed LDAP groups are created automatically.\n- Declarative [SSO](#services-karakeep-options-shb.karakeep.sso) Configuration.\n  - When SSO is enabled, login with user and password is disabled.\n  - Registration is enabled through SSO.\n- Meilisearch configured with production environment and master key.\n- Access through [subdomain](#services-karakeep-options-shb.karakeep.subdomain) using reverse proxy.\n- Access through [HTTPS](#services-karakeep-options-shb.karakeep.ssl) using reverse proxy.\n- [Backup](#services-karakeep-options-shb.karakeep.sso) through the [backup block](./blocks-backup.html).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-karakeep-usage-applicationdashboard)\n\n## Usage {#services-karakeep-usage}\n\n### Initial Configuration {#services-karakeep-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\n{\n  shb.karakeep = {\n    enable = true;\n    domain = \"example.com\";\n    subdomain = \"karakeep\";\n\n    ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n    nextauthSecret.result = config.shb.sops.secret.nextauthSecret.result;\n\n    sso = {\n      enable = true;\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n      sharedSecret.result = config.shb.sops.secret.oidcSecret.result;\n      sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result;\n    };\n  };\n\n  shb.sops.secret.nextauthSecret.request = config.shb.karakeep.nextauthSecret.request;\n  shb.sops.secret.\"karakeep/oidcSecret\".request = config.shb.karakeep.sso.sharedSecret.request;\n  shb.sops.secret.\"karakeep/oidcAutheliaSecret\" = {\n    request = config.shb.karakeep.sso.sharedSecretForAuthelia.request;\n    settings.key = \"karakeep/oidcSecret\";\n  };\n}\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nThe [user](#services-open-webui-options-shb.open-webui.ldap.userGroup)\nand [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup)\nLDAP groups are created automatically.\n\n### Application Dashboard {#services-karakeep-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-karakeep-options-shb.karakeep.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Documents.services.Karakeep = {\n    sortOrder = 3;\n    dashboard.request = config.shb.karakeep.dashboard.request;\n  };\n}\n```\n\nAn API key can be set to show extra info:\n\n```nix\n{\n  shb.homepage.servicesGroups.Documents.services.Karakeep = {\n    apiKey.result = config.shb.sops.secret.\"karakeep/homepageApiKey\".result;\n  };\n\n  shb.sops.secret.\"karakeep/homepageApiKey\".request =\n    config.shb.homepage.servicesGroups.Documents.services.Karakeep.apiKey.request;\n}\n```\n\n## Integration with Ollama {#services-karakeep-ollama}\n\nAssuming ollama is enabled, it will be available on port `config.services.ollama.port`.\nThe following snippet sets up acceleration using an AMD (i)GPU and loads some models.\n\n```nix\n{\n  services.ollama = {\n    enable = true;\n\n    # https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver\n    acceleration = \"rocm\";\n\n    # https://ollama.com/library\n    loadModels = [\n      \"deepseek-r1:1.5b\"\n      \"llama3.2:3b\"\n      \"llava:7b\"\n      \"mxbai-embed-large:335m\"\n      \"nomic-embed-text:v1.5\"\n    ];\n  };\n}\n```\n\nIntegrating with the ollama service is done with:\n\n```nix\n{\n  services.open-webui = {\n    environment.OLLAMA_BASE_URL = \"http://127.0.0.1:${toString config.services.ollama.port}\";\n  };\n}\n```\n\nNotice we're using the upstream service here `services.open-webui`, not `shb.open-webui`.\n\n## Options Reference {#services-karakeep-options}\n\n```{=include=} options\nid-prefix: services-karakeep-options-\nlist-id: selfhostblocks-services-karakeep-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/karakeep.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.karakeep;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.karakeep = {\n    enable = lib.mkEnableOption \"the Karakeep service\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Karakeep will be served.\";\n      default = \"karakeep\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Karakeep will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    port = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port Karakeep listens to incoming requests.\";\n      default = 3000;\n    };\n\n    environment = lib.mkOption {\n      default = { };\n      type = lib.types.attrsOf lib.types.str;\n      description = \"Extra environment variables. See https://docs.karakeep.app/configuration/\";\n      example = ''\n        {\n          OLLAMA_BASE_URL = \"http://127.0.0.1:''${toString config.services.ollama.port}\";\n          INFERENCE_TEXT_MODEL = \"deepseek-r1:1.5b\";\n          INFERENCE_IMAGE_MODEL = \"llava\";\n          EMBEDDING_TEXT_MODEL = \"nomic-embed-text:v1.5\";\n          INFERENCE_ENABLE_AUTO_SUMMARIZATION = \"true\";\n          INFERENCE_JOB_TIMEOUT_SEC = \"200\";\n        }\n      '';\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        Setup LDAP integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to login.\";\n            default = \"karakeep_user\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"Endpoint to the SSO provider.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = lib.mkOption {\n            type = lib.types.str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"karakeep\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = lib.types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = lib.mkOption {\n            description = \"OIDC shared secret for Karakeep.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                owner = \"karakeep\";\n                # These services are the ones relying on the environment file containing the secrets.\n                restartUnits = [\n                  \"karakeep-init.service\"\n                  \"karakeep-workers.service\"\n                  \"karakeep-workers.service\"\n                ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret for Authelia. Must be the same as `sharedSecret`\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                ownerText = \"config.shb.authelia.autheliaUser\";\n                owner = config.shb.authelia.autheliaUser;\n              };\n            };\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup state directory.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"karakeep\";\n          sourceDirectories = [\n            \"/var/lib/karakeep\"\n          ];\n        };\n      };\n    };\n\n    nextauthSecret = lib.mkOption {\n      description = \"NextAuth secret.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          owner = \"karakeep\";\n          # These services are the ones relying on the environment file containing the secrets.\n          restartUnits = [\n            \"karakeep-init.service\"\n            \"karakeep-workers.service\"\n            \"karakeep-workers.service\"\n          ];\n        };\n      };\n    };\n\n    meilisearchMasterKey = lib.mkOption {\n      description = \"Master key used to secure communication with Meilisearch.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          owner = \"karakeep\";\n          # These services are the ones relying on the environment file containing the secrets.\n          restartUnits = [\n            \"karakeep-init.service\"\n            \"karakeep-workers.service\"\n            \"karakeep-workers.service\"\n          ];\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.karakeep.subdomain}.\\${config.shb.karakeep.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = (\n    lib.mkMerge [\n      (lib.mkIf cfg.enable {\n        services.karakeep = {\n          enable = true;\n          meilisearch.enable = true;\n\n          extraEnvironment = {\n            PORT = toString cfg.port;\n            DISABLE_NEW_RELEASE_CHECK = \"true\"; # These are handled by NixOS\n          }\n          // cfg.environment;\n        };\n\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg) subdomain domain ssl;\n            upstream = \"http://127.0.0.1:${toString cfg.port}/\";\n          }\n        ];\n\n        # Piggybacking onto the upstream karakeep-init and replacing its script by ours.\n        # This is needed otherwise the MEILI_MASTER_KEY is generated randomly on first start\n        # instead of using the value from the cfg.meilisearchMasterKey option.\n        systemd.services.karakeep-init = {\n          script = lib.mkForce (\n            (shb.replaceSecrets {\n              userConfig = {\n                MEILI_MASTER_KEY.source = cfg.meilisearchMasterKey.result.path;\n                NEXTAUTH_SECRET.source = cfg.nextauthSecret.result.path;\n              }\n              // lib.optionalAttrs cfg.sso.enable {\n                OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;\n              };\n              resultPath = \"/var/lib/karakeep/settings.env\";\n              generator = shb.toEnvVar;\n            })\n            + ''\n              export DATA_DIR=\"$STATE_DIRECTORY\"\n              exec ${config.services.karakeep.package}/lib/karakeep/migrate\n            ''\n          );\n        };\n      })\n      (lib.mkIf cfg.enable {\n        services.meilisearch = {\n          masterKeyFile = cfg.meilisearchMasterKey.result.path;\n          settings = {\n            experimental_dumpless_upgrade = true;\n            env = \"production\";\n          };\n        };\n      })\n      (lib.mkIf (cfg.enable && cfg.sso.enable) {\n        shb.lldap.ensureGroups = {\n          ${cfg.ldap.userGroup} = { };\n        };\n\n        shb.authelia.extraOidcAuthorizationPolicies.karakeep = {\n          default_policy = \"deny\";\n          rules = [\n            {\n              subject = [ \"group:${cfg.ldap.userGroup}\" ];\n              policy = cfg.sso.authorization_policy;\n            }\n          ];\n        };\n        shb.authelia.oidcClients = [\n          {\n            client_id = cfg.sso.clientID;\n            client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n            scopes = [\n              \"openid\"\n              \"email\"\n              \"profile\"\n            ];\n            authorization_policy = \"karakeep\";\n            redirect_uris = [\n              \"https://${cfg.subdomain}.${cfg.domain}/api/auth/callback/custom\"\n            ];\n          }\n        ];\n        services.karakeep = {\n          extraEnvironment = {\n            DISABLE_SIGNUPS = \"false\";\n            DISABLE_PASSWORD_AUTH = \"true\";\n            NEXTAUTH_URL = \"https://${cfg.subdomain}.${cfg.domain}\";\n            OAUTH_WELLKNOWN_URL = \"${cfg.sso.authEndpoint}/.well-known/openid-configuration\";\n            OAUTH_PROVIDER_NAME = \"Single Sign-On\";\n            OAUTH_CLIENT_ID = cfg.sso.clientID;\n            OAUTH_SCOPE = \"openid email profile\";\n          };\n        };\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/mailserver/docs/default.md",
    "content": "# Mailserver Service {#services-mailserver}\n\nDefined in [`/modules/services/mailserver.nix`](@REPO@/modules/services/mailserver.nix).\n\nThis NixOS module is a service that sets up\nthe [NixOS Simple Mailserver](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver) project.\nIt integrates the upstream project\nwith the SHB modules like the SSL module, the contract for secrets and the LLDAP module.\n\nIt also exposes an XML file which allows some email clients to auto configure themselves.\n\nSetting up a self-hosted email server in this age\ncan be quite time consuming because you need to maintain\na good IP hygiene to avoid being marked as spam from the big players.\nTo avoid needing to deal with this,\nthis module provides the means\nto use an email provider (like Fastmail or ProtonMail) as a mere proxy.\nIf you also setup the email provider using your own custom domain,\nthis combination allows you to change email provider\nwithout needing to change your clients or notify your email correspondents\nand keep a backup of all your emails at the same time.\nThe setup looks like so:\n\n```\nDomain --[ DNS records ]->  Email Provider  --[ mbsync  ]->  SHB Server\n\nInternet <----------------  Email Provider  <-[ postfix ]--  SHB Server\n```\n\nConfiguring your domain name to point to your email provider is out of scope here.\nSee the documentation for \"custom domain\" for you email provider,\nlike for [Fastmail](https://www.fastmail.com/features/domains/)\nand [ProtonMail](https://proton.me/support/custom-domain)\n\nTo use an email provider as a proxy, use the\n[shb.mailserver.imapSync](#services-mailserver-options-shb.mailserver.imapSync)\nand [shb.mailserver.smtpRelay](#services-mailserver-options-shb.mailserver.smtpRelay),\noptions.\n\n## Usage {#services-mailserver-usage}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n\n```nix\nlet\n  domain = \"example.com\";\n  username = \"me@example.com\";\nin\n{\n  imports = [\n    selfhostblocks.nixosModules.mailserver\n  ];\n\n  shb.mailserver = {\n    enable = true;\n    inherit domain;\n    subdomain = \"imap\";\n    ssl = config.shb.certs.certs.letsencrypt.\"domain\";\n\n    imapSync = {\n      syncTimer = \"10s\";\n      accounts.fastmail = {\n        host = \"imap.fastmail.com\";\n        port = 993;\n        inherit username;\n        password.result = config.shb.sops.secret.\"mailserver/imap/fastmail/password\".result;\n        mapSpecialJunk = \"Spam\";\n      };\n    };\n\n    smtpRelay = {\n      host = \"smtp.fastmail.com\";\n      port = 587;\n        inherit username;\n      password.result = config.shb.sops.secret.\"mailserver/smtp/fastmail/password\".result;\n    };\n\n    ldap = {\n      enable = true;\n      host = \"127.0.0.1\";\n      port = config.shb.lldap.ldapPort;\n      dcdomain = config.shb.lldap.dcdomain;\n      adminName = \"admin\";\n      adminPassword.result = config.shb.sops.secret.\"mailserver/ldap_admin_password\".result;\n      account = \"fastmail\";\n    };\n  };\n\n  # Optionally add some mailboxes\n  mailserver.mailboxes = {\n    Drafts = {\n      auto = \"subscribe\";\n      specialUse = \"Drafts\";\n    };\n    Junk = {\n      auto = \"subscribe\";\n      specialUse = \"Junk\";\n    };\n    Sent = {\n      auto = \"subscribe\";\n      specialUse = \"Sent\";\n    };\n    Trash = {\n      auto = \"subscribe\";\n      specialUse = \"Trash\";\n    };\n    Archive = {\n      auto = \"subscribe\";\n      specialUse = \"Archive\";\n    };\n  };\n\n  shb.sops.secret.\"mailserver/smtp/fastmail/password\".request =\n    config.shb.mailserver.smtpRelay.password.request;\n\n  shb.sops.secret.\"mailserver/imap/fastmail/password\".request =\n    config.shb.mailserver.imapSync.accounts.fastmail.password.request;\n\n  shb.sops.secret.\"mailserver/ldap_admin_password\" = {\n    request = config.shb.mailserver.ldap.adminPassword.request;\n    # This reuses the admin password set in the shb.lldap module.\n    settings.key = \"lldap/user_password\";\n  };\n}\n```\n\n### Secrets {#services-mailserver-usage-secrets}\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\n### LDAP {#services-mailserver-usage-ldap}\n\nThe [user](#services-mailserver-options-shb.mailserver.ldap.userGroup)\nLDAP group is created automatically.\n\n### Disk Layout {#services-mailserver-usage-disk-layout}\n\nThe disk layout has been purposely set to use slashes `/` for subfolders.\nBy experience, this works better with iOS mail.\n\n### Backup {#services-mailserver-usage-backup}\n\nBacking up your emails using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"mailserver\" = {\n  request = config.shb.mailserver.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"mailserver\"` in the `instances` can be anything.\nThe `config.shb.mailserver.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup your emails multiple times.\n\nYou will then need to configure more options like the `repository`,\nas explained in the [restic](blocks-restic.html) documentation.\n\n### Certificates {#services-mailserver-certs}\n\nFor Let's Encrypt certificates, add:\n\n```nix\nlet\n  domain = \"example.com\";\nin\n{\n  shb.certs.certs.letsencrypt.${domain}.extraDomains = [\n    \"${config.shb.mailserver.subdomain}.${config.shb.mailserver.domain}\"\n  ];\n}\n```\n\n### Impermanence {#services-mailserver-impermanence}\n\nTo save the data folder in an impermanence setup, add:\n\n```nix\n{\n  shb.zfs.datasets.\"safe/mailserver/index\".path = config.shb.mailserver.impermanence.index;\n  shb.zfs.datasets.\"safe/mailserver/mail\".path = config.shb.mailserver.impermanence.mail;\n  shb.zfs.datasets.\"safe/mailserver/sieve\".path = config.shb.mailserver.impermanence.sieve;\n  shb.zfs.datasets.\"safe/mailserver/dkim\".path = config.shb.mailserver.impermanence.dkim;\n}\n```\n\n### Declarative LDAP {#services-mailserver-declarative-ldap}\n\nTo add a user `USERNAME` to the user group, add:\n\n```nix\nshb.lldap.ensureUsers.USERNAME.groups = [\n  config.shb.mailserver.ldap.userGroup\n];\n```\n\n### Application Dashboard {#services-mailserver-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-mailserver-options-shb.mailserver.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Home.services.Mailserver = {\n    sortOrder = 1;\n    dashboard.request = config.shb.mailserver.dashboard.request;\n  };\n}\n```\n\n## Debug {#services-mailserver-debug}\n\nDebugging this will be certainly necessary.\nThe first issue you will encounter will probably be with `mbsync`\nunder the [shb.mailserver.imapSync](#services-mailserver-options-shb.mailserver.imapSync) option\nwith the folder name mapping.\n\n### Systemd Services {#services-mailserver-debug-systemd}\n\nThe 3 systemd services setup by this module are:\n\n- `mbsync.service`\n- `dovecot.service`\n- `postfix.service`\n\n### Folders {#services-mailserver-debug-folders}\n\nThe 4 folders where state is stored are:\n\n- `config.mailserver.indexDir` = `/var/lib/dovecot/indices`\n- `config.mailserver.mailDirectory` = `/var/vmail`\n- `config.mailserver.sieveDirectory` = `/var/sieve`\n- `config.mailserver.dkimKeyDirectory` = `/var/dkim`\n\n### Open Ports {#services-mailserver-debug-ports}\n\nThe ports opened by default in this module are:\n\n- Submissions: 465\n- Imap: 993\n\nYou will need to forward those ports on your router\nif you want to access to your emails from the internet.\n\nThe complete list can be found in the [upstream repository](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/5965fae920b6b97f39f94bdb6195631e274c93a5/mail-server/networking.nix).\n\n### List Email Provider Folder Mapping {#services-mailserver-debug-folder-mapping}\n\nReplace `$USER` and `$PASSWORD` by those used to connect to your email provider.\nYes, you will need to enter verbatim `a LOGIN ...` and `b LIST \"\" \"*\"`.\n\n```\n$ nix run nixpkgs#openssl -- s_client -connect imap.fastmail.com:993 -crlf -quiet\na LOGIN $USER $password\nb LIST \"\" \"*\"\n```\n\nExample output will be:\n\n```\n* LIST (\\HasNoChildren) \"/\" INBOX\n* LIST (\\HasNoChildren \\Drafts) \"/\" Drafts\n* LIST (\\HasNoChildren \\Sent) \"/\" Sent\n* LIST (\\Noinferiors \\HasNoChildren \\Junk) \"/\" Spam\n\n...\n```\n\nHere you can see the special folder `\\Junk` is actually named `Spam`.\nTo handle this, set the `.mapSpecial*` options:\n\n```\n{\n  shb.mailserver.imapSync.accounts.<account> = {\n    mapSpecialJunk = \"Spam\";\n  };\n}\n```\n\n### List Local Folders {#services-mailserver-debug-local-folders}\n\nCheck the local folders to make sure the mapping is correct\nand all folders are correctly downloaded.\nFor example, if the mapping above is wrong, you will see both a\n`Junk` and `Spam` folder while if it is correct,\nyou will only see the `Junk` folder.\n\n```\n$ sudo doveadm mailbox list -u $USER\nJunk\nTrash\nDrafts\nSent\nINBOX\nMyCustomFolder\n```\n\nThe following command shows the number of messages in a folder:\n\n```\n$ sudo doveadm mailbox status -u $USER messages INBOX\nINBOX messages=13591\n```\n\nIf any folder is not appearing or has 0 message but should have some,\nit could mean dovecot is not setup correctly and assumes an incorrect folder layout.\nIf that is the case, check the user config with:\n\n```\n$ sudo doveadm user $USER\nfield   value\nuid     5000\ngid     5000\nhome    /var/vmail/fastmail/$USER\nmail    maildir:~/mail:LAYOUT=fs\nvirtualMail\n```\n\n### Test Auth {#services-mailserver-debug-auth}\n\nTo test authentication to your dovecot instance, run:\n\n```\n$ nix run nixpkgs#openssl -- s_client -connect $SUBDOMAIN.$DOMAIN:993 -crlf -quiet\n. LOGIN $USER $PASSWORD\n```\n\nYou must here also enter the second line verbatim,\nreplacing your user and password with the real one.\n\nOn success, you will see:\n\n```\n. OK [CAPABILITY IMAP4rev1 ...] Logged in\n```\n\nOtherwise, either if the password is wrong or,\nwhen using LDAP if the user is not part of the LDAP group, you will see:\n\n```\n. NO [AUTHENTICATIONFAILED] Authentication failed.\n```\n\nTo test the postfix instance, run:\n\n```\n$ swaks \\\n    --server $SUBDOMAIN.$DOMAIN \\\n    --port 465 \\\n    --tls-on-connect \\\n    --auth LOGIN \\\n    --auth-user $USER \\\n    --auth-password '$PASSWORD' \\\n    --from $USER \\\n    --to $USER\n```\n\nTry once with a wrong password and once with a correct one.\nThe former should log:\n\n```\n<~* 535 5.7.8 Error: authentication failed: (reason unavailable)\n```\n\n## Mobile Apps {#services-mailserver-mobile}\n\nThis module was tested with:\n- the iOS mail mobile app,\n- Thunderbird on NixOS.\n\nThe iOS mail app is pretty finicky.\nIf downloading emails does not work,\nmake sure the certificate used includes the whole chain:\n\n```bash\n$ openssl s_client -connect $SUBDOMAIN.$DOMAIN:993 -showcerts\n```\n\nNormally, the other options are setup correctly but if it fails for you,\nfeel free to open an issue.\n\n## Options Reference {#services-mailserver-options}\n\n```{=include=} options\nid-prefix: services-mailserver-options-\nlist-id: selfhostblocks-service-mailserver-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/mailserver.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  pkgs,\n  ...\n}:\nlet\n  cfg = config.shb.mailserver;\nin\n{\n  imports = [\n    (\n      builtins.fetchGit {\n        url = \"https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git\";\n        ref = \"master\";\n        rev = \"7d433bf89882f61621f95082e90a4ab91eb0bdd3\";\n      }\n      + \"/default.nix\"\n    )\n    ../blocks/lldap.nix\n  ];\n\n  options.shb.mailserver = {\n    enable = lib.mkEnableOption \"SHB's nixos-mailserver module\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which imap and smtp functions will be served.\";\n      default = \"imap\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which imap and smtp functions will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    adminUsername = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      default = null;\n      description = ''\n        Admin username.\n\n        postmaster will be made an alias of this user.\n      '';\n      example = \"admin\";\n    };\n\n    adminPassword = lib.mkOption {\n      description = \"Admin user password.\";\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = shb.contracts.secret.mkRequester {\n            mode = \"0400\";\n            owner = config.services.postfix.user;\n            ownerText = \"services.postfix.user\";\n            restartUnits = [ \"dovecot.service\" ];\n          };\n        }\n      );\n    };\n\n    imapSync = lib.mkOption {\n      description = ''\n        Synchronize one or more email providers through IMAP\n        to your dovecot instance.\n\n        This allows you to backup that email provider\n        and centralize your accounts in this dovecot instance.\n      '';\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            syncTimer = lib.mkOption {\n              type = lib.types.str;\n              default = \"5m\";\n              description = ''\n                Systemd timer for when imap sync job should happen.\n\n                This timer is not scheduling the job at regular intervals.\n                After a job finishes, the given amount of time is waited then the next job is started.\n\n                The default is set deliberatily slow to not spam you when setting up your mailserver.\n                When everything works, you will want to reduce it to 10s or something like that.\n              '';\n              example = \"10s\";\n            };\n\n            debug = lib.mkOption {\n              type = lib.types.bool;\n              default = false;\n              description = \"Enable verbose mbsync logging.\";\n            };\n\n            accounts = lib.mkOption {\n              description = ''\n                Accounts to sync emails from using IMAP.\n\n                Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}`\n              '';\n              type = lib.types.attrsOf (\n                lib.types.submodule {\n                  options = {\n                    host = lib.mkOption {\n                      type = lib.types.str;\n                      description = \"Hostname of the email's provider IMAP server.\";\n                      example = \"imap.fastmail.com\";\n                    };\n\n                    port = lib.mkOption {\n                      type = lib.types.port;\n                      description = \"Port of the email's provider IMAP server.\";\n                      default = 993;\n                    };\n\n                    username = lib.mkOption {\n                      type = lib.types.str;\n                      description = \"Username used to login to the email's provider IMAP server.\";\n                      example = \"userA@fastmail.com\";\n                    };\n\n                    password = lib.mkOption {\n                      description = ''\n                        Password used to login to the email's provider IMAP server.\n\n                        The password could be an \"app password\" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)\n                      '';\n                      type = lib.types.submodule {\n                        options = shb.contracts.secret.mkRequester {\n                          mode = \"0400\";\n                          owner = config.mailserver.vmailUserName;\n                          restartUnits = [ \"mbsync.service\" ];\n                        };\n                      };\n                    };\n\n                    sslType = lib.mkOption {\n                      description = \"Connection security method.\";\n                      type = lib.types.enum [\n                        \"IMAPS\"\n                        \"STARTTLS\"\n                      ];\n                      default = \"IMAPS\";\n                    };\n\n                    timeout = lib.mkOption {\n                      description = \"Connect and data timeout.\";\n                      type = lib.types.int;\n                      default = 120;\n                    };\n\n                    mapSpecialDrafts = lib.mkOption {\n                      type = lib.types.str;\n                      default = \"Drafts\";\n                      description = ''\n                        Drafts special folder name on far side.\n\n                        You only need to change this if mbsync logs the following error:\n\n                            Error: ... far side box Drafts cannot be opened\n                      '';\n                    };\n                    mapSpecialSent = lib.mkOption {\n                      type = lib.types.str;\n                      default = \"Sent\";\n                      description = ''\n                        Sent special folder name on far side.\n\n                        You only need to change this if mbsync logs the following error:\n\n                            Error: ... far side box Sent cannot be opened\n                      '';\n                    };\n                    mapSpecialTrash = lib.mkOption {\n                      type = lib.types.str;\n                      default = \"Trash\";\n                      description = ''\n                        Trash special folder name on far side.\n\n                        You only need to change this if mbsync logs the following error:\n\n                            Error: ... far side box Trash cannot be opened\n                      '';\n                    };\n                    mapSpecialJunk = lib.mkOption {\n                      type = lib.types.str;\n                      default = \"Junk\";\n                      description = ''\n                        Junk special folder name on far side.\n\n                        You only need to change this if mbsync logs the following error:\n\n                            Error: ... far side box Junk cannot be opened\n                      '';\n                      example = \"Spam\";\n                    };\n                  };\n                }\n              );\n            };\n          };\n        }\n      );\n    };\n\n    smtpRelay = lib.mkOption {\n      description = ''\n        Proxy outgoing emails through an email provider.\n\n        In short, this can help you avoid having your outgoing emails marked as spam.\n        See the manual for a lengthier explanation.\n      '';\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            host = lib.mkOption {\n              type = lib.types.str;\n              description = \"Hostname of the email's provider SMTP server.\";\n              example = \"smtp.fastmail.com\";\n            };\n\n            port = lib.mkOption {\n              type = lib.types.port;\n              description = \"Port of the email's provider SMTP server.\";\n              default = 587;\n            };\n\n            username = lib.mkOption {\n              description = \"Username used to login to the email's provider SMTP server.\";\n              type = lib.types.str;\n            };\n\n            password = lib.mkOption {\n              description = ''\n                Password used to login to the email's provider IMAP server.\n\n                The password could be an \"app password\" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords)\n              '';\n              type = lib.types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0400\";\n                  owner = config.services.postfix.user;\n                  ownerText = \"services.postfix.user\";\n                  restartUnits = [ \"postfix.service\" ];\n                };\n              };\n            };\n          };\n        }\n      );\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        LDAP Integration.\n\n        Enabling this app will create a new LDAP configuration or update one that exists with\n        the given host.\n      '';\n      default = { };\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            enable = lib.mkEnableOption \"LDAP app.\";\n\n            host = lib.mkOption {\n              type = lib.types.str;\n              description = ''\n                Host serving the LDAP server.\n              '';\n              default = \"127.0.0.1\";\n            };\n\n            port = lib.mkOption {\n              type = lib.types.port;\n              description = ''\n                Port of the service serving the LDAP server.\n              '';\n              default = 389;\n            };\n\n            dcdomain = lib.mkOption {\n              type = lib.types.str;\n              description = \"dc domain for ldap.\";\n              example = \"dc=mydomain,dc=com\";\n            };\n\n            account = lib.mkOption {\n              type = lib.types.str;\n              description = ''\n                Select one account from those defined in `shb.mailserver.imapSync.accounts`\n                to login with.\n\n                Using LDAP, you can only connect to one account.\n                This limitation could maybe be lifted, feel free to post an issue if you need this.\n              '';\n            };\n\n            adminName = lib.mkOption {\n              type = lib.types.str;\n              description = \"Admin user of the LDAP server.\";\n              default = \"admin\";\n            };\n\n            adminPassword = lib.mkOption {\n              description = \"LDAP server admin password.\";\n              type = lib.types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0400\";\n                  owner = \"nextcloud\";\n                  restartUnits = [ \"dovecot.service\" ];\n                };\n              };\n            };\n\n            userGroup = lib.mkOption {\n              type = lib.types.str;\n              description = \"Group users must belong to to be able to use mails.\";\n              default = \"mail_user\";\n            };\n          };\n        }\n      );\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup emails, index and sieve.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = config.mailserver.vmailUserName;\n          sourceDirectories = builtins.filter (x: x != null) [\n            config.mailserver.indexDir\n            config.mailserver.mailDirectory\n            config.mailserver.sieveDirectory\n          ];\n          sourceDirectoriesText = ''\n            [\n              config.mailserver.indexDir\n              config.mailserver.mailDirectory\n              config.mailserver.sieveDirectory\n            ]\n          '';\n        };\n      };\n    };\n\n    backupDKIM = lib.mkOption {\n      description = ''\n        Backup dkim directory.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = config.services.rspamd.user;\n          userText = \"services.rspamd.user\";\n          sourceDirectories = builtins.filter (x: x != null) [\n            config.mailserver.dkimKeyDirectory\n          ];\n          sourceDirectoriesText = ''\n            [\n              config.mailserver.dkimKeyDirectory\n            ]\n          '';\n        };\n      };\n    };\n\n    impermanence = lib.mkOption {\n      description = ''\n        Path to save when using impermanence setup.\n      '';\n      type = lib.types.attrsOf lib.types.str;\n      default = {\n        index = config.mailserver.indexDir;\n        mail = config.mailserver.mailDirectory;\n        sieve = config.mailserver.sieveDirectory;\n        dkim = config.mailserver.dkimKeyDirectory;\n      };\n      defaultText = lib.literalExpression ''\n        {\n          index = config.mailserver.indexDir;\n          mail = config.mailserver.mailDirectory;\n          sieve = config.mailserver.sieveDirectory;\n          dkim = config.mailserver.dkimKeyDirectory;\n        }\n      '';\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.mailserver.subdomain}.\\${config.shb.mailserver.domain}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkMerge [\n    (lib.mkIf cfg.enable {\n      mailserver = {\n        enable = true;\n        stateVersion = 3;\n        fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n        domains = [ cfg.domain ];\n\n        localDnsResolver = false;\n\n        enableImapSsl = true;\n        enableSubmissionSsl = true;\n        x509 = {\n          certificateFile = cfg.ssl.paths.cert;\n          privateKeyFile = cfg.ssl.paths.key;\n        };\n\n        # Using / is needed for iOS mail.\n        # Both following options are used to organize subfolders in subdirectories.\n        hierarchySeparator = \"/\";\n        useFsLayout = true;\n      };\n\n      services.postfix.config = {\n        smtpd_tls_security_level = lib.mkForce \"encrypt\";\n      };\n\n      # Is probably needed for iOS mail.\n      services.dovecot2.extraConfig = ''\n        ssl_min_protocol = TLSv1.2\n        ssl_cipher_list = HIGH:!aNULL:!MD5\n      '';\n\n      services.nginx = {\n        enable = true;\n\n        virtualHosts.\"${cfg.domain}\" =\n          let\n            announce = pkgs.writeTextDir \"config-v1.1.xml\" ''\n              <?xml version=\"1.0\" encoding=\"UTF-8\"?>\n              <clientConfig version=\"1.1\">\n                <emailProvider id=\"${cfg.domain}\">\n                  <domain>${cfg.domain}</domain>\n                  <displayName>${cfg.domain} Mailserver</displayName>\n\n                  <!-- Incoming IMAP server -->\n                  <incomingServer type=\"imap\">\n                    <hostname>${cfg.subdomain}.${cfg.domain}</hostname>\n                    <port>993</port>\n                    <socketType>SSL</socketType>\n                    <authentication>password-cleartext</authentication>\n                    <username>%EMAILADDRESS%</username>\n                  </incomingServer>\n\n                  <!-- Outgoing SMTP server -->\n                  <outgoingServer type=\"smtp\">\n                    <hostname>${cfg.subdomain}.${cfg.domain}</hostname>\n                    <port>465</port>\n                    <socketType>SSL</socketType>\n                    <authentication>password-cleartext</authentication>\n                    <username>%EMAILADDRESS%</username>\n                  </outgoingServer>\n\n                </emailProvider>\n              </clientConfig>\n            '';\n          in\n          {\n            forceSSL = true; # Redirect HTTP → HTTPS\n            root = \"/var/www\"; # Dummy root\n            locations.\"/.well-known/autoconfig/mail/\" = {\n              alias = \"${announce}/\";\n              extraConfig = ''\n                default_type application/xml;\n              '';\n            };\n          };\n        virtualHosts.\"${cfg.subdomain}.${cfg.domain}\" =\n          let\n            landingPage = pkgs.writeTextDir \"index.html\" ''\n              <html><body>\n              <p>Configuration of the mailserver is done automatically thanks to\n              <a href=\"https://${cfg.domain}/.well-known/autoconfig/mail/config-v1.1.xml\">${cfg.domain}/.well-known/autoconfig/mail/config-v1.1.xml</a>.</p>\n              </body></html>\n            '';\n          in\n          {\n            forceSSL = true; # Redirect HTTP → HTTPS\n            root = \"/var/www\"; # Dummy root\n            locations.\"/\" = {\n              alias = \"${landingPage}/\";\n              extraConfig = ''\n                default_type application/html;\n              '';\n            };\n          };\n      };\n    })\n    (lib.mkIf (cfg.enable && cfg.adminUsername != null) {\n      assertions = [\n        {\n          assertion = cfg.adminPassword != null;\n          message = \"`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null.\";\n        }\n      ];\n\n      mailserver = {\n        # To create the password hashes, use:\n        # nix run nixpkgs#mkpasswd -- --run 'mkpasswd -s'\n        loginAccounts = {\n          \"${cfg.adminUsername}@${cfg.domain}\" = {\n            hashedPasswordFile = cfg.adminPassword.result.path;\n            aliases = [ \"postmaster@${cfg.domain}\" ];\n          };\n        };\n      };\n    })\n    (lib.mkIf (cfg.enable && cfg.ldap != null) {\n      assertions = [\n        {\n          assertion = cfg.adminUsername == null;\n          message = \"`shb.mailserver.adminUsername` must be null `shb.mailserver.ldap` integration is set.\";\n        }\n      ];\n\n      shb.lldap.ensureGroups = {\n        ${cfg.ldap.userGroup} = { };\n      };\n\n      mailserver = {\n        ldap = {\n          enable = true;\n          uris = [\n            \"ldap://${cfg.ldap.host}:${toString cfg.ldap.port}\"\n          ];\n          searchBase = \"ou=people,${cfg.ldap.dcdomain}\";\n          searchScope = \"sub\";\n          bind = {\n            dn = \"uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}\";\n            passwordFile = cfg.ldap.adminPassword.result.path;\n          };\n          # Note that nixos simple mailserver sets auth_bind=yes\n          # which means authentication binds are used.\n          # https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind\n          dovecot =\n            let\n              filter = \"(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))\";\n            in\n            {\n              passAttrs = \"user=user\";\n              passFilter = filter;\n              userAttrs = lib.concatStringsSep \",\" [\n                \"=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u\"\n                # \"mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail\"\n                \"uid=${config.mailserver.vmailUserName}\"\n                \"gid=${config.mailserver.vmailGroupName}\"\n              ];\n              userFilter = filter;\n            };\n          postfix = {\n            filter = \"(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))\";\n            mailAttribute = \"mail\";\n            uidAttribute = \"mail\";\n          };\n        };\n      };\n    })\n    (lib.mkIf (cfg.enable && cfg.imapSync != null) {\n      systemd.services.mbsync =\n        let\n          configFile =\n            let\n              mkAccount = name: acct: ''\n                # ${name} account\n\n                IMAPAccount ${name}\n                Host ${acct.host}\n                Port ${toString acct.port}\n                User ${acct.username}\n                PassCmd \"cat ${acct.password.result.path}\"\n                TLSType ${acct.sslType}\n                AuthMechs LOGIN\n                Timeout ${toString acct.timeout}\n\n                IMAPStore ${name}-remote\n                Account ${name}\n\n                MaildirStore ${name}-local\n                INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/\n                # Maps subfolders on far side to actual subfolders on disk.\n                # The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.'\n                SubFolders Verbatim\n                Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/\n\n                Channel ${name}-main\n                Far :${name}-remote:\n                Near :${name}-local:\n                Patterns * !Drafts !Sent !Trash !Junk !${acct.mapSpecialDrafts} !${acct.mapSpecialSent} !${acct.mapSpecialTrash} !${acct.mapSpecialJunk}\n                Create Both\n                Expunge Both\n                SyncState *\n                Sync All\n                CopyArrivalDate yes  # Preserve date from incoming message.\n\n                Channel ${name}-drafts\n                Far :${name}-remote:\"${acct.mapSpecialDrafts}\"\n                Near :${name}-local:\"Drafts\"\n                Create Both\n                Expunge Both\n                SyncState *\n                Sync All\n                CopyArrivalDate yes  # Preserve date from incoming message.\n\n                Channel ${name}-sent\n                Far :${name}-remote:\"${acct.mapSpecialSent}\"\n                Near :${name}-local:\"Sent\"\n                Create Both\n                Expunge Both\n                SyncState *\n                Sync All\n                CopyArrivalDate yes  # Preserve date from incoming message.\n\n                Channel ${name}-trash\n                Far :${name}-remote:\"${acct.mapSpecialTrash}\"\n                Near :${name}-local:\"Trash\"\n                Create Both\n                Expunge Both\n                SyncState *\n                Sync All\n                CopyArrivalDate yes  # Preserve date from incoming message.\n\n                Channel ${name}-junk\n                Far :${name}-remote:\"${acct.mapSpecialJunk}\"\n                Near :${name}-local:\"Junk\"\n                Create Both\n                Expunge Both\n                SyncState *\n                Sync All\n                CopyArrivalDate yes  # Preserve date from incoming message.\n\n                Group ${name}\n                Channel ${name}-main\n                Channel ${name}-drafts\n                Channel ${name}-sent\n                Channel ${name}-trash\n                Channel ${name}-junk\n\n                # END ${name} account\n              '';\n\n            in\n            pkgs.writeText \"mbsync.conf\" (\n              lib.concatStringsSep \"\\n\" (lib.mapAttrsToList mkAccount cfg.imapSync.accounts)\n            );\n        in\n        {\n          description = \"Sync mailbox\";\n          serviceConfig = {\n            Type = \"oneshot\";\n            User = config.mailserver.vmailUserName;\n          };\n          script =\n            let\n              debug = if cfg.imapSync.debug then \"-V\" else \"\";\n            in\n            ''\n              ${pkgs.isync}/bin/mbsync --all ${debug} --config ${configFile}\n            '';\n        };\n\n      systemd.tmpfiles.rules =\n        let\n          mkAccount =\n            name: acct:\n            # The equal sign makes sure parent directories have the corret user and group too.\n            [\n              \"d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -\"\n              \"d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -\"\n            ];\n        in\n        lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts);\n\n      systemd.timers.mbsync = {\n        wantedBy = [ \"timers.target\" ];\n        timerConfig = {\n          OnBootSec = cfg.imapSync.syncTimer;\n          OnUnitActiveSec = cfg.imapSync.syncTimer;\n        };\n      };\n    })\n    (lib.mkIf (cfg.enable && cfg.smtpRelay != null) (\n      let\n        url = \"[${cfg.smtpRelay.host}]:${toString cfg.smtpRelay.port}\";\n      in\n      {\n        assertions = [\n          {\n            assertion = lib.hasAttr cfg.adminPassword != null;\n            message = \"`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null.\";\n          }\n        ];\n\n        # Inspiration from https://www.brull.me/postfix/debian/fastmail/2016/08/16/fastmail-smtp.html\n        services.postfix = {\n          settings.main = {\n            relayhost = [ url ];\n            smtp_sasl_auth_enable = \"yes\";\n            smtp_sasl_password_maps = \"texthash:/run/secrets/postfix/postfix-smtp-relay-password\";\n            smtp_sasl_security_options = \"noanonymous\";\n            smtp_use_tls = \"yes\";\n          };\n        };\n\n        systemd.services.postfix-pre = {\n          script = shb.replaceSecrets {\n            userConfig = {\n              inherit url;\n              inherit (cfg.smtpRelay) username;\n              password.source = cfg.smtpRelay.password.result.path;\n            };\n            generator =\n              name:\n              {\n                url,\n                username,\n                password,\n              }:\n              pkgs.writeText \"postfix-smtp-relay-password\" ''\n                ${url} ${username}:${password}\n              '';\n            resultPath = \"/run/secrets/postfix/postfix-smtp-relay-password\";\n            user = config.services.postfix.user;\n          };\n          serviceConfig.Type = \"oneshot\";\n          wantedBy = [ \"multi-user.target\" ];\n          before = [ \"postfix.service\" ];\n          requiredBy = [ \"postfix.service\" ];\n        };\n      }\n    ))\n  ];\n}\n"
  },
  {
    "path": "modules/services/nextcloud-server/dashboard/Nextcloud.json",
    "content": "{\n  \"annotations\": {\n    \"list\": [\n      {\n        \"builtIn\": 1,\n        \"datasource\": {\n          \"type\": \"grafana\",\n          \"uid\": \"-- Grafana --\"\n        },\n        \"enable\": true,\n        \"hide\": true,\n        \"iconColor\": \"rgba(0, 211, 255, 1)\",\n        \"name\": \"Annotations & Alerts\",\n        \"type\": \"dashboard\"\n      }\n    ]\n  },\n  \"editable\": true,\n  \"fiscalYearStartMonth\": 0,\n  \"graphTooltip\": 0,\n  \"id\": 13,\n  \"links\": [],\n  \"panels\": [\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 0,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 19,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableLogDetails\": true,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": false,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.3.0+security-01\",\n      \"repeat\": \"other_service\",\n      \"repeatDirection\": \"h\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=\\\"$other_service.service\\\"} | json | line_format \\\"{{.message}}\\\" | json | drop message | line_format \\\"[{{.app}} - {{.url}}] {{.Message}}: {{.exception_details}}{{.Previous_Message}}\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Panel Title\",\n      \"type\": \"logs\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 0\n      },\n      \"id\": 12,\n      \"panels\": [],\n      \"title\": \"General\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"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.\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"percent\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"orange\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 34\n              },\n              {\n                \"id\": \"min\",\n                \"value\": -40\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 1\n      },\n      \"id\": 8,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_cpu_some_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"some stall time\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_cpu_utilization_percentage_average{hostname=~\\\"$hostname\\\", service_name=~\\\".*$service.*\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"CPU\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"axisSoftMin\": -100,\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"mbytes\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"decimals\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"dark-red\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    10,\n                    10\n                  ],\n                  \"fill\": \"dash\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"green\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 10\n              },\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"auto\"\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 1\n      },\n      \"id\": 7,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"lastNotNull\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"full stall time\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum(netdata_mem_available_MiB_average{hostname=~\\\"$hostname\\\"})\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"total available\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{hostname=~\\\"$hostname\\\", service_name=~\\\".*$service.*\\\", dimension=\\\"ram\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"Memory\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"KBs\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"orange\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 34\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 9\n      },\n      \"id\": 4,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension) (netdata_system_net_kilobits_persec_average{hostname=~\\\"$hostname\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{dimension}}\",\n          \"range\": true,\n          \"refId\": \"used\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"Network I/O\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"decimals\": 2,\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"red\",\n                \"value\": null\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 0.05\n              }\n            ]\n          },\n          \"unit\": \"Kibits\"\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"A\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"red\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 12\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              },\n              {\n                \"id\": \"min\",\n                \"value\": -200\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byFrameRefID\",\n              \"options\": \"B\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.axisPlacement\",\n                \"value\": \"right\"\n              },\n              {\n                \"id\": \"unit\",\n                \"value\": \"ms\"\n              },\n              {\n                \"id\": \"color\",\n                \"value\": {\n                  \"fixedColor\": \"orange\",\n                  \"mode\": \"fixed\"\n                }\n              },\n              {\n                \"id\": \"custom.lineStyle\",\n                \"value\": {\n                  \"dash\": [\n                    0,\n                    10\n                  ],\n                  \"fill\": \"dot\"\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 2\n              },\n              {\n                \"id\": \"custom.fillOpacity\",\n                \"value\": 17\n              },\n              {\n                \"id\": \"custom.stacking\",\n                \"value\": {\n                  \"group\": \"A\",\n                  \"mode\": \"none\"\n                }\n              },\n              {\n                \"id\": \"min\",\n                \"value\": -200\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 9\n      },\n      \"id\": 6,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"sum\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true,\n          \"width\": 300\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_io_full_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"full stall time\",\n          \"range\": true,\n          \"refId\": \"A\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"netdata_system_io_some_pressure_stall_time_ms_average{hostname=~\\\"$hostname\\\"} * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"some stall time\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\\\"$hostname\\\", service_name=~\\\".*$service.*\\\", dimension=\\\"read\\\"})\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"{{service_name}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"read\",\n          \"useBackend\": false\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\\\"$hostname\\\", service_name=~\\\".*$service.*\\\", dimension=\\\"write\\\"}) * -1\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{service_name}} / {{dimension}}\",\n          \"range\": true,\n          \"refId\": \"write\"\n        }\n      ],\n      \"title\": \"Disk I/O\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"description\": \"\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"area\"\n            }\n          },\n          \"fieldMinMax\": false,\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"percentage\",\n            \"steps\": [\n              {\n                \"color\": \"transparent\",\n                \"value\": null\n              },\n              {\n                \"color\": \"orange\",\n                \"value\": 80\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 90\n              },\n              {\n                \"color\": \"transparent\",\n                \"value\": 100\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"total\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.hideFrom\",\n                \"value\": {\n                  \"legend\": true,\n                  \"tooltip\": false,\n                  \"viz\": false\n                }\n              },\n              {\n                \"id\": \"custom.lineWidth\",\n                \"value\": 0\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 17\n      },\n      \"id\": 22,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"editorMode\": \"code\",\n          \"expr\": \"sum (phpfpm_active_processes{hostname=~\\\"$hostname\\\",pool=\\\"nextcloud\\\"})\",\n          \"hide\": false,\n          \"legendFormat\": \"active\",\n          \"range\": true,\n          \"refId\": \"active\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum (phpfpm_total_processes{hostname=~\\\"$hostname\\\",pool=\\\"nextcloud\\\"})\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"total\",\n          \"range\": true,\n          \"refId\": \"total\"\n        },\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"sum (phpfpm_max_active_processes{hostname=~\\\"$hostname\\\",pool=\\\"nextcloud\\\"})\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"max active\",\n          \"range\": true,\n          \"refId\": \"max active\"\n        }\n      ],\n      \"title\": \"PHP-FPM Processes\",\n      \"transformations\": [\n        {\n          \"disabled\": true,\n          \"id\": \"calculateField\",\n          \"options\": {\n            \"binary\": {\n              \"left\": {\n                \"matcher\": {\n                  \"id\": \"byName\",\n                  \"options\": \"total\"\n                }\n              },\n              \"operator\": \"*\",\n              \"right\": {\n                \"fixed\": \"0.8\"\n              }\n            },\n            \"mode\": \"binary\",\n            \"reduce\": {\n              \"reducer\": \"sum\"\n            }\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 25\n      },\n      \"id\": 14,\n      \"panels\": [],\n      \"title\": \"Network\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"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.\",\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"fixedColor\": \"purple\",\n            \"mode\": \"fixed\",\n            \"seriesBy\": \"max\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"dashed+area\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"transparent\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 700000\n              }\n            ]\n          },\n          \"unit\": \"µs\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 26\n      },\n      \"id\": 23,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": false\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"disableTextWrap\": false,\n          \"editorMode\": \"code\",\n          \"expr\": \"max by (hostname,child) (phpfpm_process_request_duration and (abs(phpfpm_process_request_duration - phpfpm_process_request_duration offset $__interval) > 1))\",\n          \"fullMetaSearch\": false,\n          \"hide\": false,\n          \"includeNullMetadata\": true,\n          \"legendFormat\": \"__auto\",\n          \"range\": true,\n          \"refId\": \"A\",\n          \"useBackend\": false\n        }\n      ],\n      \"title\": \"PHP-FPM Request Duration\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"prometheus\",\n        \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"line\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 12,\n        \"y\": 26\n      },\n      \"id\": 24,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [],\n          \"displayMode\": \"list\",\n          \"placement\": \"bottom\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"prometheus\",\n            \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"rate(phpfpm_max_listen_queue[2m])\",\n          \"hide\": false,\n          \"instant\": false,\n          \"legendFormat\": \"{{hostname}} - max queue\",\n          \"range\": true,\n          \"refId\": \"B\"\n        },\n        {\n          \"editorMode\": \"code\",\n          \"expr\": \"phpfpm_listen_queue\",\n          \"legendFormat\": \"{{hostname}} - queue\",\n          \"range\": true,\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"PHP-FPM Requests Queue Length\",\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"palette-classic\"\n          },\n          \"custom\": {\n            \"axisBorderShow\": false,\n            \"axisCenteredZero\": false,\n            \"axisColorMode\": \"text\",\n            \"axisLabel\": \"\",\n            \"axisPlacement\": \"auto\",\n            \"barAlignment\": 0,\n            \"barWidthFactor\": 0.6,\n            \"drawStyle\": \"points\",\n            \"fillOpacity\": 0,\n            \"gradientMode\": \"none\",\n            \"hideFrom\": {\n              \"legend\": false,\n              \"tooltip\": false,\n              \"viz\": false\n            },\n            \"insertNulls\": false,\n            \"lineInterpolation\": \"linear\",\n            \"lineWidth\": 1,\n            \"pointSize\": 5,\n            \"scaleDistribution\": {\n              \"type\": \"linear\"\n            },\n            \"showPoints\": \"auto\",\n            \"spanNulls\": false,\n            \"stacking\": {\n              \"group\": \"A\",\n              \"mode\": \"none\"\n            },\n            \"thresholdsStyle\": {\n              \"mode\": \"off\"\n            }\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          },\n          \"unit\": \"ms\"\n        },\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 34\n      },\n      \"id\": 9,\n      \"options\": {\n        \"legend\": {\n          \"calcs\": [\n            \"max\",\n            \"mean\",\n            \"variance\"\n          ],\n          \"displayMode\": \"table\",\n          \"placement\": \"right\",\n          \"showLegend\": true\n        },\n        \"tooltip\": {\n          \"maxHeight\": 600,\n          \"mode\": \"single\",\n          \"sort\": \"none\"\n        }\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | server_name =~ \\\"^$subdomain.*\\\"\",\n          \"legendFormat\": \"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Requests\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"keepTime\": true,\n            \"replace\": true,\n            \"source\": \"labels\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"body_bytes_sent\": true,\n              \"bytes_sent\": true,\n              \"gzip_ration\": true,\n              \"job\": true,\n              \"line\": true,\n              \"post\": true,\n              \"referrer\": true,\n              \"remote_addr\": false,\n              \"remote_user\": true,\n              \"request\": true,\n              \"request_length\": true,\n              \"status\": true,\n              \"time_local\": true,\n              \"unit\": true,\n              \"upstream_addr\": true,\n              \"upstream_connect_time\": true,\n              \"upstream_header_time\": true,\n              \"upstream_response_time\": true,\n              \"upstream_status\": false,\n              \"user_agent\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        },\n        {\n          \"id\": \"convertFieldType\",\n          \"options\": {\n            \"conversions\": [\n              {\n                \"dateFormat\": \"\",\n                \"destinationType\": \"number\",\n                \"targetField\": \"request_time\"\n              }\n            ],\n            \"fields\": {}\n          }\n        },\n        {\n          \"id\": \"partitionByValues\",\n          \"options\": {\n            \"fields\": [\n              \"server_name\",\n              \"remote_addr\"\n            ],\n            \"keepFields\": false\n          }\n        },\n        {\n          \"id\": \"renameByRegex\",\n          \"options\": {\n            \"regex\": \"request_time (.*)\",\n            \"renamePattern\": \"$1\"\n          }\n        }\n      ],\n      \"type\": \"timeseries\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"status\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 70\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 42\n      },\n      \"id\": 3,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": [\n          {\n            \"desc\": true,\n            \"displayName\": \"Time\"\n          }\n        ]\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | upstream_addr =~ \\\"$upstream_addr\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Requests Details\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"id\": true,\n              \"labels\": true,\n              \"server_name\": true,\n              \"time_local\": true,\n              \"tsNs\": true,\n              \"upstream_addr\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Line\": 2,\n              \"Time\": 1,\n              \"body_bytes_sent\": 13,\n              \"bytes_sent\": 12,\n              \"gzip_ration\": 16,\n              \"id\": 4,\n              \"labels\": 0,\n              \"post\": 17,\n              \"referrer\": 14,\n              \"remote_addr\": 7,\n              \"remote_user\": 8,\n              \"request\": 6,\n              \"request_length\": 10,\n              \"request_time\": 20,\n              \"server_name\": 11,\n              \"status\": 5,\n              \"time_local\": 9,\n              \"tsNs\": 3,\n              \"upstream_addr\": 18,\n              \"upstream_connect_time\": 22,\n              \"upstream_header_time\": 23,\n              \"upstream_response_time\": 21,\n              \"upstream_status\": 19,\n              \"user_agent\": 15\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"default\": false,\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"color\": {\n            \"mode\": \"thresholds\"\n          },\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"status\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 70\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 50\n      },\n      \"id\": 11,\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": []\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"nginx.service\\\"} | pattern \\\"<_> <_> <line>\\\" | line_format \\\"{{.line}}\\\" | json | __error__ != \\\"JSONParserErr\\\" | upstream_addr = \\\"$upstream_addr\\\" | status =~\\\"5..\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"5XX Requests Details\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"id\": true,\n              \"labels\": true,\n              \"server_name\": true,\n              \"time_local\": true,\n              \"tsNs\": true,\n              \"upstream_addr\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Line\": 2,\n              \"Time\": 1,\n              \"body_bytes_sent\": 13,\n              \"bytes_sent\": 12,\n              \"gzip_ration\": 16,\n              \"id\": 4,\n              \"labels\": 0,\n              \"post\": 17,\n              \"referrer\": 14,\n              \"remote_addr\": 7,\n              \"remote_user\": 8,\n              \"request\": 6,\n              \"request_length\": 10,\n              \"request_time\": 20,\n              \"server_name\": 11,\n              \"status\": 5,\n              \"time_local\": 9,\n              \"tsNs\": 3,\n              \"upstream_addr\": 18,\n              \"upstream_connect_time\": 22,\n              \"upstream_header_time\": 23,\n              \"upstream_response_time\": 21,\n              \"upstream_status\": 19,\n              \"user_agent\": 15\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 58\n      },\n      \"id\": 18,\n      \"panels\": [],\n      \"title\": \"Logs\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 10,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 59\n      },\n      \"id\": 20,\n      \"maxPerRow\": 2,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableLogDetails\": true,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"repeat\": \"other_service\",\n      \"repeatDirection\": \"h\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"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 }}\\\"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Log: $other_service\",\n      \"type\": \"logs\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 89\n      },\n      \"id\": 15,\n      \"panels\": [],\n      \"title\": \"Backup\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 10,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 90\n      },\n      \"id\": 16,\n      \"maxPerRow\": 2,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableLogDetails\": true,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"repeat\": \"service_backup\",\n      \"repeatDirection\": \"h\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"$service_backup.service\\\"}\",\n          \"legendFormat\": \"\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Log: $service_backup\",\n      \"transformations\": [\n        {\n          \"disabled\": true,\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"source\": \"Line\"\n          }\n        },\n        {\n          \"disabled\": true,\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"id\": true,\n              \"labels\": true,\n              \"tsNs\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {},\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"logs\"\n    },\n    {\n      \"collapsed\": false,\n      \"gridPos\": {\n        \"h\": 1,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 100\n      },\n      \"id\": 13,\n      \"panels\": [],\n      \"title\": \"Supporting Services\",\n      \"type\": \"row\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {\n          \"custom\": {\n            \"align\": \"auto\",\n            \"cellOptions\": {\n              \"type\": \"auto\"\n            },\n            \"inspect\": false\n          },\n          \"mappings\": [],\n          \"thresholds\": {\n            \"mode\": \"absolute\",\n            \"steps\": [\n              {\n                \"color\": \"green\",\n                \"value\": null\n              },\n              {\n                \"color\": \"red\",\n                \"value\": 80\n              }\n            ]\n          }\n        },\n        \"overrides\": [\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"duration_ms\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 100\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"unit\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 150\n              }\n            ]\n          },\n          {\n            \"matcher\": {\n              \"id\": \"byName\",\n              \"options\": \"statement\"\n            },\n            \"properties\": [\n              {\n                \"id\": \"custom.width\",\n                \"value\": 505\n              }\n            ]\n          }\n        ]\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 24,\n        \"x\": 0,\n        \"y\": 101\n      },\n      \"id\": 10,\n      \"links\": [\n        {\n          \"title\": \"explore\",\n          \"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\"\n        }\n      ],\n      \"options\": {\n        \"cellHeight\": \"sm\",\n        \"footer\": {\n          \"countRows\": false,\n          \"fields\": \"\",\n          \"reducer\": [\n            \"sum\"\n          ],\n          \"show\": false\n        },\n        \"showHeader\": true,\n        \"sortBy\": [\n          {\n            \"desc\": true,\n            \"displayName\": \"Time\"\n          }\n        ]\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{hostname=~\\\"$hostname\\\",unit=\\\"postgresql.service\\\"} | regexp \\\".*duration: (?P<duration_ms>[0-9.]+) ms (?P<statement>.*)\\\" | duration_ms > 1000\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Slow PostgreSQL Queries\",\n      \"transformations\": [\n        {\n          \"id\": \"extractFields\",\n          \"options\": {\n            \"keepTime\": false,\n            \"replace\": false,\n            \"source\": \"labels\"\n          }\n        },\n        {\n          \"id\": \"organize\",\n          \"options\": {\n            \"excludeByName\": {\n              \"Line\": true,\n              \"Time\": false,\n              \"id\": true,\n              \"job\": true,\n              \"labels\": true,\n              \"tsNs\": true,\n              \"unit\": true\n            },\n            \"includeByName\": {},\n            \"indexByName\": {\n              \"Line\": 6,\n              \"Time\": 0,\n              \"duration_ms\": 1,\n              \"id\": 8,\n              \"job\": 2,\n              \"labels\": 5,\n              \"statement\": 4,\n              \"tsNs\": 7,\n              \"unit\": 3\n            },\n            \"renameByName\": {}\n          }\n        }\n      ],\n      \"type\": \"table\"\n    },\n    {\n      \"datasource\": {\n        \"type\": \"loki\",\n        \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n      },\n      \"fieldConfig\": {\n        \"defaults\": {},\n        \"overrides\": []\n      },\n      \"gridPos\": {\n        \"h\": 8,\n        \"w\": 12,\n        \"x\": 0,\n        \"y\": 109\n      },\n      \"id\": 2,\n      \"options\": {\n        \"dedupStrategy\": \"none\",\n        \"enableLogDetails\": false,\n        \"prettifyLogMessage\": false,\n        \"showCommonLabels\": false,\n        \"showLabels\": false,\n        \"showTime\": true,\n        \"sortOrder\": \"Descending\",\n        \"wrapLogMessage\": false\n      },\n      \"pluginVersion\": \"11.4.0\",\n      \"targets\": [\n        {\n          \"datasource\": {\n            \"type\": \"loki\",\n            \"uid\": \"cd6cc53e-840c-484d-85f7-96fede324006\"\n          },\n          \"editorMode\": \"code\",\n          \"expr\": \"{unit=\\\"redis-$service.service\\\"} |= ``\",\n          \"queryType\": \"range\",\n          \"refId\": \"A\"\n        }\n      ],\n      \"title\": \"Redis\",\n      \"type\": \"logs\"\n    }\n  ],\n  \"preload\": false,\n  \"schemaVersion\": 40,\n  \"tags\": [],\n  \"templating\": {\n    \"list\": [\n      {\n        \"current\": {\n          \"text\": [\n            \"baryum\"\n          ],\n          \"value\": [\n            \"baryum\"\n          ]\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"label_values(up,hostname)\",\n        \"includeAll\": false,\n        \"multi\": true,\n        \"name\": \"hostname\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(up,hostname)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      },\n      {\n        \"current\": {\n          \"text\": \"nextcloud\",\n          \"value\": \"nextcloud\"\n        },\n        \"description\": \"\",\n        \"hide\": 2,\n        \"name\": \"service\",\n        \"query\": \"nextcloud\",\n        \"skipUrlSync\": true,\n        \"type\": \"constant\"\n      },\n      {\n        \"current\": {\n          \"text\": \"n\",\n          \"value\": \"n\"\n        },\n        \"hide\": 2,\n        \"name\": \"subdomain\",\n        \"query\": \"n\",\n        \"skipUrlSync\": true,\n        \"type\": \"constant\"\n      },\n      {\n        \"current\": {\n          \"text\": \"unix:/run/phpfpm/nextcloud.sock\",\n          \"value\": \"unix:/run/phpfpm/nextcloud.sock\"\n        },\n        \"hide\": 2,\n        \"name\": \"upstream_addr\",\n        \"query\": \"unix:/run/phpfpm/nextcloud.sock\",\n        \"skipUrlSync\": true,\n        \"type\": \"constant\"\n      },\n      {\n        \"current\": {\n          \"text\": \"All\",\n          \"value\": \"$__all\"\n        },\n        \"datasource\": {\n          \"type\": \"prometheus\",\n          \"uid\": \"df80f9f5-97d7-4112-91d8-72f523a02b09\"\n        },\n        \"definition\": \"label_values({unit_name=~\\\".*$service.*\\\", unit_name=~\\\".*backups.*\\\", unit_name!~\\\".*restore_gen\\\"},unit_name)\",\n        \"description\": \"\",\n        \"hide\": 2,\n        \"includeAll\": true,\n        \"name\": \"service_backup\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values({unit_name=~\\\".*$service.*\\\", unit_name=~\\\".*backups.*\\\", unit_name!~\\\".*restore_gen\\\"},unit_name)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"sort\": 1,\n        \"type\": \"query\"\n      },\n      {\n        \"current\": {\n          \"text\": \"All\",\n          \"value\": \"$__all\"\n        },\n        \"definition\": \"label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\\\".*nextcloud.*\\\", unit_name!~\\\".*backup.*\\\", unit_name!~\\\".*redis.*\\\"},unit_name)\",\n        \"hide\": 2,\n        \"includeAll\": true,\n        \"name\": \"other_service\",\n        \"options\": [],\n        \"query\": {\n          \"qryType\": 1,\n          \"query\": \"label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\\\".*nextcloud.*\\\", unit_name!~\\\".*backup.*\\\", unit_name!~\\\".*redis.*\\\"},unit_name)\",\n          \"refId\": \"PrometheusVariableQueryEditor-VariableQuery\"\n        },\n        \"refresh\": 1,\n        \"regex\": \"\",\n        \"type\": \"query\"\n      }\n    ]\n  },\n  \"time\": {\n    \"from\": \"now-2d\",\n    \"to\": \"now\"\n  },\n  \"timepicker\": {},\n  \"timezone\": \"browser\",\n  \"title\": \"Nextcloud\",\n  \"uid\": \"cdsszybv2gow0d\",\n  \"version\": 49,\n  \"weekStart\": \"\"\n}\n"
  },
  {
    "path": "modules/services/nextcloud-server/docs/default.md",
    "content": "# Nextcloud Server Service {#services-nextcloudserver}\n\nDefined in [`/modules/services/nextcloud-server.nix`](@REPO@/modules/services/nextcloud-server.nix).\n\nThis NixOS module is a service that sets up a [Nextcloud Server](https://nextcloud.com/).\nIt is based on the nixpkgs Nextcloud server and provides opinionated defaults.\n\n## Features {#services-nextcloudserver-features}\n\n- Declarative [Apps](#services-nextcloudserver-options-shb.nextcloud.apps) Configuration - no need\n  to configure those with the UI.\n  - [LDAP](#services-nextcloudserver-usage-ldap) app:\n    enables app and sets up integration with an existing LDAP server, in this case LLDAP.\n    Note that the LDAP app cannot distinguish between normal users and admin users.\n  - [SSO](#services-nextcloudserver-usage-oidc) app:\n    enables app and sets up integration with an existing SSO server, in this case Authelia.\n    The SSO app can distinguish between normal users and admin users.\n  - [Preview Generator](#services-nextcloudserver-usage-previewgenerator) app:\n    enables app and sets up required cron job.\n  - [External Storage](#services-nextcloudserver-usage-externalstorage) app:\n    enables app and optionally configures one local mount.\n    This enables having data living on separate hard drives.\n  - [Only Office](#services-nextcloudserver-usage-onlyoffice) app:\n    enables app and sets up Only Office service.\n  - [Memories](#services-nextcloudserver-usage-memories) app:\n    enables app and sets up all required dependencies and optional hardware acceleration with VAAPI.\n  - [Recognize](#services-nextcloudserver-usage-recognize) app:\n    enables app and sets up all required dependencies and optional hardware acceleration with VAAPI.\n  - Any other app through the\n    [shb.nextcloud.extraApps](#services-nextcloudserver-options-shb.nextcloud.extraApps) option.\n- Access through subdomain using reverse proxy.\n- Forces Nginx as the reverse proxy. (This is hardcoded in the upstream nixpkgs module).\n- Sets good defaults for trusted proxies settings, chunk size, opcache php options.\n- Access through HTTPS using reverse proxy.\n- Forces PostgreSQL as the database.\n- Forces Redis as the cache and sets good defaults.\n- Backup of the [`shb.nextcloud.dataDir`][dataDir] through the [backup block](./blocks-backup.html).\n- [Monitoring Dashboard](#services-nextcloudserver-dashboard) for monitoring of reverse proxy, PHP-FPM, and database backups through the [monitoring block](./blocks-monitoring.html).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.\n- [Integration Tests](@REPO@/test/services/nextcloud.nix)\n  - Tests system cron job is setup correctly.\n  - Tests initial admin user and password are setup correctly.\n  - Tests admin user can create and retrieve a file through WebDAV.\n- Enables easy setup of xdebug for PHP debugging if needed.\n- Easily add other apps declaratively through [extraApps][]\n- By default automatically disables maintenance mode on start.\n- By default automatically launches repair mode with expensive migrations on start.\n- Access to advanced options not exposed here thanks to how NixOS modules work.\n- Has a [demo](#services-nextcloudserver-demo).\n\n[dataDir]: ./services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dataDir\n\n## Usage {#services-nextcloudserver-usage}\n\n### Nextcloud through HTTP {#services-nextcloudserver-usage-basic}\n\n[HTTP]: #services-nextcloudserver-usage-basic\n\n:::: {.note}\nThis section corresponds to the `basic` section of the [Nextcloud\ndemo](demo-nextcloud-server.html#demo-nextcloud-deploy-basic).\n::::\n\nConfiguring Nextcloud to be accessible through Nginx reverse proxy\nat the address `http://n.example.com`,\nwith PostgreSQL and Redis configured,\nis done like so:\n\n```nix\nshb.nextcloud = {\n  enable = true;\n  domain = \"example.com\";\n  subdomain = \"n\";\n  defaultPhoneRegion = \"US\";\n  initialAdminUsername = \"root\";\n  adminPass.result = config.shb.sops.secret.\"nextcloud/adminpass\".result;\n};\n\nshb.sops.secret.\"nextcloud/adminpass\".request = config.shb.nextcloud.adminPass.request;\n```\n\nThis assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nNote though that Nextcloud will not be very happy to be accessed through HTTP,\nit much prefers - rightfully - to be accessed through HTTPS.\nWe will set that up in the next section.\n\nYou can now login as the admin user using the username `root`\nand the password defined in `sops.secrets.\"nextcloud/adminpass\"`.\n\n### Nextcloud through HTTPS {#services-nextcloudserver-usage-https}\n\n[HTTPS]: #services-nextcloudserver-usage-https\n\nTo setup HTTPS, we will get our certificates from Let's Encrypt using the HTTP method.\nThis is the easiest way to get started and does not require you to programmatically \nconfigure a DNS provider.\n\nUnder the hood, we use the Self Host Block [SSL contract](./contracts-ssl.html).\nIt allows the end user to choose how to generate the certificates.\nIf you want other options to generate the certificate, follow the SSL contract link.\n\nBuilding upon the [Basic Configuration](#services-nextcloudserver-usage-basic) above, we add:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\" = {\n  domain = \"example.com\";\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"myemail@mydomain.com\";\n};\n\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"n.example.com\" ];\n\nshb.nextcloud = {\n  ssl = config.shb.certs.certs.letsencrypt.\"example.com\";\n};\n```\n\n### Choose Nextcloud Version {#services-nextcloudserver-usage-version}\n\nSelf Host Blocks is conservative in the version of Nextcloud it's using.\nTo choose the version and upgrade at the time of your liking,\njust use the [version](#services-nextcloudserver-options-shb.nextcloud.version) option:\n\n```nix\nshb.nextcloud.version = 29;\n```\n\n### Mount Point {#services-nextcloudserver-usage-mount-point}\n\nIf the `dataDir` exists in a mount point,\nit is highly recommended to make the various Nextcloud services wait on the mount point before starting.\nDoing that is just a matter of setting the `mountPointServices` option.\n\nAssuming a mount point on `/var`, the configuration would look like so:\n\n```nix\nfileSystems.\"/var\".device = \"...\";\nshb.nextcloud.mountPointServices = [ \"var.mount\" ];\n```\n\n### With LDAP Support {#services-nextcloudserver-usage-ldap}\n\n[LDAP]: #services-nextcloudserver-usage-ldap\n\n:::: {.note}\nThis section corresponds to the `ldap` section of the [Nextcloud\ndemo](demo-nextcloud-server.html#demo-nextcloud-deploy-ldap).\n::::\n\nWe will build upon the [HTTP][] and [HTTPS][] sections,\nso please read those first.\n\nWe will use the [LLDAP block][] provided by Self Host Blocks.\nAssuming it [has been set already][LLDAP block setup], add the following configuration:\n\n[LLDAP block]: blocks-lldap.html\n[LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup\n\n```nix\nshb.nextcloud.apps.ldap = {\n  enable = true;\n  host = \"127.0.0.1\";\n  port = config.shb.lldap.ldapPort;\n  dcdomain = config.shb.lldap.dcdomain;\n  adminName = \"admin\";\n  adminPassword.result = config.shb.sops.secret.\"nextcloud/ldap/adminPassword\".result\n  userGroup = \"nextcloud_user\";\n};\n\nshb.sops.secret.\"nextcloud/ldap/adminPassword\" = {\n  request = config.shb.nextcloud.apps.ldap.adminPassword.request;\n  settings.key = \"ldap/userPassword\";\n};\n```\n\nThe LDAP admin password must be shared between `shb.lldap` and `shb.nextcloud`,\nto do that with SOPS we use the `key` option so that both\n`sops.secrets.\"ldap/userPassword\"`\nand `sops.secrets.\"nextcloud/ldapUserPassword\"`\nsecrets have the same content.\n\nThe LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) is created automatically.\nAdd your user to it by going to `http://ldap.example.com`,\ncreate a user if needed and add it to the group.\nWhen that's done, go back to the Nextcloud server at\n`https://nextcloud.example.com` and login with that user.\n\nNote that we cannot create an admin user from the LDAP server,\nso you need to create a normal user like above,\nlogin with it once so it is known to Nextcloud, then logout,\nlogin with the admin Nextcloud user and promote that new user to admin level.\nThis limitation does not exist with the [SSO integration](#services-nextcloudserver-usage-oidc).\n\n### With SSO Support {#services-nextcloudserver-usage-oidc}\n\n:::: {.note}\nThis section corresponds to the `sso` section of the [Nextcloud\ndemo](demo-nextcloud-server.html#demo-nextcloud-deploy-sso).\n::::\n\nWe will build upon the [HTTP][], [HTTPS][] and [LDAP][] sections,\nso please read those first.\n\nWe will use the [SSO block][] provided by Self Host Blocks.\nAssuming it [has been set already][SSO block setup], add the following configuration:\n\n[SSO block]: blocks-sso.html\n[SSO block setup]: blocks-sso.html#blocks-sso-global-setup\n\n```nix\nshb.nextcloud.apps.sso = {\n  enable = true;\n  endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n  clientID = \"nextcloud\";\n  fallbackDefaultAuth = false;\n\n  secret.result = config.shb.sops.secret.\"nextcloud/sso/secret\".result;\n  secretForAuthelia.result = config.shb.sops.secret.\"nextcloud/sso/secretForAuthelia\".result;\n};\n\nshb.sops.secret.\"nextcloud/sso/secret\".request = config.shb.nextcloud.apps.sso.secret.request;\nshb.sops.secret.\"nextcloud/sso/secretForAuthelia\" = {\n  request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;\n  settings.key = \"nextcloud/sso/secret\";\n};\n```\n\nThe SSO secret must be shared between `shb.authelia` and `shb.nextcloud`,\nto do that with SOPS we use the `key` option so that both\n`sops.secrets.\"nextcloud/sso/secret\"`\nand `sops.secrets.\"nextcloud/sso/secretForAuthelia\"`\nsecrets have the same content.\n\nThe 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.\nAdd your user to one or both by going to `http://ldap.example.com`,\ncreate a user if needed and add it to the groups.\nWhen that's done, go back to the Nextcloud server at\n`https://nextcloud.example.com` and login with that user.\n\nSetting the `fallbackDefaultAuth` to `false` means the only way to login is through Authelia.\nIf this does not work for any reason, you can let users login through Nextcloud directly by setting this option to `true`.\n\n### Tweak PHPFpm Config {#services-nextcloudserver-usage-phpfpm}\n\nFor instances with more users, or if you feel the pages are loading slowly,\nyou can tweak the `php-fpm` pool settings.\n\n```nix\nshb.nextcloud.phpFpmPoolSettings = {\n  \"pm\" = \"static\"; # Can be dynamic\n  \"pm.max_children\" = 150;\n  # \"pm.start_servers\" = 300;\n  # \"pm.min_spare_servers\" = 300;\n  # \"pm.max_spare_servers\" = 500;\n  # \"pm.max_spawn_rate\" = 50;\n  # \"pm.max_requests\" = 50;\n  # \"pm.process_idle_timeout\" = \"20s\";\n};\n```\n\nI don't have a good heuristic for what are good values here but what I found\nis that you don't want too high of a `max_children` value\nto avoid I/O strain on the hard drives, especially if you use spinning drives.\n\nTo see the effect of your settings,\ngo to the provided [Grafana dashboard](#services-nextcloudserver-dashboard).\n\n### Tweak PostgreSQL Settings {#services-nextcloudserver-usage-postgres}\n\nThese settings will impact all databases since the NixOS Postgres module\nconfigures only one Postgres instance.\n\nTo know what values to put here, use [https://pgtune.leopard.in.ua/](https://pgtune.leopard.in.ua/).\nRemember the server hosting PostgreSQL is shared at least with the Nextcloud service and probably others.\nSo to avoid PostgreSQL hogging all the resources, reduce the values you give on that website\nfor CPU, available memory, etc.\nFor example, I put 12 GB of memory and 4 CPUs while I had more:\n\n- `DB Version`: 14\n- `OS Type`: linux\n- `DB Type`: dw\n- `Total Memory (RAM)`: 12 GB\n- `CPUs num`: 4\n- `Data Storage`: ssd\n\nAnd got the following values:\n\n```nix\nshb.nextcloud.postgresSettings = {\n  max_connections = \"400\";\n  shared_buffers = \"3GB\";\n  effective_cache_size = \"9GB\";\n  maintenance_work_mem = \"768MB\";\n  checkpoint_completion_target = \"0.9\";\n  wal_buffers = \"16MB\";\n  default_statistics_target = \"100\";\n  random_page_cost = \"1.1\";\n  effective_io_concurrency = \"200\";\n  work_mem = \"7864kB\";\n  huge_pages = \"off\";\n  min_wal_size = \"1GB\";\n  max_wal_size = \"4GB\";\n  max_worker_processes = \"4\";\n  max_parallel_workers_per_gather = \"2\";\n  max_parallel_workers = \"4\";\n  max_parallel_maintenance_workers = \"2\";\n};\n```\n\nTo see the effect of your settings,\ngo to the provided [Grafana dashboard](#services-nextcloudserver-dashboard).\n\n### Backup {#services-nextcloudserver-usage-backup}\n\nBacking up Nextcloud data files using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"nextcloud\" = {\n  request = config.shb.nextcloud.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"nextcloud\"` in the `instances` can be anything.\nThe `config.shb.nextcloud.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Nextcloud multiple times.\n\nFor backing up the Nextcloud database using the same Restic block, do like so:\n\n```nix\nshb.restic.instances.\"postgres\" = {\n  request = config.shb.postgresql.databasebackup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nNote that this will backup the whole PostgreSQL instance,\nnot just the Nextcloud database.\nThis limitation will be lifted in the future.\n\n### Application Dashboard {#services-nextcloudserver-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-nextcloudserver-options-shb.nextcloud.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Documents.services.Nextcloud = {\n    sortOrder = 1;\n    dashboard.request = config.shb.nextcloud.dashboard.request;\n  };\n}\n```\n\n### Enable Preview Generator App {#services-nextcloudserver-usage-previewgenerator}\n\nThe following snippet installs and enables the [Preview\nGenerator](https://apps.nextcloud.com/apps/previewgenerator) application as well as creates the\nrequired cron job that generates previews every 10 minutes.\n\n```nix\nshb.nextcloud.apps.previewgenerator.enable = true;\n```\n\nNote that you still need to generate the previews for any pre-existing files with:\n\n```bash\nnextcloud-occ -vvv preview:generate-all\n```\n\nThe default settings generates all possible sizes which is a waste since most are not used. SHB will\nchange the generation settings to optimize disk space and CPU usage as outlined in [this\narticle](http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/).\nYou can opt-out with:\n\n```nix\nshb.nextcloud.apps.previewgenerator.recommendedSettings = false;\n```\n\n### Enable External Storage App {#services-nextcloudserver-usage-externalstorage}\n\nThe following snippet installs and enables the [External\nStorage](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) application.\n\n```nix\nshb.nextcloud.apps.externalStorage.enable = true;\n```\n\nAdding external storage can then be done through the UI.\nFor the special case of mounting a local folder as an external storage,\nSelf Host Blocks provides options.\nThe following snippet will mount the `/srv/nextcloud/$user` local file\nin each user's `/home` Nextcloud directory.\n\n```nix\nshb.nextcloud.apps.externalStorage.userLocalMount = {\n  rootDirectory = \"/srv/nextcloud/$user\";\n  mountName = \"home\";\n};\n```\n\nYou can even make the external storage mount in the root `/` Nextcloud directory with:\n\n```nix\nshb.nextcloud.apps.externalStorage.userLocalMount = {\n  mountName = \"/\";\n};\n```\n\nRecommended use of this app is to have the Nextcloud's `dataDir` on a SSD\nand the `userLocalMount` on a HDD.\nIndeed, a SSD is much quicker than a spinning hard drive,\nwhich is well suited for randomly accessing small files like thumbnails.\nOn the other side, a spinning hard drive can store more data\nwhich is well suited for storing user data.\n\nThis Nextcloud module includes a patch that allows the external storage\nto actually create the local path. Normally, when login in for the first time,\nthe user will be greeted with an error saying the external storage path does\nnot exist. One must then create it manually. With this patch, Nextcloud\ncreates the path.\n\n### Enable OnlyOffice App {#services-nextcloudserver-usage-onlyoffice}\n\nThe following snippet installs and enables the [Only\nOffice](https://apps.nextcloud.com/apps/onlyoffice) application as well as sets up an Only Office\ninstance listening at `onlyoffice.example.com` that only listens on the local network.\n\n```nix\nshb.nextcloud.apps.onlyoffice = {\n  enable = true;\n  subdomain = \"onlyoffice\";\n  localNextworkIPRange = \"192.168.1.1/24\";\n};\n```\n\nAlso, you will need to explicitly allow the package `corefonts`:\n\n```nix\nnixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [\n  \"corefonts\"\n];\n```\n\n### Enable Memories App {#services-nextcloudserver-usage-memories}\n\nThe following snippet installs and enables the\n[Memories](https://apps.nextcloud.com/apps/memories) application.\n\n```nix\nshb.nextcloud.apps.memories = {\n  enable = true;\n  vaapi = true;  # If hardware acceleration is supported.\n  photosPath = \"/Photos\";  # This is the default.\n};\n```\n\nAll the following dependencies are installed correctly\nand fully declaratively, the config page is \"all green\":\n\n- Exiftool with the correct version\n- Indexing path is set to `/Photos` by default.\n- Images, HEIC, videos preview generation.\n- Performance is all green with database triggers.\n- Recommended apps are\n   - Albums: this is installed by default.\n   - Recognize can be installed [here](#services-nextcloudserver-usage-recognize)\n   - Preview Generator can be installed [here](#services-nextcloudserver-usage-previewgenerator)\n- Reverse Geocoding must be triggered manually with `nextcloud-occ memories:places-setup `.\n- Video streaming is setup by installed ffmpeg headless.\n- Transcoder is setup natively (not with slow WASM) wit `go-vod` binary.\n- Hardware Acceleration is optionally setup by setting `vaapi` to `true`.\n\nIt is not required but you can for the first indexing with `nextcloud-occ memories:index`.\n\nNote that the app is not configurable through the UI since the config file is read-only.\n\n### Enable Recognize App {#services-nextcloudserver-usage-recognize}\n\nThe following snippet installs and enables the\n[Recognize](https://apps.nextcloud.com/apps/recognize) application.\n\n```nix\nshb.nextcloud.apps.recognize = {\n  enable = true;\n};\n```\n\nThe required dependencies are installed: `nodejs` and `nice`.\n\n### Enable Monitoring {#services-nextcloudserver-server-usage-monitoring}\n\nEnable the [monitoring block](./blocks-monitoring.html).\nA [Grafana dashboard][] for overall server performance will be created\nand the Nextcloud metrics will automatically appear there.\n\n[Grafana dashboard]: ./blocks-monitoring.html#blocks-monitoring-performance-dashboard\n\n### Enable Tracing {#services-nextcloudserver-server-usage-tracing}\n\nYou can enable tracing with:\n\n```nix\nshb.nextcloud.debug = true;\n```\n\nTraces will be located at `/var/log/xdebug`.\nSee [my blog post][] for how to look at the traces.\nI want to make the traces available in Grafana directly\nbut that's not the case yet.\n\n[my blog post]: http://blog.tiserbox.com/posts/2023-08-12-what%27s-up-with-nextcloud-webdav-slowness.html\n\n### Appdata Location {#services-nextcloudserver-server-usage-appdata}\n\nThe appdata folder is a special folder located under the `shb.nextcloud.dataDir` directory.\nIt is named `appdata_<instanceid>` with the Nextcloud's instance ID as a suffix.\nYou can find your current instance ID with `nextcloud-occ config:system:get instanceid`.\nIn there, you will find one subfolder for every installed app that needs to store files.\n\nFor performance reasons, it is recommended to store this folder on a fast drive\nthat is optimized for randomized read and write access.\nThe best would be either an SSD or an NVMe drive.\n\nThe best way to solve this is to use the [External Storage app](#services-nextcloudserver-usage-externalstorage).\n\nIf you have an existing installation and put Nextcloud's `shb.nextcloud.dataDir` folder on a HDD with spinning disks,\nthen the appdata folder is also located on spinning drives.\nOne way to solve this is to bind mount a folder from an SSD over the appdata folder.\nSHB does not provide a declarative way to setup this\nas the external storage app is the preferred way\nbut this command should be enough:\n\n```bash\nmount /dev/sdd /srv/sdd\nmkdir -p /srv/sdd/appdata_nextcloud\nmount --bind /srv/sdd/appdata_nextcloud /var/lib/nextcloud/data/appdata_ocxvky2f5ix7\n```\n\nNote that you can re-generate a new appdata folder\nby issuing the command `nextcloud-occ config:system:delete instanceid`.\n\n## Demo {#services-nextcloudserver-demo}\n\nHead over to the [Nextcloud demo](demo-nextcloud-server.html) for a demo that installs Nextcloud with or\nwithout LDAP integration on a VM with minimal manual steps.\n\n## Monitoring Dashboard {#services-nextcloudserver-dashboard}\n\nThe dashboard is added to Grafana automatically under \"Self Host Blocks > Nextcloud\"\nas long as the Nextcloud service is [enabled][]\nas well as the [monitoring block][].\n\n[enabled]: #services-nextcloudserver-options-shb.nextcloud.enable\n[monitoring block]: ./blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.enable\n\n- The *General* section shows Nextcloud related services.\n  This includes cronjobs, Redis and backup jobs.\n- *CPU* shows stall time which means CPU is maxed out.\n  This graph is inverted so having a small area at the top means the stall time is low.\n- *Memory* shows stall time which means some job is waiting on memory to be allocated.\n  This graph is inverted so having a small area at the top means the stall time is low.\n  Some stall time will always be present. Under 10% is fine\n  but having constantly over 50% usually means available memory is low and SWAP is being used.\n  *Memory* also shows available memory which is the remaining allocatable memory.\n- Caveat: *Network I/O* shows the network input and output for\n  all services running, not only those related to Nextcloud.\n- *Disk I/O* shows \"some\" stall time which means some jobs were waiting on disk I/O.\n  Disk is usually the slowest bottleneck so having \"some\" stall time is not surprising.\n  Fixing this can be done by using disks allowing higher speeds or switching to SSDs.\n  If the \"full\" stall time is shown, this means _all_ jobs were waiting on disk i/o which\n  can be more worrying. This could indicate a failing disk if \"full\" stall time appeared recently.\n  These graphs are inverted so having a small area at the top means the stall time is low.\n  *Memory* also shows available memory which is the remaining allocatable memory.\n![Nextcloud Dashboard First Part](./assets/dashboards_Nextcloud_1.png)\n- *PHP-FPM Processes* shows how many processes are used by PHP-FPM.\n  The orange area goes from 80% to 90% of the maximum allowed processes.\n  The read area goes from 90% to 100% of the maximum allowed processes.\n  If the number of active processes reaches those areas once in a while, that's fine\n  but if it happens most of the time, the maximum allowed processes should be increased.\n- *PHP-FPM Request Duration* shows one dot per request and how long it took.\n  Request time is fine if it is under 400ms.\n  If most requests take longer than that, some [tracing](#services-nextcloudserver-server-usage-tracing)\n  is required to understand which subsystem is taking some time.\n  That being said, maybe another graph in this dashboard will show \n  why the requests are slow - like disk\n  or other processes hoarding some resources running at the same time.\n- *PHP-FPM Requests Queue Length* shows how many requests are waiting\n  to be picked up by a PHP-FPM process. Usually, this graph won't show\n  anything as long as the *PHP-FPM Processes* graph is not in the red area.\n  Fixing this requires also increasing the maximum allowed processes.\n![Nextcloud Dashboard Second Part](./assets/dashboards_Nextcloud_2.png)\n- *Requests Details* shows all requests to the Nextcloud service and the related headers.\n- *5XX Requests Details* shows only the requests having a 500 to 599 http status.\n  Having any requests appearing here should be investigated as soon as possible.\n![Nextcloud Dashboard Third Part](./assets/dashboards_Nextcloud_3.png)\n- *Log: \\<service name\\>* shows all logs from related systemd `<service name>.service` job.\n  Having no line here most often means the job ran\n  at a time not currently included in the time range of the dashboard.\n![Nextcloud Dashboard Fourth Part](./assets/dashboards_Nextcloud_4.png)\n![Nextcloud Dashboard Fifth Part](./assets/dashboards_Nextcloud_5.png)\n- A lot of care has been taken to parse error messages correctly.\n  Nextcloud mixes json and non-json messages so extracting errors\n  from json messages was not that easy.\n  Also, the stacktrace is reduced.\n  The result though is IMO pretty nice as can be seen by the following screenshot.\n  The top line is the original json message and the bottom one is the parsed error.\n![Nextcloud Dashboard Error Parsing](./assets/dashboards_Nextcloud_error_parsing.png)\n- *Backup logs* show the output of the backup jobs.\n  Here, there are two backup jobs, one for the core files of Nextcloud\n  stored on an SSD which includes the appdata folder.\n  The other backup job is for the external data stored on HDDs which contain all user files.\n![Nextcloud Dashboard Sixth Part](./assets/dashboards_Nextcloud_6.png)\n- *Slow PostgreSQL queries* shows all database queries taking longer than 1s to run.\n- *Redis* shows all Redis log output.\n![Nextcloud Dashboard Seventh Part](./assets/dashboards_Nextcloud_7.png)\n\n## Debug {#services-nextcloudserver-debug}\n\nOn the command line, the `occ` tool is called `nextcloud-occ`.\n\nIn case of an issue, check the logs for any systemd service mentioned in this section.\n\nOn startup, the oneshot systemd service `nextcloud-setup.service` starts. After it finishes, the\n`phpfpm-nextcloud.service` starts to serve Nextcloud. The `nginx.service` is used as the reverse\nproxy. `postgresql.service` run the database.\n\nNextcloud' configuration is found at `${shb.nextcloud.dataDir}/config/config.php`. Nginx'\nconfiguration can be found with `systemctl cat nginx | grep -om 1 -e \"[^ ]\\+conf\"`.\n\nEnable verbose logging by setting the `shb.nextcloud.debug` boolean to `true`.\n\nAccess the database with `sudo -u nextcloud psql`.\n\nAccess Redis with `sudo -u nextcloud redis-cli -s /run/redis-nextcloud/redis.sock`.\n\n## Options Reference {#services-nextcloudserver-options}\n\n```{=include=} options\nid-prefix: services-nextcloudserver-options-\nlist-id: selfhostblocks-service-nextcloud-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/nextcloud-server.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.nextcloud;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n  fqdnWithPort = if isNull cfg.port then fqdn else \"${fqdn}:${toString cfg.port}\";\n  protocol = if !(isNull cfg.ssl) then \"https\" else \"http\";\n\n  ssoFqdnWithPort =\n    if isNull cfg.apps.sso.port then\n      cfg.apps.sso.endpoint\n    else\n      \"${cfg.apps.sso.endpoint}:${toString cfg.apps.sso.port}\";\n\n  nextcloudPkg = builtins.getAttr (\"nextcloud\" + builtins.toString cfg.version) pkgs;\n  nextcloudApps =\n    (builtins.getAttr (\"nextcloud\" + builtins.toString cfg.version + \"Packages\") pkgs).apps;\n\n  occ = \"${config.services.nextcloud.occ}/bin/nextcloud-occ\";\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/authelia.nix\n    ../blocks/monitoring.nix\n\n    (lib.mkRenamedOptionModule\n      [ \"shb\" \"nextcloud\" \"adminUser\" ]\n      [ \"shb\" \"nextcloud\" \"initialAdminUsername\" ]\n    )\n  ];\n\n  options.shb.nextcloud = {\n    enable = lib.mkEnableOption \"the SHB Nextcloud service\";\n\n    enableDashboard = lib.mkEnableOption \"the Nextcloud SHB dashboard\" // {\n      default = true;\n    };\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = ''\n        Subdomain under which Nextcloud will be served.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      example = \"nextcloud\";\n    };\n\n    domain = lib.mkOption {\n      description = ''\n        Domain under which Nextcloud is served.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      type = lib.types.str;\n      example = \"domain.com\";\n    };\n\n    port = lib.mkOption {\n      description = ''\n        Port under which Nextcloud will be served. If null is given, then the port is omitted.\n\n        ```\n        <subdomain>.<domain>[:<port>]\n        ```\n      '';\n      type = lib.types.nullOr lib.types.port;\n      default = null;\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    externalFqdn = lib.mkOption {\n      description = \"External fqdn used to access Nextcloud. Defaults to <subdomain>.<domain>. This should only be set if you include the port when accessing Nextcloud.\";\n      type = lib.types.nullOr lib.types.str;\n      example = \"nextcloud.domain.com:8080\";\n      default = null;\n    };\n\n    version = lib.mkOption {\n      description = \"Nextcloud version to choose from.\";\n      type = lib.types.enum [\n        32\n        33\n      ];\n      default = 32;\n    };\n\n    dataDir = lib.mkOption {\n      description = \"Folder where Nextcloud will store all its data.\";\n      type = lib.types.str;\n      default = \"/var/lib/nextcloud\";\n    };\n\n    mountPointServices = lib.mkOption {\n      description = \"If given, all the systemd services and timers will depend on the specified mount point systemd services.\";\n      type = lib.types.listOf lib.types.str;\n      default = [ ];\n      example = lib.literalExpression ''[\"var.mount\"]'';\n    };\n\n    initialAdminUsername = lib.mkOption {\n      type = lib.types.str;\n      description = \"Initial username of the admin user. Once it is set, it cannot be changed!\";\n      default = \"root\";\n    };\n\n    adminPass = lib.mkOption {\n      description = \"Nextcloud admin password.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = \"nextcloud\";\n          restartUnits = [ \"phpfpm-nextcloud.service\" ];\n        };\n      };\n    };\n\n    maxUploadSize = lib.mkOption {\n      default = \"4G\";\n      type = lib.types.str;\n      description = ''\n        The upload limit for files. This changes the relevant options\n        in php.ini and nginx if enabled.\n      '';\n    };\n\n    defaultPhoneRegion = lib.mkOption {\n      type = lib.types.str;\n      description = ''\n        Two letters region defining default region.\n      '';\n      example = \"US\";\n    };\n\n    postgresSettings = lib.mkOption {\n      type = lib.types.nullOr (lib.types.attrsOf lib.types.str);\n      default = null;\n      description = ''\n        Settings for the PostgreSQL database.\n\n        Go to https://pgtune.leopard.in.ua/ and copy the generated configuration here.\n      '';\n      example = lib.literalExpression ''\n        {\n          # From https://pgtune.leopard.in.ua/ with:\n\n          # DB Version: 14\n          # OS Type: linux\n          # DB Type: dw\n          # Total Memory (RAM): 7 GB\n          # CPUs num: 4\n          # Connections num: 100\n          # Data Storage: ssd\n\n          max_connections = \"100\";\n          shared_buffers = \"1792MB\";\n          effective_cache_size = \"5376MB\";\n          maintenance_work_mem = \"896MB\";\n          checkpoint_completion_target = \"0.9\";\n          wal_buffers = \"16MB\";\n          default_statistics_target = \"500\";\n          random_page_cost = \"1.1\";\n          effective_io_concurrency = \"200\";\n          work_mem = \"4587kB\";\n          huge_pages = \"off\";\n          min_wal_size = \"4GB\";\n          max_wal_size = \"16GB\";\n          max_worker_processes = \"4\";\n          max_parallel_workers_per_gather = \"2\";\n          max_parallel_workers = \"4\";\n          max_parallel_maintenance_workers = \"2\";\n        }\n      '';\n    };\n\n    phpFpmPoolSettings = lib.mkOption {\n      type = lib.types.nullOr (lib.types.attrsOf lib.types.anything);\n      description = \"Settings for PHPFPM.\";\n      default = {\n        \"pm\" = \"static\";\n        \"pm.max_children\" = 5;\n        \"pm.start_servers\" = 5;\n      };\n      example = lib.literalExpression ''\n        {\n          \"pm\" = \"dynamic\";\n          \"pm.max_children\" = 50;\n          \"pm.start_servers\" = 25;\n          \"pm.min_spare_servers\" = 10;\n          \"pm.max_spare_servers\" = 20;\n          \"pm.max_spawn_rate\" = 50;\n          \"pm.max_requests\" = 50;\n          \"pm.process_idle_timeout\" = \"20s\";\n        }\n      '';\n    };\n\n    phpFpmPrometheusExporter = lib.mkOption {\n      description = \"Settings for exporting\";\n      default = { };\n\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkOption {\n            description = \"Enable export of php-fpm metrics to Prometheus.\";\n            type = lib.types.bool;\n            default = true;\n          };\n\n          port = lib.mkOption {\n            description = \"Port on which the exporter will listen.\";\n            type = lib.types.port;\n            default = 8300;\n          };\n        };\n      };\n    };\n\n    apps = lib.mkOption {\n      description = ''\n        Applications to enable in Nextcloud. Enabling an application here will also configure\n        various services needed for this application.\n\n        Enabled apps will automatically be installed, enabled and configured, so no need to do that\n        through the UI. You can still make changes but they will be overridden on next deploy. You\n        can still install and configure other apps through the UI.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          onlyoffice = lib.mkOption {\n            description = ''\n              Only Office App. [Nextcloud App Store](https://apps.nextcloud.com/apps/onlyoffice)\n\n              Enabling this app will also start an OnlyOffice instance accessible at the given\n              subdomain from the given network range.\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"Nextcloud OnlyOffice App\";\n\n                subdomain = lib.mkOption {\n                  type = lib.types.str;\n                  description = \"Subdomain under which Only Office will be served.\";\n                  default = \"oo\";\n                };\n\n                ssl = lib.mkOption {\n                  description = \"Path to SSL files\";\n                  type = lib.types.nullOr shb.contracts.ssl.certs;\n                  default = null;\n                };\n\n                localNetworkIPRange = lib.mkOption {\n                  type = lib.types.str;\n                  description = \"Local network range, to restrict access to Open Office to only those IPs.\";\n                  default = \"192.168.1.1/24\";\n                };\n\n                jwtSecretFile = lib.mkOption {\n                  type = lib.types.nullOr lib.types.path;\n                  description = ''\n                    File containing the JWT secret. This option is required.\n\n                    Must be readable by the nextcloud system user.\n                  '';\n                  default = null;\n                };\n              };\n            };\n          };\n\n          previewgenerator = lib.mkOption {\n            description = ''\n              Preview Generator App. [Nextcloud App Store](https://apps.nextcloud.com/apps/previewgenerator)\n\n              Enabling this app will create a cron job running every minute to generate thumbnails\n              for new and updated files.\n\n              To generate thumbnails for already existing files, run:\n\n              ```\n              nextcloud-occ -vvv preview:generate-all\n              ```\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"Nextcloud Preview Generator App\";\n\n                recommendedSettings = lib.mkOption {\n                  type = lib.types.bool;\n                  description = ''\n                    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/).\n\n                    Sets the following options:\n\n                    ```\n                    nextcloud-occ config:app:set previewgenerator squareSizes --value=\"32 256\"\n                    nextcloud-occ config:app:set previewgenerator widthSizes  --value=\"256 384\"\n                    nextcloud-occ config:app:set previewgenerator heightSizes --value=\"256\"\n                    nextcloud-occ config:system:set preview_max_x --type integer --value 2048\n                    nextcloud-occ config:system:set preview_max_y --type integer --value 2048\n                    nextcloud-occ config:system:set jpeg_quality --value 60\n                    nextcloud-occ config:app:set preview jpeg_quality --value=60\n                    ```\n                  '';\n                  default = true;\n                  example = false;\n                };\n\n                debug = lib.mkOption {\n                  type = lib.types.bool;\n                  description = \"Enable more verbose logging.\";\n                  default = false;\n                  example = true;\n                };\n              };\n            };\n          };\n\n          externalStorage = lib.mkOption {\n            # TODO: would be nice to have quota include external storage but it's not supported for root:\n            # https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html#setting-storage-quotas\n            description = ''\n              External Storage App. [Manual](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage)\n\n              Set `userLocalMount` to automatically add a local directory as an external storage.\n              Use this option if you want to store user data in another folder or another hard drive\n              altogether.\n\n              In the `directory` option, you can use either `$user` and/or `$home` which will be\n              replaced by the user's name and home directory.\n\n              Recommended use of this option is to have the Nextcloud's `dataDir` on a SSD and the\n              `userLocalRooDirectory` on a HDD. Indeed, a SSD is much quicker than a spinning hard\n              drive, which is well suited for randomly accessing small files like thumbnails. On the\n              other side, a spinning hard drive can store more data which is well suited for storing\n              user data.\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"Nextcloud External Storage App\";\n                userLocalMount = lib.mkOption {\n                  default = null;\n                  description = \"If set, adds a local mount as external storage.\";\n                  type = lib.types.nullOr (\n                    lib.types.submodule {\n                      options = {\n                        directory = lib.mkOption {\n                          type = lib.types.str;\n                          description = ''\n                            Local directory on the filesystem to mount. Use `$user` and/or `$home`\n                            which will be replaced by the user's name and home directory.\n                          '';\n                          example = \"/srv/nextcloud/$user\";\n                        };\n\n                        mountName = lib.mkOption {\n                          type = lib.types.str;\n                          description = ''\n                            Path of the mount in Nextcloud. Use `/` to mount as the root.\n                          '';\n                          default = \"\";\n                          example = [\n                            \"home\"\n                            \"/\"\n                          ];\n                        };\n                      };\n                    }\n                  );\n                };\n              };\n            };\n          };\n\n          ldap = lib.mkOption {\n            description = ''\n              LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html)\n\n              Enabling this app will create a new LDAP configuration or update one that exists with\n              the given host.\n            '';\n            default = { };\n            type = lib.types.nullOr (\n              lib.types.submodule {\n                options = {\n                  enable = lib.mkEnableOption \"LDAP app.\";\n\n                  host = lib.mkOption {\n                    type = lib.types.str;\n                    description = ''\n                      Host serving the LDAP server.\n                    '';\n                    default = \"127.0.0.1\";\n                  };\n\n                  port = lib.mkOption {\n                    type = lib.types.port;\n                    description = ''\n                      Port of the service serving the LDAP server.\n                    '';\n                    default = 389;\n                  };\n\n                  dcdomain = lib.mkOption {\n                    type = lib.types.str;\n                    description = \"dc domain for ldap.\";\n                    example = \"dc=mydomain,dc=com\";\n                  };\n\n                  adminName = lib.mkOption {\n                    type = lib.types.str;\n                    description = \"Admin user of the LDAP server.\";\n                    default = \"admin\";\n                  };\n\n                  adminPassword = lib.mkOption {\n                    description = \"LDAP server admin password.\";\n                    type = lib.types.submodule {\n                      options = shb.contracts.secret.mkRequester {\n                        mode = \"0400\";\n                        owner = \"nextcloud\";\n                        restartUnits = [ \"phpfpm-nextcloud.service\" ];\n                      };\n                    };\n                  };\n\n                  userGroup = lib.mkOption {\n                    type = lib.types.str;\n                    description = \"Group users must belong to to be able to login to Nextcloud.\";\n                    default = \"nextcloud_user\";\n                  };\n\n                  configID = lib.mkOption {\n                    type = lib.types.int;\n                    description = ''\n                      Multiple LDAP configs can co-exist with only one active at a time.\n                      This option sets the config ID used by Self Host Blocks.\n                    '';\n                    default = 50;\n                  };\n                };\n              }\n            );\n          };\n\n          sso = lib.mkOption {\n            description = ''\n              SSO Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/oidc_auth.html)\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"SSO app.\";\n\n                endpoint = lib.mkOption {\n                  type = lib.types.str;\n                  description = \"OIDC endpoint for SSO.\";\n                  example = \"https://authelia.example.com\";\n                };\n\n                port = lib.mkOption {\n                  description = \"If given, adds a port to the endpoint.\";\n                  type = lib.types.nullOr lib.types.port;\n                  default = null;\n                };\n\n                provider = lib.mkOption {\n                  type = lib.types.enum [ \"Authelia\" ];\n                  description = \"OIDC provider name, used for display.\";\n                  default = \"Authelia\";\n                };\n\n                clientID = lib.mkOption {\n                  type = lib.types.str;\n                  description = \"Client ID for the OIDC endpoint.\";\n                  default = \"nextcloud\";\n                };\n\n                authorization_policy = lib.mkOption {\n                  type = lib.types.enum [\n                    \"one_factor\"\n                    \"two_factor\"\n                  ];\n                  description = \"Require one factor (password) or two factor (device) authentication.\";\n                  default = \"one_factor\";\n                };\n\n                adminGroup = lib.mkOption {\n                  type = lib.types.str;\n                  description = ''\n                    Group admins must belong to to be able to login to Nextcloud.\n\n                    This option is purposely not inside the LDAP app because only SSO allows\n                    distinguising between users and admins.\n                  '';\n                  default = \"nextcloud_admin\";\n                };\n\n                secret = lib.mkOption {\n                  description = \"OIDC shared secret.\";\n                  type = lib.types.submodule {\n                    options = shb.contracts.secret.mkRequester {\n                      mode = \"0400\";\n                      owner = \"nextcloud\";\n                      restartUnits = [ \"phpfpm-nextcloud.service\" ];\n                    };\n                  };\n                };\n\n                secretForAuthelia = lib.mkOption {\n                  description = \"OIDC shared secret. Content must be the same as `secretFile` option.\";\n                  type = lib.types.submodule {\n                    options = shb.contracts.secret.mkRequester {\n                      mode = \"0400\";\n                      owner = \"authelia\";\n                    };\n                  };\n                };\n\n                fallbackDefaultAuth = lib.mkOption {\n                  type = lib.types.bool;\n                  description = ''\n                    Fallback to normal Nextcloud auth if something goes wrong with the SSO app.\n                    Usually, you want to enable this to transfer existing users to LDAP and then you\n                    can disabled it.\n                  '';\n                  default = false;\n                };\n              };\n            };\n          };\n\n          memories = lib.mkOption {\n            description = ''\n              Memories App. [Nextcloud App Store](https://apps.nextcloud.com/apps/memories)\n\n              Enabling this app will set up the Memories app and configure all its dependencies.\n\n              On first install, you can either let the cron job index all images or you can run it manually with:\n\n              ```nix\n              nextcloud-occ memories:index\n              ```\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"Memories app.\";\n\n                vaapi = lib.mkOption {\n                  type = lib.types.bool;\n                  description = ''\n                    Enable VAAPI transcoding.\n\n                    Will make `nextcloud` user part of the `render` group to be able to access\n                    `/dev/dri/renderD128`.\n                  '';\n                  default = false;\n                };\n\n                photosPath = lib.mkOption {\n                  type = lib.types.str;\n                  description = ''\n                    Path where photos are stored in Nextcloud.\n                  '';\n                  default = \"/Photos\";\n                };\n              };\n            };\n          };\n\n          recognize = lib.mkOption {\n            description = ''\n              Recognize App. [Nextcloud App Store](https://apps.nextcloud.com/apps/recognize)\n\n              Enabling this app will set up the Recognize app and configure all its dependencies.\n            '';\n            default = { };\n            type = lib.types.submodule {\n              options = {\n                enable = lib.mkEnableOption \"Recognize app.\";\n              };\n            };\n          };\n        };\n      };\n    };\n\n    extraApps = lib.mkOption {\n      type = lib.types.raw;\n      description = ''\n        Extra apps to install.\n\n        Should be a function returning an `attrSet` of `appid` as keys to `packages` as values,\n        like generated by `fetchNextcloudApp`.\n        The appid must be identical to the `id` value in the apps'\n        `appinfo/info.xml`.\n        Search in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/master/pkgs/servers/nextcloud/packages) for the `NN.json` files for existing apps.\n\n        You can still install apps through the appstore.\n      '';\n      default = null;\n      example = lib.literalExpression ''\n        apps: {\n          inherit (apps) mail calendar contact;\n          phonetrack = pkgs.fetchNextcloudApp {\n            name = \"phonetrack\";\n            sha256 = \"0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc\";\n            url = \"https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz\";\n            version = \"0.6.9\";\n          };\n        }\n      '';\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"nextcloud\";\n          sourceDirectories = [\n            cfg.dataDir\n          ];\n          excludePatterns = [ \".rnd\" ];\n        };\n      };\n    };\n\n    debug = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Enable more verbose logging.\";\n      default = false;\n      example = true;\n    };\n\n    tracing = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = ''\n        Enable xdebug tracing.\n\n        To trigger writing a trace to `/var/log/xdebug`, add a the following header:\n\n        ```\n        XDEBUG_TRACE <shb.nextcloud.tracing value>\n        ```\n\n        The response will contain the following header:\n\n        ```\n        x-xdebug-profile-filename /var/log/xdebug/cachegrind.out.63484\n        ```\n      '';\n      default = null;\n      example = \"debug_me\";\n    };\n\n    autoDisableMaintenanceModeOnStart = lib.mkOption {\n      type = lib.types.bool;\n      default = true;\n      description = ''\n        Upon starting the service, disable maintenance mode if set.\n\n        This is useful if a deploy failed and you try to redeploy.\n\n        Note that even if the disabling of maintenance mode fails,\n        SHB will still allow the startup to continue\n        because there are valid reasons for maintenance mode\n        to not be able to be lifted, like for example this is a brand new installation.\n      '';\n    };\n\n    alwaysApplyExpensiveMigrations = lib.mkOption {\n      type = lib.types.bool;\n      default = true;\n      description = ''\n        Run `occ maintenance:repair --include-expensive` on service start.\n\n        Larger instances should disable this and run the command at a convenient time\n        but SHB assumes that it will not be the case for most users.\n\n        Note that SHB will still allow the startup\n        even if the repair failed.\n      '';\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${fqdn}\";\n          externalUrlText = \"https://\\${config.shb.nextcloud.subdomain}.\\${config.shb.nextcloud.domain}\";\n          internalUrl = \"https://${fqdn}\";\n          internalUrlText = \"https://\\${config.shb.nextcloud.subdomain}.\\${config.shb.nextcloud.domain}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkMerge [\n    (lib.mkIf cfg.enable {\n      users.users = {\n        nextcloud = {\n          name = \"nextcloud\";\n          group = \"nextcloud\";\n          isSystemUser = true;\n        };\n      };\n\n      # LDAP is manually configured through\n      # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md, see also\n      # https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html\n      #\n      # Verify setup with:\n      #  - On admin page\n      #  - https://scan.nextcloud.com/\n      #  - https://www.ssllabs.com/ssltest/\n      # As of writing this, we got no warning on admin page and A+ on both tests.\n      #\n      # Content-Security-Policy is hard. I spent so much trying to fix lingering issues with .js files\n      # not loading to realize those scripts are inserted by extensions. Doh.\n      services.nextcloud = {\n        enable = true;\n        package = nextcloudPkg.overrideAttrs (old: {\n          patches = [\n            ../../patches/nextcloudexternalstorage.patch\n          ];\n        });\n\n        datadir = cfg.dataDir;\n\n        hostName = fqdn;\n        nginx.hstsMaxAge = 31536000; # Needs > 1 year for https://hstspreload.org to be happy\n\n        inherit (cfg) maxUploadSize;\n\n        config = {\n          dbtype = \"pgsql\";\n          adminuser = cfg.initialAdminUsername;\n          adminpassFile = cfg.adminPass.result.path;\n        };\n        database.createLocally = true;\n\n        # Enable caching using redis https://nixos.wiki/wiki/Nextcloud#Caching.\n        configureRedis = true;\n        caching.apcu = false;\n        # https://docs.nextcloud.com/server/26/admin_manual/configuration_server/caching_configuration.html\n        caching.redis = true;\n\n        # Adds appropriate nginx rewrite rules.\n        webfinger = true;\n\n        # 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\n        https = !(isNull cfg.ssl);\n\n        extraApps = if isNull cfg.extraApps then { } else cfg.extraApps nextcloudApps;\n        extraAppsEnable = true;\n        appstoreEnable = true;\n\n        settings =\n          let\n            protocol = if !(isNull cfg.ssl) then \"https\" else \"http\";\n          in\n          {\n            \"default_phone_region\" = cfg.defaultPhoneRegion;\n\n            \"overwrite.cli.url\" = \"${protocol}://${fqdn}\";\n            \"overwritehost\" = fqdnWithPort;\n            # '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\n            # TODO: could instead set extraTrustedDomains\n            \"trusted_domains\" = [ fqdn ];\n            \"trusted_proxies\" = [ \"127.0.0.1\" ];\n            # TODO: could instead set overwriteProtocol\n            \"overwriteprotocol\" = protocol; # Needed if behind a reverse_proxy\n            \"overwritecondaddr\" = \"\"; # We need to set it to empty otherwise overwriteprotocol does not work.\n            \"debug\" = cfg.debug;\n            \"loglevel\" = if !cfg.debug then 2 else 0;\n            \"filelocking.debug\" = cfg.debug;\n\n            # Use persistent SQL connections.\n            \"dbpersistent\" = \"true\";\n\n            # https://help.nextcloud.com/t/very-slow-sync-for-small-files/11064/13\n            \"chunkSize\" = \"5120MB\";\n          };\n\n        phpOptions = {\n          # The OPcache interned strings buffer is nearly full with 8, bump to 16.\n          catch_workers_output = \"yes\";\n          display_errors = \"stderr\";\n          error_reporting = \"E_ALL & ~E_DEPRECATED & ~E_STRICT\";\n          expose_php = \"Off\";\n          \"opcache.enable_cli\" = \"1\";\n          \"opcache.fast_shutdown\" = \"1\";\n          \"opcache.interned_strings_buffer\" = \"16\";\n          \"opcache.max_accelerated_files\" = \"10000\";\n          \"opcache.memory_consumption\" = \"128\";\n          \"opcache.revalidate_freq\" = \"1\";\n          short_open_tag = \"Off\";\n\n          # https://docs.nextcloud.com/server/stable/admin_manual/configuration_files/big_file_upload_configuration.html#configuring-php\n          # > Output Buffering must be turned off [...] or PHP will return memory-related errors.\n          output_buffering = \"Off\";\n\n          # Needed to avoid corruption per https://docs.nextcloud.com/server/21/admin_manual/configuration_server/caching_configuration.html#id2\n          \"redis.session.locking_enabled\" = \"1\";\n          \"redis.session.lock_retries\" = \"-1\";\n          \"redis.session.lock_wait_time\" = \"10000\";\n        }\n        // lib.optionalAttrs (!(isNull cfg.tracing)) {\n          # \"xdebug.remote_enable\" = \"on\";\n          # \"xdebug.remote_host\" = \"127.0.0.1\";\n          # \"xdebug.remote_port\" = \"9000\";\n          # \"xdebug.remote_handler\" = \"dbgp\";\n          \"xdebug.trigger_value\" = cfg.tracing;\n\n          \"xdebug.mode\" = \"profile,trace\";\n          \"xdebug.output_dir\" = \"/var/log/xdebug\";\n          \"xdebug.start_with_request\" = \"trigger\";\n        };\n\n        poolSettings = lib.mkIf (!(isNull cfg.phpFpmPoolSettings)) cfg.phpFpmPoolSettings;\n\n        phpExtraExtensions = all: [ all.xdebug ];\n      };\n\n      services.nginx.virtualHosts.${fqdn} = {\n        # listen = [ { addr = \"0.0.0.0\"; port = 443; } ];\n        forceSSL = !(isNull cfg.ssl);\n        sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n        sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n        # From [1] this should fix downloading of big files. [2] seems to indicate that buffering\n        # happens at multiple places anyway, so disabling one place should be okay.\n        # [1]: https://help.nextcloud.com/t/download-aborts-after-time-or-large-file/25044/6\n        # [2]: https://stackoverflow.com/a/50891625/1013628\n        extraConfig = ''\n          proxy_buffering off;\n        '';\n      };\n\n      environment.systemPackages = [\n        # 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\n        pkgs.ffmpeg-headless\n      ];\n\n      services.postgresql.settings = lib.mkIf (!(isNull cfg.postgresSettings)) cfg.postgresSettings;\n\n      systemd.services.phpfpm-nextcloud.preStart = ''\n        mkdir -p /var/log/xdebug; chown -R nextcloud: /var/log/xdebug\n      '';\n      systemd.services.phpfpm-nextcloud.requires = cfg.mountPointServices;\n      systemd.services.phpfpm-nextcloud.after = cfg.mountPointServices;\n\n      systemd.timers.nextcloud-cron.requires = cfg.mountPointServices;\n      systemd.timers.nextcloud-cron.after = cfg.mountPointServices;\n      # This is needed to be able to run the cron job before opening the app for the first time.\n      # Otherwise the cron job fails while searching for this directory.\n      systemd.services.nextcloud-setup.script = ''\n        mkdir -p ${cfg.dataDir}/data/appdata_$(${occ} config:system:get instanceid)/theming/global\n      '';\n\n      systemd.services.nextcloud-setup.requires = cfg.mountPointServices;\n      systemd.services.nextcloud-setup.after = cfg.mountPointServices;\n    })\n\n    (lib.mkIf (cfg.enable && cfg.phpFpmPrometheusExporter.enable) {\n      services.prometheus.exporters.php-fpm = {\n        enable = true;\n        user = \"nginx\";\n        port = cfg.phpFpmPrometheusExporter.port;\n        listenAddress = \"127.0.0.1\";\n        extraFlags = [\n          \"--phpfpm.scrape-uri=tcp://127.0.0.1:${\n            toString (cfg.phpFpmPrometheusExporter.port - 1)\n          }/status?full\"\n        ];\n      };\n\n      services.nextcloud = {\n        poolSettings = {\n          \"pm.status_path\" = \"/status\";\n          # Need to use TCP connection to get status.\n          # I couldn't get PHP-FPM exporter to work with a unix socket.\n          #\n          # I also tried to server the status page at /status.php\n          # but fcgi doesn't like the returned headers.\n          \"pm.status_listen\" = \"127.0.0.1:${toString (cfg.phpFpmPrometheusExporter.port - 1)}\";\n        };\n      };\n\n      services.prometheus.scrapeConfigs = [\n        {\n          job_name = \"phpfpm-nextcloud\";\n          static_configs = [\n            {\n              targets = [ \"127.0.0.1:${toString cfg.phpFpmPrometheusExporter.port}\" ];\n              labels = {\n                \"hostname\" = config.networking.hostName;\n                \"domain\" = cfg.domain;\n              };\n            }\n          ];\n        }\n      ];\n    })\n\n    (lib.mkIf (cfg.enable && cfg.apps.onlyoffice.enable) {\n      assertions = [\n        {\n          assertion = !(isNull cfg.apps.onlyoffice.jwtSecretFile);\n          message = \"Must set shb.nextcloud.apps.onlyoffice.jwtSecretFile.\";\n        }\n      ];\n\n      services.nextcloud.extraApps = {\n        inherit (nextcloudApps) onlyoffice;\n      };\n\n      services.onlyoffice = {\n        enable = true;\n        hostname = \"${cfg.apps.onlyoffice.subdomain}.${cfg.domain}\";\n        port = 13444;\n\n        postgresHost = \"/run/postgresql\";\n\n        jwtSecretFile = cfg.apps.onlyoffice.jwtSecretFile;\n      };\n\n      services.nginx.virtualHosts.\"${cfg.apps.onlyoffice.subdomain}.${cfg.domain}\" = {\n        forceSSL = !(isNull cfg.ssl);\n        sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert;\n        sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key;\n\n        locations.\"/\" = {\n          extraConfig = ''\n            allow ${cfg.apps.onlyoffice.localNetworkIPRange};\n          '';\n        };\n      };\n    })\n\n    (lib.mkIf (cfg.enable && cfg.apps.previewgenerator.enable) {\n      services.nextcloud.extraApps = {\n        inherit (nextcloudApps) previewgenerator;\n      };\n\n      services.nextcloud.settings = {\n        # List obtained from the admin panel of Memories app.\n        enabledPreviewProviders = [\n          \"OC\\\\Preview\\\\BMP\"\n          \"OC\\\\Preview\\\\GIF\"\n          \"OC\\\\Preview\\\\HEIC\"\n          \"OC\\\\Preview\\\\Image\"\n          \"OC\\\\Preview\\\\JPEG\"\n          \"OC\\\\Preview\\\\Krita\"\n          \"OC\\\\Preview\\\\MarkDown\"\n          \"OC\\\\Preview\\\\Movie\"\n          \"OC\\\\Preview\\\\MP3\"\n          \"OC\\\\Preview\\\\OpenDocument\"\n          \"OC\\\\Preview\\\\PNG\"\n          \"OC\\\\Preview\\\\TXT\"\n          \"OC\\\\Preview\\\\XBitmap\"\n        ];\n\n      };\n\n      # Values taken from\n      # http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/\n      systemd.services.nextcloud-setup.script = lib.mkIf cfg.apps.previewgenerator.recommendedSettings ''\n        ${occ} config:app:set previewgenerator squareSizes --value=\"32 256\"\n        ${occ} config:app:set previewgenerator widthSizes  --value=\"256 384\"\n        ${occ} config:app:set previewgenerator heightSizes --value=\"256\"\n        ${occ} config:system:set preview_max_x --type integer --value 2048\n        ${occ} config:system:set preview_max_y --type integer --value 2048\n        ${occ} config:system:set jpeg_quality --value 60\n        ${occ} config:app:set preview jpeg_quality --value=60\n      '';\n\n      # Configured as defined in https://github.com/nextcloud/previewgenerator\n      systemd.timers.nextcloud-cron-previewgenerator = {\n        wantedBy = [ \"timers.target\" ];\n        requires = cfg.mountPointServices;\n        after = [ \"nextcloud-setup.service\" ] ++ cfg.mountPointServices;\n        timerConfig.OnBootSec = \"10m\";\n        timerConfig.OnUnitActiveSec = \"10m\";\n        timerConfig.Unit = \"nextcloud-cron-previewgenerator.service\";\n      };\n\n      systemd.services.nextcloud-cron-previewgenerator = {\n        environment.NEXTCLOUD_CONFIG_DIR = \"${config.services.nextcloud.datadir}/config\";\n        serviceConfig.Type = \"oneshot\";\n        serviceConfig.ExecStart =\n          let\n            debug = if cfg.debug or cfg.apps.previewgenerator.debug then \"-vvv\" else \"\";\n          in\n          \"${occ} ${debug} preview:pre-generate\";\n      };\n    })\n\n    (lib.mkIf (cfg.enable && cfg.apps.externalStorage.enable) {\n      systemd.services.nextcloud-setup.script = ''\n        ${occ} app:install files_external || :\n        ${occ} app:enable  files_external\n      ''\n      + lib.optionalString (cfg.apps.externalStorage.userLocalMount != null) (\n        let\n          cfg' = cfg.apps.externalStorage.userLocalMount;\n          jq = \"${pkgs.jq}/bin/jq\";\n        in\n        # sh\n        ''\n          exists=$(${occ} files_external:list --output=json | ${jq} 'any(.[]; .mount_point == \"${cfg'.mountName}\" and .configuration.datadir == \"${cfg'.directory}\")')\n          if [[ \"$exists\" == \"false\" ]]; then\n            ${occ} files_external:create \\\n                    '${cfg'.mountName}' \\\n                    local \\\n                    null::null \\\n                    --config datadir='${cfg'.directory}'\n          fi\n        ''\n      );\n    })\n\n    (lib.mkIf (cfg.enable && cfg.apps.ldap.enable) {\n      systemd.services.nextcloud-setup.path = [ pkgs.jq ];\n      systemd.services.nextcloud-setup.script =\n        let\n          cfg' = cfg.apps.ldap;\n          cID = \"s\" + toString cfg'.configID;\n        in\n        ''\n          ${occ} app:install user_ldap || :\n          ${occ} app:enable  user_ldap\n\n          ${occ} config:app:set user_ldap ${cID}ldap_configuration_active --value=0\n          ${occ} config:app:set user_ldap configuration_prefixes --value '[\"${cID}\"]'\n\n          # The following CLI commands follow\n          # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md#nextcloud-config--the-cli-way\n\n          ${occ} ldap:set-config \"${cID}\" 'ldapHost' \\\n                    '${cfg'.host}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapPort' \\\n                    '${toString cfg'.port}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapAgentName' \\\n                    'uid=${cfg'.adminName},ou=people,${cfg'.dcdomain}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapAgentPassword'  \\\n                    \"$(cat ${cfg'.adminPassword.result.path})\"\n          ${occ} ldap:set-config \"${cID}\" 'ldapBase' \\\n                    '${cfg'.dcdomain}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapBaseGroups' \\\n                    '${cfg'.dcdomain}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapBaseUsers' \\\n                    '${cfg'.dcdomain}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapEmailAttribute' \\\n                    'mail'\n          ${occ} ldap:set-config \"${cID}\" 'ldapGroupFilter' \\\n                    '(&(|(objectclass=groupOfUniqueNames))(|(cn=${cfg'.userGroup})))'\n          ${occ} ldap:set-config \"${cID}\" 'ldapGroupFilterGroups' \\\n                    '${cfg'.userGroup}'\n          ${occ} ldap:set-config \"${cID}\" 'ldapGroupFilterObjectclass' \\\n                    'groupOfUniqueNames'\n          ${occ} ldap:set-config \"${cID}\" 'ldapGroupMemberAssocAttr' \\\n                    'uniqueMember'\n          ${occ} ldap:set-config \"${cID}\" 'ldapLoginFilter' \\\n                    '(&(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))(|(uid=%uid)(|(mail=%uid)(objectclass=%uid))))'\n          ${occ} ldap:set-config \"${cID}\" 'ldapLoginFilterAttributes' \\\n                    'mail;objectclass'\n          ${occ} ldap:set-config \"${cID}\" 'ldapUserDisplayName' \\\n                    'givenname'\n          ${occ} ldap:set-config \"${cID}\" 'ldapUserFilter' \\\n                    '(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))'\n          ${occ} ldap:set-config \"${cID}\" 'ldapUserFilterMode' \\\n                    '1'\n          ${occ} ldap:set-config \"${cID}\" 'ldapUserFilterObjectclass' \\\n                    'person'\n          # Makes the user_id used when creating a user through LDAP which means the ID used in\n          # Nextcloud is compatible with the one returned by a (possibly added in the future) SSO\n          # provider.\n          ${occ} ldap:set-config \"${cID}\" 'ldapExpertUsernameAttr' \\\n                    'uid'\n\n          ${occ} ldap:test-config -- \"${cID}\"\n\n          # Only one active at the same time\n\n          ALL_CONFIG=\"$(${occ} ldap:show-config --output=json)\"\n          for configid in $(echo \"$ALL_CONFIG\" | jq --raw-output \"keys[]\"); do\n            echo \"Deactivating $configid\"\n            ${occ} ldap:set-config \"$configid\" 'ldapConfigurationActive' \\\n                      '0'\n          done\n\n          ${occ} ldap:set-config \"${cID}\" 'ldapConfigurationActive' \\\n                    '1'\n        '';\n    })\n\n    (\n      let\n        scopes = [\n          \"openid\"\n          \"profile\"\n          \"email\"\n          \"groups\"\n          \"nextcloud_userinfo\"\n        ];\n      in\n      lib.mkIf (cfg.enable && cfg.apps.sso.enable) {\n        assertions = [\n          {\n            assertion = cfg.ssl != null;\n            message = \"To integrate SSO, SSL must be enabled, set the shb.nextcloud.ssl option.\";\n          }\n        ];\n\n        services.nextcloud.extraApps = {\n          inherit (nextcloudApps) oidc_login;\n        };\n\n        systemd.services.nextcloud-setup-pre = {\n          wantedBy = [ \"multi-user.target\" ];\n          before = [ \"nextcloud-setup.service\" ];\n          serviceConfig.Type = \"oneshot\";\n          serviceConfig.User = \"nextcloud\";\n          script = ''\n            mkdir -p ${cfg.dataDir}/config\n            cat <<EOF > \"${cfg.dataDir}/config/secretFile\"\n            {\n              \"oidc_login_client_secret\": \"$(cat ${cfg.apps.sso.secret.result.path})\"\n            }\n            EOF\n          '';\n        };\n\n        services.nextcloud = {\n          secretFile = \"${cfg.dataDir}/config/secretFile\";\n\n          # See all options at https://github.com/pulsejet/nextcloud-oidc-login\n          # Other important url/links are:\n          #   ${fqdn}/.well-known/openid-configuration\n          #   https://www.authelia.com/reference/guides/attributes/#custom-attributes\n          #   https://github.com/lldap/lldap/blob/main/example_configs/nextcloud_oidc_authelia.md\n          #   https://www.authelia.com/integration/openid-connect/nextcloud/#authelia\n          #   https://www.openidconnect.net/\n          settings = {\n            allow_user_to_change_display_name = false;\n            lost_password_link = \"disabled\";\n            oidc_login_provider_url = ssoFqdnWithPort;\n            oidc_login_client_id = cfg.apps.sso.clientID;\n\n            # Automatically redirect the login page to the provider.\n            oidc_login_auto_redirect = !cfg.apps.sso.fallbackDefaultAuth;\n            # Authelia at least does not support this.\n            oidc_login_end_session_redirect = false;\n            # Redirect to this page after logging out the user\n            oidc_login_logout_url = ssoFqdnWithPort;\n            oidc_login_button_text = \"Log in with ${cfg.apps.sso.provider}\";\n            oidc_login_hide_password_form = false;\n            # Now, Authelia provides the info using the UserInfo request.\n            oidc_login_use_id_token = false;\n            oidc_login_attributes = {\n              id = \"preferred_username\";\n              name = \"name\";\n              mail = \"email\";\n              groups = \"groups\";\n              is_admin = \"is_nextcloud_admin\";\n            };\n            oidc_login_allowed_groups = [\n              cfg.apps.ldap.userGroup\n              cfg.apps.sso.adminGroup\n            ];\n            oidc_login_default_group = \"oidc\";\n            oidc_login_use_external_storage = false;\n            oidc_login_scope = lib.concatStringsSep \" \" scopes;\n            oidc_login_proxy_ldap = false;\n            # Enable creation of users new to Nextcloud from OIDC login. A user may be known to the\n            # IdP but not (yet) known to Nextcloud. This setting controls what to do in this case.\n            # * 'true' (default): if the user authenticates to the IdP but is not known to Nextcloud,\n            #     then they will be returned to the login screen and not allowed entry;\n            # * 'false': if the user authenticates but is not yet known to Nextcloud, then the user\n            #     will be automatically created; note that with this setting, you will be allowing (or\n            #     relying on) a third-party (the IdP) to create new users\n            oidc_login_disable_registration = false;\n            oidc_login_redir_fallback = cfg.apps.sso.fallbackDefaultAuth;\n            # oidc_login_alt_login_page = \"assets/login.php\";\n            oidc_login_tls_verify = true;\n            # If you get your groups from the oidc_login_attributes, you might want to create them if\n            # they are not already existing, Default is `false`. This creates groups for all groups\n            # the user is associated with in LDAP. It's too much.\n            oidc_create_groups = false;\n            oidc_login_webdav_enabled = false;\n            oidc_login_password_authentication = false;\n            oidc_login_public_key_caching_time = 86400;\n            oidc_login_min_time_between_jwks_requests = 10;\n            oidc_login_well_known_caching_time = 86400;\n            # If true, nextcloud will download user avatars on login. This may lead to security issues\n            # as the server does not control which URLs will be requested. Use with care.\n            oidc_login_update_avatar = false;\n            oidc_login_code_challenge_method = \"S256\";\n          };\n        };\n\n        shb.authelia.extraDefinitions = {\n          user_attributes.\"is_nextcloud_admin\".expression =\n            ''type(groups) == list && \"${cfg.apps.sso.adminGroup}\" in groups'';\n        };\n        shb.authelia.extraOidcClaimsPolicies.\"nextcloud_userinfo\" = {\n          custom_claims = {\n            is_nextcloud_admin = { };\n          };\n        };\n        shb.authelia.extraOidcScopes.\"nextcloud_userinfo\" = {\n          claims = [ \"is_nextcloud_admin\" ];\n        };\n\n        shb.authelia.oidcClients = lib.mkIf (cfg.apps.sso.provider == \"Authelia\") [\n          {\n            client_id = cfg.apps.sso.clientID;\n            client_name = \"Nextcloud\";\n            client_secret.source = cfg.apps.sso.secretForAuthelia.result.path;\n            claims_policy = \"nextcloud_userinfo\";\n            public = false;\n            authorization_policy = cfg.apps.sso.authorization_policy;\n            require_pkce = \"true\";\n            pkce_challenge_method = \"S256\";\n            redirect_uris = [ \"${protocol}://${fqdnWithPort}/apps/oidc_login/oidc\" ];\n            inherit scopes;\n            response_types = [ \"code\" ];\n            grant_types = [ \"authorization_code\" ];\n            access_token_signed_response_alg = \"none\";\n            userinfo_signed_response_alg = \"none\";\n            token_endpoint_auth_method = \"client_secret_basic\";\n          }\n        ];\n      }\n    )\n\n    (lib.mkIf (cfg.enable && cfg.autoDisableMaintenanceModeOnStart) {\n      systemd.services.nextcloud-setup.preStart = lib.mkBefore ''\n        if [[ -e /var/lib/nextcloud/config/config.php ]]; then\n            ${occ} maintenance:mode --no-interaction --quiet --off || true\n        fi\n      '';\n    })\n\n    (lib.mkIf (cfg.enable && cfg.alwaysApplyExpensiveMigrations) {\n      systemd.services.nextcloud-setup.script = ''\n        if [[ -e /var/lib/nextcloud/config/config.php ]]; then\n            ${occ} maintenance:repair --include-expensive || true\n        fi\n      '';\n    })\n\n    # Great source of inspiration:\n    # https://github.com/Shawn8901/nix-configuration/blob/538c18d9ecbf7c7e649b1540c0d40881bada6690/modules/nixos/private/nextcloud/memories.nix#L226\n    (lib.mkIf cfg.apps.memories.enable (\n      let\n        cfg' = cfg.apps.memories;\n\n        exiftool = pkgs.exiftool.overrideAttrs (\n          f: p: {\n            version = \"12.70\";\n            src = pkgs.fetchurl {\n              url = \"https://exiftool.org/Image-ExifTool-12.70.tar.gz\";\n              hash = \"sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4=\";\n            };\n          }\n        );\n      in\n      {\n        assertions = [\n          {\n            assertion = true;\n            message = \"Memories app has an issue for now, see https://github.com/ibizaman/selfhostblocks/issues/476.\";\n          }\n        ];\n\n        services.nextcloud.extraApps = {\n          inherit (nextcloudApps) memories;\n        };\n\n        systemd.services.nextcloud-cron = {\n          # required for memories\n          # see https://github.com/pulsejet/memories/blob/master/docs/troubleshooting.md#issues-with-nixos\n          path = [ pkgs.perl ];\n        };\n\n        services.nextcloud = {\n          # See all options at https://memories.gallery/system-config/\n          settings = {\n            \"memories.exiftool\" = \"${exiftool}/bin/exiftool\";\n            \"memories.exiftool_no_local\" = false;\n            \"memories.index.mode\" = \"3\";\n            \"memories.index.path\" = cfg'.photosPath;\n            \"memories.timeline.default_path\" = cfg'.photosPath;\n\n            \"memories.vod.disable\" = !cfg'.vaapi;\n            \"memories.vod.vaapi\" = cfg'.vaapi;\n            \"memories.vod.ffmpeg\" = \"${pkgs.ffmpeg-headless}/bin/ffmpeg\";\n            \"memories.vod.ffprobe\" = \"${pkgs.ffmpeg-headless}/bin/ffprobe\";\n            \"memories.vod.use_transpose\" = true;\n            \"memories.vod.use_transpose.force_sw\" = cfg'.vaapi; # AMD and old Intel can't use hardware here.\n\n            \"memories.db.triggers.fcu\" = true;\n            \"memories.readonly\" = true;\n            \"preview_ffmpeg_path\" = \"${pkgs.ffmpeg-headless}/bin/ffmpeg\";\n          };\n        };\n\n        systemd.services.phpfpm-nextcloud.serviceConfig = lib.mkIf cfg'.vaapi {\n          DeviceAllow = [ \"/dev/dri/renderD128 rwm\" ];\n          PrivateDevices = lib.mkForce false;\n        };\n      }\n    ))\n\n    (lib.mkIf cfg.apps.recognize.enable (\n      let\n        cfg' = cfg.apps.recognize;\n      in\n      {\n        services.nextcloud.extraApps = {\n          inherit (nextcloudApps) recognize;\n        };\n\n        systemd.services.nextcloud-setup.script = ''\n          ${occ} config:app:set recognize nice_binary --value ${pkgs.coreutils}/bin/nice\n          ${occ} config:app:set recognize node_binary --value ${pkgs.nodejs}/bin/node\n          ${occ} config:app:set recognize faces.enabled --value true\n          ${occ} config:app:set recognize faces.batchSize --value 50\n          ${occ} config:app:set recognize imagenet.enabled --value true\n          ${occ} config:app:set recognize imagenet.batchSize --value 100\n          ${occ} config:app:set recognize landmarks.batchSize --value 100\n          ${occ} config:app:set recognize landmarks.enabled --value true\n          ${occ} config:app:set recognize tensorflow.cores --value 1\n          ${occ} config:app:set recognize tensorflow.gpu --value false\n          ${occ} config:app:set recognize tensorflow.purejs --value false\n          ${occ} config:app:set recognize musicnn.enabled --value true\n          ${occ} config:app:set recognize musicnn.batchSize --value 100\n        '';\n      }\n    ))\n\n    (lib.mkIf (cfg.enable && cfg.enableDashboard) {\n      shb.monitoring.dashboards = [\n        ./nextcloud-server/dashboard/Nextcloud.json\n      ];\n    })\n  ];\n}\n"
  },
  {
    "path": "modules/services/open-webui/docs/default.md",
    "content": "# Open-WebUI Service {#services-open-webui}\n\nDefined in [`/modules/blocks/open-webui.nix`](@REPO@/modules/blocks/open-webui.nix),\nfound in the `selfhostblocks.nixosModules.open-webui` module.\nSee [the manual](usage.html#usage-flake) for how to import the module in your code.\n\nThis service sets up [Open WebUI][] which provides a frontend to various LLMs.\n\n[Open WebUI]: https://docs.openwebui.com/\n\n## Features {#services-open-webui-features}\n\n- Telemetry disabled.\n- Skip onboarding through custom patch.\n- Declarative [LDAP](#services-open-webui-options-shb.open-webui.ldap) Configuration.\n  - Needed LDAP groups are created automatically.\n- Declarative [SSO](#services-open-webui-options-shb.open-webui.sso) Configuration.\n  - When SSO is enabled, login with user and password is disabled.\n  - Registration is enabled through SSO.\n  - Correct error message for unauthorized user through custom patch.\n- Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy.\n- Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy.\n- [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html).\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-open-webui-usage-applicationdashboard)\n\n## Usage {#services-open-webui-usage}\n\n### Initial Configuration {#services-open-webui-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\n{\n  shb.open-webui = {\n    enable = true;\n    domain = \"example.com\";\n    subdomain = \"open-webui\";\n\n    ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n    sso = {\n      enable = true;\n      authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n      sharedSecret.result = config.shb.sops.secret.oidcSecret.result;\n      sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result;\n    };\n  };\n\n  shb.sops.secret.\"open-webui/oidcSecret\".request = config.shb.open-webui.sso.sharedSecret.request;\n  shb.sops.secret.\"open-webui/oidcAutheliaSecret\" = {\n    request = config.shb.open-webui.sso.sharedSecretForAuthelia.request;\n    settings.key = \"open-webui/oidcSecret\";\n  };\n}\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nThe [user](#services-open-webui-options-shb.open-webui.ldap.userGroup)\nand [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup)\nLDAP groups are created automatically.\n\n### Application Dashboard {#services-open-webui-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-open-webui-options-shb.open-webui.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Documents.services.OpenWebUI = {\n    sortOrder = 1;\n    dashboard.request = config.shb.home-assistant.dashboard.request;\n    settings.icon = \"sh-open-webui\";\n  };\n}\n```\n\nThe icon needs to be set manually otherwise it is not displayed correctly.\n\n## Integration with OLLAMA {#services-open-webui-ollama}\n\nAssuming ollama is enabled, it will be available on port `config.services.ollama.port`.\nThe following snippet sets up acceleration using an AMD (i)GPU and loads some models.\n\n```nix\n{\n  services.ollama = {\n    enable = true;\n\n    # https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver\n    acceleration = \"rocm\";\n\n    # https://ollama.com/library\n    loadModels = [\n      \"deepseek-r1:1.5b\"\n      \"llama3.2:3b\"\n      \"llava:7b\"\n      \"mxbai-embed-large:335m\"\n      \"nomic-embed-text:v1.5\"\n    ];\n  };\n}\n```\n\nIntegrating with the ollama service is done with:\n\n```nix\n{\n  shb.open-webui = {\n    environment.OLLAMA_BASE_URL = \"http://127.0.0.1:${toString config.services.ollama.port}\";\n  };\n}\n```\n\n## Backup {#services-open-webui-usage-backup}\n\nBacking up Open-Webui using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"open-webui\" = {\n  request = config.shb.open-webui.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"open-webui\"` in the `instances` can be anything.\nThe `config.shb.open-webui.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Open WebUI multiple times.\n\n## Options Reference {#services-open-webui-options}\n\n```{=include=} options\nid-prefix: services-open-webui-options-\nlist-id: selfhostblocks-services-open-webui-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/open-webui.nix",
    "content": "{\n  config,\n  lib,\n  pkgs,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.open-webui;\n\n  roleClaim = \"openwebui_groups\";\n  oauthScopes = [\n    \"openid\"\n    \"email\"\n    \"profile\"\n    \"groups\"\n    \"${roleClaim}\"\n  ];\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.open-webui = {\n    enable = lib.mkEnableOption \"the Open-WebUI service\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Open-WebUI will be served.\";\n      default = \"open-webui\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Open-WebUI will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    port = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port Open-WebUI listens to incoming requests.\";\n      default = 12444;\n    };\n\n    environment = lib.mkOption {\n      type = lib.types.attrsOf lib.types.str;\n      description = \"Extra environment variables. See https://docs.openwebui.com/getting-started/env-configuration\";\n      default = { };\n      example = ''\n        {\n          WEBUI_NAME = \"SelfHostBlocks\";\n\n          OLLAMA_BASE_URL = \"http://127.0.0.1:''${toString config.services.ollama.port}\";\n          RAG_EMBEDDING_MODEL = \"nomic-embed-text:v1.5\";\n\n          ENABLE_OPENAI_API = \"True\";\n          OPENAI_API_BASE_URL = \"http://127.0.0.1:''${toString config.services.llama-cpp.port}\";\n          ENABLE_WEB_SEARCH = \"True\";\n          RAG_EMBEDDING_ENGINE = \"openai\";\n        }\n      '';\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        Setup LDAP integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to be able to login.\";\n            default = \"open-webui_user\";\n          };\n\n          adminGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"Group users must belong to to have administrator privileges.\";\n            default = \"open-webui_admin\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"Endpoint to the SSO provider.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = lib.mkOption {\n            type = lib.types.str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"open-webui\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = lib.types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n\n          sharedSecret = lib.mkOption {\n            description = \"OIDC shared secret for Open-WebUI.\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                owner = \"open-webui\";\n                restartUnits = [ \"open-webui.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = lib.mkOption {\n            description = \"OIDC shared secret for Authelia. Must be the same as `sharedSecret`\";\n            type = lib.types.submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                ownerText = \"config.shb.authelia.autheliaUser\";\n                owner = config.shb.authelia.autheliaUser;\n              };\n            };\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup state directory.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"open-webui\";\n          sourceDirectories = [\n            config.services.open-webui.stateDir\n          ];\n          sourceDirectoriesText = \"[ config.services.open-webui.stateDir ]\";\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.open-webui.subdomain}.\\${config.shb.open-webui.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = (\n    lib.mkMerge [\n      (lib.mkIf cfg.enable {\n        users.users.open-webui = {\n          isSystemUser = true;\n          group = \"open-webui\";\n        };\n        users.groups.open-webui = { };\n\n        services.open-webui = {\n          enable = true;\n\n          host = \"127.0.0.1\";\n          inherit (cfg) port;\n\n          environment = {\n            WEBUI_URL = \"https://${cfg.subdomain}.${cfg.domain}\";\n\n            ENABLE_PERSISTENT_CONFIG = \"False\";\n\n            ANONYMIZED_TELEMETRY = \"False\";\n            DO_NOT_TRACK = \"True\";\n            SCARF_NO_ANALYTICS = \"True\";\n\n            ENABLE_VERSION_UPDATE_CHECK = \"False\";\n          }\n          // cfg.environment;\n        };\n\n        systemd.services.open-webui.path = [\n          pkgs.ffmpeg-headless\n        ];\n\n        shb.nginx.vhosts = [\n          {\n            inherit (cfg) subdomain domain ssl;\n            upstream = \"http://127.0.0.1:${toString cfg.port}/\";\n            extraConfig = ''\n              proxy_read_timeout 300s;\n              proxy_send_timeout 300s;\n            '';\n          }\n        ];\n      })\n      (lib.mkIf (cfg.enable && cfg.sso.enable) {\n        shb.lldap.ensureGroups = {\n          ${cfg.ldap.userGroup} = { };\n          ${cfg.ldap.adminGroup} = { };\n        };\n\n        services.open-webui = {\n          package = pkgs.open-webui.overrideAttrs (finalAttrs: {\n            patches = [\n              ../../patches/0001-selfhostblocks-never-onboard.patch\n            ];\n          });\n          environment = {\n            ENABLE_SIGNUP = \"False\";\n            WEBUI_AUTH = \"True\";\n            ENABLE_FORWARD_USER_INFO_HEADERS = \"True\";\n            ENABLE_OAUTH_SIGNUP = \"True\";\n            OAUTH_UPDATE_PICTURE_ON_LOGIN = \"True\";\n            OAUTH_CLIENT_ID = cfg.sso.clientID;\n            OPENID_PROVIDER_URL = \"${cfg.sso.authEndpoint}/.well-known/openid-configuration\";\n            OAUTH_PROVIDER_NAME = \"Single Sign-On\";\n            OAUTH_USERNAME_CLAIM = \"preferred_username\";\n            ENABLE_OAUTH_ROLE_MANAGEMENT = \"True\";\n            OAUTH_ALLOWED_ROLES = \"user,admin\";\n            OAUTH_ADMIN_ROLES = \"admin\";\n            OAUTH_ROLES_CLAIM = roleClaim;\n            OAUTH_SCOPES = lib.concatStringsSep \" \" oauthScopes;\n          };\n        };\n\n        shb.authelia.extraDefinitions = {\n          user_attributes.${roleClaim}.expression =\n            ''\"${cfg.ldap.adminGroup}\" in groups ? [\"admin\"] : (\"${cfg.ldap.userGroup}\" in groups ? [\"user\"] : [\"\"])'';\n        };\n        shb.authelia.extraOidcClaimsPolicies.${roleClaim} = {\n          custom_claims = {\n            \"${roleClaim}\" = { };\n          };\n        };\n        shb.authelia.extraOidcScopes.\"${roleClaim}\" = {\n          claims = [ \"${roleClaim}\" ];\n        };\n\n        shb.authelia.oidcClients = [\n          {\n            client_id = cfg.sso.clientID;\n            client_name = \"Open WebUI\";\n            client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n            claims_policy = \"${roleClaim}\";\n            public = false;\n            authorization_policy = cfg.sso.authorization_policy;\n            redirect_uris = [\n              \"https://${cfg.subdomain}.${cfg.domain}/oauth/oidc/callback\"\n            ];\n            scopes = oauthScopes;\n          }\n        ];\n\n        systemd.services.open-webui.serviceConfig.EnvironmentFile = \"/run/open-webui/secrets.env\";\n        systemd.tmpfiles.rules = [\n          \"d '/run/open-webui' 0750 root root - -\"\n        ];\n        systemd.services.open-webui-pre = {\n          script = shb.replaceSecrets {\n            userConfig = {\n              OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path;\n            };\n            resultPath = \"/run/open-webui/secrets.env\";\n            generator = shb.toEnvVar;\n          };\n          serviceConfig.Type = \"oneshot\";\n          wantedBy = [ \"multi-user.target\" ];\n          before = [ \"open-webui.service\" ];\n          requiredBy = [ \"open-webui.service\" ];\n        };\n      })\n    ]\n  );\n}\n"
  },
  {
    "path": "modules/services/paperless.nix",
    "content": "{\n  config,\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.paperless;\n  dataFolder = cfg.dataDir;\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n  protocol = if !(isNull cfg.ssl) then \"https\" else \"http\";\n  ssoFqdnWithPort =\n    if isNull cfg.sso.port then cfg.sso.endpoint else \"${cfg.sso.endpoint}:${toString cfg.sso.port}\";\n\n  ssoClientSettings = {\n    openid_connect = {\n      SCOPE = [\n        \"openid\"\n        \"profile\"\n        \"email\"\n        \"groups\"\n      ];\n      OAUTH_PKCE_ENABLED = true;\n      APPS = [\n        {\n          provider_id = \"${cfg.sso.provider}\";\n          name = \"${cfg.sso.provider}\";\n          client_id = \"${cfg.sso.clientID}\";\n          secret = \"%SECRET_CLIENT_SECRET_PLACEHOLDER%\";\n          settings = {\n            server_url = ssoFqdnWithPort;\n            token_auth_method = \"client_secret_basic\";\n          };\n        }\n      ];\n    };\n  };\n  ssoClientSettingsFile = pkgs.writeText \"paperless-sso-client.env\" ''\n    PAPERLESS_SOCIALACCOUNT_PROVIDERS=${builtins.toJSON ssoClientSettings}\n  '';\n  replacements = [\n    {\n      # Note: replaceSecretsScript prepends '%SECRET_' and appends '%'\n      # when doing the replacement\n      name = [ \"CLIENT_SECRET_PLACEHOLDER\" ];\n      source = cfg.sso.sharedSecret.result.path;\n    }\n  ];\n  replaceSecretsScript = shb.replaceSecretsScript {\n    file = ssoClientSettingsFile;\n    resultPath = \"/run/paperless/paperless-sso-client.env\";\n    inherit replacements;\n    user = \"paperless\";\n  };\n  inherit (lib)\n    mkEnableOption\n    mkIf\n    lists\n    mkOption\n    ;\n  inherit (lib.types)\n    attrsOf\n    bool\n    enum\n    listOf\n    nullOr\n    port\n    submodule\n    str\n    path\n    ;\n\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.paperless = {\n    enable = mkEnableOption \"selfhostblocks.paperless\";\n\n    subdomain = mkOption {\n      type = str;\n      description = ''\n        Subdomain under which paperless will be served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      example = \"photos\";\n    };\n\n    domain = mkOption {\n      description = ''\n        Domain under which paperless is served.\n\n        ```\n        <subdomain>.<domain>\n        ```\n      '';\n      type = str;\n      example = \"example.com\";\n    };\n\n    port = mkOption {\n      description = ''\n        Port under which paperless will listen.\n      '';\n      type = port;\n      default = 28981;\n    };\n\n    ssl = mkOption {\n      description = \"Path to SSL files\";\n      type = nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    dataDir = mkOption {\n      description = \"Directory where paperless will store data files.\";\n      type = str;\n      default = \"/var/lib/paperless\";\n    };\n\n    mediaDir = mkOption {\n      description = \"Directory where paperless will store documents.\";\n      type = str;\n      defaultText = lib.literalExpression ''\"''${dataDir}/media\"'';\n      default = \"${cfg.dataDir}/media\";\n    };\n\n    consumptionDir = mkOption {\n      description = \"Directory from which new documents are imported.\";\n      type = str;\n      defaultText = lib.literalExpression ''\"''${dataDir}/consume\"'';\n      default = \"${cfg.dataDir}/consume\";\n    };\n\n    configureTika = lib.mkOption {\n      type = lib.types.bool;\n      default = false;\n      description = ''\n        Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR.\n      '';\n    };\n\n    adminPassword = mkOption {\n      description = \"Secret containing the superuser (admin) password.\";\n      type = submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0400\";\n          owner = \"paperless\";\n          group = \"paperless\";\n          restartUnits = [ \"paperless-server.service\" ];\n        };\n      };\n    };\n\n    settings = lib.mkOption {\n      type = lib.types.submodule {\n        freeformType =\n          with lib.types;\n          attrsOf (\n            let\n              typeList = [\n                bool\n                float\n                int\n                str\n                path\n                package\n              ];\n            in\n            oneOf (\n              typeList\n              ++ [\n                (listOf (oneOf typeList))\n                (attrsOf (oneOf typeList))\n              ]\n            )\n          );\n      };\n      default = { };\n      description = ''\n        Extra paperless config options.\n\n        See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options.\n\n        Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values.\n        Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience.\n      '';\n      example = {\n        PAPERLESS_OCR_LANGUAGE = \"deu+eng\";\n        PAPERLESS_CONSUMER_IGNORE_PATTERN = [\n          \".DS_STORE/*\"\n          \"desktop.ini\"\n        ];\n        PAPERLESS_OCR_USER_ARGS = {\n          optimize = 1;\n          pdfa_image_compression = \"lossless\";\n        };\n      };\n    };\n\n    mount = mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"paperless\" = {\n          poolName = \"root\";\n        } // config.shb.paperless.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = dataFolder;\n      };\n    };\n\n    backup = mkOption {\n      description = ''\n        Backup configuration for paperless media files and database.\n      '';\n      default = { };\n      type = submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"paperless\";\n          sourceDirectories = [\n            dataFolder\n          ];\n          excludePatterns = [\n          ];\n        };\n      };\n    };\n\n    sso = mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = submodule {\n        options = {\n          enable = mkEnableOption \"SSO integration.\";\n\n          provider = mkOption {\n            type = enum [\n              \"Authelia\"\n              \"Keycloak\"\n              \"Generic\"\n            ];\n            description = \"OIDC provider name, used for display.\";\n            default = \"Authelia\";\n          };\n\n          endpoint = mkOption {\n            type = str;\n            description = \"OIDC endpoint for SSO.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          clientID = mkOption {\n            type = str;\n            description = \"Client ID for the OIDC endpoint.\";\n            default = \"paperless\";\n          };\n\n          adminUserGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC admin group\";\n            default = \"paperless_admin\";\n          };\n\n          userGroup = lib.mkOption {\n            type = lib.types.str;\n            description = \"OIDC user group\";\n            default = \"paperless_user\";\n          };\n\n          port = mkOption {\n            description = \"If given, adds a port to the endpoint.\";\n            type = nullOr port;\n            default = null;\n          };\n\n          autoRegister = mkOption {\n            type = bool;\n            description = \"Automatically register new users from SSO provider.\";\n            default = true;\n          };\n\n          autoLaunch = mkOption {\n            type = bool;\n            description = \"Automatically redirect to SSO provider.\";\n            default = true;\n          };\n\n          passwordLogin = mkOption {\n            type = bool;\n            description = \"Enable password login.\";\n            default = true;\n          };\n\n          sharedSecret = mkOption {\n            description = \"OIDC shared secret for paperless.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"paperless\";\n                group = \"paperless\";\n                restartUnits = [ \"paperless-server.service\" ];\n              };\n            };\n          };\n\n          sharedSecretForAuthelia = mkOption {\n            description = \"OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option.\";\n            type = submodule {\n              options = shb.contracts.secret.mkRequester {\n                mode = \"0400\";\n                owner = \"authelia\";\n              };\n            };\n            default = null;\n          };\n\n          authorization_policy = mkOption {\n            type = enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.paperless.subdomain}.\\${config.shb.paperless.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = mkIf cfg.enable {\n    assertions = [\n      {\n        assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key);\n        message = \"SSL is enabled for paperless but no cert or key is provided.\";\n      }\n      {\n        assertion = cfg.sso.enable -> cfg.ssl != null;\n        message = \"To integrate SSO, SSL must be enabled, set the shb.paperless.ssl option.\";\n      }\n    ];\n\n    # Configure paperless service\n    services.paperless = {\n      enable = true;\n      address = \"127.0.0.1\";\n      port = cfg.port;\n      consumptionDirIsPublic = true;\n      dataDir = cfg.dataDir;\n      mediaDir = cfg.mediaDir;\n      consumptionDir = cfg.consumptionDir;\n      configureTika = cfg.configureTika;\n      settings = {\n        PAPERLESS_URL = \"${protocol}://${fqdn}\";\n      }\n      // cfg.settings\n      // lib.optionalAttrs (cfg.sso.enable) {\n        PAPERLESS_APPS = \"allauth.socialaccount.providers.openid_connect\";\n        PAPERLESS_SOCIAL_AUTO_SIGNUP = cfg.sso.autoRegister;\n        PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS = true;\n        PAPERLESS_DISABLE_REGULAR_LOGIN = !cfg.sso.passwordLogin;\n      };\n    }\n    // lib.optionalAttrs (cfg.sso.enable) {\n      environmentFile = \"/run/paperless/paperless-sso-client.env\";\n    };\n\n    # Database defaults to local sqlite\n\n    systemd.tmpfiles.rules = [\n      \"d ${cfg.dataDir} 0700 paperless paperless\"\n      \"d ${cfg.consumptionDir} 0700 paperless paperless\"\n      \"d ${cfg.mediaDir} 0700 paperless paperless\"\n    ]\n    ++ lib.optionals cfg.sso.enable [ \"d '/run/paperless' 0750 root root - -\" ];\n\n    systemd.services.paperless-pre = lib.mkIf cfg.sso.enable {\n      script = replaceSecretsScript;\n      serviceConfig.Type = \"oneshot\";\n      wantedBy = [ \"multi-user.target\" ];\n      before = [ \"paperless-scheduler.service\" ];\n      requiredBy = [ \"paperless-scheduler.service\" ];\n    };\n\n    shb.nginx.vhosts = [\n      {\n        inherit (cfg) subdomain domain ssl;\n        upstream = \"http://127.0.0.1:${toString cfg.port}\";\n        autheliaRules = lib.mkIf (cfg.sso.enable) [\n          {\n            domain = fqdn;\n            policy = cfg.sso.authorization_policy;\n            subject = [\n              \"group:paperless_user\"\n              \"group:paperless_admin\"\n            ];\n          }\n        ];\n        authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint;\n        extraConfig = ''\n          # See https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx\n          proxy_redirect off;\n          proxy_set_header X-Forwarded-Host $server_name;\n          add_header Referrer-Policy \"strict-origin-when-cross-origin\";\n        '';\n      }\n    ];\n\n    # Allow large uploads\n    services.nginx.virtualHosts.\"${fqdn}\".extraConfig = ''\n      client_max_body_size 500M;\n    '';\n\n    shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == \"Authelia\") [\n      {\n        client_id = cfg.sso.clientID;\n        client_name = \"paperless\";\n        client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;\n        public = false;\n        authorization_policy = cfg.sso.authorization_policy;\n        token_endpoint_auth_method = \"client_secret_basic\";\n        redirect_uris = [\n          \"${protocol}://${fqdn}/accounts/oidc/${cfg.sso.provider}/login/callback/\"\n        ];\n      }\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/pinchflat/docs/default.md",
    "content": "# Pinchflat Service {#services-pinchflat}\n\nDefined in [`/modules/services/pinchflat.nix`](@REPO@/modules/services/pinchflat.nix).\n\nThis NixOS module is a service that sets up a [Pinchflat](https://github.com/kieraneglin/pinchflat) instance.\n\nCompared to the stock module from nixpkgs,\nthis one sets up, in a fully declarative manner,\nLDAP and SSO integration\nand has a nicer option for secrets.\n\n## Features {#services-pinchflat-features}\n\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-pinchflat-usage-applicationdashboard)\n\n## Usage {#services-pinchflat-usage}\n\n### Initial Configuration {#services-pinchflat-usage-configuration}\n\nThe following snippet assumes a few blocks have been setup already:\n\n- the [secrets block](usage.html#usage-secrets) with SOPS,\n- the [`shb.ssl` block](blocks-ssl.html#usage),\n- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).\n- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).\n\n```nix\nshb.pinchflat = {\n  enable = true;\n\n  secretKeyBase.result = config.shb.sops.secret.\"pinchflat/secretKeyBase\".result;\n  timeZone = \"Europe/Brussels\";\n  mediaDir = \"/srv/pinchflat\";\n\n  domain = \"example.com\";\n  subdomain = \"pinchflat\";\n  ssl = config.shb.certs.certs.letsencrypt.${domain};\n\n  ldap = {\n    enable = true;\n  };\n  sso = {\n    enable = true;\n    authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n  };\n};\nshb.sops.secret.\"pinchflat/secretKeyBase\".request = config.shb.pinchflat.secretKeyBase.request;\n```\n\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nThe [user](#services-pinchflat-options-shb.pinchflat.ldap.userGroup)\nLDAP group is created automatically.\n\n### Backup {#services-pinchflat-usage-backup}\n\nBacking up Pinchflat using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"pinchflat\" = {\n  request = config.shb.pinchflat.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"pinchflat\"` in the `instances` can be anything.\nThe `config.shb.pinchflat.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Pinchflat multiple times.\n\n### Application Dashboard {#services-pinchflat-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-pinchflat-options-shb.pinchflat.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Media.services.Pinchflat = {\n    sortOrder = 2;\n    dashboard.request = config.shb.pinchflat.dashboard.request;\n  };\n}\n```\n\n## Options Reference {#services-pinchflat-options}\n\n```{=include=} options\nid-prefix: services-pinchflat-options-\nlist-id: selfhostblocks-service-pinchflat-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/pinchflat.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\nlet\n  cfg = config.shb.pinchflat;\n\n  inherit (lib) types;\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.pinchflat = {\n    enable = lib.mkEnableOption \"the Pinchflat service.\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Pinchflat will be served.\";\n      default = \"pinchflat\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Pinchflat will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    port = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port Pinchflat listens to incoming requests.\";\n      default = 8945;\n    };\n\n    secretKeyBase = lib.mkOption {\n      description = ''\n        Used to sign/encrypt cookies and other secrets.\n\n        Make sure the secret is at least 64 characters long.\n      '';\n      type = types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          restartUnits = [ \"pinchflat.service\" ];\n        };\n      };\n    };\n\n    mediaDir = lib.mkOption {\n      description = \"Path where videos are stored.\";\n      type = lib.types.str;\n    };\n\n    timeZone = lib.mkOption {\n      type = lib.types.oneOf [\n        lib.types.str\n        shb.secretFileType\n      ];\n      description = \"Timezone of this instance.\";\n      example = \"America/Los_Angeles\";\n    };\n\n    ldap = lib.mkOption {\n      description = ''\n        Setup LDAP integration.\n      '';\n      default = { };\n      type = types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"LDAP integration.\" // {\n            default = cfg.sso.enable;\n          };\n\n          userGroup = lib.mkOption {\n            type = types.str;\n            description = \"Group users must belong to be able to login.\";\n            default = \"pinchflat_user\";\n          };\n        };\n      };\n    };\n\n    sso = lib.mkOption {\n      description = ''\n        Setup SSO integration.\n      '';\n      default = { };\n      type = types.submodule {\n        options = {\n          enable = lib.mkEnableOption \"SSO integration.\";\n\n          authEndpoint = lib.mkOption {\n            type = lib.types.str;\n            description = \"Endpoint to the SSO provider.\";\n            example = \"https://authelia.example.com\";\n          };\n\n          authorization_policy = lib.mkOption {\n            type = types.enum [\n              \"one_factor\"\n              \"two_factor\"\n            ];\n            description = \"Require one factor (password) or two factor (device) authentication.\";\n            default = \"one_factor\";\n          };\n        };\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup media directory `shb.mediaDir`.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"pinchflat\";\n          sourceDirectories = [\n            cfg.mediaDir\n          ];\n          sourceDirectoriesText = \"[ config.shb.pinchflat.mediaDir ]\";\n        };\n      };\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.pinchflat.subdomain}.\\${config.shb.pinchflat.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    systemd.tmpfiles.rules = [\n      \"d '/run/pinchflat' 0750 root root - -\"\n    ];\n\n    # Pinchflat relies on the global value so for now this is the only way to pass the option in.\n    time.timeZone = lib.mkDefault cfg.timeZone;\n    services.pinchflat = {\n      inherit (cfg) enable port mediaDir;\n      secretsFile = \"/run/pinchflat/secrets.env\";\n      extraConfig = {\n        ENABLE_PROMETHEUS = true;\n        # TZ = \"as\"; # I consider where you live to be sensible so it should be passed as a secret.\n      };\n    };\n\n    # This should be using a contract instead of setting the option directly.\n    shb.lldap = lib.mkIf config.shb.lldap.enable {\n      ensureGroups = {\n        ${cfg.ldap.userGroup} = { };\n      };\n    };\n\n    systemd.services.pinchflat-pre = {\n      script = shb.replaceSecrets {\n        userConfig = {\n          SECRET_KEY_BASE.source = cfg.secretKeyBase.result.path;\n          # TZ = cfg.secretKeyBase.result.path; # Uncomment when PR is merged.\n        };\n        resultPath = \"/run/pinchflat/secrets.env\";\n        generator = shb.toEnvVar;\n      };\n      serviceConfig.Type = \"oneshot\";\n      wantedBy = [ \"multi-user.target\" ];\n      before = [ \"pinchflat.service\" ];\n      requiredBy = [ \"pinchflat.service\" ];\n    };\n\n    shb.nginx.vhosts = [\n      (\n        {\n          inherit (cfg) subdomain domain ssl;\n\n          upstream = \"http://127.0.0.1:${toString cfg.port}\";\n          autheliaRules = lib.optionals (cfg.sso.enable) [\n            {\n              domain = \"${cfg.subdomain}.${cfg.domain}\";\n              policy = cfg.sso.authorization_policy;\n              subject = [ \"group:${cfg.ldap.userGroup}\" ];\n            }\n          ];\n        }\n        // lib.optionalAttrs cfg.sso.enable {\n          inherit (cfg.sso) authEndpoint;\n        }\n      )\n    ];\n\n    services.prometheus.scrapeConfigs = [\n      {\n        job_name = \"pinchflat\";\n        static_configs = [\n          {\n            targets = [ \"127.0.0.1:${toString cfg.port}\" ];\n            labels = {\n              \"hostname\" = config.networking.hostName;\n              \"domain\" = cfg.domain;\n            };\n          }\n        ];\n      }\n    ];\n  };\n}\n"
  },
  {
    "path": "modules/services/vaultwarden/docs/default.md",
    "content": "# Vaultwarden Service {#services-vaultwarden}\n\nDefined in [`/modules/services/vaultwarden.nix`](@REPO@/modules/services/vaultwarden.nix).\n\nThis NixOS module is a service that sets up a [Vaultwarden Server](https://github.com/dani-garcia/vaultwarden).\n\n## Features {#services-vaultwarden-features}\n\n- Access through subdomain using reverse proxy.\n- Access through HTTPS using reverse proxy.\n- Automatic setup of Redis database for caching.\n- Backup of the data directory through the [backup contract](./contracts-backup.html).\n- [Integration Tests](@REPO@/test/services/vaultwarden.nix)\n  - Tests /admin can only be accessed when authenticated with SSO.\n- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.\n\n## Usage {#services-vaultwarden-usage}\n\n### Initial Configuration {#services-vaultwarden-usage-configuration}\n\nThe following snippet enables Vaultwarden and makes it available under the `vaultwarden.example.com` endpoint.\n\n```nix\nshb.vaultwarden = {\n  enable = true;\n  domain = \"example.com\";\n  subdomain = \"vaultwarden\";\n\n  port = 8222;\n\n  databasePassword.result = config.shb.sops.secret.\"vaultwarden/db\".result;\n\n  smtp = {\n    host = \"smtp.eu.mailgun.org\";\n    port = 587;\n    username = \"postmaster@mg.${domain}\";\n    from_address = \"authelia@${domain}\";\n    passwordFile = config.sops.secrets.\"vaultwarden/smtp\".path;\n  };\n};\n\nshb.sops.secret.\"vaultwarden/db\".request = config.shb.vaultwarden.databasePassword.request;\nshb.sops.secret.\"vaultwarden/smtp\".request = config.shb.vaultwarden.smtp.password.request;\n```\n\nThis assumes secrets are setup with SOPS\nas mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.\nSecrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.\n\nThe SMTP configuration is needed to invite users to Vaultwarden.\n\n### HTTPS {#services-vaultwarden-usage-https}\n\nIf the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up),\nthe instance will be reachable at `https://vaultwarden.example.com`.\n\nHere is an example with Let's Encrypt certificates, validated using the HTTP method:\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\" = {\n  domain = \"example.com\";\n  group = \"nginx\";\n  reloadServices = [ \"nginx.service\" ];\n  adminEmail = \"myemail@mydomain.com\";\n};\n```\n\nThen you can tell Vaultwarden to use those certificates.\n\n```nix\nshb.certs.certs.letsencrypt.\"example.com\".extraDomains = [ \"vaultwarden.example.com\" ];\n\nshb.forgejo = {\n  ssl = config.shb.certs.certs.letsencrypt.\"example.com\";\n};\n```\n\n### SSO {#services-vaultwarden-usage-sso}\n\nTo protect the `/admin` endpoint and avoid needing a secret passphrase for it, we can use SSO.\n\nWe will use the [SSO block][] provided by Self Host Blocks.\nAssuming it [has been set already][SSO block setup], add the following configuration:\n\n[SSO block]: blocks-sso.html\n[SSO block setup]: blocks-sso.html#blocks-sso-global-setup\n\n```nix\nshb.vaultwarden.authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n```\n\nNow, go to the LDAP server at `https://ldap.example.com`,\ncreate the `vaultwarden_admin` group and add a user to that group.\nWhen that's done, go back to the Vaultwarden server at\n`https://vaultwarden.example.com/admin` and login with that user.\n\n### ZFS {#services-vaultwarden-zfs}\n\nIntegration with the ZFS block allows to automatically create the relevant datasets.\n\n```nix\nshb.zfs.datasets.\"vaultwarden\" = config.shb.vaultwarden.mount;\nshb.zfs.datasets.\"postgresql\".path = \"/var/lib/postgresql\";\n```\n\n### Backup {#services-vaultwarden-backup}\n\nBacking up Vaultwarden using the [Restic block](blocks-restic.html) is done like so:\n\n```nix\nshb.restic.instances.\"vaultwarden\" = {\n  request = config.shb.vaultwarden.backup;\n  settings = {\n    enable = true;\n  };\n};\n```\n\nThe name `\"vaultwarden\"` in the `instances` can be anything.\nThe `config.shb.vaultwarden.backup` option provides what directories to backup.\nYou can define any number of Restic instances to backup Vaultwarden multiple times.\n\n### Application Dashboard {#services-vaultwarden-usage-applicationdashboard}\n\nIntegration with the [dashboard contract](contracts-dashboard.html) is provided\nby the [dashboard option](#services-vaultwarden-options-shb.vaultwarden.dashboard).\n\nFor example using the [Homepage](services-homepage.html) service:\n\n```nix\n{\n  shb.homepage.servicesGroups.Documents.services.Vaultwarden = {\n    sortOrder = 10;\n    dashboard.request = config.shb.vaultwarden.dashboard.request;\n  };\n}\n```\n\n## Maintenance {#services-vaultwarden-maintenance}\n\nNo command-line tool is provided to administer Vaultwarden.\n\nInstead, the admin section can be found at the `/admin` endpoint.\n\n## Debug {#services-vaultwarden-debug}\n\nIn case of an issue, check the logs of the `vaultwarden.service` systemd service.\n\nEnable verbose logging by setting the `shb.vaultwarden.debug` boolean to `true`.\n\nAccess the database with `sudo -u vaultwarden psql`.\n\n## Options Reference {#services-vaultwarden-options}\n\n```{=include=} options\nid-prefix: services-vaultwarden-options-\nlist-id: selfhostblocks-vaultwarden-options\nsource: @OPTIONS_JSON@\n```\n"
  },
  {
    "path": "modules/services/vaultwarden.nix",
    "content": "{\n  config,\n  lib,\n  shb,\n  ...\n}:\n\nlet\n  cfg = config.shb.vaultwarden;\n\n  fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n\n  dataFolder =\n    if lib.versionOlder (config.system.stateVersion or \"24.11\") \"24.11\" then\n      \"/var/lib/bitwarden_rs\"\n    else\n      \"/var/lib/vaultwarden\";\nin\n{\n  imports = [\n    ../../lib/module.nix\n    ../blocks/nginx.nix\n  ];\n\n  options.shb.vaultwarden = {\n    enable = lib.mkEnableOption \"selfhostblocks.vaultwarden\";\n\n    subdomain = lib.mkOption {\n      type = lib.types.str;\n      description = \"Subdomain under which Authelia will be served.\";\n      example = \"ha\";\n    };\n\n    domain = lib.mkOption {\n      type = lib.types.str;\n      description = \"domain under which Authelia will be served.\";\n      example = \"mydomain.com\";\n    };\n\n    ssl = lib.mkOption {\n      description = \"Path to SSL files\";\n      type = lib.types.nullOr shb.contracts.ssl.certs;\n      default = null;\n    };\n\n    port = lib.mkOption {\n      type = lib.types.port;\n      description = \"Port on which vaultwarden service listens.\";\n      default = 8222;\n    };\n\n    authEndpoint = lib.mkOption {\n      type = lib.types.nullOr lib.types.str;\n      description = \"OIDC endpoint for SSO\";\n      default = null;\n      example = \"https://authelia.example.com\";\n    };\n\n    databasePassword = lib.mkOption {\n      description = \"File containing the Vaultwarden database password.\";\n      type = lib.types.submodule {\n        options = shb.contracts.secret.mkRequester {\n          mode = \"0440\";\n          owner = \"vaultwarden\";\n          group = \"postgres\";\n          restartUnits = [\n            \"vaultwarden.service\"\n            \"postgresql.service\"\n          ];\n        };\n      };\n    };\n\n    smtp = lib.mkOption {\n      description = \"SMTP options.\";\n      default = null;\n      type = lib.types.nullOr (\n        lib.types.submodule {\n          options = {\n            from_address = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP address from which the emails originate.\";\n              example = \"vaultwarden@mydomain.com\";\n            };\n            from_name = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP name from which the emails originate.\";\n              default = \"Vaultwarden\";\n            };\n            host = lib.mkOption {\n              type = lib.types.str;\n              description = \"SMTP host to send the emails to.\";\n            };\n            security = lib.mkOption {\n              type = lib.types.enum [\n                \"starttls\"\n                \"force_tls\"\n                \"off\"\n              ];\n              description = \"Security expected by SMTP host.\";\n              default = \"starttls\";\n            };\n            port = lib.mkOption {\n              type = lib.types.port;\n              description = \"SMTP port to send the emails to.\";\n              default = 25;\n            };\n            username = lib.mkOption {\n              type = lib.types.str;\n              description = \"Username to connect to the SMTP host.\";\n            };\n            auth_mechanism = lib.mkOption {\n              type = lib.types.enum [ \"Login\" ];\n              description = \"Auth mechanism.\";\n              default = \"Login\";\n            };\n            password = lib.mkOption {\n              description = \"File containing the password to connect to the SMTP host.\";\n              type = lib.types.submodule {\n                options = shb.contracts.secret.mkRequester {\n                  mode = \"0400\";\n                  owner = \"vaultwarden\";\n                  restartUnits = [ \"vaultwarden.service\" ];\n                };\n              };\n            };\n          };\n        }\n      );\n    };\n\n    mount = lib.mkOption {\n      type = shb.contracts.mount;\n      description = ''\n        Mount configuration. This is an output option.\n\n        Use it to initialize a block implementing the \"mount\" contract.\n        For example, with a zfs dataset:\n\n        ```\n        shb.zfs.datasets.\"vaultwarden\" = {\n          poolName = \"root\";\n        } // config.shb.vaultwarden.mount;\n        ```\n      '';\n      readOnly = true;\n      default = {\n        path = dataFolder;\n      };\n    };\n\n    backup = lib.mkOption {\n      description = ''\n        Backup configuration.\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.backup.mkRequester {\n          user = \"vaultwarden\";\n          sourceDirectories = [\n            dataFolder\n          ];\n        };\n      };\n    };\n\n    debug = lib.mkOption {\n      type = lib.types.bool;\n      description = \"Set to true to enable debug logging.\";\n      default = false;\n      example = true;\n    };\n\n    dashboard = lib.mkOption {\n      description = ''\n        Dashboard contract consumer\n      '';\n      default = { };\n      type = lib.types.submodule {\n        options = shb.contracts.dashboard.mkRequester {\n          externalUrl = \"https://${cfg.subdomain}.${cfg.domain}\";\n          externalUrlText = \"https://\\${config.shb.vaultwarden.subdomain}.\\${config.shb.vaultwarden.domain}\";\n          internalUrl = \"http://127.0.0.1:${toString cfg.port}\";\n        };\n      };\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    services.vaultwarden = {\n      enable = true;\n      dbBackend = \"postgresql\";\n      config = {\n        IP_HEADER = \"X-Real-IP\";\n        SIGNUPS_ALLOWED = false;\n        # Disabled because the /admin path is protected by SSO\n        DISABLE_ADMIN_TOKEN = true;\n        INVITATIONS_ALLOWED = true;\n        DOMAIN = \"https://${fqdn}\";\n        USE_SYSLOG = true;\n        EXTENDED_LOGGING = cfg.debug;\n        LOG_LEVEL = if cfg.debug then \"trace\" else \"info\";\n        ROCKET_LOG = if cfg.debug then \"trace\" else \"info\";\n        ROCKET_ADDRESS = \"127.0.0.1\";\n        ROCKET_PORT = cfg.port;\n      }\n      // lib.optionalAttrs (cfg.smtp != null) {\n        SMTP_FROM = cfg.smtp.from_address;\n        SMTP_FROM_NAME = cfg.smtp.from_name;\n        SMTP_HOST = cfg.smtp.host;\n        SMTP_SECURITY = cfg.smtp.security;\n        SMTP_USERNAME = cfg.smtp.username;\n        SMTP_PORT = cfg.smtp.port;\n        SMTP_AUTH_MECHANISM = cfg.smtp.auth_mechanism;\n      };\n      environmentFile = \"${dataFolder}/vaultwarden.env\";\n    };\n    # We create a blank environment file for the service to start. Then, ExecPreStart kicks in and\n    # fills out the environment file for ExecStart to pick it up.\n    systemd.tmpfiles.rules = [\n      \"d ${dataFolder} 0750 vaultwarden vaultwarden\"\n      \"f ${dataFolder}/vaultwarden.env 0640 vaultwarden vaultwarden\"\n    ];\n    # Needed to be able to write template config.\n    systemd.services.vaultwarden.serviceConfig.ProtectHome = lib.mkForce false;\n    systemd.services.vaultwarden.preStart = shb.replaceSecrets {\n      userConfig = {\n        DATABASE_URL.source = cfg.databasePassword.result.path;\n        DATABASE_URL.transform = v: \"postgresql://vaultwarden:${v}@127.0.0.1:5432/vaultwarden\";\n      }\n      // lib.optionalAttrs (cfg.smtp != null) {\n        SMTP_PASSWORD.source = cfg.smtp.password.result.path;\n      };\n      resultPath = \"${dataFolder}/vaultwarden.env\";\n      generator = shb.toEnvVar;\n    };\n\n    shb.nginx.vhosts = [\n      {\n        inherit (cfg)\n          subdomain\n          domain\n          authEndpoint\n          ssl\n          ;\n        upstream = \"http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}\";\n        autheliaRules = lib.mkIf (cfg.authEndpoint != null) [\n          {\n            domain = \"${fqdn}\";\n            policy = \"two_factor\";\n            subject = [ \"group:vaultwarden_admin\" ];\n            resources = [\n              \"^/admin\"\n            ];\n          }\n          # There's no way to protect the webapp using Authelia this way, see\n          # https://github.com/dani-garcia/vaultwarden/discussions/3188\n          {\n            domain = fqdn;\n            policy = \"bypass\";\n          }\n        ];\n      }\n    ];\n\n    shb.postgresql.enableTCPIP = true;\n    shb.postgresql.ensures = [\n      {\n        username = \"vaultwarden\";\n        database = \"vaultwarden\";\n        passwordFile = cfg.databasePassword.result.path;\n      }\n    ];\n    # TODO: make this work.\n    # It does not work because it leads to infinite recursion.\n    # ${cfg.mount}.path = dataFolder;\n  };\n}\n"
  },
  {
    "path": "patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch",
    "content": "From dda895b551c7cf56476ac8892904e289a4d8b920 Mon Sep 17 00:00:00 2001\nFrom: ibizaman <ibizaman@tiserbox.com>\nDate: Sat, 1 Nov 2025 13:49:20 +0100\nSubject: [PATCH] nixos/borgbackup: add option to override state directory\n\n---\n nixos/modules/services/backup/borgbackup.nix | 23 +++++++++++++++-----\n 1 file changed, 18 insertions(+), 5 deletions(-)\n\ndiff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix\nindex adabb2ce0f8b..82baeb928398 100644\n--- a/nixos/modules/services/backup/borgbackup.nix\n+++ b/nixos/modules/services/backup/borgbackup.nix\n@@ -136,7 +136,7 @@ let\n   mkBackupService =\n     name: cfg:\n     let\n-      userHome = config.users.users.${cfg.user}.home;\n+      userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home;\n       backupJobName = \"borgbackup-job-${name}\";\n       backupScript = mkBackupScript backupJobName cfg;\n     in\n@@ -177,6 +177,7 @@ let\n       environment = {\n         BORG_REPO = cfg.repo;\n       }\n+      // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; })\n       // (mkPassEnv cfg)\n       // cfg.environment;\n     };\n@@ -223,6 +224,7 @@ let\n       set = {\n         BORG_REPO = cfg.repo;\n       }\n+      // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; })\n       // (mkPassEnv cfg)\n       // cfg.environment;\n     });\n@@ -232,14 +234,15 @@ let\n     name: cfg:\n     let\n       settings = { inherit (cfg) user group; };\n+      userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home;\n     in\n     lib.nameValuePair \"borgbackup-job-${name}\" (\n       {\n         # Create parent dirs separately, to ensure correct ownership.\n-        \"${config.users.users.\"${cfg.user}\".home}/.config\".d = settings;\n-        \"${config.users.users.\"${cfg.user}\".home}/.cache\".d = settings;\n-        \"${config.users.users.\"${cfg.user}\".home}/.config/borg\".d = settings;\n-        \"${config.users.users.\"${cfg.user}\".home}/.cache/borg\".d = settings;\n+        \"${userHome}/.config\".d = settings;\n+        \"${userHome}/.cache\".d = settings;\n+        \"${userHome}/.config/borg\".d = settings;\n+        \"${userHome}/.cache/borg\".d = settings;\n       }\n       // lib.optionalAttrs (isLocalPath cfg.repo && !cfg.removableDevice) {\n         \"${cfg.repo}\".d = settings;\n@@ -487,6 +490,16 @@ in\n               default = \"root\";\n             };\n \n+            stateDir = lib.mkOption {\n+              type = lib.types.nullOr lib.types.str;\n+              description = ''\n+                Override the directory in which {command}`borg` stores its\n+                configuration and cache. By default it uses the user's\n+                home directory but is some cases this can cause conflicts.\n+              '';\n+              default = null;\n+            };\n+\n             wrapper = lib.mkOption {\n               type = with lib.types; nullOr str;\n               description = ''\n-- \n2.50.1\n\n"
  },
  {
    "path": "patches/0001-selfhostblocks-never-onboard.patch",
    "content": "From 6897dd86a41b336c7c03a466990f7e981c5c649c Mon Sep 17 00:00:00 2001\nFrom: ibizaman <ibizaman@tiserbox.com>\nDate: Tue, 23 Sep 2025 11:36:24 +0200\nSubject: [PATCH] selfhostblocks: never onboard\n\n---\n backend/open_webui/main.py | 4 +---\n 1 file changed, 1 insertion(+), 3 deletions(-)\n\ndiff --git a/backend/open_webui/main.py b/backend/open_webui/main.py\nindex 5630a5883..5c7c88a64 100644\n--- a/backend/open_webui/main.py\n+++ b/backend/open_webui/main.py\n@@ -1654,11 +1654,9 @@ async def get_app_config(request: Request):\n             user = Users.get_user_by_id(data[\"id\"])\n \n     user_count = Users.get_num_users()\n+    # Never onboard\n     onboarding = False\n \n-    if user is None:\n-        onboarding = user_count == 0\n-\n     return {\n         **({\"onboarding\": True} if onboarding else {}),\n         \"status\": True,\n-- \n2.50.1\n\n"
  },
  {
    "path": "patches/lldap.patch",
    "content": "From e5a1bf4cb019933621eb059cc6cdd1f8af8df71d Mon Sep 17 00:00:00 2001\nFrom: ibizaman <ibizaman@tiserbox.com>\nDate: Wed, 13 Aug 2025 08:14:38 +0200\nSubject: [PATCH 1/2] lldap-bootstrap: init 0.6.2\n\n---\n pkgs/by-name/ll/lldap-bootstrap/package.nix | 57 +++++++++++++++++++++\n 1 file changed, 57 insertions(+)\n create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix\n\ndiff --git a/pkgs/by-name/ll/lldap-bootstrap/package.nix b/pkgs/by-name/ll/lldap-bootstrap/package.nix\nnew file mode 100644\nindex 00000000000000..8b9a915b18f4d9\n--- /dev/null\n+++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix\n@@ -0,0 +1,57 @@\n+{\n+  curl,\n+  fetchFromGitHub,\n+  jq,\n+  jo,\n+  lib,\n+  lldap,\n+  lldap-bootstrap,\n+  makeWrapper,\n+  stdenv,\n+}:\n+let\n+  version = \"0.6.2\";\n+in\n+stdenv.mkDerivation {\n+  pname = \"lldap-bootstrap\";\n+  inherit version;\n+\n+  src = fetchFromGitHub {\n+    owner = \"lldap\";\n+    repo = \"lldap\";\n+    rev = \"v${version}\";\n+    hash = \"sha256-UBQWOrHika8X24tYdFfY8ETPh9zvI7/HV5j4aK8Uq+Y=\";\n+  };\n+\n+  dontBuild = true;\n+\n+  nativeBuildInputs = [ makeWrapper ];\n+\n+  installPhase = ''\n+    mkdir -p $out/bin\n+    cp ./scripts/bootstrap.sh $out/bin/lldap-bootstrap\n+\n+    wrapProgram $out/bin/lldap-bootstrap \\\n+      --set LLDAP_SET_PASSWORD_PATH ${lldap}/bin/lldap_set_password \\\n+      --prefix PATH : ${\n+        lib.makeBinPath [\n+          curl\n+          jq\n+          jo\n+        ]\n+      }\n+  '';\n+\n+  meta = {\n+    description = \"Bootstrap script for LLDAP\";\n+    homepage = \"https://github.com/lldap/lldap\";\n+    changelog = \"https://github.com/lldap/lldap/blob/v${lldap-bootstrap.version}/CHANGELOG.md\";\n+    license = lib.licenses.gpl3Only;\n+    platforms = lib.platforms.linux;\n+    maintainers = with lib.maintainers; [\n+      bendlas\n+      ibizaman\n+    ];\n+    mainProgram = \"lldap-bootstrap\";\n+  };\n+}\n\n\nFrom 6666c710b77e53ea274af4c4dddcb9251b0ccf18 Mon Sep 17 00:00:00 2001\nFrom: ibizaman <ibizaman@tiserbox.com>\nDate: Wed, 13 Aug 2025 08:15:12 +0200\nSubject: [PATCH 2/2] lldap: add ensure options\n\n---\n nixos/modules/services/databases/lldap.nix | 372 ++++++++++++++++++++-\n nixos/tests/lldap.nix                      | 143 +++++++-\n 2 files changed, 498 insertions(+), 17 deletions(-)\n\ndiff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix\nindex fe956c943281..6097f8d06216 100644\n--- a/nixos/modules/services/databases/lldap.nix\n+++ b/nixos/modules/services/databases/lldap.nix\n@@ -12,6 +12,84 @@ let\n   dbUser = \"lldap\";\n   localPostgresql = cfg.database.createLocally && cfg.database.type == \"postgresql\";\n   localMysql = cfg.database.createLocally && cfg.database.type == \"mariadb\";\n+\n+  inherit (lib) mkOption types;\n+\n+  ensureFormat = pkgs.formats.json { };\n+  ensureGenerate =\n+    let\n+      filterNulls = lib.filterAttrsRecursive (n: v: v != null);\n+\n+      filteredSource =\n+        source: if builtins.isList source then map filterNulls source else filterNulls source;\n+    in\n+    name: source: ensureFormat.generate name (filteredSource source);\n+\n+  ensureFieldsOptions = name: {\n+    name = mkOption {\n+      type = types.str;\n+      description = \"Name of the field.\";\n+      default = name;\n+    };\n+\n+    attributeType = mkOption {\n+      type = types.enum [\n+        \"STRING\"\n+        \"INTEGER\"\n+        \"JPEG\"\n+        \"DATE_TIME\"\n+      ];\n+      description = \"Attribute type.\";\n+    };\n+\n+    isEditable = mkOption {\n+      type = types.bool;\n+      description = \"Is field editable.\";\n+      default = true;\n+    };\n+\n+    isList = mkOption {\n+      type = types.bool;\n+      description = \"Is field a list.\";\n+      default = false;\n+    };\n+\n+    isVisible = mkOption {\n+      type = types.bool;\n+      description = \"Is field visible in UI.\";\n+      default = true;\n+    };\n+  };\n+\n+  allUserGroups = lib.flatten (lib.mapAttrsToList (n: u: u.groups) cfg.ensureUsers);\n+  # The three hardcoded groups are always created when the service starts.\n+  allGroups = lib.mapAttrsToList (n: g: g.name) cfg.ensureGroups ++ [\n+    \"lldap_admin\"\n+    \"lldap_password_manager\"\n+    \"lldap_strict_readonly\"\n+  ];\n+  userGroupNotInEnsuredGroup = lib.sortOn lib.id (\n+    lib.unique (lib.subtractLists allGroups allUserGroups)\n+  );\n+  someUsersBelongToNonEnsuredGroup = (lib.lists.length userGroupNotInEnsuredGroup) > 0;\n+\n+  generateEnsureConfigDir =\n+    name: source:\n+    let\n+      genOne =\n+        name: sourceOne:\n+        pkgs.writeTextDir \"configs/${name}.json\" (\n+          builtins.readFile (ensureGenerate \"configs/${name}.json\" sourceOne)\n+        );\n+    in\n+    \"${\n+      pkgs.symlinkJoin {\n+        inherit name;\n+        paths = lib.mapAttrsToList genOne source;\n+      }\n+    }/configs\";\n+\n+  quoteVariable = x: \"\\\"${x}\\\"\";\n in\n {\n   options.services.lldap = with lib; {\n@@ -19,6 +97,8 @@ in\n \n     package = mkPackageOption pkgs \"lldap\" { };\n \n+    bootstrap-package = mkPackageOption pkgs \"lldap-bootstrap\" { };\n+\n     environment = mkOption {\n       type = with types; attrsOf str;\n       default = { };\n@@ -203,6 +283,198 @@ in\n         If that is okay for you and you want to silence the warning, set this option to `true`.\n       '';\n     };\n+\n+    ensureUsers = mkOption {\n+      description = ''\n+        Create the users defined here on service startup.\n+\n+        If `enforceEnsure` option is `true`, the groups\n+        users belong to must be present in the `ensureGroups` option.\n+\n+        Non-default options must be added to the `ensureGroupFields` option.\n+      '';\n+      default = { };\n+      type = types.attrsOf (\n+        types.submodule (\n+          { name, ... }:\n+          {\n+            freeformType = ensureFormat.type;\n+\n+            options = {\n+              id = mkOption {\n+                type = types.str;\n+                description = \"Username.\";\n+                default = name;\n+              };\n+\n+              email = mkOption {\n+                type = types.str;\n+                description = \"Email.\";\n+              };\n+\n+              password_file = mkOption {\n+                type = types.str;\n+                description = \"File containing the password.\";\n+              };\n+\n+              displayName = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Display name.\";\n+              };\n+\n+              firstName = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"First name.\";\n+              };\n+\n+              lastName = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Last name.\";\n+              };\n+\n+              avatar_file = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)\";\n+              };\n+\n+              avatar_url = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)\";\n+              };\n+\n+              gravatar_avatar = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Get avatar from Gravatar using the email.\";\n+              };\n+\n+              weser_avatar = mkOption {\n+                type = types.nullOr types.str;\n+                default = null;\n+                description = \"Convert avatar retrieved by gravatar or the URL.\";\n+              };\n+\n+              groups = mkOption {\n+                type = types.listOf types.str;\n+                default = [ ];\n+                description = \"Groups the user would be a member of (all the groups must be specified in group config files).\";\n+              };\n+            };\n+          }\n+        )\n+      );\n+    };\n+\n+    ensureGroups = mkOption {\n+      description = ''\n+        Create the groups defined here on service startup.\n+\n+        Non-default options must be added to the `ensureGroupFields` option.\n+      '';\n+      default = { };\n+      type = types.attrsOf (\n+        types.submodule (\n+          { name, ... }:\n+          {\n+            freeformType = ensureFormat.type;\n+\n+            options = {\n+              name = mkOption {\n+                type = types.str;\n+                description = \"Name of the group.\";\n+                default = name;\n+              };\n+            };\n+          }\n+        )\n+      );\n+    };\n+\n+    ensureUserFields = mkOption {\n+      description = \"Extra fields for users\";\n+      default = { };\n+      type = types.attrsOf (\n+        types.submodule (\n+          { name, ... }:\n+          {\n+            options = ensureFieldsOptions name;\n+          }\n+        )\n+      );\n+    };\n+\n+    ensureGroupFields = mkOption {\n+      description = \"Extra fields for groups\";\n+      default = { };\n+      type = types.attrsOf (\n+        types.submodule (\n+          { name, ... }:\n+          {\n+            options = ensureFieldsOptions name;\n+          }\n+        )\n+      );\n+    };\n+\n+    ensureAdminUsername = mkOption {\n+      type = types.str;\n+      default = \"admin\";\n+      description = ''\n+        Username of the default admin user with which to connect to the LLDAP service.\n+\n+        By default, it is `\"admin\"`.\n+        Extra admin users can be added using the `services.lldap.ensureUsers` option and adding them to the correct groups.\n+      '';\n+    };\n+\n+    ensureAdminPassword = mkOption {\n+      type = types.nullOr types.str;\n+      defaultText = \"config.services.lldap.settings.ldap_user_pass\";\n+      default = cfg.settings.ldap_user_pass or null;\n+      description = ''\n+        Password of an admin user with which to connect to the LLDAP service.\n+\n+        By default, it is the same as the password for the default admin user 'admin'.\n+        If using a password from another user, it must be managed manually.\n+\n+        Unsecure. Use `services.lldap.ensureAdminPasswordFile` option instead.\n+      '';\n+    };\n+\n+    ensureAdminPasswordFile = mkOption {\n+      type = types.nullOr types.str;\n+      defaultText = \"config.services.lldap.settings.ldap_user_pass_file\";\n+      default = cfg.settings.ldap_user_pass_file or null;\n+      description = ''\n+        Path to the file containing the password of an admin user with which to connect to the LLDAP service.\n+\n+        By default, it is the same as the password for the default admin user 'admin'.\n+        If using a password from another user, it must be managed manually.\n+      '';\n+    };\n+\n+    enforceUsers = mkOption {\n+      description = \"Delete users not managed declaratively.\";\n+      type = types.bool;\n+      default = false;\n+    };\n+\n+    enforceUserMemberships = mkOption {\n+      description = \"Remove users from groups they do not belong to declaratively.\";\n+      type = types.bool;\n+      default = false;\n+    };\n+\n+    enforceGroups = mkOption {\n+      description = \"Delete groups not managed declaratively.\";\n+      type = types.bool;\n+      default = false;\n+    };\n   };\n \n   config = lib.mkIf cfg.enable {\n@@ -219,25 +491,77 @@ in\n           (cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null;\n         message = \"lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`.\";\n       }\n+      {\n+        assertion =\n+          cfg.ensureUsers != { }\n+          || cfg.ensureGroups != { }\n+          || cfg.ensureUserFields != { }\n+          || cfg.ensureGroupFields != { }\n+          || cfg.enforceUsers\n+          || cfg.enforceUserMemberships\n+          || cfg.enforceGroups\n+          -> cfg.ensureAdminPassword != null || cfg.ensureAdminPasswordFile != null;\n+        message = ''\n+          lldap: Some ensure options are set but no admin user password is set.\n+          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\n+          or create an admin user manually and set its password in `ensureAdminPasswordFile` option.\n+        '';\n+      }\n+      {\n+        assertion = cfg.enforceUserMemberships -> !someUsersBelongToNonEnsuredGroup;\n+        message = ''\n+          lldap: Some users belong to groups not present in the ensureGroups attr,\n+          add the following groups or remove them from the groups a user belong to:\n+            ${lib.concatMapStringsSep quoteVariable \", \" userGroupNotInEnsuredGroup}\n+        '';\n+      }\n+      (\n+        let\n+          getNames = source: lib.flatten (lib.mapAttrsToList (x: v: v.name) source);\n+          allNames = getNames cfg.ensureUserFields ++ getNames cfg.ensureGroupFields;\n+          validFieldName = name: lib.match \"[a-zA-Z0-9-]+\" name != null;\n+        in\n+        {\n+          assertion = lib.all validFieldName allNames;\n+          message = ''\n+            lldap: The following custom user or group fields have invalid names. Valid characters are: a-z, A-Z, 0-9, and dash (-).\n+            The offending fields are: ${\n+              lib.concatMapStringsSep quoteVariable \", \" (lib.filter (x: !(validFieldName x)) allNames)\n+            }\n+          '';\n+        }\n+      )\n     ];\n \n     warnings =\n-      lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [\n+      (lib.optionals (cfg.ensureAdminPassword != null) [\n+        ''\n+          lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead.\n+        ''\n+      ])\n+      ++ (lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [\n         ''\n           lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead.\n         ''\n-      ]\n-      ++\n-        lib.optionals\n-          (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)\n-          [\n-            ''\n-              lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means\n-              the admin password can be changed through the UI and will drift from the one defined in your nix config.\n-              It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.\n-              Either set `force_ldap_user_pass_reset` to `\"always\"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.\n-            ''\n-          ];\n+      ])\n+      ++ (lib.optionals\n+        (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false)\n+        [\n+          ''\n+            lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means\n+            the admin password can be changed through the UI and will drift from the one defined in your nix config.\n+            It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password.\n+            Either set `force_ldap_user_pass_reset` to `\"always\"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`.\n+          ''\n+        ]\n+      )\n+      ++ (lib.optionals (!cfg.enforceUserMemberships && someUsersBelongToNonEnsuredGroup) [\n+        ''\n+          Some users belong to groups not managed by the configuration here,\n+          make sure the following groups exist or the service will not start properly:\n+            ${lib.concatStringsSep \", \" (map (x: \"\\\"${x}\\\"\") userGroupNotInEnsuredGroup)}\n+        ''\n+      ]);\n \n     services.lldap.settings.database_url = lib.mkIf cfg.database.createLocally (\n       lib.mkDefault (\n@@ -279,6 +603,28 @@ in\n         + ''\n           exec ${lib.getExe cfg.package} run --config-file ${format.generate \"lldap_config.toml\" cfg.settings}\n         '';\n+      postStart = ''\n+        export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port}\n+        export LLDAP_ADMIN_USERNAME=${cfg.ensureAdminUsername}\n+        export LLDAP_ADMIN_PASSWORD=${\n+          if cfg.ensureAdminPassword != null then cfg.ensureAdminPassword else \"\"\n+        }\n+        export LLDAP_ADMIN_PASSWORD_FILE=${\n+          if cfg.ensureAdminPasswordFile != null then cfg.ensureAdminPasswordFile else \"\"\n+        }\n+        export USER_CONFIGS_DIR=${generateEnsureConfigDir \"users\" cfg.ensureUsers}\n+        export GROUP_CONFIGS_DIR=${generateEnsureConfigDir \"groups\" cfg.ensureGroups}\n+        export USER_SCHEMAS_DIR=${\n+          generateEnsureConfigDir \"userFields\" (lib.mapAttrs (n: v: [ v ]) cfg.ensureUserFields)\n+        }\n+        export GROUP_SCHEMAS_DIR=${\n+          generateEnsureConfigDir \"groupFields\" (lib.mapAttrs (n: v: [ v ]) cfg.ensureGroupFields)\n+        }\n+        export DO_CLEANUP_USERS=${if cfg.enforceUsers then \"true\" else \"false\"}\n+        export DO_CLEANUP_USER_MEMBERSHIPS=${if cfg.enforceUserMemberships then \"true\" else \"false\"}\n+        export DO_CLEANUP_GROUPS=${if cfg.enforceGroups then \"true\" else \"false\"}\n+        ${lib.getExe cfg.bootstrap-package}\n+      '';\n       serviceConfig = {\n         StateDirectory = \"lldap\";\n         StateDirectoryMode = \"0750\";\ndiff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix\nindex 8e38d4bdefa3..47d32c7a2a7b 100644\n--- a/nixos/tests/lldap.nix\n+++ b/nixos/tests/lldap.nix\n@@ -1,6 +1,9 @@\n { ... }:\n let\n   adminPassword = \"mySecretPassword\";\n+  alicePassword = \"AlicePassword\";\n+  bobPassword = \"BobPassword\";\n+  charliePassword = \"CharliePassword\";\n in\n {\n   name = \"lldap\";\n@@ -26,7 +29,7 @@ in\n           {\n             services.lldap.settings = {\n               ldap_user_pass = lib.mkForce null;\n-              ldap_user_pass_file = lib.mkForce (toString (pkgs.writeText \"adminPasswordFile\" adminPassword));\n+              ldap_user_pass_file = toString (pkgs.writeText \"adminPasswordFile\" adminPassword);\n               force_ldap_user_pass_reset = \"always\";\n             };\n           };\n@@ -40,13 +43,110 @@ in\n               force_ldap_user_pass_reset = false;\n             };\n           };\n+\n+        withAlice.configuration =\n+          { ... }:\n+          {\n+            services.lldap = {\n+              enforceUsers = true;\n+              enforceUserMemberships = true;\n+              enforceGroups = true;\n+\n+              # This password was set in the \"differentAdminPassword\" specialisation.\n+              ensureAdminPasswordFile = toString (pkgs.writeText \"adminPasswordFile\" adminPassword);\n+\n+              ensureUsers = {\n+                alice = {\n+                  email = \"alice@example.com\";\n+                  password_file = toString (pkgs.writeText \"alicePasswordFile\" alicePassword);\n+                  groups = [ \"mygroup\" ];\n+                };\n+              };\n+\n+              ensureGroups = {\n+                mygroup = { };\n+              };\n+            };\n+          };\n+\n+        withBob.configuration =\n+          { ... }:\n+          {\n+            services.lldap = {\n+              enforceUsers = true;\n+              enforceUserMemberships = true;\n+              enforceGroups = true;\n+\n+              # This time we check that ensureAdminPasswordFile correctly defaults to `settings.ldap_user_pass_file`\n+              settings = {\n+                ldap_user_pass = lib.mkForce \"password\";\n+                force_ldap_user_pass_reset = \"always\";\n+              };\n+\n+              ensureUsers = {\n+                bob = {\n+                  email = \"bob@example.com\";\n+                  password_file = toString (pkgs.writeText \"bobPasswordFile\" bobPassword);\n+                  groups = [ \"bobgroup\" ];\n+                  displayName = \"Bob\";\n+                };\n+              };\n+\n+              ensureGroups = {\n+                bobgroup = { };\n+              };\n+            };\n+          };\n+\n+        withAttributes.configuration =\n+          { ... }:\n+          {\n+            services.lldap = {\n+              enforceUsers = true;\n+              enforceUserMemberships = true;\n+              enforceGroups = true;\n+\n+              settings = {\n+                ldap_user_pass = lib.mkForce adminPassword;\n+                force_ldap_user_pass_reset = \"always\";\n+              };\n+\n+              ensureUsers = {\n+                charlie = {\n+                  email = \"charlie@example.com\";\n+                  password_file = toString (pkgs.writeText \"charliePasswordFile\" charliePassword);\n+                  groups = [ \"othergroup\" ];\n+                  displayName = \"Charlie\";\n+                  myattribute = 2;\n+                };\n+              };\n+\n+              ensureGroups = {\n+                othergroup = {\n+                  mygroupattribute = \"Managed by NixOS\";\n+                };\n+              };\n+\n+              ensureUserFields = {\n+                myattribute = {\n+                  attributeType = \"INTEGER\";\n+                };\n+              };\n+\n+              ensureGroupFields = {\n+                mygroupattribute = {\n+                  attributeType = \"STRING\";\n+                };\n+              };\n+            };\n+          };\n       };\n     };\n \n   testScript =\n     { nodes, ... }:\n     let\n-      specializations = \"${nodes.machine.system.build.toplevel}/specialisation\";\n+      specialisations = \"${nodes.machine.system.build.toplevel}/specialisation\";\n     in\n     ''\n       machine.wait_for_unit(\"lldap.service\")\n@@ -56,6 +156,9 @@ in\n       machine.succeed(\"curl --location --fail http://localhost:17170/\")\n \n       adminPassword=\"${adminPassword}\"\n+      alicePassword=\"${alicePassword}\"\n+      bobPassword=\"${bobPassword}\"\n+      charliePassword=\"${charliePassword}\"\n \n       def try_login(user, password, expect_success=True):\n           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}'\n@@ -70,18 +173,50 @@ in\n                   raise Exception(\"Expected failure, had success\")\n           return response\n \n+      def parse_ldapsearch_output(output):\n+          return {n:v for (n, v) in (x.split(': ', 2) for x in output.splitlines() if x != \"\")}\n+\n       with subtest(\"default admin password\"):\n           try_login(\"admin\", \"password\",    expect_success=True)\n           try_login(\"admin\", adminPassword, expect_success=False)\n \n       with subtest(\"different admin password\"):\n-          machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')\n+          machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')\n           try_login(\"admin\", \"password\",    expect_success=False)\n           try_login(\"admin\", adminPassword, expect_success=True)\n \n       with subtest(\"change admin password has no effect\"):\n-          machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test')\n+          machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test')\n           try_login(\"admin\", \"password\",    expect_success=False)\n           try_login(\"admin\", adminPassword, expect_success=True)\n+\n+      with subtest(\"with alice\"):\n+          machine.succeed('${specialisations}/withAlice/bin/switch-to-configuration test')\n+          try_login(\"alice\", \"password\",    expect_success=False)\n+          try_login(\"alice\", alicePassword, expect_success=True)\n+          try_login(\"bob\",   \"password\",    expect_success=False)\n+          try_login(\"bob\",   bobPassword,   expect_success=False)\n+\n+      with subtest(\"with bob\"):\n+          machine.succeed('${specialisations}/withBob/bin/switch-to-configuration test')\n+          try_login(\"alice\", \"password\",    expect_success=False)\n+          try_login(\"alice\", alicePassword, expect_success=False)\n+          try_login(\"bob\",   \"password\",    expect_success=False)\n+          try_login(\"bob\",   bobPassword,   expect_success=True)\n+\n+      with subtest(\"with attributes\"):\n+          machine.succeed('${specialisations}/withAttributes/bin/switch-to-configuration test')\n+\n+          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)\"')\n+          print(response)\n+          charlie = parse_ldapsearch_output(response)\n+          if charlie.get('myattribute') != \"2\":\n+              raise Exception(f'Unexpected value for attribute \"myattribute\": {charlie.get('myattribute')}')\n+\n+          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)\"')\n+          print(response)\n+          othergroup = parse_ldapsearch_output(response)\n+          if othergroup.get('mygroupattribute') != \"Managed by NixOS\":\n+              raise Exception(f'Unexpected value for attribute \"mygroupattribute\": {othergroup.get('mygroupattribute')}')\n     '';\n }\n-- \n"
  },
  {
    "path": "patches/nextcloudexternalstorage.patch",
    "content": "diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php\nindex 260f9218a88..26e5a4172f7 100644\n--- a/lib/private/Files/Storage/Local.php\n+++ b/lib/private/Files/Storage/Local.php\n@@ -66,9 +66,12 @@ class Local extends \\OC\\Files\\Storage\\Common {\n \t\t$this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false);\n \n \t\tif (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) {\n-\t\t\t// data dir not accessible or available, can happen when using an external storage of type Local\n-\t\t\t// on an unmounted system mount point\n-\t\t\tthrow new StorageNotAvailableException('Local storage path does not exist \"' . $this->getSourcePath('') . '\"');\n+            if (!$this->mkdir('')) {\n+                // data dir not accessible or available, can happen when using an external storage of type Local\n+                // on an unmounted system mount point\n+                throw new StorageNotAvailableException('Local storage path does not exist and could not create it \"' . $this->getSourcePath('') . '\"');\n+            }\n+            Server::get(LoggerInterface::class)->warning('created local storage path ' . $this->getSourcePath(''), ['app' => 'core']);\n \t\t}\n \t}\n \n-- \n2.50.1\n\n"
  },
  {
    "path": "test/blocks/authelia.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  pkgs' = pkgs;\n\n  ldapAdminPassword = \"ldapAdminPassword\";\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"authelia-basic\";\n\n    nodes.machine =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          ../../modules/blocks/authelia.nix\n          ../../modules/blocks/hardcodedsecret.nix\n        ];\n\n        networking.hosts = {\n          \"127.0.0.1\" = [\n            \"machine.com\"\n            \"client1.machine.com\"\n            \"client2.machine.com\"\n            \"ldap.machine.com\"\n            \"authelia.machine.com\"\n          ];\n        };\n\n        shb.lldap = {\n          enable = true;\n          dcdomain = \"dc=example,dc=com\";\n          subdomain = \"ldap\";\n          domain = \"machine.com\";\n          ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;\n          jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;\n        };\n\n        shb.hardcodedsecret.ldapUserPassword = {\n          request = config.shb.lldap.ldapUserPassword.request;\n          settings.content = ldapAdminPassword;\n        };\n        shb.hardcodedsecret.jwtSecret = {\n          request = config.shb.lldap.jwtSecret.request;\n          settings.content = \"jwtsecret\";\n        };\n\n        shb.authelia = {\n          enable = true;\n          subdomain = \"authelia\";\n          domain = \"machine.com\";\n          ldapHostname = \"${config.shb.lldap.subdomain}.${config.shb.lldap.domain}\";\n          ldapPort = config.shb.lldap.ldapPort;\n          dcdomain = config.shb.lldap.dcdomain;\n          secrets = {\n            jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result;\n            ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result;\n            sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result;\n            storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result;\n            identityProvidersOIDCHMACSecret.result =\n              config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result;\n            identityProvidersOIDCIssuerPrivateKey.result =\n              config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result;\n          };\n\n          oidcClients = [\n            {\n              client_id = \"client1\";\n              client_name = \"My Client 1\";\n              client_secret.source = pkgs.writeText \"secret\" \"$pbkdf2-sha512$310000$LR2wY11djfLrVQixdlLJew$rPByqFt6JfbIIAITxzAXckwh51QgV8E5YZmA8rXOzkMfBUcMq7cnOKEXF6MAFbjZaGf3J/B1OzLWZTCuZtALVw\";\n              public = false;\n              authorization_policy = \"one_factor\";\n              redirect_uris = [ \"http://client1.machine.com/redirect\" ];\n            }\n            {\n              client_id = \"client2\";\n              client_name = \"My Client 2\";\n              client_secret.source = pkgs.writeText \"secret\" \"$pbkdf2-sha512$310000$76EqVU1N9K.iTOvD4WJ6ww$hqNJU.UHphiCjMChSqk27lUTjDqreuMuyV/u39Esc6HyiRXp5Ecx89ypJ5M0xk3Na97vbgDpwz7il5uwzQ4bfw\";\n              public = false;\n              authorization_policy = \"one_factor\";\n              redirect_uris = [ \"http://client2.machine.com/redirect\" ];\n            }\n          ];\n        };\n\n        shb.hardcodedsecret.autheliaJwtSecret = {\n          request = config.shb.authelia.secrets.jwtSecret.request;\n          settings.content = \"jwtSecret\";\n        };\n        shb.hardcodedsecret.ldapAdminPassword = {\n          request = config.shb.authelia.secrets.ldapAdminPassword.request;\n          settings.content = ldapAdminPassword;\n        };\n        shb.hardcodedsecret.sessionSecret = {\n          request = config.shb.authelia.secrets.sessionSecret.request;\n          settings.content = \"sessionSecret\";\n        };\n        shb.hardcodedsecret.storageEncryptionKey = {\n          request = config.shb.authelia.secrets.storageEncryptionKey.request;\n          settings.content = \"storageEncryptionKey\";\n        };\n        shb.hardcodedsecret.identityProvidersOIDCHMACSecret = {\n          request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;\n          settings.content = \"identityProvidersOIDCHMACSecret\";\n        };\n        shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = {\n          request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;\n          settings.source =\n            (pkgs.runCommand \"gen-private-key\" { } ''\n              mkdir $out\n              ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096\n            '')\n            + \"/private.pem\";\n        };\n\n        specialisation = {\n          withDebug.configuration = {\n            shb.authelia.debug = true;\n          };\n        };\n      };\n\n    testScript =\n      { nodes, ... }:\n      let\n        specializations = \"${nodes.machine.system.build.toplevel}/specialisation\";\n      in\n      ''\n        import json\n\n        start_all()\n\n        def tests():\n            machine.wait_for_unit(\"lldap.service\")\n            machine.wait_for_unit(\"authelia-authelia.machine.com.target\")\n            machine.wait_for_open_port(9091)\n\n            endpoints = json.loads(machine.succeed(\"curl -s http://machine.com/.well-known/openid-configuration\"))\n            auth_endpoint = endpoints['authorization_endpoint']\n            print(f\"auth_endpoint: {auth_endpoint}\")\n            if auth_endpoint != \"http://machine.com/api/oidc/authorization\":\n                raise Exception(\"Unexpected auth_endpoint\")\n\n            resp = machine.succeed(\n                \"curl -f -s '\"\n                + auth_endpoint\n                + \"?client_id=other\"\n                + \"&redirect_uri=http://client1.machine.com/redirect\"\n                + \"&scope=openid%20profile%20email\"\n                + \"&response_type=code\"\n                + \"&state=99999999'\"\n            )\n            print(resp)\n            if resp != \"\":\n                raise Exception(\"unexpected response\")\n\n            resp = machine.succeed(\n                \"curl -f -s '\"\n                + auth_endpoint\n                + \"?client_id=client1\"\n                + \"&redirect_uri=http://client1.machine.com/redirect\"\n                + \"&scope=openid%20profile%20email\"\n                + \"&response_type=code\"\n                + \"&state=11111111'\"\n            )\n            print(resp)\n            if \"Found\" not in resp:\n                raise Exception(\"unexpected response\")\n\n            resp = machine.succeed(\n                \"curl -f -s '\"\n                + auth_endpoint\n                + \"?client_id=client2\"\n                + \"&redirect_uri=http://client2.machine.com/redirect\"\n                + \"&scope=openid%20profile%20email\"\n                + \"&response_type=code\"\n                + \"&state=22222222'\"\n            )\n            print(resp)\n            if \"Found\" not in resp:\n                raise Exception(\"unexpected response\")\n\n        with subtest(\"no debug\"):\n            tests()\n\n        with subtest(\"with debug\"):\n            machine.succeed('${specializations}/withDebug/bin/switch-to-configuration test')\n            tests()\n      '';\n  };\n}\n"
  },
  {
    "path": "test/blocks/borgbackup.nix",
    "content": "{ shb, ... }:\nlet\n  commonTest =\n    user:\n    shb.test.runNixOSTest {\n      name = \"borgbackup_backupAndRestore_${user}\";\n\n      nodes.machine =\n        { config, ... }:\n        {\n          imports = [\n            shb.test.baseImports\n\n            ../../modules/blocks/hardcodedsecret.nix\n            ../../modules/blocks/borgbackup.nix\n          ];\n\n          shb.hardcodedsecret.A = {\n            request = {\n              owner = \"root\";\n              group = \"keys\";\n              mode = \"0440\";\n            };\n            settings.content = \"secretA\";\n          };\n          shb.hardcodedsecret.B = {\n            request = {\n              owner = \"root\";\n              group = \"keys\";\n              mode = \"0440\";\n            };\n            settings.content = \"secretB\";\n          };\n\n          shb.hardcodedsecret.passphrase = {\n            request = config.shb.borgbackup.instances.\"testinstance\".settings.passphrase.request;\n            settings.content = \"passphrase\";\n          };\n\n          shb.borgbackup.instances.\"testinstance\" = {\n            settings = {\n              enable = true;\n\n              passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n\n              repository = {\n                path = \"/opt/repos/A\";\n                timerConfig = {\n                  OnCalendar = \"00:00:00\";\n                  RandomizedDelaySec = \"5h\";\n                };\n                # Those are not needed by the repository but are still included\n                # so we can test them in the hooks section.\n                secrets = {\n                  A.source = config.shb.hardcodedsecret.A.result.path;\n                  B.source = config.shb.hardcodedsecret.B.result.path;\n                };\n              };\n            };\n\n            request = {\n              inherit user;\n\n              sourceDirectories = [\n                \"/opt/files/A\"\n                \"/opt/files/B\"\n              ];\n\n              hooks.beforeBackup = [\n                ''\n                  echo $RUNTIME_DIRECTORY\n                  if [ \"$RUNTIME_DIRECTORY\" = /run/borgbackup-backups-testinstance_opt_repos_A ]; then\n                    if ! [ -f /run/secrets_borgbackup/borgbackup-backups-testinstance_opt_repos_A ]; then\n                      exit 10\n                    fi\n                    if [ -z \"$A\" ] || ! [ \"$A\" = \"secretA\" ]; then\n                      echo \"A:$A\"\n                      exit 11\n                    fi\n                    if [ -z \"$B\" ] || ! [ \"$B\" = \"secretB\" ]; then\n                      echo \"B:$B\"\n                      exit 12\n                    fi\n                  fi\n                ''\n              ];\n            };\n          };\n        };\n\n      extraPythonPackages = p: [ p.dictdiffer ];\n      skipTypeCheck = true;\n\n      testScript =\n        { nodes, ... }:\n        let\n          provider = nodes.machine.shb.borgbackup.instances.\"testinstance\";\n          backupService = provider.result.backupService;\n          restoreScript = provider.result.restoreScript;\n        in\n        ''\n          from dictdiffer import diff\n\n          def list_files(dir):\n              files_and_content = {}\n\n              files = machine.succeed(f\"\"\"\n              find {dir} -type f\n              \"\"\").split(\"\\n\")[:-1]\n\n              for f in files:\n                  content = machine.succeed(f\"\"\"\n                  cat {f}\n                  \"\"\").strip()\n                  files_and_content[f] = content\n\n              return files_and_content\n\n          def assert_files(dir, files):\n              result = list(diff(list_files(dir), files))\n              if len(result) > 0:\n                  raise Exception(\"Unexpected files:\", result)\n\n          with subtest(\"Create initial content\"):\n              machine.succeed(\"\"\"\n              mkdir -p /opt/files/A\n              mkdir -p /opt/files/B\n\n              echo repoA_fileA_1 > /opt/files/A/fileA\n              echo repoA_fileB_1 > /opt/files/A/fileB\n              echo repoB_fileA_1 > /opt/files/B/fileA\n              echo repoB_fileB_1 > /opt/files/B/fileB\n\n              chown ${user}: -R /opt/files\n              chmod go-rwx -R /opt/files\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_1',\n                  '/opt/files/B/fileB': 'repoB_fileB_1',\n                  '/opt/files/A/fileA': 'repoA_fileA_1',\n                  '/opt/files/A/fileB': 'repoA_fileB_1',\n              })\n\n          with subtest(\"First backup in repo A\"):\n              machine.succeed(\"systemctl start ${backupService}\")\n\n          with subtest(\"New content\"):\n              machine.succeed(\"\"\"\n              echo repoA_fileA_2 > /opt/files/A/fileA\n              echo repoA_fileB_2 > /opt/files/A/fileB\n              echo repoB_fileA_2 > /opt/files/B/fileA\n              echo repoB_fileB_2 > /opt/files/B/fileB\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_2',\n                  '/opt/files/B/fileB': 'repoB_fileB_2',\n                  '/opt/files/A/fileA': 'repoA_fileA_2',\n                  '/opt/files/A/fileB': 'repoA_fileB_2',\n              })\n\n          with subtest(\"Delete content\"):\n              machine.succeed(\"\"\"\n              rm -r /opt/files/A /opt/files/B\n              \"\"\")\n\n              assert_files(\"/opt/files\", {})\n\n          with subtest(\"Restore initial content from repo A\"):\n              machine.succeed(\"\"\"\n              ${restoreScript} restore latest\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_1',\n                  '/opt/files/B/fileB': 'repoB_fileB_1',\n                  '/opt/files/A/fileA': 'repoA_fileA_1',\n                  '/opt/files/A/fileB': 'repoA_fileB_1',\n              })\n        '';\n\n    };\nin\n{\n  backupAndRestoreRoot = commonTest \"root\";\n  backupAndRestoreUser = commonTest \"nobody\";\n}\n"
  },
  {
    "path": "test/blocks/keypair.pem",
    "content": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC2x1rFx98p6djQ\nX0mJ8nUMnS3LU7ih8UU5soW/TVhdcioe+NUevLjq0Qe/RXAz+yjhAxmJoWsFwMuy\nPKQKDbnqLH6Rf2qPiOvg5NB/vINuVpnAo9mQUJlJOrWKe6/pk6FWD0YxGiwETEIX\nbdARazOo/n5emQCZ7XwFy9ULlZkURIt9co/SxhMaD0Q+P1eev0lt01XG5ZLg9wL8\nCHIkasqK5huKFUnHVyq8+ApVrzsANsvjFSLwd985FpIF+DYcVQ+8ZmwVrbZTzFFC\nPjee8CrhO1ZxOAPwVm7qNTtZ4es8xJyMJvijk6grqLGcWSIWTMAwxmN7feOuEvvk\nRlDf9DmpAgMBAAECggEACP51jySrDLAQ/wznTJpRi+u6loYhwFdD26dXGT8kNWHy\nJGjEbPEmrMhZKB5xu18VJ4Bca/c1UdjHNTeybzu2balfl4eEbfhz+fKsd1KmiYIJ\nqg7t/GHY7x9sUjqMoRLmhhp1juI9rv71JBu/WLIUnlDalUtUWh6zYwIhE0M634I8\nGjN4hCxvbVgQEyY4kMBvCcT9sixwm407qL7LfqlsT8KTGB9UU2cC1HD4B/pUKVzw\nx+vN93S6KS2SrjaYhAb1xHgxU6Bl1jT1IH8yAXVlmBBmDL9dNEJtD/kuX8kfbvuC\nyFY5NWVapSgyIhURkaHqJKmziaq51K1xHGCZYBDsYQKBgQDq0ovgTNBzgmwyl/ye\nZgUIWc/5tE2LlyoM8XTok9EB+8CnoBek8JFo9DVfNzTv+UCUXb7DCveuS1Jb0JY0\nXi4gOSczVV297Lszziogxuni4ax/1Nezah/WSffVEowakPuTLK+0dst0QxWC6+Db\nm4OHJY5qjS/mh3rLhFdjXcmAoQKBgQDHQ0BAg8AhlFz9fTxit1pyHuVs1EcEBjqI\nUOS1ClS+BDcjERVBJ8GKiZj2/la37OLlQuguH2AXX//wVC5rZEXP38+ELemW0BZC\nJFKaY5PYufMcGVd6JBDYCoEa/JERJsD87ADBAUj/kIMfvka/it9PID9jgMPaVESE\nLYIsRv40CQKBgQCtdJ0yMEuCJ4L41GAcOUvaYU1JLDBjvmOnb+xlqFqpVmd26sDM\na49dsZaDIOqPoNRdQ+oXdNCEBMtvWuK5CCCWWOFl/9bg5i9aEx33XDeECiM7weMb\nenbN+ZGB6NNpBFNw4X9glKew16TaMpbEYVmEyO8sMeKCLO09zCIpGiwwQQKBgQCF\n++dhOfXf3mXkoOgQrJ8pazLzSY1y3ElRTatrPEYc+rKkZqE3DWdrIvhy5DQlOiia\n5bE/CiPPs+JhlAkedu8mRqS/iSuvF75PvSK540kPioE4nKWgYE3fJrkHD1rwAHH1\n3y7mmFmgVmiE2Kmzs8pR5yoYWwXWcaEci4kjAp19GQKBgQDRpy4ojGUmKdDffcGU\npEpl+dGpC3YuGwEsopDTYJSjANq0p5QGcQo9L140XxBEaFd4k/jwvVh2VRx4KmkC\nwyFODOk4vbq1NKljLC9yRo6UbUZuzWBsyjP62OHPR5MBg5FQgd4RI6/c3EpAhFGX\npM/CH7yZXp7Brhp4RcdbwhQnIA==\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "test/blocks/lib.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  pkgs' = pkgs;\nin\n{\n  template =\n    let\n      aSecret = pkgs.writeText \"a-secret.txt\" \"Secret of A\";\n      bSecret = pkgs.writeText \"b-secret.txt\" \"Secret of B\";\n      userConfig = {\n        a.a.source = aSecret;\n        b.source = bSecret;\n        b.transform = v: \"prefix-${v}-suffix\";\n        c = \"not secret C\";\n        d.d = \"not secret D\";\n      };\n\n      wantedConfig = {\n        a.a = \"Secret of A\";\n        b = \"prefix-Secret of B-suffix\";\n        c = \"not secret C\";\n        d.d = \"not secret D\";\n      };\n\n      configWithTemplates = shb.withReplacements userConfig;\n\n      nonSecretConfigFile = pkgs.writeText \"config.yaml.template\" (\n        lib.generators.toJSON { } configWithTemplates\n      );\n\n      replacements = shb.getReplacements userConfig;\n\n      replaceInTemplate = shb.replaceSecretsScript {\n        file = nonSecretConfigFile;\n        resultPath = \"/var/lib/config.yaml\";\n        inherit replacements;\n      };\n\n      replaceInTemplateJSON = shb.replaceSecrets {\n        inherit userConfig;\n        resultPath = \"/var/lib/config.json\";\n        generator = shb.replaceSecretsFormatAdapter (pkgs.formats.json { });\n      };\n\n      replaceInTemplateJSONGen = shb.replaceSecrets {\n        inherit userConfig;\n        resultPath = \"/var/lib/config_gen.json\";\n        generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toJSON { });\n      };\n\n      replaceInTemplateXML = shb.replaceSecrets {\n        inherit userConfig;\n        resultPath = \"/var/lib/config.xml\";\n        generator = shb.replaceSecretsFormatAdapter (shb.formatXML { enclosingRoot = \"Root\"; });\n      };\n    in\n    shb.test.runNixOSTest {\n      name = \"lib-template\";\n      nodes.machine =\n        { config, pkgs, ... }:\n        {\n          imports = [\n            (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n            (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n            {\n              options = {\n                libtest.config = lib.mkOption {\n                  type = lib.types.attrsOf (\n                    lib.types.oneOf [\n                      lib.types.str\n                      lib.secretFileType\n                    ]\n                  );\n                };\n              };\n            }\n          ];\n\n          system.activationScripts = {\n            libtest = replaceInTemplate;\n            libtestJSON = replaceInTemplateJSON;\n            libtestJSONGen = replaceInTemplateJSONGen;\n            libtestXML = replaceInTemplateXML;\n          };\n        };\n\n      testScript =\n        { nodes, ... }:\n        ''\n          import json\n          from collections import ChainMap\n          from xml.etree import ElementTree\n\n          start_all()\n          machine.wait_for_file(\"/var/lib/config.yaml\")\n          machine.wait_for_file(\"/var/lib/config.json\")\n          machine.wait_for_file(\"/var/lib/config_gen.json\")\n          machine.wait_for_file(\"/var/lib/config.xml\")\n\n          def xml_to_dict_recursive(root):\n              all_descendants = list(root)\n              if len(all_descendants) == 0:\n                  return {root.tag: root.text}\n              else:\n                  merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))\n                  return {root.tag: dict(merged_dict)}\n\n          wantedConfig = json.loads('${lib.generators.toJSON { } wantedConfig}')\n\n          with subtest(\"config\"):\n            print(machine.succeed(\"cat ${pkgs.writeText \"replaceInTemplate\" replaceInTemplate}\"))\n\n            gotConfig = machine.succeed(\"cat /var/lib/config.yaml\")\n            print(gotConfig)\n            gotConfig = json.loads(gotConfig)\n\n            if wantedConfig != gotConfig:\n              raise Exception(\"\\nwantedConfig: {}\\n!= gotConfig: {}\".format(wantedConfig, gotConfig))\n\n          with subtest(\"config JSON Gen\"):\n            print(machine.succeed(\"cat ${pkgs.writeText \"replaceInTemplateJSONGen\" replaceInTemplateJSONGen}\"))\n\n            gotConfig = machine.succeed(\"cat /var/lib/config_gen.json\")\n            print(gotConfig)\n            gotConfig = json.loads(gotConfig)\n\n            if wantedConfig != gotConfig:\n              raise Exception(\"\\nwantedConfig:  {}\\n!= gotConfig: {}\".format(wantedConfig, gotConfig))\n\n          with subtest(\"config JSON\"):\n            print(machine.succeed(\"cat ${pkgs.writeText \"replaceInTemplateJSON\" replaceInTemplateJSON}\"))\n\n            gotConfig = machine.succeed(\"cat /var/lib/config.json\")\n            print(gotConfig)\n            gotConfig = json.loads(gotConfig)\n\n            if wantedConfig != gotConfig:\n              raise Exception(\"\\nwantedConfig:  {}\\n!= gotConfig: {}\".format(wantedConfig, gotConfig))\n\n          with subtest(\"config XML\"):\n            print(machine.succeed(\"cat ${pkgs.writeText \"replaceInTemplateXML\" replaceInTemplateXML}\"))\n\n            gotConfig = machine.succeed(\"cat /var/lib/config.xml\")\n            print(gotConfig)\n            gotConfig = xml_to_dict_recursive(ElementTree.XML(gotConfig))['Root']\n\n            if wantedConfig != gotConfig:\n              raise Exception(\"\\nwantedConfig:  {}\\n!= gotConfig: {}\".format(wantedConfig, gotConfig))\n        '';\n    };\n}\n"
  },
  {
    "path": "test/blocks/lldap.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  pkgs' = pkgs;\n\n  password = \"securepassword\";\n  charliePassword = \"CharliePassword\";\nin\n{\n  auth = shb.test.runNixOSTest {\n    name = \"ldap-auth\";\n\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          {\n            options = {\n              shb.ssl.enable = lib.mkEnableOption \"ssl\";\n            };\n          }\n          ../../modules/blocks/hardcodedsecret.nix\n          ../../modules/blocks/lldap.nix\n        ];\n\n        shb.lldap = {\n          enable = true;\n          dcdomain = \"dc=example,dc=com\";\n          subdomain = \"ldap\";\n          domain = \"example.com\";\n          ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;\n          jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;\n\n          ensureUsers = {\n            \"charlie\" = {\n              email = \"charlie@example.com\";\n              password.result = config.shb.hardcodedsecret.\"charlie\".result;\n            };\n          };\n\n          ensureGroups = {\n            \"family\" = { };\n          };\n        };\n        shb.hardcodedsecret.ldapUserPassword = {\n          request = config.shb.lldap.ldapUserPassword.request;\n          settings.content = password;\n        };\n        shb.hardcodedsecret.jwtSecret = {\n          request = config.shb.lldap.jwtSecret.request;\n          settings.content = \"jwtSecret\";\n        };\n        shb.hardcodedsecret.\"charlie\" = {\n          request = config.shb.lldap.ensureUsers.\"charlie\".password.request;\n          settings.content = charliePassword;\n        };\n\n        networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port\n\n        environment.systemPackages = [ pkgs.openldap ];\n\n        specialisation = {\n          withDebug.configuration = {\n            shb.lldap.debug = true;\n          };\n        };\n      };\n\n    nodes.client = { };\n\n    # Inspired from https://github.com/lldap/lldap/blob/33f50d13a2e2d24a3e6bb05a148246bc98090df0/example_configs/lldap-ha-auth.sh\n    testScript =\n      { nodes, ... }:\n      let\n        specializations = \"${nodes.server.system.build.toplevel}/specialisation\";\n      in\n      ''\n        import json\n\n        start_all()\n\n        def tests():\n            server.wait_for_unit(\"lldap.service\")\n            server.wait_for_open_port(${toString nodes.server.shb.lldap.webUIListenPort})\n            server.wait_for_open_port(${toString nodes.server.shb.lldap.ldapPort})\n\n            with subtest(\"fail without authenticating\"):\n                client.fail(\n                    \"curl -f -s -X GET\"\n                    + \"\"\" -H \"Content-type: application/json\" \"\"\"\n                    + \"\"\" -H \"Host: ldap.example.com\" \"\"\"\n                    + \" http://server/api/graphql\"\n                )\n\n            with subtest(\"fail authenticating with wrong credentials\"):\n                resp = client.fail(\n                    \"curl -f -s -X POST\"\n                    + \"\"\" -H \"Content-type: application/json\" \"\"\"\n                    + \"\"\" -H \"Host: ldap.example.com\" \"\"\"\n                    + \" http://server/auth/simple/login\"\n                    + \"\"\" -d '{\"username\": \"admin\", \"password\": \"wrong\"}'\"\"\"\n                )\n\n                print(resp)\n\n            with subtest(\"succeed with correct authentication\"):\n                token = json.loads(client.succeed(\n                    \"curl -f -s -X POST \"\n                    + \"\"\" -H \"Content-type: application/json\" \"\"\"\n                    + \"\"\" -H \"Host: ldap.example.com\" \"\"\"\n                    + \" http://server/auth/simple/login \"\n                    + \"\"\" -d '{\"username\": \"admin\", \"password\": \"${password}\"}' \"\"\"\n                ))['token']\n\n                data = json.loads(client.succeed(\n                    \"curl -f -s -X POST \"\n                    + \"\"\" -H \"Content-type: application/json\" \"\"\"\n                    + \"\"\" -H \"Host: ldap.example.com\" \"\"\"\n                    + \"\"\" -H \"Authorization: Bearer {token}\" \"\"\".format(token=token)\n                    + \" http://server/api/graphql \"\n                    + \"\"\" -d '{\"variables\": {\"id\": \"admin\"}, \"query\":\"query($id:String!){user(userId:$id){displayName groups{displayName}}}\"}' \"\"\"\n                ))['data']\n\n                assert data['user']['displayName'] == \"Administrator\"\n                assert data['user']['groups'][0]['displayName'] == \"lldap_admin\"\n\n            with subtest(\"succeed charlie\"):\n                resp = client.succeed(\n                    \"curl -f -s -X POST \"\n                    + \"\"\" -H \"Content-type: application/json\" \"\"\"\n                    + \"\"\" -H \"Host: ldap.example.com\" \"\"\"\n                    + \" http://server/auth/simple/login \"\n                    + \"\"\" -d '{\"username\": \"charlie\", \"password\": \"${charliePassword}\"}' \"\"\"\n                )\n                print(resp)\n\n            with subtest(\"ldap user search\"):\n                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}')\n                print(resp)\n\n                if \"uid=admin\" not in resp:\n                    raise Exception(\"Expected to find admin\")\n\n                if \"uid=charlie\" not in resp:\n                    raise Exception(\"Expected to find charlie\")\n\n        with subtest(\"no debug\"):\n            tests()\n\n        with subtest(\"with debug\"):\n            server.succeed('${specializations}/withDebug/bin/switch-to-configuration test')\n            tests()\n      '';\n  };\n}\n"
  },
  {
    "path": "test/blocks/mitmdump.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  serve =\n    port: text:\n    lib.getExe (\n      pkgs.writers.writePython3Bin \"serve\"\n        {\n          libraries = [ pkgs.python3Packages.systemd-python ];\n        }\n        (\n          let\n            content = pkgs.writeText \"content\" text;\n          in\n          ''\n            from http.server import BaseHTTPRequestHandler, HTTPServer\n            from systemd.daemon import notify\n\n            with open(\"${content}\", \"rb\") as f:\n                content = f.read()\n\n\n            class HardcodedHandler(BaseHTTPRequestHandler):\n                def do_GET(self):\n                    reponse = content + self.path.encode('utf-8')\n                    self.send_response(200)\n                    self.send_header(\"Content-Type\", \"text/plain\")\n                    self.send_header(\"Content-Length\", str(len(reponse)))\n                    self.end_headers()\n                    print(\"answering to GET request\")\n                    self.wfile.write(reponse)\n\n                def log_message(self, format, *args):\n                    pass  # optional: suppress logging\n\n\n            if __name__ == \"__main__\":\n                notify('STATUS=Starting up...')\n                server_address = ('127.0.0.1', ${toString port})\n                httpd = HTTPServer(server_address, HardcodedHandler)\n                print(\"Serving hardcoded page on http://127.0.0.1:${toString port}\")\n                notify('READY=1')\n                httpd.serve_forever()\n          ''\n        )\n    );\nin\n{\n  default = shb.test.runNixOSTest {\n    name = \"mitmdump-default\";\n\n    nodes.machine =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          ../../modules/blocks/mitmdump.nix\n        ];\n\n        systemd.services.test1 = {\n          serviceConfig.ExecStart = serve 8000 \"test1\";\n          wantedBy = [ \"multi-user.target\" ];\n          serviceConfig = {\n            Type = \"notify\";\n            StandardOutput = \"journal\";\n            StandardError = \"journal\";\n          };\n        };\n\n        systemd.services.test2 = {\n          serviceConfig.ExecStart = serve 8002 \"test2\";\n          wantedBy = [ \"multi-user.target\" ];\n          serviceConfig = {\n            Type = \"notify\";\n            StandardOutput = \"journal\";\n            StandardError = \"journal\";\n          };\n        };\n\n        shb.mitmdump.instances.\"test1\" = {\n          listenPort = 8001;\n          upstreamPort = 8000;\n          after = [ \"test1.service\" ];\n        };\n\n        shb.mitmdump.instances.\"test2\" = {\n          listenPort = 8003;\n          upstreamPort = 8002;\n          after = [ \"test2.service\" ];\n          enabledAddons = [ config.shb.mitmdump.addons.logger ];\n          extraArgs = [\n            \"--set\"\n            \"verbose_pattern=/verbose\"\n          ];\n        };\n      };\n\n    testScript =\n      { nodes, ... }:\n      ''\n        start_all()\n\n        machine.wait_for_unit(\"test1.service\")\n        machine.wait_for_unit(\"test2.service\")\n        machine.wait_for_unit(\"mitmdump-test1.service\")\n        machine.wait_for_unit(\"mitmdump-test2.service\")\n\n        resp = machine.succeed(\"curl http://127.0.0.1:8000\")\n        print(resp)\n        if resp != \"test1/\":\n            raise Exception(\"wanted 'test1'\")\n\n        resp = machine.succeed(\"curl -v http://127.0.0.1:8001\")\n        print(resp)\n        if resp != \"test1/\":\n            raise Exception(\"wanted 'test1'\")\n\n        resp = machine.succeed(\"curl http://127.0.0.1:8002\")\n        print(resp)\n        if resp != \"test2/\":\n            raise Exception(\"wanted 'test2'\")\n\n        resp = machine.succeed(\"curl http://127.0.0.1:8003/notverbose\")\n        print(resp)\n        if resp != \"test2/notverbose\":\n            raise Exception(\"wanted 'test2/notverbose'\")\n\n        resp = machine.succeed(\"curl http://127.0.0.1:8003/verbose\")\n        print(resp)\n        if resp != \"test2/verbose\":\n            raise Exception(\"wanted 'test2/verbose'\")\n\n        dump = machine.succeed(\"journalctl -b -u mitmdump-test1.service\")\n        print(dump)\n        if \"HTTP/1.0 200 OK\" not in dump:\n            raise Exception(\"expected to see HTTP/1.0 200 OK\")\n        if \"test1\" not in dump:\n            raise Exception(\"expected to see test1\")\n\n        dump = machine.succeed(\"journalctl -b -u mitmdump-test2.service\")\n        print(dump)\n        if \"HTTP/1.0 200 OK\" not in dump:\n            raise Exception(\"expected to see HTTP/1.0 200 OK\")\n        if \"test2/notverbose\" in dump:\n            raise Exception(\"expected not to see test2/notverbose\")\n        if \"test2/verbose\" not in dump:\n            raise Exception(\"expected to see test2/verbose\")\n      '';\n  };\n}\n"
  },
  {
    "path": "test/blocks/monitoring.nix",
    "content": "{ shb, ... }:\nlet\n  password = \"securepw\";\n  oidcSecret = \"oidcSecret\";\n\n  commonTestScript = shb.test.accessScript {\n    hasSSL = { node, ... }: !(isNull node.config.shb.monitoring.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"grafana.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.monitoring.grafanaPort\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/monitoring.nix\n      ];\n\n      test = {\n        subdomain = \"g\";\n      };\n\n      shb.monitoring = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n        scrutiny.enable = false;\n\n        contactPoints = [ \"me@example.com\" ];\n\n        grafanaPort = 3000;\n        adminPassword.result = config.shb.hardcodedsecret.\"admin_password\".result;\n        secretKey.result = config.shb.hardcodedsecret.\"secret_key\".result;\n      };\n\n      shb.hardcodedsecret.\"admin_password\" = {\n        request = config.shb.monitoring.adminPassword.request;\n        settings.content = password;\n      };\n      shb.hardcodedsecret.\"secret_key\" = {\n        request = config.shb.monitoring.secretKey.request;\n        settings.content = \"secret_key_pw\";\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.monitoring = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.monitoring = {\n        ldap = {\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"g\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Welcome to Grafana')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Welcome to Grafana')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"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?\n              \"expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  scrutiny =\n    { lib, ... }:\n    {\n      shb.monitoring = {\n        scrutiny.enable = lib.mkForce true;\n      };\n    };\n\n  clientScrutinyLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"scrutiny\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              ''\n                if page.get_by_role('button', name=re.compile('Accept')).count() > 0:\n                    page.get_by_role('button', name=re.compile('Accept')).click()\n              ''\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Temperature history for each device')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              ''\n                if page.get_by_role('button', name=re.compile('Accept')).count() > 0:\n                    page.get_by_role('button', name=re.compile('Accept')).click()\n              ''\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Temperature history for each device')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"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?\n              \"expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.monitoring = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n\n          sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;\n          sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;\n        };\n      };\n\n      shb.hardcodedsecret.oidcSecret = {\n        request = config.shb.monitoring.sso.sharedSecret.request;\n        settings.content = oidcSecret;\n      };\n      shb.hardcodedsecret.oidcAutheliaSecret = {\n        request = config.shb.monitoring.sso.sharedSecretForAuthelia.request;\n        settings.content = oidcSecret;\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"monitoring_basic\";\n\n    node.pkgsReadOnly = false;\n\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"monitoring_https\";\n\n    node.pkgsReadOnly = false;\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"monitoring_sso\";\n\n    node.pkgsReadOnly = false;\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n\n      virtualisation.memorySize = 4096;\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n\n        # virtualisation.memorySize = 4096;\n      };\n\n    testScript = commonTestScript;\n  };\n\n  scrutiny_sso = shb.test.runNixOSTest {\n    name = \"monitoring_scrutiny_sso\";\n\n    node.pkgsReadOnly = false;\n\n    nodes.client = {\n      imports = [\n        clientScrutinyLoginSso\n      ];\n\n      virtualisation.memorySize = 4096;\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          scrutiny\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n\n        # virtualisation.memorySize = 4096;\n      };\n\n    testScript = commonTestScript;\n  };\n}\n"
  },
  {
    "path": "test/blocks/postgresql.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  pkgs' = pkgs;\nin\n{\n  peerWithoutUser = shb.test.runNixOSTest {\n    name = \"postgresql-peerWithoutUser\";\n\n    nodes.machine =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          ../../modules/blocks/postgresql.nix\n        ];\n\n        shb.postgresql.ensures = [\n          {\n            username = \"me-with-special-chars\";\n            database = \"me-with-special-chars\";\n          }\n        ];\n      };\n\n    testScript =\n      { nodes, ... }:\n      ''\n        start_all()\n        machine.wait_for_unit(\"postgresql.service\")\n        machine.wait_for_open_port(5432)\n\n        def peer_cmd(user, database):\n            return \"sudo -u me psql -U {user} {db} --command \\\"\\\"\".format(user=user, db=database)\n\n        with subtest(\"cannot login because of missing user\"):\n            machine.fail(peer_cmd(\"me-with-special-chars\", \"me-with-special-chars\"), timeout=10)\n\n        with subtest(\"cannot login with unknown user\"):\n            machine.fail(peer_cmd(\"notme\", \"me-with-other-chars\"), timeout=10)\n\n        with subtest(\"cannot login to unknown database\"):\n            machine.fail(peer_cmd(\"me-with-special-chars\", \"notmine\"), timeout=10)\n      '';\n  };\n\n  peerAuth = shb.test.runNixOSTest {\n    name = \"postgresql-peerAuth\";\n\n    nodes.machine =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          ../../modules/blocks/postgresql.nix\n        ];\n\n        users.users.me = {\n          isSystemUser = true;\n          group = \"me\";\n          extraGroups = [ \"sudoers\" ];\n        };\n        users.groups.me = { };\n\n        shb.postgresql.ensures = [\n          {\n            username = \"me\";\n            database = \"me\";\n          }\n        ];\n      };\n\n    testScript =\n      { nodes, ... }:\n      ''\n        start_all()\n        machine.wait_for_unit(\"postgresql.service\")\n        machine.wait_for_open_port(5432)\n\n        def peer_cmd(user, database):\n            return \"sudo -u me psql -U {user} {db} --command \\\"\\\"\".format(user=user, db=database)\n\n        def tcpip_cmd(user, database, port):\n            return \"psql -h 127.0.0.1 -p {port} -U {user} {db} --command \\\"\\\"\".format(user=user, db=database, port=port)\n\n        with subtest(\"can login with provisioned user and database\"):\n            machine.succeed(peer_cmd(\"me\", \"me\"), timeout=10)\n\n        with subtest(\"cannot login with unknown user\"):\n            machine.fail(peer_cmd(\"notme\", \"me\"), timeout=10)\n\n        with subtest(\"cannot login to unknown database\"):\n            machine.fail(peer_cmd(\"me\", \"notmine\"), timeout=10)\n\n        with subtest(\"cannot login with tcpip\"):\n            machine.fail(tcpip_cmd(\"me\", \"me\", \"5432\"), timeout=10)\n      '';\n  };\n\n  tcpIPWithoutPasswordAuth = shb.test.runNixOSTest {\n    name = \"postgresql-tcpIpWithoutPasswordAuth\";\n\n    nodes.machine =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          ../../modules/blocks/postgresql.nix\n        ];\n\n        shb.postgresql.enableTCPIP = true;\n        shb.postgresql.ensures = [\n          {\n            username = \"me\";\n            database = \"me\";\n          }\n        ];\n      };\n\n    testScript =\n      { nodes, ... }:\n      ''\n        start_all()\n        machine.wait_for_unit(\"postgresql.service\")\n        machine.wait_for_open_port(5432)\n\n        def peer_cmd(user, database):\n            return \"sudo -u me psql -U {user} {db} --command \\\"\\\"\".format(user=user, db=database)\n\n        def tcpip_cmd(user, database, port):\n            return \"psql -h 127.0.0.1 -p {port} -U {user} {db} --command \\\"\\\"\".format(user=user, db=database, port=port)\n\n        with subtest(\"cannot login without existing user\"):\n            machine.fail(peer_cmd(\"me\", \"me\"), timeout=10)\n\n        with subtest(\"cannot login with user without password\"):\n            machine.fail(tcpip_cmd(\"me\", \"me\", \"5432\"), timeout=10)\n      '';\n  };\n\n  tcpIPPasswordAuth =\n    let\n      username = \"me-with-special-chars\";\n    in\n    shb.test.runNixOSTest {\n      name = \"postgresql-tcpIPPasswordAuth\";\n\n      nodes.machine =\n        { config, pkgs, ... }:\n        {\n          imports = [\n            (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n            (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n            ../../modules/blocks/postgresql.nix\n          ];\n\n          users.users.${username} = {\n            isSystemUser = true;\n            group = username;\n            extraGroups = [ \"sudoers\" ];\n          };\n          users.groups.${username} = { };\n\n          system.activationScripts.secret = ''\n            echo secretpw > /run/dbsecret\n          '';\n          shb.postgresql.enableTCPIP = true;\n          shb.postgresql.ensures = [\n            {\n              username = username;\n              database = username;\n              passwordFile = \"/run/dbsecret\";\n            }\n          ];\n        };\n\n      testScript =\n        { nodes, ... }:\n        ''\n          start_all()\n          machine.wait_for_unit(\"postgresql.service\")\n          machine.wait_for_open_port(5432)\n\n          def peer_cmd(user, database):\n              return \"sudo -u ${username} psql -U {user} {db} --command \\\"\\\"\".format(user=user, db=database)\n\n          def tcpip_cmd(user, database, port, password):\n              return \"PGPASSWORD={password} psql -h 127.0.0.1 -p {port} -U {user} {db} --command \\\"\\\"\".format(user=user, db=database, port=port, password=password)\n\n          with subtest(\"can peer login with provisioned user and database\"):\n              machine.succeed(peer_cmd(\"${username}\", \"${username}\"), timeout=10)\n\n          with subtest(\"can tcpip login with provisioned user and database\"):\n              machine.succeed(tcpip_cmd(\"${username}\", \"${username}\", \"5432\", \"secretpw\"), timeout=10)\n\n          with subtest(\"cannot tcpip login with wrong password\"):\n              machine.fail(tcpip_cmd(\"${username}\", \"${username}\", \"5432\", \"oops\"), timeout=10)\n        '';\n    };\n}\n"
  },
  {
    "path": "test/blocks/restic.nix",
    "content": "{ lib, shb, ... }:\nlet\n  commonTest =\n    user:\n    shb.test.runNixOSTest {\n      name = \"restic_backupAndRestore_${user}\";\n\n      nodes.machine =\n        { config, ... }:\n        {\n          imports = [\n            shb.test.baseImports\n\n            ../../modules/blocks/hardcodedsecret.nix\n            ../../modules/blocks/restic.nix\n          ];\n\n          shb.hardcodedsecret.A = {\n            request = {\n              owner = \"root\";\n              group = \"keys\";\n              mode = \"0440\";\n            };\n            settings.content = \"secretA\";\n          };\n          shb.hardcodedsecret.B = {\n            request = {\n              owner = \"root\";\n              group = \"keys\";\n              mode = \"0440\";\n            };\n            settings.content = \"secretB\";\n          };\n\n          shb.hardcodedsecret.passphrase = {\n            request = config.shb.restic.instances.\"testinstance\".settings.passphrase.request;\n            settings.content = \"secretB\";\n          };\n\n          shb.restic.instances.\"testinstance\" = {\n            settings = {\n              enable = true;\n\n              passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n\n              repository = {\n                path = \"/opt/repos/A\";\n                timerConfig = {\n                  OnCalendar = \"00:00:00\";\n                  RandomizedDelaySec = \"5h\";\n                };\n                # Those are not needed by the repository but are still included\n                # so we can test them in the hooks section.\n                secrets = {\n                  A.source = config.shb.hardcodedsecret.A.result.path;\n                  B.source = config.shb.hardcodedsecret.B.result.path;\n                };\n              };\n            };\n\n            request = {\n              inherit user;\n\n              sourceDirectories = [\n                \"/opt/files/A\"\n                \"/opt/files/B\"\n              ];\n\n              hooks.beforeBackup = [\n                ''\n                  echo $RUNTIME_DIRECTORY\n                  if [ \"$RUNTIME_DIRECTORY\" = /run/restic-backups-testinstance_opt_repos_A ]; then\n                    if ! [ -f /run/secrets_restic/restic-backups-testinstance_opt_repos_A ]; then\n                      exit 10\n                    fi\n                    if [ -z \"$A\" ] || ! [ \"$A\" = \"secretA\" ]; then\n                      echo \"A:$A\"\n                      exit 11\n                    fi\n                    if [ -z \"$B\" ] || ! [ \"$B\" = \"secretB\" ]; then\n                      echo \"B:$B\"\n                      exit 12\n                    fi\n                  fi\n                ''\n              ];\n            };\n          };\n        };\n\n      extraPythonPackages = p: [ p.dictdiffer ];\n      skipTypeCheck = true;\n\n      testScript =\n        { nodes, ... }:\n        let\n          provider = nodes.machine.shb.restic.instances.\"testinstance\";\n          backupService = provider.result.backupService;\n          restoreScript = provider.result.restoreScript;\n        in\n        ''\n          from dictdiffer import diff\n\n          def list_files(dir):\n              files_and_content = {}\n\n              files = machine.succeed(f\"\"\"\n              find {dir} -type f\n              \"\"\").split(\"\\n\")[:-1]\n\n              for f in files:\n                  content = machine.succeed(f\"\"\"\n                  cat {f}\n                  \"\"\").strip()\n                  files_and_content[f] = content\n\n              return files_and_content\n\n          def assert_files(dir, files):\n              result = list(diff(list_files(dir), files))\n              if len(result) > 0:\n                  raise Exception(\"Unexpected files:\", result)\n\n          with subtest(\"Create initial content\"):\n              machine.succeed(\"\"\"\n              mkdir -p /opt/files/A\n              mkdir -p /opt/files/B\n\n              echo repoA_fileA_1 > /opt/files/A/fileA\n              echo repoA_fileB_1 > /opt/files/A/fileB\n              echo repoB_fileA_1 > /opt/files/B/fileA\n              echo repoB_fileB_1 > /opt/files/B/fileB\n\n              chown ${user}: -R /opt/files\n              chmod go-rwx -R /opt/files\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_1',\n                  '/opt/files/B/fileB': 'repoB_fileB_1',\n                  '/opt/files/A/fileA': 'repoA_fileA_1',\n                  '/opt/files/A/fileB': 'repoA_fileB_1',\n              })\n\n          with subtest(\"First backup in repo A\"):\n              machine.succeed(\"systemctl start ${backupService}\")\n\n          with subtest(\"New content\"):\n              machine.succeed(\"\"\"\n              echo repoA_fileA_2 > /opt/files/A/fileA\n              echo repoA_fileB_2 > /opt/files/A/fileB\n              echo repoB_fileA_2 > /opt/files/B/fileA\n              echo repoB_fileB_2 > /opt/files/B/fileB\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_2',\n                  '/opt/files/B/fileB': 'repoB_fileB_2',\n                  '/opt/files/A/fileA': 'repoA_fileA_2',\n                  '/opt/files/A/fileB': 'repoA_fileB_2',\n              })\n\n          with subtest(\"Delete content\"):\n              machine.succeed(\"\"\"\n              rm -r /opt/files/A /opt/files/B\n              \"\"\")\n\n              assert_files(\"/opt/files\", {})\n\n          with subtest(\"Restore initial content from repo A\"):\n              machine.succeed(\"\"\"\n              ${restoreScript} restore latest\n              \"\"\")\n\n              assert_files(\"/opt/files\", {\n                  '/opt/files/B/fileA': 'repoB_fileA_1',\n                  '/opt/files/B/fileB': 'repoB_fileB_1',\n                  '/opt/files/A/fileA': 'repoA_fileA_1',\n                  '/opt/files/A/fileB': 'repoA_fileB_1',\n              })\n        '';\n\n    };\nin\n{\n  backupAndRestoreRoot = commonTest \"root\";\n  backupAndRestoreUser = commonTest \"nobody\";\n}\n"
  },
  {
    "path": "test/blocks/ssl.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  pkgs' = pkgs;\nin\n{\n  test = shb.test.runNixOSTest {\n    name = \"ssl-test\";\n\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          (pkgs'.path + \"/nixos/modules/profiles/headless.nix\")\n          (pkgs'.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n          ../../modules/blocks/ssl.nix\n        ];\n\n        users.users = {\n          user1 = {\n            group = \"group1\";\n            isSystemUser = true;\n          };\n          user2 = {\n            group = \"group2\";\n            isSystemUser = true;\n          };\n        };\n        users.groups = {\n          group1 = { };\n          group2 = { };\n        };\n\n        shb.certs = {\n          cas.selfsigned = {\n            myca = {\n              name = \"My CA\";\n            };\n            myotherca = {\n              name = \"My Other CA\";\n            };\n          };\n          certs.selfsigned = {\n            top = {\n              ca = config.shb.certs.cas.selfsigned.myca;\n\n              domain = \"example.com\";\n              group = \"nginx\";\n            };\n            subdomain = {\n              ca = config.shb.certs.cas.selfsigned.myca;\n\n              domain = \"subdomain.example.com\";\n              group = \"nginx\";\n            };\n            multi = {\n              ca = config.shb.certs.cas.selfsigned.myca;\n\n              domain = \"multi1.example.com\";\n              extraDomains = [\n                \"multi2.example.com\"\n                \"multi3.example.com\"\n              ];\n              group = \"nginx\";\n            };\n\n            cert1 = {\n              ca = config.shb.certs.cas.selfsigned.myca;\n\n              domain = \"cert1.example.com\";\n            };\n            cert2 = {\n              ca = config.shb.certs.cas.selfsigned.myca;\n\n              domain = \"cert2.example.com\";\n              group = \"group2\";\n            };\n          };\n        };\n\n        # The configuration below is to create a webserver that uses the server certificate.\n        networking.hosts.\"127.0.0.1\" = [\n          \"example.com\"\n          \"subdomain.example.com\"\n          \"wrong.example.com\"\n          \"multi1.example.com\"\n          \"multi2.example.com\"\n          \"multi3.example.com\"\n        ];\n\n        services.nginx.enable = true;\n        services.nginx.virtualHosts =\n          let\n            mkVirtualHost = response: cert: {\n              onlySSL = true;\n              sslCertificate = cert.paths.cert;\n              sslCertificateKey = cert.paths.key;\n              locations.\"/\".extraConfig = ''\n                add_header Content-Type text/plain;\n                return 200 '${response}';\n              '';\n            };\n          in\n          {\n            \"example.com\" = mkVirtualHost \"Top domain\" config.shb.certs.certs.selfsigned.top;\n            \"subdomain.example.com\" = mkVirtualHost \"Subdomain\" config.shb.certs.certs.selfsigned.subdomain;\n            \"multi1.example.com\" = mkVirtualHost \"multi1\" config.shb.certs.certs.selfsigned.multi;\n            \"multi2.example.com\" = mkVirtualHost \"multi2\" config.shb.certs.certs.selfsigned.multi;\n            \"multi3.example.com\" = mkVirtualHost \"multi3\" config.shb.certs.certs.selfsigned.multi;\n          };\n        systemd.services.nginx = {\n          after = [\n            config.shb.certs.certs.selfsigned.top.systemdService\n            config.shb.certs.certs.selfsigned.subdomain.systemdService\n            config.shb.certs.certs.selfsigned.multi.systemdService\n            config.shb.certs.certs.selfsigned.cert1.systemdService\n            config.shb.certs.certs.selfsigned.cert2.systemdService\n          ];\n          requires = [\n            config.shb.certs.certs.selfsigned.top.systemdService\n            config.shb.certs.certs.selfsigned.subdomain.systemdService\n            config.shb.certs.certs.selfsigned.multi.systemdService\n            config.shb.certs.certs.selfsigned.cert1.systemdService\n            config.shb.certs.certs.selfsigned.cert2.systemdService\n          ];\n        };\n      };\n\n    # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix\n    testScript =\n      { nodes, ... }:\n      let\n        myca = nodes.server.shb.certs.cas.selfsigned.myca;\n        myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca;\n        top = nodes.server.shb.certs.certs.selfsigned.top;\n        subdomain = nodes.server.shb.certs.certs.selfsigned.subdomain;\n        multi = nodes.server.shb.certs.certs.selfsigned.multi;\n        cert1 = nodes.server.shb.certs.certs.selfsigned.cert1;\n        cert2 = nodes.server.shb.certs.certs.selfsigned.cert2;\n      in\n      ''\n        start_all()\n\n        # Make sure certs are generated.\n        server.wait_for_file(\"${myca.paths.key}\")\n        server.wait_for_file(\"${myca.paths.cert}\")\n        server.wait_for_file(\"${myotherca.paths.key}\")\n        server.wait_for_file(\"${myotherca.paths.cert}\")\n        server.wait_for_file(\"${top.paths.key}\")\n        server.wait_for_file(\"${top.paths.cert}\")\n        server.wait_for_file(\"${subdomain.paths.key}\")\n        server.wait_for_file(\"${subdomain.paths.cert}\")\n        server.wait_for_file(\"${multi.paths.key}\")\n        server.wait_for_file(\"${multi.paths.cert}\")\n        server.wait_for_file(\"${cert1.paths.key}\")\n        server.wait_for_file(\"${cert1.paths.cert}\")\n        server.wait_for_file(\"${cert2.paths.key}\")\n        server.wait_for_file(\"${cert2.paths.cert}\")\n\n        server.require_unit_state(\"${nodes.server.shb.certs.systemdService}\", \"inactive\")\n\n        server.wait_for_unit(\"nginx\")\n        server.wait_for_open_port(443)\n\n        def assert_owner(path, user, group):\n            owner = server.succeed(\"stat --format '%U:%G' {}\".format(path)).strip();\n            want_owner = user + \":\" + group\n            if owner != want_owner:\n                raise Exception('Unexpected owner for {}: wanted \"{}\", got: \"{}\"'.format(path, want_owner, owner))\n\n        def assert_perm(path, want_perm):\n            perm = server.succeed(\"stat --format '%a' {}\".format(path)).strip();\n            if perm != want_perm:\n                raise Exception('Unexpected perm for {}: wanted \"{}\", got: \"{}\"'.format(path, want_perm, perm))\n\n        with subtest(\"Certificates content seem correct\"):\n            myca_key = server.succeed(\"cat {}\".format(\"${myca.paths.key}\")).strip();\n            myca_cert = server.succeed(\"cat {}\".format(\"${myca.paths.cert}\")).strip();\n            cert1_key = server.succeed(\"cat {}\".format(\"${cert1.paths.key}\")).strip();\n            cert1_cert = server.succeed(\"cat {}\".format(\"${cert1.paths.cert}\")).strip();\n            cert2_key = server.succeed(\"cat {}\".format(\"${cert2.paths.key}\")).strip();\n            cert2_cert = server.succeed(\"cat {}\".format(\"${cert2.paths.cert}\")).strip();\n            ca_bundle = server.succeed(\"cat /etc/ssl/certs/ca-bundle.crt\").strip();\n\n            if myca_cert == \"\":\n              raise Exception(\"CA cert was empty\")\n            if cert1_key == \"\":\n              raise Exception(\"Cert1 key was empty\")\n            if cert1_cert == \"\":\n              raise Exception(\"Cert1 cert was empty\")\n            if cert2_key == \"\":\n              raise Exception(\"Cert2 key was empty\")\n            if cert2_cert == \"\":\n              raise Exception(\"Cert2 cert was empty\")\n            if cert1_key == cert2_key:\n              raise Exception(\"Cert1 key and cert2 key are the same\")\n            if cert1_cert == cert2_cert:\n              raise Exception(\"Cert1 cert and cert2 cert are the same\")\n            if ca_bundle == \"\":\n              raise Exception(\"CA bundle was empty\")\n\n        with subtest(\"Certificate is trusted in curl\"):\n            resp = server.succeed(\"curl --fail-with-body -v https://example.com\")\n            if resp != \"Top domain\":\n                raise Exception('Unexpected response, got: {}'.format(resp))\n\n            resp = server.succeed(\"curl --fail-with-body -v https://subdomain.example.com\")\n            if resp != \"Subdomain\":\n                raise Exception('Unexpected response, got: {}'.format(resp))\n\n            resp = server.succeed(\"curl --fail-with-body -v https://multi1.example.com\")\n            if resp != \"multi1\":\n                raise Exception('Unexpected response, got: {}'.format(resp))\n\n            resp = server.succeed(\"curl --fail-with-body -v https://multi2.example.com\")\n            if resp != \"multi2\":\n                raise Exception('Unexpected response, got: {}'.format(resp))\n\n            resp = server.succeed(\"curl --fail-with-body -v https://multi3.example.com\")\n            if resp != \"multi3\":\n                raise Exception('Unexpected response, got: {}'.format(resp))\n\n        with subtest(\"Certificate has correct permission\"):\n            assert_owner(\"${cert1.paths.key}\", \"root\", \"root\")\n            assert_owner(\"${cert1.paths.cert}\", \"root\", \"root\")\n            assert_perm(\"${cert1.paths.key}\", \"640\")\n            assert_perm(\"${cert1.paths.cert}\", \"640\")\n            \n            assert_owner(\"${cert2.paths.key}\", \"root\", \"group2\")\n            assert_owner(\"${cert2.paths.cert}\", \"root\", \"group2\")\n            assert_perm(\"${cert2.paths.key}\", \"640\")\n            assert_perm(\"${cert2.paths.cert}\", \"640\")\n\n        with subtest(\"Certificates content seem correct\"):\n            if cert1_key == \"\":\n              raise Exception(\"Cert1 key was empty\")\n            if cert1_cert == \"\":\n              raise Exception(\"Cert1 cert was empty\")\n            if cert2_key == \"\":\n              raise Exception(\"Cert2 key was empty\")\n            if cert2_cert == \"\":\n              raise Exception(\"Cert2 cert was empty\")\n            if cert1_key == cert2_key:\n              raise Exception(\"Cert1 key and cert2 key are the same\")\n            if cert1_cert == cert2_cert:\n              raise Exception(\"Cert1 cert and cert2 cert are the same\")\n\n        with subtest(\"Fail if certificate is not in CA bundle\"):\n            server.fail(\"curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://example.com\")\n            server.fail(\"curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://subdomain.example.com\")\n            server.fail(\"curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://example.com\")\n            server.fail(\"curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://subdomain.example.com\")\n\n        with subtest(\"Idempotency\"):\n            server.succeed(\"systemctl restart shb-certs-ca-myca\")\n            server.succeed(\"systemctl restart shb-certs-cert-selfsigned-cert1\")\n            server.succeed(\"systemctl restart shb-certs-cert-selfsigned-cert2\")\n\n            new_myca_key = server.succeed(\"cat {}\".format(\"${myca.paths.key}\")).strip();\n            new_myca_cert = server.succeed(\"cat {}\".format(\"${myca.paths.cert}\")).strip();\n            new_cert1_key = server.succeed(\"cat {}\".format(\"${cert1.paths.key}\")).strip();\n            new_cert1_cert = server.succeed(\"cat {}\".format(\"${cert1.paths.cert}\")).strip();\n            new_cert2_key = server.succeed(\"cat {}\".format(\"${cert2.paths.key}\")).strip();\n            new_cert2_cert = server.succeed(\"cat {}\".format(\"${cert2.paths.cert}\")).strip();\n            new_ca_bundle = server.succeed(\"cat /etc/ssl/certs/ca-bundle.crt\").strip();\n            if new_myca_key != myca_key:\n                raise Exception(\"New CA key is different from old one.\")\n            if new_myca_cert != myca_cert:\n                raise Exception(\"New CA cert is different from old one.\")\n            if new_cert1_key != cert1_key:\n                raise Exception(\"New Cert1 key is different from old one.\")\n            if new_cert1_cert != cert1_cert:\n                raise Exception(\"New Cert1 cert is different from old one.\")\n            if new_cert2_key != cert2_key:\n                raise Exception(\"New Cert2 key is different from old one.\")\n            if new_cert2_cert != cert2_cert:\n                raise Exception(\"New Cert2 cert is different from old one.\")\n            if new_ca_bundle != ca_bundle:\n                raise Exception(\"New CA bundle is different from old one.\")\n      '';\n  };\n}\n"
  },
  {
    "path": "test/common.nix",
    "content": "{ pkgs, lib }:\nlet\n  inherit (lib) hasAttr mkOption optionalString;\n  inherit (lib.types)\n    bool\n    enum\n    listOf\n    nullOr\n    submodule\n    str\n    ;\n\n  baseImports = {\n    imports = [\n      (pkgs.path + \"/nixos/modules/profiles/headless.nix\")\n      (pkgs.path + \"/nixos/modules/profiles/qemu-guest.nix\")\n    ];\n  };\n\n  accessScript = lib.makeOverridable (\n    {\n      hasSSL,\n      waitForServices ? s: [ ],\n      waitForPorts ? p: [ ],\n      waitForUnixSocket ? u: [ ],\n      waitForUrls ? u: [ ],\n      extraScript ? { ... }: \"\",\n      redirectSSO ? false,\n    }:\n    { nodes, ... }:\n    let\n      cfg = nodes.server.test;\n\n      fqdn = \"${cfg.subdomain}.${cfg.domain}\";\n      proto_fqdn = if hasSSL args then \"https://${fqdn}\" else \"http://${fqdn}\";\n\n      args = {\n        node.name = \"server\";\n        node.config = nodes.server;\n        inherit fqdn proto_fqdn;\n      };\n\n      autheliaEnabled = (hasAttr \"authelia\" nodes.server.shb) && nodes.server.shb.authelia.enable;\n      lldapEnabled = (hasAttr \"lldap\" nodes.server.shb) && nodes.server.shb.lldap.enable;\n    in\n    ''\n      import json\n      import os\n      import pathlib\n\n      start_all()\n\n      def curl(target, format, endpoint, data=\"\", extra=\"\"):\n          cmd = (\"curl --show-error --location\"\n                + \" --cookie-jar cookie.txt\"\n                + \" --cookie cookie.txt\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                # Client must be able to resolve talking to auth server\n                + \" --connect-to auth.${cfg.domain}:443:server:443\"\n                + (f\" --data '{data}'\" if data != \"\" else \"\")\n                + (f\" --silent --output /dev/null --write-out '{format}'\" if format != \"\" else \"\")\n                + (f\" {extra}\" if extra != \"\" else \"\")\n                + f\" {endpoint}\")\n          print(cmd)\n          _, r = target.execute(cmd)\n          print(r)\n          try:\n              return json.loads(r)\n          except:\n              return r\n\n      def unline_with(j, s):\n          return j.join((x.strip() for x in s.split(\"\\n\")))\n    ''\n    + lib.strings.concatMapStrings (s: ''server.wait_for_unit(\"${s}\")'' + \"\\n\") (\n      waitForServices args\n      ++ (lib.optionals autheliaEnabled [ \"authelia-auth.${cfg.domain}.service\" ])\n      ++ (lib.optionals lldapEnabled [ \"lldap.service\" ])\n    )\n    + lib.strings.concatMapStrings (p: \"server.wait_for_open_port(${toString p})\" + \"\\n\") (\n      waitForPorts args\n      # TODO: when the SSO block exists, replace this hardcoded port.\n      ++ (lib.optionals autheliaEnabled [\n        9091 # nodes.server.services.authelia.instances.\"auth.${domain}\".settings.server.port\n      ])\n    )\n    + lib.strings.concatMapStrings (u: ''server.wait_for_open_unix_socket(\"${u}\")'' + \"\\n\") (\n      waitForUnixSocket args\n    )\n    + ''\n      if ${if hasSSL args then \"True\" else \"False\"}:\n          server.copy_from_vm(\"/etc/ssl/certs/ca-certificates.crt\")\n          client.succeed(\"rm -r /etc/ssl/certs\")\n          client.copy_from_host(str(pathlib.Path(os.environ.get(\"out\", os.getcwd())) / \"ca-certificates.crt\"), \"/etc/ssl/certs/ca-certificates.crt\")\n\n    ''\n    # Making a curl request to an URL needs to happen after we copied the certificates over,\n    # otherwise curl will not be able to verify the \"legitimacy of the server\".\n    + lib.strings.concatMapStrings (\n      u:\n      let\n        url = if builtins.isString u then u else u.url;\n        status = if builtins.isString u then 200 else u.status;\n      in\n      ''\n        import time\n\n        done = False\n        count = 15\n        while not done and count > 0:\n            response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${url}\")\n            time.sleep(5)\n            count -= 1\n            if isinstance(response, dict):\n                done = response.get('code') == ${toString status}\n        if not done:\n            raise Exception(f\"Response was never ${toString status}, got last: {response}\")\n      ''\n      + \"\\n\"\n    ) (waitForUrls args)\n    + (\n      if (!redirectSSO) then\n        ''\n          with subtest(\"access\"):\n              response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${proto_fqdn}\")\n\n              if response['code'] != 200:\n                  raise Exception(f\"Code is {response['code']}\")\n        ''\n      else\n        ''\n          with subtest(\"unauthenticated access is not granted\"):\n              response = curl(client, \"\"\"{\"code\":%{response_code},\"auth_host\":\"%{urle.host}\",\"auth_query\":\"%{urle.query}\",\"all\":%{json}}\"\"\", \"${proto_fqdn}\")\n\n              if response['code'] != 200:\n                  raise Exception(f\"Code is {response['code']}\")\n              if response['auth_host'] != \"auth.${cfg.domain}\":\n                  raise Exception(f\"auth host should be auth.${cfg.domain} but is {response['auth_host']}\")\n              if response['auth_query'] != \"rd=${proto_fqdn}/\":\n                  raise Exception(f\"auth query should be rd=${proto_fqdn}/ but is {response['auth_query']}\")\n        ''\n    )\n    + (\n      let\n        script = extraScript args;\n      in\n      lib.optionalString (script != \"\") script\n    )\n    + (optionalString (hasAttr \"test\" nodes.server && hasAttr \"login\" nodes.server.test) ''\n      with subtest(\"Login from server\"):\n          code, logs = server.execute(\"login_playwright\")\n          print(logs)\n          try:\n              server.copy_from_vm(\"trace\")\n          except:\n              print(\"No trace found on server\")\n          # if code != 0:\n          #     raise Exception(\"login_playwright did not succeed\")\n    '')\n    + (optionalString (hasAttr \"test\" nodes.client && hasAttr \"login\" nodes.client.test) ''\n      with subtest(\"Login from client\"):\n          code, logs = client.execute(\"login_playwright\")\n          print(logs)\n          try:\n              client.copy_from_vm(\"trace\")\n          except:\n              print(\"No trace found on client\")\n          # if code != 0:\n          #     raise Exception(\"login_playwright did not succeed\")\n    '')\n  );\n\n  backupScript =\n    args:\n    (accessScript args).override {\n      extraScript =\n        { proto_fqdn, ... }:\n        ''\n          with subtest(\"backup\"):\n              server.succeed(\"systemctl start restic-backups-testinstance_opt_repos_A\")\n        '';\n    };\nin\n{\n  inherit baseImports accessScript;\n\n  runNixOSTest =\n    args:\n    pkgs.testers.runNixOSTest (\n      {\n        interactive.sshBackdoor.enable = true;\n      }\n      // args\n    );\n\n  mkScripts = args: {\n    access = accessScript args;\n    backup = backupScript args;\n  };\n\n  baseModule =\n    { config, ... }:\n    {\n      options.test = {\n        domain = mkOption {\n          type = str;\n          default = \"example.com\";\n        };\n        subdomain = mkOption {\n          type = str;\n        };\n        fqdn = mkOption {\n          type = str;\n          readOnly = true;\n          default = \"${config.test.subdomain}.${config.test.domain}\";\n        };\n        hasSSL = mkOption {\n          type = bool;\n          default = false;\n        };\n        proto = mkOption {\n          type = str;\n          readOnly = true;\n          default = if config.test.hasSSL then \"https\" else \"http\";\n        };\n        proto_fqdn = mkOption {\n          type = str;\n          readOnly = true;\n          default = \"${config.test.proto}://${config.test.fqdn}\";\n        };\n      };\n      imports = [\n        baseImports\n        ../modules/blocks/hardcodedsecret.nix\n        ../modules/blocks/nginx.nix\n      ];\n      config = {\n        # HTTP(s) server port.\n        networking.firewall.allowedTCPPorts = [\n          80\n          443\n        ];\n        shb.nginx.accessLog = true;\n\n        networking.hosts = {\n          \"192.168.1.2\" = [\n            config.test.fqdn\n            \"auth.${config.test.domain}\"\n          ];\n        };\n      };\n    };\n\n  clientLoginModule =\n    { config, pkgs, ... }:\n    let\n      cfg = config.test.login;\n    in\n    {\n      options.test.login = {\n        browser = mkOption {\n          type = enum [\n            \"firefox\"\n            \"chromium\"\n            \"webkit\"\n          ];\n          default = \"firefox\";\n        };\n        usernameFieldLabelRegex = mkOption {\n          type = str;\n          default = \"[Uu]sername\";\n        };\n        usernameFieldSelector = mkOption {\n          type = str;\n          default = \"get_by_label(re.compile('${cfg.usernameFieldLabelRegex}'))\";\n        };\n        passwordFieldLabelRegex = mkOption {\n          type = str;\n          default = \"[Pp]assword\";\n        };\n        passwordFieldSelector = mkOption {\n          type = str;\n          default = \"get_by_label(re.compile('${cfg.passwordFieldLabelRegex}'))\";\n        };\n        loginButtonNameRegex = mkOption {\n          type = str;\n          default = \"[Ll]ogin\";\n        };\n        loginSpawnsNewPage = mkOption {\n          type = bool;\n          default = false;\n        };\n        testLoginWith = mkOption {\n          type = listOf (submodule {\n            options = {\n              username = mkOption {\n                type = nullOr str;\n                default = null;\n              };\n              password = mkOption {\n                type = nullOr str;\n                default = null;\n              };\n              nextPageExpect = mkOption {\n                type = listOf str;\n              };\n            };\n          });\n        };\n        startUrl = mkOption {\n          type = str;\n          default = \"http://${config.test.fqdn}\";\n        };\n        beforeHook = mkOption {\n          type = str;\n          default = \"\";\n        };\n      };\n      config = {\n        networking.hosts = {\n          \"192.168.1.2\" = [\n            config.test.fqdn\n            \"auth.${config.test.domain}\"\n          ];\n        };\n\n        environment.variables = {\n          PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers;\n        };\n\n        environment.systemPackages = [\n          (pkgs.writers.writePython3Bin \"login_playwright\"\n            {\n              libraries = [ pkgs.python3Packages.playwright ];\n              flakeIgnore = [\n                \"F401\"\n                \"E501\"\n              ];\n            }\n            (\n              let\n                testCfg = pkgs.writeText \"users.json\" (builtins.toJSON cfg);\n              in\n              ''\n                import json\n                import re\n                import sys\n                from playwright.sync_api import expect\n                from playwright.sync_api import sync_playwright\n\n\n                browsers = {\n                    \"chromium\": {'args': [\"--headless\", \"--disable-gpu\"], 'channel': 'chromium'},\n                    \"firefox\": {'args': [\"--reporter\", \"html\"]},\n                    \"webkit\": {},\n                }\n\n                with open(\"${testCfg}\") as f:\n                    testCfg = json.load(f)\n                    print(\"Test configuration:\")\n                    print(json.dumps(testCfg, indent=2))\n\n                browser_name = testCfg['browser']\n                browser_args = browsers.get(browser_name)\n                print(f\"Running test on {browser_name} {' '.join(browser_args)}\")\n\n                with sync_playwright() as p:\n                    browser = getattr(p, browser_name).launch(**browser_args)\n\n                    for i, u in enumerate(testCfg[\"testLoginWith\"]):\n                        print(f\"Testing for user {u['username']} and password {u['password']}\")\n\n                        context = browser.new_context(ignore_https_errors=True)\n                        context.set_default_navigation_timeout(2 * 60 * 1000)\n                        context.tracing.start(screenshots=True, snapshots=True, sources=True)\n                        try:\n                            page = context.new_page()\n                            # This is used to debug frame changes.\n                            # Frame changes or popup are somewhat handled with the expect_page() call later.\n                            page.on(\"framenavigated\", lambda frame: print(\"NAV:\", frame.url))\n                            page.on(\"frameattached\", lambda frame: print(\"ATTACHED:\", frame.url))\n                            page.on(\"framedetached\", lambda frame: print(\"DETACHED:\", frame.url))\n\n                            print(f\"Going to {testCfg['startUrl']}\")\n                            page.goto(testCfg['startUrl'])\n\n                            if testCfg.get(\"beforeHook\") is not None:\n                                if testCfg['loginSpawnsNewPage']:\n                                    print(\"Login spawns new page\")\n                                    # The with clause handles window.open() or <a target=\"_blank\">.\n                                    with context.expect_page() as p:\n                                        exec(testCfg.get(\"beforeHook\"))\n                                    page = p.value\n                                else:\n                                    exec(testCfg.get(\"beforeHook\"))\n\n                            if u['username'] is not None:\n                                print(f\"Filling field username with {u['username']}\")\n                                page.${cfg.usernameFieldSelector}.fill(u['username'])\n                            if u['password'] is not None:\n                                print(f\"Filling field password with {u['password']}\")\n                                page.${cfg.passwordFieldSelector}.fill(u['password'])\n\n                            # Assumes we don't need to login, so skip this.\n                            if u['username'] is not None or u['password'] is not None:\n                                print(f\"Clicking button {testCfg['loginButtonNameRegex']}\")\n                                page.get_by_role(\"button\", name=re.compile(testCfg['loginButtonNameRegex'])).click()\n\n                            for line in u['nextPageExpect']:\n                                print(f\"Running: {line}\")\n                                print(f\"Page has title: {page.title()}\")\n                                exec(line)\n                        finally:\n                            print(f'Saving trace at trace/{i}.zip')\n                            context.tracing.stop(path=f\"trace/{i}.zip\")\n\n                    browser.close()\n              ''\n            )\n          )\n        ];\n      };\n    };\n\n  backup =\n    backupOption:\n    { config, ... }:\n    {\n      imports = [\n        ../modules/blocks/restic.nix\n      ];\n      shb.restic.instances.\"testinstance\" = {\n        request = backupOption.request;\n        settings = {\n          enable = true;\n          passphrase.result = config.shb.hardcodedsecret.backupPassphrase.result;\n          repository = {\n            path = \"/opt/repos/A\";\n            timerConfig = {\n              OnCalendar = \"00:00:00\";\n              RandomizedDelaySec = \"5h\";\n            };\n          };\n        };\n      };\n      shb.hardcodedsecret.backupPassphrase = {\n        request = config.shb.restic.instances.\"testinstance\".settings.passphrase.request;\n        settings.content = \"PassPhrase\";\n      };\n    };\n\n  certs =\n    { config, ... }:\n    {\n      imports = [\n        ../modules/blocks/ssl.nix\n      ];\n\n      shb.certs = {\n        cas.selfsigned.myca = {\n          name = \"My CA\";\n        };\n        certs.selfsigned = {\n          n = {\n            ca = config.shb.certs.cas.selfsigned.myca;\n            domain = \"*.${config.test.domain}\";\n            group = \"nginx\";\n          };\n        };\n      };\n\n      systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ];\n      systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];\n    };\n\n  ldap =\n    { config, pkgs, ... }:\n    {\n      imports = [\n        ../modules/blocks/lldap.nix\n      ];\n\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"ldap.${config.test.domain}\" ];\n      };\n\n      shb.hardcodedsecret.ldapUserPassword = {\n        request = config.shb.lldap.ldapUserPassword.request;\n        settings.content = \"ldapUserPassword\";\n      };\n      shb.hardcodedsecret.jwtSecret = {\n        request = config.shb.lldap.jwtSecret.request;\n        settings.content = \"jwtSecrets\";\n      };\n\n      shb.lldap = {\n        enable = true;\n        inherit (config.test) domain;\n        subdomain = \"ldap\";\n        ldapPort = 3890;\n        webUIListenPort = 17170;\n        dcdomain = \"dc=example,dc=com\";\n        ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;\n        jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;\n        debug = false; # Enable this if needed, but beware it is _very_ verbose.\n\n        ensureUsers = {\n          alice = {\n            email = \"alice@example.com\";\n            groups = [ \"user_group\" ];\n            password.result.path = pkgs.writeText \"alicePassword\" \"AlicePassword\";\n          };\n          bob = {\n            email = \"bob@example.com\";\n            # Purposely not adding bob to the user_group\n            # so we can make sure users only part admins\n            # can also login normally.\n            groups = [ \"admin_group\" ];\n            password.result.path = pkgs.writeText \"bobPassword\" \"BobPassword\";\n          };\n          charlie = {\n            email = \"charlie@example.com\";\n            groups = [ \"other_group\" ];\n            password.result.path = pkgs.writeText \"charliePassword\" \"CharliePassword\";\n          };\n        };\n\n        ensureGroups = {\n          user_group = { };\n          admin_group = { };\n          other_group = { };\n        };\n      };\n    };\n\n  sso =\n    ssl:\n    { config, pkgs, ... }:\n    {\n      imports = [\n        ../modules/blocks/authelia.nix\n      ];\n\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\" ];\n      };\n\n      shb.authelia = {\n        enable = true;\n        inherit (config.test) domain;\n        subdomain = \"auth\";\n        ssl = config.shb.certs.certs.selfsigned.n;\n        debug = true;\n\n        ldapHostname = \"127.0.0.1\";\n        ldapPort = config.shb.lldap.ldapPort;\n        dcdomain = config.shb.lldap.dcdomain;\n\n        secrets = {\n          jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result;\n          ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result;\n          sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result;\n          storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result;\n          identityProvidersOIDCHMACSecret.result =\n            config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result;\n          identityProvidersOIDCIssuerPrivateKey.result =\n            config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result;\n        };\n      };\n\n      shb.hardcodedsecret.autheliaJwtSecret = {\n        request = config.shb.authelia.secrets.jwtSecret.request;\n        settings.content = \"jwtSecret\";\n      };\n      shb.hardcodedsecret.ldapAdminPassword = {\n        request = config.shb.authelia.secrets.ldapAdminPassword.request;\n        settings.content = \"ldapUserPassword\";\n      };\n      shb.hardcodedsecret.sessionSecret = {\n        request = config.shb.authelia.secrets.sessionSecret.request;\n        settings.content = \"sessionSecret\";\n      };\n      shb.hardcodedsecret.storageEncryptionKey = {\n        request = config.shb.authelia.secrets.storageEncryptionKey.request;\n        settings.content = \"storageEncryptionKey\";\n      };\n      shb.hardcodedsecret.identityProvidersOIDCHMACSecret = {\n        request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;\n        settings.content = \"identityProvidersOIDCHMACSecret\";\n      };\n      shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = {\n        request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;\n        settings.source =\n          (pkgs.runCommand \"gen-private-key\" { } ''\n            mkdir $out\n            ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096\n          '')\n          + \"/private.pem\";\n      };\n    };\n\n}\n"
  },
  {
    "path": "test/contracts/backup.nix",
    "content": "{ shb, ... }:\n{\n  restic_root = shb.contracts.test.backup {\n    name = \"restic_root\";\n    username = \"root\";\n    providerRoot = [\n      \"shb\"\n      \"restic\"\n      \"instances\"\n      \"mytest\"\n    ];\n    modules = [\n      ../../modules/blocks/restic.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { username, config, ... }:\n      {\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.restic.instances.\"mytest\".settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n\n  restic_nonroot = shb.contracts.test.backup {\n    name = \"restic_nonroot\";\n    username = \"me\";\n    providerRoot = [\n      \"shb\"\n      \"restic\"\n      \"instances\"\n      \"mytest\"\n    ];\n    modules = [\n      ../../modules/blocks/restic.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { username, config, ... }:\n      {\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.restic.instances.\"mytest\".settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n\n  borgbackup_root = shb.contracts.test.backup {\n    name = \"borgbackup_root\";\n    username = \"root\";\n    providerRoot = [\n      \"shb\"\n      \"borgbackup\"\n      \"instances\"\n      \"mytest\"\n    ];\n    modules = [\n      ../../modules/blocks/borgbackup.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { username, config, ... }:\n      {\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.borgbackup.instances.\"mytest\".settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n\n  borgbackup_nonroot = shb.contracts.test.backup {\n    name = \"borgbackup_nonroot\";\n    username = \"me\";\n    providerRoot = [\n      \"shb\"\n      \"borgbackup\"\n      \"instances\"\n      \"mytest\"\n    ];\n    modules = [\n      ../../modules/blocks/borgbackup.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { username, config, ... }:\n      {\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.borgbackup.instances.\"mytest\".settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n}\n"
  },
  {
    "path": "test/contracts/databasebackup.nix",
    "content": "{ shb, ... }:\n{\n  restic_postgres = shb.contracts.test.databasebackup {\n    name = \"restic_postgres\";\n    requesterRoot = [\n      \"shb\"\n      \"postgresql\"\n      \"databasebackup\"\n    ];\n    providerRoot = [\n      \"shb\"\n      \"restic\"\n      \"databases\"\n      \"postgresql\"\n    ];\n    modules = [\n      ../../modules/blocks/postgresql.nix\n      ../../modules/blocks/restic.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { config, database, ... }:\n      {\n        shb.postgresql.ensures = [\n          {\n            inherit database;\n            username = database;\n          }\n        ];\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.restic.databases.postgresql.settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n\n  borgbackup_postgres = shb.contracts.test.databasebackup {\n    name = \"borgbackup_postgres\";\n    requesterRoot = [\n      \"shb\"\n      \"postgresql\"\n      \"databasebackup\"\n    ];\n    providerRoot = [\n      \"shb\"\n      \"borgbackup\"\n      \"databases\"\n      \"postgresql\"\n    ];\n    modules = [\n      ../../modules/blocks/postgresql.nix\n      ../../modules/blocks/borgbackup.nix\n      ../../modules/blocks/hardcodedsecret.nix\n    ];\n    settings =\n      { repository, config, ... }:\n      {\n        enable = true;\n        stateDir = \"/var/lib/borgbackup_postgres\";\n        passphrase.result = config.shb.hardcodedsecret.passphrase.result;\n        repository = {\n          path = repository;\n          timerConfig = {\n            OnCalendar = \"00:00:00\";\n          };\n        };\n      };\n    extraConfig =\n      { config, database, ... }:\n      {\n        shb.postgresql.ensures = [\n          {\n            inherit database;\n            username = database;\n          }\n        ];\n        shb.hardcodedsecret.passphrase = {\n          request = config.shb.borgbackup.databases.postgresql.settings.passphrase.request;\n          settings.content = \"passphrase\";\n        };\n      };\n  };\n}\n"
  },
  {
    "path": "test/contracts/secret/sops.yaml",
    "content": ""
  },
  {
    "path": "test/contracts/secret.nix",
    "content": "{ shb, ... }:\n{\n  hardcoded_root_root = shb.contracts.test.secret {\n    name = \"hardcoded\";\n    modules = [ ../../modules/blocks/hardcodedsecret.nix ];\n    configRoot = [\n      \"shb\"\n      \"hardcodedsecret\"\n    ];\n    settingsCfg = secret: {\n      content = secret;\n    };\n  };\n\n  hardcoded_user_group = shb.contracts.test.secret {\n    name = \"hardcoded\";\n    modules = [ ../../modules/blocks/hardcodedsecret.nix ];\n    configRoot = [\n      \"shb\"\n      \"hardcodedsecret\"\n    ];\n    settingsCfg = secret: {\n      content = secret;\n    };\n    owner = \"user\";\n    group = \"group\";\n    mode = \"640\";\n  };\n\n  # TODO: how to do this?\n  # sops = shb.contracts.test.secret {\n  #   name = \"sops\";\n  #   configRoot = cfg: name: cfg.sops.secrets.${name};\n  #   createContent = content: {\n  #     sopsFile = ./secret/sops.yaml;\n  #   };\n  # };\n}\n"
  },
  {
    "path": "test/modules/davfs.nix",
    "content": "{ pkgs, lib, ... }:\nlet\n  anyOpt =\n    default:\n    lib.mkOption {\n      type = lib.types.anything;\n      inherit default;\n    };\n\n  testConfig =\n    m:\n    let\n      cfg =\n        (lib.evalModules {\n          specialArgs = { inherit pkgs; };\n          modules = [\n            {\n              options = {\n                systemd = anyOpt { };\n                services = anyOpt { };\n              };\n            }\n            ../../modules/blocks/davfs.nix\n            m\n          ];\n        }).config;\n    in\n    {\n      inherit (cfg) systemd services;\n    };\nin\n{\n  testDavfsNoOptions = {\n    expected = {\n      services.davfs2.enable = false;\n      systemd.mounts = [ ];\n    };\n    expr = testConfig { };\n  };\n}\n"
  },
  {
    "path": "test/modules/homepage.nix",
    "content": "{ shb }:\n{\n  testHomepageAsServiceGroup = {\n    expected = [\n      {\n        \"Media\" = [\n          {\n            \"Jellyfin\" = {\n              \"href\" = \"https://example.com/jellyfin\";\n              \"icon\" = \"sh-jellyfin\";\n              \"siteMonitor\" = \"http://127.0.0.1:8096\";\n            };\n          }\n        ];\n      }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      Media = {\n        services = {\n          Jellyfin = {\n            dashboard.request = {\n              externalUrl = \"https://example.com/jellyfin\";\n              internalUrl = \"http://127.0.0.1:8096\";\n            };\n            apiKey = null;\n          };\n        };\n      };\n    };\n  };\n\n  testHomepageAsServiceGroupApiKey = {\n    expected = [\n      {\n        \"Media\" = [\n          {\n            \"Jellyfin\" = {\n              \"href\" = \"https://example.com/jellyfin\";\n              \"icon\" = \"sh-jellyfin\";\n              \"siteMonitor\" = \"http://127.0.0.1:8096\";\n              \"widget\" = {\n                \"key\" = \"{{HOMEPAGE_FILE_Media_Jellyfin}}\";\n                \"password\" = \"{{HOMEPAGE_FILE_Media_Jellyfin}}\";\n                \"type\" = \"jellyfin\";\n                \"url\" = \"http://127.0.0.1:8096\";\n              };\n            };\n          }\n        ];\n      }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      Media = {\n        services = {\n          Jellyfin = {\n            dashboard.request = {\n              externalUrl = \"https://example.com/jellyfin\";\n              internalUrl = \"http://127.0.0.1:8096\";\n            };\n            apiKey.result.path = \"path_D\";\n          };\n        };\n      };\n    };\n  };\n\n  testHomepageAsServiceGroupNoServiceMonitor = {\n    expected = [\n      {\n        \"Media\" = [\n          {\n            \"Jellyfin\" = {\n              \"href\" = \"https://example.com/jellyfin\";\n              \"icon\" = \"sh-jellyfin\";\n              \"siteMonitor\" = null;\n            };\n          }\n        ];\n      }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      Media = {\n        services = {\n          Jellyfin = {\n            dashboard.request = {\n              externalUrl = \"https://example.com/jellyfin\";\n              internalUrl = null;\n            };\n            apiKey = null;\n          };\n        };\n      };\n    };\n  };\n\n  testHomepageAsServiceGroupOverride = {\n    expected = [\n      {\n        \"Media\" = [\n          {\n            \"Jellyfin\" = {\n              \"href\" = \"https://example.com/jellyfin\";\n              \"icon\" = \"sh-icon\";\n              \"siteMonitor\" = \"http://127.0.0.1:8096\";\n            };\n          }\n        ];\n      }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      Media = {\n        services = {\n          Jellyfin = {\n            dashboard.request = {\n              externalUrl = \"https://example.com/jellyfin\";\n              internalUrl = \"http://127.0.0.1:8096\";\n            };\n            settings = {\n              icon = \"sh-icon\";\n            };\n            apiKey = null;\n          };\n        };\n      };\n    };\n  };\n\n  testHomepageAsServiceGroupSortOrder = {\n    expected = [\n      { \"C\" = [ ]; }\n      { \"A\" = [ ]; }\n      { \"B\" = [ ]; }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      A = {\n        sortOrder = 2;\n        services = { };\n      };\n      B = {\n        sortOrder = 3;\n        services = { };\n      };\n      C = {\n        sortOrder = 1;\n        services = { };\n      };\n    };\n  };\n\n  testHomepageAsServiceServicesSortOrder = {\n    expected = [\n      {\n        \"Media\" = [\n          {\n            \"A\" = {\n              \"href\" = \"https://example.com/a\";\n              \"icon\" = \"sh-a\";\n              \"siteMonitor\" = null;\n            };\n          }\n          {\n            \"C\" = {\n              \"href\" = \"https://example.com/c\";\n              \"icon\" = \"sh-c\";\n              \"siteMonitor\" = null;\n            };\n          }\n          {\n            \"B\" = {\n              \"href\" = \"https://example.com/b\";\n              \"icon\" = \"sh-b\";\n              \"siteMonitor\" = null;\n            };\n          }\n        ];\n      }\n    ];\n\n    expr = shb.homepage.asServiceGroup {\n      Media = {\n        sortOrder = null;\n        services = {\n          A = {\n            sortOrder = 1;\n            dashboard.request = {\n              externalUrl = \"https://example.com/a\";\n              internalUrl = null;\n            };\n            apiKey = null;\n          };\n          B = {\n            sortOrder = 3;\n            dashboard.request = {\n              externalUrl = \"https://example.com/b\";\n              internalUrl = null;\n            };\n            apiKey = null;\n          };\n          C = {\n            sortOrder = 2;\n            dashboard.request = {\n              externalUrl = \"https://example.com/c\";\n              internalUrl = null;\n            };\n            apiKey = null;\n          };\n        };\n      };\n    };\n  };\n\n  testHomepageAllKeys = {\n    expected = {\n      \"A_A\" = \"path_A\";\n      \"A_B\" = \"path_B\";\n      \"B_D\" = \"path_D\";\n    };\n\n    expr = shb.homepage.allKeys {\n      A = {\n        sortOrder = 1;\n        services = {\n          A = {\n            sortOrder = 1;\n            dashboard.request = {\n              externalUrl = \"https://example.com/a\";\n              internalUrl = null;\n            };\n            apiKey.result.path = \"path_A\";\n          };\n          B = {\n            sortOrder = 2;\n            dashboard.request = {\n              externalUrl = \"https://example.com/b\";\n              internalUrl = null;\n            };\n            apiKey.result.path = \"path_B\";\n          };\n        };\n      };\n      B = {\n        sortOrder = 2;\n        services = {\n          C = {\n            sortOrder = 1;\n            dashboard.request = {\n              externalUrl = \"https://example.com/a\";\n              internalUrl = null;\n            };\n            apiKey = null;\n          };\n          D = {\n            sortOrder = 2;\n            dashboard.request = {\n              externalUrl = \"https://example.com/b\";\n              internalUrl = null;\n            };\n            apiKey.result.path = \"path_D\";\n          };\n        };\n      };\n    };\n  };\n}\n"
  },
  {
    "path": "test/modules/lib.nix",
    "content": "{ lib, shb, ... }:\nlet\n  inherit (lib) nameValuePair;\nin\n{\n  # Tests that withReplacements can:\n  # - recurse in attrs and lists\n  # - .source field is understood\n  # - .transform field is understood\n  # - if .source field is found, ignores other fields\n  testLibWithReplacements = {\n    expected =\n      let\n        item = root: {\n          a = \"A\";\n          b = \"%SECRET_${root}B%\";\n          c = \"%SECRET_${root}C%\";\n        };\n      in\n      (item \"\")\n      // {\n        nestedAttr = item \"NESTEDATTR_\";\n        nestedList = [ (item \"NESTEDLIST_0_\") ];\n        doubleNestedList = [ { n = (item \"DOUBLENESTEDLIST_0_N_\"); } ];\n      };\n    expr =\n      let\n        item = {\n          a = \"A\";\n          b.source = \"/path/B\";\n          b.transform = null;\n          c.source = \"/path/C\";\n          c.transform = v: \"prefix-${v}-suffix\";\n          c.other = \"other\";\n        };\n      in\n      shb.withReplacements (\n        item\n        // {\n          nestedAttr = item;\n          nestedList = [ item ];\n          doubleNestedList = [ { n = item; } ];\n        }\n      );\n  };\n\n  testLibWithReplacementsRootList = {\n    expected =\n      let\n        item = root: {\n          a = \"A\";\n          b = \"%SECRET_${root}B%\";\n          c = \"%SECRET_${root}C%\";\n        };\n      in\n      [\n        (item \"0_\")\n        (item \"1_\")\n        [ (item \"2_0_\") ]\n        [ { n = (item \"3_0_N_\"); } ]\n      ];\n    expr =\n      let\n        item = {\n          a = \"A\";\n          b.source = \"/path/B\";\n          b.transform = null;\n          c.source = \"/path/C\";\n          c.transform = v: \"prefix-${v}-suffix\";\n          c.other = \"other\";\n        };\n      in\n      shb.withReplacements [\n        item\n        item\n        [ item ]\n        [ { n = item; } ]\n      ];\n  };\n\n  testLibGetReplacements = {\n    expected =\n      let\n        secrets = root: [\n          (nameValuePair \"%SECRET_${root}B%\" \"$(cat /path/B)\")\n          (nameValuePair \"%SECRET_${root}C%\" \"prefix-$(cat /path/C)-suffix\")\n        ];\n      in\n      (secrets \"\")\n      ++ (secrets \"DOUBLENESTEDLIST_0_N_\")\n      ++ (secrets \"NESTEDATTR_\")\n      ++ (secrets \"NESTEDLIST_0_\");\n    expr =\n      let\n        item = {\n          a = \"A\";\n          b.source = \"/path/B\";\n          b.transform = null;\n          c.source = \"/path/C\";\n          c.transform = v: \"prefix-${v}-suffix\";\n          c.other = \"other\";\n        };\n      in\n      map shb.genReplacement (\n        shb.getReplacements (\n          item\n          // {\n            nestedAttr = item;\n            nestedList = [ item ];\n            doubleNestedList = [ { n = item; } ];\n          }\n        )\n      );\n  };\n\n  testParseXML = {\n    expected = {\n      \"a\" = {\n        \"b\" = \"1\";\n        \"c\" = {\n          \"d\" = \"1\";\n        };\n      };\n    };\n\n    expr = shb.parseXML ''\n      <a>\n        <b>1</b>\n        <c><d>1</d></c>\n      </a>\n    '';\n  };\n}\n// import ./homepage.nix { inherit shb; }\n"
  },
  {
    "path": "test/services/arr.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  healthUrl = \"/health\";\n  loginUrl = \"/UI/Login\";\n\n  # TODO: Test login\n  commonTestScript =\n    appname: cfgPathFn:\n    shb.test.mkScripts {\n      hasSSL = { node, ... }: !(isNull node.config.shb.arr.${appname}.ssl);\n      waitForServices =\n        { ... }:\n        [\n          \"${appname}.service\"\n          \"nginx.service\"\n        ];\n      waitForPorts =\n        { node, ... }:\n        [\n          node.config.shb.arr.${appname}.settings.Port\n        ];\n      extraScript =\n        {\n          node,\n          fqdn,\n          proto_fqdn,\n          ...\n        }:\n        let\n          shbapp = node.config.shb.arr.${appname};\n          cfgPath = cfgPathFn shbapp;\n          apiKey = if (shbapp.settings ? ApiKey) then \"01234567890123456789\" else null;\n        in\n        ''\n          # These curl requests still return a 200 even with sso redirect.\n          with subtest(\"health\"):\n              response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${fqdn}${healthUrl}\")\n              print(\"response =\", response)\n\n              if response['code'] != 200:\n                  raise Exception(f\"Code is {response['code']}\")\n\n          with subtest(\"login\"):\n              response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${fqdn}${loginUrl}\")\n\n              if response['code'] != 200:\n                  raise Exception(f\"Code is {response['code']}\")\n        ''\n        + lib.optionalString (apiKey != null && cfgPath != null) ''\n\n          with subtest(\"apikey\"):\n              config = server.succeed(\"cat ${cfgPath}\")\n              if \"${apiKey}\" not in config:\n                  raise Exception(f\"Unexpected API Key. Want '${apiKey}', got '{config}'\")\n        '';\n    };\n\n  basic =\n    appname:\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/arr.nix\n      ];\n\n      test = {\n        subdomain = appname;\n      };\n\n      shb.arr.${appname} = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        settings.ApiKey.source = pkgs.writeText \"APIKey\" \"01234567890123456789\"; # Needs to be >=20 characters.\n      };\n    };\n\n  clientLogin =\n    appname:\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n\n      test = {\n        subdomain = appname;\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"[Uu]sername\";\n        passwordFieldLabelRegex = \"^ *[Pp]assword\";\n        loginButtonNameRegex = \"[Ll]og [Ii]n\";\n        testLoginWith = [\n          {\n            nextPageExpect = [\n              \"expect(page).to_have_title(re.compile('${appname}', re.IGNORECASE))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  basicTest =\n    appname: cfgPathFn:\n    shb.test.runNixOSTest {\n      name = \"arr_${appname}_basic\";\n\n      nodes.client = {\n        imports = [\n          (clientLogin appname)\n        ];\n      };\n      nodes.server = {\n        imports = [\n          (basic appname)\n        ];\n      };\n\n      testScript = (commonTestScript appname cfgPathFn).access;\n    };\n\n  backupTest =\n    appname: cfgPathFn:\n    shb.test.runNixOSTest {\n      name = \"arr_${appname}_backup\";\n\n      nodes.server =\n        { config, ... }:\n        {\n          imports = [\n            (basic appname)\n            (shb.test.backup config.shb.arr.${appname}.backup)\n          ];\n        };\n\n      nodes.client = { };\n\n      testScript = (commonTestScript appname cfgPathFn).backup;\n    };\n\n  https =\n    appname:\n    { config, ... }:\n    {\n      shb.arr.${appname} = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  httpsTest =\n    appname: cfgPathFn:\n    shb.test.runNixOSTest {\n      name = \"arr_${appname}_https\";\n\n      nodes.server =\n        { config, pkgs, ... }:\n        {\n          imports = [\n            (basic appname)\n            shb.test.certs\n            (https appname)\n          ];\n        };\n\n      nodes.client = { };\n\n      testScript = (commonTestScript appname cfgPathFn).access;\n    };\n\n  sso =\n    appname:\n    { config, ... }:\n    {\n      shb.arr.${appname} = {\n        authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n      };\n    };\n\n  ssoTest =\n    appname: cfgPathFn:\n    shb.test.runNixOSTest {\n      name = \"arr_${appname}_sso\";\n\n      nodes.server =\n        { config, pkgs, ... }:\n        {\n          imports = [\n            (basic appname)\n            shb.test.certs\n            (https appname)\n            shb.test.ldap\n            (shb.test.sso config.shb.certs.certs.selfsigned.n)\n            (sso appname)\n          ];\n        };\n\n      nodes.client = { };\n\n      testScript = (commonTestScript appname cfgPathFn).access.override {\n        redirectSSO = true;\n      };\n    };\n\n  radarrCfgFn = cfg: \"${cfg.dataDir}/config.xml\";\n  sonarrCfgFn = cfg: \"${cfg.dataDir}/config.xml\";\n  bazarrCfgFn = cfg: null;\n  readarrCfgFn = cfg: \"${cfg.dataDir}/config.xml\";\n  lidarrCfgFn = cfg: \"${cfg.dataDir}/config.xml\";\n  jackettCfgFn = cfg: \"${cfg.dataDir}/ServerConfig.json\";\nin\n{\n  radarr_basic = basicTest \"radarr\" radarrCfgFn;\n  radarr_backup = backupTest \"radarr\" radarrCfgFn;\n  radarr_https = httpsTest \"radarr\" radarrCfgFn;\n  radarr_sso = ssoTest \"radarr\" radarrCfgFn;\n\n  sonarr_basic = basicTest \"sonarr\" sonarrCfgFn;\n  sonarr_backup = backupTest \"sonarr\" sonarrCfgFn;\n  sonarr_https = httpsTest \"sonarr\" sonarrCfgFn;\n  sonarr_sso = ssoTest \"sonarr\" sonarrCfgFn;\n\n  bazarr_basic = basicTest \"bazarr\" bazarrCfgFn;\n  bazarr_backup = backupTest \"bazarr\" bazarrCfgFn;\n  bazarr_https = httpsTest \"bazarr\" bazarrCfgFn;\n  bazarr_sso = ssoTest \"bazarr\" bazarrCfgFn;\n\n  readarr_basic = basicTest \"readarr\" readarrCfgFn;\n  readarr_backup = backupTest \"readarr\" readarrCfgFn;\n  readarr_https = httpsTest \"readarr\" readarrCfgFn;\n  readarr_sso = ssoTest \"readarr\" readarrCfgFn;\n\n  lidarr_basic = basicTest \"lidarr\" lidarrCfgFn;\n  lidarr_backup = backupTest \"lidarr\" lidarrCfgFn;\n  lidarr_https = httpsTest \"lidarr\" lidarrCfgFn;\n  lidarr_sso = ssoTest \"lidarr\" lidarrCfgFn;\n\n  jackett_basic = basicTest \"jackett\" jackettCfgFn;\n  jackett_backup = backupTest \"jackett\" jackettCfgFn;\n  jackett_https = httpsTest \"jackett\" jackettCfgFn;\n  jackett_sso = ssoTest \"jackett\" jackettCfgFn;\n}\n"
  },
  {
    "path": "test/services/audiobookshelf.nix",
    "content": "{ shb, ... }:\nlet\n  commonTestScript = shb.test.accessScript {\n    hasSSL = { node, ... }: !(isNull node.config.shb.audiobookshelf.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"audiobookshelf.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.audiobookshelf.webPort\n      ];\n    # TODO: Test login\n    # extraScript = { ... }: ''\n    # '';\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/audiobookshelf.nix\n      ];\n\n      test = {\n        subdomain = \"a\";\n      };\n      shb.audiobookshelf = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"a\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"[Uu]sername\";\n        passwordFieldLabelRegex = \"[Pp]assword\";\n        loginButtonNameRegex = \"[Ll]og [Ii]n\";\n        testLoginWith = [\n          # Failure is after so we're not throttled too much.\n          {\n            username = \"root\";\n            password = \"rootpw\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Wrong username or password')).to_be_visible()\"\n            ];\n          }\n          # { username = adminUser; password = adminPass; nextPageExpect = [\n          #     \"expect(page.get_by_text('Wrong username or password')).not_to_be_visible()\"\n          #     \"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()\"\n          #     \"expect(page).to_have_title(re.compile('Dashboard'))\"\n          #   ]; }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.audiobookshelf = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.audiobookshelf = {\n        sso = {\n          enable = true;\n          endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          sharedSecret.result = config.shb.hardcodedsecret.audiobookshelfSSOPassword.result;\n          sharedSecretForAuthelia.result =\n            config.shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia.result;\n        };\n      };\n\n      shb.hardcodedsecret.audiobookshelfSSOPassword = {\n        request = config.shb.audiobookshelf.sso.sharedSecret.request;\n        settings.content = \"ssoPassword\";\n      };\n\n      shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia = {\n        request = config.shb.audiobookshelf.sso.sharedSecretForAuthelia.request;\n        settings.content = \"ssoPassword\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"audiobookshelf-basic\";\n\n    nodes.client = {\n      imports = [\n        # TODO: enable this when declarative user management is possible.\n        # clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"audiobookshelf-https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"audiobookshelf-sso\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n}\n"
  },
  {
    "path": "test/services/deluge.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n  ...\n}:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.deluge.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"nginx.service\"\n        \"deluged.service\"\n        \"delugeweb.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.deluge.daemonPort\n        node.config.shb.deluge.webPort\n      ];\n    extraScript =\n      { node, proto_fqdn, ... }:\n      ''\n        print(${node.name}.succeed('journalctl -n100 -u deluged'))\n        print(${node.name}.succeed('systemctl status deluged'))\n        print(${node.name}.succeed('systemctl status delugeweb'))\n\n        with subtest(\"web connect\"):\n            print(server.succeed(\"cat ${node.config.services.deluge.dataDir}/.config/deluge/auth\"))\n\n            response = curl(client, \"\", \"${proto_fqdn}/json\", extra = unline_with(\" \", \"\"\"\n              -H \"Content-Type: application/json\"\n              -H \"Accept: application/json\"\n              \"\"\"), data = unline_with(\" \", \"\"\"\n              {\"method\": \"auth.login\", \"params\": [\"deluge\"], \"id\": 1}\n              \"\"\"))\n            print(response)\n            if response['error']:\n                raise Exception(f\"error is {response['error']}\")\n            if not response['result']:\n                raise Exception(f\"response is {response}\")\n\n            response = curl(client, \"\", \"${proto_fqdn}/json\", extra = unline_with(\" \", \"\"\"\n              -H \"Content-Type: application/json\"\n              -H \"Accept: application/json\"\n              \"\"\"), data = unline_with(\" \", \"\"\"\n              {\"method\": \"web.get_hosts\", \"params\": [], \"id\": 1}\n              \"\"\"))\n            print(response)\n            if response['error']:\n                raise Exception(f\"error is {response['error']}\")\n\n            hostID = response['result'][0][0]\n            response = curl(client, \"\", \"${proto_fqdn}/json\", extra = unline_with(\" \", \"\"\"\n              -H \"Content-Type: application/json\"\n              -H \"Accept: application/json\"\n              \"\"\"), data = unline_with(\" \", f\"\"\"\n              {{\"method\": \"web.connect\", \"params\": [\"{hostID}\"], \"id\": 1}}\n              \"\"\"))\n            print(response)\n            if response['error']:\n                raise Exception(f\"result had an error {response['error']}\")\n      '';\n  };\n\n  prometheusTestScript =\n    { nodes, ... }:\n    ''\n      server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.deluge.port})\n      with subtest(\"prometheus\"):\n          response = server.succeed(\n              \"curl -sSf \"\n              + \" http://localhost:${toString nodes.server.services.prometheus.exporters.deluge.port}/metrics\"\n          )\n          print(response)\n    '';\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/deluge.nix\n      ];\n\n      test = {\n        subdomain = \"d\";\n      };\n\n      shb.deluge = {\n        enable = true;\n        inherit (config.test) domain subdomain;\n\n        settings = {\n          downloadLocation = \"/var/lib/deluge\";\n        };\n\n        extraUsers = {\n          user.password.source = pkgs.writeText \"userpw\" \"userpw\";\n        };\n\n        localclientPassword.result = config.shb.hardcodedsecret.\"localclientpassword\".result;\n      };\n      shb.hardcodedsecret.\"localclientpassword\" = {\n        request = config.shb.deluge.localclientPassword.request;\n        settings.content = \"localpw\";\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"d\";\n      };\n\n      test.login = {\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"Login\";\n        testLoginWith = [\n          {\n            password = \"deluge\";\n            nextPageExpect = [\n              \"expect(page.get_by_role('button', name='Login')).not_to_be_visible()\"\n              \"expect(page.get_by_text('Login Failed')).not_to_be_visible()\"\n            ];\n          }\n          {\n            password = \"other\";\n            nextPageExpect = [\n              \"expect(page.get_by_role('button', name='Login')).to_be_visible()\"\n              \"expect(page.get_by_text('Login Failed')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  prometheus =\n    { config, ... }:\n    {\n      shb.deluge = {\n        prometheusScraperPassword.result = config.shb.hardcodedsecret.\"scraper\".result;\n      };\n      shb.hardcodedsecret.\"scraper\" = {\n        request = config.shb.deluge.prometheusScraperPassword.request;\n        settings.content = \"scraperpw\";\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.deluge = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.deluge = {\n        authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"deluge_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"deluge_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.deluge.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"deluge_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"deluge_sso\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access.override {\n      redirectSSO = true;\n    };\n  };\n\n  prometheus = shb.test.runNixOSTest {\n    name = \"deluge_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n        prometheus\n      ];\n    };\n\n    nodes.client = { };\n\n    # The inputs attrset must be named out explicitly\n    testScript =\n      inputs@{ nodes, ... }: (commonTestScript.access inputs) + (prometheusTestScript inputs);\n  };\n}\n"
  },
  {
    "path": "test/services/firefly-iii.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.firefly-iii.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"phpfpm-firefly-iii.service\"\n        \"phpfpm-firefly-iii-data-importer.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        # node.config.shb.firefly-iii.port\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/firefly-iii.nix\n      ];\n\n      test = {\n        subdomain = \"f\";\n      };\n\n      shb.firefly-iii = {\n        enable = true;\n        debug = true;\n        inherit (config.test) subdomain domain;\n        siteOwnerEmail = \"mail@example.com\";\n        appKey.result = config.shb.hardcodedsecret.appKey.result;\n        dbPassword.result = config.shb.hardcodedsecret.dbPassword.result;\n        importer.firefly-iii-accessToken.result = config.shb.hardcodedsecret.accessToken.result;\n      };\n\n      shb.hardcodedsecret.appKey = {\n        request = config.shb.firefly-iii.appKey.request;\n        # Firefly-iir requires this to be exactly 32 characters.\n        settings.content = pkgs.lib.strings.replicate 32 \"Z\";\n      };\n      shb.hardcodedsecret.dbPassword = {\n        request = config.shb.firefly-iii.dbPassword.request;\n        settings.content = pkgs.lib.strings.replicate 64 \"Y\";\n      };\n      shb.hardcodedsecret.accessToken = {\n        request = config.shb.firefly-iii.importer.firefly-iii-accessToken.request;\n        settings.content = pkgs.lib.strings.replicate 64 \"X\";\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"f\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        # There is no login without SSO integration.\n        testLoginWith = [\n          {\n            username = null;\n            password = null;\n            nextPageExpect = [\n              \"expect(page.get_by_text('Register a new account')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  clientLoginDataImporter =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"f-importer\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        # There is no login without SSO integration.\n        testLoginWith = [\n          {\n            username = null;\n            password = null;\n            nextPageExpect = [\n              # The error to connect is expected since the access token must be created manually in Firefly-iii.\n              \"expect(page.get_by_text('The importer could not connect')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.firefly-iii = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.firefly-iii = {\n        ldap = {\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"f\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Dashboard')).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Dashboard')).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"expect(page).to_have_url(re.compile('.*/authenticated'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  clientLoginSsoDataImporter =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"f-importer\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              # Only admins have access\n              \"expect(page.get_by_text('Authenticated')).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              # The error to connect is expected since the access token must be created manually in Firefly-iii.\n              \"expect(page.get_by_text('The importer could not connect')).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"expect(page).to_have_url(re.compile('.*/authenticated'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.firefly-iii = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n        };\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"firefly-iii_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  data-importer_basic = shb.test.runNixOSTest {\n    name = \"firefly-iii-data-importer_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLoginDataImporter\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"firefly-iii_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.firefly-iii.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"firefly-iii_https\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"firefly-iii_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    testScript = commonTestScript.access.override {\n      redirectSSO = true;\n    };\n  };\n\n  data-importer_sso = shb.test.runNixOSTest {\n    name = \"firefly-iii-data-importer_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSsoDataImporter\n      ];\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/forgejo.nix",
    "content": "{ shb, ... }:\nlet\n  adminPassword = \"AdminPassword\";\n\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.forgejo.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"forgejo.service\"\n        \"nginx.service\"\n      ];\n    waitForUnixSocket =\n      { node, ... }:\n      [\n        node.config.services.forgejo.settings.server.HTTP_ADDR\n      ];\n    extraScript =\n      { node, ... }:\n      ''\n        server.wait_for_unit(\"gitea-runner-local.service\", timeout=10)\n        server.succeed(\"journalctl -o cat -u gitea-runner-local.service | grep -q 'Runner registered successfully'\")\n      '';\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/forgejo.nix\n      ];\n\n      test = {\n        subdomain = \"f\";\n      };\n\n      shb.forgejo = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        users = {\n          \"theadmin\" = {\n            isAdmin = true;\n            email = \"theadmin@example.com\";\n            password.result = config.shb.hardcodedsecret.forgejoAdminPassword.result;\n          };\n          \"theuser\" = {\n            email = \"theuser@example.com\";\n            password.result = config.shb.hardcodedsecret.forgejoUserPassword.result;\n          };\n        };\n        databasePassword.result = config.shb.hardcodedsecret.forgejoDatabasePassword.result;\n      };\n\n      # Needed for gitea-runner-local to be able to ping forgejo.\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"${config.test.subdomain}.${config.test.domain}\" ];\n      };\n\n      shb.hardcodedsecret.forgejoAdminPassword = {\n        request = config.shb.forgejo.users.\"theadmin\".password.request;\n        settings.content = adminPassword;\n      };\n\n      shb.hardcodedsecret.forgejoUserPassword = {\n        request = config.shb.forgejo.users.\"theuser\".password.request;\n        settings.content = \"userPassword\";\n      };\n\n      shb.hardcodedsecret.forgejoDatabasePassword = {\n        request = config.shb.forgejo.databasePassword.request;\n        settings.content = \"databasePassword\";\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"f\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}/user/login\";\n        usernameFieldLabelRegex = \"Username or email address\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"theadmin\";\n            password = adminPassword + \"oops\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"theadmin\";\n            password = adminPassword;\n            nextPageExpect = [\n              \"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n            ];\n          }\n          {\n            username = \"theuser\";\n            password = \"userPasswordOops\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"theuser\";\n            password = \"userPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.forgejo = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.forgejo = {\n        ldap = {\n          enable = true;\n          host = \"127.0.0.1\";\n          port = config.shb.lldap.ldapPort;\n          dcdomain = config.shb.lldap.dcdomain;\n          adminPassword.result = config.shb.hardcodedsecret.forgejoLdapUserPassword.result;\n          waitForSystemdServices = [ \"lldap.service\" ];\n\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n        };\n      };\n\n      shb.hardcodedsecret.forgejoLdapUserPassword = {\n        request = config.shb.forgejo.ldap.adminPassword.request;\n        settings.content = \"ldapUserPassword\";\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.forgejo = {\n        sso = {\n          enable = true;\n          endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          sharedSecret.result = config.shb.hardcodedsecret.forgejoSSOPassword.result;\n          sharedSecretForAuthelia.result = config.shb.hardcodedsecret.forgejoSSOPasswordAuthelia.result;\n        };\n      };\n\n      shb.hardcodedsecret.forgejoSSOPassword = {\n        request = config.shb.forgejo.sso.sharedSecret.request;\n        settings.content = \"ssoPassword\";\n      };\n\n      shb.hardcodedsecret.forgejoSSOPasswordAuthelia = {\n        request = config.shb.forgejo.sso.sharedSecretForAuthelia.request;\n        settings.content = \"ssoPassword\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"forgejo_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"forgejo_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.forgejo.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"forgejo_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  ldap = shb.test.runNixOSTest {\n    name = \"forgejo_ldap\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.ldap\n        ldap\n      ];\n    };\n\n    nodes.client = {\n      imports = [\n        (\n          { config, ... }:\n          {\n            imports = [\n              shb.test.baseModule\n              shb.test.clientLoginModule\n            ];\n\n            test = {\n              subdomain = \"f\";\n            };\n\n            test.login = {\n              startUrl = \"http://${config.test.fqdn}/user/login\";\n              usernameFieldLabelRegex = \"Username or email address\";\n              passwordFieldLabelRegex = \"Password\";\n              loginButtonNameRegex = \"[sS]ign [iI]n\";\n              testLoginWith = [\n                {\n                  username = \"alice\";\n                  password = \"NotAlicePassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n                  ];\n                }\n                {\n                  username = \"alice\";\n                  password = \"AlicePassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()\"\n                    \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n                    \"expect(page).to_have_title(re.compile('Dashboard'))\"\n                  ];\n                }\n                {\n                  username = \"bob\";\n                  password = \"NotBobPassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n                  ];\n                }\n                {\n                  username = \"bob\";\n                  password = \"BobPassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()\"\n                    \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n                    \"expect(page).to_have_title(re.compile('Dashboard'))\"\n                  ];\n                }\n                {\n                  username = \"charlie\";\n                  password = \"NotCharliePassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n                  ];\n                }\n                {\n                  username = \"charlie\";\n                  password = \"CharliePassword\";\n                  nextPageExpect = [\n                    \"expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()\"\n                  ];\n                }\n              ];\n            };\n          }\n        )\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"forgejo_sso\";\n\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          ldap\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/grocy.nix",
    "content": "{ shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.grocy.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"phpfpm-grocy.service\"\n        \"nginx.service\"\n      ];\n    waitForUnixSocket =\n      { node, ... }:\n      [\n        node.config.services.phpfpm.pools.grocy.socket\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/grocy.nix\n      ];\n\n      test = {\n        subdomain = \"g\";\n      };\n\n      shb.grocy = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"g\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"OK\";\n        testLoginWith = [\n          {\n            username = \"admin\";\n            password = \"admin oops\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Invalid credentials, please try again')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"admin\";\n            password = \"admin\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Invalid credentials, please try again')).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('OK'))).not_to_be_visible()\"\n              \"expect(page).to_have_title(re.compile('Grocy'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.grocy = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"grocy_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"grocy_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/hledger.nix",
    "content": "{ shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.hledger.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"hledger-web.service\"\n        \"nginx.service\"\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/hledger.nix\n      ];\n\n      test = {\n        subdomain = \"h\";\n      };\n\n      shb.hledger = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n\n      test = {\n        subdomain = \"h\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        testLoginWith = [\n          {\n            nextPageExpect = [\n              \"expect(page).to_have_title('journal - hledger-web')\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.hledger = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.hledger = {\n        authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"hledger_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"hledger_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.hledger.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"hledger_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"hledger_sso\";\n\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access.override {\n      redirectSSO = true;\n    };\n  };\n}\n"
  },
  {
    "path": "test/services/home-assistant.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.home-assistant.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"home-assistant.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        8123\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/home-assistant.nix\n      ];\n\n      test = {\n        subdomain = \"ha\";\n      };\n\n      shb.home-assistant = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        config = {\n          name = \"Tiserbox\";\n          country = \"CH\";\n          latitude = \"01.0000000000\";\n          longitude.source = pkgs.writeText \"longitude\" \"01.0000000000\";\n          time_zone = \"Europe/Zurich\";\n          unit_system = \"metric\";\n        };\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"ha\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        testLoginWith = [\n          {\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Create my smart home')).click()\"\n\n              \"expect(page.get_by_text('Create user')).to_be_visible()\"\n              \"page.get_by_label(re.compile('Name')).fill('Admin')\"\n              \"page.get_by_label(re.compile('Username')).fill('admin')\"\n              \"page.get_by_label(re.compile('Password')).fill('adminpassword')\"\n              \"page.get_by_label(re.compile('Confirm password')).fill('adminpassword')\"\n              \"page.get_by_role('button', name=re.compile('Create account')).click()\"\n\n              \"expect(page.get_by_text('All set!')).to_be_visible()\"\n              \"page.get_by_role('button', name=re.compile('Finish')).click()\"\n\n              \"expect(page).to_have_title(re.compile('Overview'), timeout=15000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.home-assistant = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.home-assistant = {\n        ldap = {\n          enable = true;\n          host = \"127.0.0.1\";\n          port = config.shb.lldap.webUIListenPort;\n          userGroup = \"homeassistant_user\";\n        };\n      };\n    };\n\n  # Not yet supported\n  #\n  # sso = { config, ... }: {\n  #   shb.home-assistant = {\n  #     sso = {\n  #     };\n  #   };\n  # };\n\n  voice =\n    { config, ... }:\n    {\n      # For now, verifying the packages can build is good enough.\n      environment.systemPackages = [\n        config.services.wyoming.piper.package\n        config.services.wyoming.openwakeword.package\n        config.services.wyoming.faster-whisper.package\n      ];\n\n      # TODO: enable this back. The issue id the services cannot talk to the internet\n      # to download the models so they fail to start..\n      # shb.home-assistant.voice.text-to-speech = {\n      #   \"fr\" = {\n      #     enable = true;\n      #     voice = \"fr-siwis-medium\";\n      #     uri = \"tcp://0.0.0.0:10200\";\n      #     speaker = 0;\n      #   };\n      #   \"en\" = {\n      #     enable = true;\n      #     voice = \"en_GB-alba-medium\";\n      #     uri = \"tcp://0.0.0.0:10201\";\n      #     speaker = 0;\n      #   };\n      # };\n      # shb.home-assistant.voice.speech-to-text = {\n      #   \"tiny-fr\" = {\n      #     enable = true;\n      #     model = \"base-int8\";\n      #     language = \"fr\";\n      #     uri = \"tcp://0.0.0.0:10300\";\n      #     device = \"cpu\";\n      #   };\n      #   \"tiny-en\" = {\n      #     enable = true;\n      #     model = \"base-int8\";\n      #     language = \"en\";\n      #     uri = \"tcp://0.0.0.0:10301\";\n      #     device = \"cpu\";\n      #   };\n      # };\n      # shb.home-assistant.voice.wakeword = {\n      #   enable = true;\n      #   uri = \"tcp://127.0.0.1:10400\";\n      #   preloadModels = [\n      #     \"alexa\"\n      #     \"hey_jarvis\"\n      #     \"hey_mycroft\"\n      #     \"hey_rhasspy\"\n      #     \"ok_nabu\"\n      #   ];\n      # };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"homeassistant_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"homeassistant_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.home-assistant.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"homeassistant_https\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  ldap = shb.test.runNixOSTest {\n    name = \"homeassistant_ldap\";\n\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.ldap\n        ldap\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  # Not yet supported\n  #\n  # sso = shb.test.runNixOSTest {\n  #   name = \"vaultwarden_sso\";\n  #\n  #   nodes.server = lib.mkMerge [\n  #     basic\n  #     (shb.certs domain)\n  #     https\n  #     ldap\n  #     (shb.ldap domain pkgs')\n  #     (shb.test.sso domain pkgs' config.shb.certs.certs.selfsigned.n)\n  #     sso\n  #   ];\n  #\n  #   nodes.client = {};\n  #\n  #   testScript = commonTestScript.access;\n  # };\n\n  voice = shb.test.runNixOSTest {\n    name = \"homeassistant_voice\";\n\n    nodes.server = {\n      imports = [\n        basic\n        voice\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/homepage.nix",
    "content": "{ shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.homepage.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"homepage-dashboard.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.services.homepage-dashboard.listenPort\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/homepage.nix\n      ];\n\n      test = {\n        subdomain = \"h\";\n      };\n\n      shb.homepage = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        servicesGroups.MyHomeGroup.services.TestService.dashboard = { };\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"h\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        # There is no login without SSO integration.\n        testLoginWith = [\n          {\n            username = null;\n            password = null;\n            nextPageExpect = [\n              \"expect(page.get_by_text('TestService')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.homepage = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.homepage = {\n        ldap = {\n          userGroup = \"user_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"h\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('TestService')).to_be_visible(timeout=10000)\"\n            ];\n          }\n          # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"expect(page).to_have_url(re.compile('.*/authenticated'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.homepage = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n        };\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"homepage_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"homepage_https\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"homepage_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    testScript = commonTestScript.access.override {\n      redirectSSO = true;\n    };\n  };\n}\n"
  },
  {
    "path": "test/services/immich.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  subdomain = \"i\";\n  domain = \"example.com\";\n\n  commonTestScript = shb.test.accessScript {\n    hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"immich-server.service\"\n        \"postgresql.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { ... }:\n      [\n        2283\n        80\n      ];\n    waitForUrls = { proto_fqdn, ... }: [ \"${proto_fqdn}\" ];\n  };\n\n  base =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/immich.nix\n      ];\n\n      virtualisation.memorySize = 4096;\n      virtualisation.cores = 2;\n\n      test = {\n        inherit subdomain domain;\n      };\n\n      shb.immich = {\n        enable = true;\n        inherit subdomain domain;\n\n        debug = true;\n      };\n\n      # Required for tests\n      environment.systemPackages = [ pkgs.curl ];\n    };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [ base ];\n\n      test.hasSSL = false;\n    };\n\n  https =\n    { config, ... }:\n    {\n      imports = [\n        base\n        shb.test.certs\n      ];\n\n      test.hasSSL = true;\n      shb.immich.ssl = config.shb.certs.certs.selfsigned.n;\n    };\n\n  backup =\n    { config, ... }:\n    {\n      imports = [\n        https\n        (shb.test.backup config.shb.immich.backup)\n      ];\n    };\n\n  sso =\n    { config, ... }:\n    {\n      imports = [\n        https\n        shb.test.ldap\n        (shb.test.sso config.shb.certs.certs.selfsigned.n)\n      ];\n\n      shb.immich.sso = {\n        enable = true;\n        provider = \"Authelia\";\n        endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n        clientID = \"immich\";\n        autoLaunch = true;\n        sharedSecret.result = config.shb.hardcodedsecret.immichSSOSecret.result;\n        sharedSecretForAuthelia.result = config.shb.hardcodedsecret.immichSSOSecretAuthelia.result;\n      };\n\n      shb.hardcodedsecret.immichSSOSecret = {\n        request = config.shb.immich.sso.sharedSecret.request;\n        settings.content = \"immichSSOSecret\";\n      };\n\n      shb.hardcodedsecret.immichSSOSecretAuthelia = {\n        request = config.shb.immich.sso.sharedSecretForAuthelia.request;\n        settings.content = \"immichSSOSecret\";\n      };\n\n      # Configure LDAP groups for group-based access control\n      shb.lldap.ensureGroups.immich_user = { };\n\n      shb.lldap.ensureUsers.immich_test_user = {\n        email = \"immich_user@example.com\";\n        groups = [ \"immich_user\" ];\n        password.result = config.shb.hardcodedsecret.ldapImmichUserPassword.result;\n      };\n\n      shb.lldap.ensureUsers.regular_test_user = {\n        email = \"regular_user@example.com\";\n        groups = [ ];\n        password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result;\n      };\n\n      shb.hardcodedsecret.ldapImmichUserPassword = {\n        request = config.shb.lldap.ensureUsers.immich_test_user.password.request;\n        settings.content = \"immich_user_password\";\n      };\n\n      shb.hardcodedsecret.ldapRegularUserPassword = {\n        request = config.shb.lldap.ensureUsers.regular_test_user.password.request;\n        settings.content = \"regular_user_password\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"immich-basic\";\n\n    nodes.server = basic;\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"immich-https\";\n\n    nodes.server = https;\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"immich-backup\";\n\n    nodes.server = backup;\n    nodes.client = { };\n\n    testScript =\n      (shb.test.mkScripts {\n        hasSSL = args: !(isNull args.node.config.shb.immich.ssl);\n        waitForServices = args: [\n          \"immich-server.service\"\n          \"postgresql.service\"\n          \"nginx.service\"\n        ];\n        waitForPorts = args: [\n          2283\n          80\n        ];\n        waitForUrls = args: [ \"${args.proto_fqdn}\" ];\n      }).backup;\n  };\n}\n"
  },
  {
    "path": "test/services/jellyfin.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  port = 9096;\n\n  adminUser = \"jellyfin2\";\n  adminPassword = \"admin\";\n\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"jellyfin.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        port\n      ];\n    waitForUrls =\n      { proto_fqdn, ... }:\n      [\n        \"${proto_fqdn}/System/Info/Public\"\n        {\n          url = \"${proto_fqdn}/Users/AuthenticateByName\";\n          status = 401;\n        }\n      ];\n    extraScript =\n      { node, ... }:\n      ''\n        server.wait_until_succeeds(\"journalctl --since -1m --unit jellyfin --grep 'Startup complete'\")\n        headers = unline_with(\" \", \"\"\"\n            -H 'Content-Type: application/json'\n            -H 'Authorization: MediaBrowser Client=\"Android TV\", Device=\"Nvidia Shield\", DeviceId=\"ZQ9YQHHrUzk24vV\", Version=\"0.15.3\"'\n        \"\"\")\n        import time\n        with subtest(\"api login success\"):\n            ok = False\n            for i in range(1, 5):\n                response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${node.config.test.proto_fqdn}/Users/AuthenticateByName\",\n                    data=\"\"\"{\"Username\": \"${adminUser}\", \"Pw\": \"${adminPassword}\"}\"\"\",\n                    extra=headers)\n                if response['code'] == 200:\n                    ok = True\n                    break\n                time.sleep(5)\n            if not ok:\n                raise Exception(f\"Expected success, got: {response['code']}\")\n\n        with subtest(\"api login failure\"):\n            response = curl(client, \"\"\"{\"code\":%{response_code}}\"\"\", \"${node.config.test.proto_fqdn}/Users/AuthenticateByName\",\n                data=\"\"\"{\"Username\": \"${adminUser}\", \"Pw\": \"badpassword\"}\"\"\",\n                extra=headers)\n            if response['code'] != 401:\n                raise Exception(f\"Expected failure, got: {response['code']}\")\n      '';\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/jellyfin.nix\n      ];\n      # Jellyfin checks for minimum 2Gib on startup.\n      virtualisation.diskSize = 4096;\n      virtualisation.memorySize = 4096;\n      test = {\n        subdomain = \"j\";\n      };\n\n      shb.jellyfin = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n        inherit port;\n        admin = {\n          username = adminUser;\n          password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result;\n        };\n        debug = true;\n      };\n\n      shb.hardcodedsecret.jellyfinAdminPassword = {\n        request = config.shb.jellyfin.admin.password.request;\n        settings.content = adminPassword;\n      };\n\n      environment.systemPackages = [\n        pkgs.sqlite\n      ];\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"j\";\n      };\n\n      test.login = {\n        browser = \"firefox\";\n        startUrl = \"${config.test.proto}://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"[Uu]ser\";\n        loginButtonNameRegex = \"Sign In\";\n        testLoginWith = [\n          {\n            username = adminUser;\n            password = \"badpassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = adminUser;\n            password = adminPassword;\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.jellyfin = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n      test = {\n        hasSSL = true;\n      };\n    };\n\n  ldap =\n    { config, lib, ... }:\n    {\n      shb.jellyfin = {\n        ldap = {\n          enable = true;\n          host = \"127.0.0.1\";\n          port = config.shb.lldap.ldapPort;\n          dcdomain = config.shb.lldap.dcdomain;\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n          adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result;\n        };\n      };\n\n      # There's something weird happending here\n      # where this plugin disappears after a jellyfin restart.\n      # I don't know why this is the case.\n      # I tried using a real plugin here instead of a mock or just creating a meta.json file.\n      # But this didn't help.\n      shb.jellyfin.plugins = lib.mkBefore [\n        (shb.mkJellyfinPlugin (rec {\n          pname = \"jellyfin-plugin-ldapauth\";\n          version = \"19\";\n          url = \"https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip\";\n          hash = \"sha256-NunkpdYjsxYT6a4RaDXLkgRn4scRw8GaWvyHGs9IdWo=\";\n        }))\n      ];\n\n      shb.hardcodedsecret.jellyfinLdapUserPassword = {\n        request = config.shb.jellyfin.ldap.adminPassword.request;\n        settings.content = \"ldapUserPassword\";\n      };\n    };\n\n  clientLoginLdap =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"j\";\n      };\n\n      test.login = {\n        startUrl = \"${config.test.proto}://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"[Uu]ser\";\n        loginButtonNameRegex = \"Sign In\";\n        testLoginWith = [\n          {\n            username = adminUser;\n            password = \"badpassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = adminUser;\n            password = adminPassword;\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              # For a reason I can't explain, redirection needs to happen manually.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              # For a reason I can't explain, redirection needs to happen manually.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.jellyfin = {\n        ldap = {\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n        };\n\n        sso = {\n          enable = true;\n          endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          sharedSecret.result = config.shb.hardcodedsecret.jellyfinSSOPassword.result;\n          sharedSecretForAuthelia.result = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.result;\n        };\n      };\n\n      shb.hardcodedsecret.jellyfinSSOPassword = {\n        request = config.shb.jellyfin.sso.sharedSecret.request;\n        settings.content = \"ssoPassword\";\n      };\n\n      shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = {\n        request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request;\n        settings.content = \"ssoPassword\";\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"j\";\n      };\n\n      test.login = {\n        startUrl = \"${config.test.proto}://${config.test.fqdn}\";\n        beforeHook = ''\n          page.locator('text=Sign in with Authelia').click()\n        '';\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[Ss]ign [Ii]n\";\n        loginSpawnsNewPage = true;\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"page.get_by_text(re.compile('[Aa]ccept')).click()\"\n              # For a reason I can't explain, redirection needs to happen manually.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              # For a reason I can't explain, redirection needs to happen manually.\n              # So for failing auth, we check we're back on the login page.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"page.get_by_text(re.compile('[Aa]ccept')).click()\"\n              # For a reason I can't explain, redirection needs to happen manually.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              # For a reason I can't explain, redirection needs to happen manually.\n              \"page.goto('${config.test.proto}://${config.test.fqdn}/web/')\"\n              # \"expect(page).to_have_title(re.compile('Jellyfin'))\"\n              \"expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  jellyfinTest =\n    name:\n    { nodes, testScript }:\n    shb.test.runNixOSTest {\n      name = \"jellyfin_${name}\";\n\n      interactive.sshBackdoor.enable = true;\n      interactive.nodes.server = {\n        environment.systemPackages = [\n          pkgs.sqlite\n        ];\n      };\n\n      inherit nodes;\n      inherit testScript;\n    };\nin\n{\n  basic = jellyfinTest \"basic\" {\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = jellyfinTest \"backup\" {\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.jellyfin.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = jellyfinTest \"https\" {\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    nodes.client =\n      { config, lib, ... }:\n      {\n        imports = [\n          clientLogin\n        ];\n      };\n\n    testScript = commonTestScript.access;\n  };\n\n  ldap = jellyfinTest \"ldap\" {\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n        shb.test.ldap\n        ldap\n      ];\n    };\n\n    nodes.client = {\n      imports = [\n        clientLoginLdap\n      ];\n    };\n\n    testScript = commonTestScript.access.override {\n      extraScript =\n        {\n          node,\n          ...\n        }:\n        # I have no idea why the LDAP Authentication_19.0.0.0 plugin disappears.\n        ''\n          r = server.execute('cat \"${node.config.services.jellyfin.dataDir}/plugins/LDAP Authentication_19.0.0.0/meta.json\"')\n          if r[0] != 0:\n              print(\"meta.json for plugin LDAP Authentication_19.0.0.0 not found\")\n          else:\n              c = json.loads(r[1])\n              if \"status\" in c and c[\"status\"] != \"Disabled\":\n                  raise Exception(f'meta.json status: expected Disabled, got: {c[\"status\"]}')\n        '';\n    };\n  };\n\n  sso = jellyfinTest \"sso\" {\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/karakeep.nix",
    "content": "{ shb, ... }:\nlet\n  nextauthSecret = \"nextauthSecret\";\n  oidcSecret = \"oidcSecret\";\n\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"karakeep-init.service\"\n        \"karakeep-browser.service\"\n        \"karakeep-web.service\"\n        \"karakeep-workers.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.karakeep.port\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/karakeep.nix\n      ];\n\n      test = {\n        subdomain = \"k\";\n      };\n\n      shb.karakeep = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        nextauthSecret.result = config.shb.hardcodedsecret.nextauthSecret.result;\n        meilisearchMasterKey.result = config.shb.hardcodedsecret.meilisearchMasterKey.result;\n      };\n\n      shb.hardcodedsecret.nextauthSecret = {\n        request = config.shb.karakeep.nextauthSecret.request;\n        settings.content = nextauthSecret;\n      };\n      shb.hardcodedsecret.meilisearchMasterKey = {\n        request = config.shb.karakeep.meilisearchMasterKey.request;\n        settings.content = \"meilisearch-master-key\";\n      };\n\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"${config.test.subdomain}.${config.test.domain}\" ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.karakeep = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.karakeep = {\n        ldap = {\n          userGroup = \"user_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"k\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        beforeHook = ''\n          page.get_by_role(\"button\", name=\"single sign-on\").click()\n        '';\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('new item')).to_be_visible()\"\n            ];\n          }\n          # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              # \"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?\n              \"expect(page.get_by_text(re.compile('login failed'))).to_be_visible(timeout=10000)\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.karakeep = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          clientID = \"karakeep\";\n\n          sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;\n          sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;\n        };\n      };\n\n      shb.hardcodedsecret.oidcSecret = {\n        request = config.shb.karakeep.sso.sharedSecret.request;\n        settings.content = oidcSecret;\n      };\n      shb.hardcodedsecret.oidcAutheliaSecret = {\n        request = config.shb.karakeep.sso.sharedSecretForAuthelia.request;\n        settings.content = oidcSecret;\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"karakeep_basic\";\n\n    nodes.client = { };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"karakeep_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.karakeep.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"karakeep_https\";\n\n    nodes.client = { };\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"karakeep_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n\n      virtualisation.memorySize = 4096;\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n\n        virtualisation.memorySize = 4096;\n      };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/nextcloud.nix",
    "content": "{ lib, shb, ... }:\nlet\n  supportedVersion = [\n    32\n    33\n  ];\n\n  adminUser = \"root\";\n  adminPass = \"rootpw\";\n  oidcSecret = \"oidcSecret\";\n\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.nextcloud.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"phpfpm-nextcloud.service\"\n        \"nginx.service\"\n      ];\n    waitForUnixSocket =\n      { node, ... }:\n      [\n        node.config.services.phpfpm.pools.nextcloud.socket\n      ];\n    extraScript =\n      {\n        node,\n        fqdn,\n        proto_fqdn,\n        ...\n      }:\n      ''\n        with subtest(\"fails with incorrect authentication\"):\n            client.fail(\n                \"curl -f -s --location -X PROPFIND\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:other \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \" ${proto_fqdn}/remote.php/dav/files/${adminUser}/\"\n            )\n\n            client.fail(\n                \"curl -f -s --location -X PROPFIND\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u root:rootpw \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \" ${proto_fqdn}/remote.php/dav/files/other/\"\n            )\n\n        with subtest(\"fails with incorrect path\"):\n            client.fail(\n                \"curl -f -s --location -X PROPFIND\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:${adminPass} \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \" ${proto_fqdn}/remote.php/dav/files/other/\"\n            )\n\n        with subtest(\"can access webdav\"):\n            client.succeed(\n                \"curl -f -s --location -X PROPFIND\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:${adminPass} \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \" ${proto_fqdn}/remote.php/dav/files/${adminUser}/\"\n            )\n\n        with subtest(\"can create and retrieve file\"):\n            client.fail(\n                \"curl -f -s --location -X GET\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:${adminPass} \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \"\"\" -T file \"\"\"\n                + \" ${proto_fqdn}/remote.php/dav/files/${adminUser}/file\"\n            )\n            client.succeed(\"echo 'hello' > file\")\n            client.succeed(\n                \"curl -f -s --location -X PUT\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:${adminPass} \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \"\"\" -T file \"\"\"\n                + \" ${proto_fqdn}/remote.php/dav/files/${adminUser}/\"\n            )\n            content = client.succeed(\n                \"curl -f -s --location -X GET\"\n                + \"\"\" -H \"Depth: 1\" \"\"\"\n                + \"\"\" -u ${adminUser}:${adminPass} \"\"\"\n                + \" --connect-to ${fqdn}:443:server:443\"\n                + \" --connect-to ${fqdn}:80:server:80\"\n                + \"\"\" -T file \"\"\"\n                + \" ${proto_fqdn}/remote.php/dav/files/${adminUser}/file\"\n            )\n            if content != \"hello\\n\":\n                raise Exception(\"Got incorrect content for file, expected 'hello\\n' but got:\\n{}\".format(content))\n      '';\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/nextcloud-server.nix\n      ];\n\n      test = {\n        subdomain = \"n\";\n      };\n\n      shb.nextcloud = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        dataDir = \"/var/lib/nextcloud\";\n        tracing = null;\n        defaultPhoneRegion = \"US\";\n\n        # This option is only needed because we do not access Nextcloud at the default port in the VM.\n        externalFqdn = \"${config.test.fqdn}:8080\";\n\n        adminUser = adminUser;\n        adminPass.result = config.shb.hardcodedsecret.adminPass.result;\n        debug = false; # Enable this if needed, but beware it is _very_ verbose.\n      };\n\n      shb.hardcodedsecret.adminPass = {\n        request = config.shb.nextcloud.adminPass.request;\n        settings.content = adminPass;\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"n\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Account name\";\n        passwordFieldLabelRegex = \"^ *[Pp]assword\";\n        loginButtonNameRegex = \"^[Ll]og [Ii]n$\";\n        testLoginWith = [\n          {\n            username = adminUser;\n            password = adminPass;\n            nextPageExpect = [\n              \"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n            ];\n          }\n          # Failure is after so we're not throttled too much.\n          {\n            username = adminUser;\n            password = adminPass + \"oops\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Wrong login or password')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  clientLdapLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"n\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Account name\";\n        passwordFieldLabelRegex = \"^ *[Pp]assword\";\n        loginButtonNameRegex = \"^[Ll]og [Ii]n$\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Wrong login or password')).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Wrong login or password')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  clientSsoLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n\n      test = {\n        subdomain = \"n\";\n      };\n\n      networking.hosts = {\n        \"192.168.1.2\" = [ \"auth.example.com\" ];\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        # No need since Nextcloud is auto-redirecting to the SSO sign in page.\n        # beforeHook = ''\n        #   page.get_by_role(\"link\", name=\"Sign in with SHB-Authelia\").click()\n        # '';\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldSelector = \"get_by_label(\\\"Password *\\\")\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n              \"page.goto('https://${config.test.fqdn}/settings/admin')\"\n              \"expect(page.get_by_text('Access forbidden')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Incorrect username or password')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page).to_have_title(re.compile('Dashboard'))\"\n              \"page.goto('https://${config.test.fqdn}/settings/admin')\"\n              \"expect(page.get_by_text('Access forbidden')).not_to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Incorrect username or password')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text('Incorrect username or password')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text('not member of the allowed groups')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.nextcloud = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n\n        externalFqdn = lib.mkForce null;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.nextcloud = {\n        apps.ldap = {\n          enable = true;\n          host = \"127.0.0.1\";\n          port = config.shb.lldap.ldapPort;\n          dcdomain = config.shb.lldap.dcdomain;\n          adminName = \"admin\";\n          adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result;\n          userGroup = \"user_group\";\n        };\n      };\n      shb.hardcodedsecret.nextcloudLdapUserPassword = {\n        request = config.shb.nextcloud.apps.ldap.adminPassword.request;\n        settings = config.shb.hardcodedsecret.ldapUserPassword.settings;\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.nextcloud = {\n        apps.ldap = {\n          userGroup = \"user_group\";\n        };\n        apps.sso = {\n          enable = true;\n          endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          clientID = \"nextcloud\";\n          adminGroup = \"admin_group\";\n\n          secret.result = config.shb.hardcodedsecret.oidcSecret.result;\n          secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;\n\n          fallbackDefaultAuth = false;\n        };\n      };\n      # Needed because OIDC somehow does not like self-signed certificates\n      # which we do use in tests.\n      # See https://github.com/pulsejet/nextcloud-oidc-login/issues/267\n      services.nextcloud.settings.oidc_login_tls_verify = lib.mkForce false;\n\n      shb.hardcodedsecret.oidcSecret = {\n        request = config.shb.nextcloud.apps.sso.secret.request;\n        settings.content = oidcSecret;\n      };\n      shb.hardcodedsecret.oidcAutheliaSecret = {\n        request = config.shb.nextcloud.apps.sso.secretForAuthelia.request;\n        settings.content = oidcSecret;\n      };\n    };\n\n  previewgenerator =\n    { config, ... }:\n    {\n      systemd.tmpfiles.rules = [\n        \"d '/srv/nextcloud' 0750 nextcloud nextcloud - -\"\n      ];\n\n      shb.nextcloud = {\n        apps.previewgenerator.enable = true;\n      };\n    };\n\n  externalstorage = {\n    systemd.tmpfiles.rules = [\n      \"d '/srv/nextcloud' 0750 nextcloud nextcloud - -\"\n    ];\n\n    shb.nextcloud = {\n      apps.externalStorage = {\n        enable = true;\n        userLocalMount.directory = \"/srv/nextcloud/$user\";\n        userLocalMount.mountName = \"home\";\n      };\n    };\n  };\n\n  memories =\n    { config, ... }:\n    {\n      systemd.tmpfiles.rules = [\n        \"d '/srv/nextcloud' 0750 nextcloud nextcloud - -\"\n      ];\n\n      shb.nextcloud = {\n        apps.memories.enable = true;\n        apps.memories.vaapi = true;\n      };\n    };\n\n  recognize =\n    { config, ... }:\n    {\n      systemd.tmpfiles.rules = [\n        \"d '/srv/nextcloud' 0750 nextcloud nextcloud - -\"\n      ];\n\n      shb.nextcloud = {\n        apps.recognize.enable = true;\n      };\n    };\n\n  prometheus =\n    { config, ... }:\n    {\n      shb.nextcloud = {\n        phpFpmPrometheusExporter.enable = true;\n      };\n    };\n\n  prometheusTestScript =\n    { nodes, ... }:\n    ''\n      server.wait_for_open_unix_socket(\"${nodes.server.services.phpfpm.pools.nextcloud.socket}\")\n      server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.php-fpm.port})\n      with subtest(\"prometheus\"):\n          response = server.succeed(\n              \"curl -sSf \"\n              + \" http://localhost:${toString nodes.server.services.prometheus.exporters.php-fpm.port}/metrics\"\n          )\n          print(response)\n    '';\n\n  basicTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_basic_${toString version}\";\n\n      nodes.client = {\n        imports = [\n          clientLogin\n        ];\n      };\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n        ];\n      };\n\n      testScript = commonTestScript.access;\n    };\n\n  cronTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_cron_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n        ];\n      };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.access.override {\n        extraScript =\n          {\n            node,\n            fqdn,\n            proto_fqdn,\n            ...\n          }:\n          ''\n            import time\n\n            def find_in_logs(unit, text):\n                return server.systemctl(\"status {}\".format(unit))[1].find(text) != -1\n\n            with subtest(\"cron job succeeds\"):\n                # This call does not block until the service is done.\n                server.succeed(\"systemctl start nextcloud-cron.service&\")\n\n                # If the service failed, then we're not happy.\n                status = \"active\"\n                while status == \"active\":\n                    status = server.get_unit_info(\"nextcloud-cron\")[\"ActiveState\"]\n                    time.sleep(5)\n                if status != \"inactive\":\n                    raise Exception(\"Cron job did not finish correctly\")\n\n                if not find_in_logs(\"nextcloud-cron\", \"nextcloud-cron.service: Deactivated successfully.\"):\n                    raise Exception(\"Nextcloud cron job did not finish successfully.\")\n          '';\n      };\n    };\n\n  backupTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_backup_${toString version}\";\n\n      nodes.server =\n        { config, ... }:\n        {\n          imports = [\n            basic\n            {\n              shb.nextcloud.version = version;\n            }\n            (shb.test.backup config.shb.nextcloud.backup)\n          ];\n        };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.backup;\n    };\n\n  httpsTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_https_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n          shb.test.certs\n          https\n        ];\n      };\n\n      nodes.client = { };\n\n      # TODO: Test login\n      testScript = commonTestScript.access;\n    };\n\n  previewGeneratorTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_previewGenerator_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n          shb.test.certs\n          https\n          previewgenerator\n        ];\n      };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.access;\n    };\n\n  externalStorageTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_externalStorage_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n          shb.test.certs\n          https\n          externalstorage\n        ];\n      };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.access;\n    };\n\n  # TODO: fix memories app\n  # See https://github.com/ibizaman/selfhostblocks/issues/476\n\n  memoriesTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_memories_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n          shb.test.certs\n          https\n          memories\n        ];\n      };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.access;\n    };\n\n  recognizeTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_recognize_${toString version}\";\n\n      nodes.server = {\n        imports = [\n          basic\n          {\n            shb.nextcloud.version = version;\n          }\n          shb.test.certs\n          https\n          recognize\n        ];\n      };\n\n      nodes.client = { };\n\n      testScript = commonTestScript.access;\n    };\n\n  ldapTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_ldap_${toString version}\";\n\n      nodes.server =\n        { config, ... }:\n        {\n          imports = [\n            basic\n            {\n              shb.nextcloud.version = version;\n            }\n            shb.test.certs\n            https\n            shb.test.ldap\n            ldap\n          ];\n        };\n\n      nodes.client = {\n        imports = [\n          clientLdapLogin\n        ];\n      };\n\n      testScript = commonTestScript.access;\n    };\n\n  ssoTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_sso_${toString version}\";\n\n      nodes.server =\n        { config, ... }:\n        {\n          imports = [\n            basic\n            {\n              shb.nextcloud.version = version;\n            }\n            shb.test.certs\n            https\n            shb.test.ldap\n            (shb.test.sso config.shb.certs.certs.selfsigned.n)\n            sso\n            (\n              { config, ... }:\n              {\n                networking.hosts = {\n                  \"127.0.0.1\" = [ config.test.fqdn ];\n                };\n              }\n            )\n          ];\n        };\n\n      nodes.client = {\n        imports = [\n          clientSsoLogin\n          (\n            { config, ... }:\n            {\n              networking.hosts = {\n                \"192.168.1.2\" = [ config.test.fqdn ];\n              };\n            }\n          )\n        ];\n      };\n\n      testScript = commonTestScript.access;\n    };\n\n  prometheusTest =\n    version:\n    shb.test.runNixOSTest {\n      name = \"nextcloud_prometheus_${toString version}\";\n\n      nodes.server =\n        { config, ... }:\n        {\n          imports = [\n            basic\n            {\n              shb.nextcloud.version = version;\n            }\n            prometheus\n          ];\n        };\n\n      nodes.client = { };\n\n      testScript = prometheusTestScript;\n    };\n\n  versionedTests =\n    v:\n    {\n      \"basic_${toString v}\" = basicTest v;\n\n      \"cron_${toString v}\" = cronTest v;\n\n      \"backup_${toString v}\" = backupTest v;\n\n      \"https_${toString v}\" = httpsTest v;\n\n      \"previewGenerator_${toString v}\" = previewGeneratorTest v;\n\n      \"externalStorage_${toString v}\" = externalStorageTest v;\n\n      \"ldap_${toString v}\" = ldapTest v;\n\n      \"sso_${toString v}\" = ssoTest v;\n\n      \"prometheus_${toString v}\" = prometheusTest v;\n    }\n    // lib.optionalAttrs (v == 32) {\n      \"memories_${toString v}\" = memoriesTest v;\n      \"recognize_${toString v}\" = recognizeTest v;\n    };\nin\nlib.foldl (all: v: lib.mergeAttrs all (versionedTests v)) { } supportedVersion\n"
  },
  {
    "path": "test/services/open-webui.nix",
    "content": "{ shb, ... }:\nlet\n  oidcSecret = \"oidcSecret\";\n\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.open-webui.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"open-webui.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.open-webui.port\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/open-webui.nix\n      ];\n\n      test = {\n        subdomain = \"o\";\n      };\n\n      shb.open-webui = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n      };\n      # Speeds up tests because models can't be downloaded anyway and that leads to retries.\n      services.open-webui.environment.OFFLINE_MODE = \"true\";\n\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"${config.test.subdomain}.${config.test.domain}\" ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.open-webui = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n\n      systemd.services.open-webui.environment = {\n        # Needed for open-webui to be able to talk to auth server.\n        SSL_CERT_FILE = \"/etc/ssl/certs/ca-certificates.crt\";\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.open-webui = {\n        ldap = {\n          userGroup = \"user_group\";\n          adminGroup = \"admin_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      virtualisation.memorySize = 4096;\n      test = {\n        subdomain = \"o\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}/auth\";\n        beforeHook = ''\n          page.get_by_role(\"button\", name=\"continue\").click()\n        '';\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('logged in')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"NotBobPassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"bob\";\n            password = \"BobPassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('logged in')).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"page.get_by_role('button', name=re.compile('Accept')).click()\"\n              \"expect(page.get_by_text('unauthorized')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.open-webui = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n          clientID = \"open-webui\";\n\n          sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result;\n          sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result;\n        };\n      };\n\n      shb.hardcodedsecret.oidcSecret = {\n        request = config.shb.open-webui.sso.sharedSecret.request;\n        settings.content = oidcSecret;\n      };\n      shb.hardcodedsecret.oidcAutheliaSecret = {\n        request = config.shb.open-webui.sso.sharedSecretForAuthelia.request;\n        settings.content = oidcSecret;\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"open-webui_basic\";\n\n    nodes.client = { };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"open-webui_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.open-webui.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"open-webui_https\";\n\n    nodes.client = { };\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"open-webui_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    testScript = commonTestScript.access;\n  };\n}\n"
  },
  {
    "path": "test/services/paperless.nix",
    "content": "{\n  pkgs,\n  lib,\n  shb,\n}:\nlet\n  subdomain = \"p\";\n  domain = \"example.com\";\n\n  commonTestScript = shb.test.accessScript {\n    hasSSL = { node, ... }: !(isNull node.config.shb.paperless.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"paperless-web.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { ... }:\n      [\n        28981\n        80\n      ];\n    waitForUrls = { proto_fqdn, ... }: [ \"${proto_fqdn}\" ];\n  };\n\n  base =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/services/paperless.nix\n      ];\n\n      virtualisation.memorySize = 4096;\n      virtualisation.cores = 2;\n\n      test = {\n        inherit subdomain domain;\n      };\n\n      shb.paperless = {\n        enable = true;\n        inherit subdomain domain;\n      };\n\n      # Required for tests\n      environment.systemPackages = [ pkgs.curl ];\n    };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [ base ];\n\n      test.hasSSL = false;\n    };\n\n  https =\n    { config, ... }:\n    {\n      imports = [\n        base\n        shb.test.certs\n      ];\n\n      test.hasSSL = true;\n      shb.paperless.ssl = config.shb.certs.certs.selfsigned.n;\n    };\n\n  backup =\n    { config, ... }:\n    {\n      imports = [\n        https\n        (shb.test.backup config.shb.paperless.backup)\n      ];\n    };\n\n  sso =\n    { config, ... }:\n    {\n      imports = [\n        https\n        shb.test.ldap\n        (shb.test.sso config.shb.certs.certs.selfsigned.n)\n      ];\n\n      shb.paperless.sso = {\n        enable = true;\n        provider = \"Authelia\";\n        endpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n        clientID = \"paperless\";\n        autoLaunch = true;\n        sharedSecret.result = config.shb.hardcodedsecret.paperlessSSOSecret.result;\n        sharedSecretForAuthelia.result = config.shb.hardcodedsecret.paperlessSSOSecretAuthelia.result;\n      };\n\n      shb.hardcodedsecret.paperlessSSOSecret = {\n        request = config.shb.paperless.sso.sharedSecret.request;\n        settings.content = \"paperlessSSOSecret\";\n      };\n\n      shb.hardcodedsecret.paperlessSSOSecretAuthelia = {\n        request = config.shb.paperless.sso.sharedSecretForAuthelia.request;\n        settings.content = \"paperlessSSOSecret\";\n      };\n\n      # Configure LDAP groups for group-based access control\n      shb.lldap.ensureGroups.paperless_user = { };\n\n      shb.lldap.ensureUsers.paperless_test_user = {\n        email = \"paperless_user@example.com\";\n        groups = [ \"paperless_user\" ];\n        password.result = config.shb.hardcodedsecret.ldappaperlessUserPassword.result;\n      };\n\n      shb.lldap.ensureUsers.regular_test_user = {\n        email = \"regular_user@example.com\";\n        groups = [ ];\n        password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result;\n      };\n\n      shb.hardcodedsecret.ldappaperlessUserPassword = {\n        request = config.shb.lldap.ensureUsers.paperless_test_user.password.request;\n        settings.content = \"paperless_user_password\";\n      };\n\n      shb.hardcodedsecret.ldapRegularUserPassword = {\n        request = config.shb.lldap.ensureUsers.regular_test_user.password.request;\n        settings.content = \"regular_user_password\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"paperless-basic\";\n\n    nodes.server = basic;\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"paperless-https\";\n\n    nodes.server = https;\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"paperless-https\";\n\n    nodes.server = sso;\n    nodes.client = { };\n\n    testScript = commonTestScript;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"paperless-backup\";\n\n    nodes.server = backup;\n    nodes.client = { };\n\n    testScript =\n      (shb.test.mkScripts {\n        hasSSL = args: !(isNull args.node.config.shb.paperless.ssl);\n        waitForServices = args: [\n          \"paperless-web.service\"\n          \"nginx.service\"\n        ];\n        waitForPorts = args: [\n          28981\n          80\n        ];\n        waitForUrls = args: [ \"${args.proto_fqdn}\" ];\n      }).backup;\n  };\n\n}\n"
  },
  {
    "path": "test/services/pinchflat.nix",
    "content": "{ pkgs, shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.pinchflat.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"pinchflat.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        node.config.shb.pinchflat.port\n      ];\n  };\n\n  basic =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/pinchflat.nix\n      ];\n\n      test = {\n        subdomain = \"p\";\n      };\n\n      shb.pinchflat = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n        mediaDir = \"/src/pinchflat\";\n        timeZone = \"America/Los_Angeles\";\n        secretKeyBase.result = config.shb.hardcodedsecret.secretKeyBase.result;\n      };\n\n      systemd.tmpfiles.rules = [\n        \"d '/src/pinchflat' 0750 pinchflat pinchflat - -\"\n      ];\n\n      # Needed for gitea-runner-local to be able to ping pinchflat.\n      networking.hosts = {\n        \"127.0.0.1\" = [ \"${config.test.subdomain}.${config.test.domain}\" ];\n      };\n\n      shb.hardcodedsecret.secretKeyBase = {\n        request = config.shb.pinchflat.secretKeyBase.request;\n        settings.content = pkgs.lib.strings.replicate 64 \"Z\";\n      };\n    };\n\n  clientLogin =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"p\";\n      };\n\n      test.login = {\n        startUrl = \"http://${config.test.fqdn}\";\n        # There is no login without SSO integration.\n        testLoginWith = [\n          {\n            username = null;\n            password = null;\n            nextPageExpect = [\n              \"expect(page.get_by_text('Create a media profile')).to_be_visible()\"\n            ];\n          }\n        ];\n      };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.pinchflat = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  ldap =\n    { config, ... }:\n    {\n      shb.pinchflat = {\n        ldap = {\n          enable = true;\n\n          userGroup = \"user_group\";\n        };\n      };\n    };\n\n  clientLoginSso =\n    { config, ... }:\n    {\n      imports = [\n        shb.test.baseModule\n        shb.test.clientLoginModule\n      ];\n      test = {\n        subdomain = \"p\";\n      };\n\n      test.login = {\n        startUrl = \"https://${config.test.fqdn}\";\n        usernameFieldLabelRegex = \"Username\";\n        passwordFieldLabelRegex = \"Password\";\n        loginButtonNameRegex = \"[sS]ign [iI]n\";\n        testLoginWith = [\n          {\n            username = \"alice\";\n            password = \"NotAlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"alice\";\n            password = \"AlicePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()\"\n              \"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()\"\n              \"expect(page.get_by_text('Create a media profile')).to_be_visible()\"\n            ];\n          }\n          # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep.\n          {\n            username = \"charlie\";\n            password = \"NotCharliePassword\";\n            nextPageExpect = [\n              \"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()\"\n            ];\n          }\n          {\n            username = \"charlie\";\n            password = \"CharliePassword\";\n            nextPageExpect = [\n              \"expect(page).to_have_url(re.compile('.*/authenticated'))\"\n            ];\n          }\n        ];\n      };\n    };\n\n  sso =\n    { config, ... }:\n    {\n      shb.pinchflat = {\n        sso = {\n          enable = true;\n          authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n        };\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"pinchflat_basic\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"pinchflat_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          basic\n          (shb.test.backup config.shb.pinchflat.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"pinchflat_https\";\n\n    nodes.client = {\n      imports = [\n        clientLogin\n      ];\n    };\n    nodes.server = {\n      imports = [\n        basic\n        shb.test.certs\n        https\n      ];\n    };\n\n    testScript = commonTestScript.access;\n  };\n\n  sso = shb.test.runNixOSTest {\n    name = \"pinchflat_sso\";\n\n    nodes.client = {\n      imports = [\n        clientLoginSso\n      ];\n    };\n    nodes.server =\n      { config, pkgs, ... }:\n      {\n        imports = [\n          basic\n          shb.test.certs\n          https\n          shb.test.ldap\n          ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    testScript = commonTestScript.access.override {\n      redirectSSO = true;\n    };\n  };\n}\n"
  },
  {
    "path": "test/services/vaultwarden.nix",
    "content": "{ shb, ... }:\nlet\n  commonTestScript = shb.test.mkScripts {\n    hasSSL = { node, ... }: !(isNull node.config.shb.vaultwarden.ssl);\n    waitForServices =\n      { ... }:\n      [\n        \"vaultwarden.service\"\n        \"nginx.service\"\n      ];\n    waitForPorts =\n      { node, ... }:\n      [\n        8222\n        5432\n      ];\n    # to get the get token test to succeed we need:\n    # 1. add group Vaultwarden_admin to LLDAP\n    # 2. add an Authelia user with to that group\n    # 3. login in Authelia with that user\n    # 4. go to the Vaultwarden /admin endpoint\n    # 5. create a Vaultwarden user\n    # 6. now login with that new user to Vaultwarden\n    extraScript =\n      { node, proto_fqdn, ... }:\n      ''\n        with subtest(\"prelogin\"):\n            response = curl(client, \"\", \"${proto_fqdn}/identity/accounts/prelogin\", data=unline_with(\"\", \"\"\"\n                {\"email\": \"me@example.com\"}\n            \"\"\"))\n            print(response)\n            if 'kdf' not in response:\n                raise Exception(\"Unrecognized response: {}\".format(response))\n\n        with subtest(\"get token\"):\n            response = curl(client, \"\", \"${proto_fqdn}/identity/connect/token\", data=unline_with(\"\", \"\"\"\n              scope=api%20offline_access\n              &client_id=web\n              &deviceType=10\n              &deviceIdentifier=a60323bf-4686-4b4d-96e0-3c241fa5581c\n              &deviceName=firefox\n              &grant_type=password&username=me\n              &password=mypassword\n            \"\"\"))\n            print(response)\n            if response[\"message\"] != \"Username or password is incorrect. Try again\":\n                raise Exception(\"Unrecognized response: {}\".format(response))\n      '';\n  };\n\n  basic =\n    { config, ... }:\n    {\n      test = {\n        subdomain = \"v\";\n      };\n\n      shb.vaultwarden = {\n        enable = true;\n        inherit (config.test) subdomain domain;\n\n        port = 8222;\n        databasePassword.result = config.shb.hardcodedsecret.passphrase.result;\n      };\n      shb.hardcodedsecret.passphrase = {\n        request = config.shb.vaultwarden.databasePassword.request;\n        settings.content = \"PassPhrase\";\n      };\n\n      # networking.hosts = {\n      #   \"127.0.0.1\" = [ fqdn ];\n      # };\n    };\n\n  https =\n    { config, ... }:\n    {\n      shb.vaultwarden = {\n        ssl = config.shb.certs.certs.selfsigned.n;\n      };\n    };\n\n  # Not yet supported\n  # ldap = { config, ... }: {\n  #   # shb.vaultwarden = {\n  #   #   ldapHostname = \"127.0.0.1\";\n  #   #   ldapPort = config.shb.lldap.webUIListenPort;\n  #   # };\n  # };\n\n  sso =\n    { config, ... }:\n    {\n      shb.vaultwarden = {\n        authEndpoint = \"https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}\";\n      };\n    };\nin\n{\n  basic = shb.test.runNixOSTest {\n    name = \"vaultwarden_basic\";\n\n    nodes.server = {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/vaultwarden.nix\n        basic\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  https = shb.test.runNixOSTest {\n    name = \"vaultwarden_https\";\n\n    nodes.server = {\n      imports = [\n        shb.test.baseModule\n        ../../modules/blocks/hardcodedsecret.nix\n        ../../modules/services/vaultwarden.nix\n        shb.test.certs\n        basic\n        https\n      ];\n    };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access;\n  };\n\n  # Not yet supported\n  #\n  # ldap = shb.test.runNixOSTest {\n  #   name = \"vaultwarden_ldap\";\n  #\n  #   nodes.server = lib.mkMerge [\n  #     shb.test.baseModule\n  #     ../../modules/blocks/hardcodedsecret.nix\n  #     ../../modules/services/vaultwarden.nix\n  #     basic\n  #     ldap\n  #   ];\n  #\n  #   nodes.client = {};\n  #\n  #   testScript = commonTestScript.access;\n  # };\n\n  sso = shb.test.runNixOSTest {\n    name = \"vaultwarden_sso\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          shb.test.baseModule\n          ../../modules/blocks/hardcodedsecret.nix\n          ../../modules/services/vaultwarden.nix\n          shb.test.certs\n          basic\n          https\n          shb.test.ldap\n          (shb.test.sso config.shb.certs.certs.selfsigned.n)\n          sso\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.access.override {\n      waitForPorts =\n        { node, ... }:\n        [\n          8222\n          5432\n          9091\n        ];\n      extraScript =\n        { node, proto_fqdn, ... }:\n        ''\n          with subtest(\"unauthenticated access is not granted to /admin\"):\n              response = curl(client, \"\"\"{\"code\":%{response_code},\"auth_host\":\"%{urle.host}\",\"auth_query\":\"%{urle.query}\",\"all\":%{json}}\"\"\", \"${proto_fqdn}/admin\")\n\n              if response['code'] != 200:\n                  raise Exception(f\"Code is {response['code']}\")\n              if response['auth_host'] != \"auth.${node.config.test.domain}\":\n                  raise Exception(f\"auth host should be auth.${node.config.test.domain} but is {response['auth_host']}\")\n              if response['auth_query'] != \"rd=${proto_fqdn}/admin\":\n                  raise Exception(f\"auth query should be rd=${proto_fqdn}/admin but is {response['auth_query']}\")\n        '';\n    };\n  };\n\n  backup = shb.test.runNixOSTest {\n    name = \"vaultwarden_backup\";\n\n    nodes.server =\n      { config, ... }:\n      {\n        imports = [\n          shb.test.baseModule\n          ../../modules/blocks/hardcodedsecret.nix\n          ../../modules/services/vaultwarden.nix\n          basic\n          (shb.test.backup config.shb.vaultwarden.backup)\n        ];\n      };\n\n    nodes.client = { };\n\n    testScript = commonTestScript.backup;\n  };\n}\n"
  }
]