Compare commits

...

131 commits
v0.7.3 ... main

Author SHA1 Message Date
ibizaman
973c18edb2 open-webui: more consistent example
Some checks failed
build / path-filter (push) Has been cancelled
format / format (push) Has been cancelled
build / manual (push) Has been cancelled
Demo / path-filter (push) Has been cancelled
Deploy docs / deploy (push) Has been cancelled
Demo / result (push) Has been cancelled
build / build-matrix (push) Has been cancelled
build / tests (push) Has been cancelled
build / Final Results (push) Has been cancelled
Demo / build (map[flake:basic name:homeassistant]) (push) Has been cancelled
Demo / build (map[flake:basic name:nextcloud]) (push) Has been cancelled
Demo / build (map[flake:ldap name:homeassistant]) (push) Has been cancelled
Demo / build (map[flake:ldap name:nextcloud]) (push) Has been cancelled
Demo / build (map[flake:lowlevel name:minimal]) (push) Has been cancelled
Demo / build (map[flake:minimal name:minimal]) (push) Has been cancelled
Demo / build (map[flake:sops name:minimal]) (push) Has been cancelled
Demo / build (map[flake:sso name:nextcloud]) (push) Has been cancelled
2026-06-25 22:33:29 +02:00
ibizaman
271bc1dc0b zfs: print snapshots on activation 2026-06-25 22:33:29 +02:00
ibizaman
3432df47f1 davfs: handle password file 2026-06-25 22:33:29 +02:00
ibizaman
ba0d3cac4a test: fix lib pretty printer 2026-06-25 22:33:29 +02:00
ibizaman
798aa29b45 home-assistant: use mkDerivation for lldap-ha-auth 2026-06-22 20:30:47 +02:00
ibizaman
691b0d0bdd bump to 0.9.0 2026-06-20 14:36:40 +02:00
ibizaman
2e253267db tests: replace deprecated copy_from_vm function 2026-06-20 13:18:18 +02:00
Dmitry
508bc9844c Add eval-only test for Let's Encrypt DNS provider configuration 2026-06-18 12:49:08 +02:00
Dmitry
bd9575783f Update Open WebUI never-onboarding patch 2026-06-17 13:55:57 +02:00
Dmitry
3cb61ca908 nix flake update to 9ae611a455b90cf061d8f332b977e387bda8e1ca (2026-06-10) 2026-06-17 13:55:57 +02:00
Dmitry
24fdbb81df Fix Open WebUI tests in offline mode
Open WebUI 0.9.6 still initializes its embedding function during startup, even when OFFLINE_MODE=true and BYPASS_EMBEDDING_AND_RETRIEVAL=true. With OFFLINE_MODE=true and no cached SentenceTransformer model, startup failed with:

ValueError: No embedding model is loaded. Set RAG_EMBEDDING_MODEL to a valid SentenceTransformer model name, or configure an external RAG_EMBEDDING_ENGINE (ollama, openai, azure_openai).

Configure the tests to use a dummy Ollama embedding endpoint so startup does not try to load or download a local embedding model. The endpoint is not meant to be contacted by these tests; it only keeps startup on the external embedding configuration path.
2026-06-17 12:50:57 +02:00
Pierre Penninckx
315b36c910
update nixpkgs to abd1ea17fdbedd48cbca770847b39e3a7a09ab5b (#733)
Automated nixpkgs update. Latest tries:

-
4d72182edd...9ae611a455

-
4d72182edd...8f7b4a3918
(bisected in the past) 
-
4d72182edd...abd1ea17fd
(bisected in the past)
2026-06-16 13:10:59 +02:00
Update Flake Lock PR
f500e23b96 update nixpkgs to abd1ea17fdbedd48cbca770847b39e3a7a09ab5b 2026-06-16 13:00:39 +02:00
Update Flake Lock PR
b1b3ac157c update nixpkgs to 8f7b4a3918b4d5c61788a8705ad117bf513baa70 2026-06-16 13:00:39 +02:00
Update Flake Lock PR
3969bfa121 update nixpkgs to 9ae611a455b90cf061d8f332b977e387bda8e1ca 2026-06-16 13:00:39 +02:00
Dmitry
e21a8f348d Change credentialsFile to environmentFile in ssl.nix
This option was renamed in https://github.com/NixOS/nixpkgs/pull/244477, and the alias was deleted in https://github.com/NixOS/nixpkgs/pull/512107.

There are currently no tests to catch the regression.
2026-06-16 12:44:59 +02:00
Pierre Penninckx
746eb96867
update nixpkgs to 4d72182edd899c02ec2018b784adab36bb105ddb (#710)
Automated nixpkgs update. Latest tries:

-
da5ad661ba...d233902339

-
da5ad661ba...bea8623f39
(bisected in the past) 
-
da5ad661ba...4d72182edd
(bisected in the past)
2026-06-15 13:06:40 +02:00
Update Flake Lock PR
6052d5ff35 update nixpkgs to 4d72182edd899c02ec2018b784adab36bb105ddb 2026-06-14 22:25:04 +00:00
Update Flake Lock PR
2a568b6a4d update nixpkgs to bea8623f390c15be9e1a9f674126cedf6dafa576 2026-06-14 23:37:11 +02:00
Update Flake Lock PR
de96f722fb update nixpkgs to d233902339c02a9c334e7e593de68855ad26c4cb 2026-06-14 23:37:11 +02:00
Dmitry
2a57f335f2 Cap deluged restarts at 2
`StartLimit` does not exactly cap the number of retries; instead, it specifies a window at the beginning of the service lifespan when the number of retries is capped.

Also assert later that the number of restarts is at most 1.
2026-06-14 18:37:08 +02:00
Dmitry
f4f4881310 Restart deluged on failure in tests
Running

```sh
nix build .#checks.x86_64-linux.vm_deluge_sso --max-jobs 1 --cores 1
```

`deluged` sometimes segfaults: https://gist.github.com/dniku/5056689d9191e839e39d4c7b3fbf6410

Fixing this does not seem in scope for SHB.
2026-06-14 18:37:08 +02:00
Dmitry
60dfb9d19b Extract Jellyfin test extraScript into commonExtraScript and prepend it in LDAP test
`commonTestScript.access.override` replaces the full extraScript, and in particular drops:

```
server.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'")
```

https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731 shows the symptoms:

* https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7163 logs `Login from client` (defined in 8871b9435b/test/common.nix (L166)) at timestamp `2026-06-14T08:34:06.7507323Z` (visible in the raw logs)
* https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7167 is the first request from Firefox at timestamp 2026-06-14T08:34:18.2868336Z, so the attempt to fill in the username starts around there, with a 30-second timeout
*  https://github.com/ibizaman/selfhostblocks/actions/runs/27462763816/job/81262609484?pr=731#step:5:7577 logs `Main: Startup complete 0:01:00.1813498` at timestamp 2026-06-14T08:34:51.1325282Z, 45 seconds after test starts

Work around by explicitly prepending the full extraScript to the override.
2026-06-14 16:21:49 +02:00
Dmitry
8871b9435b
grafana: fix tests to use API for checking user login (#725) 2026-06-07 13:11:26 +02:00
Dmitry
1b89d47651
use Python instead of sed for secret substitution and use escapeShellArg
#720
2026-06-05 18:07:00 +02:00
Dmitry
669d4bf1b7 test(open-webui): assert SSO session instead of login toast
Open WebUI redirects from /auth to / after OIDC login and briefly shows the "You're now logged in" toast during that transition. The toast is not a stable post-login signal, so the VM test can fail even though SSO succeeded.

Wait for the final root URL and verify the authenticated Open WebUI session through /api/v1/auths/ instead. This keeps the test focused on the expected SSO outcome while avoiding the transient UI race.
2026-06-05 09:28:29 +02:00
ibizaman
066ff81def scrutiny: use correct assert in tests 2026-06-04 00:47:53 +02:00
ibizaman
81c32c75d5 scrutiny: remove click in test 2026-06-04 00:47:53 +02:00
ibizaman
8dcb2af682 scrutiny: increase delays in tests 2026-06-04 00:47:53 +02:00
ibizaman
1fa0781f09 authelia: admin defeat and comment out failing tests for now 2026-06-04 00:47:53 +02:00
ibizaman
5bfd1c4008 open-webui: increase test timeouts 2026-06-04 00:47:53 +02:00
ibizaman
9736456153 open-webui: update assert in test after wording change 2026-06-04 00:47:53 +02:00
ibizaman
aa290b517c mailserver: use same casing for passwords as for other tests 2026-06-04 00:47:53 +02:00
ibizaman
1a056b4c23 tests: use correct casing in tests 2026-06-04 00:47:53 +02:00
ibizaman
4bb9287aa4 home-assistant: fix login test 2026-06-04 00:47:53 +02:00
ibizaman
2c23d8a258 tests: keep trace even on failure 2026-06-04 00:47:53 +02:00
ibizaman
337f3ec8bc tests: fail tests if playwright cannot login 2026-06-04 00:47:53 +02:00
ibizaman
d5654165ea zfs: handle dubplicate dataset names 2026-05-26 22:47:34 +02:00
ibizaman
941f2257d0 zfs: allow snapshot before activation 2026-05-26 22:25:53 +02:00
ibizaman
5fd0b1dc83 fluentbit: remove tracing 2026-05-24 22:26:33 +02:00
ibizaman
db0b5df83d fluentbit: fix unit startup 2026-05-24 11:19:38 +02:00
ibizaman
503f1c31ea mailserver: update to e33fbde and add test 2026-05-24 01:37:52 +02:00
ibizaman
c7e404f8be tests: use hardcodedsecret for lldap secrets 2026-05-24 01:37:52 +02:00
ibizaman
672b536390 update: add nixpkgs commit in title 2026-05-14 10:16:35 +02:00
Pierre Penninckx
6bec01a954
Update nixpkgs (#708)
Automated nixpkgs update. Latest tries:

-
e28d4b75e9...da5ad661ba
2026-05-12 17:50:45 +02:00
Update Flake Lock PR
6875627d25 update nixpkgs to da5ad661ba4e5ef59ba743f0d112cbc30e474f32 2026-05-12 15:13:52 +00:00
ibizaman
1094342950 update: fix flake lock update
Fix handling of bisect_future input.
2026-05-12 17:08:21 +02:00
ibizaman
c1edcc7f77 update: fix flake lock update 2026-05-12 16:59:55 +02:00
ibizaman
3cc8ebd4a4 update: fix flake lock update 2026-05-12 16:51:45 +02:00
ibizaman
818c18554f update: fix flake lock update 2026-05-12 16:45:21 +02:00
dependabot[bot]
8eabeb2c39 build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 08:58:22 +02:00
dependabot[bot]
7ee9318bd0 build(deps): bump actions/upload-pages-artifact from 4 to 5
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-11 17:21:55 +02:00
ibizaman
f933f405b4 scrutiny: fix patch by rebasing in fork 2026-05-11 01:27:27 +02:00
ibizaman
029a31cf57 update nixpkgs to e28d4b7 to fix building grafana 2026-05-11 01:27:27 +02:00
ibizaman
e07b414473 demo: add needed fsType
See https://github.com/NixOS/nixpkgs/pull/444829
2026-05-11 01:27:27 +02:00
ibizaman
acee18e7a3 monitoring: switch to fluent-bit 2026-05-11 01:27:27 +02:00
ibizaman
ec3406039d open-webui: increase memory for open-webui test 2026-05-11 01:27:27 +02:00
ibizaman
670ebbc7b5 open-webui: fix never onboard patch 2026-05-11 01:27:27 +02:00
ibizaman
23d3a59172 authelia: update test now that authelia forbids http 2026-05-11 01:27:27 +02:00
ibizaman
49efab7385 mitmdump: add timeout option 2026-05-11 01:27:27 +02:00
ibizaman
b63caca422 update nixpkgs to c0ef3dd78d91e9794d9571926ad73b83a2b69ee0 2026-05-11 01:27:27 +02:00
ibizaman
467595bea1 update nixpkgs to bf70a27d013ece986224bc9aa9ce41e6b8bad42f 2026-05-11 01:27:27 +02:00
ibizaman
8538944042 update nixpkgs to 71600138d1df554c4f22f126db68464acb24b0c3 2026-05-11 01:27:27 +02:00
ibizaman
da6a38712c update nixpkgs to f2c3ab4e1f6deb015423652ae19a677948352389 2026-05-11 01:27:27 +02:00
ibizaman
cefa53ca6a update nixpkgs to f70c5455a60385fee072d1503a8ea7e317c7a7c8 2026-05-11 01:27:27 +02:00
ibizaman
c452148f2c update nixpkgs to 961fc87eb29740a58452f2d643135df38d08eaff 2026-05-11 01:27:27 +02:00
ibizaman
b81a29ba68 update nixpkgs to 65f69374ab04ecaa8917b19e5884fb5f254c4ed5 2026-05-11 01:27:27 +02:00
ibizaman
319afed097 update nixpkgs to 549bd84d6279f9852cae6225e372cc67fb91a4c1 2026-05-11 01:27:27 +02:00
ibizaman
e57c9a1c22 ci: use bisect flake lock workflow 2026-05-11 01:27:27 +02:00
ibizaman
d4776dc2b8 zfs: fix doc 2026-04-27 23:12:41 +02:00
ibizaman
d830a92342 sanoid: fix doc 2026-04-27 23:12:41 +02:00
ibizaman
7ca1f93f3f datasetBackup: add contract with sanoid implementation 2026-04-27 06:38:52 +02:00
ibizaman
a0d29e9d97 backup: enhance restore script and more tests 2026-04-26 23:17:28 +02:00
ibizaman
bdd8a0cc2a zfs: add doc and tests and convert to systemd services 2026-04-26 22:19:53 +02:00
ibizaman
ff20e56ae1 contract: fix backup and databasebackup documentation 2026-04-26 21:46:13 +02:00
ibizaman
96a8753548 backup: do not make systemd service wait for backup to complete
fixes #421
2026-04-26 21:40:48 +02:00
ibizaman
4855404f5d lldap: add hardening configuration 2026-04-25 23:45:52 +02:00
ibizaman
e69c253bdd fix bump to 0.8.0 in changelog 2026-04-25 23:26:45 +02:00
ibizaman
bba09dc607 bump to 0.8.0 2026-04-15 02:00:06 +02:00
ibizaman
b01cc68ac3 mailserver: add dashboard contract 2026-04-15 01:46:42 +02:00
ibizaman
a237cdfc7b mailserver: add landing page 2026-04-15 01:46:42 +02:00
ibizaman
ce16f2c1d6 mailserver: fix smtp description 2026-04-15 01:46:42 +02:00
github-actions[bot]
640e00abb6 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/8110df5ad7abf5d4c0f6fb0f8f978390e77f9685?narHash=sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg%3D' (2026-03-28)
  → 'github:nixos/nixpkgs/6201e203d09599479a3b3450ed24fa81537ebc4e?narHash=sha256-ZojAnPuCdy657PbTq5V0Y%2BAHKhZAIwSIT2cb8UgAz/U%3D' (2026-04-01)
2026-04-02 04:54:47 +02:00
github-actions[bot]
7596056b9d flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/aed2906f73add0ecb8e80a1da734aa029ca254e0?narHash=sha256-MUBFHKnsUMWHIXJWxfpYfKaeQRMJzgUjnZZYjaLIyfQ%3D' (2026-03-31)
  → 'github:nixos/nixpkgs/8110df5ad7abf5d4c0f6fb0f8f978390e77f9685?narHash=sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg%3D' (2026-03-28)
2026-04-01 05:01:04 +02:00
ibizaman
968e54dd89 deluge: refactor to fix test 2026-03-31 23:19:33 +02:00
ibizaman
40abc1f15f immich: remove deprecated option 2026-03-31 23:19:33 +02:00
ibizaman
7d0c97e7d3 open-webui: no need anymore for custom patch for roles 2026-03-31 23:19:33 +02:00
github-actions[bot]
7139a1d0bf flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/aca4d95fce4914b3892661bcb80b8087293536c6?narHash=sha256-E1bxHxNKfDoQUuvriG71%2Bf%2Bs/NT0qWkImXsYZNFFfCs%3D' (2026-03-06)
  → 'github:nixos/nixpkgs/8110df5ad7abf5d4c0f6fb0f8f978390e77f9685?narHash=sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg%3D' (2026-03-28)
2026-03-31 23:19:33 +02:00
dependabot[bot]
b5adefcb57 build(deps): bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 21:17:24 +02:00
dependabot[bot]
6e49b2d5c0 build(deps): bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-31 21:15:41 +02:00
Dmitry
78b3a989f9 Fix syntax for Homepage datetime widget 2026-03-29 23:27:22 +02:00
Dmitry
1dabf955a8 Fix typo in SMTP server configuration recommendation 2026-03-28 19:23:30 +01:00
Dmitry
a400efb636 monitoring: fix secret propagation in documentation example 2026-03-28 17:01:24 +01:00
Dmitry
25238560ad Fix missing .request in restic documentation 2026-03-28 16:39:59 +01:00
dependabot[bot]
e7c1fcf76e build(deps): bump cachix/cachix-action from 16 to 17
Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 16 to 17.
- [Release notes](https://github.com/cachix/cachix-action/releases)
- [Commits](https://github.com/cachix/cachix-action/compare/v16...v17)

---
updated-dependencies:
- dependency-name: cachix/cachix-action
  dependency-version: '17'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 11:54:40 +01:00
ibizaman
419c339266 monitoring: reduce metrics scraping interval from 1m to 15s 2026-03-22 22:22:53 +01:00
ibizaman
f0124a4615 ssl: make self-signed certs idempotent 2026-03-15 15:57:00 +01:00
Dmitry
9d7e93b70d Clarify in SSO demo that dnsmasq is required for Nextcloud/Authelia interaction 2026-03-15 13:40:27 +01:00
ibizaman
8590aa37d6 zfs: add permissions management 2026-03-14 23:27:23 +01:00
ibizaman
0c5116ee21 authelia: add info on secrets length 2026-03-14 23:13:36 +01:00
ibizaman
b3c3f85191 lldap: add info on secrets length 2026-03-14 23:13:36 +01:00
ibizaman
9185403b70 nextcloud: make more obvious initial admin username 2026-03-14 00:32:46 +01:00
ibizaman
7a1f5ec921 nextcloud: do not fail startup on pre-startup commands 2026-03-14 00:08:18 +01:00
ibizaman
1c87d059b2 flake: allow patches on aarch64-linux too 2026-03-13 00:03:56 +01:00
ibizaman
0dc2406b6a scrutiny: init 2026-03-12 23:24:41 +01:00
ibizaman
606869bdc1 nextcloud: fix oidc_login version 2026-03-12 20:43:43 +01:00
ibizaman
c7cf3cce13 monitoring: contact points is now always needed it seems 2026-03-12 20:43:43 +01:00
ibizaman
cccafa9c56 sonarr: create needed datadir 2026-03-12 20:43:43 +01:00
ibizaman
7611e99eaa nextcloud: disable tests for apps not supporting 33 2026-03-12 20:43:43 +01:00
ibizaman
d28c8d7b6a mod: only define checks for x86_64-linux 2026-03-12 20:43:43 +01:00
ibizaman
c333122720 nextcloud: bump to 32 and 33 2026-03-12 20:43:43 +01:00
ibizaman
4724e926fd lldap: update patch 2026-03-12 20:43:43 +01:00
github-actions[bot]
418500d924 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/bfc1b8a4574108ceef22f02bafcf6611380c100d?narHash=sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI%3D' (2026-01-26)
  → 'github:nixos/nixpkgs/aca4d95fce4914b3892661bcb80b8087293536c6?narHash=sha256-E1bxHxNKfDoQUuvriG71%2Bf%2Bs/NT0qWkImXsYZNFFfCs%3D' (2026-03-06)
2026-03-12 20:43:43 +01:00
Dmitry
d635527328 Fix description for Nextcloud example 2026-03-12 11:14:24 +01:00
dependabot[bot]
23357c9945 build(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 21:28:15 +01:00
ibizaman
261e7d47d5 lldap: set better default for enforceGroups 2026-02-27 13:18:03 +01:00
ibizaman
c0c9562789 forgejo: make sure ldap is setup when sso is enabled 2026-02-26 22:40:23 +01:00
ibizaman
d8981d465b mod: add example on how to import only contracts 2026-02-24 20:33:35 +01:00
ibizaman
9fcdff56bb docs: fix watcher script 2026-02-24 20:00:36 +01:00
ibizaman
f485cc60fe homepage: init with dashboard contract 2026-02-24 20:00:36 +01:00
ibizaman
77869300ee forgejo: create ldap groups 2026-02-24 20:00:36 +01:00
ibizaman
ca11631015 forgejo: add default empty users option 2026-02-24 20:00:36 +01:00
ibizaman
de013ce302 arr: bypass authentication for readarr when sso is enabled 2026-02-24 20:00:36 +01:00
ibizaman
6fb2250669 arr: make arr stack declare ldap groups 2026-02-24 20:00:36 +01:00
ibizaman
0d2a7c315f arr: add declarative api key 2026-02-24 20:00:36 +01:00
ibizaman
54d9be7783 docs: link to expose service recipe from blocks page 2026-02-24 20:00:36 +01:00
ibizaman
6d070cf9d7 docs: fix expose service recipe snippets 2026-02-24 20:00:36 +01:00
ibizaman
084b583667 docs: fix dns recipe title 2026-02-24 20:00:36 +01:00
sitrius
70bf331c6a refactor: use cfg'.dataDir directly for consistency and add missing bazarr dataDir
- Replace shb.arr.<app>.dataDir with cfg'.dataDir for direct access
- Add missing dataDir configuration for bazarr service
- Update all resultPath references to use cfg'.dataDir
2026-02-22 10:04:13 +01:00
sitrius
5da47a77c7 fix: use configurable dataDir paths in arr stack and fix sops.secret references
- Replace hardcoded /var/lib/<app> paths with shb.arr.<app>.dataDir in arr.nix
- Fix bazarr config path in test to use cfg.dataDir
- Correct shb.sops.secrets → shb.sops.secret in documentation files
2026-02-22 10:04:13 +01:00
sivert
62ea06e57c Added Immich Public Proxy service for publically sharing photos
Requests to /share/* are optionally redirected to Immich Public Proxy
for public access without going through Authelia, and without exposing
the whole Immich service.

Fixes #656
2026-02-21 18:31:32 +01:00
118 changed files with 7981 additions and 1256 deletions

View file

@ -23,7 +23,7 @@ on:
required: false required: false
jobs: jobs:
automerge: automerge-rebase:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: reitermarkus/automerge@v2 - uses: reitermarkus/automerge@v2
@ -35,3 +35,16 @@ jobs:
pull-request: ${{ github.event.inputs.pull-request }} pull-request: ${{ github.event.inputs.pull-request }}
review: ${{ github.event.inputs.review }} review: ${{ github.event.inputs.review }}
dry-run: false dry-run: false
automerge-merge:
runs-on: ubuntu-latest
steps:
- uses: reitermarkus/automerge@v2
with:
token: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
merge-method: merge
do-not-merge-labels: never-merge
required-labels: automerge-merge
pull-request: ${{ github.event.inputs.pull-request }}
review: ${{ github.event.inputs.review }}
dry-run: false

View file

@ -61,7 +61,7 @@ jobs:
keep-outputs = true keep-outputs = true
keep-failed = true keep-failed = true
- name: Setup Caching - name: Setup Caching
uses: cachix/cachix-action@v16 uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -93,7 +93,7 @@ jobs:
keep-outputs = true keep-outputs = true
keep-failed = true keep-failed = true
- name: Setup Caching - name: Setup Caching
uses: cachix/cachix-action@v16 uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -124,7 +124,7 @@ jobs:
keep-outputs = true keep-outputs = true
keep-failed = true keep-failed = true
- name: Setup Caching - name: Setup Caching
uses: cachix/cachix-action@v16 uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -133,7 +133,7 @@ jobs:
echo "resultPath=$(nix eval .#checks.x86_64-linux.${{ matrix.check }} --raw)" >> $GITHUB_ENV echo "resultPath=$(nix eval .#checks.x86_64-linux.${{ matrix.check }} --raw)" >> $GITHUB_ENV
nix build --print-build-logs --show-trace .#checks.x86_64-linux.${{ matrix.check }} nix build --print-build-logs --show-trace .#checks.x86_64-linux.${{ matrix.check }}
- name: Upload Build Result - name: Upload Build Result
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
if: always() && startsWith(matrix.check, 'vm_') if: always() && startsWith(matrix.check, 'vm_')
with: with:
name: ${{ matrix.check }} name: ${{ matrix.check }}

View file

@ -77,7 +77,7 @@ jobs:
keep-outputs = true keep-outputs = true
keep-failed = true keep-failed = true
- uses: cachix/cachix-action@v16 - uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

View file

@ -15,7 +15,7 @@ jobs:
with: with:
github_access_token: ${{ secrets.GITHUB_TOKEN }} github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Caching - name: Setup Caching
uses: cachix/cachix-action@v16 uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

View file

@ -1,9 +1,16 @@
# See shb.skarabox.com/misc.html
name: Update Flake Lock name: Update Flake Lock
on: on:
workflow_dispatch:
schedule: schedule:
- cron: '0 0 * * *' # runs daily at 00:00 - cron: '0 */3 * * *' # runs every 3 hours at :00
workflow_dispatch:
inputs:
bisect_future:
description: "Bisect in the future instead of the past"
required: false
default: "false"
jobs: jobs:
lockfile: lockfile:
@ -15,9 +22,22 @@ jobs:
uses: cachix/install-nix-action@v31 uses: cachix/install-nix-action@v31
with: with:
github_access_token: ${{ secrets.GITHUB_TOKEN }} github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Update flake.lock - name: Cache nixpkgs mirror
uses: DeterminateSystems/update-flake-lock@main uses: actions/cache@v5
with: with:
token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} path: .cache/nixpkgs.git
pr-labels: | key: nixpkgs-mirror-v1
automerge - name: Update flake.lock
env:
GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
BISECT_FUTURE: ${{ inputs.bisect_future }}
run: |
git config user.email "update-flake-lock-pr@selfhostblocks.com"
git config user.name "Update Flake Lock PR"
if [ "$BISECT_FUTURE" = false ]; then
BISECT_FUTURE=
elif [ -n "$BISECT_FUTURE" ]; then
BISECT_FUTURE=future
fi
nix run .#update-flake-lock-pr $BISECT_FUTURE

View file

@ -37,7 +37,7 @@ jobs:
github_access_token: ${{ secrets.GITHUB_TOKEN }} github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Caching - name: Setup Caching
uses: cachix/cachix-action@v16 uses: cachix/cachix-action@v17
with: with:
name: selfhostblocks name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -59,13 +59,13 @@ jobs:
public public
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v5 uses: actions/configure-pages@v6
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v5
with: with:
path: ./public path: ./public
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@v5

View file

@ -0,0 +1,223 @@
{
gh,
writeShellApplication,
}:
writeShellApplication {
name = "update-flake-lock-pr";
runtimeInputs = [
gh
];
text = ''
branch=ci/nixpkgs-update
if [ "''${GITHUB_EVENT_NAME:-}" = "schedule" ]; then
branch=ci/nixpkgs-update-auto
fi
goto_future=no
if [ -n "''${1:-}" ]; then
goto_future=yes
fi
main () {
PR=$(get_pr)
if [ -z "$PR" ]; then
create_pr
exit 0
fi
case "$(get_pr_check_status "$PR")" in
pending)
echo "Checks are running on PR $PR, nothing to do yet."
exit 0
;;
succeeded)
echo "Checks succeeded, nothing to do."
exit 0
;;
failing)
echo "Checks failed, updating PR to midpoint commit."
local CURRENT_COMMIT
local LAST_WORKING_COMMIT
local FAILING_COMMIT
local NEW_COMMIT
CURRENT_COMMIT="$(get_main_branch_commit)"
if [ "$goto_future" = "no" ]; then
LAST_WORKING_COMMIT="$CURRENT_COMMIT"
FAILING_COMMIT="$(get_pr_commit "$PR")"
else
LAST_WORKING_COMMIT="$(get_pr_commit "$PR")"
FAILING_COMMIT="$(get_latest_nixpkgs_commit)"
fi
NEW_COMMIT="$(midpoint_commit "$LAST_WORKING_COMMIT" "$FAILING_COMMIT")"
if [ "$NEW_COMMIT" = "$LAST_WORKING_COMMIT" ] || [ "$NEW_COMMIT" = "$FAILING_COMMIT" ]; then
NEW_COMMIT="$(get_latest_nixpkgs_commit)"
fi
if [ "$NEW_COMMIT" = "$FAILING_COMMIT" ]; then
echo "Latest nixpkgs commit is still same failing commit, will retry later."
exit 0
fi
update_pr "$PR" "$CURRENT_COMMIT" "$NEW_COMMIT"
exit 0
;;
esac
}
get_main_branch_commit() {
git fetch origin main
git show origin/main:flake.lock \
| jq -r '.nodes.nixpkgs.locked.rev'
}
midpoint_commit() {
local good="$1"
local bad="$2"
mkdir -p .cache
if [ ! -d .cache/nixpkgs.git ]; then
git clone --mirror https://github.com/NixOS/nixpkgs.git .cache/nixpkgs.git
else
git --git-dir=.cache/nixpkgs.git fetch origin
fi
local commits=()
mapfile -t commits < <(
git --git-dir=.cache/nixpkgs.git \
rev-list \
--reverse \
--ancestry-path \
"''${good}..''${bad}"
)
local count="''${#commits[@]}"
if [ "$count" -eq 0 ]; then
echo "$bad"
return
fi
local midpoint_index=$((count / 2))
echo "''${commits[$midpoint_index]}"
}
get_latest_nixpkgs_commit () {
git ls-remote https://github.com/NixOS/nixpkgs nixos-unstable | cut -f1
}
get_pr () {
gh pr list \
--head "$branch" \
--state open \
--json number \
--jq '.[0].number // empty'
}
create_pr() {
local test_commit
test_commit="$(get_latest_nixpkgs_commit)"
echo "Creating PR on nixpkgs' latest commit $test_commit"
git checkout -B "$branch"
nix flake lock \
--override-input nixpkgs "github:NixOS/nixpkgs/$test_commit"
git add flake.lock
git commit -m "update nixpkgs to $test_commit"
git push -u origin "$branch" --force
current_commit="$(get_main_branch_commit)"
gh pr create \
--title "update nixpkgs to $test_commit" \
--body "$(printf "%s\n\n%s" "Automated nixpkgs update. Latest tries:" " - https://github.com/NixOS/nixpkgs/compare/$current_commit...$test_commit")" \
--label automerge-merge
}
append_pr_body() {
local pr="$1"
local text="$2"
local current_body
current_body="$(gh pr view "$pr" --json body --jq .body)"
gh pr edit "$pr" \
--body "$(printf '%s%b' "$current_body" "$text")"
}
update_pr() {
local pr="$1"
local current_commit="$2"
local test_commit="$3"
echo "Updating PR to nixpkgs' commit $test_commit"
git checkout "$branch"
nix flake lock \
--override-input nixpkgs "github:NixOS/nixpkgs/$test_commit"
git add flake.lock
git commit -m "update nixpkgs to $test_commit"
git push --force-with-lease origin "$branch"
local future_text
if [ "$goto_future" = "yes" ]; then
future_text="bisected in the future"
else
future_text="bisected in the past"
fi
gh pr edit "$pr" \
--title "update nixpkgs to $test_commit"
append_pr_body "$pr" \
" \n - https://github.com/NixOS/nixpkgs/compare/$current_commit...$test_commit ($future_text)"
}
get_pr_check_status() {
local pr="$1"
local status
status="$(gh pr checks "$pr" --json state --jq '.[].state' || true)"
if echo "$status" | grep -qE 'PENDING|QUEUED|IN_PROGRESS'; then
echo "pending"
return
fi
if echo "$status" | grep -qE 'FAILURE|TIMED_OUT|CANCELLED'; then
echo "failing"
return
fi
echo "success"
}
get_pr_commit() {
local pr="$1"
git fetch origin "$branch"
git show "origin/$branch:flake.lock" \
| jq -r '.nodes.nixpkgs.locked.rev'
}
main
'';
}

View file

@ -16,6 +16,70 @@ Template:
# Upcoming Release # Upcoming Release
# v0.9.0
Commits: https://github.com/ibizaman/selfhostblocks/compare/v0.8.0...v0.9.0
## Breaking Changes
- Updated simple-nixos-mailserver. Update all options following this pattern:
```diff
- mailserver.mailboxes.<name>.specialUse = "value"
+ mailserver.mailboxes.<name>.special_use = "\\value"
```
Note that simple-nixos-mailserver wanted to upgrade the location of the storage path
to include the ldap UID instead of the email but I kept the email address.
This means there is no migration to do.
- Update nixpkgs [from 6201e2 to abd1ea](https://github.com/ibizaman/selfhostblocks/compare/6201e203d09599479a3b3450ed24fa81537ebc4e...abd1ea17fdbedd48cbca770847b39e3a7a09ab5b).
## New Features
- Add `shb.zfs.snapshotBeforeActivation` option to take a ZFS snapshot of given datasets before activation.
See [the manual](https://shb.skarabox.com/blocks-zfs.html) for more details.
- Add [sanoid block](https://shb.skarabox.com/blocks-sanoid.html)
- Add option to take snapshot before activation with ZFS block.
## Fixes
- Fix oidc_login build for Nextcloud
- Fix mailserver build
## Other Changes
- Harden lldap configuration
- Convert ZFS block to system and [add documentation](https://shb.skarabox.com/blocks-zfs.html)
- Switch monitoring log fetching from deprecated promtail to fluent-bit
- Add test to catch drift for Let's Encrypt DNS provider
# [BROKEN] v0.8.0
## Breaking Changes
- Bump of Nextcloud version to 32 and 33 because of nixpkgs bump. All provided apps are verified compatible with Nextcloud 33 thanks to new tests.
## New Features
- Added Immich Public Proxy service
- Add homepage service with dashboard contract implemented by all services
- Add scrutiny service.
- ZFS module now supports setting permissions
- Add landing page for mailserver and dashboard contract integration
## Bug Fixes
- Use configurable dataDir in arr stack
- Forgejo ensures ldap is setup when sso is configured
- Add nixpkgs patches on aarch64-linux too
- Self-signed certs are now idempotent
- Prometheus scrapes metrics at 15s interval instead of 1m
## Other Changes
- Arr stack declares ldap groups, declare ApiKeys and bypasses auth for readarr when sso is enabled
- Forgejo declares ldap group
# v0.7.3 # v0.7.3
## New Features ## New Features

View file

@ -153,7 +153,7 @@ SelfHostBlocks provides building blocks that take care of common self-hosting ne
- Backup for all services. - Backup for all services.
- Automatic creation of ZFS datasets per service. - Automatic creation of ZFS datasets per service.
- LDAP and SSO integration for most services. - LDAP and SSO integration for most services.
- Monitoring with Grafana and Prometheus stack with provided dashboards. - Monitoring with Grafana and Prometheus stack with provided dashboards and integration with Scrutiny.
- Automatic reverse proxy and certificate management for HTTPS. - Automatic reverse proxy and certificate management for HTTPS.
- VPN and proxy tunneling services. - VPN and proxy tunneling services.
@ -208,7 +208,7 @@ which altogether provides a solid foundation for self-hosting services:
- BorgBackup - BorgBackup
- Davfs - Davfs
- LDAP - LDAP
- Monitoring (Grafana - Prometheus - Loki stack) - Monitoring (Grafana - Prometheus - Loki stack + Scrutiny)
- Nginx - Nginx
- PostgreSQL - PostgreSQL
- Restic - Restic
@ -250,14 +250,14 @@ shb.nextcloud = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."nextcloud/ldap/admin_password".result; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result;
}; };
apps.sso = { apps.sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."nextcloud/sso/secret".result; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."nextcloud/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
}; };
}; };
``` ```
@ -275,15 +275,15 @@ shb.forgejo = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."nextcloud/ldap/admin_password".result; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result;
}; };
sso = { sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."forgejo/sso/secret".result; secret.result = config.shb.sops.secret."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."forgejo/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result;
}; };
}; };
``` ```

