Compare commits

...

230 commits
v0.6.1 ... 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
ibizaman
8dd6914578 bump to 0.7.3 2026-02-02 12:59:49 +01:00
ibizaman
1392295172 mailserver: update to follow nixpkgs 2026-02-02 12:47:33 +01:00
Cody Wright
09634f4530 fix: add OIDC scopes to Jellyfin service
Also adds <DisabledPushedAuthorization>true</DisablePushedAuthorization>, as
otherwise I get "Error preparing login: Unauthorized - Failed to push
authorization parameters"
2026-02-02 08:14:21 +01:00
ibizaman
aeff324fcf jellyfin: update jellyfin cli for 10.11.6 2026-02-02 08:14:21 +01:00
ibizaman
3f7e6852ce forgejo: update deprecated package 2026-02-02 08:14:21 +01:00
ibizaman
f39f0bd9bf mitmdump: use systemd-python after systemd got deprecated 2026-02-02 08:14:21 +01:00
ibizaman
6ebd3204c9 nit: use shb nixos tester
The pkgs.nixosTest has been deprecated
2026-02-02 08:14:21 +01:00
ibizaman
7b58458270 nit: run linter after update 2026-02-02 08:14:21 +01:00
github-actions[bot]
cfc19f2548 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/5e2a59a5b1a82f89f2c7e598302a9cacebb72a67?narHash=sha256-K5Osef2qexezUfs0alLvZ7nQFTGS9DL2oTVsIXsqLgs%3D' (2025-10-19)
  → 'github:nixos/nixpkgs/bfc1b8a4574108ceef22f02bafcf6611380c100d?narHash=sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI%3D' (2026-01-26)
2026-02-02 08:14:21 +01:00
ibizaman
4b2021885b authelia: fix restartUnits service name 2026-01-25 12:22:52 +01:00
ibizaman
c28e2ae81c restic: fix restartUnits service name 2026-01-25 12:22:52 +01:00
ibizaman
a1b8fc758a mailserver: separate backup for dkim as it uses other user 2026-01-25 12:11:02 +01:00
ibizaman
aeebb4d34b doc: add command to continuously rebuild documentation 2026-01-22 12:07:58 +01:00
ibizaman
6a74419271 monitoring: move dashboards into related modules 2026-01-22 12:07:58 +01:00
ibizaman
7787957488 monitoring: add module option to add dashboards to grafana 2026-01-22 12:07:58 +01:00
ibizaman
ef19e76b97 mailserver: init 2026-01-18 22:06:03 +01:00
ibizaman
a7c73695db bump to version 0.7.2 2026-01-12 11:42:07 +01:00
ibizaman
cac0a5fbf9 nextcloud: accept users only part of the admin group 2026-01-12 09:50:31 +01:00
ibizaman
d3e2834604 pinchflat: remove test for unsupported admin only user 2026-01-12 09:50:31 +01:00
ibizaman
8ad4385ad8 karakeep: remove test for unsupported admin only user 2026-01-12 09:50:31 +01:00
ibizaman
896246a187 forgejo: accept users only part of the admin group 2026-01-12 09:50:31 +01:00
ibizaman
38d8f3034e open-webui: speed up test by enabling offline mode 2026-01-12 09:50:31 +01:00
ibizaman
4d7c5844a8 ldap: make sure admin user can log in too 2026-01-12 09:50:31 +01:00
ibizaman
a584decd52 nextcloud: test for all supported versions 2026-01-11 23:16:55 +01:00
ibizaman
78b5e59830 jellyfin: disable other plugin version 2026-01-11 22:17:37 +01:00
ibizaman
6ffa9127e2 jellyfin: move function to create plugins into lib 2026-01-11 22:17:37 +01:00
ibizaman
f39865391e doc: increase indentation for some firefly-iii subsections 2026-01-11 22:17:37 +01:00
ibizaman
00a288de39 firefly-iii: remove commented code from test 2026-01-11 22:17:37 +01:00
ibizaman
f19d14c509 jellyfin: append version to plugin name 2026-01-10 19:17:50 +01:00
ibizaman
a5b9a62f73 jellyfin: declarative plugins, ldap and sso 2026-01-10 18:18:43 +01:00
ibizaman
6d9831e4f8 authelia: revert do not use dots in systemd service name
The issue is the fqdn is used to setup the nginx virtual host
2026-01-08 21:20:13 +01:00
sivert
e3ed7cd976 firefly-iii: allow admin group access 2026-01-08 20:22:18 +01:00
Piotr Gaczkowski
7ceb03dd78 Update nextcloud-server.nix 2026-01-07 23:36:29 +01:00
ibizaman
83c70fc2f0 docs: add more info on how to run tests 2026-01-07 22:27:58 +01:00
ibizaman
3b9c1622e1 firefly-iii: also add sso to data importer 2026-01-07 22:07:32 +01:00
ibizaman
9152a082bf firefly-iii: add info about data importer in the doc preamble 2025-12-31 09:09:39 +01:00
ibizaman
ffb34bb6bc firefly-iii: init 2025-12-31 09:00:23 +01:00
ibizaman
0d47a24342 nginx: nit change null comparison 2025-12-30 09:49:31 +01:00
ibizaman
50877bfe4c nginx: add phpForwardAuth 2025-12-30 09:49:31 +01:00
ibizaman
edce2b26ad nginx: allow null upstream option 2025-12-30 09:49:31 +01:00
ibizaman
f48048a4f6 nginx: add documentation 2025-12-30 09:49:31 +01:00
ibizaman
f204c98230 postgresql: add usage documentation 2025-12-29 23:26:30 +01:00
ibizaman
7c457c7226 postgresql: fix setting password for username with special chars 2025-12-29 23:26:30 +01:00
ibizaman
21bf3dad20 jellyfin: add instructions about installing plugins 2025-12-29 23:04:36 +01:00
ibizaman
7c260a0b85 nextcloud: fix option documentation 2025-12-29 23:04:36 +01:00
ibizaman
a4144d6cf8 monitoring: try late backup rule without false positive 2025-12-26 22:45:09 +01:00
Piotr Gaczkowski
f17e9885d2 fix: Properly handle Authelia email 2025-12-23 22:33:43 +01:00
Piotr Gaczkowski
f5edf1b339 Update authelia.nix
Fixes #603
2025-12-22 09:01:09 +01:00
dependabot[bot]
32879c36b4 build(deps): bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-18 06:49:38 +01:00
ibizaman
0c1d5ca455 forgejo: use secrets contract for smtp password 2025-12-17 23:21:47 +01:00
ibizaman
328a27d89e monitoring: do not alert of all backups succeed 2025-12-17 23:21:47 +01:00
ibizaman
1aea0300e3 authelia: fix deprecated env var 2025-12-17 23:21:47 +01:00
ibizaman
a512378553 authelia: do not use dots in systemd service name 2025-12-17 23:21:47 +01:00
ibizaman
a3fd215638 bump to 0.7.1 2025-12-08 11:39:35 +01:00
ibizaman
e2f5ffb8d2 ssl: add assertion to catch duplicate domain names 2025-12-08 11:39:35 +01:00
ibizaman
8acea12e8a backup: avoid erroneous templating in alert name 2025-12-08 11:39:35 +01:00
ibizaman
21d0cc11d0 ssl: update documentation 2025-12-08 11:39:35 +01:00
ibizaman
73f2076000 ssl: fix renewal for domain name without subdomain 2025-12-08 11:39:35 +01:00
ibizaman
248317adb5 doc: fix grammar in monitoring chapter 2025-12-08 11:39:35 +01:00
ibizaman
1105fccc2d ssl: add graphs to dashboard and show logs 2025-12-08 11:39:35 +01:00
ibizaman
79d3782412 ssl: reduce number of redundant alerts 2025-12-08 11:39:35 +01:00
ibizaman
113ca40c5f doc: add links to monitoring dashboard from backup doc 2025-12-08 11:39:35 +01:00
cinereal
7d398c769e docs: distinguish description for contract databasebackup from backup's
Signed-off-by: cinereal <cinereal@riseup.net>
2025-12-06 23:00:49 +01:00
ibizaman
ef7f2212f5 bump to 0.7.0 2025-12-03 22:48:38 +01:00
ibizaman
a2457e6d47 docs: update usage documentation 2025-12-03 22:19:45 +01:00
ibizaman
80b4388044 mod: simplify by moving overlay to corresponding module 2025-12-03 22:19:45 +01:00
ibizaman
a695663dc3 doc: copy readme to preface 2025-12-03 22:19:45 +01:00
ibizaman
66b9657a93 doc: update snippets 2025-12-03 22:19:45 +01:00
ibizaman
bcb08761d7 mod: remove system from overlays path and use module 2025-12-03 22:19:45 +01:00
ibizaman
c10cab627f mod: use explicit shb argument to pass lib 2025-12-03 22:19:45 +01:00
ibizaman
fa4c636dbc ci: make job fail if test matrix cannot be generated 2025-12-03 22:19:45 +01:00
ibizaman
b5fdabc567 docs: update changelog 2025-12-03 22:19:45 +01:00
ibizaman
34e54408f0 nextcloud: only setup php-fpm exporter if nextcloud is enabled 2025-12-03 22:19:45 +01:00
ibizaman
6f92c457a2 monitoring: fix link 2025-12-03 22:19:45 +01:00
ibizaman
e0055e97e9 monitoring: ensure admin group 2025-12-03 22:19:45 +01:00
sivert
d655655e0c #587 Implement SHB module for paperless service 2025-11-24 23:01:46 +01:00
dependabot[bot]
879254bcfd build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 22:47:34 +01:00
ibizaman
337d08e3c9 docs: fix note syntax 2025-11-24 22:39:28 +01:00
ibizaman
bef767a77c docs: add recip for dns server 2025-11-24 22:32:01 +01:00
ibizaman
403fb20c1b tests: remove more implicit imports 2025-11-21 09:52:13 +01:00
ibizaman
1cf6f6a2fe monitoring: add sso integration 2025-11-21 09:52:13 +01:00
ibizaman
9677691b2d docs: use better names for secrets 2025-11-21 09:52:13 +01:00
ibizaman
dce519849f karakeep: remove option already set in common lib 2025-11-21 09:52:13 +01:00
ibizaman
583b562ec2 monitoring: fix app name 2025-11-21 09:52:13 +01:00
ibizaman
061c194b1b docs: remove misleading comment 2025-11-21 09:52:13 +01:00
ibizaman
116dc41d6f tests: use shb.lib everywhere 2025-11-18 23:06:26 +01:00
ibizaman
c714bd3d87 karakeep: remove unneeded imports in tests 2025-11-18 23:06:26 +01:00
ibizaman
23649e4c64 grafana: move test file 2025-11-18 23:06:26 +01:00
ibizaman
2c84a51a6b docs: update changelog 2025-11-18 23:06:26 +01:00
ibizaman
0071c8120d docs: add monitoring dashboard 2025-11-17 21:58:38 +01:00
ibizaman
29f2248681 forgejo: update deprecated options 2025-11-17 21:58:38 +01:00
ibizaman
bd286e6423 karakeep: update deprecated options 2025-11-17 21:58:38 +01:00
ibizaman
aa2622847e monitoring: add late backups alert 2025-11-17 21:58:38 +01:00
ibizaman
f93de0420f monitoring: add backup dashboard 2025-11-17 21:58:38 +01:00
ibizaman
693541e78f monitoring: add backup scheduling 2025-11-17 21:58:38 +01:00
ibizaman
42a8b2eebc monitoring: add logs to scraping job dashboard 2025-11-17 21:58:38 +01:00
ibizaman
e04ea6cb24 monitoring: surround with mkMerge 2025-11-17 21:58:38 +01:00
sivert
6e2cf2d035 #585 Immich: allow large uploads from mobile app 2025-11-15 20:48:52 +01:00
sivert
5287f42316 #583 Immich: add authelia bypass for mobile app
Adds bypasses for /api and /.well-known/immich so that the mobile app
authentication works.
2025-11-12 12:36:07 +01:00
168 changed files with 17752 additions and 4217 deletions

View file

@ -23,7 +23,7 @@ on:
required: false
jobs:
automerge:
automerge-rebase:
runs-on: ubuntu-latest
steps:
- uses: reitermarkus/automerge@v2
@ -35,3 +35,16 @@ jobs:
pull-request: ${{ github.event.inputs.pull-request }}
review: ${{ github.event.inputs.review }}
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