View file

@ -1 +1 @@
0.7.3 0.9.0

View file

@ -24,7 +24,10 @@
# This module makes the assertions happy and the build succeed. # This module makes the assertions happy and the build succeed.
# This is of course wrong and will not work on any real system. # This is of course wrong and will not work on any real system.
filesystemModule = { filesystemModule = {
fileSystems."/".device = "/dev/null"; fileSystems."/" = {
device = "/dev/null";
fsType = "none";
};
boot.loader.grub.devices = [ "/dev/null" ]; boot.loader.grub.devices = [ "/dev/null" ];
}; };
in in
@ -181,6 +184,40 @@
) )
]; ];
}; };
# Test with:
# nix build .#nixosConfigurations.contractsDirect.config.system.build.toplevel
contractsDirect =
let
nixosSystem' = import "${selfhostblocks.inputs.nixpkgs}/nixos/lib/eval-config.nix";
in
nixosSystem' {
inherit system;
modules = [
filesystemModule
(import "${selfhostblocks}/lib/module.nix")
(
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {
myOption.source = "/a/path";
# Use the option.
environment.etc.myOption.text = config.myOption.source;
};
}
)
];
};
}; };
}; };
} }

View file

@ -187,8 +187,9 @@ This section corresponds to the `sso` section of the [Nextcloud
manual](services-nextcloud.html#services-nextcloudserver-usage-oidc). manual](services-nextcloud.html#services-nextcloudserver-usage-oidc).
:::: ::::
At this point, it is assumed you already deployed the `sso` demo. There is no host to add to At this point, it is assumed you already deployed the `sso` demo. This time, we cannot simply edit local
`/etc/hosts` here. Instead, there is a `dnsmasq` server running in the VM and you must create a `/etc/hosts`, because Nextcloud SSO addon must be able to connect to Authelia by domain name
(`auth.example.com`). Instead, there is a `dnsmasq` server running in the VM and you must create a
SOCKS proxy to connect to it like so: SOCKS proxy to connect to it like so:
```bash ```bash

View file

@ -1,5 +1,5 @@
{ {
description = "Home Assistant example for Self Host Blocks"; description = "Nextcloud example for Self Host Blocks";
inputs = { inputs = {
selfhostblocks.url = "github:ibizaman/selfhostblocks"; selfhostblocks.url = "github:ibizaman/selfhostblocks";

View file

@ -5,8 +5,11 @@ Blocks help you self-host apps or services. They implement a specific function l
access through a subdomain. Each block is designed to be usable on its own and to fit nicely with access through a subdomain. Each block is designed to be usable on its own and to fit nicely with
others. others.
Not all blocks are documented yet. All blocks are implemented under the blocks folder [in the repository](@REPO@/modules/blocks).
You can find all available blocks [in the repository](@REPO@/modules/blocks).
All services in SHB document how to setup the various blocks provided here.
For custom services or those not provided by SHB,
the [Expose a service Recipe](recipes-exposeService.html) explains how to use the blocks here.
## Authentication {#blocks-category-authentication} ## Authentication {#blocks-category-authentication}
@ -28,6 +31,10 @@ modules/blocks/borgbackup/docs/default.md
modules/blocks/restic/docs/default.md modules/blocks/restic/docs/default.md
``` ```
```{=include=} chapters html:into-file=//blocks-sanoid.html
modules/blocks/sanoid/docs/default.md
```
## Database {#blocks-category-database} ## Database {#blocks-category-database}
```{=include=} chapters html:into-file=//blocks-postgresql.html ```{=include=} chapters html:into-file=//blocks-postgresql.html
@ -40,6 +47,12 @@ modules/blocks/postgresql/docs/default.md
modules/blocks/sops/docs/default.md modules/blocks/sops/docs/default.md
``` ```
## Filesystem {#blocks-category-filesystem}
```{=include=} chapters html:into-file=//blocks-zfs.html
modules/blocks/zfs/docs/default.md
```
## Network {#blocks-category-network} ## Network {#blocks-category-network}
```{=include=} chapters html:into-file=//blocks-ssl.html ```{=include=} chapters html:into-file=//blocks-ssl.html

View file

@ -37,14 +37,21 @@ Provided contracts are:
- [SSL generator contract](contracts-ssl.html) to generate SSL certificates. - [SSL generator contract](contracts-ssl.html) to generate SSL certificates.
Two providers are implemented: self-signed and Let's Encrypt. Two providers are implemented: self-signed and Let's Encrypt.
- [Backup contract][] to backup directories. - [Backup contract][] to backup directories.
One provider is implemented: [Restic][]. Two providers are implemented: [BorgBackup][] and [Restic][].
- [Database Backup contract](contracts-databasebackup.html) to backup database dumps. - [Database Backup contract](contracts-databasebackup.html) to backup database dumps.
One provider is implemented: [Restic][]. Two providers are implemented: [BorgBackup][] and [Restic][].
- [Secret contract](contracts-secret.html) to provide secrets that are deployed outside of the Nix store. - [Dataset Backup contract](contracts-datasetbackup.html) to backup ZFS datasets.
One provider is implemented: [Sanoid][]
- [Contract for Secrets](contracts-secret.html) to provide secrets that are deployed outside of the Nix store.
One provider is implemented: [SOPS][]. One provider is implemented: [SOPS][].
- [Dashboard contract](contracts-dashboard.html) to show services in a nice user-facing dashboard.
One provider is implemented: [Homepage][].
[backup contract]: contracts-backup.html [backup contract]: contracts-backup.html
[borgbackup]: blocks-borgbackup.html
[homepage]: services-homepage.html
[restic]: blocks-restic.html [restic]: blocks-restic.html
[sanoid]: blocks-sanoid.html
[sops]: blocks-sops.html [sops]: blocks-sops.html
```{=include=} chapters html:into-file=//contracts-ssl.html ```{=include=} chapters html:into-file=//contracts-ssl.html
@ -59,10 +66,18 @@ modules/contracts/backup/docs/default.md
modules/contracts/databasebackup/docs/default.md modules/contracts/databasebackup/docs/default.md
``` ```
```{=include=} chapters html:into-file=//contracts-datasetbackup.html
modules/contracts/datasetbackup/docs/default.md
```
```{=include=} chapters html:into-file=//contracts-secret.html ```{=include=} chapters html:into-file=//contracts-secret.html
modules/contracts/secret/docs/default.md modules/contracts/secret/docs/default.md
``` ```
```{=include=} chapters html:into-file=//contracts-dashboard.html
modules/contracts/dashboard/docs/default.md
```
## Problem Statement {#contracts-why} ## Problem Statement {#contracts-why}
Currently in nixpkgs, every module accessing a shared resource Currently in nixpkgs, every module accessing a shared resource

View file

@ -61,6 +61,25 @@ If the test includes playwright tests, you can see the playwright trace with:
$ nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip $ nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip
``` ```
If the test fails, you won't have the output directory available.
Instead, run the test with `--keep-failed` and at the end you will see a line saying
```
Keeping build directory '/nix/var/nix/builds/nix-31776-4270051001/build'
```
Copy the path and run:
```bash
sudo cp -r /nix/var/nix/builds/nix-31776-4270051001/build/shared-xchg/trace trace && sudo chown -R $USER: trace
```
Now, open that file with playwright:
```bash
nix run .#playwright -- show-trace trace/0.zip
```
### Debug Tests {#contributing-debug-tests} ### Debug Tests {#contributing-debug-tests}
Run the test in driver interactive mode: Run the test in driver interactive mode:

View file

@ -28,6 +28,10 @@ blocks.md
recipes.md recipes.md
``` ```
```{=include=} chapters html:into-file=//misc.html
misc.md
```
```{=include=} chapters html:into-file=//demos.html ```{=include=} chapters html:into-file=//demos.html
demos.md demos.md
``` ```

39
docs/misc.md Normal file
View file

@ -0,0 +1,39 @@
<!-- Read these docs at https://shb.skarabox.com -->
# Miscellaneous {#misc}
Here goes meta-discussions around the repository and other topics not related directly to the usage of SHB.
## Lock file update {#misc-lock-file-update}
*SHB has an unusual strategy to keep up with its flake inputs. This section explains why.*
SHB only depends on `nixpkgs` as far as flake inputs goes.
There are others but they only matter for tasks around taking care of the repository itself.
So only the `nixpkgs` input is important for downstream consumers of SHB.
To keep up to date with nixpkgs unstable,
initially a job was updating the nixpkgs input by simply updating the lock file and creating a PR.
Now, this job failed most of the time because the tip of unstable often breaks modules and packages, resulting in failing SHB tests.
To fix this, we usually needed to choose a commit around 2 weeks prior to the latest unstable commit.
This usually did not lead to successful tests but the reason was then not
because of failing packages but because nixpkgs modules evolved and SHB needs to adapt to that.
The net effect was SHB was lagging too far behind unstable and updating it was becoming annoying.
The new strategy now is as follows. On every job run:
- If there is no PR, update nixpkgs input to latest unstable commit and create a PR which auto-merges when tests are successful.
- If there is a PR:
- If the checks succeeded, do nothing because the PR will auto-merge.
- If the checks are pending, do nothing yet.
- If the checks failed, bisect and update nixpkgs input to between the commit on main and the commit in the PR.
The effect here is as long as the tests are failing, we'll try a commit further in the past from nixpkgs unstable
and closer to the tip of main branch.
This could be made smarter by distinguishing between types of failures.
We will see if this is needed.
One can also run the bisect manually with `nix run .#update-flake-lock-pr`.
This will create a PR on a different branch than the one used for the automated workflow.
It accepts also one argument `future` which instead of trying a commit in between the tip of SHB main and the PR's failing one,
it tries a commit between the PR's failing one and the nixpkgs unstable latest commit.

View file

@ -221,14 +221,14 @@ shb.nextcloud = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."nextcloud/ldap/admin_password".result; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result;
}; };
apps.sso = { apps.sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."nextcloud/sso/secret".result; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."nextcloud/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
}; };
}; };
``` ```
@ -246,15 +246,15 @@ shb.forgejo = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."nextcloud/ldap/admin_password".result; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result;
}; };
sso = { sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."forgejo/sso/secret".result; secret.result = config.shb.sops.secret."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."forgejo/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result;
}; };
}; };
``` ```

View file

@ -1,5 +1,5 @@
<!-- Read these docs at https://shb.skarabox.com --> <!-- Read these docs at https://shb.skarabox.com -->
# Self-Host a DNS server making {#recipes-dnsServer} # Self-Host a DNS server {#recipes-dnsServer}
This recipe will show how to setup [dnsmasq][] as a local DNS server This recipe will show how to setup [dnsmasq][] as a local DNS server
that forwards all queries to your own domain `example.com` to a local IP - your server running SelfHostBlocks for example. that forwards all queries to your own domain `example.com` to a local IP - your server running SelfHostBlocks for example.

View file

@ -20,6 +20,7 @@ let
fqdn = "${subdomain}.${domain}"; fqdn = "${subdomain}.${domain}";
listenPort = 9000; listenPort = 9000;
dataDir = "/var/lib/awesome"; dataDir = "/var/lib/awesome";
ldapGroup = "awesome_user";
in in
``` ```
@ -75,11 +76,11 @@ shb.nginx.vhosts = [
{ {
inherit subdomain domain; inherit subdomain domain;
ssl = config.shb.certs.certs.letsencrypt.${domain}; ssl = config.shb.certs.certs.letsencrypt.${domain};
upstream = "http://127.0.0.1:${toString config.services.calibre-web.listen.port}"; upstream = "http://127.0.0.1:${toString listenPort}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
autheliaRules = [{ autheliaRules = [{
policy = "one_factor"; policy = "one_factor";
subject = [ "group:${config.shb.lldap.ensureGroups.calibre_user.name}" ]; subject = [ "group:${ldapGroup}" ];
}]; }];
} }
]; ];
@ -120,10 +121,36 @@ shb.restic.instances.awesome = {
request.sourceDirectories = [ dataDir ]; request.sourceDirectories = [ dataDir ];
settings.enable = true; settings.enable = true;
settings.passphrase.result = config.shb.sops.secret.awesome.result; settings.passphrase.result = config.shb.sops.secret.awesome.result;
settings.repository.path = "/srv/backup/awesome"; settings.repository.path = config.services.awesome.dataDir;
}; };
shb.sops.secret."awesome" = { shb.sops.secret."awesome" = {
request = config.shb.restic.instances.awesome.settings.passphrase.request; request = config.shb.restic.instances.awesome.settings.passphrase.request;
}; };
``` ```
## Impermanence {#recipes-exposeService-impermanence}
To save the data folder in an impermanence setup, add:
```nix
{
shb.zfs.datasets."safe/awesome".path = config.services.awesome.dataDir;
}
```
## Application Dashboard {#recipes-exposeService-applicationdashboard}
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.MyServices.services.Awesome = {
sortOrder = 1;
dashboard.request = {
externalUrl = "https://${fqdn}";
internalUrl = "http://127.0.0.1:${toString listenPort}";
};
};
}
```

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,9 @@
<!-- Read these docs at https://shb.skarabox.com --> <!-- Read these docs at https://shb.skarabox.com -->
# Services {#services} # Services {#services}
Services are usually web applications that SHB help you self-host. Services are usually web applications that SHB help you self-host some of your data.
Configuration of those is purposely made more opinionated than the upstream nixpkgs modules Configuration of those is purposely made more opinionated than the upstream nixpkgs modules
in exchange for requiring less options to define. in exchange for an uniformized configuration experience.
That is possible thanks to the extensive use of blocks provided by SHB. That is possible thanks to the extensive use of blocks provided by SHB.
::: {.note} ::: {.note}
@ -19,6 +19,7 @@ information is provided in the respective manual sections.
| [Firefly-iii][] | Y (1) | Y | Y | Y | Y (2) | N | | [Firefly-iii][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Forgejo][] | Y (1) | Y | Y | Y | Y (2) | N | | [Forgejo][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Home-Assistant][] | Y (1) | Y | N | Y | Y (2) | N | | [Home-Assistant][] | Y (1) | Y | N | Y | Y (2) | N |
| [Homepage][] | Y (1) | Y | N | Y | Y (2) | N |
| [Jellyfin][] | Y (1) | Y | Y | Y | Y (2) | N | | [Jellyfin][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Karakeep][] | Y (1) | Y | Y | Y | Y (2) | N | | [Karakeep][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Nextcloud Server][] | Y (1) | Y | Y | Y | Y (2) | P (3) | | [Nextcloud Server][] | Y (1) | Y | Y | Y | Y (2) | P (3) |
@ -40,6 +41,7 @@ Legend: **N**: no but WIP; **P**: partial; **Y**: yes
[Firefly-iii]: services-firefly-iii.html [Firefly-iii]: services-firefly-iii.html
[Forgejo]: services-forgejo.html [Forgejo]: services-forgejo.html
[Home-Assistant]: services-home-assistant.html [Home-Assistant]: services-home-assistant.html
[Homepage]: services-homepage.html
[Jellyfin]: services-jellyfin.html [Jellyfin]: services-jellyfin.html
[Karakeep]: services-karakeep.html [Karakeep]: services-karakeep.html
[Nextcloud Server]: services-nextcloud.html [Nextcloud Server]: services-nextcloud.html
@ -48,6 +50,12 @@ Legend: **N**: no but WIP; **P**: partial; **Y**: yes
[Simple NixOS Mailserver]: services-mailserver.html [Simple NixOS Mailserver]: services-mailserver.html
[Vaultwarden]: services-vaultwarden.html [Vaultwarden]: services-vaultwarden.html
## Dashboard {#services-category-dashboard}
```{=include=} chapters html:into-file=//services-homepage.html
modules/services/homepage/docs/default.md
```
## Documents {#services-category-documents} ## Documents {#services-category-documents}
```{=include=} chapters html:into-file=//services-nextcloud.html ```{=include=} chapters html:into-file=//services-nextcloud.html

View file

@ -634,8 +634,8 @@ One way to setup secrets management using `sops-nix`:
Setting the default this way makes all sops instances use that same file. Setting the default this way makes all sops instances use that same file.
7. Reference the secrets in nix: 7. Reference the secrets in nix:
```nix ```nix
shb.sops.secrets."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request; shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request;
shb.nextcloud.adminPass.result = config.shb.sops.secrets."nextcloud/adminpass".result; shb.nextcloud.adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result;
``` ```
The above snippet uses the [secrets contract](./contracts-secret.html) and The above snippet uses the [secrets contract](./contracts-secret.html) and
[sops block](./blocks-sops.html) to ease the configuration. [sops block](./blocks-sops.html) to ease the configuration.

View file

@ -20,11 +20,11 @@
}, },
"nix-flake-tests": { "nix-flake-tests": {
"locked": { "locked": {
"lastModified": 1677844186, "lastModified": 1775571237,
"narHash": "sha256-ErJZ/Gs1rxh561CJeWP5bohA2IcTq1rDneu1WT6CVII=", "narHash": "sha256-1f5Uvgcy3gx/eyRp4rqfDRBjcZc9uiHoIlfcFIL7GvI=",
"owner": "antifuchs", "owner": "antifuchs",
"repo": "nix-flake-tests", "repo": "nix-flake-tests",
"rev": "bbd9216bd0f6495bb961a8eb8392b7ef55c67afb", "rev": "86b789cff8aecbd7c1bed7cd421dd837c3f62201",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -35,11 +35,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1769461804, "lastModified": 1781074563,
"narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
"type": "github" "type": "github"
}, },
"original": { "original": {

339
flake.nix
View file

@ -20,52 +20,66 @@
nmdsrc, nmdsrc,
... ...
}: }:
flake-utils.lib.eachDefaultSystem ( let
system: shbPatches =
let system:
originPkgs = nixpkgs.legacyPackages.${system}; nixpkgs.legacyPackages.${system}.lib.optionals
shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [ (system == "x86_64-linux" || system == "aarch64-linux")
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged. [
./patches/lldap.patch # Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.
./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch ./patches/lldap.patch
./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch
# Leaving commented out as an example. # Leaving commented out as an example.
# (originPkgs.fetchpatch { # (originPkgs.fetchpatch {
# url = "https://github.com/NixOS/nixpkgs/pull/317107.patch"; # url = "https://github.com/NixOS/nixpkgs/pull/317107.patch";
# hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk="; # hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk=";
# }) # })
]; ];
patchNixpkgs =
{ patchNixpkgs =
nixpkgs, {
patches, nixpkgs,
system, patches,
}: system,
nixpkgs.legacyPackages.${system}.applyPatches { }:
name = "nixpkgs-patched"; nixpkgs.legacyPackages.${system}.applyPatches {
src = nixpkgs; name = "nixpkgs-patched";
inherit patches; src = nixpkgs;
inherit patches;
};
patchedNixpkgs =
system:
let
patched = patchNixpkgs {
nixpkgs = inputs.nixpkgs;
patches = shbPatches system;
inherit system;
}; };
patchedNixpkgs = in
let patched
patched = patchNixpkgs { // {
nixpkgs = inputs.nixpkgs; nixosSystem = args: import "${patched}/nixos/lib/eval-config.nix" args;
patches = shbPatches; };
inherit system; pkgs' =
}; system:
in import (patchedNixpkgs system) {
patched
// {
nixosSystem = args: import "${patched}/nixos/lib/eval-config.nix" args;
};
pkgs = import patchedNixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
}; };
in
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = pkgs' system;
# The contract dummies are used to show options for contracts. # The contract dummies are used to show options for contracts.
contractDummyModules = [ contractDummyModules = [
modules/contracts/backup/dummyModule.nix modules/contracts/backup/dummyModule.nix
modules/contracts/dashboard/dummyModule.nix
modules/contracts/databasebackup/dummyModule.nix
modules/contracts/datasetbackup/dummyModule.nix
modules/contracts/secret/dummyModule.nix
modules/contracts/ssl/dummyModule.nix modules/contracts/ssl/dummyModule.nix
]; ];
in in
@ -90,6 +104,13 @@
"blocks/authelia" = ./modules/blocks/authelia.nix; "blocks/authelia" = ./modules/blocks/authelia.nix;
"blocks/borgbackup" = ./modules/blocks/borgbackup.nix; "blocks/borgbackup" = ./modules/blocks/borgbackup.nix;
"blocks/lldap" = ./modules/blocks/lldap.nix; "blocks/lldap" = ./modules/blocks/lldap.nix;
"blocks/mitmdump" = ./modules/blocks/mitmdump.nix;
"blocks/monitoring" = ./modules/blocks/monitoring.nix;
"blocks/nginx" = ./modules/blocks/nginx.nix;
"blocks/postgresql" = ./modules/blocks/postgresql.nix;
"blocks/restic" = ./modules/blocks/restic.nix;
"blocks/sanoid" = ./modules/blocks/sanoid.nix;
"blocks/sops" = ./modules/blocks/sops.nix;
"blocks/ssl" = { "blocks/ssl" = {
module = ./modules/blocks/ssl.nix; module = ./modules/blocks/ssl.nix;
optionRoot = [ optionRoot = [
@ -97,12 +118,8 @@
"certs" "certs"
]; ];
}; };
"blocks/mitmdump" = ./modules/blocks/mitmdump.nix; "blocks/zfs" = ./modules/blocks/zfs.nix;
"blocks/monitoring" = ./modules/blocks/monitoring.nix;
"blocks/nginx" = ./modules/blocks/nginx.nix;
"blocks/postgresql" = ./modules/blocks/postgresql.nix;
"blocks/restic" = ./modules/blocks/restic.nix;
"blocks/sops" = ./modules/blocks/sops.nix;
"services/arr" = ./modules/services/arr.nix; "services/arr" = ./modules/services/arr.nix;
"services/firefly-iii" = ./modules/services/firefly-iii.nix; "services/firefly-iii" = ./modules/services/firefly-iii.nix;
"services/forgejo" = [ "services/forgejo" = [
@ -110,6 +127,7 @@
(pkgs.path + "/nixos/modules/services/misc/forgejo.nix") (pkgs.path + "/nixos/modules/services/misc/forgejo.nix")
]; ];
"services/home-assistant" = ./modules/services/home-assistant.nix; "services/home-assistant" = ./modules/services/home-assistant.nix;
"services/homepage" = ./modules/services/homepage.nix;
"services/jellyfin" = ./modules/services/jellyfin.nix; "services/jellyfin" = ./modules/services/jellyfin.nix;
"services/karakeep" = ./modules/services/karakeep.nix; "services/karakeep" = ./modules/services/karakeep.nix;
"services/mailserver" = ./modules/services/mailserver.nix; "services/mailserver" = ./modules/services/mailserver.nix;
@ -123,6 +141,7 @@
"services/open-webui" = ./modules/services/open-webui.nix; "services/open-webui" = ./modules/services/open-webui.nix;
"services/pinchflat" = ./modules/services/pinchflat.nix; "services/pinchflat" = ./modules/services/pinchflat.nix;
"services/vaultwarden" = ./modules/services/vaultwarden.nix; "services/vaultwarden" = ./modules/services/vaultwarden.nix;
"contracts/backup" = { "contracts/backup" = {
module = ./modules/contracts/backup/dummyModule.nix; module = ./modules/contracts/backup/dummyModule.nix;
optionRoot = [ optionRoot = [
@ -131,6 +150,14 @@
"backup" "backup"
]; ];
}; };
"contracts/dashboard" = {
module = ./modules/contracts/dashboard/dummyModule.nix;
optionRoot = [
"shb"
"contracts"
"dashboard"
];
};
"contracts/databasebackup" = { "contracts/databasebackup" = {
module = ./modules/contracts/databasebackup/dummyModule.nix; module = ./modules/contracts/databasebackup/dummyModule.nix;
optionRoot = [ optionRoot = [
@ -139,6 +166,14 @@
"databasebackup" "databasebackup"
]; ];
}; };
"contracts/datasetbackup" = {
module = ./modules/contracts/datasetbackup/dummyModule.nix;
optionRoot = [
"shb"
"contracts"
"datasetbackup"
];
};
"contracts/secret" = { "contracts/secret" = {
module = ./modules/contracts/secret/dummyModule.nix; module = ./modules/contracts/secret/dummyModule.nix;
optionRoot = [ optionRoot = [
@ -210,117 +245,30 @@
packages.manualHtml-watch = pkgs.writeShellApplication { packages.manualHtml-watch = pkgs.writeShellApplication {
name = "manualHtml-watch"; name = "manualHtml-watch";
runtimeInputs = [
pkgs.findutils
pkgs.entr
];
text = '' text = ''
while sleep 1; do while sleep 1; do
find . -name "*.nix" -o -name "*.md" \ find . -name "*.nix" -o -name "*.md" \
| entr -d sh -c '(nix run --offline .#update-redirects && nix build --offline .#manualHtml || :)' | entr -d sh -c '(nix run --offline .#update-redirects && nix build --offline .#manualHtml)' || :
done done
''; '';
}; };
packages.update-flake-lock-pr = pkgs.callPackage ./.github/workflows/update-flake-lock-pr.nix { };
lib = (pkgs.callPackage ./lib { }) // { lib = (pkgs.callPackage ./lib { }) // {
test = pkgs.callPackage ./test/common.nix { }; test = pkgs.callPackage ./test/common.nix { };
contracts = pkgs.callPackage ./modules/contracts { contracts = pkgs.callPackage ./modules/contracts {
shb = self.lib.${system}; shb = self.lib.${system};
}; };
patches = shbPatches; patches = shbPatches system;
inherit patchNixpkgs patchedNixpkgs; inherit patchNixpkgs;
patchedNixpkgs = patchedNixpkgs system;
}; };
checks =
let
inherit (pkgs.lib)
foldl
foldlAttrs
mergeAttrs
optionalAttrs
;
importFiles =
files:
map (
m:
pkgs.callPackage m {
shb = self.lib.${system};
}
) files;
mergeTests = foldl mergeAttrs { };
flattenAttrs =
root: attrset:
foldlAttrs (
acc: name: value:
acc
// {
"${root}_${name}" = value;
}
) { } attrset;
vm_test =
name: path:
flattenAttrs "vm_${name}" (
removeAttrs
(pkgs.callPackage path {
shb = self.lib.${system};
})
[
"override"
"overrideDerivation"
]
);
in
(optionalAttrs (system == "x86_64-linux") (
{
modules = self.lib.${system}.check {
inherit pkgs;
tests = mergeTests (importFiles [
./test/modules/davfs.nix
# TODO: Make this not use IFD
./test/modules/lib.nix
]);
};
# TODO: Make this not use IFD
lib = nix-flake-tests.lib.check {
inherit pkgs;
tests = pkgs.callPackage ./test/modules/lib.nix {
shb = self.lib.${system};
};
};
}
// (vm_test "arr" ./test/services/arr.nix)
// (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix)
// (vm_test "deluge" ./test/services/deluge.nix)
// (vm_test "firefly-iii" ./test/services/firefly-iii.nix)
// (vm_test "forgejo" ./test/services/forgejo.nix)
// (vm_test "grocy" ./test/services/grocy.nix)
// (vm_test "hledger" ./test/services/hledger.nix)
// (vm_test "immich" ./test/services/immich.nix)
// (vm_test "homeassistant" ./test/services/home-assistant.nix)
// (vm_test "jellyfin" ./test/services/jellyfin.nix)
// (vm_test "karakeep" ./test/services/karakeep.nix)
// (vm_test "nextcloud" ./test/services/nextcloud.nix)
// (vm_test "open-webui" ./test/services/open-webui.nix)
// (vm_test "paperless" ./test/services/paperless.nix)
// (vm_test "pinchflat" ./test/services/pinchflat.nix)
// (vm_test "vaultwarden" ./test/services/vaultwarden.nix)
// (vm_test "authelia" ./test/blocks/authelia.nix)
// (vm_test "borgbackup" ./test/blocks/borgbackup.nix)
// (vm_test "lldap" ./test/blocks/lldap.nix)
// (vm_test "lib" ./test/blocks/lib.nix)
// (vm_test "mitmdump" ./test/blocks/mitmdump.nix)
// (vm_test "monitoring" ./test/blocks/monitoring.nix)
// (vm_test "postgresql" ./test/blocks/postgresql.nix)
// (vm_test "restic" ./test/blocks/restic.nix)
// (vm_test "ssl" ./test/blocks/ssl.nix)
// (vm_test "contracts-backup" ./test/contracts/backup.nix)
// (vm_test "contracts-databasebackup" ./test/contracts/databasebackup.nix)
// (vm_test "contracts-secret" ./test/contracts/secret.nix)
));
# To see the traces, run: # To see the traces, run:
# nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip # nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip
packages.playwright = pkgs.callPackage ( packages.playwright = pkgs.callPackage (
@ -376,6 +324,111 @@
}; };
} }
) )
// flake-utils.lib.eachSystem [ "x86_64-linux" ] (
system:
let
pkgs = pkgs' system;
in
{
checks =
let
inherit (pkgs.lib)
foldl
foldlAttrs
mergeAttrs
;
importFiles =
files:
map (
m:
pkgs.callPackage m {
shb = self.lib.${system};
}
) files;
mergeTests = foldl mergeAttrs { };
flattenAttrs =
root: attrset:
foldlAttrs (
acc: name: value:
acc
// {
"${root}_${name}" = value;
}
) { } attrset;
vm_test =
name: path:
flattenAttrs "vm_${name}" (
removeAttrs
(pkgs.callPackage path {
shb = self.lib.${system};
})
[
"override"
"overrideDerivation"
]
);
in
(
{
modules = self.lib.${system}.check {
inherit pkgs;
tests = mergeTests (importFiles [
./test/modules/davfs.nix
# TODO: Make this not use IFD
./test/modules/lib.nix
]);
};
# TODO: Make this not use IFD
lib = nix-flake-tests.lib.check {
inherit pkgs;
tests = pkgs.callPackage ./test/modules/lib.nix {
shb = self.lib.${system};
};
};
}
// (vm_test "arr" ./test/services/arr.nix)
// (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix)
// (vm_test "deluge" ./test/services/deluge.nix)
// (vm_test "firefly-iii" ./test/services/firefly-iii.nix)
// (vm_test "forgejo" ./test/services/forgejo.nix)
// (vm_test "grocy" ./test/services/grocy.nix)
// (vm_test "hledger" ./test/services/hledger.nix)
// (vm_test "immich" ./test/services/immich.nix)
// (vm_test "homeassistant" ./test/services/home-assistant.nix)
// (vm_test "homepage" ./test/services/homepage.nix)
// (vm_test "jellyfin" ./test/services/jellyfin.nix)
// (vm_test "karakeep" ./test/services/karakeep.nix)
// (vm_test "mailserver" ./test/services/mailserver.nix)
// (vm_test "nextcloud" ./test/services/nextcloud.nix)
// (vm_test "open-webui" ./test/services/open-webui.nix)
// (vm_test "paperless" ./test/services/paperless.nix)
// (vm_test "pinchflat" ./test/services/pinchflat.nix)
// (vm_test "vaultwarden" ./test/services/vaultwarden.nix)
// (vm_test "authelia" ./test/blocks/authelia.nix)
// (vm_test "borgbackup" ./test/blocks/borgbackup.nix)
// (vm_test "lib" ./test/blocks/lib.nix)
// (vm_test "lldap" ./test/blocks/lldap.nix)
// (vm_test "mitmdump" ./test/blocks/mitmdump.nix)
// (vm_test "monitoring" ./test/blocks/monitoring.nix)
// (vm_test "postgresql" ./test/blocks/postgresql.nix)
// (vm_test "restic" ./test/blocks/restic.nix)
// (vm_test "ssl" ./test/blocks/ssl.nix)
// (vm_test "zfs" ./test/blocks/zfs.nix)
// (vm_test "contracts-backup" ./test/contracts/backup.nix)
// (vm_test "contracts-databasebackup" ./test/contracts/databasebackup.nix)
// (vm_test "contracts-datasetbackup" ./test/contracts/datasetbackup.nix)
// (vm_test "contracts-secret" ./test/contracts/secret.nix)
);
}
)
// { // {
herculesCI.ciSystems = [ "x86_64-linux" ]; herculesCI.ciSystems = [ "x86_64-linux" ];
@ -392,6 +445,7 @@
self.nixosModules.nginx self.nixosModules.nginx
self.nixosModules.postgresql self.nixosModules.postgresql
self.nixosModules.restic self.nixosModules.restic
self.nixosModules.sanoid
self.nixosModules.ssl self.nixosModules.ssl
self.nixosModules.tinyproxy self.nixosModules.tinyproxy
self.nixosModules.vpn self.nixosModules.vpn
@ -407,6 +461,7 @@
self.nixosModules.hledger self.nixosModules.hledger
self.nixosModules.immich self.nixosModules.immich
self.nixosModules.home-assistant self.nixosModules.home-assistant
self.nixosModules.homepage
self.nixosModules.jellyfin self.nixosModules.jellyfin
self.nixosModules.karakeep self.nixosModules.karakeep
self.nixosModules.mailserver self.nixosModules.mailserver
@ -430,8 +485,9 @@
nixosModules.nginx = modules/blocks/nginx.nix; nixosModules.nginx = modules/blocks/nginx.nix;
nixosModules.postgresql = modules/blocks/postgresql.nix; nixosModules.postgresql = modules/blocks/postgresql.nix;
nixosModules.restic = modules/blocks/restic.nix; nixosModules.restic = modules/blocks/restic.nix;
nixosModules.ssl = modules/blocks/ssl.nix; nixosModules.sanoid = modules/blocks/sanoid.nix;
nixosModules.sops = modules/blocks/sops.nix; nixosModules.sops = modules/blocks/sops.nix;
nixosModules.ssl = modules/blocks/ssl.nix;
nixosModules.tinyproxy = modules/blocks/tinyproxy.nix; nixosModules.tinyproxy = modules/blocks/tinyproxy.nix;
nixosModules.vpn = modules/blocks/vpn.nix; nixosModules.vpn = modules/blocks/vpn.nix;
nixosModules.zfs = modules/blocks/zfs.nix; nixosModules.zfs = modules/blocks/zfs.nix;
@ -445,6 +501,7 @@
nixosModules.hledger = modules/services/hledger.nix; nixosModules.hledger = modules/services/hledger.nix;
nixosModules.immich = modules/services/immich.nix; nixosModules.immich = modules/services/immich.nix;
nixosModules.home-assistant = modules/services/home-assistant.nix; nixosModules.home-assistant = modules/services/home-assistant.nix;
nixosModules.homepage = modules/services/homepage.nix;
nixosModules.jellyfin = modules/services/jellyfin.nix; nixosModules.jellyfin = modules/services/jellyfin.nix;
nixosModules.karakeep = modules/services/karakeep.nix; nixosModules.karakeep = modules/services/karakeep.nix;
nixosModules.mailserver = modules/services/mailserver.nix; nixosModules.mailserver = modules/services/mailserver.nix;

View file

@ -1,412 +1,460 @@
{ pkgs, lib }: { pkgs, lib }:
let let
inherit (builtins) isAttrs hasAttr; inherit (builtins) isAttrs hasAttr;
inherit (lib) any concatMapStringsSep concatStringsSep; inherit (lib)
in any
rec { concatMapStringsSep
# Replace secrets in a file. concatStringsSep
# - userConfig is an attrset that will produce a config file. escapeShellArg
# - resultPath is the location the config file should have on the filesystem. ;
# - generator is a function taking two arguments name and value and returning path in the nix shb = rec {
# nix store where the # Replace secrets in a file.
replaceSecrets = # - userConfig is an attrset that will produce a config file.
{ # - resultPath is the location the config file should have on the filesystem.
userConfig, # - generator is a function taking two arguments name and value and returning path in the nix
resultPath, # nix store where the
generator, replaceSecrets =
user ? null, {
permissions ? "u=r,g=r,o=", userConfig,
}: resultPath,
let generator,
configWithTemplates = withReplacements userConfig; user ? null,
permissions ? "u=r,g=r,o=",
}:
let
configWithTemplates = withReplacements userConfig;
nonSecretConfigFile = generator "template" configWithTemplates; nonSecretConfigFile = generator "template" configWithTemplates;
replacements = getReplacements userConfig; replacements = getReplacements userConfig;
in in
replaceSecretsScript { replaceSecretsScript {
file = nonSecretConfigFile; file = nonSecretConfigFile;
inherit resultPath replacements; inherit resultPath replacements;
inherit user permissions; inherit user permissions;
};
replaceSecretsFormatAdapter = format: format.generate;
replaceSecretsGeneratorAdapter =
generator: name: value:
pkgs.writeText "generator " (generator value);
toEnvVar = replaceSecretsGeneratorAdapter (
v: (lib.generators.toINIWithGlobalSection { } { globalSection = v; })
);
template =
file: newPath: replacements:
replaceSecretsScript {
inherit file replacements;
resultPath = newPath;
};
genReplacement =
secret:
let
t =
{
transform ? null,
...
}:
if isNull transform then x: x else transform;
in
lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})");
replaceSecretsScript =
{
file,
resultPath,
replacements,
user ? null,
permissions ? "u=r,g=r,o=",
}:
let
templatePath = resultPath + ".template";
# We check that the files containing the secrets have the
# correct permissions for us to read them in this separate
# step. Otherwise, the $(cat ...) commands inside the sed
# replacements could fail but not fail individually but
# not fail the whole script.
checkPermissions = concatMapStringsSep "\n" (
pattern: "cat ${pattern.source} > /dev/null"
) replacements;
sedPatterns = concatMapStringsSep " " (pattern: "-e \"s|${pattern.name}|${pattern.value}|\"") (
map genReplacement replacements
);
sedCmd = if replacements == [ ] then "cat" else "${pkgs.gnused}/bin/sed ${sedPatterns}";
in
''
set -euo pipefail
${checkPermissions}
mkdir -p $(dirname ${templatePath})
ln -fs ${file} ${templatePath}
rm -f ${resultPath}
touch ${resultPath}
''
+ (lib.optionalString (user != null) ''
chown ${user} ${resultPath}
'')
+ ''
${sedCmd} ${templatePath} > ${resultPath}
chmod ${permissions} ${resultPath}
'';
secretFileType = lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.path;
description = "File containing the value.";
}; };
transform = lib.mkOption { replaceSecretsFormatAdapter = format: format.generate;
type = lib.types.raw; replaceSecretsGeneratorAdapter =
description = "An optional function to transform the secret."; generator: name: value:
default = null; pkgs.writeText "generator " (generator value);
example = lib.literalExpression '' toEnvVar = replaceSecretsGeneratorAdapter (
v: "prefix-$${v}-suffix" v: (lib.generators.toINIWithGlobalSection { } { globalSection = v; })
);
template =
file: newPath: replacements:
replaceSecretsScript {
inherit file replacements;
resultPath = newPath;
};
genReplacement =
secret:
let
t =
{
transform ? null,
...
}:
if isNull transform then x: x else transform;
in
lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})");
replaceSecretsScript =
{
file,
resultPath,
replacements,
user ? null,
permissions ? "u=r,g=r,o=",
}:
let
templatePath = resultPath + ".template";
# We check that the files containing the secrets have the
# correct permissions for us to read them in this separate
# step. Otherwise, the $(cat ...) commands inside the sed
# replacements could fail but not fail individually but
# not fail the whole script.
checkPermissions = concatMapStringsSep "\n" (
pattern: "cat ${pattern.source} > /dev/null"
) replacements;
generatedReplacements = map genReplacement replacements;
replaceScript = pkgs.writers.writePython3 "replace-secret" { } ''
import argparse
import pathlib
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--file", required=True, type=pathlib.Path)
parser.add_argument("--marker", required=True)
parser.add_argument("--replacement", required=True)
return parser.parse_args()
args = parse_args()
content = args.file.read_text()
args.file.write_text(content.replace(args.marker, args.replacement))
''; '';
replaceCommands = concatMapStringsSep "\n" (pattern: ''
${replaceScript} \
--file ${escapeShellArg resultPath} \
--marker ${escapeShellArg pattern.name} \
--replacement "${pattern.value}"
'') generatedReplacements;
replaceCmd =
if replacements == [ ] then
"cat ${escapeShellArg templatePath} > ${escapeShellArg resultPath}"
else
''
cat ${escapeShellArg templatePath} > ${escapeShellArg resultPath}
${replaceCommands}
'';
in
''
set -euo pipefail
${checkPermissions}
mkdir -p $(dirname ${templatePath})
ln -fs ${file} ${templatePath}
rm -f ${resultPath}
touch ${resultPath}
''
+ (lib.optionalString (user != null) ''
chown ${user} ${resultPath}
'')
+ ''
${replaceCmd}
chmod ${permissions} ${resultPath}
'';
secretFileType = lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.path;
description = "File containing the value.";
};
transform = lib.mkOption {
type = lib.types.raw;
description = "An optional function to transform the secret.";
default = null;
example = lib.literalExpression ''
v: "prefix-$${v}-suffix"
'';
};
}; };
}; };
};
secretName = secretName =
names: "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%"; names: "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%";
withReplacements = withReplacements =
attrs: attrs:
let let
valueOrReplacement = valueOrReplacement =
name: value: if !(builtins.isAttrs value && value ? "source") then value else secretName name; name: value: if !(builtins.isAttrs value && value ? "source") then value else secretName name;
in in
mapAttrsRecursiveCond (v: !v ? "source") valueOrReplacement attrs; mapAttrsRecursiveCond (v: !v ? "source") valueOrReplacement attrs;
getReplacements = getReplacements =
attrs: attrs:
let let
addNameField = addNameField =
name: value: name: value:
if !(builtins.isAttrs value && value ? "source") then value else value // { name = name; }; if !(builtins.isAttrs value && value ? "source") then value else value // { name = name; };
secretsWithName = mapAttrsRecursiveCond (v: !v ? "source") addNameField attrs; secretsWithName = mapAttrsRecursiveCond (v: !v ? "source") addNameField attrs;
in in
collect (v: builtins.isAttrs v && v ? "source") secretsWithName; collect (v: builtins.isAttrs v && v ? "source") secretsWithName;
# Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists. # Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists.
mapAttrsRecursiveCond = mapAttrsRecursiveCond =
# A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set. # A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set.
cond: cond:
# A function, given a list of attribute names and a value, returns a new value. # A function, given a list of attribute names and a value, returns a new value.
f: f:
# Attribute set or list to recursively map over. # Attribute set or list to recursively map over.
set: set:
let let
recurse = recurse =
path: val: path: val:
if builtins.isAttrs val && cond val then if builtins.isAttrs val && cond val then
lib.attrsets.mapAttrs (n: v: recurse (path ++ [ n ]) v) val lib.attrsets.mapAttrs (n: v: recurse (path ++ [ n ]) v) val
else if builtins.isList val && cond val then else if builtins.isList val && cond val then
lib.lists.imap0 (i: v: recurse (path ++ [ (builtins.toString i) ]) v) val lib.lists.imap0 (i: v: recurse (path ++ [ (builtins.toString i) ]) v) val
else else
f path val; f path val;
in in
recurse [ ] set; recurse [ ] set;
# Like lib.attrsets.collect but also recurses on lists. # Like lib.attrsets.collect but also recurses on lists.
collect = collect =
# Given an attribute's value, determine if recursion should stop. # Given an attribute's value, determine if recursion should stop.
pred: pred:
# The attribute set to recursively collect. # The attribute set to recursively collect.
attrs: attrs:
if pred attrs then if pred attrs then
[ attrs ] [ attrs ]
else if builtins.isAttrs attrs then else if builtins.isAttrs attrs then
lib.lists.concatMap (collect pred) (lib.attrsets.attrValues attrs) lib.lists.concatMap (collect pred) (lib.attrsets.attrValues attrs)
else if builtins.isList attrs then else if builtins.isList attrs then
lib.lists.concatMap (collect pred) attrs lib.lists.concatMap (collect pred) attrs
else else
[ ]; [ ];
indent = indent =
i: str: i: str:
lib.concatMapStringsSep "\n" (x: (lib.strings.replicate i " ") + x) (lib.splitString "\n" str); lib.concatMapStringsSep "\n" (x: (lib.strings.replicate i " ") + x) (lib.splitString "\n" str);
# Generator for XML # Generator for XML
formatXML = formatXML =
{ {
enclosingRoot ? null, enclosingRoot ? null,
}: }:
{ {
type = type =
with lib.types; with lib.types;
let let
valueType = valueType =
nullOr (oneOf [ nullOr (oneOf [
bool bool
int int
float float
str str
path path
(attrsOf valueType) (attrsOf valueType)
(listOf valueType) (listOf valueType)
]) ])
// { // {
description = "XML value"; description = "XML value";
}; };
in in
valueType; valueType;
generate = generate =
name: value: name: value:
pkgs.callPackage ( pkgs.callPackage (
{ runCommand, python3 }:
runCommand "config"
{
value = builtins.toJSON (if enclosingRoot == null then value else { ${enclosingRoot} = value; });
passAsFile = [ "value" ];
}
(
pkgs.writers.writePython3 "dict2xml"
{
libraries = with python3.pkgs; [
python
dict2xml
];
}
''
import os
import json
from dict2xml import dict2xml
with open(os.environ["valuePath"]) as f:
content = json.loads(f.read())
if content is None:
print("Could not parse env var valuePath as json")
os.exit(2)
with open(os.environ["out"], "w") as out:
out.write(dict2xml(content))
''
)
) { };
};
parseXML =
xml:
let
xmlToJsonFile = pkgs.callPackage (
{ runCommand, python3 }: { runCommand, python3 }:
runCommand "config" runCommand "config"
{ {
value = builtins.toJSON (if enclosingRoot == null then value else { ${enclosingRoot} = value; }); inherit xml;
passAsFile = [ "value" ]; passAsFile = [ "xml" ];
} }
( (
pkgs.writers.writePython3 "dict2xml" pkgs.writers.writePython3 "xml2json"
{ {
libraries = with python3.pkgs; [ libraries = with python3.pkgs; [ python ];
python
dict2xml
];
} }
'' ''
import os import os
import json import json
from dict2xml import dict2xml from collections import ChainMap
from xml.etree import ElementTree
def xml_to_dict_recursive(root):
all_descendants = list(root)
if len(all_descendants) == 0:
return {root.tag: root.text}
else:
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)}
with open(os.environ["xmlPath"]) as f:
root = ElementTree.XML(f.read())
xml = xml_to_dict_recursive(root)
j = json.dumps(xml)
with open(os.environ["valuePath"]) as f:
content = json.loads(f.read())
if content is None:
print("Could not parse env var valuePath as json")
os.exit(2)
with open(os.environ["out"], "w") as out: with open(os.environ["out"], "w") as out:
out.write(dict2xml(content)) out.write(j)
'' ''
) )
) { }; ) { };
in
builtins.fromJSON (builtins.readFile xmlToJsonFile);
}; renameAttrName =
attrset: from: to:
(lib.attrsets.filterAttrs (name: v: name == from) attrset)
// {
${to} = attrset.${from};
};
parseXML = # Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix
xml: # with a nicer diff display function.
let check =
xmlToJsonFile = pkgs.callPackage ( { pkgs, tests }:
{ runCommand, python3 }: let
runCommand "config" formatValue =
val:
if (builtins.isList val || builtins.isAttrs val) then
builtins.toJSON val
else
builtins.toString val;
resultToString =
{ {
inherit xml; name,
passAsFile = [ "xml" ]; expected,
} result,
( }:
pkgs.writers.writePython3 "xml2json" builtins.readFile (
pkgs.runCommand "nix-flake-tests-error"
{ {
libraries = with python3.pkgs; [ python ]; expected = formatValue expected;
result = formatValue result;
passAsFile = [
"expected"
"result"
];
nativeBuildInputs = [
(pkgs.python3.withPackages (ps: [ ps.deepdiff ] ++ ps.deepdiff.optional-dependencies.cli))
];
} }
'' ''
import os echo "${name} failed (- expected, + result)" > $out
import json cp ''${expectedPath} ''${expectedPath}.json
from collections import ChainMap cp ''${resultPath} ''${resultPath}.json
from xml.etree import ElementTree deep diff ''${expectedPath}.json ''${resultPath}.json >> $out
def xml_to_dict_recursive(root):
all_descendants = list(root)
if len(all_descendants) == 0:
return {root.tag: root.text}
else:
merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants))
return {root.tag: dict(merged_dict)}
with open(os.environ["xmlPath"]) as f:
root = ElementTree.XML(f.read())
xml = xml_to_dict_recursive(root)
j = json.dumps(xml)
with open(os.environ["out"], "w") as out:
out.write(j)
'' ''
) );
results = pkgs.lib.runTests tests;
in
if results != [ ] then
builtins.throw (concatStringsSep "\n" (map resultToString (lib.traceValSeq results)))
else
pkgs.runCommand "nix-flake-tests-success" { } "echo > $out";
genConfigOutOfBandSystemd =
{
config,
configLocation,
generator,
user ? null,
permissions ? "u=r,g=r,o=",
}:
{
loadCredentials = getLoadCredentials "source" config;
preStart = lib.mkBefore (replaceSecrets {
userConfig = updateToLoadCredentials "source" "$CREDENTIALS_DIRECTORY" config;
resultPath = configLocation;
inherit generator;
inherit user permissions;
});
};
updateToLoadCredentials =
sourceField: rootDir: attrs:
let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
valueOrLoadCredential =
path: value:
if !(hasPlaceholderField value) then
value
else
value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; };
in
mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) valueOrLoadCredential attrs;
getLoadCredentials =
sourceField: attrs:
let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
addPathField =
path: value: if !(hasPlaceholderField value) then value else value // { inherit path; };
secretsWithPath = mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) addPathField attrs;
allSecrets = collect (v: hasPlaceholderField v) secretsWithPath;
genLoadCredentials = secret: "${concatStringsSep "_" secret.path}:${secret.${sourceField}}";
in
map genLoadCredentials allSecrets;
anyNotNull = any (x: x != null);
mkJellyfinPlugin =
{
pname,
version,
hash,
url,
}:
pkgs.callPackage (
{ stdenv, fetchzip }:
stdenv.mkDerivation (finalAttrs: {
inherit pname version;
src = fetchzip {
inherit url hash;
stripRoot = false;
};
dontBuild = true;
installPhase = ''
mkdir $out
cp -r . $out
'';
})
) { }; ) { };
in
builtins.fromJSON (builtins.readFile xmlToJsonFile);
renameAttrName = update =
attrset: from: to: attr: fn: attrset:
(lib.attrsets.filterAttrs (name: v: name == from) attrset) attrset // { ${attr} = fn attrset.${attr}; };
// {
${to} = attrset.${from};
};
# Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix };
# with a nicer diff display function. in
check = shb
{ pkgs, tests }: // {
let homepage = pkgs.callPackage ./homepage.nix { inherit shb; };
formatValue =
val:
if (builtins.isList val || builtins.isAttrs val) then
builtins.toJSON val
else
builtins.toString val;
resultToString =
{
name,
expected,
result,
}:
builtins.readFile (
pkgs.runCommand "nix-flake-tests-error"
{
expected = formatValue expected;
result = formatValue result;
passAsFile = [
"expected"
"result"
];
}
''
echo "${name} failed (- expected, + result)" > $out
cp ''${expectedPath} ''${expectedPath}.json
cp ''${resultPath} ''${resultPath}.json
${pkgs.deepdiff}/bin/deep diff ''${expectedPath}.json ''${resultPath}.json >> $out
''
);
results = pkgs.lib.runTests tests;
in
if results != [ ] then
builtins.throw (concatStringsSep "\n" (map resultToString (lib.traceValSeq results)))
else
pkgs.runCommand "nix-flake-tests-success" { } "echo > $out";
genConfigOutOfBandSystemd =
{
config,
configLocation,
generator,
user ? null,
permissions ? "u=r,g=r,o=",
}:
{
loadCredentials = getLoadCredentials "source" config;
preStart = lib.mkBefore (replaceSecrets {
userConfig = updateToLoadCredentials "source" "$CREDENTIALS_DIRECTORY" config;
resultPath = configLocation;
inherit generator;
inherit user permissions;
});
};
updateToLoadCredentials =
sourceField: rootDir: attrs:
let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
valueOrLoadCredential =
path: value:
if !(hasPlaceholderField value) then
value
else
value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; };
in
mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) valueOrLoadCredential attrs;
getLoadCredentials =
sourceField: attrs:
let
hasPlaceholderField = v: isAttrs v && hasAttr sourceField v;
addPathField =
path: value: if !(hasPlaceholderField value) then value else value // { inherit path; };
secretsWithPath = mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) addPathField attrs;
allSecrets = collect (v: hasPlaceholderField v) secretsWithPath;
genLoadCredentials = secret: "${concatStringsSep "_" secret.path}:${secret.${sourceField}}";
in
map genLoadCredentials allSecrets;
anyNotNull = any (x: x != null);
mkJellyfinPlugin =
{
pname,
version,
hash,
url,
}:
pkgs.callPackage (
{ stdenv, fetchzip }:
stdenv.mkDerivation (finalAttrs: {
inherit pname version;
src = fetchzip {
inherit url hash;
stripRoot = false;
};
dontBuild = true;
installPhase = ''
mkdir $out
cp -r . $out
'';
})
) { };
} }

92
lib/homepage.nix Normal file
View file

@ -0,0 +1,92 @@
{ lib, shb }:
let
sort =
attr: vs:
map (v: { ${v.name} = v.${attr}; }) (
lib.sortOn (v: v.sortOrder) (lib.mapAttrsToList (n: v: v // { name = n; }) vs)
);
slufigy = builtins.replaceStrings [ "-" ] [ "_" ];
mkService =
groupName: serviceName:
{
request,
...
}:
apiKey: settings:
lib.recursiveUpdate (
{
href = request.externalUrl;
siteMonitor = if (request.internalUrl == null) then null else request.internalUrl;
icon = "sh-${lib.toLower serviceName}";
}
// lib.optionalAttrs (apiKey != null) {
widget = {
# Duplicating because widgets call the api key various names
# and duplicating is a hacky but easy solution.
key = "{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}";
password = "{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}";
type = lib.toLower serviceName;
url = if (request.internalUrl != null) then request.internalUrl else request.externalUrl;
};
}
) settings;
asServiceGroup =
cfg:
sort "services" (
lib.mapAttrs (
groupName: groupCfg:
shb.update "services" (
services:
sort "dashboard" (
lib.mapAttrs (
serviceName: serviceCfg:
shb.update "dashboard" (
dashboard:
(mkService groupName serviceName) dashboard serviceCfg.apiKey (serviceCfg.settings or { })
) serviceCfg
) services
)
) groupCfg
) cfg
);
allKeys =
cfg:
let
flat = lib.flatten (
lib.mapAttrsToList (
groupName: groupCfg:
lib.mapAttrsToList (
serviceName: serviceCfg:
lib.optionalAttrs (serviceCfg.apiKey != null) {
inherit serviceName groupName;
inherit (serviceCfg.apiKey.result) path;
}
) groupCfg.services
) cfg
);
flatWithApiKey = builtins.filter (v: v != { }) flat;
in
builtins.listToAttrs (
map (
{
groupName,
serviceName,
path,
}:
lib.nameValuePair "${slufigy groupName}_${slufigy serviceName}" path
) flatWithApiKey
);
in
{
inherit
allKeys
asServiceGroup
mkService
sort
;
}

View file

@ -114,7 +114,7 @@ in
}; };
}; };
storageEncryptionKey = lib.mkOption { storageEncryptionKey = lib.mkOption {
description = "Storage encryption key."; description = "Storage encryption key. Must be >= 20 characters.";
type = lib.types.submodule { type = lib.types.submodule {
options = shb.contracts.secret.mkRequester { options = shb.contracts.secret.mkRequester {
mode = "0400"; mode = "0400";
@ -124,7 +124,7 @@ in
}; };
}; };
identityProvidersOIDCHMACSecret = lib.mkOption { identityProvidersOIDCHMACSecret = lib.mkOption {
description = "Identity provider OIDC HMAC secret."; description = "Identity provider OIDC HMAC secret. Must be >= 40 characters.";
type = lib.types.submodule { type = lib.types.submodule {
options = shb.contracts.secret.mkRequester { options = shb.contracts.secret.mkRequester {
mode = "0400"; mode = "0400";
@ -390,6 +390,20 @@ in
to see exactly what Authelia receives and sends back. to see exactly what Authelia receives and sends back.
''; '';
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.authelia.subdomain}.\${config.shb.authelia.domain}";
internalUrl = "http://127.0.0.1:${toString listenPort}";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@ -631,6 +645,7 @@ in
shb.mitmdump.instances."authelia-${fqdn}" = lib.mkIf cfg.debug { shb.mitmdump.instances."authelia-${fqdn}" = lib.mkIf cfg.debug {
listenPort = 9091; listenPort = 9091;
upstreamPort = 9090; upstreamPort = 9090;
timeout = 30;
after = [ "authelia-${fqdn}.service" ]; after = [ "authelia-${fqdn}.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ]; enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [ extraArgs = [

View file

@ -12,7 +12,13 @@ as well as some extensive [troubleshooting](#blocks-authelia-troubleshooting) fe
Note that forward authentication is configured with the [nginx block](blocks-nginx.html#blocks-nginx-usage-shbforwardauth). Note that forward authentication is configured with the [nginx block](blocks-nginx.html#blocks-nginx-usage-shbforwardauth).
## Global Setup {#blocks-authelia-global-setup} ## Features {#services-authelia-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-authelia-usage-applicationdashboard)
## Usage {#services-authelia-usage}
### Initial Configuration {#blocks-authelia-usage-configuration}
Authelia cannot work without SSL and LDAP. Authelia cannot work without SSL and LDAP.
So setting up the Authelia block requires to setup the [SSL block][] first So setting up the Authelia block requires to setup the [SSL block][] first
@ -44,31 +50,31 @@ shb.authelia = {
port = 587; port = 587;
username = "postmaster@mg.example.com"; username = "postmaster@mg.example.com";
from_address = "authelia@example.com"; from_address = "authelia@example.com";
password.result = config.shb.sops.secrets."authelia/smtp_password".result; password.result = config.shb.sops.secret."authelia/smtp_password".result;
}; };
secrets = { secrets = {
jwtSecret.result = config.shb.sops.secrets."authelia/jwt_secret".result; jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result;
ldapAdminPassword.result = config.shb.sops.secrets."authelia/ldap_admin_password".result; ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result;
sessionSecret.result = config.shb.sops.secrets."authelia/session_secret".result; sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result;
storageEncryptionKey.result = config.shb.sops.secrets."authelia/storage_encryption_key".result; storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result;
identityProvidersOIDCHMACSecret.result = config.shb.sops.secrets."authelia/hmac_secret".result; identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secrets."authelia/private_key".result; identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result;
}; };
}; };
shb.certs.certs.letsencrypt."example.com".extraDomains = [ "auth.example.com" ]; shb.certs.certs.letsencrypt."example.com".extraDomains = [ "auth.example.com" ];
shb.sops.secrets."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request; shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request;
shb.sops.secrets."authelia/ldap_admin_password" = { shb.sops.secret."authelia/ldap_admin_password" = {
request = config.shb.authelia.secrets.ldapAdminPassword.request; request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.key = "lldap/user_password"; settings.key = "lldap/user_password";
}; };
shb.sops.secrets."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request; shb.sops.secret."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request;
shb.sops.secrets."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request; shb.sops.secret."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request;
shb.sops.secrets."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request; shb.sops.secret."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
shb.sops.secrets."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request; shb.sops.secret."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
shb.sops.secrets."authelia/smtp_password".request = config.shb.authelia.smtp.password.request; shb.sops.secret."authelia/smtp_password".request = config.shb.authelia.smtp.password.request;
``` ```
This assumes secrets are setup with SOPS This assumes secrets are setup with SOPS
@ -80,6 +86,22 @@ Crucially, the `shb.authelia.secrets.ldapAdminPasswordFile` must be the same
as the `shb.lldap.ldapUserPassword` defined for the [LLDAP block][]. as the `shb.lldap.ldapUserPassword` defined for the [LLDAP block][].
This is done using Sops' `key` option. This is done using Sops' `key` option.
### Application Dashboard {#services-authelia-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-authelia-options-shb.authelia.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.Authelia = {
sortOrder = 2;
dashboard.request = config.shb.authelia.dashboard.request;
};
}
```
## SHB OIDC integration {#blocks-authelia-shb-oidc} ## SHB OIDC integration {#blocks-authelia-shb-oidc}
For services [provided by SelfHostBlocks][services] that handle [OIDC integration][OIDC], For services [provided by SelfHostBlocks][services] that handle [OIDC integration][OIDC],
@ -95,8 +117,8 @@ shb.<service>.sso = {
enable = true; enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."<service>/sso/secret".result; secret.result = config.shb.sops.secret."<service>/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."<service>/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."<service>/sso/secretForAuthelia".result;
}; };
shb.sops.secret."<service>/sso/secret".request = config.shb.<service>.sso.secret.request; shb.sops.secret."<service>/sso/secret".request = config.shb.<service>.sso.secret.request;
@ -122,7 +144,7 @@ the necessary configuration is:
shb.authelia.oidcClients = [ shb.authelia.oidcClients = [
{ {
client_id = "<service>"; client_id = "<service>";
client_secret.source = shb.sops.secret."<service>/sso/secretForAuthelia".response.path; client_secret.source = config.shb.sops.secret."<service>/sso/secretForAuthelia".response.path;
scopes = [ "openid" "email" "profile" ]; scopes = [ "openid" "email" "profile" ];
redirect_uris = [ redirect_uris = [
"<provided by service documentation>" "<provided by service documentation>"
@ -167,7 +189,7 @@ services.open-webui.environment = {
shb.authelia.oidcClients = [ shb.authelia.oidcClients = [
{ {
client_id = "open-webui"; client_id = "open-webui";
client_secret.source = shb.sops.secret."open-webui/sso/secretForAuthelia".response.path; client_secret.source = config.shb.sops.secret."open-webui/sso/secretForAuthelia".response.path;
scopes = [ "openid" "email" "profile" ]; scopes = [ "openid" "email" "profile" ];
redirect_uris = [ redirect_uris = [
"<provided by service documentation>" "<provided by service documentation>"

View file

@ -386,8 +386,13 @@ in
${serviceName} = mkMerge [ ${serviceName} = mkMerge [
{ {
serviceConfig = { serviceConfig = {
# Makes the systemd service wait for the backup to be done before changing state to inactive. # Purposely not a oneshot systemd service otherwise
Type = "oneshot"; # the service waits on the completion the backup before deactivating.
# This seems like a nice property at first but there is one annoying
# edge case when deploying. If a backup job somehow is started when
# the deploy happens, the deploy will wait on the service to finish
# before considering the deploy done. And worse, it will consider the
# deploy as failed if the backup fails, which is not what you want.
Nice = lib.mkForce cfg.performance.niceness; Nice = lib.mkForce cfg.performance.niceness;
IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass; IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass;
IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority; IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority;
@ -414,6 +419,7 @@ in
in in
{ {
script = script.preStart; script = script.preStart;
# Makes the systemd service wait for the backup to be done before changing state to inactive.
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
serviceConfig.LoadCredential = script.loadCredentials; serviceConfig.LoadCredential = script.loadCredentials;
} }
@ -451,39 +457,16 @@ in
let let
mkBorgBackupBinary = mkBorgBackupBinary =
name: instance: name: instance:
pkgs.writeShellApplication { shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository; name = fullName name instance.settings.repository;
text = '' user = instance.request.user;
usage() { sudoPreserveEnvs = [
echo "$0 restore latest" "BORG_REPO"
} "BORG_PASSCOMMAND"
];
if ! [ "$1" = "restore" ]; then secretsFile = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
usage restoreCmd = ''(cd / && ${pkgs.borgbackup}/bin/borg extract \"$BORG_REPO::$snapshot\")'';
exit 1 listCmd = ''if [ -e \"$BORG_REPO/data\" ]; then borg list --short \"$BORG_REPO\"; fi'';
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
(cd / && sudocmd ${pkgs.borgbackup}/bin/borg extract "$BORG_REPO"::"$archive")
'';
}; };
in in
flatten (mapAttrsToList mkBorgBackupBinary cfg.instances); flatten (mapAttrsToList mkBorgBackupBinary cfg.instances);
@ -493,39 +476,16 @@ in
let let
mkBorgBackupBinary = mkBorgBackupBinary =
name: instance: name: instance:
pkgs.writeShellApplication { shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository; name = fullName name instance.settings.repository;
text = '' user = instance.request.user;
usage() { sudoPreserveEnvs = [
echo "$0 restore latest" "BORG_REPO"
} "BORG_PASSCOMMAND"
];
if ! [ "$1" = "restore" ]; then secretsFile = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
usage restoreCmd = ''${pkgs.borgbackup}/bin/borg extract \"$BORG_REPO::$snapshot\" --stdout | ${instance.request.restoreCmd}'';
exit 1 listCmd = ''if [ -e \"$BORG_REPO/data\" ]; then borg list --short \"$BORG_REPO\"; fi'';
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
sudocmd sh -c "${pkgs.borgbackup}/bin/borg extract $BORG_REPO::$archive --stdout | ${instance.request.restoreCmd}"
'';
}; };
in in
flatten (mapAttrsToList mkBorgBackupBinary cfg.databases); flatten (mapAttrsToList mkBorgBackupBinary cfg.databases);

View file

@ -3,6 +3,7 @@
Defined in [`/modules/blocks/borgbackup.nix`](@REPO@/modules/blocks/borgbackup.nix). Defined in [`/modules/blocks/borgbackup.nix`](@REPO@/modules/blocks/borgbackup.nix).
This block sets up a backup job using [BorgBackup][]. This block sets up a backup job using [BorgBackup][].
It is heavily based on the nixpkgs BorgBackup module.
[borgbackup]: https://www.borgbackup.org/ [borgbackup]: https://www.borgbackup.org/
@ -50,7 +51,7 @@ shb.borgbackup.instances."myservice" = {
settings = { settings = {
enable = true; enable = true;
passphrase.result = shb.sops.secret."passphrase".result; passphrase.result = config.shb.sops.secret."passphrase".result;
repository = { repository = {
path = "/srv/backups/myservice"; path = "/srv/backups/myservice";
@ -71,7 +72,7 @@ shb.borgbackup.instances."myservice" = {
}; };
shb.sops.secret."passphrase".request = shb.sops.secret."passphrase".request =
shb.borgbackup.instances."myservice".settings.passphrase.request; config.shb.borgbackup.instances."myservice".settings.passphrase.request;
``` ```
### One folder backed up with contract {#blocks-borgbackup-usage-provider-contract} ### One folder backed up with contract {#blocks-borgbackup-usage-provider-contract}
@ -87,7 +88,7 @@ shb.borgbackup.instances."myservice" = {
settings = { settings = {
enable = true; enable = true;
passphrase.result = shb.sops.secret."passphrase".result; passphrase.result = config.shb.sops.secret."passphrase".result;
repository = { repository = {
path = "/srv/backups/myservice"; path = "/srv/backups/myservice";
@ -108,7 +109,7 @@ shb.borgbackup.instances."myservice" = {
}; };
shb.sops.secret."passphrase".request = shb.sops.secret."passphrase".request =
shb.borgbackup.instances."myservice".settings.passphrase.request; config.shb.borgbackup.instances."myservice".settings.passphrase.request;
``` ```
### One folder backed up to S3 {#blocks-borgbackup-usage-provider-remote} ### One folder backed up to S3 {#blocks-borgbackup-usage-provider-remote}
@ -229,6 +230,36 @@ See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backu
## Maintenance {#blocks-borgbackup-maintenance} ## Maintenance {#blocks-borgbackup-maintenance}
### Manual Backup {#blocks-borgbackup-maintenance-manuql}
To launch a backup manually, just run:
```bash
systemctl start <systemd-service-name>
```
You can easily discover the systemd service name you need by either listing the units:
```bash
systemctl list-units 'borgbackup*'
```
Or by autocompleting the unit name with `<TAB>`:
```bash
systemctl start borgbackup<TAB><TAB>
```
Note that the systemd services are of `Type=simple` which means the systemd service
will not wait for the backup completion to terminate.
If you want instead to wait for the backup to complete, use the `--wait` flag:
```bash
systemctl start --wait <systemd-service-name>
```
### Restore {#blocks-borgbackup-maintenance-restore}
One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets. One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets.
The restore script has all the secrets needed to access the repo, The restore script has all the secrets needed to access the repo,

View file

@ -75,6 +75,30 @@ in
config = { config = {
services.davfs2.enable = builtins.length cfg.mounts > 0; services.davfs2.enable = builtins.length cfg.mounts > 0;
systemd.services = lib.optionalAttrs (builtins.length cfg.mounts > 0) {
davfs2-password-generation = {
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = ''
mkdir -p /etc/davfs2
[ -f /etc/davfs2/secrets ] && mv /etc/davfs2/secrets /etc/davfs2/secrets.prev
touch /etc/davfs2/secrets
chown root: /etc/davfs2
chown root: /etc/davfs2/secrets
chmod 700 /etc/davfs2
chmod 600 /etc/davfs2/secrets
''
+ (
let
mkPasswordCmd = cfg': ''
printf "%s %s %s\n" "${cfg'.mountPoint}" "${cfg'.username}" "$(cat ${cfg'.passwordFile})" >> /etc/davfs2/secrets
'';
in
lib.concatStringsSep "\n" (map mkPasswordCmd cfg.mounts)
);
};
};
systemd.mounts = systemd.mounts =
let let
mkMountCfg = c: { mkMountCfg = c: {
@ -82,6 +106,7 @@ in
description = "Webdav mount point"; description = "Webdav mount point";
after = [ "network-online.target" ]; after = [ "network-online.target" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
what = c.remoteUrl; what = c.remoteUrl;
where = c.mountPoint; where = c.mountPoint;

View file

@ -99,7 +99,7 @@ in
}; };
ldapUserPassword = lib.mkOption { ldapUserPassword = lib.mkOption {
description = "LDAP admin user secret."; description = "LDAP admin user secret. Must be >= 8 characters.";
type = lib.types.submodule { type = lib.types.submodule {
options = shb.contracts.secret.mkRequester { options = shb.contracts.secret.mkRequester {
mode = "0440"; mode = "0440";
@ -331,7 +331,21 @@ in
enforceGroups = mkOption { enforceGroups = mkOption {
description = "Remove groups not set declaratively."; description = "Remove groups not set declaratively.";
type = types.bool; type = types.bool;
default = true; default = false;
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.lldap.subdomain}.\${config.shb.lldap.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.webUIListenPort}";
};
};
}; };
}; };
@ -404,6 +418,41 @@ in
) cfg.ensureUsers; ) cfg.ensureUsers;
}; };
# Harden lldap following https://github.com/NixOS/nixpkgs/pull/487933
systemd.services.lldap.serviceConfig = {
RemoveIPC = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
LockPersonality = true;
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
ProtectProc = "invisible";
ProcSubset = "pid";
MemoryDenyWriteExecute = true;
};
shb.mitmdump.instances."lldap-web" = lib.mkIf cfg.debug { shb.mitmdump.instances."lldap-web" = lib.mkIf cfg.debug {
listenPort = config.shb.lldap.webUIListenPort; listenPort = config.shb.lldap.webUIListenPort;
upstreamPort = config.shb.lldap.webUIListenPort + 1; upstreamPort = config.shb.lldap.webUIListenPort + 1;

View file

@ -7,7 +7,13 @@ across services.
[LLDAP]: https://github.com/lldap/lldap [LLDAP]: https://github.com/lldap/lldap
## Global Setup {#blocks-lldap-global-setup} ## Features {#blocks-lldap-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-lldap-usage-applicationdashboard)
## Usage {#blocks-lldap-usage}
### Initial Configuration {#blocks-lldap-usage-configuration}
```nix ```nix
shb.lldap = { shb.lldap = {
@ -29,7 +35,7 @@ shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPasswor
This assumes secrets are setup with SOPS This assumes secrets are setup with SOPS
as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.
## SSL {#blocks-lldap-ssl} ### SSL {#blocks-lldap-usage-ssl}
Using SSL is an important security practice, like always. Using SSL is an important security practice, like always.
Using the [SSL block][], the configuration to add to the one above is: Using the [SSL block][], the configuration to add to the one above is:
@ -44,7 +50,7 @@ shb.certs.certs.letsencrypt.${domain}.extraDomains = [
shb.lldap.ssl = config.shb.certs.certs.letsencrypt.${config.shb.lldap.domain}; shb.lldap.ssl = config.shb.certs.certs.letsencrypt.${config.shb.lldap.domain};
``` ```
## Restrict Access By IP {#blocks-lldap-restrict-access-by-ip} ### Restrict Access By IP {#blocks-lldap-usage-restrict-access-by-ip}
For added security, you can restrict access to the LLDAP UI For added security, you can restrict access to the LLDAP UI
by adding the following line: by adding the following line:
@ -53,6 +59,22 @@ by adding the following line:
shb.lldap.restrictAccessIPRange = "192.168.50.0/24"; shb.lldap.restrictAccessIPRange = "192.168.50.0/24";
``` ```
### Application Dashboard {#blocks-lldap-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-lldap-options-shb.lldap.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.LLDAP = {
sortOrder = 2;
dashboard.request = config.shb.lldap.dashboard.request;
};
}
```
## Manage Groups {#blocks-lldap-manage-groups} ## Manage Groups {#blocks-lldap-manage-groups}
The following snippet will create group named "family" if it does not exist yet. The following snippet will create group named "family" if it does not exist yet.
@ -61,7 +83,7 @@ Also, all other groups will be deleted and only the "family" group will remain.
Note that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist. Note that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist.
If you want existing groups not declared in the `shb.lldap.ensureGroups` to be deleted, If you want existing groups not declared in the `shb.lldap.ensureGroups` to be deleted,
set [`shb.lldap.enforceGroups`](#blocks-lldap-options-shb.lldap.enforceGroups) to `false`. set [`shb.lldap.enforceGroups`](#blocks-lldap-options-shb.lldap.enforceGroups) to `true`.
```nix ```nix
{ {
@ -142,7 +164,7 @@ set [`shb.lldap.enforceUserMemberships`](#blocks-lldap-options-shb.lldap.enforce
}; };
shb.sops.secret."dad".request = shb.sops.secret."dad".request =
shb.lldap.ensureUsers.dad.password.request; config.shb.lldap.ensureUsers.dad.password.request;
} }
``` ```

View file

@ -48,7 +48,7 @@ let
logging.basicConfig(level=logging.INFO, format='%(message)s') logging.basicConfig(level=logging.INFO, format='%(message)s')
def wait_for_port(host, port, timeout=10): def wait_for_port(host, port, timeout):
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
try: try:
@ -68,6 +68,7 @@ let
parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on") parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on")
parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost") parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost")
parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream") parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream")
parser.add_argument("--timeout", required=False, type=int, default=10, help="Timeout to wait for port availability")
args, rest = parser.parse_known_args() args, rest = parser.parse_known_args()
MITMDUMP_BIN = os.environ.get("MITMDUMP_BIN") MITMDUMP_BIN = os.environ.get("MITMDUMP_BIN")
@ -75,7 +76,7 @@ let
raise Exception("MITMDUMP_BIN env var must be set to the path of the mitmdump binary") raise Exception("MITMDUMP_BIN env var must be set to the path of the mitmdump binary")
logging.info(f"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.") logging.info(f"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.")
wait_for_port(args.upstream_host, args.upstream_port, timeout=10) wait_for_port(args.upstream_host, args.upstream_port, timeout=args.timeout)
logging.info(f"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.") logging.info(f"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.")
proc = subprocess.Popen( proc = subprocess.Popen(
@ -90,10 +91,11 @@ let
) )
logging.info(f"Waiting for mitmdump instance to start on port {args.listen_port}.") logging.info(f"Waiting for mitmdump instance to start on port {args.listen_port}.")
if wait_for_port("127.0.0.1", args.listen_port, timeout=10): if wait_for_port("127.0.0.1", args.listen_port, timeout=args.timeout):
logging.info(f"Mitmdump is started on port {args.listen_port}.") logging.info(f"Mitmdump is started on port {args.listen_port}.")
notify("READY=1") notify("READY=1")
else: else:
print(f"Mitmdump instance did not start before the timeout of {args.timeout} seconds, consider increasing the timeout")
proc.terminate() proc.terminate()
exit(1) exit(1)
@ -222,6 +224,14 @@ in
''; '';
}; };
timeout = mkOption {
type = types.int;
default = 30;
description = ''
Time to wait for upstream to start and mitmdump to start.
'';
};
after = mkOption { after = mkOption {
type = listOf str; type = listOf str;
default = [ ]; default = [ ];
@ -282,7 +292,7 @@ in
addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons; addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons;
extraArgs = lib.concatStringsSep " " cfg'.extraArgs; extraArgs = lib.concatStringsSep " " cfg'.extraArgs;
in in
"${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}"; "${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} --timeout ${toString cfg'.timeout} ${addons} ${extraArgs}";
}; };
requires = cfg'.after; requires = cfg'.after;
after = cfg'.after; after = cfg'.after;

View file

@ -27,8 +27,10 @@ let
in in
{ {
imports = [ imports = [
../../lib/module.nix
../blocks/authelia.nix ../blocks/authelia.nix
../blocks/lldap.nix ../blocks/lldap.nix
../blocks/nginx.nix
]; ];
options.shb.monitoring = { options.shb.monitoring = {
@ -108,6 +110,35 @@ in
default = [ ]; default = [ ];
}; };
scrutiny = {
enable = lib.mkEnableOption "scrutiny service" // {
default = true;
};
subdomain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
If a string, this will be the subdomain under which the scrutiny web interface will be servced.
If null, the web interface will not be served and only the prometheus metrics will be accessible.
'';
default = "scrutiny";
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.scrutiny.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.monitoring.scrutiny.subdomain}.\${config.shb.monitoring.domain}";
internalUrl = "http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}";
internalUrlText = "https://127.0.0.1.\${config.services.scrutiny.settings.web.listen.port}";
};
};
};
};
adminPassword = lib.mkOption { adminPassword = lib.mkOption {
description = "Initial admin password."; description = "Initial admin password.";
type = lib.types.submodule { type = lib.types.submodule {
@ -247,13 +278,38 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.monitoring.subdomain}.\${config.shb.monitoring.domain}";
internalUrl = "https://${cfg.subdomain}.${cfg.domain}";
internalUrlText = "https://\${config.shb.monitoring.subdomain}.\${config.shb.monitoring.domain}";
};
};
};
impermanence = lib.mkOption {
description = ''
Paths to save when using impermanence setup.
'';
type = lib.types.attrsOf lib.types.str;
default = {
fluent-bit = "/var/fluent-bit";
};
};
}; };
config = lib.mkMerge [ config = lib.mkMerge [
(lib.mkIf cfg.enable { (lib.mkIf cfg.enable {
assertions = [ assertions = [
{ {
assertion = (!(isNull cfg.smtp)) -> builtins.length cfg.contactPoints > 0; assertion = builtins.length cfg.contactPoints > 0;
message = "Must have at least one contact point for alerting"; message = "Must have at least one contact point for alerting";
} }
]; ];
@ -417,6 +473,9 @@ in
services.prometheus = { services.prometheus = {
enable = true; enable = true;
port = cfg.prometheusPort; port = cfg.prometheusPort;
globalConfig = {
scrape_interval = "15s";
};
}; };
services.loki = { services.loki = {
@ -526,41 +585,62 @@ in
}; };
}; };
services.promtail = { # I decided to switch to fluent-bit because it can be tested locally https://docs.fluentbit.io/manual/local-testing/logging-pipeline
services.fluent-bit = {
enable = true; enable = true;
configuration = { settings = {
server = { service = {
http_listen_port = 9080; flush = 1;
grpc_listen_port = 0; log_level = "info";
http_server = "true";
http_listen = "127.0.0.1";
http_port = 9080;
grace = 30;
}; };
positions.filename = "/tmp/positions.yaml"; pipeline = {
inputs = [
{
name = "systemd";
client.url = "http://localhost:${toString config.services.loki.configuration.server.http_listen_port}/api/prom/push"; # The asterisk appends the _SYSTEMD_UNIT to the prefix.
tag = "systemd.*";
scrape_configs = [ # Read logs from this systemd journal directory.
{
job_name = "systemd";
journal = {
json = false;
max_age = "12h";
path = "/var/log/journal"; path = "/var/log/journal";
# matches = "_TRANSPORT=kernel";
labels = { # Database file to keep track of the journald cursor.
db = "/var/fluent-bit/systemd.db";
# Start reading new entries. Skip entries already stored in journald.
read_from_tail = true;
# Max entries to lookback on start.
max_entries = 10000;
}
];
outputs = [
{
name = "loki";
match = "systemd.*";
host = "localhost";
port = config.services.loki.configuration.server.http_listen_port;
labels = lib.concatMapAttrsStringSep ", " (n: v: "${n}=${v}") {
job = "systemd-journal";
domain = cfg.domain; domain = cfg.domain;
hostname = config.networking.hostName; hostname = config.networking.hostName;
job = "systemd-journal";
}; };
};
relabel_configs = [ label_keys = "$unit";
{ }
source_labels = [ "__journal__systemd_unit" ]; ];
target_label = "unit"; };
}
];
}
];
}; };
graceLimit = "1m";
}; };
services.nginx = { services.nginx = {
@ -834,5 +914,77 @@ in
} }
]; ];
}) })
(lib.mkIf (cfg.enable && cfg.scrutiny.enable) {
services.scrutiny = {
enable = true;
openFirewall = false;
# This src includes Prometheus metrics exporter.
package = pkgs.scrutiny.overrideAttrs ({
src = pkgs.fetchFromGitHub {
owner = "ibizaman";
repo = "scrutiny";
rev = "74faf06f77df83f29e7e1806cd88b2fafc0bbb82";
hash = "sha256-r0AVWL+E046xHxitwMPfRNTOpjuOk+W6tB41YgmLTPg=";
};
vendorHash = "sha256-kAlnlWnBMFCdgdak5L5hRquRtyLi5MTmDa/kxwqPs4E=";
});
settings = {
web = {
metrics.enabled = true; # Enables Prometheus exporter
listenHost = "127.0.0.1";
};
};
collector = {
enable = true;
};
};
services.prometheus.scrapeConfigs = [
{
job_name = "scrutiny";
metrics_path = "/api/metrics";
static_configs = [
{
targets = [ "127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}" ];
labels = commonLabels;
}
];
}
];
shb.monitoring.dashboards = [
./monitoring/dashboards/Health.json
];
shb.nginx.vhosts = lib.mkIf (cfg.scrutiny.subdomain != null) [
(
{
inherit (cfg) domain ssl;
subdomain = cfg.scrutiny.subdomain;
upstream = "http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}";
autheliaRules = lib.optionals (cfg.sso.enable) [
{
domain = "${cfg.subdomain}.${cfg.domain}";
policy = cfg.sso.authorization_policy;
subject = [
"group:${cfg.ldap.userGroup}"
"group:${cfg.ldap.adminGroup}"
];
}
];
}
// lib.optionalAttrs cfg.sso.enable {
inherit (cfg.sso) authEndpoint;
}
)
];
})
]; ];
} }