@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: tj-actions/changed-files@v47
id: filter
@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
@ -61,13 +61,15 @@ jobs:
keep-outputs = true
keep-failed = true
- name: Setup Caching
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Generate Matrix
id: generate-matrix
run: |
set -euox pipefail
nix flake show --allow-import-from-derivation --json \
| jq -c '.["checks"]["x86_64-linux"] | keys' > .output
@ -81,7 +83,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
@ -91,7 +93,7 @@ jobs:
keep-outputs = true
keep-failed = true
- name: Setup Caching
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -112,7 +114,7 @@ jobs:
check: ${{ fromJson(needs.build-matrix.outputs.check) }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
@ -122,7 +124,7 @@ jobs:
keep-outputs = true
keep-failed = true
- name: Setup Caching
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -131,7 +133,7 @@ jobs:
echo "resultPath=$(nix eval .#checks.x86_64-linux.${{ matrix.check }} --raw)" >> $GITHUB_ENV
nix build --print-build-logs --show-trace .#checks.x86_64-linux.${{ matrix.check }}
- name: Upload Build Result
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v7
if: always() && startsWith(matrix.check, 'vm_')
with:
name: ${{ matrix.check }}

View file

@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- uses: tj-actions/changed-files@v47
id: filter
@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
@ -77,7 +77,7 @@ jobs:
keep-outputs = true
keep-failed = true
- uses: cachix/cachix-action@v16
- uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

View file

@ -9,13 +9,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Caching
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

View file

@ -1,23 +1,43 @@
# See shb.skarabox.com/misc.html
name: Update Flake Lock
on:
workflow_dispatch:
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:
lockfile:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@main
- name: Cache nixpkgs mirror
uses: actions/cache@v5
with:
token: ${{ secrets.GH_TOKEN_FOR_UPDATES }}
pr-labels: |
automerge
path: .cache/nixpkgs.git
key: nixpkgs-mirror-v1
- 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

@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install Nix
uses: cachix/install-nix-action@v31
@ -37,7 +37,7 @@ jobs:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Caching
uses: cachix/cachix-action@v16
uses: cachix/cachix-action@v17
with:
name: selfhostblocks
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
@ -59,13 +59,13 @@ jobs:
public
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: ./public
- name: Deploy to GitHub Pages
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

@ -11,7 +11,7 @@ jobs:
create-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Get version

View file

@ -16,6 +16,145 @@ Template:
# 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
## New Features
- Add [mailserver module](https://shb.skarabox.com/services-mailserver.html) integrating with [Simple NixOS Mailserver](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver) and allowing full backup of an email provider.
- Bump nixpkgs from https://github.com/NixOS/nixpkgs/commit/5e2a59a5b1a82f89f2c7e598302a9cacebb72a67 to https://github.com/NixOS/nixpkgs/commit/bfc1b8a4574108ceef22f02bafcf6611380c100d. [Full diff](https://github.com/nixos/nixpkgs/compare/5e2a59a5b1a82f89f2c7e598302a9cacebb72a67...bfc1b8a4574108ceef22f02bafcf6611380c100d).
On top of minor changes, the most notable one was:
- Updated Jellyfin LDAP and SSO plugins and configuration. @Codys-Wright
## Bug Fixes
- Fix Restic and Authelia modules referencing systemd services without the `.service` suffix and leading to
# v0.7.2
## New Features
- Forgejo uses secrets contract for smtp password.
- Add [Firefly-iii](https://shb.skarabox.com/services-firefly-iii.html) service.
- Jellyfin can [install plugins declaratively](https://shb.skarabox.com/services-jellyfin.html#services-jellyfin-options-shb.jellyfin.plugins).
(Support is quite crude and WIP).
- Jellyfin configures LDAP and SSO fully declaratively, including installing necessary plugins.
- Nextcloud 32 is fully supported thanks to tests for version 31 and 32.
## Fixes
- Revert Authelia to continue using dots in systemd service names.
This caused issue with nginx name resolution.
## Other Changes
- Authelia uses non deprecated `smtp.address` option.
- Add documentation for Nginx block
- Now a user which is only member of the admin LDAP group of a service can login.
Before, some services required a user to be member of both the user and admin LDAP group.
This is ensured by regression tests going forward.
# v0.7.1
## New Features
- Add a Grafana dashboard showing SSL certificate renewal jobs
## Fixes
- Fix let's encrypt certificate renewal jobs by removing duplicated domain name.
Also adds an assertion to catch these kinds of errors.
## Other Changes
- Reduce number of late SSL renewal alert by merging all metrics corresponding to one CN.
# v0.7.0
## Breaking Changes
- Fix pkgs overrides not being passed to users of SelfHostBlocks.
This will require to update your flake to follow the example in the [Usage](https://shb.skarabox.com/usage.html) section.
## New Features
- Add a Grafana dashboard showing stats on backup jobs
and also an alert if a backup job did not run in the last 24 hours or never succeeded in the last 24 hours.
- Add SSO integration in Grafana.
- Add Paperless service.
## Fixes
- Allow to upload big files in Immich.
- Only enable php-fpm Prometheus exporter if Nextcloud is enabled.
## Other Changes
- Add recipe to setup DNS server with DNSSEC.
# v0.6.1
## New Features

View file

@ -98,10 +98,10 @@ To get started using SelfHostBlocks, the following snippet is enough:
outputs = { selfhostblocks, ... }: let
system = "x86_64-linux";
shb = selfhostblocks.lib.${system};
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
in
nixosConfigurations = {
myserver = shb.pkgs.nixosSystem {
myserver = nixpkgs'.nixosSystem {
inherit system;
modules = [
selfhostblocks.nixosModules.default
@ -125,7 +125,6 @@ and also how to handle secrets management with [SOPS][].
Then, to actually configure services, you can choose which one interests you in
the [services section](https://shb.skarabox.com/services.html) of the manual.
Not all services have a corresponding manual page yet.
The [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases.
@ -154,7 +153,7 @@ SelfHostBlocks provides building blocks that take care of common self-hosting ne
- Backup for all services.
- Automatic creation of ZFS datasets per service.
- LDAP and SSO integration for most services.
- Monitoring with Grafana and Prometheus stack with provided dashboards.
- Monitoring with Grafana and Prometheus stack with provided dashboards and integration with Scrutiny.
- Automatic reverse proxy and certificate management for HTTPS.
- VPN and proxy tunneling services.
@ -175,6 +174,8 @@ Also, the stack fits together nicely thanks to [contracts](#contracts).
- Nextcloud
- Audiobookshelf
- Deluge + *arr stack
- Simple NixOS Mailserver
- Firefly-iii
- Forgejo
- Grocy
- Hledger
@ -207,7 +208,7 @@ which altogether provides a solid foundation for self-hosting services:
- BorgBackup
- Davfs
- LDAP
- Monitoring (Grafana - Prometheus - Loki stack)
- Monitoring (Grafana - Prometheus - Loki stack + Scrutiny)
- Nginx
- PostgreSQL
- Restic
@ -249,14 +250,14 @@ shb.nextcloud = {
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
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 = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."nextcloud/sso/secretForAuthelia".result;
secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
};
};
```
@ -274,15 +275,15 @@ shb.forgejo = {
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
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 = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."forgejo/sso/secretForAuthelia".result;
secret.result = config.shb.sops.secret."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result;
};
};
```
@ -290,7 +291,7 @@ shb.forgejo = {
As you can see, they are pretty similar!
This makes setting up a new service pretty easy and intuitive.
SelfHostBlocks provides an ever growing list of [services](#provided-services)
SelfHostBlocks provides an ever growing list of [services](#services)
that are configured in the same way.
### Contracts

View file

@ -1 +1 @@
0.6.1
0.9.0

View file

@ -15,7 +15,6 @@
let
system = "x86_64-linux";
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
inherit (selfhostblocks.lib.${system}) pkgs;
basic =
{ config, ... }:
@ -105,7 +104,7 @@
in
{
nixosConfigurations = {
basic = pkgs.nixosSystem {
basic = nixpkgs'.nixosSystem {
system = "x86_64-linux";
modules = [
basic
@ -113,7 +112,7 @@
];
};
ldap = pkgs.nixosSystem {
ldap = nixpkgs'.nixosSystem {
system = "x86_64-linux";
modules = [
basic

View file

@ -19,31 +19,39 @@
nixosConfigurations =
let
system = "x86_64-linux";
shb = selfhostblocks.lib.${system};
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
# This module makes the assertions happy and the build succeed.
# This is of course wrong and will not work on any real system.
filesystemModule = {
fileSystems."/".device = "/dev/null";
fileSystems."/" = {
device = "/dev/null";
fsType = "none";
};
boot.loader.grub.devices = [ "/dev/null" ];
};
in
{
# Test with:
# nix build .#nixosConfigurations.minimal.config.system.build.toplevel
minimal = shb.pkgs.nixosSystem {
minimal = nixpkgs'.nixosSystem {
inherit system;
modules = [
selfhostblocks.nixosModules.default
filesystemModule
# This modules showcases the use of SHB's lib.
(
{ config, lib, ... }:
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# Using provided nixosSystem directly
# SHB's lib is available under `lib.shb`.
type = lib.shb.secretFileType;
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {
myOption.source = "/a/path";
@ -58,7 +66,7 @@
# Test with:
# nix build .#nixosConfigurations.sops.config.system.build.toplevel
# nix eval .#nixosConfigurations.sops.config.myOption
sops = shb.pkgs.nixosSystem {
sops = nixpkgs'.nixosSystem {
inherit system;
modules = [
selfhostblocks.nixosModules.default
@ -67,12 +75,17 @@
filesystemModule
# This modules showcases the use of SHB's lib.
(
{ config, lib, ... }:
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# Using provided nixosSystem directly
# SHB's lib is available under `lib.shb`.
type = lib.shb.secretFileType;
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {
myOption.source = "/a/path";
@ -84,8 +97,7 @@
];
};
# Note: this is just to show-off a common pitfall for more advanced user.
# Prefer using the `shb.pkgs.nixosSystem` function directly.
# This example shows how to import the nixosSystem patches to nixpkgs manually.
#
# Test with:
# nix build .#nixosConfigurations.lowlevel.config.system.build.toplevel
@ -94,7 +106,7 @@
let
# We must import nixosSystem directly from the patched nixpkgs
# otherwise we do not get the patches.
nixosSystem' = import "${shb.patchedNixpkgs}/nixos/lib/eval-config.nix";
nixosSystem' = import "${nixpkgs'}/nixos/lib/eval-config.nix";
in
nixosSystem' {
inherit system;
@ -103,12 +115,98 @@
filesystemModule
# This modules showcases the use of SHB's lib.
(
{ config, lib, ... }:
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# lib.shb.secretFileType is not available here,
# so we must pass around the shb flake input.
# type = shb.secretFileType;
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {
myOption.source = "/a/path";
# Use the option.
environment.etc.myOption.text = config.myOption.source;
};
}
)
];
};
# This example shows how to apply patches to nixpkgs manually.
#
# Test with:
# nix build .#nixosConfigurations.manual.config.system.build.toplevel
# nix eval .#nixosConfigurations.manual.config.myOption
manual =
let
pkgs = import selfhostblocks.inputs.nixpkgs {
inherit system;
};
nixpkgs' = pkgs.applyPatches {
name = "nixpkgs-patched";
src = selfhostblocks.inputs.nixpkgs;
patches = selfhostblocks.lib.${system}.patches;
};
# We must import nixosSystem directly from the patched nixpkgs
# otherwise we do not get the patches.
nixosSystem' = import "${nixpkgs'}/nixos/lib/eval-config.nix";
in
nixosSystem' {
inherit system;
modules = [
selfhostblocks.nixosModules.default
filesystemModule
# This modules showcases the use of SHB's lib.
(
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {
myOption.source = "/a/path";
# Use the option.
environment.etc.myOption.text = config.myOption.source;
};
}
)
];
};
# Test with:
# nix build .#nixosConfigurations.contractsDirect.config.system.build.toplevel
contractsDirect =
let
nixosSystem' = import "${selfhostblocks.inputs.nixpkgs}/nixos/lib/eval-config.nix";
in
nixosSystem' {
inherit system;
modules = [
filesystemModule
(import "${selfhostblocks}/lib/module.nix")
(
{
config,
lib,
shb,
...
}:
{
options.myOption = lib.mkOption {
# Using provided nixosSystem directly.
# SHB's lib is available under `shb` thanks to the overlay.
type = shb.secretFileType;
};
config = {

View file

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

View file

@ -1,5 +1,5 @@
{
description = "Home Assistant example for Self Host Blocks";
description = "Nextcloud example for Self Host Blocks";
inputs = {
selfhostblocks.url = "github:ibizaman/selfhostblocks";
@ -15,7 +15,6 @@
let
system = "x86_64-linux";
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
inherit (selfhostblocks.lib.${system}) pkgs;
basic =
{ config, ... }:
@ -178,14 +177,14 @@
in
{
nixosConfigurations = {
basic = pkgs.nixosSystem {
basic = nixpkgs'.nixosSystem {
system = "x86_64-linux";
modules = [
sopsConfig
basic
];
};
ldap = pkgs.nixosSystem {
ldap = nixpkgs'.nixosSystem {
system = "x86_64-linux";
modules = [
sopsConfig
@ -193,7 +192,7 @@
ldap
];
};
sso = pkgs.nixosSystem {
sso = nixpkgs'.nixosSystem {
system = "x86_64-linux";
modules = [
sopsConfig

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
others.
Not all blocks are documented yet.
You can find all available blocks [in the repository](@REPO@/modules/blocks).
All blocks are implemented under the blocks folder [in the repository](@REPO@/modules/blocks).
All services in SHB document how to setup the various blocks provided here.
For custom services or those not provided by SHB,
the [Expose a service Recipe](recipes-exposeService.html) explains how to use the blocks here.
## Authentication {#blocks-category-authentication}
@ -28,6 +31,10 @@ modules/blocks/borgbackup/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}
```{=include=} chapters html:into-file=//blocks-postgresql.html
@ -40,12 +47,22 @@ modules/blocks/postgresql/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}
```{=include=} chapters html:into-file=//blocks-ssl.html
modules/blocks/ssl/docs/default.md
```
```{=include=} chapters html:into-file=//blocks-nginx.html
modules/blocks/nginx/docs/default.md
```
## Introspection {#blocks-category-introspection}
```{=include=} chapters html:into-file=//blocks-monitoring.html

View file

@ -37,14 +37,21 @@ Provided contracts are:
- [SSL generator contract](contracts-ssl.html) to generate SSL certificates.
Two providers are implemented: self-signed and Let's Encrypt.
- [Backup contract][] to backup directories.
One provider is implemented: [Restic][].
Two providers are implemented: [BorgBackup][] and [Restic][].
- [Database Backup contract](contracts-databasebackup.html) to backup database dumps.
One provider is implemented: [Restic][].
- [Secret contract](contracts-secret.html) to provide secrets that are deployed outside of the Nix store.
Two providers are implemented: [BorgBackup][] and [Restic][].
- [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][].
- [Dashboard contract](contracts-dashboard.html) to show services in a nice user-facing dashboard.
One provider is implemented: [Homepage][].
[backup contract]: contracts-backup.html
[borgbackup]: blocks-borgbackup.html
[homepage]: services-homepage.html
[restic]: blocks-restic.html
[sanoid]: blocks-sanoid.html
[sops]: blocks-sops.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
```
```{=include=} chapters html:into-file=//contracts-datasetbackup.html
modules/contracts/datasetbackup/docs/default.md
```
```{=include=} chapters html:into-file=//contracts-secret.html
modules/contracts/secret/docs/default.md
```
```{=include=} chapters html:into-file=//contracts-dashboard.html
modules/contracts/dashboard/docs/default.md
```
## Problem Statement {#contracts-why}
Currently in nixpkgs, every module accessing a shared resource
@ -445,7 +460,7 @@ A simplified test for a secret contract would look like the following.
First, there is the generic test:
```nix
{ pkgs, lib, ... }:
{ pkgs, lib, shb, ... }:
let
inherit (lib) getAttrFromPath setAttrByPath;
in
@ -455,7 +470,7 @@ in
modules ? [],
owner ? "root",
content ? "secretPasswordA",
}: pkgs.testers.runNixOSTest {
}: shb.test.runNixOSTest {
inherit name;
nodes.machine = { config, ... }: {

View file

@ -53,15 +53,7 @@ $ nix build .#checks.${system}.modules
$ nix build .#checks.${system}.vm_postgresql_peerAuth
```
Run one VM test interactively:
```bash
$ nix run .#checks.${system}.vm_postgresql_peerAuth.driverInteractive
```
When you get to the shell, run either `start_all()` or `test_script()`. The former just starts all
the VMs and service, then you can introspect. The latter also starts the VMs if they are not yet and
then will run the test script.
### Playwright Tests {#contributing-playwright-tests}
If the test includes playwright tests, you can see the playwright trace with:
@ -69,6 +61,60 @@ 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
```
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}
Run the test in driver interactive mode:
```bash
$ nix run .#checks.${system}.vm_postgresql_peerAuth.driverInteractive
```
When you get to the shell, start the server and/or client with one of the following commands:
```bash
server.start()
client.start()
start_all()
```
To run the test from the shell, use `test_script()`.
Note that if the test script ends in error,
the shell will exit and you will need to restart the VMs.
After the shell started, you will see lines like so:
```
SSH backdoor enabled, the machines can be accessed like this:
Note: this requires systemd-ssh-proxy(1) to be enabled (default on NixOS 25.05 and newer).
client: ssh -o User=root vsock/3
server: ssh -o User=root vsock/4
```
With the following command, you can directly access the server's nginx instance with your browser at `http://localhost:8000`:
```bash
ssh-keygen -R vsock/4; ssh -o User=root -L 8000:localhost:80 vsock/4
```
## Upload test results to CI {#contributing-upload}
Github actions do now have hardware acceleration, so running them there is not slow anymore. If
@ -80,6 +126,18 @@ After running the `nix-fast-build` command from the previous section, run:
$ find . -type l -name "result-vm_*" | xargs readlink | nix run nixpkgs#cachix -- push selfhostblocks
```
## Upload package to CI {#contributing-upload-package}
In the rare case where a package must be built but cannot in CI,
for example because of not enough memory,
you can push the package directly to the cache with:
```bash
nix build .#checks.x86_64-linux.vm_karakeep_backup.nodes.server.services.karakeep.package
readlink result | nix run nixpkgs#cachix -- push selfhostblocks
```
## Deploy using colmena {#contributing-deploy-colmena}
```bash

View file

@ -124,7 +124,7 @@ let
outputPath = "share/doc/selfhostblocks";
manpage-urls = pkgs.writeText "manpage-urls.json" ''{}'';
manpage-urls = pkgs.writeText "manpage-urls.json" "{}";
in
stdenv.mkDerivation {
name = "self-host-blocks-manual";

View file

@ -28,6 +28,10 @@ blocks.md
recipes.md
```
```{=include=} chapters html:into-file=//misc.html
misc.md
```
```{=include=} chapters html:into-file=//demos.html
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

@ -10,22 +10,203 @@ Feel free to join the dedicated Matrix room
[matrix.org#selfhostblocks](https://matrix.to/#/#selfhostblocks:matrix.org).
:::
Self Host Blocks intends to help you self host any service you would like
with best practices out of the box.
SelfHostBlocks is:
Compared to the stock nixpkgs experience, Self Host Blocks provides
an unified interface to setup common dependencies, called blocks
in this project:
- Your escape from the cloud, for privacy and data sovereignty enthusiast. [Why?](#preface-why-self-hosting)
- A groupware to self-host [all your data](#preface-services): documents, pictures, calendars, contacts, etc.
- An opinionated NixOS server management OS for a [safe self-hosting experience](#preface-features).
- A NixOS distribution making sure all services build and work correctly thanks to NixOS VM tests.
- A collection of NixOS modules standardizing options so configuring services [look the same](#preface-unified-interfaces).
- A testing ground for [contracts](#preface-contracts) which intents to make nixpkgs modules more modular.
- [Upstreaming][] as much as possible.
- reverse proxy
- TLS certificate management
- serving service under subdomain
- backup
[upstreaming]: https://github.com/pulls?page=1&q=created%3A%3E2023-06-01+is%3Apr+author%3Aibizaman+archived%3Afalse+-repo%3Aibizaman%2Fselfhostblocks+-repo%3Aibizaman%2Fskarabox
## Why Self-Hosting {#preface-why-self-hosting}
It is obvious by now that
a deep dependency on proprietary service providers - "the cloud" -
is a significant liability.
One aspect often talked about is privacy
which is inherently not guaranteed when using a proprietary service
and is a valid concern.
A more punishing issue is having your account closed or locked
without prior warning
When that happens,
you get an instantaneous sinking feeling in your stomach
at the realization you lost access to your data,
possibly without recourse.
Hosting services yourself is the obvious alternative
to alleviate those concerns
but it tends to require a lot of technical skills and time.
SelfHostBlocks (together with its sibling project [Skarabox][])
aims to lower the bar to self-hosting,
and provides an opinionated server management system based on NixOS modules
embedding best practices.
Contrary to other server management projects,
its main focus is ease of long term maintenance
before ease of installation.
To achieve this, it provides building blocks to setup services.
Some are already provided out of the box,
and customizing or adding additional ones is done easily.
The building blocks fit nicely together thanks to [contracts](#contracts)
which SelfHostBlocks sets out to introduce into nixpkgs.
This will increase modularity, code reuse
and empower end users to assemble components
that fit together to build their server.
## Usage {#preface-usage}
> **Caution:** You should know that although I am using everything in this repo for my personal
> production server, this is really just a one person effort for now and there are most certainly
> bugs that I didn't discover yet.
To get started using SelfHostBlocks, the following snippet is enough:
```nix
{
inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks";
outputs = { selfhostblocks, ... }: let
system = "x86_64-linux";
nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs;
in
nixosConfigurations = {
myserver = nixpkgs'.nixosSystem {
inherit system;
modules = [
selfhostblocks.nixosModules.default
./configuration.nix
];
};
};
}
```
SelfHostBlocks provides its own patched nixpkgs, so you are required to use it
otherwise evaluation can quickly break.
[The usage section](https://shb.skarabox.com/usage.html) of the manual has
more details and goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][]
and also how to handle secrets management with [SOPS][].
[Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena
[nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild
[deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs
[SOPS]: https://shb.skarabox.com/usage.html#usage-secrets
Then, to actually configure services, you can choose which one interests you in
the [services section](https://shb.skarabox.com/services.html) of the manual.
The [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases.
Head over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org)
for any remaining question, or just to say hi :)
### Installation From Scratch {#preface-usage-installation-from-scratch}
I do recommend for this my sibling project [Skarabox][]
which bootstraps a new server and sets up a few tools:
- Create a bootable ISO, installable on an USB key.
- Handles one or two (in raid 1) SSDs for root partition.
- Handles two (in raid 1) or more hard drives for data partition.
- [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) to install NixOS headlessly.
- [disko](https://github.com/nix-community/disko) to format the drives using native ZFS encryption with remote unlocking through ssh.
- [sops-nix](https://github.com/Mic92/sops-nix) to handle secrets.
- [deploy-rs](https://github.com/serokell/deploy-rs) to deploy updates.
[Skarabox]: https://github.com/ibizaman/skarabox
## Features {#preface-features}
SelfHostBlocks provides building blocks that take care of common self-hosting needs:
- Backup for all services.
- Automatic creation of ZFS datasets per service.
- LDAP and SSO integration for most services.
- Monitoring with Grafana and Prometheus stack with provided dashboards.
- Automatic reverse proxy and certificate management for HTTPS.
- VPN and proxy tunneling services.
Great care is taken to make the proposed stack robust.
This translates into a test suite comprised of automated NixOS VM tests
which includes playwright tests to verify some important workflow
like logging in.
This test suite also serves as a guaranty that all services provided by SelfHostBlocks
all evaluate, build and work correctly together. It works similarly as a distribution but here it's all [automated](#preface-updates).
Also, the stack fits together nicely thanks to [contracts](#preface-contracts).
### Services {#preface-services}
[Provided services](https://shb.skarabox.com/services.html) are:
- Nextcloud
- Audiobookshelf
- Deluge + *arr stack
- Forgejo
- Grocy
- Hledger
- Home-Assistant
- Jellyfin
- Karakeep
- Open WebUI
- Pinchflat
- Vaultwarden
Like explained above, those services all benefit from
out of the box backup,
LDAP and SSO integration,
monitoring with Grafana,
reverse proxy and certificate management
and VPN integration for the *arr suite.
Some services do not have an entry yet in the manual.
To know options for those, the only way for now
is to go to the [All Options][] section of the manual.
[All Options]: https://shb.skarabox.com/options.html
### Blocks {#preface-blocks}
The services above rely on the following [common blocks][]
which altogether provides a solid foundation for self-hosting services:
- Authelia
- BorgBackup
- Davfs
- LDAP
- SSO.
- Monitoring (Grafana - Prometheus - Loki stack)
- Nginx
- PostgreSQL
- Restic
- Sops
- SSL
- Tinyproxy
- VPN
- ZFS
Those blocks can be used with services
not provided by SelfHostBlocks as shown [in the manual][common blocks].
[common blocks]: https://shb.skarabox.com/blocks.html
The manual also provides documentation for each individual blocks.
### Unified Interfaces {#preface-unified-interfaces}
Thanks to the blocks,
SelfHostBlocks provides an unified configuration interface
for the services it provides.
Compare the configuration for Nextcloud and Forgejo.
The following snippets focus on similitudes and assume the relevant blocks are configured off-screen.
The following snippets focus on similitudes and assume the relevant blocks - like secrets - are configured off-screen.
It also does not show specific options for each service.
These are still complete snippets that configure HTTPS,
subdomain serving the service, LDAP and SSO integration.
```nix
shb.nextcloud = {
@ -40,14 +221,14 @@ shb.nextcloud = {
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
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 = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."nextcloud/sso/secretForAuthelia".result;
secret.result = config.shb.sops.secret."nextcloud/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result;
};
};
```
@ -65,56 +246,160 @@ shb.forgejo = {
host = "127.0.0.1";
port = config.shb.lldap.ldapPort;
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 = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."forgejo/sso/secretForAuthelia".result;
secret.result = config.shb.sops.secret."forgejo/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result;
};
};
```
SHB facilitates testing NixOS and slowly switching an existing installation to NixOS.
As you can see, they are pretty similar!
This makes setting up a new service pretty easy and intuitive.
To achieve this, SHB pioneers [contracts][]
which allows you, the final user, to be more in control of which piece go where.
SelfHostBlocks provides an ever growing list of [services](#preface-services)
that are configured in the same way.
### Contracts {#preface-contracts}
To make building blocks that fit nicely together,
SelfHostBlocks pioneers [contracts][] which allows you, the final user,
to be more in control of which piece goes where.
This lets you choose, for example,
any reverse proxy you want or any database you want,
without requiring work from maintainers of the services you want to self host.
[contracts]: contracts.html
An [RFC][] exists to upstream this concept into `nixpkgs`.
The [manual][contracts] also provides an explanation of the why and how of contracts.
To achieve this, Self Host Blocks provides building blocks
which each provide part of what a self hosted app should do (SSO, HTTPS, etc.).
It also provides some services that are already integrated with all those building blocks.
Also, two videos exist of me presenting the topic,
the first at [NixCon North America in spring of 2024][NixConNA2024]
and the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024].
Self Host Blocks uses the full power of NixOS modules to achieve these goals.
Blocks and service are both NixOS modules.
[contracts]: https://shb.skarabox.com/contracts.html
[RFC]: https://github.com/NixOS/rfcs/pull/189
[NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM
[NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc
## Next Steps {#next-steps}
### Interfacing With Other OSes {#preface-interface}
To get started using SelfHostBlocks,
follow [the usage section](https://shb.skarabox.com/usage.html) of the manual.
It goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][]
and also goes over secrets management with [SOPS][].
Thanks to [contracts](#contracts), one can interface NixOS
with systems on other OSes.
The [RFC][] explains how that works.
[Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena
[nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild
[deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs
[SOPS]: https://shb.skarabox.com/usage.html#usage-secrets
### Sitting on the Shoulders of a Giant {#preface-giants}
Then, depending on what you want to do:
By using SelfHostBlocks, you get all the benefits of NixOS
which are, for self hosted applications specifically:
- You are new to self hosting and want pre-configured services to deploy easily.
Look at the [services section](services.html).
- You are a seasoned self-hoster but want to enhance some services you deploy already.
Go to the [blocks section](blocks.html) and the [recipes section](recipes.html).
- You are a user of Self Host Blocks but would like to use your own implementation for a block.
Go to the [contracts section](https://shb.skarabox.com/contracts.html).
- declarative configuration;
- atomic configuration rollbacks;
- real programming language to define configurations;
- create your own higher level abstractions on top of SelfHostBlocks;
- integration with the rest of nixpkgs;
- much fewer "works on my machine" type of issues.
Head over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org)
for any remaining question, or just to say hi :)
### Automatic Updates {#preface-updates}
SelfHostBlocks follows nixpkgs unstable branch closely.
There is a GitHub action running every couple of days that updates
the `nixpkgs` input in the root `flakes.nix`,
runs the tests and merges the PR automatically
if the tests pass.
A release is then made every few commits,
whenever deemed sensible.
On your side, to update I recommend pinning to a release
with the following command,
replacing the RELEASE with the one you want:
```bash
RELEASE=0.2.4
nix flake update \
--override-input selfhostblocks github:ibizaman/selfhostblocks/$RELEASE \
selfhostblocks
```
### Demos {#preface-demos}
Demos that start and deploy a service
on a Virtual Machine on your computer are located
under the [demo](./demo/) folder.
These show the onboarding experience you would get
if you deployed one of the services on your own server.
## Roadmap {#preface-roadmap}
Currently, the Nextcloud and Vaultwarden services
and the SSL and backup blocks
are the most advanced and most documented.
Documenting all services and blocks will be done
as I make all blocks and services use the contracts.
Upstreaming changes is also on the roadmap.
Check the [issues][] and the [milestones]() to see planned work.
Feel free to add more or to contribute!
[issues]: (https://github.com/ibizaman/selfhostblocks/issues)
[milestones]: https://github.com/ibizaman/selfhostblocks/milestones
All blocks and services have NixOS tests.
Also, I am personally using all the blocks and services in this project, so they do work to some extent.
## Community {#preface-community}
This project has been the main focus
of my (non work) life for the past 3 year now
and I intend to continue working on this for a long time.
All issues and PRs are welcome:
- Use this project. Something does not make sense? Something's not working?
- Documentation. Something is not clear?
- New services. Have one of your preferred service not integrated yet?
- Better patterns. See something weird in the code?
For PRs, if they are substantial changes, please open an issue to
discuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html)
of the manual.
Issues that are being worked on are labeled with the [in progress][] label.
Before starting work on those, you might want to talk about it in the issue tracker
or in the [matrix][] channel.
The prioritized issues are those belonging to the [next milestone][milestone].
Those issues are not set in stone and I'd be very happy to solve
an issue an user has before scratching my own itch.
[in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22
[matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org
[milestone]: https://github.com/ibizaman/selfhostblocks/milestones
One aspect that's close to my heart is I intent to make SelfHostBlocks the lightest layer on top of nixpkgs as
possible. I want to upstream as much as possible. I will still take some time to experiment here but
when I'm satisfied with how things look, I'll upstream changes.
## Funding {#preface-funding}
I was lucky to [obtain a grant][nlnet] from NlNet which is an European fund,
under [NGI Zero Core][NGI0],
to work on this project.
This also funds the contracts RFC.
Go apply for a grant too!
[nlnet]: https://nlnet.nl/project/SelfHostBlocks
[NGI0]: https://nlnet.nl/core/
## License {#preface-license}
I'm following the [Nextcloud](https://github.com/nextcloud/server) license which is AGPLv3.
See [this article](https://www.fsf.org/bulletin/2021/fall/the-fundamentals-of-the-agplv3) from the FSF that explains what this license adds to the GPL one.

View file

@ -3,6 +3,10 @@
This section of the manual gives you easy to follow recipes for common use cases.
```{=include=} chapters html:into-file=//recipes-dnsServer.html
recipes/dnsServer.md
```
```{=include=} chapters html:into-file=//recipes-exposeService.html
recipes/exposeService.md
```

186
docs/recipes/dnsServer.md Normal file
View file

@ -0,0 +1,186 @@
<!-- Read these docs at https://shb.skarabox.com -->
# Self-Host a DNS server {#recipes-dnsServer}
This recipe will show how to setup [dnsmasq][] as a local DNS server
that forwards all queries to your own domain `example.com` to a local IP - your server running SelfHostBlocks for example.
[dnsmasq]: https://dnsmasq.org/doc.html
Other DNS queries will be forwarded to an external DNS server
using [DNSSEC][] to encrypt your queries.
[DNSSEC]: https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions
For this to work, you must configure the DHCP server of your network
to set the DNS server to the IP of the host where the DNS server is running.
Usually, your ISP's router can do this but probably easier is to disable completely that DHCP server
and also self-host the DHCP server.
This recipe shows how to do that too.
## Why {#recipes-dnsServer-why}
_You want to hide your DNS queries from your ISP or other prying eyes._
Even if you use HTTPS to access an URL,
DNS queries are by default made in plain text.
Crazy, right?
So, even if the actual communication is encrypted,
everyone can see which site you're trying to access.
Using DNSSEC means encrypting the traffic to your preferred external DNS server.
Of course, that server will see what domain names you're trying to resolve,
but at least intermediary hops will not be able to anymore.
_You want more control on which DNS queries can be made._
Self-hosting your own DNS server means you can block some domains or subdomains.
This is done in practice by instructing your DNS server
to fail resolving some domains or subdomains.
Want to block Facebook for every host in the house?
That's the way to go.
Some routers allow this level of fine-tuning but if not,
self-hosting your own DNS server is the way to go.
## Drawbacks {#recipes-dnsServer-drawbacks}
Although it has some nice advantages,
self-hosting your own DNS server has one major drawback:
if it goes down, the whole household will be impacted.
By experience, it takes up to 5 minutes for others to notice something is wrong with internet.
So be wary when you deploy a new config.
## Recipe {#recipes-dnsServer-recipe}
The following snippet:
- Opens UDP port 53 in the firewall which is the ubiquitous (and hardcoded, crazy I know) port for DNS queries.
- Disables the default DNS resolver.
- Sets up dnsmasq as the DNS server.
- Optionally sets up dnsmasq as the DHCP server.
- Answers all DNS requests to your domain with the internal IP of the server.
- Forwards all other DNS requests to an external DNS server using DNSSEC.
This is done using [stubby][].
[stubby]: https://dnsprivacy.org/dns_privacy_daemon_-_stubby/
For more information about options, read the dnsmasq [manual][].
[manual]: https://dnsmasq.org/docs/dnsmasq-man.html
```nix
let
# Replace these values with what matches your network.
domain = "example.com";
serverIP = "192.168.1.30";
# This port is used internally for dnsmasq to talk to stubby on the loopback interface.
# Only change this if that port is already taken.
stubbyPort = 53000;
in
{
networking.firewall.allowedUDPPorts = [ 53 ];
services.resolved.enable = false;
services.dnsmasq = {
enable = true;
settings = {
inherit domain;
# Redirect queries to the stubby instance.
server = [
"127.0.0.1#${stubbyPort}"
"::1#${stubbyPort}"
];
# We do trust our own instance of stubby
# so we can proxy DNSSEC stuff.
# I'm not sure how useful this is.
proxy-dnssec = true;
# Log all queries.
# This produces a lot of log lines
# and looking at those can be scary!
log-queries = true;
# Do not look at /etc/resolv.conf
no-resolv = true;
# Do not forward externally reverse DNS lookups for internal IPs.
bogus-priv = true;
address = [
"/.${domain}/${serverIP}"
# You can redirect anything anywhere too.
"/pikvm.${domain}/192.168.1.31"
];
};
};
services.stubby = {
enable = true;
# It's a bit weird but default values comes from the examples settings hosted at
# https://github.com/getdnsapi/stubby/blob/develop/stubby.yml.example
settings = pkgs.stubby.passthru.settingsExample // {
listen_addresses = [
"127.0.0.1@${stubbyPort}"
"0::1@${stubbyPort}"
];
# For more example of good DNS resolvers,
# head to https://dnsprivacy.org/public_resolvers/
#
# The digest comes from https://nixos.wiki/wiki/Encrypted_DNS#Stubby
upstream_recursive_servers = [
{
address_data = "9.9.9.9";
tls_auth_name = "dns.quad9.net";
tls_pubkey_pinset = [
{
digest = "sha256";
value = "i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I=";
}
];
}
{
address_data = "149.112.112.112";
tls_auth_name = "dns.quad9.net";
tls_pubkey_pinset = [
{
digest = "sha256";
value = "i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I=";
}
];
}
];
};
};
}
```
Optionally, to use dnsmasq as the DHCP server too,
use the following snippet:
```nix
services.dnsmasq = {
settings = {
# When switching DNS server, accept old leases from previous server.
dhcp-authoritative = true;
# Adapt to your needs
# <ip-from>,<ip-to>,<mask>,<lease-ttl>
dhcp-range = "192.168.1.101,192.168.1.150,255.255.255.0,6h";
# Static DNS leases if needed.
# Choose an IP outside of the DHCP range
# <mac-address>,<DNS name>,<ip>,<lease-ttl>
dhcp-host = [
"12:34:56:78:9a:bc,server,192.168.1.50,infinite"
];
# Set default route to the router that can acccess the internet.
dhcp-option = [
"3,192.168.1.1"
];
};
};
```

View file

@ -20,6 +20,7 @@ let
fqdn = "${subdomain}.${domain}";
listenPort = 9000;
dataDir = "/var/lib/awesome";
ldapGroup = "awesome_user";
in
```
@ -75,11 +76,11 @@ shb.nginx.vhosts = [
{
inherit subdomain 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}";
autheliaRules = [{
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 ];
settings.enable = true;
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" = {
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

@ -295,12 +295,12 @@ let
in {
# Test variants (all 6 required)
basic = lib.shb.runNixOSTest { ... };
backup = lib.shb.runNixOSTest { ... };
https = lib.shb.runNixOSTest { ... };
ldap = lib.shb.runNixOSTest { ... };
monitoring = lib.shb.runNixOSTest { ... };
sso = lib.shb.runNixOSTest { ... };
basic = lib.shb.test.runNixOSTest { ... };
backup = lib.shb.test.runNixOSTest { ... };
https = lib.shb.test.runNixOSTest { ... };
ldap = lib.shb.test.runNixOSTest { ... };
monitoring = lib.shb.test.runNixOSTest { ... };
sso = lib.shb.test.runNixOSTest { ... };
}
```
@ -461,6 +461,13 @@ nix flake check
nix build .#manualHtml
```
To continuously rebuild the documentation of file change, run the following command.
To exit, you'll need to do Ctrl-C twice in a row.
```bash
nix run .#manualHtml-watch
```
### Iterative Development Approach {#iterative-development-approach}
1. **Start with basic functionality** - get core service working

View file

@ -1,9 +1,9 @@
<!-- Read these docs at https://shb.skarabox.com -->
# 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
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.
::: {.note}
@ -13,17 +13,20 @@ Not all services are yet documented. You can find all available services [in the
The following table summarizes for each documented service what features it provides. More
information is provided in the respective manual sections.
| Service | Backup | Reverse Proxy | SSO | LDAP | Monitoring | Profiling |
|----------------------|--------|---------------|-----|-------|------------|-----------|
| [*Arr][] | Y (1) | Y | Y | Y (4) | Y (2) | N |
| [Forgejo][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Home-Assistant][] | Y (1) | Y | N | Y | Y (2) | N |
| [Jellyfin][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Karakeep][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Nextcloud Server][] | Y (1) | Y | Y | Y | Y (2) | P (3) |
| [Open WebUI][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Pinchflat][] | Y | Y | Y | Y (4) | Y (5) | N |
| [Vaultwarden][] | Y (1) | Y | Y | Y | Y (2) | N |
| Service | Backup | Reverse Proxy | SSO | LDAP | Monitoring | Profiling |
|-----------------------------|--------|---------------|-----|-------|------------|-----------|
| [*Arr][] | Y (1) | Y | Y | Y (4) | Y (2) | N |
| [Firefly-iii][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Forgejo][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Home-Assistant][] | Y (1) | Y | N | Y | Y (2) | N |
| [Homepage][] | Y (1) | Y | N | Y | Y (2) | N |
| [Jellyfin][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Karakeep][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Nextcloud Server][] | Y (1) | Y | Y | Y | Y (2) | P (3) |
| [Open WebUI][] | Y (1) | Y | Y | Y | Y (2) | N |
| [Pinchflat][] | Y | Y | Y | Y (4) | Y (5) | N |
| [Simple NixOS Mailserver][] | Y | Y | N | Y | Y | N |
| [Vaultwarden][] | Y (1) | Y | Y | Y | Y (2) | N |
Legend: **N**: no but WIP; **P**: partial; **Y**: yes
@ -35,21 +38,36 @@ Legend: **N**: no but WIP; **P**: partial; **Y**: yes
4. Uses LDAP indirectly through forward auth.
[*Arr]: services-arr.html
[Firefly-iii]: services-firefly-iii.html
[Forgejo]: services-forgejo.html
[Home-Assistant]: services-home-assistant.html
[Homepage]: services-homepage.html
[Jellyfin]: services-jellyfin.html
[Karakeep]: services-karakeep.html
[Nextcloud Server]: services-nextcloud.html
[Open WebUI]: services-open-webui.html
[Pinchflat]: services-pinchflat.html
[Simple NixOS Mailserver]: services-mailserver.html
[Vaultwarden]: services-vaultwarden.html
## Dashboard {#services-category-dashboard}
```{=include=} chapters html:into-file=//services-homepage.html
modules/services/homepage/docs/default.md
```
## Documents {#services-category-documents}
```{=include=} chapters html:into-file=//services-nextcloud.html
modules/services/nextcloud-server/docs/default.md
```
## Emails {#services-category-emails}
```{=include=} chapters html:into-file=//services-mailserver.html
modules/services/mailserver/docs/default.md
```
## Passwords {#services-category-passwords}
```{=include=} chapters html:into-file=//services-vaultwarden.html
@ -91,3 +109,9 @@ modules/services/jellyfin/docs/default.md
```{=include=} chapters html:into-file=//services-pinchflat.html
modules/services/pinchflat/docs/default.md
```
## Finance {#services-category-finance}
```{=include=} chapters html:into-file=//services-firefly-iii.html
modules/services/firefly-iii/docs/default.md
```

File diff suppressed because it is too large Load diff

View file

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

371
flake.nix
View file

@ -20,67 +20,66 @@
nmdsrc,
...
}:
let
shbPatches =
system:
nixpkgs.legacyPackages.${system}.lib.optionals
(system == "x86_64-linux" || system == "aarch64-linux")
[
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.
./patches/lldap.patch
./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch
# Leaving commented out as an example.
# (originPkgs.fetchpatch {
# url = "https://github.com/NixOS/nixpkgs/pull/317107.patch";
# hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk=";
# })
];
patchNixpkgs =
{
nixpkgs,
patches,
system,
}:
nixpkgs.legacyPackages.${system}.applyPatches {
name = "nixpkgs-patched";
src = nixpkgs;
inherit patches;
};
patchedNixpkgs =
system:
let
patched = patchNixpkgs {
nixpkgs = inputs.nixpkgs;
patches = shbPatches system;
inherit system;
};
in
patched
// {
nixosSystem = args: import "${patched}/nixos/lib/eval-config.nix" args;
};
pkgs' =
system:
import (patchedNixpkgs system) {
inherit system;
config.allowUnfree = true;
};
in
flake-utils.lib.eachDefaultSystem (
system:
let
originPkgs = nixpkgs.legacyPackages.${system};
shbPatches = originPkgs.lib.optionals (system == "x86_64-linux") [
# Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged.
./patches/lldap.patch
./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch
# Leaving commented out as an example.
# (originPkgs.fetchpatch {
# url = "https://github.com/NixOS/nixpkgs/pull/317107.patch";
# hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk=";
# })
];
patchNixpkgs =
{
nixpkgs,
patches,
system,
}:
nixpkgs.legacyPackages.${system}.applyPatches {
name = "nixpkgs-patched";
src = nixpkgs;
inherit patches;
};
patchedNixpkgs = (
patchNixpkgs {
nixpkgs = inputs.nixpkgs;
patches = shbPatches;
inherit system;
}
);
pkgs = import patchedNixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [
(final: prev: {
lib = prev.lib // {
shb = self.lib.${system};
evalModules =
args:
((prev.lib.makeOverridable prev.lib.evalModules) args).override (prevAttrs: {
specialArgs = (prevAttrs.specialArgs or { }) // {
inherit (pkgs) lib;
};
});
};
nixosSystem =
args:
((prev.lib.makeOverridable (import "${patchedNixpkgs}/nixos/lib/eval-config.nix")) args).override
(prevAttrs: {
inherit (pkgs) lib;
});
})
];
};
pkgs = pkgs' system;
# The contract dummies are used to show options for contracts.
contractDummyModules = [
modules/contracts/backup/dummyModule.nix
modules/contracts/dashboard/dummyModule.nix
modules/contracts/databasebackup/dummyModule.nix
modules/contracts/datasetbackup/dummyModule.nix
modules/contracts/secret/dummyModule.nix
modules/contracts/ssl/dummyModule.nix
];
in
@ -105,6 +104,13 @@
"blocks/authelia" = ./modules/blocks/authelia.nix;
"blocks/borgbackup" = ./modules/blocks/borgbackup.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" = {
module = ./modules/blocks/ssl.nix;
optionRoot = [
@ -112,19 +118,19 @@
"certs"
];
};
"blocks/mitmdump" = ./modules/blocks/mitmdump.nix;
"blocks/monitoring" = ./modules/blocks/monitoring.nix;
"blocks/postgresql" = ./modules/blocks/postgresql.nix;
"blocks/restic" = ./modules/blocks/restic.nix;
"blocks/sops" = ./modules/blocks/sops.nix;
"blocks/zfs" = ./modules/blocks/zfs.nix;
"services/arr" = ./modules/services/arr.nix;
"services/firefly-iii" = ./modules/services/firefly-iii.nix;
"services/forgejo" = [
./modules/services/forgejo.nix
(pkgs.path + "/nixos/modules/services/misc/forgejo.nix")
];
"services/home-assistant" = ./modules/services/home-assistant.nix;
"services/homepage" = ./modules/services/homepage.nix;
"services/jellyfin" = ./modules/services/jellyfin.nix;
"services/karakeep" = ./modules/services/karakeep.nix;
"services/mailserver" = ./modules/services/mailserver.nix;
"services/nextcloud-server" = {
module = ./modules/services/nextcloud-server.nix;
optionRoot = [
@ -135,6 +141,7 @@
"services/open-webui" = ./modules/services/open-webui.nix;
"services/pinchflat" = ./modules/services/pinchflat.nix;
"services/vaultwarden" = ./modules/services/vaultwarden.nix;
"contracts/backup" = {
module = ./modules/contracts/backup/dummyModule.nix;
optionRoot = [
@ -143,6 +150,14 @@
"backup"
];
};
"contracts/dashboard" = {
module = ./modules/contracts/dashboard/dummyModule.nix;
optionRoot = [
"shb"
"contracts"
"dashboard"
];
};
"contracts/databasebackup" = {
module = ./modules/contracts/databasebackup/dummyModule.nix;
optionRoot = [
@ -151,6 +166,14 @@
"databasebackup"
];
};
"contracts/datasetbackup" = {
module = ./modules/contracts/datasetbackup/dummyModule.nix;
optionRoot = [
"shb"
"contracts"
"datasetbackup"
];
};
"contracts/secret" = {
module = ./modules/contracts/secret/dummyModule.nix;
optionRoot = [
@ -220,94 +243,31 @@
'';
});
lib =
(pkgs.callPackage ./lib { })
// (pkgs.callPackage ./test/common.nix { })
// {
contracts = pkgs.callPackage ./modules/contracts { };
patches = shbPatches;
inherit patchNixpkgs patchedNixpkgs pkgs;
packages.manualHtml-watch = pkgs.writeShellApplication {
name = "manualHtml-watch";
runtimeInputs = [
pkgs.findutils
pkgs.entr
];
text = ''
while sleep 1; do
find . -name "*.nix" -o -name "*.md" \
| entr -d sh -c '(nix run --offline .#update-redirects && nix build --offline .#manualHtml)' || :
done
'';
};
packages.update-flake-lock-pr = pkgs.callPackage ./.github/workflows/update-flake-lock-pr.nix { };
lib = (pkgs.callPackage ./lib { }) // {
test = pkgs.callPackage ./test/common.nix { };
contracts = pkgs.callPackage ./modules/contracts {
shb = self.lib.${system};
};
checks =
let
inherit (pkgs.lib)
foldl
foldlAttrs
removeAttrs
mergeAttrs
optionalAttrs
;
importFiles = files: map (m: pkgs.callPackage m { }) 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 { }) [
"override"
"overrideDerivation"
]
);
in
(optionalAttrs (system == "x86_64-linux") (
{
modules = pkgs.lib.shb.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 { };
};
}
// (vm_test "arr" ./test/services/arr.nix)
// (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix)
// (vm_test "deluge" ./test/services/deluge.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 "monitoring" ./test/services/monitoring.nix)
// (vm_test "nextcloud" ./test/services/nextcloud.nix)
// (vm_test "open-webui" ./test/services/open-webui.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 "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)
));
patches = shbPatches system;
inherit patchNixpkgs;
patchedNixpkgs = patchedNixpkgs system;
};
# To see the traces, run:
# nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip
@ -364,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" ];
@ -380,6 +445,7 @@
self.nixosModules.nginx
self.nixosModules.postgresql
self.nixosModules.restic
self.nixosModules.sanoid
self.nixosModules.ssl
self.nixosModules.tinyproxy
self.nixosModules.vpn
@ -389,20 +455,26 @@
self.nixosModules.arr
self.nixosModules.audiobookshelf
self.nixosModules.deluge
self.nixosModules.firefly-iii
self.nixosModules.forgejo
self.nixosModules.grocy
self.nixosModules.hledger
self.nixosModules.immich
self.nixosModules.home-assistant
self.nixosModules.homepage
self.nixosModules.jellyfin
self.nixosModules.karakeep
self.nixosModules.mailserver
self.nixosModules.nextcloud-server
self.nixosModules.open-webui
self.nixosModules.pinchflat
self.nixosModules.paperless
self.nixosModules.vaultwarden
];
};
nixosModules.lib = lib/module.nix;
nixosModules.authelia = modules/blocks/authelia.nix;
nixosModules.borgbackup = modules/blocks/borgbackup.nix;
nixosModules.davfs = modules/blocks/davfs.nix;
@ -413,8 +485,9 @@
nixosModules.nginx = modules/blocks/nginx.nix;
nixosModules.postgresql = modules/blocks/postgresql.nix;
nixosModules.restic = modules/blocks/restic.nix;
nixosModules.ssl = modules/blocks/ssl.nix;
nixosModules.sanoid = modules/blocks/sanoid.nix;
nixosModules.sops = modules/blocks/sops.nix;
nixosModules.ssl = modules/blocks/ssl.nix;
nixosModules.tinyproxy = modules/blocks/tinyproxy.nix;
nixosModules.vpn = modules/blocks/vpn.nix;
nixosModules.zfs = modules/blocks/zfs.nix;
@ -422,15 +495,19 @@
nixosModules.arr = modules/services/arr.nix;
nixosModules.audiobookshelf = modules/services/audiobookshelf.nix;
nixosModules.deluge = modules/services/deluge.nix;
nixosModules.firefly-iii = modules/services/firefly-iii.nix;
nixosModules.forgejo = modules/services/forgejo.nix;
nixosModules.grocy = modules/services/grocy.nix;
nixosModules.hledger = modules/services/hledger.nix;
nixosModules.immich = modules/services/immich.nix;
nixosModules.home-assistant = modules/services/home-assistant.nix;
nixosModules.homepage = modules/services/homepage.nix;
nixosModules.jellyfin = modules/services/jellyfin.nix;
nixosModules.karakeep = modules/services/karakeep.nix;
nixosModules.mailserver = modules/services/mailserver.nix;
nixosModules.nextcloud-server = modules/services/nextcloud-server.nix;
nixosModules.open-webui = modules/services/open-webui.nix;
nixosModules.paperless = modules/services/paperless.nix;
nixosModules.pinchflat = modules/services/pinchflat.nix;
nixosModules.vaultwarden = modules/services/vaultwarden.nix;
};

View file

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

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
;
}

10
lib/module.nix Normal file
View file

@ -0,0 +1,10 @@
{ pkgs, lib, ... }:
let
shb = (import ./default.nix { inherit pkgs lib; });
in
{
_module.args.shb = shb // {
test = pkgs.callPackage ../test/common.nix { };
contracts = pkgs.callPackage ../modules/contracts { inherit shb; };
};
}

View file

@ -3,6 +3,7 @@
options,
pkgs,
lib,
shb,
...
}:
@ -10,8 +11,6 @@ let
cfg = config.shb.authelia;
opt = options.shb.authelia;
contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}";
fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}";
@ -23,6 +22,7 @@ let
in
{
imports = [
../../lib/module.nix
./lldap.nix
./mitmdump.nix
./postgresql.nix
@ -51,7 +51,7 @@ in
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
@ -86,50 +86,50 @@ in
jwtSecret = lib.mkOption {
description = "JWT secret.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
ldapAdminPassword = lib.mkOption {
description = "LDAP admin user password.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
sessionSecret = lib.mkOption {
description = "Session secret.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
storageEncryptionKey = lib.mkOption {
description = "Storage encryption key.";
description = "Storage encryption key. Must be >= 20 characters.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
identityProvidersOIDCHMACSecret = lib.mkOption {
description = "Identity provider OIDC HMAC secret.";
description = "Identity provider OIDC HMAC secret. Must be >= 40 characters.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
@ -140,10 +140,10 @@ in
Generate one with `nix run nixpkgs#openssl -- genrsa -out keypair.pem 2048`
'';
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}" ];
restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ];
};
};
};
@ -204,7 +204,7 @@ in
};
client_secret = lib.mkOption {
type = lib.shb.secretFileType;
type = shb.secretFileType;
description = ''
File containing the shared secret with the OIDC client.
@ -295,6 +295,15 @@ in
description = "SMTP name from which the emails originate.";
default = "Authelia";
};
scheme = lib.mkOption {
description = "The protocl must be smtp, submission, or submissions. The only difference between these schemes are the default ports and submissions requires a TLS transport per SMTP Ports Security Measures, whereas submission and smtp use a standard TCP transport and typically enforce StartTLS.";
type = lib.types.enum [
"smtp"
"submission"
"submissions"
];
default = "smtp";
};
host = lib.mkOption {
type = lib.types.str;
description = "SMTP host to send the emails to.";
@ -311,10 +320,10 @@ in
password = lib.mkOption {
description = "File containing the password to connect to the SMTP host.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = cfg.autheliaUser;
restartUnits = [ "authelia-${fqdn}" ];
restartUnits = [ "authelia-${fqdn}.service" ];
};
};
};
@ -331,7 +340,7 @@ in
};
mount = lib.mkOption {
type = contracts.mount;
type = shb.contracts.mount;
description = ''
Mount configuration. This is an output option.
@ -354,7 +363,7 @@ in
};
mountRedis = lib.mkOption {
type = contracts.mount;
type = shb.contracts.mount;
description = ''
Mount configuration for Redis. This is an output option.
@ -381,6 +390,20 @@ in
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 {
@ -411,20 +434,19 @@ in
secrets = {
jwtSecretFile = cfg.secrets.jwtSecret.result.path;
storageEncryptionKeyFile = cfg.secrets.storageEncryptionKey.result.path;
sessionSecretFile = cfg.secrets.sessionSecret.result.path;
oidcIssuerPrivateKeyFile = cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path;
oidcHmacSecretFile = cfg.secrets.identityProvidersOIDCHMACSecret.result.path;
};
# See https://www.authelia.com/configuration/methods/secrets/
environmentVariables = {
AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = toString cfg.secrets.ldapAdminPassword.result.path;
AUTHELIA_SESSION_SECRET_FILE = toString cfg.secrets.sessionSecret.result.path;
# Not needed since we use peer auth.
# AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE = "/run/secrets/authelia/postgres_password";
AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE = toString cfg.secrets.storageEncryptionKey.result.path;
AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE = toString cfg.secrets.identityProvidersOIDCHMACSecret.result.path;
AUTHELIA_IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY_FILE = toString cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path;
AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = lib.mkIf (!(builtins.isString cfg.smtp)) (
toString cfg.smtp.password.result.path
);
X_AUTHELIA_CONFIG_FILTERS = "template";
};
settings = {
server.address = "tcp://127.0.0.1:${toString listenPort}";
@ -486,8 +508,7 @@ in
filename = cfg.smtp;
};
smtp = lib.mkIf (!(builtins.isString cfg.smtp)) {
host = cfg.smtp.host;
port = cfg.smtp.port;
address = "${cfg.smtp.scheme}://${cfg.smtp.host}:${toString cfg.smtp.port}";
username = cfg.smtp.username;
sender = "${cfg.smtp.from_name} <${cfg.smtp.from_address}>";
subject = "[Authelia] {title}";
@ -554,12 +575,12 @@ in
let
mkCfg =
clients:
lib.shb.replaceSecrets {
shb.replaceSecrets {
userConfig = {
identity_providers.oidc.clients = clients;
};
resultPath = "/var/lib/authelia-${fqdn}/oidc_clients.yaml";
generator = lib.shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });
generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { });
};
in
lib.mkBefore (
@ -624,6 +645,7 @@ in
shb.mitmdump.instances."authelia-${fqdn}" = lib.mkIf cfg.debug {
listenPort = 9091;
upstreamPort = 9090;
timeout = 30;
after = [ "authelia-${fqdn}.service" ];
enabledAddons = [ config.shb.mitmdump.addons.logger ];
extraArgs = [

View file

@ -8,10 +8,17 @@ This block sets up an [Authelia][] service for Single-Sign On integration.
Compared to the upstream nixpkgs module, this module is tightly integrated
with SHB which allows easy configuration of SSO with [OIDC integration](#blocks-authelia-shb-oidc)
or with [forward auth integration](#blocks-authelia-shb-forward-auth)
as well as some extensive [troubleshooting](#blocks-authelia-troubleshooting) features.
## Global Setup {#blocks-authelia-global-setup}
Note that forward authentication is configured with the [nginx block](blocks-nginx.html#blocks-nginx-usage-shbforwardauth).
## Features {#services-authelia-features}
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-authelia-usage-applicationdashboard)
## Usage {#services-authelia-usage}
### Initial Configuration {#blocks-authelia-usage-configuration}
Authelia cannot work without SSL and LDAP.
So setting up the Authelia block requires to setup the [SSL block][] first
@ -43,31 +50,31 @@ shb.authelia = {
port = 587;
username = "postmaster@mg.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 = {
jwtSecret.result = config.shb.sops.secrets."authelia/jwt_secret".result;
ldapAdminPassword.result = config.shb.sops.secrets."authelia/ldap_admin_password".result;
sessionSecret.result = config.shb.sops.secrets."authelia/session_secret".result;
storageEncryptionKey.result = config.shb.sops.secrets."authelia/storage_encryption_key".result;
identityProvidersOIDCHMACSecret.result = config.shb.sops.secrets."authelia/hmac_secret".result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secrets."authelia/private_key".result;
jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result;
ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result;
sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result;
storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result;
identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result;
identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result;
};
};
shb.certs.certs.letsencrypt."example.com".extraDomains = [ "auth.example.com" ];
shb.sops.secrets."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request;
shb.sops.secrets."authelia/ldap_admin_password" = {
shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request;
shb.sops.secret."authelia/ldap_admin_password" = {
request = config.shb.authelia.secrets.ldapAdminPassword.request;
settings.key = "lldap/user_password";
};
shb.sops.secrets."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.secrets."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.secrets."authelia/smtp_password".request = config.shb.authelia.smtp.password.request;
shb.sops.secret."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request;
shb.sops.secret."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request;
shb.sops.secret."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request;
shb.sops.secret."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request;
shb.sops.secret."authelia/smtp_password".request = config.shb.authelia.smtp.password.request;
```
This assumes secrets are setup with SOPS
@ -79,6 +86,22 @@ Crucially, the `shb.authelia.secrets.ldapAdminPasswordFile` must be the same
as the `shb.lldap.ldapUserPassword` defined for the [LLDAP block][].
This is done using Sops' `key` option.
### Application Dashboard {#services-authelia-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-authelia-options-shb.authelia.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.Authelia = {
sortOrder = 2;
dashboard.request = config.shb.authelia.dashboard.request;
};
}
```
## SHB OIDC integration {#blocks-authelia-shb-oidc}
For services [provided by SelfHostBlocks][services] that handle [OIDC integration][OIDC],
@ -94,8 +117,8 @@ shb.<service>.sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secret.result = config.shb.sops.secrets."<service>/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secrets."<service>/sso/secretForAuthelia".result;
secret.result = config.shb.sops.secret."<service>/sso/secret".result;
secretForAuthelia.result = config.shb.sops.secret."<service>/sso/secretForAuthelia".result;
};
shb.sops.secret."<service>/sso/secret".request = config.shb.<service>.sso.secret.request;
@ -121,7 +144,7 @@ the necessary configuration is:
shb.authelia.oidcClients = [
{
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" ];
redirect_uris = [
"<provided by service documentation>"
@ -166,7 +189,7 @@ services.open-webui.environment = {
shb.authelia.oidcClients = [
{
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" ];
redirect_uris = [
"<provided by service documentation>"
@ -191,40 +214,9 @@ Inspiration can be taken from SelfHostBlocks' source code.
To access the UI, we will need to create an `open-webui_user` and
`open-webui_admin` LDAP group and assign our user to it.
## SHB Forward Auth {#blocks-authelia-shb-forward-auth}
For services provided by SelfHostBlocks that do not handle [OIDC integration][OIDC],
this block can provide [forward authentication][] which still allows the service to be protected by Authelia.
The user could still be required to authenticate to the service itself,
although some services can automatically users authorized by Authelia.
[forward authentication]: https://doc.traefik.io/traefik/middlewares/http/forwardauth/
Integrating with this block is done with the following code:
```nix
shb.<services>.authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
```
## Forward Auth {#blocks-authelia-forward-auth}
To integrate a service that does not handle OIDC integration
and which is not provided by SelfHostBlocks with this Authelia block,
the necessary configuration is:
```nix
shb.nginx.vhosts = [
{
subdomain = "<service>";
domain = "example.com";
ssl = config.shb.certs.certs.letsencrypt."example.com";
upstream = "http://127.0.0.1:${toString config.services.<service>.port}/";
}
];
```
This configuration assumes usage of the [SSL block][].
Forward authentication is provided by the [nginx block](blocks-nginx.html#blocks-nginx-usage-ssl).
## Troubleshooting {#blocks-authelia-troubleshooting}

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,13 @@
pkgs,
lib,
utils,
shb,
...
}:
let
cfg = config.shb.borgbackup;
contracts = pkgs.callPackage ../contracts { };
inherit (lib)
concatStringsSep
filterAttrs
@ -56,7 +55,7 @@ let
passphrase = lib.mkOption {
description = "Encryption key for the backup repository.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.request.user;
ownerText = "[shb.borgbackup.${prefix}.<name>.request.user](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.request.user)";
@ -76,7 +75,7 @@ let
};
secrets = mkOption {
type = attrsOf lib.shb.secretFileType;
type = attrsOf shb.secretFileType;
default = { };
description = ''
Secrets needed to access the repository where the backups will be stored.
@ -99,7 +98,7 @@ let
OnCalendar = "daily";
Persistent = true;
};
description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.'';
description = "When to run the backup. See {manpage}`systemd.timer(5)` for details.";
example = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
@ -157,15 +156,24 @@ let
fullName = name: repository: "borgbackup-job-${name}_${repoSlugName repository.path}";
in
{
imports = [
../../lib/module.nix
../blocks/monitoring.nix
];
options.shb.borgbackup = {
enableDashboard = lib.mkEnableOption "the Backups SHB dashboard" // {
default = true;
};
instances = mkOption {
description = "Files to backup following the [backup contract](./contracts-backup.html).";
description = "Files to backup following the [backup contract](./shb.contracts-backup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = contracts.backup.mkProvider {
options = shb.contracts.backup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the BorgBackup provider.
@ -193,13 +201,13 @@ in
};
databases = mkOption {
description = "Databases to backup following the [database backup contract](./contracts-databasebackup.html).";
description = "Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = contracts.databasebackup.mkProvider {
options = shb.contracts.databasebackup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the BorgBackup provider.
@ -378,8 +386,13 @@ in
${serviceName} = mkMerge [
{
serviceConfig = {
# Makes the systemd service wait for the backup to be done before changing state to inactive.
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.
Nice = lib.mkForce cfg.performance.niceness;
IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass;
IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority;
@ -397,15 +410,16 @@ in
"${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) (
let
script = lib.shb.genConfigOutOfBandSystemd {
script = shb.genConfigOutOfBandSystemd {
config = instance.settings.repository.secrets;
configLocation = "/run/secrets_borgbackup/${serviceName}";
generator = lib.shb.toEnvVar;
generator = shb.toEnvVar;
user = instance.request.user;
};
in
{
script = script.preStart;
# Makes the systemd service wait for the backup to be done before changing state to inactive.
serviceConfig.Type = "oneshot";
serviceConfig.LoadCredential = script.loadCredentials;
}
@ -424,13 +438,13 @@ in
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = (
lib.shb.replaceSecrets {
shb.replaceSecrets {
userConfig = instance.settings.repository.secrets // {
BORG_PASSCOMMAND = ''"cat ${instance.settings.passphrase.result.path}"'';
BORG_REPO = instance.settings.repository.path;
};
resultPath = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
generator = lib.shb.toEnvVar;
generator = shb.toEnvVar;
user = instance.request.user;
}
);
@ -443,39 +457,16 @@ in
let
mkBorgBackupBinary =
name: instance:
pkgs.writeShellApplication {
shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
(cd / && sudocmd ${pkgs.borgbackup}/bin/borg extract "$BORG_REPO"::"$archive")
'';
user = instance.request.user;
sudoPreserveEnvs = [
"BORG_REPO"
"BORG_PASSCOMMAND"
];
secretsFile = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
restoreCmd = ''(cd / && ${pkgs.borgbackup}/bin/borg extract \"$BORG_REPO::$snapshot\")'';
listCmd = ''if [ -e \"$BORG_REPO/data\" ]; then borg list --short \"$BORG_REPO\"; fi'';
};
in
flatten (mapAttrsToList mkBorgBackupBinary cfg.instances);
@ -485,43 +476,26 @@ in
let
mkBorgBackupBinary =
name: instance:
pkgs.writeShellApplication {
shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}")
set +a
archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)"
echo "Will restore archive $archive"
sudocmd sh -c "${pkgs.borgbackup}/bin/borg extract $BORG_REPO::$archive --stdout | ${instance.request.restoreCmd}"
'';
user = instance.request.user;
sudoPreserveEnvs = [
"BORG_REPO"
"BORG_PASSCOMMAND"
];
secretsFile = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}";
restoreCmd = ''${pkgs.borgbackup}/bin/borg extract \"$BORG_REPO::$snapshot\" --stdout | ${instance.request.restoreCmd}'';
listCmd = ''if [ -e \"$BORG_REPO/data\" ]; then borg list --short \"$BORG_REPO\"; fi'';
};
in
flatten (mapAttrsToList mkBorgBackupBinary cfg.databases);
}
(lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) {
shb.monitoring.dashboards = [
./backup/dashboard/Backups.json
];
})
]
);
}

View file

@ -3,6 +3,7 @@
Defined in [`/modules/blocks/borgbackup.nix`](@REPO@/modules/blocks/borgbackup.nix).
This block sets up a backup job using [BorgBackup][].
It is heavily based on the nixpkgs BorgBackup module.
[borgbackup]: https://www.borgbackup.org/
@ -50,7 +51,7 @@ shb.borgbackup.instances."myservice" = {
settings = {
enable = true;
passphrase.result = shb.sops.secret."passphrase".result;
passphrase.result = config.shb.sops.secret."passphrase".result;
repository = {
path = "/srv/backups/myservice";
@ -71,7 +72,7 @@ shb.borgbackup.instances."myservice" = {
};
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}
@ -87,7 +88,7 @@ shb.borgbackup.instances."myservice" = {
settings = {
enable = true;
passphrase.result = shb.sops.secret."passphrase".result;
passphrase.result = config.shb.sops.secret."passphrase".result;
repository = {
path = "/srv/backups/myservice";
@ -108,7 +109,7 @@ shb.borgbackup.instances."myservice" = {
};
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}
@ -118,7 +119,7 @@ Here we will only highlight the differences with the previous configuration.
This assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/).
```diff
shb.backup.instances.myservice = {
shb.test.backup.instances.myservice = {
repository = {
- path = "/srv/pool1/backups/myfolder";
@ -211,23 +212,54 @@ backupcfg = repositories: name: sourceDirectories {
Now, we can define multiple backup jobs to backup different folders:
```nix
shb.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"];
shb.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"];
shb.test.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"];
shb.test.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"];
```
The difference between the above snippet and putting all the folders into one configuration (shown
below) is the former splits the backups into sub-folders on the repositories.
```nix
shb.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"];
shb.test.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"];
```
## Monitoring {#blocks-borgbackup-monitoring}
[WIP](https://github.com/ibizaman/selfhostblocks/issues/151)
A generic dashboard for all backup solutions is provided.
See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter.
## Maintenance {#blocks-borgbackup-maintenance}
### 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.
The restore script has all the secrets needed to access the repo,

View file

@ -75,6 +75,30 @@ in
config = {
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 =
let
mkMountCfg = c: {
@ -82,6 +106,7 @@ in
description = "Webdav mount point";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
what = c.remoteUrl;
where = c.mountPoint;

View file

@ -2,13 +2,12 @@
config,
lib,
pkgs,
shb,
...
}:
let
cfg = config.shb.hardcodedsecret;
contracts = pkgs.callPackage ../contracts { };
inherit (lib) mapAttrs' mkOption nameValuePair;
inherit (lib.types)
attrsOf
@ -19,6 +18,10 @@ let
inherit (pkgs) writeText;
in
{
imports = [
../../lib/module.nix
];
options.shb.hardcodedsecret = mkOption {
default = { };
description = ''
@ -40,7 +43,7 @@ in
submodule (
{ name, ... }:
{
options = contracts.secret.mkProvider {
options = shb.contracts.secret.mkProvider {
settings = mkOption {
description = ''
Settings specific to the hardcoded secret module.

View file

@ -2,14 +2,13 @@
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.lldap;
contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}";
inherit (lib) mkOption types;
@ -54,6 +53,7 @@ let
in
{
imports = [
../../lib/module.nix
./mitmdump.nix
(lib.mkRenamedOptionModule [ "shb" "ldap" ] [ "shb" "lldap" ])
@ -88,7 +88,7 @@ in
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
@ -99,9 +99,9 @@ in
};
ldapUserPassword = lib.mkOption {
description = "LDAP admin user secret.";
description = "LDAP admin user secret. Must be >= 8 characters.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
@ -113,7 +113,7 @@ in
jwtSecret = lib.mkOption {
description = "JWT secret.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
@ -136,7 +136,7 @@ in
};
mount = lib.mkOption {
type = contracts.mount;
type = shb.contracts.mount;
description = ''
Mount configuration. This is an output option.
@ -160,7 +160,7 @@ in
Backup configuration.
'';
type = lib.types.submodule {
options = contracts.backup.mkRequester {
options = shb.contracts.backup.mkRequester {
# TODO: is there a workaround that avoid needing to use root?
# root because otherwise we cannot access the private StateDiretory
user = "root";
@ -203,7 +203,7 @@ in
password = mkOption {
description = "Password.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "lldap";
group = "lldap";
@ -331,7 +331,21 @@ in
enforceGroups = mkOption {
description = "Remove groups not set declaratively.";
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;
};
# 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 {
listenPort = config.shb.lldap.webUIListenPort;
upstreamPort = config.shb.lldap.webUIListenPort + 1;

View file

@ -7,7 +7,13 @@ across services.
[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
shb.lldap = {
@ -29,7 +35,7 @@ shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPasswor
This assumes secrets are setup with SOPS
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 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};
```
## 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
by adding the following line:
@ -53,6 +59,22 @@ by adding the following line:
shb.lldap.restrictAccessIPRange = "192.168.50.0/24";
```
### Application Dashboard {#blocks-lldap-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-lldap-options-shb.lldap.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.LLDAP = {
sortOrder = 2;
dashboard.request = config.shb.lldap.dashboard.request;
};
}
```
## Manage Groups {#blocks-lldap-manage-groups}
The following snippet will create group named "family" if it does not exist yet.
@ -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.
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
{
@ -142,7 +164,7 @@ set [`shb.lldap.enforceUserMemberships`](#blocks-lldap-options-shb.lldap.enforce
};
shb.sops.secret."dad".request =
shb.lldap.ensureUsers.dad.password.request;
config.shb.lldap.ensureUsers.dad.password.request;
}
```

View file

@ -29,7 +29,7 @@ let
p = pkgs.python3Packages;
in
[
p.systemd
p.systemd-python
p.mitmproxy
];
flakeIgnore = [ "E501" ];
@ -48,7 +48,7 @@ let
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
while time.time() < deadline:
try:
@ -68,6 +68,7 @@ let
parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on")
parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost")
parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream")
parser.add_argument("--timeout", required=False, type=int, default=10, help="Timeout to wait for port availability")
args, rest = parser.parse_known_args()
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")
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.")
proc = subprocess.Popen(
@ -90,10 +91,11 @@ let
)
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}.")
notify("READY=1")
else:
print(f"Mitmdump instance did not start before the timeout of {args.timeout} seconds, consider increasing the timeout")
proc.terminate()
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 {
type = listOf str;
default = [ ];
@ -239,7 +249,7 @@ in
description = ''
Addons to enable on this mitmdump instance.
'';
example = lib.literalExpression ''[ config.shb.mitmdump.addons.logger ]'';
example = lib.literalExpression "[ config.shb.mitmdump.addons.logger ]";
};
extraArgs = mkOption {
@ -282,7 +292,7 @@ in
addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons;
extraArgs = lib.concatStringsSep " " cfg'.extraArgs;
in
"${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}";
"${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;
after = cfg'.after;

File diff suppressed because it is too large Load diff

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

@ -1,143 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 16,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line+area"
}
},
"mappings": [],
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "transparent",
"value": 604808
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true,
"sortBy": "Last *",
"sortDesc": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.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"
}
],
"preload": false,
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "SSL Certificates",
"uid": "ae818js0bvw8wb",
"version": 8,
"weekStart": ""
}

View file

@ -21,6 +21,50 @@
"id": 5,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 5,
"options": {
"dedupStrategy": "none",
"enableInfiniteScrolling": false,
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"direction": "backward",
"editorMode": "code",
"expr": "{unit=~\"prometheus-.*-exporter.service\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Exporter Logs",
"type": "logs"
},
{
"datasource": {
"type": "prometheus",
@ -286,5 +330,5 @@
"timezone": "",
"title": "Scraping Jobs",
"uid": "debb763d-77aa-47bd-9290-2e02583c8ed2",
"version": 22
"version": 24
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View file

@ -8,26 +8,70 @@ This block sets up the monitoring stack for Self Host Blocks. It is composed of:
- Prometheus as the database for metrics.
- Loki as the database for logs.
## Configuration {#blocks-monitoring-configuration}
## Features {#services-monitoring-features}
- Declarative [LDAP](#blocks-monitoring-options-shb.monitoring.ldap) Configuration.
- Needed LDAP groups are created automatically.
- Declarative [SSO](#blocks-monitoring-options-shb.monitoring.sso) Configuration.
- When SSO is enabled, login with user and password is disabled.
- Registration is enabled through SSO.
- Access through [subdomain](#blocks-monitoring-options-shb.monitoring.subdomain) using reverse proxy.
- Access through [HTTPS](#blocks-monitoring-options-shb.monitoring.ssl) using reverse proxy.
- Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-monitoring-usage-applicationdashboard)
- Out of the box integration with [Scrutiny](https://github.com/AnalogJ/scrutiny) service for Hard Drives monitoring. [Manual](#blocks-monitoring-usage-scrutiny)
## Usage {#blocks-monitoring-usage}
### Initial Configuration {#blocks-monitoring-usage-configuration}
The following snippet assumes a few blocks have been setup already:
- the [secrets block](usage.html#usage-secrets) with SOPS,
- the [`shb.ssl` block](blocks-ssl.html#usage),
- the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup).
- the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup).
```nix
shb.monitoring = {
enable = true;
subdomain = "grafana";
inherit domain;
contactPoints = [ "me@example.com" ];
adminPassword.result = config.sops.secrets."monitoring/admin_password".result;
secretKey.result = config.sops.secrets."monitoring/secret_key".result;
};
{
shb.monitoring = {
enable = true;
subdomain = "grafana";
inherit domain;
contactPoints = [ "me@example.com" ];
adminPassword.result = config.shb.sops.secret."monitoring/admin_password".result;
secretKey.result = config.shb.sops.secret."monitoring/secret_key".result;
shb.sops.secret."monitoring/admin_password".request = config.shb.monitoring.adminPassword.request;
shb.sops.secret."monitoring/secret_key".request = config.shb.monitoring.secretKey.request;
sso = {
enable = true;
authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
sharedSecret.result = config.shb.sops.secret."monitoring/oidcSecret".result;
sharedSecretForAuthelia.result = config.shb.sops.secret."monitoring/oidcAutheliaSecret".result;
};
};
shb.sops.secret."monitoring/admin_password".request = config.shb.monitoring.adminPassword.request;
shb.sops.secret."monitoring/secret_key".request = config.shb.monitoring.secretKey.request;
shb.sops.secret."monitoring/oidcSecret".request = config.shb.monitoring.sso.sharedSecret.request;
shb.sops.secret."monitoring/oidcAutheliaSecret" = {
request = config.shb.monitoring.sso.sharedSecretForAuthelia.request;
settings.key = "monitoring/oidcSecret";
};
};
```
With that, Grafana, Prometheus, Loki and Promtail are setup! You can access `Grafana` at
`grafana.example.com` with user `admin` and password ``.
Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`.
I recommend adding a STMP server configuration so you receive alerts by email:
With that, Grafana, Prometheus, Loki and Promtail are setup! You can access `Grafana` at
`grafana.example.com` with user `admin` and the password from the sops key `monitoring/admin_password`.
The [user](#blocks-monitoring-options-shb.monitoring.ldap.userGroup)
and [admin](#blocks-monitoring-options-shb.monitoring.ldap.adminGroup)
LDAP groups are created automatically.
### SMTP {#blocks-monitoring-usage-smtp}
I recommend adding an SMTP server configuration so you receive alerts by email:
```nix
shb.monitoring.smtp = {
@ -48,6 +92,8 @@ sops.secrets."monitoring/secret_key" = {
};
```
### Log Optimization {#blocks-monitoring-usage-log-optimization}
Since all logs are now stored in Loki, you can probably reduce the systemd journal retention
time with:
@ -68,6 +114,42 @@ You might for example want to update the metrics retention time with:
services.prometheus.retentionTime = "60d";
```
### Application Dashboard {#blocks-monitoring-usage-applicationdashboard}
Integration with the [dashboard contract](contracts-dashboard.html) is provided
by the [dashboard option](#blocks-monitoring-options-shb.monitoring.dashboard).
For example using the [Homepage](services-homepage.html) service:
```nix
{
shb.homepage.servicesGroups.Admin.services.Grafana = {
sortOrder = 10;
dashboard.request = config.shb.monitoring.dashboard.request;
};
}
```
There is also an integration for the scrutiny service, see next section.
### Scrutiny {#blocks-monitoring-usage-scrutiny}
Integration with the [Scrutiny](https://github.com/AnalogJ/scrutiny) service is enabled by default and setup automatically.
The web interface will be served under the [scrutiny.subdomain](#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain) option.
If you don't want the web interface, set the option to `null`.
For integration with the [dashboard contract](contracts-dashboard.html):
```nix
{
shb.homepage.servicesGroups.Admin.services.Scrutiny = {
sortOrder = 11;
dashboard.request = config.shb.monitoring.scrutiny.dashboard.request;
};
}
```
## Provisioning {#blocks-monitoring-provisioning}
Self Host Blocks will create automatically the following resources:
@ -118,6 +200,43 @@ This dashboard is used to monitor a [deluge](./services-deluge.html) instance.
![Deluge Dashboard Top Part](./assets/dashboards_Deluge_1.png)
![Deluge Dashboard Bottom Part](./assets/dashboards_Deluge_2.png)
## Backups Dashboard and Alert {#blocks-monitoring-backup}
This dashboard shows Restic and BorgBackup backup jobs, or any job with "backup" in the systemd service name.
### Dashboard {#blocks-monitoring-backup-dashboard}
Variables:
- The "Job" variable allows to select one or more backup jobs. "All" is the default.
- The "mountpoints" variable allows to select only relevant mountpoints for backup. "All" is the default.
The most important graphs are the first three:
- "Backup Jobs in the Past Week": Shows stats on all backup jobs that ran in the past.
It is sorted by the "Failed" column in descending order.
This way, one can directly see when a job has failures.
- "Schedule": Shows when a job will run.
The unit is "Datetime from Now" meaning it shows when a job ran or will run relative to the current time.
An annotation will show up when the "Late Backups" alert fired or resolved.
- "Backup jobs": Shows when a backup job ran.
Normally, jobs running for less than 15 seconds will not show up in the graph.
We crafted a query that still shows them but the length is 15 seconds, even if the backup job
took less time to run.
![Backups Dashboard Top Part](./assets/dashboards_Backups_1.png)
![Backups Dashboard Middle Part](./assets/dashboards_Backups_2.png)
![Backups Dashboard Bottom Part](./assets/dashboards_Backups_3.png)
### Alerts {#blocks-monitoring-backup-alerts}
- The "Late Backups" alert will fire if a backup job did not run at all in the last 24 hours
or if all runs were failures in the last 24 hours.
It will show up as annotations in the "Schedule" panel of the dashboard.
![Late Backups Alert Firing](./assets/alert_rules_LateBackups_1.png)
![Backups Alert Showing Up In Dashboard](./assets/dashboards_Backups_alert.png)
## Requests Error Budget Alert {#blocks-monitoring-budget-alerts}
This alert will fire when the ratio between number of requests getting a 5XX response from a service
@ -126,6 +245,55 @@ and the total requests to that service exceeds 1%.
![Error Dashboard Top Part](./assets/alert_rules_5xx_1.png)
![Error Dashboard Bottom Part](./assets/alert_rules_5xx_2.png)
## SSL Certificates Dashboard and Alert {#blocks-monitoring-ssl}
This dashboard shows Let's Encrypt renewal and setup jobs,
or any job starting with "acme-" in the systemd service name.
### Dashboard {#blocks-monitoring-ssl-dashboard}
Variables:
- The "Job" variable allows to focus on one or more certificate. "All" is the default.
Graphs:
- "Certificate Remaining Validity": Shows in how long will certificates expire.
It shows all files under `/var/lib/acme`.
An annotation will show up when the "Certificate Did Not Renew" alert fired or resolved.
- "Schedule": Shows when a job will run.
The unit is "Datetime from Now" meaning it shows when a job ran or will run relative to the current time.
- "Jobs in the Past Week": Shows stats on all renewal jobs that ran in the past.
It is sorted by the "Failed" column in descending order.
This way, one can directly see when a job has failures.
Note, the stats is not accurate because detecting jobs taking taking less than 15 seconds
is not supported well.
- "Job Runs": Shows when a renewal job ran.
Normally, jobs running for less than 15 seconds will not show up in the graph.
We crafted a query that still shows them but the length is 100 seconds, even if the job
took less time to run.
![SSL Dashboard No Filter](./assets/dashboards_SSL_all.png)
![SSL Dashboard Filter Failing](./assets/dashboards_SSL_fail.png)
### Alerts {#blocks-monitoring-ssl-alerts}
- The "Certificate Did Not Renew" alert will fire if a backup job did not run at all in the last 24 hours
or if all runs were failures in the last 24 hours.
It will show up as annotations in the "Schedule" panel of the dashboard.
![Late SSL Jobs Alert Firing](./assets/alert_rules_LateSSL_1.png)
## 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}
```{=include=} options

View file

@ -146,7 +146,7 @@
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "ssl_certificate_expiry_seconds",
"expr": "min by(subject) (ssl_certificate_expiry_seconds)",
"interval": "",
"intervalMs": 15000,
"legendFormat": "{{exported_hostname}}: {{subject}} {{path}}",
@ -254,5 +254,133 @@
"role": "sysadmin"
},
"isPaused": false
},
{
"uid": "df4doj5pomhvkf",
"title": "Late Backups",
"condition": "C",
"data": [
{
"refId": "A",
"relativeTimeRange": {
"from": 10800,
"to": 0
},
"datasourceUid": "df80f9f5-97d7-4112-91d8-72f523a02b09",
"model": {
"adhocFilters": [],
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"exemplar": false,
"expr": "(\n # Timer triggered at least once in the last 24h\n label_replace((\n time()\n -\n systemd_timer_last_trigger_seconds{name=~\".*backup.*.timer\"}\n ) < 24*60*60, \"name\", \"$1.service\", \"name\", \"(.*).timer\")\n AND on(name)\n # At least one failure in the last 24h\n (\n max_over_time(systemd_unit_state{name=~\".*backup.*.service\", state=\"failed\"}[24h]) > 0\n )\n AND on(name)\n # No successes in the last 24h\n (\n max_over_time(systemd_unit_state{name=~\".*backup.*.service\", state=\"inactive\"}[24h]) == 0\n )\n)",
"instant": false,
"interval": "",
"intervalMs": 15000,
"legendFormat": "{{name}}",
"maxDataPoints": 43200,
"range": true,
"refId": "A"
}
},
{
"refId": "B",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"conditions": [
{
"evaluator": {
"params": [],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"B"
]
},
"reducer": {
"params": [],
"type": "last"
},
"type": "query"
}
],
"datasource": {
"type": "__expr__",
"uid": "__expr__"
},
"expression": "A",
"intervalMs": 1000,
"maxDataPoints": 43200,
"reducer": "last",
"refId": "B",
"type": "reduce"
}
},
{
"refId": "C",
"relativeTimeRange": {
"from": 0,
"to": 0
},
"datasourceUid": "__expr__",
"model": {
"conditions": [
{
"evaluator": {
"params": [
0
],
"type": "gt"
},
"operator": {
"type": "and"
},
"query": {
"params": [
"C"
]
},
"reducer": {
"params": [],
"type": "last"
},
"type": "query"
}
],
"datasource": {
"type": "__expr__",
"uid": "__expr__"
},
"expression": "B",
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "C",
"type": "threshold"
}
}
],
"dashboardUid": "f05500d0-15ed-4719-b68d-fb898ca13cc8",
"panelId": 15,
"noDataState": "OK",
"execErrState": "Error",
"annotations": {
"__dashboardUid__": "f05500d0-15ed-4719-b68d-fb898ca13cc8",
"__panelId__": "15",
"summary": "A backup did not run in the last 24 hours."
},
"labels": {
"role": "sysadmin"
},
"isPaused": false
}
]

View file

@ -1,15 +1,13 @@
{
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.nginx;
contracts = pkgs.callPackage ../contracts { };
fqdn = c: "${c.subdomain}.${c.domain}";
vhostConfig = lib.types.submodule {
@ -28,13 +26,14 @@ let
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
upstream = lib.mkOption {
type = lib.types.str;
type = lib.types.nullOr lib.types.str;
description = "Upstream url to be protected.";
default = null;
example = "http://127.0.0.1:1234";
};
@ -50,10 +49,40 @@ let
default = [ ];
description = "Authelia rule configuration";
example = lib.literalExpression ''
[{
policy = "two_factor";
subject = ["group:service_user"];
}]'';
[
# Protect /admin endpoint with 2FA
# and only allow access to admin users.
{
domain = "myapp.example.com";
policy = "two_factor";
subject = [ "group:service_admin" ];
resources = [
"^/admin"
];
}
# Leave /api endpoint open - assumes an API key is used to protect it.
{
domain = "myapp.example.com";
policy = "bypass";
resources = [
"^/api"
];
},
# Protect rest of app with 1FA
# and allow access to normal and admin users.
{
domain = "myapp.example.com";
policy = "one_factor";
subject = ["group:service_user"];
},
]
'';
};
phpForwardAuth = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Authelia rule configuration";
};
extraConfig = lib.mkOption {
@ -65,6 +94,10 @@ let
};
in
{
imports = [
./authelia.nix
];
options.shb.nginx = {
accessLog = lib.mkOption {
type = lib.types.bool;
@ -131,52 +164,58 @@ in
sslCertificateKey = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.key;
# Taken from https://github.com/authelia/authelia/issues/178
locations."/".extraConfig = ''
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";
add_header X-Download-Options noopen;
add_header X-Permitted-Cross-Domain-Policies none;
locations."/".extraConfig =
lib.optionalString (c.upstream != null) ''
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive";
add_header X-Download-Options noopen;
add_header X-Permitted-Cross-Domain-Policies none;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass $http_upgrade;
proxy_pass ${c.upstream};
''
+ c.extraConfig
+ lib.optionalString (c.authEndpoint != null) ''
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header X-Forwarded-User $user;
proxy_set_header X-Forwarded-Groups $groups;
# TODO: Are those needed?
# auth_request_set $name $upstream_http_remote_name;
# auth_request_set $email $upstream_http_remote_email;
# proxy_set_header Remote-Name $name;
# proxy_set_header Remote-Email $email;
# TODO: Would be nice to have this working, I think.
# set $new_cookie $http_cookie;
# if ($http_cookie ~ "(.*)(?:^|;)\s*example\.com\.session\.id=[^;]+(.*)") {
# set $new_cookie $1$2;
# }
# proxy_set_header Cookie $new_cookie;
proxy_pass ${c.upstream};
''
+ c.extraConfig
+ lib.optionalString (c.authEndpoint != null) ''
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header X-Forwarded-User $user;
proxy_set_header X-Forwarded-Groups $groups;
# TODO: Are those needed?
# auth_request_set $name $upstream_http_remote_name;
# auth_request_set $email $upstream_http_remote_email;
# proxy_set_header Remote-Name $name;
# proxy_set_header Remote-Email $email;
# TODO: Would be nice to have this working, I think.
# set $new_cookie $http_cookie;
# if ($http_cookie ~ "(.*)(?:^|;)\s*example\.com\.session\.id=[^;]+(.*)") {
# set $new_cookie $1$2;
# }
# proxy_set_header Cookie $new_cookie;
auth_request_set $redirect $scheme://$http_host$request_uri;
error_page 401 =302 ${c.authEndpoint}?rd=$redirect;
error_page 403 = ${c.authEndpoint}/error/403;
auth_request_set $redirect $scheme://$http_host$request_uri;
error_page 401 =302 ${c.authEndpoint}?rd=$redirect;
error_page 403 = ${c.authEndpoint}/error/403;
'';
locations."~ \\.php$".extraConfig = lib.mkIf (c.phpForwardAuth) ''
fastcgi_param HTTP_X_FORWARDED_USER $user;
fastcgi_param HTTP_X_FORWARDED_GROUPS $groups;
'';
# Virtual endpoint created by nginx to forward auth requests.
locations."/authelia".extraConfig = lib.mkIf (!(isNull c.authEndpoint)) ''
locations."/authelia".extraConfig = lib.mkIf (c.authEndpoint != null) ''
internal;
proxy_pass ${c.authEndpoint}/api/verify;

View file

@ -0,0 +1,211 @@
# Nginx Block {#blocks-nginx}
Defined in [`/modules/blocks/nginx.nix`](@REPO@/modules/blocks/nginx.nix).
This block sets up a [Nginx](https://nginx.org/) instance.
It complements the upstream nixpkgs with some authentication and debugging improvements as shows in the Usage section.
## Usage {#blocks-nginx-usage}
### Access Logging {#blocks-nginx-usage-accesslog}
JSON access logging is enabled with the [`shb.nginx.accessLog`](#blocks-nginx-options-shb.nginx.accessLog) option:
```nix
{
shb.nginx.accessLog = true;
}
```
Looking at the systemd logs (`journalctl -fu nginx`) will show for example:
```json
nginx[969]: server nginx:
{
"remote_addr":"192.168.1.1",
"remote_user":"-",
"time_local":"29/Dec/2025:14:22:41 +0000",
"request":"POST /api/firstfactor HTTP/2.0",
"request_length":"264",
"server_name":"auth_example_com",
"status":"200",
"bytes_sent":"855",
"body_bytes_sent":"60",
"referrer":"-",
"user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
"gzip_ration":"-",
"post":"{\x22username\x22:\x22charlie\x22,\x22password\x22:\x22CharliePassword\x22,\x22keepMeLoggedIn\x22:false,\x22targetURL\x22:\x22https://f.example.com/\x22,\x22requestMethod\x22:null}",
"upstream_addr":"127.0.0.1:9091",
"upstream_status":"200",
"request_time":"0.873",
"upstream_response_time":"0.873",
"upstream_connect_time":"0.001",
"upstream_header_time":"0.872"
}
```
This _will_ log the body of POST queries so it should only be enabled for debug logging.
### Debug Logging {#blocks-nginx-usage-debuglog}
Debug logging is enabled with the [`shb.nginx.debugLog`](#blocks-nginx-options-shb.nginx.debugLog) option:
```nix
{
shb.nginx.debugLog = true;
}
```
If enabled, it sets:
```
error_log stderr warn;
```
### Virtual Host Upstream Proxy {#blocks-nginx-usage-upstream}
Easy upstream proxy setup is done with the [`shb.nginx.vhosts.*.upstream`](#blocks-nginx-options-shb.nginx.vhosts._.upstream) option:
```nix
{
shb.nginx.vhosts = [
{
domain = "example.com";
subdomain = "mysubdomain";
upstream = "http://127.0.0.1:9090";
}
];
}
```
This will set also a few headers.
Some are shown here and others please see in the [nginx](@REPO@/modules/blocks/nginx.nix) module:
- `Host` = `$host`;
- `X-Real-IP` = `$remote_addr`;
- `X-Forwarded-For` = `$proxy_add_x_forwarded_for`;
- `X-Forwarded-Proto` = `$scheme`;
### Virtual Host SSL Generator Contract Integration {#blocks-nginx-usage-ssl}
This module integrates with the [SSL Generator Contract](./contracts-ssl.html)
to setup HTTPs with the [`shb.nginx.vhosts.*.ssl`](#blocks-nginx-options-shb.nginx.vhosts._.ssl) option:
```nix
{
shb.nginx.vhosts = [
{
domain = "example.com";
subdomain = "mysubdomain";
ssl = config.shb.certs.certs.letsencrypt.${domain};;
}
];
shb.certs.certs.letsencrypt.${domain} = {
inherit domain;
};
}
```
### Virtual Host SHB Forward Authentication {#blocks-nginx-usage-shbforwardauth}
For services provided by SelfHostBlocks that do not handle [OIDC integration][OIDC],
this block can provide [forward authentication][] which still allows the service
to still be protected by an SSO server.
[OIDC]: blocks-authelia.html#blocks-authelia-shb-oidc
The user could still be required to authenticate to the service itself,
although some services can automatically users authorized by Authelia.
[forward authentication]: https://doc.traefik.io/traefik/middlewares/http/forwardauth/
Integrating with this block is done with the following code:
```nix
shb.<services>.authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
```
### Virtual Host Forward Authentication {#blocks-nginx-usage-forwardauth}
Forward authentication is when Nginx talks with the SSO service directly
and the user is authenticated before reaching the upstream application.
The SSO service responds with the username, group and more information about the user.
This is then forwarded to the upstream application by Nginx.
Note that _every_ request is authenticated this way with the SSO server
so it involves more hops than a direct [OIDC integration](blocks-authelia.html#blocks-authelia-shb-oidc).
```nix
{
shb.nginx.vhosts = [
{
domain = "example.com";
subdomain = "mysubdomain";
authEndpoint = "authelia.example.com";
autheliaRules = [
[
# Protect /admin endpoint with 2FA
# and only allow access to admin users.
{
domain = "myapp.example.com";
policy = "two_factor";
subject = [ "group:service_admin" ];
resources = [
"^/admin"
];
}
# Leave /api endpoint open - assumes an API key is used to protect it.
{
domain = "myapp.example.com";
policy = "bypass";
resources = [
"^/api"
];
},
# Protect rest of app with 1FA
# and allow access to normal and admin users.
{
domain = "myapp.example.com";
policy = "one_factor";
subject = ["group:service_user"];
},
]
];
}
];
}
```
If PHP is used with fastCGI,
extra headers must be added by enabling the [`shb.nginx.vhosts.*.phpForwardAuth`](#blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth) option.
### Virtual Host Extra Config {#blocks-nginx-usage-extraconfig}
To add extra configuration to a virtual host,
use the [`shb.nginx.vhosts.*.extraConfig`](#blocks-nginx-options-shb.nginx.vhosts._.extraConfig) option.
This can be used to add headers, for example:
```nix
{
shb.nginx.vhosts = [
{
domain = "example.com";
subdomain = "mysubdomain";
extraConfig = ''
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
'';
}
];
}
```
## Options Reference {#blocks-nginx-options}
```{=include=} options
id-prefix: blocks-nginx-options-
list-id: selfhostblocks-block-nginx-options
source: @OPTIONS_JSON@
```

View file

@ -2,11 +2,11 @@
config,
lib,
pkgs,
shb,
...
}:
let
cfg = config.shb.postgresql;
contracts = pkgs.callPackage ../contracts { };
upgrade-script =
old: new:
@ -39,6 +39,10 @@ let
'';
in
{
imports = [
../../lib/module.nix
];
options.shb.postgresql = {
debug = lib.mkOption {
type = lib.types.bool;
@ -63,13 +67,13 @@ in
default = { };
type = lib.types.submodule {
options = contracts.databasebackup.mkRequester {
options = shb.contracts.databasebackup.mkRequester {
user = "postgres";
backupName = "postgres.sql";
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 = ''
@ -158,7 +162,7 @@ in
{ username, passwordFile, ... }:
''
password := trim(both from replace(pg_read_file('${passwordFile}'), E'\n', '''));
EXECUTE format('ALTER ROLE ${username} WITH PASSWORD '''%s''';', password);
EXECUTE format('ALTER ROLE "${username}" WITH PASSWORD '''%s''';', password);
'';
cfgsWithPasswords = builtins.filter (cfg: cfg.passwordFile != null) ensureCfgs;
in

View file

@ -6,17 +6,46 @@ This block sets up a [PostgreSQL][] database.
[postgresql]: https://www.postgresql.org/
## Tests {#blocks-postgresql-tests}
Compared to the upstream nixpkgs module, this module also sets up:
Specific integration tests are defined in [`/test/blocks/postgresql.nix`](@REPO@/test/blocks/postgresql.nix).
- Enabling TCP/IP login and also accepting password authentication from localhost with [`shb.postgresql.enableTCPIP`](#blocks-postgresql-options-shb.postgresql.enableTCPIP).
- Enhance the `ensure*` upstream option by setting up a database's password from a password file with [`shb.postgresql.ensures`](#blocks-postgresql-options-shb.postgresql.ensures).
- Debug logging with `auto_explain` and `pg_stat_statements` with [`shb.postgresql.debug`](#blocks-postgresql-options-shb.postgresql.debug).
## Database Backup Requester Contracts {#blocks-postgresql-contract-databasebackup}
## Usage {#blocks-postgresql-usage}
### Ensure User and Database {#blocks-postgresql-ensures}
Ensure a database and user exists:
```nix
shb.postgresql.ensures = [
{
username = "firefly-iii";
database = "firefly-iii";
}
];
```
Also set up the database password from a file path:
```nix
shb.postgresql.ensures = [
{
username = "firefly-iii";
database = "firefly-iii";
passwordFile = "/run/secrets/firefly-iii_db_password";
}
];
```
### Database Backup Requester Contracts {#blocks-postgresql-contract-databasebackup}
This block can be backed up using the [database backup](contracts-databasebackup.html) contract.
Contract integration tests are defined in [`/test/contracts/databasebackup.nix`](@REPO@/test/contracts/databasebackup.nix).
### Backing up All Databases {#blocks-postgresql-contract-databasebackup-all}
#### Backing up All Databases {#blocks-postgresql-contract-databasebackup-all}
```nix
{
@ -30,6 +59,10 @@ Contract integration tests are defined in [`/test/contracts/databasebackup.nix`]
}
```
## Tests {#blocks-postgresql-tests}
Specific integration tests are defined in [`/test/blocks/postgresql.nix`](@REPO@/test/blocks/postgresql.nix).
## Options Reference {#blocks-postgresql-options}
```{=include=} options

View file

@ -2,6 +2,7 @@
config,
pkgs,
lib,
shb,
utils,
...
}:
@ -9,8 +10,6 @@
let
cfg = config.shb.restic;
contracts = pkgs.callPackage ../contracts { };
inherit (lib)
concatStringsSep
filterAttrs
@ -60,11 +59,11 @@ let
passphrase = lib.mkOption {
description = "Encryption key for the backup repository.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.request.user;
ownerText = "[shb.restic.${prefix}.<name>.request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)";
restartUnits = [ (fullName name config.settings.repository) ];
restartUnits = [ "${fullName name config.settings.repository}.service" ];
restartUnitsText = "[ [shb.restic.${prefix}.<name>.settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]";
};
};
@ -80,7 +79,7 @@ let
};
secrets = mkOption {
type = attrsOf lib.shb.secretFileType;
type = attrsOf shb.secretFileType;
default = { };
description = ''
Secrets needed to access the repository where the backups will be stored.
@ -103,7 +102,7 @@ let
OnCalendar = "daily";
Persistent = true;
};
description = ''When to run the backup. See {manpage}`systemd.timer(5)` for details.'';
description = "When to run the backup. See {manpage}`systemd.timer(5)` for details.";
example = {
OnCalendar = "00:05";
RandomizedDelaySec = "5h";
@ -148,15 +147,24 @@ let
fullName = name: repository: "restic-backups-${name}_${repoSlugName repository.path}";
in
{
imports = [
../../lib/module.nix
../blocks/monitoring.nix
];
options.shb.restic = {
enableDashboard = lib.mkEnableOption "the Backups SHB dashboard" // {
default = true;
};
instances = mkOption {
description = "Files to backup following the [backup contract](./contracts-backup.html).";
description = "Files to backup following the [backup contract](./shb.contracts-backup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = contracts.backup.mkProvider {
options = shb.contracts.backup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the Restic provider.
@ -184,13 +192,13 @@ in
};
databases = mkOption {
description = "Databases to backup following the [database backup contract](./contracts-databasebackup.html).";
description = "Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html).";
default = { };
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = contracts.databasebackup.mkProvider {
options = shb.contracts.databasebackup.mkProvider {
settings = mkOption {
description = ''
Settings specific to the Restic provider.
@ -380,10 +388,10 @@ in
"${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) (
let
script = lib.shb.genConfigOutOfBandSystemd {
script = shb.genConfigOutOfBandSystemd {
config = instance.settings.repository.secrets;
configLocation = "/run/secrets_restic/${serviceName}";
generator = lib.shb.toEnvVar;
generator = shb.toEnvVar;
user = instance.request.user;
};
in
@ -405,15 +413,21 @@ in
nameValuePair "${fullName name instance.settings.repository}_restore_gen" {
enable = true;
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 = (
lib.shb.replaceSecrets {
shb.replaceSecrets {
userConfig = instance.settings.repository.secrets // {
RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path;
RESTIC_REPOSITORY = instance.settings.repository.path;
};
resultPath = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
generator = lib.shb.toEnvVar;
generator = shb.toEnvVar;
user = instance.request.user;
}
);
@ -426,38 +440,16 @@ in
let
mkResticBinary =
name: instance:
pkgs.writeShellApplication {
shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}")
set +a
echo "Will restore archive 'latest'"
sudocmd ${pkgs.restic}/bin/restic restore latest --target /
'';
user = instance.request.user;
sudoPreserveEnvs = [
"RESTIC_REPOSITORY"
"RESTIC_PASSWORD_FILE"
];
secretsFile = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
restoreCmd = ''${pkgs.restic}/bin/restic restore \"$snapshot\" --target /'';
listCmd = ''if [ -e \"$RESTIC_REPOSITORY/index\" ]; then ${pkgs.restic}/bin/restic snapshots --json | ${pkgs.jq}/bin/jq '.[].id'; fi'';
};
in
flatten (mapAttrsToList mkResticBinary cfg.instances);
@ -467,42 +459,26 @@ in
let
mkResticBinary =
name: instance:
pkgs.writeShellApplication {
shb.contracts.backup.mkRestoreScript {
name = fullName name instance.settings.repository;
text = ''
usage() {
echo "$0 restore latest"
}
if ! [ "$1" = "restore" ]; then
usage
exit 1
fi
shift
if ! [ "$1" = "latest" ]; then
usage
exit 1
fi
shift
sudocmd() {
sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@"
}
set -a
# shellcheck disable=SC1090
source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}")
set +a
echo "Will restore archive 'latest'"
sudocmd sh -c "${pkgs.restic}/bin/restic dump latest ${instance.request.backupName} | ${instance.request.restoreCmd}"
'';
user = instance.request.user;
sudoPreserveEnvs = [
"RESTIC_REPOSITORY"
"RESTIC_PASSWORD_FILE"
];
secretsFile = "/run/secrets_restic_env/${fullName name instance.settings.repository}";
restoreCmd = ''${pkgs.restic}/bin/restic dump \"$snapshot\" ${instance.request.backupName} | ${instance.request.restoreCmd}'';
listCmd = ''if [ -e \"$RESTIC_REPOSITORY/index\" ]; then ${pkgs.restic}/bin/restic snapshots --json | ${pkgs.jq}/bin/jq '.[].id'; fi'';
};
in
flatten (mapAttrsToList mkResticBinary cfg.databases);
}
(lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) {
shb.monitoring.dashboards = [
./backup/dashboard/Backups.json
];
})
]
);
}

View file

@ -3,6 +3,7 @@
Defined in [`/modules/blocks/restic.nix`](@REPO@/modules/blocks/restic.nix).
This block sets up a backup job using [Restic][].
It is heavily based on the nixpkgs Restic module.
[restic]: https://restic.net/
@ -50,7 +51,7 @@ shb.restic.instances."myservice" = {
settings = {
enable = true;
passphrase.result = shb.sops.secret."passphrase".result;
passphrase.result = config.shb.sops.secret."passphrase".result;
repository = {
path = "/srv/backups/myservice";
@ -71,7 +72,7 @@ shb.restic.instances."myservice" = {
};
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}
@ -82,12 +83,12 @@ the snippet above becomes:
```nix
shb.restic.instances."myservice" = {
request = config.myservice.backup;
request = config.myservice.backup.request;
settings = {
enable = true;
passphrase.result = shb.sops.secret."passphrase".result;
passphrase.result = config.shb.sops.secret."passphrase".result;
repository = {
path = "/srv/backups/myservice";
@ -108,7 +109,7 @@ shb.restic.instances."myservice" = {
};
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}
@ -118,7 +119,7 @@ Here we will only highlight the differences with the previous configuration.
This assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/).
```diff
shb.backup.instances.myservice = {
shb.test.backup.instances.myservice = {
repository = {
- path = "/srv/pool1/backups/myfolder";
@ -211,28 +212,62 @@ backupcfg = repositories: name: sourceDirectories {
Now, we can define multiple backup jobs to backup different folders:
```nix
shb.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"];
shb.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"];
shb.test.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"];
shb.test.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"];
```
The difference between the above snippet and putting all the folders into one configuration (shown
below) is the former splits the backups into sub-folders on the repositories.
```nix
shb.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"];
shb.test.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"];
```
## Monitoring {#blocks-restic-monitoring}
[WIP](https://github.com/ibizaman/selfhostblocks/issues/151)
A generic dashboard for all backup solutions is provided.
See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter.
## Maintenance {#blocks-restic-maintenance}
One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets.
### 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,
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`:

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

@ -1,18 +1,20 @@
{
config,
lib,
pkgs,
shb,
...
}:
let
inherit (lib) mapAttrs mkOption;
inherit (lib.types) attrsOf anything submodule;
contracts = pkgs.callPackage ../contracts { };
cfg = config.shb.sops;
in
{
imports = [
../../lib/module.nix
];
options.shb.sops = {
secret = mkOption {
description = "Secret following the [secret contract](./contracts-secret.html).";
@ -21,7 +23,7 @@ in
submodule (
{ name, options, ... }:
{
options = contracts.secret.mkProvider {
options = shb.contracts.secret.mkProvider {
settings = mkOption {
description = ''
Settings specific to the Sops provider.

View file

@ -13,7 +13,7 @@ to adapt it to the [secret contract](./contracts-secret.html).
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.
[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
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

View file

@ -2,14 +2,13 @@
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.certs;
contracts = pkgs.callPackage ../contracts { };
inherit (builtins) dirOf;
inherit (lib)
flatten
@ -20,6 +19,11 @@ let
;
in
{
imports = [
../../lib/module.nix
./monitoring.nix
];
options.shb.certs = {
systemdService = lib.mkOption {
description = ''
@ -28,6 +32,9 @@ in
type = lib.types.str;
default = "shb-ca-bundle.service";
};
enableDashboard = lib.mkEnableOption "the SSL SHB dashboard" // {
default = true;
};
cas.selfsigned = lib.mkOption {
description = "Generate a self-signed Certificate Authority.";
default = { };
@ -51,7 +58,7 @@ in
This option implements the SSL Generator contract.
'';
type = contracts.ssl.certs-paths;
type = shb.contracts.ssl.certs-paths;
default = {
key = "/var/lib/certs/cas/${config._module.args.name}.key";
cert = "/var/lib/certs/cas/${config._module.args.name}.cert";
@ -81,7 +88,7 @@ in
{
options = {
ca = lib.mkOption {
type = lib.types.nullOr contracts.ssl.cas;
type = lib.types.nullOr shb.contracts.ssl.cas;
description = ''
CA used to generate this certificate. Only used for self-signed.
@ -128,7 +135,7 @@ in
This option implements the SSL Generator contract.
'';
type = contracts.ssl.certs-paths;
type = shb.contracts.ssl.certs-paths;
default = {
key = "/var/lib/certs/selfsigned/${config._module.args.name}.key";
cert = "/var/lib/certs/selfsigned/${config._module.args.name}.cert";
@ -196,7 +203,7 @@ in
This option implements the SSL Generator contract.
'';
type = contracts.ssl.certs-paths;
type = shb.contracts.ssl.certs-paths;
default = {
key = "/var/lib/acme/${config._module.args.name}/key.pem";
cert = "/var/lib/acme/${config._module.args.name}/cert.pem";
@ -335,6 +342,40 @@ in
serviceName = lib.strings.removeSuffix ".service";
in
lib.mkMerge [
# Generic assertions
{
assertions = lib.flatten (
lib.mapAttrsToList (
_name: certCfg:
(
let
domainInExtraDomains =
(lib.lists.findFirstIndex (x: x == certCfg.domain) null certCfg.extraDomains) != null;
firstDuplicateDomain =
(
l:
let
sorted = lib.sort (x: y: x < y) l;
maybeDupe = lib.lists.removePrefix (lib.lists.commonPrefix (lib.uniqueStrings sorted) sorted) sorted;
in
if maybeDupe == [ ] then null else lib.head maybeDupe
)
certCfg.extraDomains;
in
[
{
assertion = !domainInExtraDomains;
message = "Error in SHB option for domain ${certCfg.domain}: do not repeat the domain name in the `extraDomain` option.";
}
{
assertion = firstDuplicateDomain == null;
message = "Error in SHB option for domain ${certCfg.domain}: `extraDomain` option cannot have duplicates, first offender is: ${firstDuplicateDomain} in the following list: ${lib.concatStringsSep " " certCfg.extraDomains}.";
}
]
)
) (cfg.certs.selfsigned // cfg.certs.letsencrypt)
);
}
# Config for self-signed CA.
{
systemd.services = lib.mapAttrs' (
@ -358,21 +399,25 @@ in
crl_signing_key
EOF
mkdir -p "$(dirname -- "${caCfg.paths.key}")"
${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile ${caCfg.paths.key}
chmod 666 ${caCfg.paths.key}
keyfile="${caCfg.paths.key}"
keydir="$(dirname -- "$keyfile")"
mkdir -p "$keydir"
[ -f "$keyfile" ] || ${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile "$keyfile"
chmod 666 "$keyfile"
mkdir -p "$(dirname -- "${caCfg.paths.cert}")"
${pkgs.gnutls}/bin/certtool \
--generate-self-signed \
--load-privkey ${caCfg.paths.key} \
--template ca.template \
--outfile ${caCfg.paths.cert}
chmod 666 ${caCfg.paths.cert}
certfile="${caCfg.paths.cert}"
certdir="$(dirname -- "$certfile")"
mkdir -p "$certdir"
[ -f "$certfile" ] || ${pkgs.gnutls}/bin/certtool \
--generate-self-signed \
--load-privkey "$keyfile" \
--template ca.template \
--outfile "$certfile"
chmod 666 "$certfile"
'';
}
) cfg.cas.selfsigned;
@ -386,6 +431,8 @@ in
script = ''
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-certificates.crt
@ -415,8 +462,8 @@ in
let
extraDnsNames = lib.strings.concatStringsSep "\n" (map (n: "dns_name = ${n}") certCfg.extraDomains);
chmod = cert: ''
chown root:${certCfg.group} ${cert}
chmod 640 ${cert}
chown root:${certCfg.group} "${cert}"
chmod 640 "${cert}"
'';
in
''
@ -433,23 +480,27 @@ in
signing_key
EOF
mkdir -p "$(dirname -- "${certCfg.paths.key}")"
${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile ${certCfg.paths.key}
${chmod certCfg.paths.key}
keyfile="${certCfg.paths.key}"
keydir="$(dirname -- "$keyfile")"
mkdir -p "$keydir"
[ -f "$keyfile" ] || ${pkgs.gnutls}/bin/certtool \
--generate-privkey \
--key-type rsa \
--sec-param High \
--outfile "$keyfile"
${chmod "$keyfile"}
mkdir -p "$(dirname -- "${certCfg.paths.cert}")"
${pkgs.gnutls}/bin/certtool \
--generate-certificate \
--load-privkey ${certCfg.paths.key} \
--load-ca-privkey ${certCfg.ca.paths.key} \
--load-ca-certificate ${certCfg.ca.paths.cert} \
--template server.template \
--outfile ${certCfg.paths.cert}
${chmod certCfg.paths.cert}
certfile="${certCfg.paths.cert}"
certdir="$(dirname -- "$certfile")"
mkdir -p "$certdir"
[ -f "$certfile" ] || ${pkgs.gnutls}/bin/certtool \
--generate-certificate \
--load-privkey "$keyfile" \
--load-ca-privkey "${certCfg.ca.paths.key}" \
--load-ca-certificate "${certCfg.ca.paths.cert}" \
--template server.template \
--outfile "$certfile"
${chmod "$certfile"}
'';
postStart = lib.optionalString (certCfg.reloadServices != [ ]) ''
@ -492,7 +543,7 @@ in
[
{
"${name}" = {
extraDomainNames = [ certCfg.domain ] ++ certCfg.extraDomains;
extraDomainNames = certCfg.extraDomains;
email = certCfg.adminEmail;
enableDebugLogs = certCfg.debug;
server = lib.mkIf certCfg.stagingServer "https://acme-staging-v02.api.letsencrypt.org/directory";
@ -500,7 +551,7 @@ in
// lib.optionalAttrs (certCfg.dnsProvider != null) {
inherit (certCfg) dnsProvider dnsResolver;
inherit (certCfg) group reloadServices;
credentialsFile = certCfg.credentialsFile;
environmentFile = certCfg.credentialsFile;
};
}
]
@ -625,5 +676,10 @@ in
in
optionals (cfg.certs.letsencrypt != { }) (flatten (mapAttrsToList scrapeCfg cfg.certs.letsencrypt));
}
(lib.mkIf (cfg.enableDashboard && (cfg.certs.selfsigned != { } || cfg.certs.letsencrypt != { })) {
shb.monitoring.dashboards = [
./ssl/dashboard/SSL.json
];
})
];
}

View file

@ -0,0 +1,727 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 16,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"axisSoftMin": 0,
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "line+area"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "transparent",
"value": 604808
}
]
},
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true,
"sortBy": "Last *",
"sortDesc": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"expr": "min by(exported_hostname, subject, path) (ssl_certificate_expiry_seconds{subject=~\"CN=$job\"})",
"legendFormat": "{{exported_hostname}}: {{subject}} {{path}}",
"range": true,
"refId": "A"
}
],
"title": "Certificate Remaining Validity",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineStyle": {
"fill": "solid"
},
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"fieldMinMax": false,
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
}
]
},
"unit": "dateTimeFromNow"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "right",
"showLegend": true,
"sortBy": "Last *",
"sortDesc": true,
"width": 300
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.2.0",
"targets": [
{
"editorMode": "code",
"exemplar": false,
"expr": "systemd_timer_next_trigger_seconds{name=~\"acme-renew-$job.timer\"} * 1000",
"format": "time_series",
"instant": false,
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Schedule",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"fieldConfig": {
"defaults": {
"color": {
"fixedColor": "green",
"mode": "fixed"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"footer": {
"reducers": []
},
"inspect": false
},
"decimals": 0,
"mappings": [],
"noValue": "0",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "green",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "% failed"
},
"properties": [
{
"id": "unit",
"value": "percentunit"
},
{
"id": "custom.cellOptions",
"value": {
"mode": "gradient",
"type": "color-background"
}
},
{
"id": "color",
"value": {
"mode": "continuous-GrYlRd"
}
},
{
"id": "max",
"value": 1
}
]
},
{
"matcher": {
"id": "byName",
"options": "total"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"mode": "gradient",
"type": "color-background"
}
},
{
"id": "color",
"value": {
"mode": "thresholds"
}
},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "transparent",
"value": 1
}
]
}
}
]
},
{
"matcher": {
"id": "byType",
"options": "string"
},
"properties": [
{
"id": "custom.minWidth",
"value": 150
}
]
},
{
"matcher": {
"id": "byType",
"options": "number"
},
"properties": [
{
"id": "custom.width",
"value": 100
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 4,
"options": {
"cellHeight": "sm",
"enablePagination": true,
"frozenColumns": {},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"exemplar": false,
"expr": "increase(systemd_unit_state{name=~\"acme-(order-renew-)?[[job]].service\", state=\"activating\"}[7d])",
"instant": true,
"legendFormat": "__auto",
"range": false,
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"exemplar": false,
"expr": "increase(systemd_unit_state{name=~\"acme-(order-renew-)?[[job]].service\", state=\"failed\"}[7d])",
"hide": false,
"instant": true,
"legendFormat": "__auto",
"range": false,
"refId": "B"
}
],
"title": "Jobs in the Past Week",
"transformations": [
{
"id": "labelsToFields",
"options": {
"mode": "columns"
}
},
{
"id": "merge",
"options": {}
},
{
"id": "groupingToMatrix",
"options": {
"columnField": "state",
"rowField": "name",
"valueField": "Value"
}
},
{
"id": "calculateField",
"options": {
"alias": "total",
"binary": {
"left": {
"matcher": {
"id": "byName",
"options": "activating"
}
},
"operator": "+",
"right": {
"matcher": {
"id": "byName",
"options": "failed"
}
}
},
"mode": "binary",
"reduce": {
"include": [
"activating",
"failed"
],
"reducer": "sum"
}
}
},
{
"id": "calculateField",
"options": {
"alias": "% failed",
"binary": {
"left": {
"matcher": {
"id": "byName",
"options": "failed"
}
},
"operator": "/",
"right": {
"matcher": {
"id": "byName",
"options": "total"
}
}
},
"mode": "binary",
"reduce": {
"reducer": "sum"
}
}
},
{
"id": "sortBy",
"options": {
"fields": {},
"sort": [
{
"desc": true,
"field": "total"
}
]
}
},
{
"id": "sortBy",
"options": {
"fields": {},
"sort": [
{
"desc": true,
"field": "failed"
}
]
}
},
{
"id": "organize",
"options": {
"excludeByName": {},
"includeByName": {},
"indexByName": {},
"renameByName": {
"activating": "success",
"name\\state": "Job"
}
}
}
],
"type": "table"
},
{
"description": "",
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 2,
"w": 12,
"x": 12,
"y": 8
},
"id": 7,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "If the log panel is empty, it may be because the amount of lines is too high. Try filtering a few jobs first.",
"mode": "markdown"
},
"pluginVersion": "12.2.0",
"title": "",
"type": "text"
},
{
"datasource": {
"default": false,
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 14,
"w": 12,
"x": 12,
"y": 10
},
"id": 8,
"options": {
"dedupStrategy": "none",
"enableInfiniteScrolling": false,
"enableLogDetails": false,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "cd6cc53e-840c-484d-85f7-96fede324006"
},
"direction": "backward",
"editorMode": "code",
"expr": "{unit=~\"acme-(order-renew-)?($job).service\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Logs - $job",
"type": "logs"
},
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"description": "The job duration is not accurate. Jobs taking less than 15s to run will sometimes appear as taking 100s.",
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
"mappings": [
{
"options": {
"1": {
"color": "green",
"index": 0,
"text": "Running"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "#EAB839",
"value": 0
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 6,
"options": {
"alignValue": "left",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "never",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.2.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "df80f9f5-97d7-4112-91d8-72f523a02b09"
},
"editorMode": "code",
"exemplar": false,
"expr": "max(label_replace(systemd_unit_state{name=~\"acme-(order-renew-)?$job.service\", state=\"activating\"}, \"name\", \"$1\", \"name\", \"acme-order-renew-(.*).service\") > 0 or on(name) label_replace(clamp(systemd_timer_last_trigger_seconds{name=~\"acme-renew-$job.timer\"} - (systemd_timer_last_trigger_seconds{name=~\"acme-renew-$job.timer\"} offset 100s) > 0, 0, 1), \"name\", \"$1\", \"name\", \"acme-renew-(.*).timer\")) by (name)",
"format": "time_series",
"hide": false,
"instant": false,
"key": "Q-e1d5c07a-8dcc-4f34-aa5c-cdebcbdda322-0",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Job Runs",
"type": "state-timeline"
}
],
"preload": false,
"schemaVersion": 42,
"tags": [],
"templating": {
"list": [
{
"current": {
"text": [
"All"
],
"value": [
"$__all"
]
},
"definition": "label_values(systemd_unit_state{name=~\"acme-renew-.*.timer\"},name)",
"includeAll": true,
"label": "Job",
"multi": true,
"name": "job",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(systemd_unit_state{name=~\"acme-renew-.*.timer\"},name)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "/acme-renew-(?<value>.*).timer/",
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "SSL Certificates",
"uid": "ae818js0bvw8wb",
"version": 25
}

View file

@ -62,6 +62,7 @@ We can ask Let's Encrypt to generate a certificate with:
shb.certs.certs.letsencrypt."example.com" = {
domain = "example.com";
group = "nginx";
reloadServices = [ "nginx.service" ];
dnsProvider = "linode";
adminEmail = "admin@example.com";
credentialsFile = /path/to/secret/file;
@ -79,6 +80,13 @@ The credential file's content would be a key-value pair:
LINODE_TOKEN=XYZ...
```
If you use one subdomain per service,
asking for certificates for a subdomain is done with:
```nix
shb.certs.certs.letsencrypt."example.com".extraDomains = [ "nextcloud.${domain}" ];
```
For other providers, see the [official instruction](https://go-acme.github.io/lego/dns/).
## Usage {#block-ssl-usage}
@ -110,11 +118,35 @@ config.shb.certs.systemdService
See also the [SSL certificate generator usage](contracts-ssl.html#ssl-contract-usage) for a more detailed usage
example.
## Monitoring {#blocks-ssl-monitoring}
A dashboard for SSL certificates is provided.
See [SSL Certificates Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-ssl) section in the monitoring chapter.
## Debug {#block-ssl-debug}
Each CA and Cert is generated by a systemd service whose name can be seen in the `systemdService`
option. You can then see the latest errors messages using `journalctl`.
### Let's Encrypt debug {#blocks-ssl-debug-lets-encrypt}
Since the SHB SSL block uses the [`security.acme`][] module under the hood,
knowing how that one works can become required if something goes wrong.
For each domain and subdomain, noted as `fqdn` hereunder,
the following systemd timers and services are created:
- `acme-renew-${fqdn}.timer` triggers the `acme-order-renew-${fqdn}.service` service every day.
- `acme-${fqdn}.service` (re)generate the initial self-signed certificate,
only if the following job never succeeded at least once yet.
- `acme-order-renew-${fqdn}.service` asks for a new certificate
only if the certificate will expire in the next 30 days.
Has logic to only renew if the list of domains has not changed.
Also, a global service named `acme-setup.service` is created
[`security.acme`]: https://nixos.org/manual/nixos/stable/#module-security-acme
## Tests {#block-ssl-tests}
The self-signed implementation is tested in [`/tests/vm/ssl.nix`](@REPO@/tests/vm/ssl.nix).

View file

@ -2,83 +2,258 @@
config,
pkgs,
lib,
shb,
utils,
...
}:
let
cfg = config.shb.zfs;
datasets =
if cfg.snapshotBeforeActivation.datasets != null then
cfg.snapshotBeforeActivation.datasets
else
config.boot.zfs.extraPools;
in
{
imports = [
../../lib/module.nix
];
options.shb.zfs = {
defaultPoolName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "ZFS pool name datasets should be created on if no pool name is given in the dataset.";
};
datasets = lib.mkOption {
pools = lib.mkOption {
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 attrset name is the dataset name.
The ZFS pools are not managed by this module, they should already exist.
This block implements the following contracts:
- mount
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.
'';
default = { };
example = lib.literalExpression ''
shb.zfs."safe/postgresql".path = "/var/lib/postgresql";
'';
type = lib.types.attrsOf (
lib.types.submodule {
options = {
enable = lib.mkEnableOption "shb.zfs.datasets";
datasets = lib.mkOption {
description = ''
ZFS Datasets.
poolName = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "ZFS pool name this dataset should be created on. Overrides the defaultPoolName.";
};
Each entry in the attrset will be created and mounted in the given path.
The attrset name is the dataset name.
path = lib.mkOption {
type = lib.types.str;
description = "Path this dataset should be mounted on.";
This block implements the following contracts:
- mount
'';
default = { };
example = lib.literalExpression ''
shb.zfs."safe/postgresql".path = "/var/lib/postgresql";
'';
type = lib.types.attrsOf (
lib.types.submodule (
{ 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 = {
assertions = [
{
assertion =
lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets)
-> cfg.defaultPoolName != null;
message = "Cannot have both datasets.poolName and defaultPoolName set to null";
}
];
boot.zfs.extraPools = lib.uniqueStrings (builtins.attrNames cfg.pools);
system.activationScripts = lib.mapAttrs' (
name: cfg':
systemd.services =
let
dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + "/" + name;
in
lib.attrsets.nameValuePair "zfsCreate-${name}" {
text = ''
${pkgs.zfs}/bin/zfs list ${dataset} > /dev/null 2>&1 \
|| ${pkgs.zfs}/bin/zfs create \
-o mountpoint=none \
${dataset} || :
mkPool =
poolName: poolCfg: lib.listToAttrs (lib.mapAttrsToList (mkDataset poolName) poolCfg.datasets);
[ "$(${pkgs.zfs}/bin/zfs get -H mountpoint -o value ${dataset})" = ${cfg'.path} ] \
|| ${pkgs.zfs}/bin/zfs set \
mountpoint=${cfg'.path} \
${dataset}
'';
}
) cfg.datasets;
mkDataset =
poolName: name: cfg':
let
dataset = poolName + "/" + name;
in
lib.attrsets.nameValuePair "zfs-create-${utils.escapeSystemdPath dataset}" {
# 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, ... }:
{
lib,
shb,
pkgs,
...
}:
let
inherit (lib)
concatStringsSep
@ -13,7 +18,7 @@ let
submodule
str
;
inherit (lib.shb) anyNotNull;
inherit (shb) anyNotNull;
in
{
mkRequest =
@ -158,7 +163,7 @@ in
description = ''
Result part of the backup contract.
Options set by the provider module that indicates the name of the backup and restor scripts.
Options set by the provider module that indicates the name of the backup and restore scripts.
'';
default = {
inherit restoreScript backupService;
@ -187,12 +192,14 @@ in
```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 latest
$ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore <snapshot 1>
```
'';
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}
A service that can be backed up will provide a `backup` option.
Such a service is a `requester` providing a `request` for a module `provider` of this contract.
What this option defines is, from the user perspective - that is _you_ - an implementation detail
but it will at least define what directories to backup,
the user to backup with
and possibly hooks to run before or after the backup job runs.
Here is an example module defining such a `backup` option:
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.backup = mkOption {
type = contracts.backup.request;
default = {
user = "myservice";
sourceDirectories = [
"/var/lib/myservice"
];
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = "nextcloud";
sourceDirectories = [
"/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.
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`.
Let's assume such a module is available under the `backupService` option
and that one can create multiple backup instances under `backupService.instances`.
Then, to actually backup the `myservice` service, one would write:
```nix
backupservice.instances.myservice = {
request = myservice.backup;
backupService.instances.myservice = {
request = myservice.backup.request;
settings = {
enable = true;
@ -63,17 +60,17 @@ backupservice.instances.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.
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
backupservice.instances.myservice_2 = {
request = myservice.backup;
backupService.instances.myservice_2 = {
request = myservice.backup.request;
settings = {
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}
- [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}

View file

@ -1,11 +1,13 @@
{ pkgs, lib, ... }:
{ lib, shb, ... }:
let
contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption;
inherit (lib.types) submodule;
in
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.backup = mkOption {
description = ''
Contract for backing up files
@ -23,7 +25,7 @@ in
'';
type = submodule {
options = contracts.backup.contract;
options = shb.contracts.backup.contract;
};
};
}

View file

@ -1,4 +1,8 @@
{ pkgs, lib }:
{
pkgs,
lib,
shb,
}:
let
inherit (lib)
concatMapStringsSep
@ -17,16 +21,16 @@ in
"/opt/files/A"
"/opt/files/B"
],
settings, # { repository, config } -> attrset
extraConfig ? null, # { username, config } -> attrset
settings ? { ... }: { }, # { filesRoot, config } -> attrset
extraConfig ? null, # { filesRoot, username, config } -> attrset
}:
lib.shb.runNixOSTest {
shb.test.runNixOSTest {
inherit name;
nodes.machine =
{ config, ... }:
{
imports = [ lib.shb.baseImports ] ++ modules;
imports = [ shb.test.baseImports ] ++ modules;
config = lib.mkMerge [
(setAttrByPath providerRoot {
@ -36,7 +40,7 @@ lib.shb.runNixOSTest {
};
settings = settings {
inherit config;
repository = "/opt/repos/${name}";
filesRoot = "/opt/files";
};
})
(mkIf (username != "root") {
@ -48,6 +52,7 @@ lib.shb.runNixOSTest {
})
(optionalAttrs (extraConfig != null) (extraConfig {
inherit username config;
filesRoot = "/opt/files";
}))
];
};
@ -61,7 +66,9 @@ lib.shb.runNixOSTest {
provider = (getAttrFromPath providerRoot nodes.machine).result;
in
''
from datetime import datetime, timedelta
from dictdiffer import diff
import re
username = "${username}"
sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ]
@ -99,9 +106,25 @@ lib.shb.runNixOSTest {
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"):
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"):
for path in sourceDirectories:
@ -115,14 +138,37 @@ lib.shb.runNixOSTest {
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 initial content from repo"):
machine.succeed("""${provider.restoreScript} restore latest""")
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, {

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

@ -1,4 +1,4 @@
{ lib, ... }:
{ lib, shb, ... }:
let
inherit (lib)
mkOption
@ -8,7 +8,7 @@ let
optionalString
;
inherit (lib.types) submodule str;
inherit (lib.shb) anyNotNull;
inherit (shb) anyNotNull;
in
{
mkRequest =
@ -24,7 +24,7 @@ in
}:
mkOption {
description = ''
Request part of the backup contract.
Request part of the database backup contract.
Options set by the requester module
enforcing how to backup files.
@ -122,9 +122,9 @@ in
}:
mkOption {
description = ''
Result part of the backup contract.
Result part of the database backup contract.
Options set by the provider module that indicates the name of the backup and restor scripts.
Options set by the provider module that indicates the name of the backup and restore scripts.
'';
default = {
inherit restoreScript backupService;

View file

@ -20,21 +20,21 @@ source: @OPTIONS_JSON@
## Usage {#contract-databasebackup-usage}
A database that can be backed up will provide a `databasebackup` option.
Such a service is a `requester` providing a `request` for a module `provider` of this contract.
What this option defines is, from the user perspective - that is _you_ - an implementation detail
but it will at least define how to create a database dump,
the user to backup with
and how to restore from a database dump.
Here is an example module defining such a `databasebackup` option:
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
{
options = {
myservice.databasebackup = mkOption {
type = contracts.databasebackup.request;
default = {
type = shb.contracts.databasebackup.mkRequester = {
user = "myservice";
backupCmd = ''
${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.
Let's assume such a module is available under the `databasebackupservice` option
and that one can create multiple backup instances under `databasebackupservice.instances`.
Let's assume such a module is available under the `databaseBackupService` option
and that one can create multiple backup instances under `databaseBackupService.instances`.
Then, to actually backup the `myservice` service, one would write:
```nix
databasebackupservice.instances.myservice = {
request = myservice.databasebackup;
databaseBackupService.instances.myservice = {
request = myservice.databasebackup.request;
settings = {
enable = true;
@ -72,11 +72,11 @@ databasebackupservice.instances.myservice = {
```
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
databasebackupservice.instances.myservice_2 = {
request = myservice.backup;
databaseBackupService.instances.myservice_2 = {
request = myservice.databasebackup.request;
settings = {
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}
- [Restic block](blocks-restic.html).
- [Borgbackup block](blocks-borgbackup.html) [WIP].
- [Borgbackup block](blocks-borgbackup.html).
## Requester Blocks and Services {#contract-databasebackup-requesters}

View file

@ -1,11 +1,13 @@
{ pkgs, lib, ... }:
{ lib, shb, ... }:
let
contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption;
inherit (lib.types) submodule;
in
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.databasebackup = mkOption {
description = ''
Contract for database backup between a requester module
@ -23,7 +25,7 @@ in
'';
type = submodule {
options = contracts.databasebackup.contract;
options = shb.contracts.databasebackup.contract;
};
};
}

View file

@ -1,4 +1,8 @@
{ pkgs, lib }:
{
pkgs,
lib,
shb,
}:
let
inherit (lib)
getAttrFromPath
@ -16,13 +20,13 @@ in
database ? "me",
settings, # { repository, config } -> attrset
}:
lib.shb.runNixOSTest {
shb.test.runNixOSTest {
inherit name;
nodes.machine =
{ config, ... }:
{
imports = [ lib.shb.baseImports ] ++ modules;
imports = [ shb.test.baseImports ] ++ modules;
config = lib.mkMerge [
(setAttrByPath providerRoot {
request = (getAttrFromPath requesterRoot config).request;
@ -47,16 +51,18 @@ lib.shb.runNixOSTest {
testScript =
{ nodes, ... }:
let
provider = getAttrFromPath providerRoot nodes.machine;
provider = (getAttrFromPath providerRoot nodes.machine).result;
in
''
import csv
import re
start_all()
machine.wait_for_unit("postgresql.service")
machine.wait_for_unit("postgresql-setup.service")
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)
def query(query):
@ -69,30 +75,68 @@ lib.shb.runNixOSTest {
if len(diff) > 0:
raise Exception(i, diff)
table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}]
with subtest("create fixture"):
machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)"))
machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1), ('lollipop', 2)"))
machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1)"))
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)
with subtest("backup"):
print(machine.succeed("systemctl cat ${provider.result.backupService}"))
print(machine.succeed("ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase"))
machine.succeed("systemctl start ${provider.result.backupService}")
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("drop database"):
machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres"))
machine.fail(peer_cmd("SELECT * FROM test"))
with subtest("restore"):
print(machine.succeed("readlink -f $(type ${provider.result.restoreScript})"))
machine.succeed("${provider.result.restoreScript} restore latest ")
with subtest("restore second snapshot"):
print(machine.succeed("readlink -f $(type ${provider.restoreScript})"))
machine.succeed(f"${provider.restoreScript} restore {secondSnapshot}")
with subtest("check restoration"):
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)
'';
}

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

@ -1,4 +1,8 @@
{ pkgs, lib }:
{
pkgs,
lib,
shb,
}:
let
inherit (lib) mkOption optionalAttrs;
inherit (lib.types) anything;
@ -44,21 +48,31 @@ let
importContract =
module:
let
importedModule = pkgs.callPackage module { };
importedModule = pkgs.callPackage module {
shb = shb // {
inherit contracts;
};
};
in
mkContractFunctions {
(mkContractFunctions {
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 { };
databasebackup = pkgs.callPackage ./databasebackup/test.nix { };
backup = pkgs.callPackage ./backup/test.nix { };
};
}
in
contracts

View file

@ -1,4 +1,4 @@
{ lib, ... }:
{ lib, shb, ... }:
let
inherit (lib)
concatStringsSep
@ -8,7 +8,7 @@ let
optionalString
;
inherit (lib.types) listOf submodule str;
inherit (lib.shb) anyNotNull;
inherit (shb) anyNotNull;
in
{
mkRequest =

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:
```nix
shb.sops.secrets."ldap/user_password" = {
shb.sops.secret."ldap/user_password" = {
request = config.shb.lldap.userPassword.request;
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
@ -63,9 +63,9 @@ sops.defaultSopsFile = ./secrets.yaml;
Then the snippet above is even more simplified:
```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}

View file

@ -1,11 +1,13 @@
{ pkgs, lib, ... }:
{ lib, shb, ... }:
let
contracts = pkgs.callPackage ../. { };
inherit (lib) mkOption;
inherit (lib.types) submodule;
in
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.secret = mkOption {
description = ''
Contract for secrets between a requester module
@ -21,7 +23,7 @@ in
through the `result.*` options.
'';
type = submodule {
options = contracts.secret.contract;
options = shb.contracts.secret.contract;
};
};
}

View file

@ -1,4 +1,8 @@
{ pkgs, lib }:
{
pkgs,
lib,
shb,
}:
let
inherit (lib) getAttrFromPath setAttrByPath;
inherit (lib) mkIf;
@ -13,13 +17,13 @@ in
mode ? "0400",
restartUnits ? [ "myunit.service" ],
}:
lib.shb.runNixOSTest {
shb.test.runNixOSTest {
name = "secret_${name}_${owner}_${group}_${mode}";
nodes.machine =
{ config, ... }:
{
imports = [ lib.shb.baseImports ] ++ modules;
imports = [ shb.test.baseImports ] ++ modules;
config = lib.mkMerge [
(setAttrByPath configRoot {
A = {

View file

@ -1,10 +1,11 @@
{ pkgs, lib, ... }:
let
contracts = pkgs.callPackage ../. { };
in
{ lib, shb, ... }:
{
imports = [
../../../lib/module.nix
];
options.shb.contracts.ssl = lib.mkOption {
description = "Contract for SSL Certificate generator.";
type = contracts.ssl.certs;
type = shb.contracts.ssl.certs;
};
}

View file

@ -2,17 +2,16 @@
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.arr;
contracts = pkgs.callPackage ../contracts { };
apps = {
radarr = {
settingsFormat = lib.shb.formatXML { enclosingRoot = "Config"; };
settingsFormat = shb.formatXML { enclosingRoot = "Config"; };
moreOptions = {
settings = lib.mkOption {
description = "Specific options for radarr.";
@ -21,7 +20,7 @@ let
freeformType = apps.radarr.settingsFormat.type;
options = {
ApiKey = lib.mkOption {
type = lib.shb.secretFileType;
type = shb.secretFileType;
description = "Path to api key secret file.";
};
LogLevel = lib.mkOption {
@ -73,7 +72,7 @@ let
};
};
sonarr = {
settingsFormat = lib.shb.formatXML { enclosingRoot = "Config"; };
settingsFormat = shb.formatXML { enclosingRoot = "Config"; };
moreOptions = {
settings = lib.mkOption {
description = "Specific options for sonarr.";
@ -82,7 +81,7 @@ let
freeformType = apps.sonarr.settingsFormat.type;
options = {
ApiKey = lib.mkOption {
type = lib.shb.secretFileType;
type = shb.secretFileType;
description = "Path to api key secret file.";
};
LogLevel = lib.mkOption {
@ -129,7 +128,7 @@ let
};
};
bazarr = {
settingsFormat = lib.shb.formatXML { enclosingRoot = "Config"; };
settingsFormat = shb.formatXML { enclosingRoot = "Config"; };
moreOptions = {
settings = lib.mkOption {
description = "Specific options for bazarr.";
@ -145,6 +144,10 @@ let
description = "Log level.";
default = "info";
};
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption {
type = lib.types.port;
description = "Port on which bazarr listens to incoming requests.";
@ -157,7 +160,7 @@ let
};
};
readarr = {
settingsFormat = lib.shb.formatXML { enclosingRoot = "Config"; };
settingsFormat = shb.formatXML { enclosingRoot = "Config"; };
moreOptions = {
settings = lib.mkOption {
description = "Specific options for readarr.";
@ -173,6 +176,10 @@ let
description = "Log level.";
default = "info";
};
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption {
type = lib.types.port;
description = "Port on which readarr listens to incoming requests.";
@ -184,7 +191,7 @@ let
};
};
lidarr = {
settingsFormat = lib.shb.formatXML { enclosingRoot = "Config"; };
settingsFormat = shb.formatXML { enclosingRoot = "Config"; };
moreOptions = {
settings = lib.mkOption {
description = "Specific options for lidarr.";
@ -200,6 +207,10 @@ let
description = "Log level.";
default = "info";
};
ApiKey = lib.mkOption {
type = shb.secretFileType;
description = "Path to api key secret file.";
};
Port = lib.mkOption {
type = lib.types.port;
description = "Port on which lidarr listens to incoming requests.";
@ -220,7 +231,7 @@ let
freeformType = apps.jackett.settingsFormat.type;
options = {
ApiKey = lib.mkOption {
type = lib.shb.secretFileType;
type = shb.secretFileType;
description = "Path to api key secret file.";
};
FlareSolverrUrl = lib.mkOption {
@ -229,7 +240,7 @@ let
default = null;
};
OmdbApiKey = lib.mkOption {
type = lib.types.nullOr lib.shb.secretFileType;
type = lib.types.nullOr shb.secretFileType;
description = "File containing the Open Movie Database API key.";
default = null;
};
@ -306,7 +317,7 @@ let
{
domain = "${c.subdomain}.${c.domain}";
policy = "two_factor";
subject = [ "group:arr_user" ];
subject = [ "group:${c.ldapUserGroup}" ];
}
];
};
@ -341,10 +352,20 @@ let
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
ldapUserGroup = lib.mkOption {
description = ''
LDAP group a user must belong to be able to login.
Note that all users are admins too.
'';
type = lib.types.str;
default = "arr_user";
};
authEndpoint = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -358,7 +379,7 @@ let
'';
default = { };
type = lib.types.submodule {
options = contracts.backup.mkRequester {
options = shb.contracts.backup.mkRequester {
user = name;
sourceDirectories = [
cfg.${name}.dataDir
@ -371,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 { });
};
@ -379,7 +414,9 @@ let
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
../blocks/lldap.nix
];
options.shb.arr = lib.listToAttrs (lib.mapAttrsToList appOption apps);
@ -395,21 +432,25 @@ in
services.radarr = {
enable = true;
dataDir = "/var/lib/radarr";
dataDir = cfg'.dataDir;
};
systemd.services.radarr.preStart = lib.shb.replaceSecrets {
systemd.services.radarr.preStart = shb.replaceSecrets {
userConfig =
cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "${config.services.radarr.dataDir}/config.xml";
generator = lib.shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat;
resultPath = "${cfg'.dataDir}/config.xml";
generator = shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat;
};
shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
@ -419,28 +460,36 @@ in
isSSOEnabled = !(isNull cfg'.authEndpoint);
in
{
systemd.tmpfiles.rules = [
"d ${cfg'.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.user}"
];
services.nginx.enable = true;
services.sonarr = {
enable = true;
dataDir = "/var/lib/sonarr";
dataDir = cfg'.dataDir;
};
users.users.sonarr = {
extraGroups = [ "media" ];
};
systemd.services.sonarr.preStart = lib.shb.replaceSecrets {
systemd.services.sonarr.preStart = shb.replaceSecrets {
userConfig =
cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "${config.services.sonarr.dataDir}/config.xml";
resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.sonarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
@ -452,45 +501,64 @@ in
{
services.bazarr = {
enable = true;
dataDir = cfg'.dataDir;
listenPort = cfg'.settings.Port;
};
users.users.bazarr = {
extraGroups = [ "media" ];
};
systemd.services.bazarr.preStart = lib.shb.replaceSecrets {
userConfig =
cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "/var/lib/bazarr/config.xml";
generator = apps.bazarr.settingsFormat.generate;
};
# This is actually not working. Bazarr uses a config file in dataDir/config/config.yaml
# which includes all configuration so we must somehow merge our declarative config with it.
# It's doable but will take some time. Help is welcomed.
#
# systemd.services.bazarr.preStart = shb.replaceSecrets {
# userConfig =
# cfg'.settings
# // (lib.optionalAttrs isSSOEnabled {
# AuthenticationRequired = "DisabledForLocalAddresses";
# AuthenticationMethod = "External";
# });
# resultPath = "${cfg'.dataDir}/config.xml";
# generator = apps.bazarr.settingsFormat.generate;
# };
shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
(lib.mkIf cfg.readarr.enable (
let
cfg' = cfg.readarr;
isSSOEnabled = !(isNull cfg'.authEndpoint);
in
{
services.readarr = {
enable = true;
dataDir = "/var/lib/readarr";
dataDir = cfg'.dataDir;
};
users.users.readarr = {
extraGroups = [ "media" ];
};
systemd.services.readarr.preStart = lib.shb.replaceSecrets {
userConfig = cfg'.settings;
resultPath = "${config.services.readarr.dataDir}/config.xml";
systemd.services.readarr.preStart = shb.replaceSecrets {
userConfig =
cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.readarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
@ -502,23 +570,27 @@ in
{
services.lidarr = {
enable = true;
dataDir = "/var/lib/lidarr";
dataDir = cfg'.dataDir;
};
users.users.lidarr = {
extraGroups = [ "media" ];
};
systemd.services.lidarr.preStart = lib.shb.replaceSecrets {
systemd.services.lidarr.preStart = shb.replaceSecrets {
userConfig =
cfg'.settings
// (lib.optionalAttrs isSSOEnabled {
AuthenticationRequired = "DisabledForLocalAddresses";
AuthenticationMethod = "External";
});
resultPath = "${config.services.lidarr.dataDir}/config.xml";
resultPath = "${cfg'.dataDir}/config.xml";
generator = apps.lidarr.settingsFormat.generate;
};
shb.nginx.vhosts = [ (vhosts { } cfg') ];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
@ -529,15 +601,15 @@ in
{
services.jackett = {
enable = true;
dataDir = "/var/lib/jackett";
dataDir = cfg'.dataDir;
};
# TODO: avoid implicitly relying on the media group
users.users.jackett = {
extraGroups = [ "media" ];
};
systemd.services.jackett.preStart = lib.shb.replaceSecrets {
userConfig = lib.shb.renameAttrName cfg'.settings "ApiKey" "APIKey";
resultPath = "${config.services.jackett.dataDir}/ServerConfig.json";
systemd.services.jackett.preStart = shb.replaceSecrets {
userConfig = shb.renameAttrName cfg'.settings "ApiKey" "APIKey";
resultPath = "${cfg'.dataDir}/ServerConfig.json";
generator = apps.jackett.settingsFormat.generate;
};
@ -546,6 +618,10 @@ in
extraBypassResources = [ "^/dl.*" ];
} cfg')
];
shb.lldap.ensureGroups = {
${cfg'.ldapUserGroup} = { };
};
}
))
];

View file

@ -3,12 +3,242 @@
Defined in [`/modules/services/arr.nix`](@REPO@/modules/services/arr.nix).
This NixOS module sets up multiple [Servarr](https://wiki.servarr.com/) services.
## Features {#services-arr-features}
Compared to the stock module from nixpkgs,
this one sets up, in a fully declarative manner
LDAP and SSO integration as well as the API key.
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}

View file

@ -1,15 +1,13 @@
{
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.audiobookshelf;
contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}";
roleClaim = "audiobookshelf_groups";
@ -38,7 +36,7 @@ in
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
@ -103,7 +101,7 @@ in
sharedSecret = lib.mkOption {
description = "OIDC shared secret for Audiobookshelf.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = "audiobookshelf";
group = "audiobookshelf";
@ -115,7 +113,7 @@ in
sharedSecretForAuthelia = lib.mkOption {
description = "OIDC shared secret for Authelia.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
mode = "0400";
ownerText = "config.shb.authelia.autheliaUser";
owner = config.shb.authelia.autheliaUser;
@ -131,7 +129,7 @@ in
Backup configuration.
'';
type = lib.types.submodule {
options = contracts.backup.mkRequester {
options = shb.contracts.backup.mkRequester {
user = "audiobookshelf";
sourceDirectories = [
"/var/lib/audiobookshelf"
@ -154,6 +152,20 @@ in
default = false;
example = true;
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.audiobookshelf.subdomain}.\${config.shb.audiobookshelf.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.webPort}";
};
};
};
};
config = lib.mkIf cfg.enable (

View file

@ -2,14 +2,13 @@
config,
pkgs,
lib,
shb,
...
}:
let
cfg = config.shb.deluge;
contracts = pkgs.callPackage ../contracts { };
fqdn = "${cfg.subdomain}.${cfg.domain}";
authGenerator =
@ -29,11 +28,17 @@ let
in
{
imports = [
../../lib/module.nix
../blocks/nginx.nix
../blocks/monitoring.nix
];
options.shb.deluge = {
enable = lib.mkEnableOption "selfhostblocks.deluge";
enable = lib.mkEnableOption "the SHB Deluge service";
enableDashboard = lib.mkEnableOption "the Torrents SHB monitoring dashboard" // {
default = true;
};
subdomain = lib.mkOption {
type = lib.types.str;
@ -49,7 +54,7 @@ in
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr contracts.ssl.certs;
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
@ -194,7 +199,7 @@ in
lib.types.submodule {
options = {
password = lib.mkOption {
type = lib.shb.secretFileType;
type = shb.secretFileType;
description = "File containing the user password.";
};
};
@ -205,7 +210,7 @@ in
localclientPassword = lib.mkOption {
description = "Password for mandatory localclient user.";
type = lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
owner = "deluge";
restartUnits = [ "deluged.service" ];
};
@ -216,7 +221,7 @@ in
description = "Password for prometheus scraper. Setting this option will activate the prometheus deluge exporter.";
type = lib.types.nullOr (
lib.types.submodule {
options = contracts.secret.mkRequester {
options = shb.contracts.secret.mkRequester {
owner = "deluge";
restartUnits = [
"deluged.service"
@ -273,7 +278,7 @@ in
'';
default = { };
type = lib.types.submodule {
options = contracts.backup.mkRequester {
options = shb.contracts.backup.mkRequester {
user = "deluge";
sourceDirectories = [
cfg.dataDir
@ -296,6 +301,20 @@ in
default = null;
example = "info";
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${fqdn}";
externalUrlText = "https://\${config.shb.deluge.subdomain}.\${config.shb.deluge.domain}";
internalUrl = "http://127.0.0.1:${toString cfg.webPort}";
};
};
};
};
config = lib.mkIf cfg.enable (
@ -361,7 +380,7 @@ in
};
systemd.services.deluged.preStart = lib.mkBefore (
lib.shb.replaceSecrets {
shb.replaceSecrets {
userConfig =
cfg.extraUsers
// {
@ -452,6 +471,12 @@ in
}
];
})
(lib.mkIf (cfg.enable && cfg.enableDashboard) {
shb.monitoring.dashboards = [
./deluge/dashboard/Torrents.json
];
})
]
);
}

View file

@ -0,0 +1,457 @@
{
config,
lib,
shb,
...
}:
let
cfg = config.shb.firefly-iii;
in
{
imports = [
../blocks/nginx.nix
../blocks/lldap.nix
../../lib/module.nix
];
options.shb.firefly-iii = {
enable = lib.mkEnableOption "SHB's firefly-iii module";
subdomain = lib.mkOption {
type = lib.types.str;
description = ''
Subdomain under which firefly-iii will be served.
```
<subdomain>.<domain>
```
'';
example = "firefly-iii";
};
domain = lib.mkOption {
description = ''
Domain under which firefly-iii is served.
```
<subdomain>.<domain>[:<port>]
```
'';
type = lib.types.str;
example = "domain.com";
};
ssl = lib.mkOption {
description = "Path to SSL files";
type = lib.types.nullOr shb.contracts.ssl.certs;
default = null;
};
siteOwnerEmail = lib.mkOption {
description = "Email of the site owner.";
type = lib.types.str;
example = "mail@example.com";
};
impermanence = lib.mkOption {
description = ''
Path to save when using impermanence setup.
'';
type = lib.types.str;
default = config.services.firefly-iii.dataDir;
defaultText = "services.firefly-iii.dataDir";
};
backup = lib.mkOption {
description = ''
Backup configuration.
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.backup.mkRequester {
user = config.services.firefly-iii.user;
userText = "services.firefly-iii.user";
sourceDirectories = [
config.services.firefly-iii.dataDir
];
sourceDirectoriesText = ''
[
config.services.firefly-iii.dataDir
]
'';
};
};
};
appKey = lib.mkOption {
description = "Encryption key used for sessions. Must be 32 characters long exactly.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.services.firefly-iii.user;
ownerText = "services.firefly-iii.user";
restartUnits = [ "firefly-iii-setup.service" ];
};
};
};
dbPassword = lib.mkOption {
description = "DB password.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0440";
owner = config.services.firefly-iii.user;
ownerText = "services.firefly-iii.user";
group = "postgres";
restartUnits = [
"postgresql.service"
"firefly-iii-setup.service"
];
};
};
};
ldap = lib.mkOption {
description = ''
LDAP Integration
'';
default = { };
type = lib.types.submodule {
options = {
userGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to login to Firefly-iii.";
default = "firefly-iii_user";
};
adminGroup = lib.mkOption {
type = lib.types.str;
description = "Group users must belong to to be able to import data user the Firefly-iii data importer.";
default = "firefly-iii_admin";
};
};
};
};
sso = lib.mkOption {
description = ''
SSO Integration
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "SSO integration.";
authEndpoint = lib.mkOption {
type = lib.types.str;
description = "OIDC endpoint for SSO.";
example = "https://authelia.example.com";
};
port = lib.mkOption {
description = "If given, adds a port to the endpoint.";
type = lib.types.nullOr lib.types.port;
default = null;
};
provider = lib.mkOption {
type = lib.types.enum [ "Authelia" ];
description = "OIDC provider name, used for display.";
default = "Authelia";
};
clientID = lib.mkOption {
type = lib.types.str;
description = "Client ID for the OIDC endpoint.";
default = "firefly-iii";
};
authorization_policy = lib.mkOption {
type = lib.types.enum [
"one_factor"
"two_factor"
];
description = "Require one factor (password) or two factor (device) authentication.";
default = "one_factor";
};
adminGroup = lib.mkOption {
type = lib.types.str;
description = "Group admins must belong to to be able to login to Firefly-iii.";
default = "firefly-iii_admin";
};
secret = lib.mkOption {
description = "OIDC shared secret.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "firefly-iii";
restartUnits = [ "firefly-iii-setup.service" ];
};
};
};
secretForAuthelia = lib.mkOption {
description = "OIDC shared secret. Content must be the same as `secretFile` option.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = "authelia";
};
};
};
};
};
};
smtp = lib.mkOption {
description = ''
If set, send notifications through smtp.
https://docs.firefly-iii.org/how-to/firefly-iii/advanced/notifications/
'';
default = null;
type = lib.types.nullOr (
lib.types.submodule {
options = {
from_address = lib.mkOption {
type = lib.types.str;
description = "SMTP address from which the emails originate.";
example = "authelia@mydomain.com";
};
host = lib.mkOption {
type = lib.types.str;
description = "SMTP host to send the emails to.";
};
port = lib.mkOption {
type = lib.types.port;
description = "SMTP port to send the emails to.";
default = 25;
};
username = lib.mkOption {
type = lib.types.str;
description = "Username to connect to the SMTP host.";
};
password = lib.mkOption {
description = "File containing the password to connect to the SMTP host.";
type = lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.services.firefly-iii.user;
ownerText = "services.firefly-iii.user";
restartUnits = [ "firefly-iii-setup.service" ];
};
};
};
};
}
);
};
debug = lib.mkOption {
type = lib.types.bool;
description = "Enable more verbose logging.";
default = false;
example = true;
};
importer = lib.mkOption {
description = ''
Configuration for Firefly-iii data importer.
'';
default = { };
type = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Firefly-iii Data Importer." // {
default = true;
};
subdomain = lib.mkOption {
type = lib.types.str;
description = ''
Subdomain under which the firefly-iii data importer will be served.
'';
default = "${cfg.subdomain}-importer";
defaultText = lib.literalExpression "\${shb.firefly-iii.subdomain}-importer";
};
firefly-iii-accessToken = lib.mkOption {
type = lib.types.nullOr (
lib.types.submodule {
options = shb.contracts.secret.mkRequester {
mode = "0400";
owner = config.services.firefly-iii-data-importer.user;
ownerText = "services.firefly-iii-data-importer.user";
restartUnits = [ "firefly-iii-data-importer-setup.service" ];
};
}
);
description = ''
Create a Personal Access Token then set then token in this option.
'';
default = null;
};
};
};
};
dashboard = lib.mkOption {
description = ''
Dashboard contract consumer
'';
default = { };
type = lib.types.submodule {
options = shb.contracts.dashboard.mkRequester {
externalUrl = "https://${cfg.subdomain}.${cfg.domain}";
externalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}";
# This works thanks to the Personal Access Token.
internalUrl = "https://${cfg.subdomain}.${cfg.domain}";
internalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}";
};
};
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.firefly-iii = {
enable = true;
group = "nginx";
virtualHost = "${cfg.subdomain}.${cfg.domain}";
# https://github.com/firefly-iii/firefly-iii/blob/main/.env.example
settings = {
APP_ENV = "production";
APP_URL = "https://${cfg.subdomain}.${cfg.domain}";
APP_DEBUG = cfg.debug;
APP_LOG_LEVEL = if cfg.debug then "debug" else "notice";
LOG_CHANNEL = "stdout";
APP_KEY_FILE = cfg.appKey.result.path;
SITE_OWNER = cfg.siteOwnerEmail;
DB_CONNECTION = "pgsql";
DB_HOST = "localhost";
DB_PORT = config.services.postgresql.settings.port;
DB_DATABASE = "firefly-iii";
DB_USERNAME = "firefly-iii";
DB_PASSWORD_FILE = cfg.dbPassword.result.path;
# MAP_DEFAULT_LAT = "51.983333";
# MAP_DEFAULT_LONG = "5.916667";
# MAP_DEFAULT_ZOOM = "6";
};
};
shb.postgresql.enableTCPIP = true;
shb.postgresql.ensures = [
{
username = "firefly-iii";
database = "firefly-iii";
passwordFile = cfg.dbPassword.result.path;
}
];
# This should be using a contract instead of setting the option directly.
shb.lldap = lib.mkIf config.shb.lldap.enable {
ensureGroups = {
${cfg.ldap.userGroup} = { };
${cfg.ldap.adminGroup} = { };
};
};
# We enable the firefly-iii nginx integration and merge it with SHB's nginx configuration.
services.firefly-iii.enableNginx = true;
shb.nginx.vhosts = [
{
inherit (cfg) subdomain domain ssl;
}
];
}
(lib.mkIf cfg.importer.enable {
services.firefly-iii-data-importer = {
enable = true;
virtualHost = "${cfg.importer.subdomain}.${cfg.domain}";
settings = {
FIREFLY_III_URL = "https://${config.services.firefly-iii.virtualHost}";
}
// lib.optionalAttrs (cfg.importer.firefly-iii-accessToken != null) {
FIREFLY_III_ACCESS_TOKEN_FILE = cfg.importer.firefly-iii-accessToken.result.path;
};
};
# We enable the firefly-iii-data-importer nginx integration and merge it with SHB's nginx configuration.
services.firefly-iii-data-importer.enableNginx = true;
shb.nginx.vhosts = [
{
inherit (cfg) domain ssl;
subdomain = cfg.importer.subdomain;
}
];
})
(lib.mkIf (cfg.smtp != null) {
services.firefly-iii.settings = {
MAIL_MAILER = "smtp";
MAIL_HOST = cfg.smtp.host;
MAIL_PORT = cfg.smtp.port;
MAIL_FROM = cfg.smtp.from_address;
MAIL_USERNAME = cfg.smtp.username;
MAIL_PASSWORD_FILE = cfg.smtp.password.result.path;
MAIL_ENCRYPTION = "tls";
};
})
(lib.mkIf cfg.sso.enable {
services.firefly-iii.settings = {
AUTHENTICATION_GUARD = "remote_user_guard";
AUTHENTICATION_GUARD_HEADER = "HTTP_X_FORWARDED_USER";
};
shb.nginx.vhosts = [
{
inherit (cfg) subdomain domain ssl;
inherit (cfg.sso) authEndpoint;
phpForwardAuth = true;
autheliaRules = [
{
domain = "${cfg.subdomain}.${cfg.domain}";
policy = "bypass";
resources = [ "^/api" ];
}
{
domain = "${cfg.subdomain}.${cfg.domain}";
policy = cfg.sso.authorization_policy;
subject = [
"group:${cfg.ldap.userGroup}"
"group:${cfg.ldap.adminGroup}"
];
}
];
}
];
})
(lib.mkIf (cfg.sso.enable && cfg.importer.enable) {
shb.nginx.vhosts = [
{
inherit (cfg.importer) subdomain;
inherit (cfg) domain ssl;
inherit (cfg.sso) authEndpoint;
autheliaRules = [
{
domain = "${cfg.importer.subdomain}.${cfg.domain}";
policy = cfg.sso.authorization_policy;
subject = [ "group:${cfg.ldap.adminGroup}" ];
}
];
}
];
})
]
);
}

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