View file

@ -0,0 +1,872 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 4,
"panels": [],
"repeat": "hostname",
"title": "${hostname}",
"type": "row"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 12,
"x": 0,
"y": 1
},
"id": 3,
"maxDataPoints": 400,
"options": {
"cellHeight": "sm",
"showHeader": true
},
"pluginVersion": "12.4.0",
"targets": [
{
"disableTextWrap": false,
"editorMode": "builder",
"expr": "node_os_info",
"fullMetaSearch": false,
"includeNullMetadata": true,
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
}
],
"title": "OS Versions",
"transformations": [
{
"id": "labelsToFields",
"options": {
"keepLabels": [
"build_id",
"domain",
"hostname",
"id",
"instance",
"job",
"name",
"pretty_name",
"version",
"version_codename",
"version_id"
],
"mode": "columns"
}
},
{
"id": "groupBy",
"options": {
"fields": {
"Time": {
"aggregations": [
"firstNotNull"
],
"operation": "aggregate"
},
"pretty_name": {
"aggregations": [],
"operation": "groupby"
}
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 1
},
"id": 1,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "node_hwmon_temp_celsius{hostname=~\"$hostname\"}",
"instant": false,
"legendFormat": "{{chip}} - {{sensor}}",
"range": true,
"refId": "A"
}
],
"title": "Temperature",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 0
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 9
},
"id": 5,
"interval": "1m",
"maxDataPoints": 200,
"options": {
"colWidth": 1,
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"rowHeight": 0.8,
"showValue": "never",
"tooltip": {
"hideZeros": false,
"mode": "none",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"repeat": "hostname",
"repeatDirection": "h",
"targets": [
{
"editorMode": "code",
"expr": "node_zfs_zpool_state{hostname=~\"$hostname\", state=\"online\"} > 0",
"legendFormat": "{{zpool}} - {{state}}",
"range": true,
"refId": "A"
}
],
"title": "ZFS Pools",
"type": "status-history"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line+area"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "transparent",
"value": 604808
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 13
},
"id": 2,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "ssl_certificate_expiry_seconds",
"legendFormat": "{{exported_hostname}}: {{subject}} {{path}}",
"range": true,
"refId": "A"
}
],
"title": "Certificate Remaining Validity",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMin": 0,
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "years"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 13
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"targets": [
{
"editorMode": "builder",
"expr": "scrutiny_smart_power_on_hours{hostname=~\"$hostname\"} / (24 * 365)",
"legendFormat": "{{device_name}}",
"range": true,
"refId": "A"
}
],
"title": "Operating Years",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-YlRd"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMax": 100,
"axisSoftMin": 0,
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineStyle": {
"fill": "solid"
},
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 21
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true,
"width": 400
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"targets": [
{
"disableTextWrap": false,
"editorMode": "builder",
"expr": "sum by(hostname, domain, mountpoint, device) (node_filesystem_free_bytes{hostname=~\"$hostname\"})",
"fullMetaSearch": false,
"hide": true,
"includeNullMetadata": false,
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "builder",
"expr": "sum by(hostname, domain, mountpoint, device) (node_filesystem_size_bytes{hostname=~\"$hostname\"})",
"hide": true,
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "B"
},
{
"datasource": {
"name": "Expression",
"type": "__expr__",
"uid": "__expr__"
},
"expression": "(1 - $A / $B) * 100",
"refId": "Disk Full",
"type": "math"
}
],
"title": "Filesystem Disk Usage",
"transformations": [
{
"id": "joinByField",
"options": {
"byField": "Time",
"mode": "outer"
}
}
],
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMin": 0,
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 21
},
"id": 9,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.4.0",
"targets": [
{
"editorMode": "builder",
"expr": "scrutiny_smart_power_on_hours{hostname=~\"$hostname\"}",
"legendFormat": "{{device_name}}",
"range": true,
"refId": "A"
}
],
"title": "Operating Years",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 29
},
"id": 8,
"options": {
"cellHeight": "sm",
"showHeader": true
},
"pluginVersion": "12.4.0",
"targets": [
{
"editorMode": "builder",
"exemplar": false,
"expr": "scrutiny_device_info{hostname=~\"$hostname\"}",
"format": "table",
"instant": true,
"legendFormat": "{{device_name}}",
"range": false,
"refId": "A"
}
],
"title": "Disk Info",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true,
"__name__": true,
"domain": true,
"hostname": true,
"instance": true,
"job": true,
"wwn": false
},
"includeByName": {},
"indexByName": {},
"renameByName": {}
}
}
],
"type": "table"
}
],
"preload": false,
"schemaVersion": 42,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": "baryum",
"value": "baryum"
},
"definition": "label_values(up,hostname)",
"name": "hostname",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(up,hostname)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"regexApplyTo": "value",
"type": "query"
}
]
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Node Health",
"uid": "edhuvl28vpjwge",
"version": 25,
"weekStart": ""
}

View file

@ -17,9 +17,13 @@ This block sets up the monitoring stack for Self Host Blocks. It is composed of:
- Registration is enabled through SSO. - Registration is enabled through SSO.
- Access through [subdomain](#blocks-monitoring-options-shb.monitoring.subdomain) using reverse proxy. - Access through [subdomain](#blocks-monitoring-options-shb.monitoring.subdomain) using reverse proxy.
- Access through [HTTPS](#blocks-monitoring-options-shb.monitoring.ssl) using reverse proxy. - Access through [HTTPS](#blocks-monitoring-options-shb.monitoring.ssl) using reverse proxy.
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-monitoring-usage-applicationdashboard)
- Out of the box integration with [Scrutiny](https://github.com/AnalogJ/scrutiny) service for Hard Drives monitoring. [Manual](#blocks-monitoring-usage-scrutiny)
## Usage {#blocks-monitoring-usage} ## Usage {#blocks-monitoring-usage}
### Initial Configuration {#blocks-monitoring-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -34,16 +38,16 @@ The following snippet assumes a few blocks have been setup already:
subdomain = "grafana"; subdomain = "grafana";
inherit domain; inherit domain;
contactPoints = [ "me@example.com" ]; contactPoints = [ "me@example.com" ];
adminPassword.result = config.sops.secrets."monitoring/admin_password".result; adminPassword.result = config.shb.sops.secret."monitoring/admin_password".result;
secretKey.result = config.sops.secrets."monitoring/secret_key".result; secretKey.result = config.shb.sops.secret."monitoring/secret_key".result;
sso = { sso = {
enable = true; enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.sops.secret.oidcSecret.result; sharedSecret.result = config.shb.sops.secret."monitoring/oidcSecret".result;
sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result; sharedSecretForAuthelia.result = config.shb.sops.secret."monitoring/oidcAutheliaSecret".result;
}; };
}; };
shb.sops.secret."monitoring/admin_password".request = config.shb.monitoring.adminPassword.request; shb.sops.secret."monitoring/admin_password".request = config.shb.monitoring.adminPassword.request;
@ -67,7 +71,7 @@ LDAP groups are created automatically.
### SMTP {#blocks-monitoring-usage-smtp} ### SMTP {#blocks-monitoring-usage-smtp}
I recommend adding a STMP server configuration so you receive alerts by email: I recommend adding an SMTP server configuration so you receive alerts by email:
```nix ```nix
shb.monitoring.smtp = { shb.monitoring.smtp = {
@ -110,6 +114,42 @@ You might for example want to update the metrics retention time with:
services.prometheus.retentionTime = "60d"; services.prometheus.retentionTime = "60d";
``` ```
### Application Dashboard {#blocks-monitoring-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-monitoring-options-shb.monitoring.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.Grafana = {
sortOrder = 10;
dashboard.request = config.shb.monitoring.dashboard.request;
};
}
```
There is also an integration for the scrutiny service, see next section.
### Scrutiny {#blocks-monitoring-usage-scrutiny}
Integration with the [Scrutiny](https://github.com/AnalogJ/scrutiny) service is enabled by default and setup automatically.
The web interface will be served under the [scrutiny.subdomain](#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain) option.
If you don't want the web interface, set the option to `null`.
For integration with the [dashboard contract](contracts-dashboard.html):
```nix
{
shb.homepage.servicesGroups.Admin.services.Scrutiny = {
sortOrder = 11;
dashboard.request = config.shb.monitoring.scrutiny.dashboard.request;
};
}
```
## Provisioning {#blocks-monitoring-provisioning} ## Provisioning {#blocks-monitoring-provisioning}
Self Host Blocks will create automatically the following resources: Self Host Blocks will create automatically the following resources:
@ -244,6 +284,16 @@ Graphs:
![Late SSL Jobs Alert Firing](./assets/alert_rules_LateSSL_1.png) ![Late SSL Jobs Alert Firing](./assets/alert_rules_LateSSL_1.png)
## Impermanence {#blocks-monitoring-impermanence}
To save the fluent-bit folder in an impermanence setup, add:
```nix
{
shb.zfs.datasets."safe/monitoring-fluent-bit".path = config.shb.jellyfin.impermanence.fluent-bit;
}
```
## Options Reference {#blocks-monitoring-options} ## Options Reference {#blocks-monitoring-options}
```{=include=} options ```{=include=} options

View file

@ -73,7 +73,7 @@ in
backupName = "postgres.sql"; backupName = "postgres.sql";
backupCmd = '' backupCmd = ''
${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable ${pkgs.postgresql}/bin/pg_dumpall --clean --if-exists | ${pkgs.gzip}/bin/gzip --rsyncable
''; '';
restoreCmd = '' restoreCmd = ''

View file

@ -413,7 +413,13 @@ in
nameValuePair "${fullName name instance.settings.repository}_restore_gen" { nameValuePair "${fullName name instance.settings.repository}_restore_gen" {
enable = true; enable = true;
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot"; # Purposely not a oneshot systemd service otherwise
# the service waits on the completion the backup before deactivating.
# This seems like a nice property at first but there is one annoying
# edge case when deploying. If a backup job somehow is started when
# the deploy happens, the deploy will wait on the service to finish
# before considering the deploy done. And worse, it will consider the
# deploy as failed if the backup fails, which is not what you want.
script = ( script = (
shb.replaceSecrets { shb.replaceSecrets {
userConfig = instance.settings.repository.secrets // { userConfig = instance.settings.repository.secrets // {
@ -434,38 +440,16 @@ in
let let
mkResticBinary = mkResticBinary =
name: instance: name: instance:
pkgs.writeShellApplication { shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository; name = fullName name instance.settings.repository;
text = '' user = instance.request.user;
usage() { sudoPreserveEnvs = [
echo "$0 restore latest" "RESTIC_REPOSITORY"
} "RESTIC_PASSWORD_FILE"
];
if ! [ "$1" = "restore" ]; then secretsFile = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
usage restoreCmd = ''${pkgs.restic}/bin/restic restore \"$snapshot\" --target /'';
exit 1 listCmd = ''if [ -e \"$RESTIC_REPOSITORY/index\" ]; then ${pkgs.restic}/bin/restic snapshots --json | ${pkgs.jq}/bin/jq '.[].id'; fi'';
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}")
set +a
echo "Will restore archive 'latest'"
sudocmd ${pkgs.restic}/bin/restic restore latest --target /
'';
}; };
in in
flatten (mapAttrsToList mkResticBinary cfg.instances); flatten (mapAttrsToList mkResticBinary cfg.instances);
@ -475,38 +459,16 @@ in
let let
mkResticBinary = mkResticBinary =
name: instance: name: instance:
pkgs.writeShellApplication { shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository; name = fullName name instance.settings.repository;
text = '' user = instance.request.user;
usage() { sudoPreserveEnvs = [
echo "$0 restore latest" "RESTIC_REPOSITORY"
} "RESTIC_PASSWORD_FILE"
];
if ! [ "$1" = "restore" ]; then secretsFile = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
usage restoreCmd = ''${pkgs.restic}/bin/restic dump \"$snapshot\" ${instance.request.backupName} | ${instance.request.restoreCmd}'';
exit 1 listCmd = ''if [ -e \"$RESTIC_REPOSITORY/index\" ]; then ${pkgs.restic}/bin/restic snapshots --json | ${pkgs.jq}/bin/jq '.[].id'; fi'';
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}")
set +a
echo "Will restore archive 'latest'"
sudocmd sh -c "${pkgs.restic}/bin/restic dump latest ${instance.request.backupName} | ${instance.request.restoreCmd}"
'';
}; };
in in
flatten (mapAttrsToList mkResticBinary cfg.databases); flatten (mapAttrsToList mkResticBinary cfg.databases);

View file

@ -3,6 +3,7 @@
Defined in [`/modules/blocks/restic.nix`](@REPO@/modules/blocks/restic.nix). Defined in [`/modules/blocks/restic.nix`](@REPO@/modules/blocks/restic.nix).
This block sets up a backup job using [Restic][]. This block sets up a backup job using [Restic][].
It is heavily based on the nixpkgs Restic module.
[restic]: https://restic.net/ [restic]: https://restic.net/
@ -50,7 +51,7 @@ shb.restic.instances."myservice" = {
settings = { settings = {
enable = true; enable = true;
passphrase.result = shb.sops.secret."passphrase".result; passphrase.result = config.shb.sops.secret."passphrase".result;
repository = { repository = {
path = "/srv/backups/myservice"; path = "/srv/backups/myservice";
@ -71,7 +72,7 @@ shb.restic.instances."myservice" = {
}; };
shb.sops.secret."passphrase".request = shb.sops.secret."passphrase".request =
shb.restic.instances."myservice".settings.passphrase.request; config.shb.restic.instances."myservice".settings.passphrase.request;
``` ```
### One folder backed up with contract {#blocks-restic-usage-provider-contract} ### One folder backed up with contract {#blocks-restic-usage-provider-contract}
@ -82,12 +83,12 @@ the snippet above becomes:
```nix ```nix
shb.restic.instances."myservice" = { shb.restic.instances."myservice" = {
request = config.myservice.backup; request = config.myservice.backup.request;
settings = { settings = {
enable = true; enable = true;
passphrase.result = shb.sops.secret."passphrase".result; passphrase.result = config.shb.sops.secret."passphrase".result;
repository = { repository = {
path = "/srv/backups/myservice"; path = "/srv/backups/myservice";
@ -108,7 +109,7 @@ shb.restic.instances."myservice" = {
}; };
shb.sops.secret."passphrase".request = shb.sops.secret."passphrase".request =
shb.restic.instances."myservice".settings.passphrase.request; config.shb.restic.instances."myservice".settings.passphrase.request;
``` ```
### One folder backed up to S3 {#blocks-restic-usage-provider-remote} ### One folder backed up to S3 {#blocks-restic-usage-provider-remote}
@ -229,11 +230,44 @@ See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backu
## Maintenance {#blocks-restic-maintenance} ## Maintenance {#blocks-restic-maintenance}
One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets. ### Manual Backup {#blocks-restic-maintenance-manuql}
To launch a backup manually, just run:
```bash
systemctl start <systemd-service-name>
```
You can easily discover the systemd service name you need by either listing the units:
```bash
systemctl list-units 'restic*'
```
Or by autocompleting the unit name with `<TAB>`:
```bash
systemctl start restic<TAB><TAB>
```
Note that the systemd services are of `Type=simple` which means the systemd service
will not wait for the backup completion to terminate.
If you want instead to wait for the backup to complete, use the `--wait` flag:
```bash
systemctl start --wait <systemd-service-name>
```
### Restore {#blocks-restic-maintenance-restore}
One command-line helper is provided per backup instance and repository pair which allows to:
- list snapshots: `<script> snapshots`
- to restore a snapshot: `<script> restore <snapshot>`
The restore script has all the secrets needed to access the repo, The restore script has all the secrets needed to access the repo,
it will run `sudo` automatically it will run `sudo` automatically
and the user running it needs to have correct permissions for privilege escalation and the user running it needs to have correct permissions for privilege escalation.
In the [multiple directories example](#blocks-restic-usage-multiple) above, the following 6 helpers are provided in the `$PATH`: In the [multiple directories example](#blocks-restic-usage-multiple) above, the following 6 helpers are provided in the `$PATH`:

164
modules/blocks/sanoid.nix Normal file
View file

@ -0,0 +1,164 @@
{
config,
lib,
pkgs,
shb,
utils,
...
}:
let
cfg = config.shb.sanoid;
restoreScriptName = name: "sanoid-${utils.escapeSystemdPath name}-restore";
backupScriptBase = "sanoid";
restoreScript =
name:
pkgs.writers.writePython3Bin (restoreScriptName name)
{
flakeIgnore = [ "E501" ];
}
''
import argparse
import subprocess
import sys
dataset = "${name}"
class ZFSError(Exception):
pass
def run_command(cmd: list[str]) -> str:
try:
result = subprocess.run(
cmd,
check=True,
text=True,
capture_output=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
raise ZFSError(
f"Command failed: {' '.join(cmd)}\n"
f"Exit code: {e.returncode}\n"
f"stderr: {e.stderr.strip()}"
) from e
def list_snapshots() -> None:
"""List all ZFS snapshots."""
output = run_command(["zfs", "list", "-H", "-t", "snapshot", dataset])
if not output:
return []
return output.splitlines()
def restore_snapshot(snapshot: str) -> None:
"""Rollback to a given snapshot."""
if not snapshot:
raise ValueError("Snapshot name must not be empty")
print(f"Rolling back to snapshot: {snapshot}")
run_command(["zfs", "rollback", "-r", snapshot])
print("Rollback completed successfully.")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=f"Restore script for {dataset}")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser(
"snapshots",
help="List all ZFS snapshots",
)
restore_parser = subparsers.add_parser(
"restore",
help="Rollback to a specific snapshot",
)
restore_parser.add_argument(
"snapshot",
help="Snapshot name (e.g. pool/dataset@snapname)",
)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
try:
if args.command == "snapshots":
snapshots = list_snapshots()
for s in snapshots:
print(s)
elif args.command == "restore":
restore_snapshot(args.snapshot)
else:
parser.print_help()
sys.exit(1)
except ZFSError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
'';
in
{
imports = [
../../lib/module.nix
];
options.shb.sanoid.backup = lib.mkOption {
description = "Sanoid prodiver for file backup contract";
default = { };
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = shb.contracts.backup.mkProvider {
resultCfg = {
restoreScript = restoreScriptName name;
restoreScriptText = restoreScriptName "<name>";
backupService = "${backupScriptBase}.service";
backupServiceText = "${backupScriptBase}.service";
};
settings = lib.mkOption {
description = "Options passed to the `services.sanoid.datasets.<name>` option.";
default = { };
type = lib.types.attrsOf lib.types.anything;
};
};
}
)
);
};
config = lib.mkIf (cfg.backup != { }) {
services.sanoid.enable = true;
services.sanoid.datasets =
let
mkDataset = name: cfg': {
inherit name;
value = cfg'.settings;
};
in
lib.mapAttrs' mkDataset cfg.backup;
environment.systemPackages = map restoreScript (lib.attrNames cfg.backup);
};
}

View file

@ -0,0 +1,97 @@
# Sanoid Block {#blocks-sanoid}
Defined in [`/modules/blocks/sanoid.nix`](@REPO@/modules/blocks/sanoid.nix):
```nix
{
imports = [
inputs.selfhostblocks.nixosModules.sanoid
];
}
```
## Provider Contracts {#blocks-sanoid-contract-provider}
This block provides the following contracts:
- [dataset backup contract](contracts-datasetbackup.html) under the [`shb.sanoid.backup`][backup] option.
It is tested with the [generic contract tests][backup contract tests].
[backup]: #blocks-sanoid-options-shb.sanoid.backup
[backup contract tests]: @REPO@/test/contracts/backup.nix
## Usage {#blocks-sanoid-usage}
Sanoid uses templates to know when snapshots should be kept or pruned.
### Default Template {#blocks-sanoid-usage-default-template}
Backup a dataset using the default Sanoid template:
```nix
{
shb.zfs.pools.root.datasets.home = {
path = "/home";
};
shb.sanoid.backup."root/home" = {
request = shb.zfs.pools.root.datasets.home.datasetBackup.request;
};
}
```
This uses the dataset backup contract which is exposed through the ZFS module's [`shb.zfs.pools.<name>.datasets.<name>.datasetBackup`](blocks-zfs.html#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.datasetbackup) option.
### Custom Template {#blocks-sanoid-usage-custom-template}
Create a custom template and use it:
```nix
{
shb.zfs.pools.root.datasets.home = {
path = "/home";
};
services.sanoid.templates."yearly" = {
hourly = 10;
daily = 3;
monthly = 3;
yearly = 2;
};
shb.sanoid.backup."root/home" = {
request = shb.zfs.pools.root.datasets.home.datasetBackup.request;
template = "yearly";
};
}
```
Note we use the upstream `services.sanoid.templates` option to define the templates.
### Without contract {#blocks-sanoid-usage-without-contract}
To backup a dataset that does not provide the dataset backup contract,
we can just set the request manually:
```nix
{
shb.zfs.pools.root.datasets.home = {
path = "/home";
};
shb.sanoid.backup."root/home" = {
request.dataset = "root/home";
};
}
```
Note the attr name under the `shb.sanoid.backup` option does not
set the dataset name.
## Options Reference {#blocks-sanoid-options}
```{=include=} options
id-prefix: blocks-sanoid-options-
list-id: selfhostblocks-blocks-sanoid-options
source: @OPTIONS_JSON@
```

View file

@ -13,7 +13,7 @@ to adapt it to the [secret contract](./contracts-secret.html).
This block provides the following contracts: This block provides the following contracts:
- [secret contract][] under the [`shb.sops.secrets`][secret] option. - [secret contract][] under the [`shb.sops.secret`][secret] option.
It is not yet tested with [contract tests][secret contract tests] but it is used extensively on several machines. It is not yet tested with [contract tests][secret contract tests] but it is used extensively on several machines.
[secret]: #blocks-sops-options-shb.sops.secret [secret]: #blocks-sops-options-shb.sops.secret
@ -21,7 +21,7 @@ This block provides the following contracts:
[secret contract tests]: @REPO@/test/contracts/secret.nix [secret contract tests]: @REPO@/test/contracts/secret.nix
As requested by the contract, when asking for a secret with the `shb.sops` module, As requested by the contract, when asking for a secret with the `shb.sops` module,
the path where the secret will be located can be found under the [`shb.sops.secrets.<name>.result`][result] option. the path where the secret will be located can be found under the [`shb.sops.secret.<name>.result`][result] option.
[result]: #blocks-sops-options-shb.sops.secret._name_.result [result]: #blocks-sops-options-shb.sops.secret._name_.result

View file

@ -399,21 +399,25 @@ in
crl_signing_key crl_signing_key
EOF EOF
mkdir -p "$(dirname -- "${caCfg.paths.key}")" keyfile="${caCfg.paths.key}"
${pkgs.gnutls}/bin/certtool \ keydir="$(dirname -- "$keyfile")"
--generate-privkey \ mkdir -p "$keydir"
--key-type rsa \ [ -f "$keyfile" ] || ${pkgs.gnutls}/bin/certtool \
--sec-param High \ --generate-privkey \
--outfile ${caCfg.paths.key} --key-type rsa \
chmod 666 ${caCfg.paths.key} --sec-param High \
--outfile "$keyfile"
chmod 666 "$keyfile"
mkdir -p "$(dirname -- "${caCfg.paths.cert}")" certfile="${caCfg.paths.cert}"
${pkgs.gnutls}/bin/certtool \ certdir="$(dirname -- "$certfile")"
--generate-self-signed \ mkdir -p "$certdir"
--load-privkey ${caCfg.paths.key} \ [ -f "$certfile" ] || ${pkgs.gnutls}/bin/certtool \
--template ca.template \ --generate-self-signed \
--outfile ${caCfg.paths.cert} --load-privkey "$keyfile" \
chmod 666 ${caCfg.paths.cert} --template ca.template \
--outfile "$certfile"
chmod 666 "$certfile"
''; '';
} }
) cfg.cas.selfsigned; ) cfg.cas.selfsigned;
@ -427,6 +431,8 @@ in
script = '' script = ''
mkdir -p /etc/ssl/certs mkdir -p /etc/ssl/certs
# This file is automatically idempotent since the source files used are idempotent.
rm -f /etc/ssl/certs/ca-bundle.crt rm -f /etc/ssl/certs/ca-bundle.crt
rm -f /etc/ssl/certs/ca-certificates.crt rm -f /etc/ssl/certs/ca-certificates.crt
@ -456,8 +462,8 @@ in
let let
extraDnsNames = lib.strings.concatStringsSep "\n" (map (n: "dns_name = ${n}") certCfg.extraDomains); extraDnsNames = lib.strings.concatStringsSep "\n" (map (n: "dns_name = ${n}") certCfg.extraDomains);
chmod = cert: '' chmod = cert: ''
chown root:${certCfg.group} ${cert} chown root:${certCfg.group} "${cert}"
chmod 640 ${cert} chmod 640 "${cert}"
''; '';
in in
'' ''
@ -474,23 +480,27 @@ in
signing_key signing_key
EOF EOF
mkdir -p "$(dirname -- "${certCfg.paths.key}")" keyfile="${certCfg.paths.key}"
${pkgs.gnutls}/bin/certtool \ keydir="$(dirname -- "$keyfile")"
--generate-privkey \ mkdir -p "$keydir"
--key-type rsa \ [ -f "$keyfile" ] || ${pkgs.gnutls}/bin/certtool \
--sec-param High \ --generate-privkey \
--outfile ${certCfg.paths.key} --key-type rsa \
${chmod certCfg.paths.key} --sec-param High \
--outfile "$keyfile"
${chmod "$keyfile"}
mkdir -p "$(dirname -- "${certCfg.paths.cert}")" certfile="${certCfg.paths.cert}"
${pkgs.gnutls}/bin/certtool \ certdir="$(dirname -- "$certfile")"
--generate-certificate \ mkdir -p "$certdir"
--load-privkey ${certCfg.paths.key} \ [ -f "$certfile" ] || ${pkgs.gnutls}/bin/certtool \
--load-ca-privkey ${certCfg.ca.paths.key} \ --generate-certificate \
--load-ca-certificate ${certCfg.ca.paths.cert} \ --load-privkey "$keyfile" \
--template server.template \ --load-ca-privkey "${certCfg.ca.paths.key}" \
--outfile ${certCfg.paths.cert} --load-ca-certificate "${certCfg.ca.paths.cert}" \
${chmod certCfg.paths.cert} --template server.template \
--outfile "$certfile"
${chmod "$certfile"}
''; '';
postStart = lib.optionalString (certCfg.reloadServices != [ ]) '' postStart = lib.optionalString (certCfg.reloadServices != [ ]) ''
@ -541,7 +551,7 @@ in
// lib.optionalAttrs (certCfg.dnsProvider != null) { // lib.optionalAttrs (certCfg.dnsProvider != null) {
inherit (certCfg) dnsProvider dnsResolver; inherit (certCfg) dnsProvider dnsResolver;
inherit (certCfg) group reloadServices; inherit (certCfg) group reloadServices;
credentialsFile = certCfg.credentialsFile; environmentFile = certCfg.credentialsFile;
}; };
} }
] ]

View file

@ -2,83 +2,258 @@
config, config,
pkgs, pkgs,
lib, lib,
shb,
utils,
... ...
}: }:
let let
cfg = config.shb.zfs; cfg = config.shb.zfs;
datasets =
if cfg.snapshotBeforeActivation.datasets != null then
cfg.snapshotBeforeActivation.datasets
else
config.boot.zfs.extraPools;
in in
{ {
imports = [
../../lib/module.nix
];
options.shb.zfs = { options.shb.zfs = {
defaultPoolName = lib.mkOption { pools = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "ZFS pool name datasets should be created on if no pool name is given in the dataset.";
};
datasets = lib.mkOption {
description = '' description = ''
ZFS Datasets. Attrset of ZFS pools under which datasets will be created.
Each entry in the attrset will be created and mounted in the given path. The ZFS pools are not managed by this module, they should already exist.
The attrset name is the dataset name.
This block implements the following contracts: Each pool named here will be added to the [`boot.zfs.extraPools`](https://search.nixos.org/options?channel=unstable&include_modular_service_options=0&include_nixos_options=1&query=boot.zfs.extrapools&show=option:boot.zfs.extraPools) option.
- mount
''; '';
default = { }; default = { };
example = lib.literalExpression ''
shb.zfs."safe/postgresql".path = "/var/lib/postgresql";
'';
type = lib.types.attrsOf ( type = lib.types.attrsOf (
lib.types.submodule { lib.types.submodule {
options = { options = {
enable = lib.mkEnableOption "shb.zfs.datasets"; datasets = lib.mkOption {
description = ''
ZFS Datasets.
poolName = lib.mkOption { Each entry in the attrset will be created and mounted in the given path.
type = lib.types.nullOr lib.types.str; The attrset name is the dataset name.
default = null;
description = "ZFS pool name this dataset should be created on. Overrides the defaultPoolName.";
};
path = lib.mkOption { This block implements the following contracts:
type = lib.types.str; - mount
description = "Path this dataset should be mounted on."; '';
default = { };
example = lib.literalExpression ''
shb.zfs."safe/postgresql".path = "/var/lib/postgresql";
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ config, name, ... }:
{
options = {
enable = lib.mkEnableOption "shb.zfs.datasets";
path = lib.mkOption {
type = lib.types.str;
description = "Path this dataset should be mounted on. If the string 'none' is given, the dataset will not be mounted.";
};
mode = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "If non null, unix mode to apply to the dataset root folder.";
default = null;
example = "ug=rwx,g+s";
};
owner = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "If non null, unix user to apply to the dataset root folder.";
default = null;
example = "syncthing";
};
group = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = "If non null, unix group to apply to the dataset root folder.";
default = null;
example = "syncthing";
};
defaultACLs = lib.mkOption {
type = lib.types.nullOr lib.types.str;
description = ''
If non null, default ACL to set on the dataset root folder.
Executes "setfacl -d -m $acl $path"
'';
default = null;
example = "g:syncthing:rwX";
};
after = lib.mkOption {
type = lib.types.listOf lib.types.str;
description = ''
Order creating this dataset after the mentioned ones.
This only works with datasets managed by this module.
Use the name of the dataset without the pool name.
'';
default = [ ];
example = lib.literalExpression ''
[
"backup"
]
'';
};
backup = lib.mkOption {
description = ''
Backup contract consumer configuration.
This contract will backup the files inside the dataset.
'';
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = if config.owner == null then "root" else config.owner;
sourceDirectories = [
config.path
];
sourceDirectoriesText = ''
[
shb.zfs.pools.<name>.datasets.<name>.path
]
'';
};
};
};
datasetbackup = lib.mkOption {
description = ''
ZFS dataset backup contract configuration.
This contract will take snaphots of the dataset.
'';
type = lib.types.submodule {
options = shb.contracts.datasetbackup.mkRequester {
dataset = name;
};
};
};
};
}
)
);
}; };
}; };
} }
); );
}; };
snapshotBeforeActivation = {
enable = lib.mkOption {
type = lib.types.bool;
description = "Take a snapshot of all datasets before activation";
default = false;
};
template = lib.mkOption {
type = lib.types.str;
description = "Bash command to generate the snapshot name. $1 is the path to the new generation.";
default = ''pre-$(date --utc '+%y%m%dT%H%M%S')-$(basename "$1")'';
};
datasets = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
description = ''
Defines all datasets to take a snapshot of.
If set to `null`, the default, take the list of datasets from `config.boot.zfs.extraPools`.
The snapshots are not recursive unless specificed in the `recursive` option.
'';
};
recursive = lib.mkOption {
type = lib.types.bool;
description = "If true, take snapshots recurisevly on the given datasets.";
default = false;
};
};
}; };
# The implementation is greatly inspired by https://discourse.nixos.org/t/configure-zfs-filesystems-after-install/48633/3
config = { config = {
assertions = [ boot.zfs.extraPools = lib.uniqueStrings (builtins.attrNames cfg.pools);
{
assertion =
lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets)
-> cfg.defaultPoolName != null;
message = "Cannot have both datasets.poolName and defaultPoolName set to null";
}
];
system.activationScripts = lib.mapAttrs' ( systemd.services =
name: cfg':
let let
dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + "/" + name; mkPool =
in poolName: poolCfg: lib.listToAttrs (lib.mapAttrsToList (mkDataset poolName) poolCfg.datasets);
lib.attrsets.nameValuePair "zfsCreate-${name}" {
text = ''
${pkgs.zfs}/bin/zfs list ${dataset} > /dev/null 2>&1 \
|| ${pkgs.zfs}/bin/zfs create \
-o mountpoint=none \
${dataset} || :
[ "$(${pkgs.zfs}/bin/zfs get -H mountpoint -o value ${dataset})" = ${cfg'.path} ] \ mkDataset =
|| ${pkgs.zfs}/bin/zfs set \ poolName: name: cfg':
mountpoint=${cfg'.path} \ let
${dataset} dataset = poolName + "/" + name;
''; in
} lib.attrsets.nameValuePair "zfs-create-${utils.escapeSystemdPath dataset}" {
) cfg.datasets; # oneshot is used to make the systemd service wait on completion of the script.
serviceConfig.Type = "oneshot";
unitConfig.DefaultDependencies = false;
requiredBy = [ "local-fs.target" ];
before = [ "local-fs.target" ];
after = [
"zfs-import-${poolName}.service"
"zfs-mount.service"
]
++ map (n: "zfs-create-${poolName}-${n}.service") cfg'.after;
unitConfig.ConditionPathIsMountPoint = lib.mkIf (cfg'.path != "none") "!${cfg'.path}";
script = ''
${pkgs.zfs}/bin/zfs list ${dataset} > /dev/null 2>&1 \
|| ${pkgs.zfs}/bin/zfs create \
-o mountpoint=none \
${dataset} || :
[ "$(${pkgs.zfs}/bin/zfs get -H mountpoint -o value ${dataset})" = ${cfg'.path} ] \
|| ${pkgs.zfs}/bin/zfs set \
mountpoint="${cfg'.path}" \
${dataset}
''
+ lib.optionalString (cfg'.path != "none" && cfg'.mode != null) ''
chmod "${cfg'.mode}" "${cfg'.path}"
''
+ lib.optionalString (cfg'.path != "none" && cfg'.owner != null) ''
chown "${cfg'.owner}" "${cfg'.path}"
''
+ lib.optionalString (cfg'.path != "none" && cfg'.group != null) ''
chown :"${cfg'.group}" "${cfg'.path}"
''
+ lib.optionalString (cfg'.path != "none" && cfg'.defaultACLs != null) ''
${pkgs.acl}/bin/setfacl -d -m "${cfg'.defaultACLs}" "${cfg'.path}"
'';
};
mergeAttrs = lib.foldl lib.mergeAttrs { };
in
mergeAttrs (lib.mapAttrsToList mkPool cfg.pools);
system.preSwitchChecks."shb-zfs-snapshot" = lib.mkIf cfg.snapshotBeforeActivation.enable (
''
name="${cfg.snapshotBeforeActivation.template}"
''
+ (
let
recursiveFlag = lib.optionalString cfg.snapshotBeforeActivation.recursive "-r";
in
lib.concatMapStringsSep "\n" (ds: ''
echo "Taking ZFS snapshot of ${ds}@$name"
zfs snapshot ${recursiveFlag} ${ds}@"$name"
'') (lib.uniqueStrings datasets)
)
);
}; };
} }

View file

@ -0,0 +1,138 @@
# ZFS Block {#blocks-zfs}
Defined in [`/modules/blocks/zfs.nix`](@REPO@/modules/blocks/zfs.nix):
```nix
{
inputs,
...
}:
{
imports = [
inputs.selfhostblocks.nixosModules.zfs
];
}
```
This block creates ZFS datasets, optionally mounts them and sets permissions on the mount point.
It also enables taking snapshots on activation.
## Features {#blocks-zfs-features}
- Creates ZFS dataset which is [optionally mounted](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.path).
- Sets permissions, [owner](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.owner), [group](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.group) and [ACL](#blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.defaultACLs) on the mount point.
- Backup of the files in the dataset [`shb.zfs.<name>.backup`][backup] through the [backup contract](./contracts-backup.html).
- Backup of the dataset itself [`shb.zfs.<name>.datasetbackup`][datasetbackup] through the [dataset backup contract](./contracts-datasetbackup.html).
- Take a snapshot [before activating or deploying][activationsnapshot] allowing a safe rollback.
[backup]: #blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.backup
[datasetbackup]: #blocks-zfs-options-shb.zfs.pools._name_.datasets._name_.datasetbackup
[activationsnapshot]: #blocks-zfs-options-shb.zfs.snapshotBeforeActivation.enable
## Usage {#blocks-zfs-usage}
Create a dataset at `root/safe/users` mounted on `/var/lib/nixos`:
```nix
shb.zfs.pools.root.datasets."safe/users".path = "/var/lib/nixos";
```
Create a dataset at `backup/syncoid` but do not mount it:
```nix
shb.zfs.pools.backup.datasets."syncoid".path = "none";
```
Create a dataset at `root/syncthing` and set custom permissions and ACL.
Permission and ACL are only enforced for the mount point.
```nix
shb.zfs.pools.root.datasets."syncthing" = {
path = "/srv/syncthing";
mode = "ug=rwx,g+s,o=";
owner = "syncthing";
group = "syncthing";
defaultACLs = "g:syncthing:rwX";
};
```
### Backup dataset {#blocks-zfs-usage-backup-dataset}
To backup the dataset directly, use the [dataset backup contract](contracts-datasetbackup.html).
For example, with the sanoid module as the dataset backup contract provider:
```nix
{
shb.zfs.pools.root.datasets.home = {
path = "/home";
};
shb.sanoid.backup."root/home" = {
request = shb.zfs.pools.root.datasets.home.datasetBackup.request;
template = "yearly";
};
}
```
See the [Sanoid block](blocks-sanoid.html) for more examples.
### Backup files {#blocks-zfs-usage-backup-files}
To backup the files in the dataset, use the [file backup contract](contracts-backup.html).
For example, with the restic module as the file backup contract provider:
```nix
{
shb.zfs.pools.root.datasets.home = {
path = "/home";
};
shb.restic.instances."myservice" = {
request = shb.zfs.pools.root.datasets.home.backup.request;
};
}
```
See the [Restic block](blocks-restic.html) for more examples.
### Snapshot before activation {#blocks-zfs-usage-snapshot-before-activation}
To take a snapshot of all datasets before activating a new configuration:
```nix
{
shb.zfs.snapshotBeforeActivation.enable = true;
}
```
This does not take a recursive snapshot by default, to do it recursively, do:
```nix
{
shb.zfs.snapshotBeforeActivation = {
enable = true;
recursive = true;
};
}
```
We did not specify datasets manually, which means the datasets list comes from `boot.zfs.extraPools`.
To specify the datasets manually, you can use:
```nix
{
shb.zfs.snapshotBeforeActivation = {
enable = true;
datasets = [ "root" "root/var" ];
};
}
```
## Options Reference {#blocks-zfs-options}
```{=include=} options
id-prefix: blocks-zfs-options-
list-id: selfhostblocks-block-zfs-options
source: @OPTIONS_JSON@
```

View file

@ -1,4 +1,9 @@
{ lib, shb, ... }: {
lib,
shb,
pkgs,
...
}:
let let
inherit (lib) inherit (lib)
concatStringsSep concatStringsSep
@ -187,12 +192,14 @@ in
```bash ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots
<snapshot 1> <metadata>
<snapshot 2> <metadata>
``` ```
And restore the database with: And restore the database with:
```bash ```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest $ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore <snapshot 1>
``` ```
''; '';
type = str; type = str;
@ -222,4 +229,56 @@ in
}; };
}; };
}; };
passthru.mkRestoreScript =
{
name,
user,
sudoPreserveEnvs ? [ ],
secretsFile,
restoreCmd,
listCmd,
}:
pkgs.writeShellApplication {
inherit name;
text = ''
usage() {
echo "$0 snapshots"
echo "$0 restore <snapshot>"
}
sudocmd() {
sudo --preserve-env=${lib.concatStringsSep "," sudoPreserveEnvs} -u ${user} "$@"
}
loadenv() {
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "${secretsFile}")
set +a
}
if [ "$1" = "restore" ]; then
shift
snapshot="$1"
loadenv
echo "Will restore snapshot '$snapshot'"
sudocmd sh -c "${restoreCmd}"
elif [ "$1" = "snapshots" ]; then
shift
loadenv
sudocmd sh -c "${listCmd}"
else
usage
exit 1
fi
'';
};
} }

View file

@ -20,25 +20,22 @@ source: @OPTIONS_JSON@
## Usage {#contract-backup-usage} ## Usage {#contract-backup-usage}
A service that can be backed up will provide a `backup` option. A service that can be backed up will provide a `backup` option.
Such a service is a `requester` providing a `request` for a module `provider` of this contract.
What this option defines is, from the user perspective - that is _you_ - an implementation detail Here is an example module defining such a `backup` option,
but it will at least define what directories to backup, which defines what directories to backup (`sourceDirectories`)
the user to backup with and the user to backup with (`user`).
and possibly hooks to run before or after the backup job runs.
Here is an example module defining such a `backup` option:
```nix ```nix
{ {
options = { options = {
myservice.backup = mkOption { myservice.backup = mkOption {
type = contracts.backup.request; type = lib.types.submodule {
default = { options = shb.contracts.backup.mkRequester {
user = "myservice"; user = "nextcloud";
sourceDirectories = [ sourceDirectories = [
"/var/lib/myservice" "/var/lib/nextcloud"
]; ];
};
}; };
}; };
}; };
@ -48,13 +45,13 @@ Here is an example module defining such a `backup` option:
Now, on the other side we have a service that uses this `backup` option and actually backs up files. Now, on the other side we have a service that uses this `backup` option and actually backs up files.
This service is a `provider` of this contract and will provide a `result` option. This service is a `provider` of this contract and will provide a `result` option.
Let's assume such a module is available under the `backupservice` option Let's assume such a module is available under the `backupService` option
and that one can create multiple backup instances under `backupservice.instances`. and that one can create multiple backup instances under `backupService.instances`.
Then, to actually backup the `myservice` service, one would write: Then, to actually backup the `myservice` service, one would write:
```nix ```nix
backupservice.instances.myservice = { backupService.instances.myservice = {
request = myservice.backup; request = myservice.backup.request;
settings = { settings = {
enable = true; enable = true;
@ -63,17 +60,17 @@ backupservice.instances.myservice = {
path = "/srv/backup/myservice"; path = "/srv/backup/myservice";
}; };
# ... Other options specific to backupservice like scheduling. # ... Other options specific to backupService like scheduling.
}; };
}; };
``` ```
It is advised to backup files to different location, to improve redundancy. It is advised to backup files to different location, to improve redundancy.
Thanks to using contracts, this can be made easily either with the same `backupservice`: Thanks to using contracts, this can be made easily either with the same `backupService`:
```nix ```nix
backupservice.instances.myservice_2 = { backupService.instances.myservice_2 = {
request = myservice.backup; request = myservice.backup.request;
settings = { settings = {
enable = true; enable = true;
@ -85,12 +82,13 @@ backupservice.instances.myservice_2 = {
}; };
``` ```
Or with another module `backupservice_2`! Or with another module `backupService_2`!
## Providers of the Backup Contract {#contract-backup-providers} ## Providers of the Backup Contract {#contract-backup-providers}
- [Restic block](blocks-restic.html). - [Restic block](blocks-restic.html).
- [Borgbackup block](blocks-borgbackup.html) [WIP]. - [Borgbackup block](blocks-borgbackup.html).
- [ZFS block](blocks-zfs.html).
## Requester Blocks and Services {#contract-backup-requesters} ## Requester Blocks and Services {#contract-backup-requesters}

View file

@ -21,8 +21,8 @@ in
"/opt/files/A" "/opt/files/A"
"/opt/files/B" "/opt/files/B"
], ],
settings, # { repository, config } -> attrset settings ? { ... }: { }, # { filesRoot, config } -> attrset
extraConfig ? null, # { username, config } -> attrset extraConfig ? null, # { filesRoot, username, config } -> attrset
}: }:
shb.test.runNixOSTest { shb.test.runNixOSTest {
inherit name; inherit name;
@ -40,7 +40,7 @@ shb.test.runNixOSTest {
}; };
settings = settings { settings = settings {
inherit config; inherit config;
repository = "/opt/repos/${name}"; filesRoot = "/opt/files";
}; };
}) })
(mkIf (username != "root") { (mkIf (username != "root") {
@ -52,6 +52,7 @@ shb.test.runNixOSTest {
}) })
(optionalAttrs (extraConfig != null) (extraConfig { (optionalAttrs (extraConfig != null) (extraConfig {
inherit username config; inherit username config;
filesRoot = "/opt/files";
})) }))
]; ];
}; };
@ -65,7 +66,9 @@ shb.test.runNixOSTest {
provider = (getAttrFromPath providerRoot nodes.machine).result; provider = (getAttrFromPath providerRoot nodes.machine).result;
in in
'' ''
from datetime import datetime, timedelta
from dictdiffer import diff from dictdiffer import diff
import re
username = "${username}" username = "${username}"
sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ] sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ]
@ -103,9 +106,25 @@ shb.test.runNixOSTest {
f'{path}/fileB': 'repo_fileB_1', f'{path}/fileB': 'repo_fileB_1',
}) })
with subtest("Initial snapshot"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
if len(out) != 0:
raise Exception(f"Unexpected snapshots:\n{out}")
with subtest("First backup in repo"): with subtest("First backup in repo"):
print(machine.succeed("systemctl cat ${provider.backupService}")) print(machine.succeed("systemctl cat ${provider.backupService}"))
machine.succeed("systemctl start ${provider.backupService}") machine.succeed("systemctl start --wait ${provider.backupService}")
with subtest("One snapshot"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 1:
raise Exception(f"Unexpected snapshots:\n{out}")
# To accomodate for snapshot orchestrators which keep only a given amount
# of snapshots per unit of time, we set the time to now + 2h.
new_date = (datetime.now() + timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
machine.succeed(f"timedatectl set-time '{new_date}'")
with subtest("New content"): with subtest("New content"):
for path in sourceDirectories: for path in sourceDirectories:
@ -119,14 +138,37 @@ shb.test.runNixOSTest {
f'{path}/fileB': 'repo_fileB_2', f'{path}/fileB': 'repo_fileB_2',
}) })
with subtest("Second backup in repo"):
machine.succeed("systemctl start --wait ${provider.backupService}")
with subtest("two snapshots"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 2:
raise Exception(f"Unexpected snapshots:\n{out}")
firstSnapshot = re.split("[ \t+]", out[0], maxsplit=1)[0]
secondSnapshot = re.split("[ \t+]", out[1], maxsplit=1)[0]
print(f"First snapshot {firstSnapshot}")
print(f"Second snapshot {secondSnapshot}")
with subtest("Delete content"): with subtest("Delete content"):
for path in sourceDirectories: for path in sourceDirectories:
machine.succeed(f"""rm -r {path}/*""") machine.succeed(f"""rm -r {path}/*""")
assert_files(path, {}) assert_files(path, {})
with subtest("Restore initial content from repo"): with subtest("Restore second backup"):
machine.succeed("""${provider.restoreScript} restore latest""") machine.succeed(f"${provider.restoreScript} restore {secondSnapshot}")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_2',
f'{path}/fileB': 'repo_fileB_2',
})
with subtest("Restore first backup"):
machine.succeed(f"${provider.restoreScript} restore {firstSnapshot}")
for path in sourceDirectories: for path in sourceDirectories:
assert_files(path, { assert_files(path, {

View file

@ -0,0 +1,77 @@
{ lib, ... }:
let
inherit (lib) mkOption;
inherit (lib.types)
nullOr
submodule
str
;
in
{
mkRequest =
{
serviceName ? "",
externalUrl ? "",
externalUrlText ? null,
internalUrl ? null,
internalUrlText ? null,
apiKey ? null,
}:
mkOption {
description = ''
Request part of the dashboard contract.
'';
default = { };
type = submodule {
options = {
externalUrl =
mkOption {
description = ''
URL at which the service can be accessed.
This URL should go through the reverse proxy.
'';
type = str;
default = externalUrl;
example = "https://jellyfin.example.com";
}
// (lib.optionalAttrs (externalUrlText != null) {
defaultText = externalUrlText;
});
internalUrl =
mkOption {
description = ''
URL at which the service can be accessed directly.
This URL should bypass the reverse proxy.
It can be used for example to ping the service
and making sure it is up and running correctly.
'';
type = nullOr str;
default = internalUrl;
example = "http://127.0.0.1:8081";
}
// (lib.optionalAttrs (internalUrlText != null) {
defaultText = internalUrlText;
});
};
};
};
mkResult =
{
}:
mkOption {
description = ''
Result part of the dashboard contract.
No option is provided here.
'';
default = { };
type = submodule {
options = {
};
};
};
}

View file

@ -0,0 +1,65 @@
# Dashboard Contract {#contract-dashboard}
This NixOS contract is used for user-facing services
that want to be displayed on a dashboard.
It is a contract between a service that can be accessed through an URL
and a service that wants to show a list of those services.
## Providers {#contract-dashboard-providers}
The providers of this contract in SHB are:
<!-- Somehow generate this list -->
- The homepage service under its [shb.homepage.servicesGroups.<name>.services.<name>.dashboard](#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard) option.
## Usage {#contracts-dashboard-usage}
A service that can be shown on a dashboard will provide a `dashboard` option.
Here is an example module defining such a requester option for this dashboard contract:
```nix
{
options = {
myservice.dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${config.myservice.subdomain}.${config.myservice.domain}";
internalUrl = "http://127.0.0.1:${config.myservice.port}";
};
};
};
};
};
```
Then, plug both consumer and provider together in the `config`:
```nix
{
config = {
<provider-module> = {
dashboard.request = config.myservice.dashboard.request;
};
};
}
```
And that's it for the contract part.
For more specific details on each provider, go to their respective manual pages.
## Contract Reference {#contract-dashboard-options}
These are all the options that are expected to exist for this contract to be respected.
```{=include=} options
id-prefix: contracts-dashboard-options-
list-id: selfhostblocks-options
source: @OPTIONS_JSON@
```

View file

@ -0,0 +1,30 @@
{ lib, shb, ... }:
let
inherit (lib) mkOption;
inherit (lib.types) submodule;
in
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.dashboard = mkOption {
description = ''
Contract for user-facing services that want to
be displayed on a dashboard.
The requester communicates to the provider
how to access the service
through the `request` options.
The provider reads from the `request` options
and configures what is necessary on its side
to show the service and check its availability.
It does not communicate back to the requester.
'';
type = submodule {
options = shb.contracts.dashboard.contract;
};
};
}

View file

@ -20,21 +20,21 @@ source: @OPTIONS_JSON@
## Usage {#contract-databasebackup-usage} ## Usage {#contract-databasebackup-usage}
A database that can be backed up will provide a `databasebackup` option. A database that can be backed up will provide a `databasebackup` option.
Such a service is a `requester` providing a `request` for a module `provider` of this contract.
What this option defines is, from the user perspective - that is _you_ - an implementation detail What this option defines is, from the user perspective - that is _you_ - an implementation detail
but it will at least define how to create a database dump, but it will at least define how to create a database dump,
the user to backup with the user to backup with
and how to restore from a database dump. and how to restore from a database dump.
Here is an example module defining such a `databasebackup` option: Here is an example module defining such a `databasebackup` option,
which defines the user to backup with (`user`)
and how to backup (`backupCmd`) and restore (`restoreCmd`) the database:
```nix ```nix
{ {
options = { options = {
myservice.databasebackup = mkOption { myservice.databasebackup = mkOption {
type = contracts.databasebackup.request; type = shb.contracts.databasebackup.mkRequester = {
default = {
user = "myservice"; user = "myservice";
backupCmd = '' backupCmd = ''
${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable
@ -48,16 +48,16 @@ Here is an example module defining such a `databasebackup` option:
}; };
``` ```
Now, on the other side we have a service that uses this `backup` option and actually backs up files. Now, on the other side we have a service that uses this `databasebackup` option and actually backs up files.
This service is a `provider` of this contract and will provide a `result` option. This service is a `provider` of this contract and will provide a `result` option.
Let's assume such a module is available under the `databasebackupservice` option Let's assume such a module is available under the `databaseBackupService` option
and that one can create multiple backup instances under `databasebackupservice.instances`. and that one can create multiple backup instances under `databaseBackupService.instances`.
Then, to actually backup the `myservice` service, one would write: Then, to actually backup the `myservice` service, one would write:
```nix ```nix
databasebackupservice.instances.myservice = { databaseBackupService.instances.myservice = {
request = myservice.databasebackup; request = myservice.databasebackup.request;
settings = { settings = {
enable = true; enable = true;
@ -72,11 +72,11 @@ databasebackupservice.instances.myservice = {
``` ```
It is advised to backup files to different location, to improve redundancy. It is advised to backup files to different location, to improve redundancy.
Thanks to using contracts, this can be made easily either with the same `databasebackupservice`: Thanks to using contracts, this can be made easily either with the same `databaseBackupService`:
```nix ```nix
databasebackupservice.instances.myservice_2 = { databaseBackupService.instances.myservice_2 = {
request = myservice.backup; request = myservice.databasebackup.request;
settings = { settings = {
enable = true; enable = true;
@ -88,12 +88,12 @@ databasebackupservice.instances.myservice_2 = {
}; };
``` ```
Or with another module `databasebackupservice_2`! Or with another module `databaseBackupService_2`!
## Providers of the Database Backup Contract {#contract-databasebackup-providers} ## Providers of the Database Backup Contract {#contract-databasebackup-providers}
- [Restic block](blocks-restic.html). - [Restic block](blocks-restic.html).
- [Borgbackup block](blocks-borgbackup.html) [WIP]. - [Borgbackup block](blocks-borgbackup.html).
## Requester Blocks and Services {#contract-databasebackup-requesters} ## Requester Blocks and Services {#contract-databasebackup-requesters}

View file

@ -51,16 +51,18 @@ shb.test.runNixOSTest {
testScript = testScript =
{ nodes, ... }: { nodes, ... }:
let let
provider = getAttrFromPath providerRoot nodes.machine; provider = (getAttrFromPath providerRoot nodes.machine).result;
in in
'' ''
import csv import csv
import re
start_all() start_all()
machine.wait_for_unit("postgresql.service") machine.wait_for_unit("postgresql.service")
machine.wait_for_unit("postgresql-setup.service")
machine.wait_for_open_port(5432) machine.wait_for_open_port(5432)
def peer_cmd(cmd, db="me"): def peer_cmd(cmd, db="${database}"):
return "sudo -u ${database} psql -U ${database} {db} --csv --command \"{cmd}\"".format(cmd=cmd, db=db) return "sudo -u ${database} psql -U ${database} {db} --csv --command \"{cmd}\"".format(cmd=cmd, db=db)
def query(query): def query(query):
@ -73,30 +75,68 @@ shb.test.runNixOSTest {
if len(diff) > 0: if len(diff) > 0:
raise Exception(i, diff) raise Exception(i, diff)
table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]
with subtest("create fixture"): with subtest("create fixture"):
machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)")) machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)"))
machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1), ('lollipop', 2)")) machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1)"))
res = query("SELECT * FROM test") res = query("SELECT * FROM test")
table = [{'name': 'car', 'count': '1'}]
cmp_tables(res, table)
with subtest("Initial snapshots"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 0:
raise Exception(f"Unexpected snapshots:\n{out}")
with subtest("backup"):
machine.succeed("systemctl start --wait ${provider.backupService}")
with subtest("One snapshot"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 1:
raise Exception(f"Unexpected snapshots:\n{out}")
with subtest("New content"):
machine.succeed(peer_cmd("INSERT INTO test VALUES ('lollipop', 2)"))
res = query("SELECT * FROM test")
table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]
cmp_tables(res, table) cmp_tables(res, table)
with subtest("backup"): with subtest("backup"):
print(machine.succeed("systemctl cat ${provider.result.backupService}")) machine.succeed("systemctl start --wait ${provider.backupService}")
print(machine.succeed("ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase"))
machine.succeed("systemctl start ${provider.result.backupService}") with subtest("Two snapshots"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 2:
raise Exception(f"Unexpected snapshots:\n{out}")
firstSnapshot = re.split("[ \t+]", out[0], maxsplit=1)[0]
secondSnapshot = re.split("[ \t+]", out[1], maxsplit=1)[0]
print(f"First snapshot {firstSnapshot}")
print(f"Second snapshot {secondSnapshot}")
with subtest("drop database"): with subtest("drop database"):
machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres")) machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres"))
machine.fail(peer_cmd("SELECT * FROM test")) machine.fail(peer_cmd("SELECT * FROM test"))
with subtest("restore"): with subtest("restore second snapshot"):
print(machine.succeed("readlink -f $(type ${provider.result.restoreScript})")) print(machine.succeed("readlink -f $(type ${provider.restoreScript})"))
machine.succeed("${provider.result.restoreScript} restore latest ") machine.succeed(f"${provider.restoreScript} restore {secondSnapshot}")
with subtest("check restoration"):
res = query("SELECT * FROM test") res = query("SELECT * FROM test")
table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]
cmp_tables(res, table)
with subtest("restore first snapshot"):
print(machine.succeed("readlink -f $(type ${provider.restoreScript})"))
machine.succeed(f"${provider.restoreScript} restore {firstSnapshot}")
res = query("SELECT * FROM test")
table = [{'name': 'car', 'count': '1'}]
cmp_tables(res, table) cmp_tables(res, table)
''; '';
} }

View file

@ -0,0 +1,113 @@
{ lib, ... }:
let
inherit (lib)
literalMD
mkOption
optionalAttrs
;
inherit (lib.types)
submodule
str
;
in
{
mkRequest =
{
dataset ? "",
datasetText ? null,
}:
mkOption {
description = ''
Request part of the backup contract.
Options set by the requester module
enforcing how to backup files.
'';
default = { };
type = submodule {
options = {
dataset =
mkOption {
description = ''
Dataset to backup, including the pool name.
'';
type = str;
example = "root/home";
default = dataset;
}
// optionalAttrs (datasetText != null) {
defaultText = literalMD datasetText;
};
};
};
};
mkResult =
{
restoreScript ? "restore",
restoreScriptText ? null,
backupService ? "backup.service",
backupServiceText ? null,
}:
mkOption {
description = ''
Result part of the backup contract.
Options set by the provider module that indicates the name of the backup and restore scripts.
'';
default = { };
type = submodule {
options = {
restoreScript =
mkOption {
description = ''
Name of script that can restore the database.
One can then list snapshots with:
```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots
<snapshot 1> <metadata>
<snapshot 2> <metadata>
```
And restore the database with:
```bash
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore <snapshot 1>
```
It is not garanteed to be able to restore back to a snapshot in the future.
With the above example, it may not be possible to restore `<snapshot 2>`
after having restored `<snapshot 1>`.
'';
type = str;
default = restoreScript;
}
// optionalAttrs (restoreScriptText != null) {
defaultText = literalMD restoreScriptText;
};
backupService =
mkOption {
description = ''
Name of service backing up the database.
This script can be ran manually to backup the database:
```bash
$ systemctl start ${if backupServiceText != null then backupServiceText else backupService}
```
'';
type = str;
default = backupService;
}
// optionalAttrs (backupServiceText != null) {
defaultText = literalMD backupServiceText;
};
};
};
};
}

View file

@ -0,0 +1,85 @@
# Dataset Backup Contract {#contract-dataset-backup}
This NixOS contract represents a backup job
that will backup one ZFS dataset.
It is a contract between a service that manages ZFS datasets
and a service that backs up ZFS datasets.
## Contract Reference {#contract-datatsetbackup-options}
These are all the options that are expected to exist for this contract to be respected.
```{=include=} options
id-prefix: contracts-datasetbackup-options-
list-id: selfhostblocks-options
source: @OPTIONS_JSON@
```
## Usage {#contract-datasetbackup-usage}
A service that manages ZFS datasets that can be backed up will provide a `datasetbackup` option.
Here is an example module defining such a `backup` option,
which defines what directories to backup (`sourceDirectories`)
and the user to backup with (`user`).
```nix
{
options = {
myservice.datasetbackup = mkOption {
type = lib.types.submodule {
options = shb.contracts.datasetbackup.mkRequester {
dataset = "root/test";
};
};
};
};
};
```
Now, on the other side we have a service that uses this `datasetbackup` option and actually backs up the dataset.
This service is a `provider` of this contract and will provide a `result` option.
Let's assume such a module is available under the `backupService` option
and that one can create multiple backup instances under `backupService.instances`.
Then, to actually backup the `myservice` service, one would write:
```nix
backupService.instances.myservice = {
request = myservice.datasetbackup.request;
settings = {
enable = true;
targetDataset = "backup/myservice";
# ... Other options specific to backupService like scheduling.
};
};
```
It is advised to backup files to different location, to improve redundancy.
Thanks to using contracts, this can be made easily either with the same `backupService`:
```nix
backupService.instances.myservice_2 = {
request = myservice.datasetbackup.request;
settings = {
enable = true;
targetDataset = "backup2/myservice";
};
};
```
Or with another module `backupService_2`!
## Providers of the Backup Contract {#contract-datasetbackup-providers}
- [Sanoid block](blocks-sanoid.html).
## Requester Blocks and Services {#contract-datasetbackup-requesters}
- [ZFS](blocks-zfs.html#blocks-zfs-backup).

View file

@ -0,0 +1,30 @@
{ lib, shb, ... }:
let
inherit (lib) mkOption;
inherit (lib.types) submodule;
in
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.datasetbackup = mkOption {
description = ''
Contract for backing up ZFS datasets.
The requester communicates to the provider
the dataset to backup
through the `request` options.
The provider reads from the `request` options
and backs up the requested dataset.
It communicates to the requester what script is used
to backup and restore the files
through the `result` options.
'';
type = submodule {
options = shb.contracts.datasetbackup.contract;
};
};
}

View file

@ -0,0 +1,164 @@
{
pkgs,
lib,
shb,
}:
let
inherit (lib)
concatMapStringsSep
getAttrFromPath
setAttrByPath
;
in
{
name,
providerRoot,
modules ? [ ],
dataset ? "root/test",
sourceDirectories ? [
"/opt/files/A"
"/opt/files/B"
],
settings ? { ... }: { }, # { filesRoot, config } -> attrset
extraConfig ? null, # { filesRoot, config } -> attrset
}:
shb.test.runNixOSTest {
inherit name;
nodes.machine =
{ config, ... }:
{
imports = [ shb.test.baseImports ] ++ modules;
config = lib.mkMerge [
(setAttrByPath providerRoot {
request = {
inherit dataset;
};
settings = settings {
inherit config;
filesRoot = "/opt/files";
};
})
(lib.optionalAttrs (extraConfig != null) (extraConfig {
inherit config;
filesRoot = "/opt/files";
}))
];
};
extraPythonPackages = p: [ p.dictdiffer ];
skipTypeCheck = true;
testScript =
{ nodes, ... }:
let
provider = (getAttrFromPath providerRoot nodes.machine).result;
in
''
from datetime import datetime, timedelta
from dictdiffer import diff
import re
sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ]
def list_files(dir):
files_and_content = {}
files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1]
for f in files:
content = machine.succeed(f"""cat {f}""").strip()
files_and_content[f] = content
return files_and_content
def assert_files(dir, files):
result = list(diff(list_files(dir), files))
if len(result) > 0:
raise Exception("Unexpected files:", result)
with subtest("Create initial content"):
for path in sourceDirectories:
machine.succeed(f"""
mkdir -p {path}
echo repo_fileA_1 > {path}/fileA
echo repo_fileB_1 > {path}/fileB
""")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})
with subtest("Initial snapshot"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
if len(out) != 0:
raise Exception(f"Unexpected snapshots:\n{out}")
with subtest("First backup in repo"):
machine.succeed("systemctl start --wait ${provider.backupService}")
with subtest("One snapshot"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 1:
raise Exception(f"Unexpected snapshots:\n{out}")
# To accomodate for snapshot orchestrators which keep only a given amount
# of snapshots per unit of time, we set the time to now + 2h.
new_date = (datetime.now() + timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
machine.succeed(f"timedatectl set-time '{new_date}'")
with subtest("New content"):
for path in sourceDirectories:
machine.succeed(f"""
echo repo_fileA_2 > {path}/fileA
echo repo_fileB_2 > {path}/fileB
""")
assert_files(path, {
f'{path}/fileA': 'repo_fileA_2',
f'{path}/fileB': 'repo_fileB_2',
})
with subtest("Second backup in repo"):
machine.succeed("systemctl start --wait ${provider.backupService}")
with subtest("two snapshots"):
out = machine.succeed("${provider.restoreScript} snapshots").splitlines()
print(f"Found snapshots:\n{out}")
if len(out) != 2:
raise Exception(f"Unexpected snapshots:\n{out}")
firstSnapshot = re.split("[ \t+]", out[0], maxsplit=1)[0]
secondSnapshot = re.split("[ \t+]", out[1], maxsplit=1)[0]
print(f"First snapshot {firstSnapshot}")
print(f"Second snapshot {secondSnapshot}")
with subtest("Delete content"):
for path in sourceDirectories:
machine.succeed(f"""rm -r {path}/*""")
assert_files(path, {})
with subtest("Restore second backup"):
machine.succeed(f"${provider.restoreScript} restore {secondSnapshot}")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_2',
f'{path}/fileB': 'repo_fileB_2',
})
with subtest("Restore first backup"):
machine.succeed(f"${provider.restoreScript} restore {firstSnapshot}")
for path in sourceDirectories:
assert_files(path, {
f'{path}/fileA': 'repo_fileA_1',
f'{path}/fileB': 'repo_fileB_1',
})
'';
}

View file

@ -48,21 +48,31 @@ let
importContract = importContract =
module: module:
let let
importedModule = pkgs.callPackage module { inherit shb; }; importedModule = pkgs.callPackage module {
shb = shb // {
inherit contracts;
};
};
in in
mkContractFunctions { (mkContractFunctions {
inherit (importedModule) mkRequest mkResult; inherit (importedModule) mkRequest mkResult;
})
// (importedModule.passthru or { });
contracts = {
databasebackup = importContract ./databasebackup.nix;
datasetbackup = importContract ./datasetbackup.nix;
dashboard = importContract ./dashboard.nix;
backup = importContract ./backup.nix;
mount = pkgs.callPackage ./mount.nix { };
secret = importContract ./secret.nix;
ssl = pkgs.callPackage ./ssl.nix { };
test = {
secret = pkgs.callPackage ./secret/test.nix { inherit shb; };
databasebackup = pkgs.callPackage ./databasebackup/test.nix { inherit shb; };
datasetbackup = pkgs.callPackage ./datasetbackup/test.nix { inherit shb; };
backup = pkgs.callPackage ./backup/test.nix { inherit shb; };
}; };
in
{
databasebackup = importContract ./databasebackup.nix;
backup = importContract ./backup.nix;
mount = pkgs.callPackage ./mount.nix { };
secret = importContract ./secret.nix;
ssl = pkgs.callPackage ./ssl.nix { };
test = {
secret = pkgs.callPackage ./secret/test.nix { inherit shb; };
databasebackup = pkgs.callPackage ./databasebackup/test.nix { inherit shb; };
backup = pkgs.callPackage ./backup/test.nix { inherit shb; };
}; };
} in
contracts

View file

@ -42,12 +42,12 @@ Now, with this contract, a layer on top of `sops` is added which is found under
The configuration then becomes: The configuration then becomes:
```nix ```nix
shb.sops.secrets."ldap/user_password" = { shb.sops.secret."ldap/user_password" = {
request = config.shb.lldap.userPassword.request; request = config.shb.lldap.userPassword.request;
settings.sopsFile = ./secrets.yaml; settings.sopsFile = ./secrets.yaml;
}; };
shb.lldap.userPassword.result = config.shb.sops.secrets."ldap/user_password".result; shb.lldap.userPassword.result = config.shb.sops.secret."ldap/user_password".result;
``` ```
The issue is now gone as the responsibility falls The issue is now gone as the responsibility falls
@ -63,9 +63,9 @@ sops.defaultSopsFile = ./secrets.yaml;
Then the snippet above is even more simplified: Then the snippet above is even more simplified:
```nix ```nix
shb.sops.secrets."ldap/user_password".request = config.shb.lldap.userPassword.request; shb.sops.secret."ldap/user_password".request = config.shb.lldap.userPassword.request;
shb.lldap.userPassword.result = config.shb.sops.secrets."ldap/user_password".result; shb.lldap.userPassword.result = config.shb.sops.secret."ldap/user_password".result;
``` ```
## Contract Reference {#contract-secret-options} ## Contract Reference {#contract-secret-options}

View file

@ -144,6 +144,10 @@ let
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption { Port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
description = "Port on which bazarr listens to incoming requests."; description = "Port on which bazarr listens to incoming requests.";
@ -172,6 +176,10 @@ let
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption { Port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
description = "Port on which readarr listens to incoming requests."; description = "Port on which readarr listens to incoming requests.";
@ -199,6 +207,10 @@ let
description = "Log level."; description = "Log level.";
default = "info"; default = "info";
}; };
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption { Port = lib.mkOption {
type = lib.types.port; type = lib.types.port;
description = "Port on which lidarr listens to incoming requests."; description = "Port on which lidarr listens to incoming requests.";
@ -305,7 +317,7 @@ let
{ {
domain = "${c.subdomain}.${c.domain}"; domain = "${c.subdomain}.${c.domain}";
policy = "two_factor"; policy = "two_factor";
subject = [ "group:arr_user" ]; subject = [ "group:${c.ldapUserGroup}" ];
} }
]; ];
}; };
@ -344,6 +356,16 @@ let
default = null; default = null;
}; };
ldapUserGroup = lib.mkOption {
description = ''
LDAP group a user must belong to be able to login.
Note that all users are admins too.
'';
type = lib.types.str;
default = "arr_user";
};
authEndpoint = lib.mkOption { authEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;
@ -370,6 +392,20 @@ let
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.${name}.subdomain}.${cfg.${name}.domain}";
externalUrlText = "https://\${config.shb.arr.${name}.subdomain}.\${config.shb.arr.${name}.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.${name}.settings.Port}";
};
};
};
} }
// (c.moreOptions or { }); // (c.moreOptions or { });
}; };
@ -380,6 +416,7 @@ in
imports = [ imports = [
../../lib/module.nix ../../lib/module.nix
../blocks/nginx.nix ../blocks/nginx.nix
../blocks/lldap.nix
]; ];
options.shb.arr = lib.listToAttrs (lib.mapAttrsToList appOption apps); options.shb.arr = lib.listToAttrs (lib.mapAttrsToList appOption apps);
@ -395,7 +432,7 @@ in
services.radarr = { services.radarr = {
enable = true; enable = true;
dataDir = "/var/lib/radarr"; dataDir = cfg'.dataDir;
}; };
systemd.services.radarr.preStart = shb.replaceSecrets { systemd.services.radarr.preStart = shb.replaceSecrets {
@ -405,11 +442,15 @@ in
AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External"; AuthenticationMethod = "External";
}); });
resultPath = "${config.services.radarr.dataDir}/config.xml"; resultPath = "${cfg'.dataDir}/config.xml";
generator = shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat; generator = shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat;
}; };
shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
@ -419,11 +460,15 @@ in
isSSOEnabled = !(isNull cfg'.authEndpoint); isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
systemd.tmpfiles.rules = [
"d ${cfg'.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.user}"
];
services.nginx.enable = true; services.nginx.enable = true;
services.sonarr = { services.sonarr = {
enable = true; enable = true;
dataDir = "/var/lib/sonarr"; dataDir = cfg'.dataDir;
}; };
users.users.sonarr = { users.users.sonarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
@ -436,11 +481,15 @@ in
AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External"; AuthenticationMethod = "External";
}); });
resultPath = "${config.services.sonarr.dataDir}/config.xml"; resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.sonarr.settingsFormat.generate; generator = apps.sonarr.settingsFormat.generate;
}; };
shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
@ -452,45 +501,64 @@ in
{ {
services.bazarr = { services.bazarr = {
enable = true; enable = true;
dataDir = cfg'.dataDir;
listenPort = cfg'.settings.Port; listenPort = cfg'.settings.Port;
}; };
users.users.bazarr = { users.users.bazarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.bazarr.preStart = shb.replaceSecrets { # This is actually not working. Bazarr uses a config file in dataDir/config/config.yaml
userConfig = # which includes all configuration so we must somehow merge our declarative config with it.
cfg'.settings # It's doable but will take some time. Help is welcomed.
// (lib.optionalAttrs isSSOEnabled { #
AuthenticationRequired = "DisabledForLocalAddresses"; # systemd.services.bazarr.preStart = shb.replaceSecrets {
AuthenticationMethod = "External"; # userConfig =
}); # cfg'.settings
resultPath = "/var/lib/bazarr/config.xml"; # // (lib.optionalAttrs isSSOEnabled {
generator = apps.bazarr.settingsFormat.generate; # AuthenticationRequired = "DisabledForLocalAddresses";
}; # AuthenticationMethod = "External";
# });
# resultPath = "${cfg'.dataDir}/config.xml";
# generator = apps.bazarr.settingsFormat.generate;
# };
shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
(lib.mkIf cfg.readarr.enable ( (lib.mkIf cfg.readarr.enable (
let let
cfg' = cfg.readarr; cfg' = cfg.readarr;
isSSOEnabled = !(isNull cfg'.authEndpoint);
in in
{ {
services.readarr = { services.readarr = {
enable = true; enable = true;
dataDir = "/var/lib/readarr"; dataDir = cfg'.dataDir;
}; };
users.users.readarr = { users.users.readarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
}; };
systemd.services.readarr.preStart = shb.replaceSecrets { systemd.services.readarr.preStart = shb.replaceSecrets {
userConfig = cfg'.settings; userConfig =
resultPath = "${config.services.readarr.dataDir}/config.xml"; cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.readarr.settingsFormat.generate; generator = apps.readarr.settingsFormat.generate;
}; };
shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
@ -502,7 +570,7 @@ in
{ {
services.lidarr = { services.lidarr = {
enable = true; enable = true;
dataDir = "/var/lib/lidarr"; dataDir = cfg'.dataDir;
}; };
users.users.lidarr = { users.users.lidarr = {
extraGroups = [ "media" ]; extraGroups = [ "media" ];
@ -514,11 +582,15 @@ in
AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External"; AuthenticationMethod = "External";
}); });
resultPath = "${config.services.lidarr.dataDir}/config.xml"; resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.lidarr.settingsFormat.generate; generator = apps.lidarr.settingsFormat.generate;
}; };
shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
@ -529,7 +601,7 @@ in
{ {
services.jackett = { services.jackett = {
enable = true; enable = true;
dataDir = "/var/lib/jackett"; dataDir = cfg'.dataDir;
}; };
# TODO: avoid implicitly relying on the media group # TODO: avoid implicitly relying on the media group
users.users.jackett = { users.users.jackett = {
@ -537,7 +609,7 @@ in
}; };
systemd.services.jackett.preStart = shb.replaceSecrets { systemd.services.jackett.preStart = shb.replaceSecrets {
userConfig = shb.renameAttrName cfg'.settings "ApiKey" "APIKey"; userConfig = shb.renameAttrName cfg'.settings "ApiKey" "APIKey";
resultPath = "${config.services.jackett.dataDir}/ServerConfig.json"; resultPath = "${cfg'.dataDir}/ServerConfig.json";
generator = apps.jackett.settingsFormat.generate; generator = apps.jackett.settingsFormat.generate;
}; };
@ -546,6 +618,10 @@ in
extraBypassResources = [ "^/dl.*" ]; extraBypassResources = [ "^/dl.*" ];
} cfg') } cfg')
]; ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
} }
)) ))
]; ];

View file

@ -3,12 +3,242 @@
Defined in [`/modules/services/arr.nix`](@REPO@/modules/services/arr.nix). Defined in [`/modules/services/arr.nix`](@REPO@/modules/services/arr.nix).
This NixOS module sets up multiple [Servarr](https://wiki.servarr.com/) services. This NixOS module sets up multiple [Servarr](https://wiki.servarr.com/) services.
## Features {#services-arr-features}
Compared to the stock module from nixpkgs, Compared to the stock module from nixpkgs,
this one sets up, in a fully declarative manner this one sets up, in a fully declarative manner
LDAP and SSO integration as well as the API key. LDAP and SSO integration as well as the API key.
This manual page is under construction. ## Usage {#services-arr-usage}
### Initial Configuration {#services-arr-usage-configuration}
The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS,
- the [`shb.ssl` block](blocks-ssl.html#usage),
- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).
- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).
```nix
{
shb.certs.certs.letsencrypt.${domain}.extraDomains = [
"moviesdl.${domain}"
"seriesdl.${domain}"
"subtitlesdl.${domain}"
"booksdl.${domain}"
"musicdl.${domain}"
"indexer.${domain}"
];
shb.arr = {
radarr = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt.${domain};
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
sonarr = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt."${domain}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
bazarr = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt."${domain}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
readarr = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt."${domain}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
lidarr = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt."${domain}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
jackett = {
inherit domain;
enable = true;
ssl = config.shb.certs.certs.letsencrypt."${domain}";
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
};
}
```
The user and admin LDAP groups are created automatically.
### API Keys {#services-arr-usage-apikeys}
The API keys for each arr service can be created declaratively.
First, generate one secret for each service with `nix run nixpkgs#openssl -- rand -hex 64`
and store it in your secrets file (for example the SOPS file).
Then, add the API key to each service:
```nix
{
shb.arr = {
radarr = {
settings = {
ApiKey.source = config.shb.sops.secret."radarr/apikey".result.path;
};
};
sonarr = {
settings = {
ApiKey.source = config.shb.sops.secret."sonarr/apikey".result.path;
};
};
bazarr = {
settings = {
ApiKey.source = config.shb.sops.secret."bazarr/apikey".result.path;
};
};
readarr = {
settings = {
ApiKey.source = config.shb.sops.secret."readarr/apikey".result.path;
};
};
lidarr = {
settings = {
ApiKey.source = config.shb.sops.secret."lidarr/apikey".result.path;
};
};
jackett = {
settings = {
ApiKey.source = config.shb.sops.secret."jackett/apikey".result.path;
};
};
};
shb.sops.secret."radarr/apikey".request = {
mode = "0440";
owner = "radarr";
group = "radarr";
restartUnits = [ "radarr.service" ];
};
shb.sops.secret."sonarr/apikey".request = {
mode = "0440";
owner = "sonarr";
group = "sonarr";
restartUnits = [ "sonarr.service" ];
};
shb.sops.secret."bazarr/apikey".request = {
mode = "0440";
owner = "bazarr";
group = "bazarr";
restartUnits = [ "bazarr.service" ];
};
shb.sops.secret."readarr/apikey".request = {
mode = "0440";
owner = "readarr";
group = "readarr";
restartUnits = [ "readarr.service" ];
};
shb.sops.secret."lidarr/apikey".request = {
mode = "0440";
owner = "lidarr";
group = "lidarr";
restartUnits = [ "lidarr.service" ];
};
shb.sops.secret."jackett/apikey".request = {
mode = "0440";
owner = "jackett";
group = "jackett";
restartUnits = [ "jackett.service" ];
};
}
```
### Application Dashboard {#services-arr-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the various dashboard options.
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Media.services.Radarr = {
sortOrder = 10;
dashboard.request = config.shb.arr.radarr.dashboard.request;
apiKey.result = config.shb.sops.secret."radarr/homepageApiKey".result;
};
shb.sops.secret."radarr/homepageApiKey" = {
settings.key = "radarr/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Radarr.apiKey.request;
};
shb.homepage.servicesGroups.Media.services.Sonarr = {
sortOrder = 11;
dashboard.request = config.shb.arr.sonarr.dashboard.request;
apiKey.result = config.shb.sops.secret."sonarr/homepageApiKey".result;
};
shb.sops.secret."sonarr/homepageApiKey" = {
settings.key = "sonarr/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Sonarr.apiKey.request;
};
shb.homepage.servicesGroups.Media.services.Bazarr = {
sortOrder = 12;
dashboard.request = config.shb.arr.bazarr.dashboard.request;
apiKey.result = config.shb.sops.secret."bazarr/homepageApiKey".result;
};
shb.sops.secret."bazarr/homepageApiKey" = {
settings.key = "bazarr/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Bazarr.apiKey.request;
};
shb.homepage.servicesGroups.Media.services.Readarr = {
sortOrder = 13;
dashboard.request = config.shb.arr.readarr.dashboard.request;
apiKey.result = config.shb.sops.secret."readarr/homepageApiKey".result;
};
shb.sops.secret."readarr/homepageApiKey" = {
settings.key = "readarr/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Readarr.apiKey.request;
};
shb.homepage.servicesGroups.Media.services.Lidarr = {
sortOrder = 14;
dashboard.request = config.shb.arr.lidarr.dashboard.request;
apiKey.result = config.shb.sops.secret."lidarr/homepageApiKey".result;
};
shb.sops.secret."lidarr/homepageApiKey" = {
settings.key = "lidarr/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Lidarr.apiKey.request;
};
shb.homepage.servicesGroups.Media.services.Jackett = {
sortOrder = 15;
dashboard.request = config.shb.arr.jackett.dashboard.request;
apiKey.result = config.shb.sops.secret."jackett/homepageApiKey".result;
};
shb.sops.secret."jackett/homepageApiKey" = {
settings.key = "jackett/apikey";
request = config.shb.homepage.servicesGroups.Media.services.Jackett.apiKey.request;
};
}
```
This example reuses the API keys generated declaratively from the previous section.
### Jackett Proxy {#services-arr-usage-jackett-proxy}
The Jackett service can be made to use a proxy with:
```nix
{
shb.arr.jackett = {
settings = {
ProxyType = "0";
ProxyUrl = "127.0.0.1:1234";
};
};
};
```
## Options Reference {#services-arr-options} ## Options Reference {#services-arr-options}

View file

@ -152,6 +152,20 @@ in
default = false; default = false;
example = true; example = true;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.audiobookshelf.subdomain}.\${config.shb.audiobookshelf.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.webPort}";
};
};
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable (

View file

@ -36,7 +36,7 @@ in
options.shb.deluge = { options.shb.deluge = {
enable = lib.mkEnableOption "the SHB Deluge service"; enable = lib.mkEnableOption "the SHB Deluge service";
enableDashboard = lib.mkEnableOption "the Torrents SHB dashboard" // { enableDashboard = lib.mkEnableOption "the Torrents SHB monitoring dashboard" // {
default = true; default = true;
}; };
@ -301,6 +301,20 @@ in
default = null; default = null;
example = "info"; example = "info";
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.deluge.subdomain}.\${config.shb.deluge.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.webPort}";
};
};
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable (

View file

@ -10,6 +10,9 @@ let
in in
{ {
imports = [ imports = [
../blocks/nginx.nix
../blocks/lldap.nix
../../lib/module.nix ../../lib/module.nix
]; ];
@ -293,6 +296,22 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}";
# This works thanks to the Personal Access Token.
internalUrl = "https://${cfg.subdomain}.${cfg.domain}";
internalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}";
};
};
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable (

View file

@ -12,8 +12,14 @@ It also sets up the Firefly-iii data importer service
and nearly automatically links it to the Firefly-iii instance using a Personal Account Token. and nearly automatically links it to the Firefly-iii instance using a Personal Account Token.
Instructions on how to do so is given in the next section. Instructions on how to do so is given in the next section.
## Features {#services-firefly-iii-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-firefly-iii-usage-applicationdashboard)
## Usage {#services-firefly-iii-usage} ## Usage {#services-firefly-iii-usage}
### Initial Configuration {#services-firefly-iii-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -39,7 +45,7 @@ shb.firefly-iii = {
port = 587; port = 587;
username = "postmaster@mg.example.com"; username = "postmaster@mg.example.com";
from_address = "firefly-iii@example.com"; from_address = "firefly-iii@example.com";
password.result = config.shb.sops.secrets."firefly-iii/smtpPassword".result; password.result = config.shb.sops.secret."firefly-iii/smtpPassword".result;
}; };
sso = { sso = {
@ -127,6 +133,38 @@ shb.lldap.ensureUsers.USERNAME.groups = [
]; ];
``` ```
### Application Dashboard {#services-firefly-iii-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-firefly-iii-options-shb.firefly-iii.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Finance.services.Firefly-iii = {
sortOrder = 1;
dashboard.request = config.shb.firefly-iii.dashboard.request;
settings.widget.type = "firefly";
};
}
```
The widget type needs to be set manually otherwise it is not displayed correctly.
An API key can be set to show extra info:
```nix
{
shb.homepage.servicesGroups.Finance.services.Firefly-iii = {
apiKey.result = config.shb.sops.secret."firefly-iii/homepageApiKey".result;
};
shb.sops.secret."firefly-iii/homepageApiKey".request =
config.shb.homepage.servicesGroups.Finance.services.Firefly-iii.apiKey.request;
}
```
## Database Inspection {#services-firefly-iii-database-inspection} ## Database Inspection {#services-firefly-iii-database-inspection}
Access the database with: Access the database with:

View file

@ -44,16 +44,18 @@ in
{ {
imports = [ imports = [
../blocks/nginx.nix ../blocks/nginx.nix
../blocks/lldap.nix
(lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] '' (lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] ''
Instead, define an admin user in shb.forgejo.users and give it the same password, like so: Instead, define an admin user in shb.forgejo.users and give it the same password, like so:
shb.forgejo.users = {
"forgejoadmin" = { shb.forgejo.users = {
isAdmin = true; "forgejoadmin" = {
email = "forgejoadmin@example.com"; isAdmin = true;
password.result = <path/to/password>; email = "forgejoadmin@example.com";
}; password.result = <path/to/password>;
}; };
};
'') '')
]; ];
@ -233,6 +235,7 @@ in
users = mkOption { users = mkOption {
description = "Users managed declaratively."; description = "Users managed declaratively.";
default = { };
type = attrsOf (submodule { type = attrsOf (submodule {
options = { options = {
isAdmin = mkOption { isAdmin = mkOption {
@ -245,7 +248,7 @@ in
description = '' description = ''
Email of user. Email of user.
This is only set when the user is created, changing this later on will have no effect. This is only set when the user is created, changing this later on will have no effect.
''; '';
type = str; type = str;
}; };
@ -331,7 +334,7 @@ in
options = shb.contracts.backup.mkRequester { options = shb.contracts.backup.mkRequester {
user = options.services.forgejo.user.value; user = options.services.forgejo.user.value;
sourceDirectories = [ sourceDirectories = [
options.services.forgejo.dump.backupDir.value config.services.forgejo.dump.backupDir
] ]
++ optionals (cfg.repositoryRoot != null) [ ++ optionals (cfg.repositoryRoot != null) [
cfg.repositoryRoot cfg.repositoryRoot
@ -405,6 +408,21 @@ in
type = bool; type = bool;
default = false; default = false;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}";
internalUrl = "https://${cfg.subdomain}.${cfg.domain}";
internalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}";
};
};
};
}; };
config = mkMerge [ config = mkMerge [
@ -468,6 +486,12 @@ in
(mkIf (cfg.enable && cfg.ldap.enable != false) { (mkIf (cfg.enable && cfg.ldap.enable != false) {
systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices;
systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices;
shb.lldap.ensureGroups = {
${cfg.ldap.adminGroup} = { };
${cfg.ldap.userGroup} = { };
};
# The delimiter in the `cut` command is a TAB! # The delimiter in the `cut` command is a TAB!
systemd.services.forgejo.preStart = systemd.services.forgejo.preStart =
let let
@ -529,6 +553,13 @@ in
# For Forgejo config: https://forgejo.org/docs/latest/admin/config-cheat-sheet # For Forgejo config: https://forgejo.org/docs/latest/admin/config-cheat-sheet
# For cli info: https://docs.gitea.com/usage/command-line # For cli info: https://docs.gitea.com/usage/command-line
(mkIf (cfg.enable && cfg.sso.enable != false) { (mkIf (cfg.enable && cfg.sso.enable != false) {
assertions = [
{
assertion = cfg.ldap.enable == true;
message = "'shb.forgejo.ldap.enable' must be set to true and ldap configured when 'shb.forgejo.sso.enable' is true. Otherwise you will never be able to register new accounts.";
}
];
services.forgejo.settings = { services.forgejo.settings = {
oauth2 = { oauth2 = {
ENABLED = true; ENABLED = true;

View file

@ -17,6 +17,7 @@ LDAP and SSO integration as well as one local runner.
- Access through [subdomain](#services-forgejo-options-shb.forgejo.subdomain) using reverse proxy. [Manual](#services-forgejo-usage-configuration). - Access through [subdomain](#services-forgejo-options-shb.forgejo.subdomain) using reverse proxy. [Manual](#services-forgejo-usage-configuration).
- Access through [HTTPS](#services-forgejo-options-shb.forgejo.ssl) using reverse proxy. [Manual](#services-forgejo-usage-configuration). - Access through [HTTPS](#services-forgejo-options-shb.forgejo.ssl) using reverse proxy. [Manual](#services-forgejo-usage-configuration).
- [Backup](#services-forgejo-options-shb.forgejo.sso) through the [backup block](./blocks-backup.html). [Manual](#services-forgejo-usage-backup). - [Backup](#services-forgejo-options-shb.forgejo.sso) through the [backup block](./blocks-backup.html). [Manual](#services-forgejo-usage-backup).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-forgejo-usage-applicationdashboard)
## Usage {#services-forgejo-usage} ## Usage {#services-forgejo-usage}
@ -34,20 +35,20 @@ shb.forgejo = {
"theadmin" = { "theadmin" = {
isAdmin = true; isAdmin = true;
email = "theadmin@example.com"; email = "theadmin@example.com";
password.result = config.shb.sops.secrets.forgejoAdminPassword.result; password.result = config.shb.sops.secret.forgejoAdminPassword.result;
}; };
"theuser" = { "theuser" = {
email = "theuser@example.com"; email = "theuser@example.com";
password.result = config.shb.sops.secrets.forgejoUserPassword.result; password.result = config.shb.sops.secret.forgejoUserPassword.result;
}; };
}; };
}; };
shb.sops.secrets."forgejo/admin/password" = { shb.sops.secret."forgejo/admin/password" = {
request = config.shb.forgejo.users."theadmin".password.request; request = config.shb.forgejo.users."theadmin".password.request;
}; };
shb.sops.secrets."forgejo/user/password" = { shb.sops.secret."forgejo/user/password" = {
request = config.shb.forgejo.users."theuser".password.request; request = config.shb.forgejo.users."theuser".password.request;
}; };
``` ```
@ -110,10 +111,10 @@ shb.forgejo.ldap = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."forgejo/ldap/adminPassword".result adminPassword.result = config.shb.sops.secret."forgejo/ldap/adminPassword".result
}; };
shb.sops.secrets."forgejo/ldap/adminPassword" = { shb.sops.secret."forgejo/ldap/adminPassword" = {
request = config.shb.forgejo.ldap.adminPassword.request; request = config.shb.forgejo.ldap.adminPassword.request;
settings.key = "ldap/userPassword"; settings.key = "ldap/userPassword";
}; };
@ -206,6 +207,22 @@ The name `"forgejo"` in the `instances` can be anything.
The `config.shb.forgejo.backup` option provides what directories to backup. The `config.shb.forgejo.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Forgejo multiple times. You can define any number of Restic instances to backup Forgejo multiple times.
### Application Dashboard {#services-forgejo-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-forgejo-options-shb.forgejo.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.Forgejo = {
sortOrder = 1;
dashboard.request = config.shb.forgejo.dashboard.request;
};
}
```
### Extra Settings {#services-forgejo-usage-extra-settings} ### Extra Settings {#services-forgejo-usage-extra-settings}
Other Forgejo settings can be accessed through the nixpkgs [stock service][]. Other Forgejo settings can be accessed through the nixpkgs [stock service][].

View file

@ -111,6 +111,21 @@ in
default = false; default = false;
example = true; example = true;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.grocy.subdomain}.\${config.shb.grocy.domain}";
internalUrl = "https://${cfg.subdomain}.${cfg.domain}";
internalUrlText = "https://\${config.shb.grocy.subdomain}.\${config.shb.grocy.domain}";
};
};
};
}; };
config = lib.mkIf cfg.enable ( config = lib.mkIf cfg.enable (

View file

@ -82,6 +82,21 @@ in
default = [ "--forecast" ]; default = [ "--forecast" ];
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.hledger.subdomain}.\${config.shb.hledger.domain}";
internalUrl = "http://127.0.0.1:${toString config.services.hledger-web.port}";
internalUrlText = "http://127.0.0.1:\${config.services.hledger-web.port}";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {

View file

@ -11,23 +11,13 @@ let
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
ldap_auth_script_repo = pkgs.fetchFromGitHub {
owner = "lldap";
repo = "lldap";
rev = "7d1f5abc137821c500de99c94f7579761fc949d8";
sha256 = "sha256-8D+7ww70Ja6Qwdfa+7MpjAAHewtCWNf/tuTAExoUrg0=";
};
ldap_auth_script = pkgs.writeShellScriptBin "ldap_auth.sh" ''
export PATH=${pkgs.gnused}/bin:${pkgs.curl}/bin:${pkgs.jq}/bin
exec ${pkgs.bash}/bin/bash ${ldap_auth_script_repo}/example_configs/lldap-ha-auth.sh $@
'';
# Filter secrets from config. Secrets are those of the form { source = <path>; } # Filter secrets from config. Secrets are those of the form { source = <path>; }
secrets = lib.attrsets.filterAttrs (k: v: builtins.isAttrs v) cfg.config; secrets = lib.attrsets.filterAttrs (k: v: builtins.isAttrs v) cfg.config;
nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config); nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config);
lldap_ha_auth = pkgs.callPackage ./home-assistant/lldap_ha_auth.nix { };
configWithSecretsIncludes = nonSecrets // (lib.attrsets.mapAttrs (k: v: "!secret ${k}") secrets); configWithSecretsIncludes = nonSecrets // (lib.attrsets.mapAttrs (k: v: "!secret ${k}") secrets);
in in
{ {
@ -218,6 +208,21 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.home-assistant.subdomain}.\${config.shb.home-assistant.domain}";
internalUrl = "http://127.0.0.1:${toString config.services.home-assistant.config.http.server_port}";
internalUrlText = "http://127.0.0.1:\${config.services.home-assistant.config.http.server_port}";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@ -248,7 +253,6 @@ in
config = { config = {
# Includes dependencies for a basic setup # Includes dependencies for a basic setup
# https://www.home-assistant.io/integrations/default_config/ # https://www.home-assistant.io/integrations/default_config/
default_config = { };
http = { http = {
use_x_forwarded_for = true; use_x_forwarded_for = true;
server_host = "127.0.0.1"; server_host = "127.0.0.1";
@ -266,9 +270,10 @@ in
} }
]) ])
++ (lib.optionals cfg.ldap.enable [ ++ (lib.optionals cfg.ldap.enable [
# https://www.home-assistant.io/docs/authentication/providers/#command-line
{ {
type = "command_line"; type = "command_line";
command = ldap_auth_script + "/bin/ldap_auth.sh"; command = lldap_ha_auth + "/bin/lldap-ha-auth";
args = [ args = [
"http://${cfg.ldap.host}:${toString cfg.ldap.port}" "http://${cfg.ldap.host}:${toString cfg.ldap.port}"
cfg.ldap.userGroup cfg.ldap.userGroup

View file

@ -15,6 +15,7 @@ LDAP and SSO integration.
- Access through [subdomain](#services-home-assistant-options-shb.home-assistant.subdomain) using reverse proxy. [Manual](#services-home-assistant-usage-configuration). - Access through [subdomain](#services-home-assistant-options-shb.home-assistant.subdomain) using reverse proxy. [Manual](#services-home-assistant-usage-configuration).
- Access through [HTTPS](#services-home-assistant-options-shb.home-assistant.ssl) using reverse proxy. [Manual](#services-home-assistant-usage-configuration). - Access through [HTTPS](#services-home-assistant-options-shb.home-assistant.ssl) using reverse proxy. [Manual](#services-home-assistant-usage-configuration).
- [Backup](#services-home-assistant-options-shb.home-assistant.backup) through the [backup block](./blocks-backup.html). [Manual](#services-home-assistant-usage-backup). - [Backup](#services-home-assistant-options-shb.home-assistant.backup) through the [backup block](./blocks-backup.html). [Manual](#services-home-assistant-usage-backup).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-home-assistant-usage-applicationdashboard)
- Not yet: declarative SSO. - Not yet: declarative SSO.
@ -163,6 +164,57 @@ You can define any number of Restic instances to backup Home-Assistant multiple
You will then need to configure more options like the `repository`, You will then need to configure more options like the `repository`,
as explained in the [restic](blocks-restic.html) documentation. as explained in the [restic](blocks-restic.html) documentation.
### Application Dashboard {#services-home-assistant-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-home-assistant-options-shb.home-assistant.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Home.services.HomeAssistant = {
sortOrder = 1;
dashboard.request = config.shb.home-assistant.dashboard.request;
settings.icon = "si-homeassistant";
};
}
```
The icon needs to be set manually otherwise it is not displayed correctly.
An API key can be set to show extra info:
```nix
{
shb.homepage.servicesGroups.Home.services.HomeAssistant = {
apiKey.result = config.shb.sops.secret."home-assistant/homepageApiKey".result;
};
shb.sops.secret."home-assistant/homepageApiKey".request =
config.shb.homepage.servicesGroups.Home.services.HomeAssistant.apiKey.request;
}
```
Custom widgets can be set using Home Assistant templating:
```nix
{
shb.homepage.servicesGroups.Home.services.HomeAssistant = {
settings.widget.custom = [
{
template = "{{ states('sensor.power_consumption_power_consumption', with_unit=True, rounded=True) }}";
label = "energy now";
}
{
state = "sensor.power_consumption_daily_power_consumption";
label = "energy today";
}
];
};
}
```
### Extra Components {#services-home-assistant-usage-extra-components} ### Extra Components {#services-home-assistant-usage-extra-components}
Packaged components can be found in the documentation of the corresponding option Packaged components can be found in the documentation of the corresponding option

View file

@ -0,0 +1,37 @@
{
lib,
pkgs,
stdenvNoCC,
}:
stdenvNoCC.mkDerivation {
name = "lldap-ha-auth";
src = pkgs.fetchFromGitHub {
owner = "lldap";
repo = "lldap";
rev = "7d1f5abc137821c500de99c94f7579761fc949d8";
sha256 = "sha256-8D+7ww70Ja6Qwdfa+7MpjAAHewtCWNf/tuTAExoUrg0=";
};
nativeBuildInputs = [
pkgs.makeWrapper
];
buildPhase = ''
mkdir -p $out/bin
cp example_configs/lldap-ha-auth.sh $out/bin/lldap-ha-auth
chmod a+x $out/bin/lldap-ha-auth
'';
installPhase = ''
wrapProgram $out/bin/lldap-ha-auth \
--prefix PATH : ${
lib.makeBinPath [
pkgs.gnused
pkgs.curl
pkgs.jq
]
}
'';
}

View file

@ -0,0 +1,286 @@
{
config,
lib,
shb,
...
}:
let
cfg = config.shb.homepage;
inherit (lib) types;
in
{
imports = [
../../lib/module.nix
../blocks/lldap.nix
../blocks/nginx.nix
];
options.shb.homepage = {
enable = lib.mkEnableOption "the SHB homepage service";
subdomain = lib.mkOption {
type = types.str;
description = ''
Subdomain under which homepage will be served.
```
<subdomain>.<domain>
```
'';
example = "homepage";
};
domain = lib.mkOption {
description = ''
Domain under which homepage is served.
```
<subdomain>.<domain>
```
'';
type = types.str;
example = "domain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = types.nullOr shb.contracts.ssl.certs;
default = null;
};
servicesGroups = lib.mkOption {
description = "Group of services that should be showed on the dashboard.";
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = types.str;
description = "Display name of the group. Defaults to the attr name.";
default = name;
};
sortOrder = lib.mkOption {
description = ''
Order in which groups will be shown.
The rules are:
- Lowest number is shown first.
- Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order.
- Default is null which means at the end.
'';
type = types.nullOr types.int;
default = null;
};
services = lib.mkOption {
description = "Services that should be showed in the group on the dashboard.";
default = { };
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = {
name = lib.mkOption {
type = types.str;
description = "Display name of the service. Defaults to the attr name.";
default = name;
};
sortOrder = lib.mkOption {
type = types.nullOr types.int;
description = ''
Order in which groups will be shown.
The rules are:
- Lowest number is shown first.
- Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order.
- Default is null which means at the end.
'';
default = null;
};
dashboard = lib.mkOption {
description = ''
Provider of the dashboard contract.
By default:
- The `serviceName` option comes from the attr name.
- The `icon` option comes from applying `toLower` on the attr name.
- The `siteMonitor` option is set only if `internalUrl` is set.
'';
type = types.submodule {
options = shb.contracts.dashboard.mkProvider {
resultCfg = { };
};
};
};
apiKey = lib.mkOption {
description = ''
API key used to access the service.
This can be used to get data from the service.
'';
default = null;
type = types.nullOr (
lib.types.submodule {
options = shb.contracts.secret.mkRequester {
owner = "root";
restartUnits = [ "homepage-dashboard.service" ];
};
}
);
};
settings = lib.mkOption {
description = ''
Extra options to pass to the homepage service.
Check https://gethomepage.dev/configs/services/#icons
if the default icon is not correct.
And check https://gethomepage.dev/widgets
if the default widget type is not correct.
'';
default = { };
type = types.attrsOf types.anything;
example = lib.literalExpression ''
{
icon = "si-homeassistant";
widget.type = "firefly";
widget.custom = [
{
template = "{{ states('sensor.total_power', with_unit=True, rounded=True) }}";
label = "energy now";
}
{
state = "sensor.total_power_today";
label = "energy today";
}
];
}
'';
};
};
}
)
);
};
};
}
)
);
};
ldap = lib.mkOption {
description = ''
Setup LDAP integration.
'';
default = { };
type = types.submodule {
options = {
userGroup = lib.mkOption {
type = types.str;
description = "Group users must belong to be able to login.";
default = "homepage_user";
};
};
};
};
sso = lib.mkOption {
description = ''
Setup SSO integration.
'';
default = { };
type = types.submodule {
options = {
enable = lib.mkEnableOption "SSO integration.";
authEndpoint = lib.mkOption {
type = lib.types.str;
description = "Endpoint to the SSO provider.";
example = "https://authelia.example.com";
};
authorization_policy = lib.mkOption {
type = types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
};
};
};
};
config = lib.mkIf cfg.enable {
services.homepage-dashboard = {
enable = true;
allowedHosts = "${cfg.subdomain}.${cfg.domain}";
settings = {
baseUrl = "https://${cfg.subdomain}.${cfg.domain}";
startUrl = "https://${cfg.subdomain}.${cfg.domain}";
disableUpdateCheck = true;
};
bookmarks = [ ];
services = shb.homepage.asServiceGroup cfg.servicesGroups;
widgets = [ ];
};
systemd.services.homepage-dashboard.serviceConfig =
let
keys = shb.homepage.allKeys cfg.servicesGroups;
in
{
# LoadCredential = [
# "Media_Jellyfin:/path"
# ];
LoadCredential = lib.mapAttrsToList (name: path: "${name}:${path}") keys;
# Environment = [
# "HOMEPAGE_FILE_Media_Jellyfin=%d/Media_Jellyfin"
# ];
Environment = lib.mapAttrsToList (name: path: "HOMEPAGE_FILE_${name}=%d/${name}") keys;
};
# This should be using a contract instead of setting the option directly.
shb.lldap = lib.mkIf config.shb.lldap.enable {
ensureGroups = {
${cfg.ldap.userGroup} = { };
};
};
shb.nginx.vhosts = [
(
{
inherit (cfg) subdomain domain ssl;
upstream = "http://127.0.0.1:${toString config.services.homepage-dashboard.listenPort}/";
extraConfig = ''
proxy_read_timeout 300s;
proxy_send_timeout 300s;
'';
autheliaRules = lib.optionals (cfg.sso.enable) [
{
domain = "${cfg.subdomain}.${cfg.domain}";
policy = cfg.sso.authorization_policy;
subject = [ "group:${cfg.ldap.userGroup}" ];
}
];
}
// lib.optionalAttrs cfg.sso.enable {
inherit (cfg.sso) authEndpoint;
}
)
];
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View file

@ -0,0 +1,193 @@
# Homepage Service {#services-homepage}
Defined in [`/modules/services/homepage.nix`](@REPO@/modules/services/homepage.nix),
found in the `selfhostblocks.nixosModules.homepage` module.
See [the manual](usage.html#usage-flake) for how to import the module in your code.
This service sets up [Homepage Dashboard][] which provides
a highly customizable homepage Docker and service API integrations.
![](./Screenshot.png)
[Homepage Dashboard]: https://github.com/gethomepage/homepage
## Features {#services-homepage-features}
- Declarative SSO login through forward authentication.
Only users of the [Homepage LDAP user group][] can access the web UI.
This is enforced using the [Authelia block][] which integrates with the LLDAP block.
- Access through [subdomain][] using the reverse proxy.
It is implemented with the [Nginx block][].
- Access through [HTTPS][] using the reverse proxy.
It is implemented with the [SSL block][].
- Integration with [secrets contract][] to set the API key for a widget.
[Homepage LDAP user group]: #services-homepage-options-shb.homepage.ldap.userGroup
[Authelia block]: blocks-authelia.html
[subdomain]: #services-open-webui-options-shb.open-webui.subdomain
[HTTPS]: #services-open-webui-options-shb.open-webui.ssl
[Nginx block]: blocks-nginx.html
[SSL block]: blocks-ssl.html
[secrets contract]: contracts-secret.html
::: {.note}
The service does not use state so no backup or impermanence integration is provided.
:::
## Usage {#services-homepage-usage}
The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS,
- the [`shb.ssl` block](blocks-ssl.html#usage),
- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).
- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).
::: {.note}
Part of the configuration is done through the `shb.homepage` option described here
and the rest is done through the upstream [`services.homepage-dashboard`][] option.
:::
[`services.homepage-dashboard`]: https://search.nixos.org/options?query=services.homepage-dashboard
### Main service configuration {#services-homepage-usage-main}
This part sets up the web UI and its integration with the other SHB services.
It also creates the various service groups which will hold each service.
The names are arbitrary and you can order them as you wish through the `sortOrder` option.
```nix
{
shb.certs.certs.letsencrypt.${domain}.extraDomains = [
"${config.shb.homepage.subdomain}.${config.shb.homepage.domain}"
];
shb.homepage = {
enable = true;
subdomain = "home";
inherit domain;
ssl = config.shb.certs.certs.letsencrypt.${domain};
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
};
servicesGroups = {
Home.sortOrder = 1;
Documents.sortOrder = 2;
Finance.sortOrder = 3;
Media.sortOrder = 4;
Admin.sortOrder = 5;
};
};
services.homepage-dashboard = {
settings = {
statusStyle = "dot";
disableIndexing = true;
};
widgets = [
{
datetime = {
locale = "fr";
format = {
dateStyle = "long";
timeStyle = "long";
};
};
}
];
};
}
```
The [Homepage LDAP user group][] is created automatically and users can be added declaratively to the group with:.
```nix
{
shb.lldap.ensureUsers.${user}.groups = [
config.shb.homepage.ldap.userGroup
];
}
```
### Display SHB service {#services-homepage-usage-service}
A service consumer of the dashboard contract provides a `dashboard` option that can be used like so:
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
sortOrder = 2;
dashboard.request = config.shb.jellyfin.dashboard.request;
};
}
```
By default:
- The `serviceName` option comes from the attr name, here `Jellyfin`.
- The `icon` option comes from applying `toLower` on the attr name.
- The `siteMonitor` option is set only if `internalUrl` is set.
They can be overridden by setting them in the [settings](#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings option) (see option documentation for examples):
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
sortOrder = 2;
dashboard.request = config.shb.<service>.dashboard.request;
settings = {
// custom options here.
};
};
}
```
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
### Display custom service {#services-homepage-usage-custom}
To display a service that does not provide a `dashboard` option like in the previous section, set the values of the request manually:
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
sortOrder = 2;
dashboard.request = {
externalUrl = "https://jellyfin.example.com";
internalUrl = "http://127.0.0.1:8081";
};
};
}
```
### Add API key for widget {#services-homepage-usage-widget}
For services [supporting a widget](https://gethomepage.dev/widgets/),
create an API key through the service's web UI if available
then store it securely (using SOPS for example) and provide it through the
`apiKey` option:
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
sortOrder = 1;
dashboard.request = config.shb.jellyfin.dashboard.request;
apiKey.result = config.shb.sops.secret."jellyfin/homepageApiKey".result;
};
shb.sops.secret."jellyfin/homepageApiKey".request =
config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request;
}
```
Unfortunately creating API keys declaratively is rarely supported by upstream services.
## Options Reference {#services-homepage-options}
```{=include=} options
id-prefix: services-homepage-options-
list-id: selfhostblocks-service-homepage-options
source: @OPTIONS_JSON@
```

View file

@ -144,6 +144,22 @@ in
default = 2283; default = 2283;
}; };
publicProxyEnable = mkOption {
description = ''
Enable Immich Public Proxy service for sharing media publically.
'';
type = bool;
default = false;
};
publicProxyPort = mkOption {
description = ''
Port under which Immich Public Proxy will listen.
'';
type = port;
default = 2284;
};
ssl = mkOption { ssl = mkOption {
description = "Path to SSL files"; description = "Path to SSL files";
type = nullOr shb.contracts.ssl.certs; type = nullOr shb.contracts.ssl.certs;
@ -449,6 +465,20 @@ in
default = false; default = false;
example = true; example = true;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.immich.subdomain}.\${config.shb.immich.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
@ -475,12 +505,6 @@ in
# Database configuration defaults to Unix socket /run/postgresql # Database configuration defaults to Unix socket /run/postgresql
# Database configuration
database = {
# Disable pgvecto.rs, as it was deprecated before SHB integration
enableVectors = false;
};
# Machine learning configuration # Machine learning configuration
machine-learning = mkIf cfg.machineLearning.enable { machine-learning = mkIf cfg.machineLearning.enable {
enable = true; enable = true;
@ -502,6 +526,12 @@ in
}; };
}; };
services.immich-public-proxy = mkIf (cfg.publicProxyEnable) {
enable = true;
port = cfg.publicProxyPort;
immichUrl = "https://${fqdn}";
};
# Create basic directories for Immich # Create basic directories for Immich
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/immich 0700 immich immich" "d /var/lib/immich 0700 immich immich"
@ -550,6 +580,8 @@ in
resources = [ resources = [
"^/api.*" "^/api.*"
"^/.well-known/immich" "^/.well-known/immich"
"^/share.*"
"^/_app/immutable/.*"
]; ];
} }
{ {
@ -572,9 +604,15 @@ in
]; ];
# Allow large uploads from mobile app # Allow large uploads from mobile app
services.nginx.virtualHosts."${fqdn}".extraConfig = '' services.nginx.virtualHosts."${fqdn}" = {
client_max_body_size 50G; extraConfig = ''
''; client_max_body_size 50G;
'';
locations."^~ /share" = {
recommendedProxySettings = true;
proxyPass = "http://127.0.0.1:${toString cfg.publicProxyPort}";
};
};
# Ensure services start in correct order # Ensure services start in correct order
systemd.services.immich-server = { systemd.services.immich-server = {

View file

@ -317,6 +317,20 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.jellyfin.subdomain}.\${config.shb.jellyfin.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
imports = [ imports = [

View file

@ -20,9 +20,12 @@ this one sets up, in a fully declarative manner:
- Declarative [LDAP](#services-jellyfin-options-shb.jellyfin.ldap) configuration. - Declarative [LDAP](#services-jellyfin-options-shb.jellyfin.ldap) configuration.
- Declarative [SSO](#services-jellyfin-options-shb.jellyfin.sso) configuration. - Declarative [SSO](#services-jellyfin-options-shb.jellyfin.sso) configuration.
- [Backup](#services-jellyfin-options-shb.jellyfin.backup) through the [backup block](./blocks-backup.html). [Manual](#services-jellyfin-usage-backup). - [Backup](#services-jellyfin-options-shb.jellyfin.backup) through the [backup block](./blocks-backup.html). [Manual](#services-jellyfin-usage-backup).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-jellyfin-usage-applicationdashboard)
## Usage {#services-jellyfin-usage} ## Usage {#services-jellyfin-usage}
### Initial Configuration {#services-jellyfin-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -46,7 +49,7 @@ shb.jellyfin = {
host = "127.0.0.1"; host = "127.0.0.1";
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminPassword.result = config.shb.sops.secrets."jellyfin/ldap/adminPassword".result adminPassword.result = config.shb.sops.secret."jellyfin/ldap/adminPassword".result
}; };
sso = { sso = {
@ -60,7 +63,7 @@ shb.jellyfin = {
shb.sops.secret."jellyfin/adminPassword".request = config.shb.jellyfin.admin.password.request; shb.sops.secret."jellyfin/adminPassword".request = config.shb.jellyfin.admin.password.request;
shb.sops.secrets."jellyfin/ldap/adminPassword".request = config.shb.jellyfin.ldap.adminPassword.request; shb.sops.secret."jellyfin/ldap/adminPassword".request = config.shb.jellyfin.ldap.adminPassword.request;
shb.sops.secret."jellyfin/sso_secret".request = config.shb.jellyfin.sso.sharedSecret.request; shb.sops.secret."jellyfin/sso_secret".request = config.shb.jellyfin.sso.sharedSecret.request;
shb.sops.secret."jellyfin/authelia/sso_secret" = { shb.sops.secret."jellyfin/authelia/sso_secret" = {
@ -133,6 +136,35 @@ shb.lldap.ensureUsers.USERNAME.groups = [
]; ];
``` ```
### Application Dashboard {#services-jellyfin-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-jellyfin-options-shb.jellyfin.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
sortOrder = 1;
dashboard.request = config.shb.jellyfin.dashboard.request;
};
}
```
An API key can be set to show extra info:
```nix
{
shb.homepage.servicesGroups.Media.services.Jellyfin = {
apiKey.result = config.shb.sops.secret."jellyfin/homepageApiKey".result;
};
shb.sops.secret."jellyfin/homepageApiKey".request =
config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request;
}
```
## Debug {#services-jellyfin-debug} ## Debug {#services-jellyfin-debug}
In case of an issue, check the logs for systemd service `jellyfin.service`. In case of an issue, check the logs for systemd service `jellyfin.service`.

View file

@ -175,6 +175,20 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.karakeep.subdomain}.\${config.shb.karakeep.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = ( config = (

View file

@ -21,9 +21,12 @@ It integrates well with [Ollama][].
- Access through [subdomain](#services-karakeep-options-shb.karakeep.subdomain) using reverse proxy. - Access through [subdomain](#services-karakeep-options-shb.karakeep.subdomain) using reverse proxy.
- Access through [HTTPS](#services-karakeep-options-shb.karakeep.ssl) using reverse proxy. - Access through [HTTPS](#services-karakeep-options-shb.karakeep.ssl) using reverse proxy.
- [Backup](#services-karakeep-options-shb.karakeep.sso) through the [backup block](./blocks-backup.html). - [Backup](#services-karakeep-options-shb.karakeep.sso) through the [backup block](./blocks-backup.html).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-karakeep-usage-applicationdashboard)
## Usage {#services-karakeep-usage} ## Usage {#services-karakeep-usage}
### Initial Configuration {#services-karakeep-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -66,6 +69,35 @@ The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup)
and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup) and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup)
LDAP groups are created automatically. LDAP groups are created automatically.
### Application Dashboard {#services-karakeep-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-karakeep-options-shb.karakeep.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.Karakeep = {
sortOrder = 3;
dashboard.request = config.shb.karakeep.dashboard.request;
};
}
```
An API key can be set to show extra info:
```nix
{
shb.homepage.servicesGroups.Documents.services.Karakeep = {
apiKey.result = config.shb.sops.secret."karakeep/homepageApiKey".result;
};
shb.sops.secret."karakeep/homepageApiKey".request =
config.shb.homepage.servicesGroups.Documents.services.Karakeep.apiKey.request;
}
```
## Integration with Ollama {#services-karakeep-ollama} ## Integration with Ollama {#services-karakeep-ollama}
Assuming ollama is enabled, it will be available on port `config.services.ollama.port`. Assuming ollama is enabled, it will be available on port `config.services.ollama.port`.

View file

@ -14,7 +14,7 @@ in
builtins.fetchGit { builtins.fetchGit {
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git"; url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git";
ref = "master"; ref = "master";
rev = "7d433bf89882f61621f95082e90a4ab91eb0bdd3"; rev = "e33fbde199eaad513ef5d0746db19d5878150232";
} }
+ "/default.nix" + "/default.nix"
) )
@ -105,7 +105,7 @@ in
description = '' description = ''
Accounts to sync emails from using IMAP. Accounts to sync emails from using IMAP.
Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}` Emails will be stored under `''${config.mailserver.storage.path}/''${name}/''${username}`
''; '';
type = lib.types.attrsOf ( type = lib.types.attrsOf (
lib.types.submodule { lib.types.submodule {
@ -137,7 +137,7 @@ in
type = lib.types.submodule { type = lib.types.submodule {
options = shb.contracts.secret.mkRequester { options = shb.contracts.secret.mkRequester {
mode = "0400"; mode = "0400";
owner = config.mailserver.vmailUserName; owner = config.mailserver.storage.owner;
restartUnits = [ "mbsync.service" ]; restartUnits = [ "mbsync.service" ];
}; };
}; };
@ -267,7 +267,7 @@ in
Enabling this app will create a new LDAP configuration or update one that exists with Enabling this app will create a new LDAP configuration or update one that exists with
the given host. the given host.
''; '';
default = { }; default = null;
type = lib.types.nullOr ( type = lib.types.nullOr (
lib.types.submodule { lib.types.submodule {
options = { options = {
@ -333,6 +333,26 @@ in
); );
}; };
stateVersion = lib.mkOption {
type = lib.types.ints.positive;
# SHB started at stateVersion 3.
default = 4;
description = ''
Tracking stateful version changes as an incrementing number.
When a new release comes out we may require manual migration steps to
be completed, before the new version can be put into production.
If your `stateVersion` is too low one or multiple assertions may
trigger to give you instructions on what migrations steps are required
to continue. Increase the `stateVersion` as instructed by the assertion
message.
See https://nixos-mailserver.readthedocs.io/en/latest/release-notes.html
and https://nixos-mailserver.readthedocs.io/en/latest/migrations.html
'';
};
backup = lib.mkOption { backup = lib.mkOption {
description = '' description = ''
Backup emails, index and sieve. Backup emails, index and sieve.
@ -340,16 +360,16 @@ in
default = { }; default = { };
type = lib.types.submodule { type = lib.types.submodule {
options = shb.contracts.backup.mkRequester { options = shb.contracts.backup.mkRequester {
user = config.mailserver.vmailUserName; user = config.mailserver.storage.owner;
sourceDirectories = builtins.filter (x: x != null) [ sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.indexDir config.mailserver.indexDir
config.mailserver.mailDirectory config.mailserver.storage.path
config.mailserver.sieveDirectory config.mailserver.sieveDirectory
]; ];
sourceDirectoriesText = '' sourceDirectoriesText = ''
[ [
config.mailserver.indexDir config.mailserver.indexDir
config.mailserver.mailDirectory config.mailserver.storage.path
config.mailserver.sieveDirectory config.mailserver.sieveDirectory
] ]
''; '';
@ -367,7 +387,7 @@ in
user = config.services.rspamd.user; user = config.services.rspamd.user;
userText = "services.rspamd.user"; userText = "services.rspamd.user";
sourceDirectories = builtins.filter (x: x != null) [ sourceDirectories = builtins.filter (x: x != null) [
config.mailserver.dkimKeyDirectory config.mailserver.dkim.keyDirectory
]; ];
sourceDirectoriesText = '' sourceDirectoriesText = ''
[ [
@ -385,27 +405,40 @@ in
type = lib.types.attrsOf lib.types.str; type = lib.types.attrsOf lib.types.str;
default = { default = {
index = config.mailserver.indexDir; index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory; mail = config.mailserver.storage.path;
sieve = config.mailserver.sieveDirectory; sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory; dkim = config.mailserver.dkimKeyDirectory;
}; };
defaultText = lib.literalExpression '' defaultText = lib.literalExpression ''
{ {
index = config.mailserver.indexDir; index = config.mailserver.indexDir;
mail = config.mailserver.mailDirectory; mail = config.mailserver.storage.path;
sieve = config.mailserver.sieveDirectory; sieve = config.mailserver.sieveDirectory;
dkim = config.mailserver.dkimKeyDirectory; dkim = config.mailserver.dkimKeyDirectory;
} }
''; '';
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.mailserver.subdomain}.\${config.shb.mailserver.domain}";
};
};
};
}; };
config = lib.mkMerge [ config = lib.mkMerge [
(lib.mkIf cfg.enable { (lib.mkIf cfg.enable {
mailserver = { mailserver = {
enable = true; enable = true;
stateVersion = 3;
fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdn = "${cfg.subdomain}.${cfg.domain}";
inherit (cfg) stateVersion;
domains = [ cfg.domain ]; domains = [ cfg.domain ];
localDnsResolver = false; localDnsResolver = false;
@ -420,18 +453,22 @@ in
# Using / is needed for iOS mail. # Using / is needed for iOS mail.
# Both following options are used to organize subfolders in subdirectories. # Both following options are used to organize subfolders in subdirectories.
hierarchySeparator = "/"; hierarchySeparator = "/";
useFsLayout = true; storage = {
directoryLayout = "fs";
};
}; };
services.postfix.config = { services.postfix.settings.main = {
smtpd_tls_security_level = lib.mkForce "encrypt"; smtpd_tls_security_level = lib.mkForce "encrypt";
}; };
# Is probably needed for iOS mail. # Is probably needed for iOS mail.
services.dovecot2.extraConfig = '' services.dovecot2.settings = {
ssl_min_protocol = TLSv1.2 ssl_min_protocol = "TLSv1.2";
ssl_cipher_list = HIGH:!aNULL:!MD5 # This conflicts with the default setting which seems much better than this one.
''; # So I'm leaving the default settings and we'll see if something breaks.
# ssl_cipher_list = "HIGH:!aNULL:!MD5";
};
services.nginx = { services.nginx = {
enable = true; enable = true;
@ -458,7 +495,7 @@ in
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>${cfg.subdomain}.${cfg.domain}</hostname> <hostname>${cfg.subdomain}.${cfg.domain}</hostname>
<port>465</port> <port>465</port>
<socketType>STARTTLS</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
@ -469,6 +506,8 @@ in
in in
{ {
forceSSL = true; # Redirect HTTP → HTTPS forceSSL = true; # Redirect HTTP → HTTPS
sslCertificate = cfg.ssl.paths.cert;
sslCertificateKey = cfg.ssl.paths.key;
root = "/var/www"; # Dummy root root = "/var/www"; # Dummy root
locations."/.well-known/autoconfig/mail/" = { locations."/.well-known/autoconfig/mail/" = {
alias = "${announce}/"; alias = "${announce}/";
@ -477,6 +516,27 @@ in
''; '';
}; };
}; };
virtualHosts."${cfg.subdomain}.${cfg.domain}" =
let
landingPage = pkgs.writeTextDir "index.html" ''
<html><body>
<p>Configuration of the mailserver is done automatically thanks to
<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>
</body></html>
'';
in
{
forceSSL = true; # Redirect HTTP → HTTPS
sslCertificate = cfg.ssl.paths.cert;
sslCertificateKey = cfg.ssl.paths.key;
root = "/var/www"; # Dummy root
locations."/" = {
alias = "${landingPage}/";
extraConfig = ''
default_type application/html;
'';
};
};
}; };
}) })
(lib.mkIf (cfg.enable && cfg.adminUsername != null) { (lib.mkIf (cfg.enable && cfg.adminUsername != null) {
@ -516,37 +576,44 @@ in
uris = [ uris = [
"ldap://${cfg.ldap.host}:${toString cfg.ldap.port}" "ldap://${cfg.ldap.host}:${toString cfg.ldap.port}"
]; ];
searchBase = "ou=people,${cfg.ldap.dcdomain}"; base = "ou=people,${cfg.ldap.dcdomain}";
searchScope = "sub"; scope = "sub";
bind = { bind = {
dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}"; dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}";
passwordFile = cfg.ldap.adminPassword.result.path; passwordFile = cfg.ldap.adminPassword.result.path;
}; };
# Note that nixos simple mailserver sets auth_bind=yes
# which means authentication binds are used.
# https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind
dovecot = dovecot =
let let
filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
in in
{ {
passAttrs = "user=user";
passFilter = filter; passFilter = filter;
userAttrs = lib.concatStringsSep "," [
"=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u"
# "mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail"
"uid=${config.mailserver.vmailUserName}"
"gid=${config.mailserver.vmailGroupName}"
];
userFilter = filter; userFilter = filter;
}; };
# username needs to be set to mail so postfix maps correctly the mail address.
# Otherwise we get error messages when sending
# Sender address rejected: not owned by user
attributes = {
username = "mail";
};
postfix = { postfix = {
filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))";
mailAttribute = "mail";
uidAttribute = "mail";
}; };
}; };
}; };
services.dovecot2.settings = {
"userdb ldap" = {
fields.home = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}";
fields.mail_index_path = lib.mkForce "${config.mailserver.storage.path}/${cfg.ldap.account}/%{user}";
};
"passdb ldap" = {
# LLDAP does not understand ldap user password setup.
fields.password = lib.mkForce null;
# We use bind DN auth.
# https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind
bind = true;
};
};
}) })
(lib.mkIf (cfg.enable && cfg.imapSync != null) { (lib.mkIf (cfg.enable && cfg.imapSync != null) {
systemd.services.mbsync = systemd.services.mbsync =
@ -569,11 +636,11 @@ in
Account ${name} Account ${name}
MaildirStore ${name}-local MaildirStore ${name}-local
INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ INBOX ${config.mailserver.storage.path}/${name}/${acct.username}/mail/
# Maps subfolders on far side to actual subfolders on disk. # Maps subfolders on far side to actual subfolders on disk.
# The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.' # The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.'
SubFolders Verbatim SubFolders Verbatim
Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ Path ${config.mailserver.storage.path}/${name}/${acct.username}/mail/
Channel ${name}-main Channel ${name}-main
Far :${name}-remote: Far :${name}-remote:
@ -640,7 +707,7 @@ in
description = "Sync mailbox"; description = "Sync mailbox";
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
User = config.mailserver.vmailUserName; User = config.mailserver.storage.owner;
}; };
script = script =
let let
@ -657,8 +724,8 @@ in
name: acct: name: acct:
# The equal sign makes sure parent directories have the corret user and group too. # The equal sign makes sure parent directories have the corret user and group too.
[ [
"d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" "d '${config.mailserver.storage.path}/${name}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -"
"d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" "d '${config.mailserver.storage.path}/${name}/${acct.username}' 0750 ${config.mailserver.storage.owner} ${config.mailserver.storage.group} - -"
]; ];
in in
lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts); lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts);

View file

@ -128,6 +128,8 @@ in
} }
``` ```
With the example above, the mails are stored under `/var/vmail/fastmail/<email address>`.
### Secrets {#services-mailserver-usage-secrets} ### Secrets {#services-mailserver-usage-secrets}
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
@ -200,6 +202,22 @@ shb.lldap.ensureUsers.USERNAME.groups = [
]; ];
``` ```
### Application Dashboard {#services-mailserver-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-mailserver-options-shb.mailserver.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Home.services.Mailserver = {
sortOrder = 1;
dashboard.request = config.shb.mailserver.dashboard.request;
};
}
```
## Debug {#services-mailserver-debug} ## Debug {#services-mailserver-debug}
Debugging this will be certainly necessary. Debugging this will be certainly necessary.

View file

@ -30,6 +30,11 @@ in
../../lib/module.nix ../../lib/module.nix
../blocks/authelia.nix ../blocks/authelia.nix
../blocks/monitoring.nix ../blocks/monitoring.nix
(lib.mkRenamedOptionModule
[ "shb" "nextcloud" "adminUser" ]
[ "shb" "nextcloud" "initialAdminUsername" ]
)
]; ];
options.shb.nextcloud = { options.shb.nextcloud = {
@ -91,10 +96,10 @@ in
version = lib.mkOption { version = lib.mkOption {
description = "Nextcloud version to choose from."; description = "Nextcloud version to choose from.";
type = lib.types.enum [ type = lib.types.enum [
31
32 32
33
]; ];
default = 31; default = 32;
}; };
dataDir = lib.mkOption { dataDir = lib.mkOption {
@ -110,9 +115,9 @@ in
example = lib.literalExpression ''["var.mount"]''; example = lib.literalExpression ''["var.mount"]'';
}; };
adminUser = lib.mkOption { initialAdminUsername = lib.mkOption {
type = lib.types.str; type = lib.types.str;
description = "Username of the initial admin user."; description = "Initial username of the admin user. Once it is set, it cannot be changed!";
default = "root"; default = "root";
}; };
@ -681,6 +686,11 @@ in
Upon starting the service, disable maintenance mode if set. Upon starting the service, disable maintenance mode if set.
This is useful if a deploy failed and you try to redeploy. This is useful if a deploy failed and you try to redeploy.
Note that even if the disabling of maintenance mode fails,
SHB will still allow the startup to continue
because there are valid reasons for maintenance mode
to not be able to be lifted, like for example this is a brand new installation.
''; '';
}; };
@ -691,9 +701,27 @@ in
Run `occ maintenance:repair --include-expensive` on service start. Run `occ maintenance:repair --include-expensive` on service start.
Larger instances should disable this and run the command at a convenient time Larger instances should disable this and run the command at a convenient time
but Self Host Blocks assumes that it will not be the case for most users. but SHB assumes that it will not be the case for most users.
Note that SHB will still allow the startup
even if the repair failed.
''; '';
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}";
internalUrl = "https://${fqdn}";
internalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}";
};
};
};
}; };
config = lib.mkMerge [ config = lib.mkMerge [
@ -735,7 +763,7 @@ in
config = { config = {
dbtype = "pgsql"; dbtype = "pgsql";
adminuser = cfg.adminUser; adminuser = cfg.initialAdminUsername;
adminpassFile = cfg.adminPass.result.path; adminpassFile = cfg.adminPass.result.path;
}; };
database.createLocally = true; database.createLocally = true;
@ -1114,21 +1142,9 @@ in
} }
]; ];
services.nextcloud.extraApps = services.nextcloud.extraApps = {
if (cfg.version == 31) then inherit (nextcloudApps) oidc_login;
{ };
inherit (nextcloudApps) oidc_login;
}
else
{
oidc_login = pkgs.fetchNextcloudApp {
appName = "oidc_login";
sha256 = "sha256-nI5HSzwlcjWOpo7eWjgzm3BE9rXnqKSC42KSP2LGfdE=";
url = "https://github.com/toony/nextcloud-oidc-login/archive/refs/heads/bugfix/319-nextcloud32-compat.tar.gz";
appVersion = "3.2.2-nextcloud32-compat";
license = "agpl3Plus";
};
};
systemd.services.nextcloud-setup-pre = { systemd.services.nextcloud-setup-pre = {
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -1251,7 +1267,7 @@ in
(lib.mkIf (cfg.enable && cfg.autoDisableMaintenanceModeOnStart) { (lib.mkIf (cfg.enable && cfg.autoDisableMaintenanceModeOnStart) {
systemd.services.nextcloud-setup.preStart = lib.mkBefore '' systemd.services.nextcloud-setup.preStart = lib.mkBefore ''
if [[ -e /var/lib/nextcloud/config/config.php ]]; then if [[ -e /var/lib/nextcloud/config/config.php ]]; then
${occ} maintenance:mode --no-interaction --quiet --off ${occ} maintenance:mode --no-interaction --quiet --off || true
fi fi
''; '';
}) })
@ -1259,7 +1275,7 @@ in
(lib.mkIf (cfg.enable && cfg.alwaysApplyExpensiveMigrations) { (lib.mkIf (cfg.enable && cfg.alwaysApplyExpensiveMigrations) {
systemd.services.nextcloud-setup.script = '' systemd.services.nextcloud-setup.script = ''
if [[ -e /var/lib/nextcloud/config/config.php ]]; then if [[ -e /var/lib/nextcloud/config/config.php ]]; then
${occ} maintenance:repair --include-expensive ${occ} maintenance:repair --include-expensive || true
fi fi
''; '';
}) })

View file

@ -35,8 +35,8 @@ It is based on the nixpkgs Nextcloud server and provides opinionated defaults.
- Forces PostgreSQL as the database. - Forces PostgreSQL as the database.
- Forces Redis as the cache and sets good defaults. - Forces Redis as the cache and sets good defaults.
- Backup of the [`shb.nextcloud.dataDir`][dataDir] through the [backup block](./blocks-backup.html). - Backup of the [`shb.nextcloud.dataDir`][dataDir] through the [backup block](./blocks-backup.html).
- [Dashboard](#services-nextcloudserver-dashboard) for monitoring of reverse proxy, PHP-FPM, and database backups through the [monitoring - [Monitoring Dashboard](#services-nextcloudserver-dashboard) for monitoring of reverse proxy, PHP-FPM, and database backups through the [monitoring block](./blocks-monitoring.html).
block](./blocks-monitoring.html). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.
- [Integration Tests](@REPO@/test/services/nextcloud.nix) - [Integration Tests](@REPO@/test/services/nextcloud.nix)
- Tests system cron job is setup correctly. - Tests system cron job is setup correctly.
- Tests initial admin user and password are setup correctly. - Tests initial admin user and password are setup correctly.
@ -72,10 +72,11 @@ shb.nextcloud = {
domain = "example.com"; domain = "example.com";
subdomain = "n"; subdomain = "n";
defaultPhoneRegion = "US"; defaultPhoneRegion = "US";
adminPass.result = config.shb.sops.secrets."nextcloud/adminpass".result; initialAdminUsername = "root";
adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result;
}; };
shb.sops.secrets."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request; shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request;
``` ```
This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual.
@ -85,7 +86,7 @@ Note though that Nextcloud will not be very happy to be accessed through HTTP,
it much prefers - rightfully - to be accessed through HTTPS. it much prefers - rightfully - to be accessed through HTTPS.
We will set that up in the next section. We will set that up in the next section.
You can now login as the admin user using the username `admin` You can now login as the admin user using the username `root`
and the password defined in `sops.secrets."nextcloud/adminpass"`. and the password defined in `sops.secrets."nextcloud/adminpass"`.
### Nextcloud through HTTPS {#services-nextcloudserver-usage-https} ### Nextcloud through HTTPS {#services-nextcloudserver-usage-https}
@ -165,11 +166,11 @@ shb.nextcloud.apps.ldap = {
port = config.shb.lldap.ldapPort; port = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
adminName = "admin"; adminName = "admin";
adminPassword.result = config.shb.sops.secrets."nextcloud/ldap/adminPassword".result adminPassword.result = config.shb.sops.secret."nextcloud/ldap/adminPassword".result
userGroup = "nextcloud_user"; userGroup = "nextcloud_user";
}; };
shb.sops.secrets."nextcloud/ldap/adminPassword" = { shb.sops.secret."nextcloud/ldap/adminPassword" = {
request = config.shb.nextcloud.apps.ldap.adminPassword.request; request = config.shb.nextcloud.apps.ldap.adminPassword.request;
settings.key = "ldap/userPassword"; settings.key = "ldap/userPassword";
}; };
@ -216,8 +217,8 @@ shb.nextcloud.apps.sso = {
clientID = "nextcloud"; clientID = "nextcloud";
fallbackDefaultAuth = false; fallbackDefaultAuth = false;
secret.result = config.shb.sops.secrets."nextcloud/sso/secret".result; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."nextcloud/sso/secretForAuthelia".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
}; };
shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request; shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request;
@ -344,6 +345,22 @@ Note that this will backup the whole PostgreSQL instance,
not just the Nextcloud database. not just the Nextcloud database.
This limitation will be lifted in the future. This limitation will be lifted in the future.
### Application Dashboard {#services-nextcloudserver-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-nextcloudserver-options-shb.nextcloud.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.Nextcloud = {
sortOrder = 1;
dashboard.request = config.shb.nextcloud.dashboard.request;
};
}
```
### Enable Preview Generator App {#services-nextcloudserver-usage-previewgenerator} ### Enable Preview Generator App {#services-nextcloudserver-usage-previewgenerator}
The following snippet installs and enables the [Preview The following snippet installs and enables the [Preview
@ -537,7 +554,7 @@ by issuing the command `nextcloud-occ config:system:delete instanceid`.
Head over to the [Nextcloud demo](demo-nextcloud-server.html) for a demo that installs Nextcloud with or Head over to the [Nextcloud demo](demo-nextcloud-server.html) for a demo that installs Nextcloud with or
without LDAP integration on a VM with minimal manual steps. without LDAP integration on a VM with minimal manual steps.
## Dashboard {#services-nextcloudserver-dashboard} ## Monitoring Dashboard {#services-nextcloudserver-dashboard}
The dashboard is added to Grafana automatically under "Self Host Blocks > Nextcloud" The dashboard is added to Grafana automatically under "Self Host Blocks > Nextcloud"
as long as the Nextcloud service is [enabled][] as long as the Nextcloud service is [enabled][]

View file

@ -59,12 +59,12 @@ in
WEBUI_NAME = "SelfHostBlocks"; WEBUI_NAME = "SelfHostBlocks";
OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}";
RAG_EMBEDDING_ENGINE = "ollama";
RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5"; RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5";
ENABLE_OPENAI_API = "True"; ENABLE_OPENAI_API = "True";
OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}"; OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}";
ENABLE_WEB_SEARCH = "True"; ENABLE_WEB_SEARCH = "True";
RAG_EMBEDDING_ENGINE = "openai";
} }
''; '';
}; };
@ -160,6 +160,20 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.open-webui.subdomain}.\${config.shb.open-webui.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = ( config = (
@ -216,7 +230,6 @@ in
package = pkgs.open-webui.overrideAttrs (finalAttrs: { package = pkgs.open-webui.overrideAttrs (finalAttrs: {
patches = [ patches = [
../../patches/0001-selfhostblocks-never-onboard.patch ../../patches/0001-selfhostblocks-never-onboard.patch
../../patches/0002-selfhostblocks-do-not-allow-unauthorized-roles.patch
]; ];
}); });
environment = { environment = {

View file

@ -21,9 +21,12 @@ This service sets up [Open WebUI][] which provides a frontend to various LLMs.
- Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy. - Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy.
- Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy. - Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy.
- [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html). - [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html).
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-open-webui-usage-applicationdashboard)
## Usage {#services-open-webui-usage} ## Usage {#services-open-webui-usage}
### Initial Configuration {#services-open-webui-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -63,6 +66,25 @@ The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup)
and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup) and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup)
LDAP groups are created automatically. LDAP groups are created automatically.
### Application Dashboard {#services-open-webui-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-open-webui-options-shb.open-webui.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.OpenWebUI = {
sortOrder = 1;
dashboard.request = config.shb.home-assistant.dashboard.request;
settings.icon = "sh-open-webui";
};
}
```
The icon needs to be set manually otherwise it is not displayed correctly.
## Integration with OLLAMA {#services-open-webui-ollama} ## Integration with OLLAMA {#services-open-webui-ollama}
Assuming ollama is enabled, it will be available on port `config.services.ollama.port`. Assuming ollama is enabled, it will be available on port `config.services.ollama.port`.

View file

@ -59,10 +59,8 @@ let
mkIf mkIf
lists lists
mkOption mkOption
optionals
; ;
inherit (lib.types) inherit (lib.types)
attrs
attrsOf attrsOf
bool bool
enum enum
@ -346,6 +344,20 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.paperless.subdomain}.\${config.shb.paperless.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {

View file

@ -1,7 +1,6 @@
{ {
config, config,
lib, lib,
pkgs,
shb, shb,
... ...
}: }:
@ -132,6 +131,20 @@ in
}; };
}; };
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.pinchflat.subdomain}.\${config.shb.pinchflat.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {

View file

@ -9,8 +9,14 @@ this one sets up, in a fully declarative manner,
LDAP and SSO integration LDAP and SSO integration
and has a nicer option for secrets. and has a nicer option for secrets.
## Features {#services-pinchflat-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-pinchflat-usage-applicationdashboard)
## Usage {#services-pinchflat-usage} ## Usage {#services-pinchflat-usage}
### Initial Configuration {#services-pinchflat-usage-configuration}
The following snippet assumes a few blocks have been setup already: The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS, - the [secrets block](usage.html#usage-secrets) with SOPS,
@ -46,7 +52,7 @@ Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`
The [user](#services-pinchflat-options-shb.pinchflat.ldap.userGroup) The [user](#services-pinchflat-options-shb.pinchflat.ldap.userGroup)
LDAP group is created automatically. LDAP group is created automatically.
## Backup {#services-pinchflat-usage-backup} ### Backup {#services-pinchflat-usage-backup}
Backing up Pinchflat using the [Restic block](blocks-restic.html) is done like so: Backing up Pinchflat using the [Restic block](blocks-restic.html) is done like so:
@ -63,6 +69,22 @@ The name `"pinchflat"` in the `instances` can be anything.
The `config.shb.pinchflat.backup` option provides what directories to backup. The `config.shb.pinchflat.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Pinchflat multiple times. You can define any number of Restic instances to backup Pinchflat multiple times.
### Application Dashboard {#services-pinchflat-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-pinchflat-options-shb.pinchflat.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Media.services.Pinchflat = {
sortOrder = 2;
dashboard.request = config.shb.pinchflat.dashboard.request;
};
}
```
## Options Reference {#services-pinchflat-options} ## Options Reference {#services-pinchflat-options}
```{=include=} options ```{=include=} options

View file

@ -170,6 +170,20 @@ in
default = false; default = false;
example = true; example = true;
}; };
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.vaultwarden.subdomain}.\${config.shb.vaultwarden.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.port}";
};
};
};
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {

View file

@ -12,7 +12,7 @@ This NixOS module is a service that sets up a [Vaultwarden Server](https://githu
- Backup of the data directory through the [backup contract](./contracts-backup.html). - Backup of the data directory through the [backup contract](./contracts-backup.html).
- [Integration Tests](@REPO@/test/services/vaultwarden.nix) - [Integration Tests](@REPO@/test/services/vaultwarden.nix)
- Tests /admin can only be accessed when authenticated with SSO. - Tests /admin can only be accessed when authenticated with SSO.
- Access to advanced options not exposed here thanks to how NixOS modules work. - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard.
## Usage {#services-vaultwarden-usage} ## Usage {#services-vaultwarden-usage}
@ -28,7 +28,7 @@ shb.vaultwarden = {
port = 8222; port = 8222;
databasePassword.result = config.shb.sops.secrets."vaultwarden/db".result; databasePassword.result = config.shb.sops.secret."vaultwarden/db".result;
smtp = { smtp = {
host = "smtp.eu.mailgun.org"; host = "smtp.eu.mailgun.org";
@ -120,6 +120,22 @@ The name `"vaultwarden"` in the `instances` can be anything.
The `config.shb.vaultwarden.backup` option provides what directories to backup. The `config.shb.vaultwarden.backup` option provides what directories to backup.
You can define any number of Restic instances to backup Vaultwarden multiple times. You can define any number of Restic instances to backup Vaultwarden multiple times.
### Application Dashboard {#services-vaultwarden-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#services-vaultwarden-options-shb.vaultwarden.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Documents.services.Vaultwarden = {
sortOrder = 10;
dashboard.request = config.shb.vaultwarden.dashboard.request;
};
}
```
## Maintenance {#services-vaultwarden-maintenance} ## Maintenance {#services-vaultwarden-maintenance}
No command-line tool is provided to administer Vaultwarden. No command-line tool is provided to administer Vaultwarden.

View file

@ -1,29 +1,24 @@
From 6897dd86a41b336c7c03a466990f7e981c5c649c Mon Sep 17 00:00:00 2001 From 8d38721322bc47ce089f8d246513be610e22c187 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com> From: ibizaman <ibizaman@tiserbox.com>
Date: Tue, 23 Sep 2025 11:36:24 +0200 Date: Sun, 10 May 2026 08:58:10 +0200
Subject: [PATCH] selfhostblocks: never onboard Subject: [PATCH] never onboard
--- ---
backend/open_webui/main.py | 4 +--- backend/open_webui/main.py | 2 --
1 file changed, 1 insertion(+), 3 deletions(-) 1 file changed, 2 deletions(-)
diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py
index 5630a5883..5c7c88a64 100644 index e05497c..826af7b 100644
--- a/backend/open_webui/main.py --- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py +++ b/backend/open_webui/main.py
@@ -1654,11 +1654,9 @@ async def get_app_config(request: Request): @@ -2391,8 +2391,6 @@ async def get_app_config(request: Request):
user = Users.get_user_by_id(data["id"]) user = await Users.get_user_by_id(data['id'])
user_count = Users.get_num_users()
+ # Never onboard
onboarding = False onboarding = False
- if user is None: - if user is None:
- onboarding = user_count == 0 - onboarding = not await Users.has_users()
-
return { user_count = await Users.get_num_users() if app.state.LICENSE_METADATA else None
**({"onboarding": True} if onboarding else {}),
"status": True,
-- --
2.50.1 2.53.0

View file

@ -1,48 +0,0 @@
From fed4cfab1f66e6a2a46dbdd20ad52aee664f06b1 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com>
Date: Thu, 9 Oct 2025 01:37:43 +0200
Subject: [PATCH] selfhostblocks: do not allow unauthorized roles
---
backend/open_webui/constants.py | 2 +-
backend/open_webui/utils/oauth.py | 5 +++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py
index 59ee6aaac..5a42f1805 100644
--- a/backend/open_webui/constants.py
+++ b/backend/open_webui/constants.py
@@ -51,7 +51,7 @@ class ERROR_MESSAGES(str, Enum):
EXISTING_USERS = "You can't turn off authentication because there are existing users. If you want to disable WEBUI_AUTH, make sure your web interface doesn't have any existing users and is a fresh installation."
- UNAUTHORIZED = "401 Unauthorized"
+ UNAUTHORIZED = "Unauthorized"
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
ACTION_PROHIBITED = (
"The requested action has been restricted as a security measure."
diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py
index 9090c38ce..3c68dead4 100644
--- a/backend/open_webui/utils/oauth.py
+++ b/backend/open_webui/utils/oauth.py
@@ -336,8 +336,7 @@ class OAuthManager:
oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES
oauth_roles = []
- # Default/fallback role if no matching roles are found
- role = auth_manager_config.DEFAULT_USER_ROLE
+ role = None
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
@@ -373,6 +372,8 @@ class OAuthManager:
log.debug("Assigned user the admin role")
role = "admin"
break
+ if role is None:
+ raise HTTPException(403, detail=ERROR_MESSAGES.UNAUTHORIZED)
else:
if not user:
# If role management is disabled, use the default role for new users
--
2.50.1

View file

@ -72,7 +72,8 @@ index 00000000000000..8b9a915b18f4d9
+ }; + };
+} +}
From 850339f833113255afca5c6038e9fafa5b59bbb8 Mon Sep 17 00:00:00 2001
From 6666c710b77e53ea274af4c4dddcb9251b0ccf18 Mon Sep 17 00:00:00 2001
From: ibizaman <ibizaman@tiserbox.com> From: ibizaman <ibizaman@tiserbox.com>
Date: Wed, 13 Aug 2025 08:15:12 +0200 Date: Wed, 13 Aug 2025 08:15:12 +0200
Subject: [PATCH 2/2] lldap: add ensure options Subject: [PATCH 2/2] lldap: add ensure options
@ -83,13 +84,13 @@ Subject: [PATCH 2/2] lldap: add ensure options
2 files changed, 498 insertions(+), 17 deletions(-) 2 files changed, 498 insertions(+), 17 deletions(-)
diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix
index e21deef91f3366..03f321996a3c99 100644 index fe956c943281..6097f8d06216 100644
--- a/nixos/modules/services/databases/lldap.nix --- a/nixos/modules/services/databases/lldap.nix
+++ b/nixos/modules/services/databases/lldap.nix +++ b/nixos/modules/services/databases/lldap.nix
@@ -8,6 +8,84 @@ @@ -12,6 +12,84 @@ let
let dbUser = "lldap";
cfg = config.services.lldap; localPostgresql = cfg.database.createLocally && cfg.database.type == "postgresql";
format = pkgs.formats.toml { }; localMysql = cfg.database.createLocally && cfg.database.type == "mariadb";
+ +
+ inherit (lib) mkOption types; + inherit (lib) mkOption types;
+ +
@ -171,7 +172,7 @@ index e21deef91f3366..03f321996a3c99 100644
in in
{ {
options.services.lldap = with lib; { options.services.lldap = with lib; {
@@ -15,6 +93,8 @@ in @@ -19,6 +97,8 @@ in
package = mkPackageOption pkgs "lldap" { }; package = mkPackageOption pkgs "lldap" { };
@ -180,7 +181,7 @@ index e21deef91f3366..03f321996a3c99 100644
environment = mkOption { environment = mkOption {
type = with types; attrsOf str; type = with types; attrsOf str;
default = { }; default = { };
@@ -169,6 +249,198 @@ in @@ -203,6 +283,198 @@ in
If that is okay for you and you want to silence the warning, set this option to `true`. If that is okay for you and you want to silence the warning, set this option to `true`.
''; '';
}; };
@ -379,7 +380,7 @@ index e21deef91f3366..03f321996a3c99 100644
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
@@ -185,25 +457,77 @@ in @@ -219,25 +491,77 @@ in
(cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null; (cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null;
message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`."; message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`.";
} }
@ -426,13 +427,13 @@ index e21deef91f3366..03f321996a3c99 100644
]; ];
warnings = warnings =
- lib.optionals (cfg.settings.ldap_user_pass or null != null) [ - lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [
+ (lib.optionals (cfg.ensureAdminPassword != null) [ + (lib.optionals (cfg.ensureAdminPassword != null) [
+ '' + ''
+ lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead. + lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead.
+ '' + ''
+ ]) + ])
+ ++ (lib.optionals (cfg.settings.ldap_user_pass or null != null) [ + ++ (lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [
'' ''
lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead. lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead.
'' ''
@ -468,11 +469,11 @@ index e21deef91f3366..03f321996a3c99 100644
+ '' + ''
+ ]); + ]);
systemd.services.lldap = { services.lldap.settings.database_url = lib.mkIf cfg.database.createLocally (
description = "Lightweight LDAP server (lldap)"; lib.mkDefault (
@@ -226,6 +550,28 @@ in @@ -279,6 +603,28 @@ in
+ '' + ''
${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings} exec ${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings}
''; '';
+ postStart = '' + postStart = ''
+ export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port} + export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port}
@ -500,7 +501,7 @@ index e21deef91f3366..03f321996a3c99 100644
StateDirectory = "lldap"; StateDirectory = "lldap";
StateDirectoryMode = "0750"; StateDirectoryMode = "0750";
diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix
index 8e38d4bdefa31d..47d32c7a2a7bbc 100644 index 8e38d4bdefa3..47d32c7a2a7b 100644
--- a/nixos/tests/lldap.nix --- a/nixos/tests/lldap.nix
+++ b/nixos/tests/lldap.nix +++ b/nixos/tests/lldap.nix
@@ -1,6 +1,9 @@ @@ -1,6 +1,9 @@
@ -697,3 +698,4 @@ index 8e38d4bdefa31d..47d32c7a2a7bbc 100644
+ raise Exception(f'Unexpected value for attribute "mygroupattribute": {othergroup.get('mygroupattribute')}') + raise Exception(f'Unexpected value for attribute "mygroupattribute": {othergroup.get('mygroupattribute')}')
''; '';
} }
--

View file

@ -16,6 +16,7 @@ in
(pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix")
../../modules/blocks/authelia.nix ../../modules/blocks/authelia.nix
../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/hardcodedsecret.nix
../../modules/blocks/ssl.nix
]; ];
networking.hosts = { networking.hosts = {
@ -28,11 +29,29 @@ in
]; ];
}; };
shb.certs.cas.selfsigned.myca = {
name = "My CA";
};
shb.certs.certs.selfsigned = {
"machine.com" = {
ca = config.shb.certs.cas.selfsigned.myca;
domain = "*.machine.com";
group = "nginx";
};
};
systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned."machine.com".systemdService ];
systemd.services.nginx.requires = [
config.shb.certs.certs.selfsigned."machine.com".systemdService
];
shb.lldap = { shb.lldap = {
enable = true; enable = true;
dcdomain = "dc=example,dc=com"; dcdomain = "dc=example,dc=com";
subdomain = "ldap"; subdomain = "ldap";
domain = "machine.com"; domain = "machine.com";
ssl = config.shb.certs.certs.selfsigned."machine.com";
ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result;
jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result;
}; };
@ -50,6 +69,8 @@ in
enable = true; enable = true;
subdomain = "authelia"; subdomain = "authelia";
domain = "machine.com"; domain = "machine.com";
ssl = config.shb.certs.certs.selfsigned."machine.com";
ldapHostname = "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}"; ldapHostname = "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}";
ldapPort = config.shb.lldap.ldapPort; ldapPort = config.shb.lldap.ldapPort;
dcdomain = config.shb.lldap.dcdomain; dcdomain = config.shb.lldap.dcdomain;
@ -136,10 +157,10 @@ in
machine.wait_for_unit("authelia-authelia.machine.com.target") machine.wait_for_unit("authelia-authelia.machine.com.target")
machine.wait_for_open_port(9091) machine.wait_for_open_port(9091)
endpoints = json.loads(machine.succeed("curl -s http://machine.com/.well-known/openid-configuration")) endpoints = json.loads(machine.succeed("curl -s https://authelia.machine.com/.well-known/openid-configuration"))
auth_endpoint = endpoints['authorization_endpoint'] auth_endpoint = endpoints['authorization_endpoint']
print(f"auth_endpoint: {auth_endpoint}") print(f"auth_endpoint: {auth_endpoint}")
if auth_endpoint != "http://machine.com/api/oidc/authorization": if auth_endpoint != "https://authelia.machine.com/api/oidc/authorization":
raise Exception("Unexpected auth_endpoint") raise Exception("Unexpected auth_endpoint")
resp = machine.succeed( resp = machine.succeed(

View file

@ -143,7 +143,7 @@ let
}) })
with subtest("First backup in repo A"): with subtest("First backup in repo A"):
machine.succeed("systemctl start ${backupService}") machine.succeed("systemctl start --wait ${backupService}")
with subtest("New content"): with subtest("New content"):
machine.succeed(""" machine.succeed("""
@ -168,8 +168,12 @@ let
assert_files("/opt/files", {}) assert_files("/opt/files", {})
with subtest("Restore initial content from repo A"): with subtest("Restore initial content from repo A"):
machine.succeed(""" snapshot = machine.succeed("""
${restoreScript} restore latest ${restoreScript} snapshots
""")
print(snapshot)
machine.succeed(f"""
${restoreScript} restore {snapshot}
""") """)
assert_files("/opt/files", { assert_files("/opt/files", {

View file

@ -9,6 +9,9 @@ let
{ ... }: { ... }:
[ [
"grafana.service" "grafana.service"
"fluent-bit.service"
"loki.service"
"netdata.service"
]; ];
waitForPorts = waitForPorts =
{ node, ... }: { node, ... }:
@ -32,6 +35,9 @@ let
shb.monitoring = { shb.monitoring = {
enable = true; enable = true;
inherit (config.test) subdomain domain; inherit (config.test) subdomain domain;
scrutiny.enable = false;
contactPoints = [ "me@example.com" ];
grafanaPort = 3000; grafanaPort = 3000;
adminPassword.result = config.shb.hardcodedsecret."admin_password".result; adminPassword.result = config.shb.hardcodedsecret."admin_password".result;
@ -98,7 +104,19 @@ let
"page.get_by_role('button', name=re.compile('Accept')).click()" "page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Welcome to Grafana')).to_be_visible()" ''
assert page.evaluate("""
async () => {
const r = await fetch('/api/user');
if (!r.ok) return false;
const u = await r.json();
return u.login === 'alice'
&& u.email === 'alice@example.com'
&& u.orgId === 1;
}
""")
''
]; ];
} }
{ {
@ -115,7 +133,19 @@ let
"page.get_by_role('button', name=re.compile('Accept')).click()" "page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Welcome to Grafana')).to_be_visible()" ''
assert page.evaluate("""
async () => {
const r = await fetch('/api/user');
if (!r.ok) return false;
const u = await r.json();
return u.login === 'bob'
&& u.email === 'bob@example.com'
&& u.orgId === 1;
}
""")
''
]; ];
} }
{ {
@ -129,8 +159,103 @@ let
username = "charlie"; username = "charlie";
password = "CharliePassword"; password = "CharliePassword";
nextPageExpect = [ nextPageExpect = [
"page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token? "page.get_by_role('button', name=re.compile('Accept')).click()"
"expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)" "expect(page).to_have_url('https://${config.test.fqdn}/login', timeout=10000)"
''
assert page.evaluate("""
async () => {
const r = await fetch('/api/user');
if (r.status !== 401) return false;
const u = await r.json();
return u.message === 'Unauthorized';
}
""")
''
];
}
];
};
};
scrutiny =
{ lib, ... }:
{
shb.monitoring = {
scrutiny.enable = lib.mkForce true;
};
};
clientScrutinyLoginSso =
{ config, ... }:
{
imports = [
shb.test.baseModule
shb.test.clientLoginModule
];
test = {
subdomain = "scrutiny";
};
test.login = {
startUrl = "https://${config.test.fqdn}";
usernameFieldLabelRegex = "Username";
passwordFieldLabelRegex = "Password";
loginButtonNameRegex = "[sS]ign [iI]n";
testLoginWith = [
{
username = "alice";
password = "NotAlicePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "alice";
password = "AlicePassword";
nextPageExpect = [
''
if page.get_by_role('button', name=re.compile('Accept')).count() > 0:
page.get_by_role('button', name=re.compile('Accept')).click()
''
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Temperature history for each device')).to_be_visible(timeout=20000)"
];
}
{
username = "bob";
password = "NotBobPassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "bob";
password = "BobPassword";
nextPageExpect = [
''
if page.get_by_role('button', name=re.compile('Accept')).count() > 0:
page.get_by_role('button', name=re.compile('Accept')).click()
''
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)"
"expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()"
"expect(page.get_by_text('Temperature history for each device')).to_be_visible(timeout=20000)"
];
}
{
username = "charlie";
password = "NotCharliePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)"
];
}
{
username = "charlie";
password = "CharliePassword";
nextPageExpect = [
"expect(page.get_by_text(re.compile('Hi'))).to_be_visible(timeout=10000)" # This Hi is the Authelia profile page.
# "expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)"
]; ];
} }
]; ];
@ -225,4 +350,36 @@ in
testScript = commonTestScript; testScript = commonTestScript;
}; };
scrutiny_sso = shb.test.runNixOSTest {
name = "monitoring_scrutiny_sso";
node.pkgsReadOnly = false;
nodes.client = {
imports = [
clientScrutinyLoginSso
];
virtualisation.memorySize = 4096;
};
nodes.server =
{ config, pkgs, ... }:
{
imports = [
basic
scrutiny
shb.test.certs
https
shb.test.ldap
ldap
(shb.test.sso config.shb.certs.certs.selfsigned.n)
sso
];
# virtualisation.memorySize = 4096;
};
testScript = commonTestScript;
};
} }

Some files were not shown because too many files have changed in this diff Show more