docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit lineage, debugging stories, restated code) in several hot spots (scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix). Trim every comment to its load-bearing "why" — gotchas, safety warnings, and non-obvious rationale survive verbatim in substance, just tightened to 1-2 sentences; historical narrative and anything already covered in CLAUDE.md is cut. No code/logic changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
This commit is contained in:
@@ -9,11 +9,10 @@
|
||||
enable = true;
|
||||
enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb
|
||||
|
||||
# LibreChat's isEnabled() treats an UNSET var as false, not true — so
|
||||
# registration is closed unless this is explicit, despite .env.example
|
||||
# suggesting true is the default. Only reachable over the tailnet
|
||||
# (trusted interface, see module comment below), so leaving it open is
|
||||
# fine; flip to false once your account exists if you want it locked down.
|
||||
# LibreChat's isEnabled() treats an unset var as false, not true (despite
|
||||
# .env.example suggesting true is the default), so this must be explicit.
|
||||
# Fine to leave open since it's tailnet-only; flip to false once your
|
||||
# account exists to lock it down.
|
||||
env.ALLOW_REGISTRATION = true;
|
||||
|
||||
credentials = {
|
||||
@@ -32,10 +31,9 @@
|
||||
apiKey = "ollama";
|
||||
baseURL = "http://127.0.0.1:11434/v1";
|
||||
models = {
|
||||
# schema requires >=1 entry even though fetch=true overwrites it
|
||||
# at runtime with whatever's pulled (see loadModels in
|
||||
# hosts/terra/configuration.nix) — kept roughly in sync anyway
|
||||
# so the UI has sane names before the first fetch completes.
|
||||
# Schema requires >=1 entry even though fetch=true overwrites this at
|
||||
# runtime with whatever's pulled (hosts/terra/configuration.nix) —
|
||||
# kept roughly in sync so the UI has sane names before the first fetch.
|
||||
default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ];
|
||||
fetch = true; # pull the model list from ollama at startup
|
||||
};
|
||||
@@ -43,22 +41,15 @@
|
||||
}
|
||||
];
|
||||
|
||||
# Persistent memory is opt-in at the CONFIG level — omitting this block
|
||||
# (as before) leaves the feature entirely off, no matter what a user
|
||||
# toggles in Settings > Personalization. `agent.provider` must match
|
||||
# endpoints.custom[].name above exactly ("Ollama"), which is how the
|
||||
# memory-extraction agent picks a backend/model.
|
||||
# Persistent memory is opt-in at the config level — omitting this block
|
||||
# leaves it off regardless of the user's Settings > Personalization toggle.
|
||||
# `agent.provider` must match endpoints.custom[].name above exactly.
|
||||
memory = {
|
||||
personalize = true; # still needs a per-user opt-in toggle in the UI
|
||||
# instructions REPLACES the default extraction prompt entirely (not
|
||||
# appended to it) — the 3b model (llama3.2:3b, dropped) was
|
||||
# defaulting to saving things like its own "I am a helpful
|
||||
# assistant..." boilerplate under an invented "user_conversation"
|
||||
# key, and even after adding this prompt, still saved "I am an AI
|
||||
# assistant with tool calling capabilities" as personal_info after
|
||||
# the user introduced THEMSELVES — a capability ceiling, not a
|
||||
# prompting problem. validKeys constrains it to a fixed whitelist
|
||||
# and instructions spells out the bar for each one.
|
||||
# instructions REPLACES the default extraction prompt, not appends to it —
|
||||
# needed because the smaller llama3.2:3b (since dropped) kept saving its
|
||||
# own assistant boilerplate as memories, a capability ceiling rather than
|
||||
# a prompting gap. validKeys whitelists what can be stored.
|
||||
validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ];
|
||||
agent = {
|
||||
enabled = true;
|
||||
|
||||
+72
-153
@@ -1,14 +1,12 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# Gitea — self-hosted git. stateDir/repositories were migrated from the old
|
||||
# ZimaOS docker instance straight into stateDir's default layout, so no
|
||||
# import step is needed — just chown it to the gitea user after first deploy
|
||||
# (currently darman:users from the CIFS copy):
|
||||
# chown -R gitea:gitea /mnt/data/AppData/gitea
|
||||
# Gitea — self-hosted git. Repos were migrated from the old ZimaOS docker
|
||||
# instance straight into stateDir's default layout, so after first deploy
|
||||
# just: chown -R gitea:gitea /mnt/data/AppData/gitea
|
||||
#
|
||||
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
|
||||
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
|
||||
# :222 — the unpriv gitea user can't bind <1024).
|
||||
# SSH uses gitea's own server on :2222, since the unprivileged gitea user
|
||||
# can't bind :22 or :222 (<1024).
|
||||
let
|
||||
# Repos where the ci-bot account (see below) should be a Write collaborator
|
||||
# and whitelisted to push past branch protection. Add a repo here and
|
||||
@@ -21,40 +19,15 @@ let
|
||||
# nothing she does lands without darman clicking merge.
|
||||
lunaRepos = [ "darman/homelab" ];
|
||||
|
||||
# One gitea webhook per Hermes route. `route` is the path segment Hermes
|
||||
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must
|
||||
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
|
||||
# One gitea webhook per Hermes route; `route` must match a key in the route
|
||||
# config hosts/mars/hermes-agent.nix writes.
|
||||
#
|
||||
# `events` are the strings gitea's HOOK API accepts. That set is coarser
|
||||
# than gitea's internal HookEventType set, and both collide on spelling with
|
||||
# the wire names Hermes matches on — three namespaces, one of which is a
|
||||
# trap. From routers/api/v1/utils/hook.go (updateHookEvents),
|
||||
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()):
|
||||
#
|
||||
# api event (here) delivers wire name (mars route)
|
||||
# -------------------- ------------------- ----------------------
|
||||
# pull_request_comment comment on a PR issue_comment
|
||||
# pull_request_review review with a body pull_request_comment
|
||||
# changes requested pull_request_rejected
|
||||
# approval pull_request_approved
|
||||
#
|
||||
# So this file and hosts/mars/hermes-agent.nix name the same event
|
||||
# differently on purpose, and neither is a typo.
|
||||
#
|
||||
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
|
||||
# so a plausible-looking but non-API name leaves the hook registered with no
|
||||
# events at all, delivering nothing and reporting no error. That is exactly
|
||||
# what "pull_request_review_comment" did here — a real HookEventType, and a
|
||||
# real value of X-GitHub-Event-Type, but not an API event name.
|
||||
#
|
||||
# There is no narrower name for reviews: HasEvent collapses approved,
|
||||
# rejected and review-comment onto HookEventPullRequestReview, so
|
||||
# `pull_request_review` is a single switch for all three. Approvals
|
||||
# therefore cannot be excluded here. They are dropped on the mars side
|
||||
# instead — the route's event list has no "pull_request_approved", so Hermes
|
||||
# answers {"status": "ignored"} without running the filter or spending a
|
||||
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
|
||||
# that is the design, not a failure.
|
||||
# `events` must be gitea's HOOK API event names, which gitea silently drops
|
||||
# if unrecognized — registering with no events and no error ("pull_request_
|
||||
# review_comment" did this: a real HookEventType, but not an API name).
|
||||
# `pull_request_review` also covers approvals with no narrower option, so
|
||||
# those are filtered on the mars side instead (answered 200 and ignored —
|
||||
# expected, not a failure).
|
||||
giteaHermesHooks = [
|
||||
{
|
||||
name = "PR comments Hermes";
|
||||
@@ -81,9 +54,8 @@ in
|
||||
server = {
|
||||
DOMAIN = "git.mgaction.town";
|
||||
SSH_DOMAIN = "git.mgaction.town";
|
||||
# https, not http: neptun's Caddy terminates TLS for this name. Gitea
|
||||
# builds its absolute URLs (clone buttons, redirects, webhooks) from
|
||||
# ROOT_URL, so an http:// value hands out downgraded links.
|
||||
# https, not http: neptun's Caddy terminates TLS here, and gitea builds
|
||||
# its absolute URLs (clone buttons, webhooks) from ROOT_URL.
|
||||
ROOT_URL = "https://git.mgaction.town/";
|
||||
HTTP_PORT = 3000;
|
||||
START_SSH_SERVER = true;
|
||||
@@ -94,20 +66,11 @@ in
|
||||
DISABLE_REGISTRATION = true;
|
||||
};
|
||||
security = {
|
||||
# Gitea refuses to deliver a webhook to any host outside this list,
|
||||
# which defaults to `external` — "a valid non-private unicast IP".
|
||||
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT),
|
||||
# which is neither RFC1918 private nor, as far as gitea's matcher is
|
||||
# concerned, external — so the hermes relay on mars was refused with
|
||||
# deny 'mars.orbit.sol(100.64.0.6:8644)'
|
||||
# even though nothing here is private in the RFC1918 sense. Adding
|
||||
# the tailnet CIDR is what makes tailnet-internal webhook targets
|
||||
# deliverable at all; `external` is kept so a future webhook to a
|
||||
# public service (discord, slack) still works without another edit.
|
||||
#
|
||||
# This lives in [security], not [webhook]: the webhook-section key is
|
||||
# deprecated and now just falls back to this one, which is the name
|
||||
# the delivery error itself reports.
|
||||
# Gitea's default `external` webhook target filter treats tailnet
|
||||
# addresses (100.64.0.0/10, CGNAT) as neither private nor external, so
|
||||
# the mars hermes relay was refused until the CIDR was added here.
|
||||
# Lives under [security], not the deprecated [webhook] key it falls
|
||||
# back to.
|
||||
ALLOWED_HOST_LIST = "external,100.64.0.0/10";
|
||||
};
|
||||
actions = {
|
||||
@@ -118,14 +81,10 @@ in
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 2222 ];
|
||||
|
||||
# `gitea <args>` == the admin CLI, as the gitea user, against the real
|
||||
# state dir — mirrors the `hermes` alias on mars. Worth having because none
|
||||
# of that is discoverable: the package is not in systemPackages (so `gitea`
|
||||
# is not otherwise on PATH at all), every admin subcommand needs
|
||||
# GITEA_WORK_DIR pointed at a stateDir that is not the module default, and
|
||||
# it has to run as the gitea user or it writes root-owned files into that
|
||||
# directory. Both paths come from the config rather than being spelled out,
|
||||
# so a package bump or a stateDir move cannot leave this stale.
|
||||
# `gitea <args>` == the admin CLI as the gitea user against the real state
|
||||
# dir. Not otherwise usable: the package isn't on PATH, and admin
|
||||
# subcommands need GITEA_WORK_DIR set and root-owned files avoided by
|
||||
# running as gitea.
|
||||
#
|
||||
# Handy ones:
|
||||
# gitea admin user generate-access-token --username luna \
|
||||
@@ -138,15 +97,13 @@ in
|
||||
|
||||
users.users.gitea.extraGroups = [ "users" ];
|
||||
|
||||
# Runner instance registered against this same gitea. Jobs run in containers
|
||||
# (podman, via services/containers.nix — already enabled on jupiter), one
|
||||
# image per requested `runs-on` label using the catthehacker act-compatible
|
||||
# images (same ones upstream `act`/Forgejo docs recommend).
|
||||
# Runner instance registered against this same gitea. Jobs run in podman
|
||||
# containers (services/containers.nix), one image per `runs-on` label, using
|
||||
# the catthehacker act-compatible images.
|
||||
#
|
||||
# tokenFile points at an env file rendered by sops (TOKEN=<registration
|
||||
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the
|
||||
# secret never lands in the Nix store. The registration token itself is NOT
|
||||
# generated by this module — it comes from gitea once Actions is enabled:
|
||||
# tokenFile (not `token`) keeps the sops-rendered secret out of the Nix
|
||||
# store. The registration token isn't generated by this module — get it
|
||||
# from gitea once Actions is enabled:
|
||||
# su gitea -s /bin/sh -c \
|
||||
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
|
||||
# then written into secrets/jupiter.yaml as gitea_runner_token.
|
||||
@@ -161,23 +118,18 @@ in
|
||||
];
|
||||
};
|
||||
|
||||
# ci-bot: dedicated account CI workflows push as (kept separate from any
|
||||
# human account so its own PAT can be scoped/rotated/revoked independently).
|
||||
# Collaborator access + branch-protection push-whitelisting have no CLI or
|
||||
# config-file surface in gitea — only the HTTP API — so this is the one
|
||||
# part of the setup that stays imperative even though it's nix-triggered:
|
||||
# a oneshot that PUTs/PATCHes the API into the desired state on every
|
||||
# deploy where its script changed (adding a repo to `ciBotRepos` and
|
||||
# redeploying is enough to pick it up; it won't self-heal a manual revert
|
||||
# done via the web UI unless the unit is also restarted).
|
||||
# ci-bot: dedicated account CI workflows push as, so its PAT can be scoped
|
||||
# and rotated independently of any human account. Collaborator access and
|
||||
# branch-protection whitelisting have no CLI/config-file surface in gitea —
|
||||
# only the HTTP API — so this oneshot re-applies the desired state via
|
||||
# PUT/PATCH on every deploy (won't self-heal a manual UI revert unless
|
||||
# restarted).
|
||||
#
|
||||
# Auth for those API calls is darman's OWN token (named
|
||||
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
|
||||
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
|
||||
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
|
||||
# the collaborator/branch-protection endpoints; write:user is additionally
|
||||
# needed to push ci-bot's token below as a secret on darman's own account.
|
||||
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
|
||||
# Auth is darman's own token (write:repository + write:user, see
|
||||
# hosts/jupiter/secrets.nix): an owner-scoped token is required by the
|
||||
# collaborator/branch-protection endpoints, and write:user is needed to
|
||||
# push ci-bot's token as a secret on darman's account — ci-bot can't grant
|
||||
# itself access.
|
||||
#
|
||||
# ci-bot's own push token (separate secret, ci_bot_token) is generated
|
||||
# once via:
|
||||
@@ -255,47 +207,26 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A.,
|
||||
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot:
|
||||
# Hermes runs on mars, takes instructions over Telegram, and can be
|
||||
# prompt-injected via tool output — a dedicated account with its own
|
||||
# scoped, revocable token keeps that blast radius off darman's own
|
||||
# credentials, and the branch-protection whitelists below keep it off
|
||||
# `master` entirely regardless of what the token can technically do.
|
||||
# She gets Write collaborator access (needed to push a branch and open a
|
||||
# PR against the same repo — this instance has no fork workflow), but:
|
||||
# - enable_push + enable_push_whitelist(darman only): nobody but darman
|
||||
# can push straight to master; luna can only land on a side branch.
|
||||
# - enable_merge_whitelist(darman only): opening a PR is not the same
|
||||
# as merging one — only darman can click merge.
|
||||
# - required_approvals=1 + enable_approvals_whitelist(darman only):
|
||||
# an approval has to come from darman specifically, not luna
|
||||
# rubber-stamping her own PR from a second identity.
|
||||
# This covers the SERVER side only (account + collaborator + branch
|
||||
# protection). The client side — git/tea inside the hermes-agent container,
|
||||
# and the token below — lives in hosts/mars/hermes-agent.nix.
|
||||
# luna: Hermes Agent's gitea identity, deliberately PR-tier only (not
|
||||
# push-tier like ci-bot) — Hermes runs on mars, takes Telegram instructions,
|
||||
# and can be prompt-injected via tool output, so branch protection below
|
||||
# keeps her off `master` regardless of what her token can technically do:
|
||||
# - enable_push_whitelist(darman only): nobody but darman pushes to master.
|
||||
# - enable_merge_whitelist(darman only): opening a PR isn't merging one.
|
||||
# - required_approvals=1 + enable_approvals_whitelist(darman only): no
|
||||
# self-approval from a second identity.
|
||||
# This is the server side only; the client side (git/tea, token) is in
|
||||
# hosts/mars/hermes-agent.nix.
|
||||
#
|
||||
# luna's own push token is generated once, the same way ci-bot's was:
|
||||
# su gitea -s /bin/sh -c \
|
||||
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
|
||||
# --username luna --scopes write:repository,write:issue,read:user'
|
||||
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
|
||||
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
|
||||
# since luna isn't a CI workflow running inside gitea, she's an external
|
||||
# agent calling out to it.
|
||||
# luna's push token is generated once (same as ci-bot's, username luna,
|
||||
# scopes write:repository,write:issue,read:user) and stored as a secret —
|
||||
# NOT pushed into gitea as an Actions secret, since she's an external agent
|
||||
# calling in, not a CI workflow.
|
||||
#
|
||||
# **write:issue is NOT optional and is easy to miss**: this token started
|
||||
# life as `write:repository` alone, which clones, fetches and pushes
|
||||
# branches perfectly well — so everything looks fine right up until the
|
||||
# first `tea pr create`, which gitea rejects with
|
||||
# token scope=write:repository,read:user required=read:issue
|
||||
# A pull request IS an issue in gitea's data model, so every /pulls
|
||||
# endpoint is gated on the *issue* scope category, not the repository one.
|
||||
# write:issue covers it (in gitea's scope model write:X implies read:X);
|
||||
# read:issue alone would satisfy the GET half and then fail the POST that
|
||||
# actually opens the PR. The error names read:issue only because that's
|
||||
# the first check tea trips on. Rotating the token is free — the prepare
|
||||
# oneshot on mars does delete-then-add for the tea login on every start.
|
||||
# write:issue is required, not optional: a PR is an issue in gitea's data
|
||||
# model, so `tea pr create` needs it even though push/fetch work fine on
|
||||
# write:repository alone. The resulting error misleadingly names read:issue
|
||||
# (the first check tea trips), not write:issue.
|
||||
systemd.services.gitea-luna-provision = {
|
||||
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
|
||||
after = [ "gitea.service" ];
|
||||
@@ -359,14 +290,10 @@ in
|
||||
'';
|
||||
};
|
||||
|
||||
# Register one Gitea webhook per Hermes route (giteaHermesHooks above).
|
||||
# Idempotent: each target URL is updated if a hook for it already exists and
|
||||
# created otherwise.
|
||||
#
|
||||
# It deliberately does NOT delete anything, including hooks for routes that
|
||||
# were removed from the list above. Retiring one is a one-off, done by hand
|
||||
# in the repo's Settings -> Webhooks, so that a redeploy can never silently
|
||||
# unregister a hook someone added on purpose.
|
||||
# Register one Gitea webhook per Hermes route (giteaHermesHooks above),
|
||||
# idempotently (update if the target URL exists, else create). Deliberately
|
||||
# never deletes — a hook for a route removed from the list is retired by
|
||||
# hand in Settings -> Webhooks, not silently by a redeploy.
|
||||
systemd.services.gitea-hermes-webhook-provision = {
|
||||
description = "Provision Gitea webhooks for Hermes routes";
|
||||
after = [ "gitea.service" ];
|
||||
@@ -386,24 +313,17 @@ in
|
||||
set -euo pipefail
|
||||
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
|
||||
|
||||
# Neither secret is ever passed as an argument. This unit runs as the
|
||||
# gitea user on a multi-user box, where /proc/<pid>/cmdline is
|
||||
# world-readable for the lifetime of the process — so `-H "Authorization:
|
||||
# token $t"` would publish the admin token, and `jq --arg secret "$s"`
|
||||
# the webhook secret. The token goes into a 0600 curl config file
|
||||
# instead (printf is a shell builtin, so the substitution below never
|
||||
# reaches an argv), the webhook secret into jq via --rawfile, and the
|
||||
# request body into curl on stdin with --data @-.
|
||||
# Secrets never go on argv, since /proc/<pid>/cmdline is world-readable
|
||||
# on this multi-user box: the token goes into a 0600 curl config file
|
||||
# (printf avoids argv entirely), the webhook secret into jq via
|
||||
# --rawfile, and the body into curl via stdin.
|
||||
authcfg="$(mktemp)"
|
||||
trap 'rm -f "$authcfg"' EXIT
|
||||
chmod 0600 "$authcfg"
|
||||
printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg"
|
||||
|
||||
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
|
||||
# above: After=gitea.service only means the process started, not that it
|
||||
# is serving HTTP yet. Without this the first curl below fails under
|
||||
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
|
||||
# the webhooks silently unregistered until someone restarts the unit.
|
||||
# Same readiness gate as the other provisioning units: After=gitea.service
|
||||
# only means the process started, not that it's serving HTTP yet.
|
||||
for _ in $(seq 1 30); do
|
||||
curl -fs "$api/version" >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
@@ -413,10 +333,9 @@ in
|
||||
local name="$1" route="$2" events="$3" url body hook_id
|
||||
url="http://mars.orbit.sol:8644/webhooks/$route"
|
||||
|
||||
# rtrimstr: sops stores this without a trailing newline, but one
|
||||
# slipping in would change the key the HMAC is computed with and make
|
||||
# every delivery fail signature validation on the Hermes side. The
|
||||
# same trim happens there, so both ends agree either way.
|
||||
# rtrimstr: a stray trailing newline would change the HMAC key and
|
||||
# break signature validation on the Hermes side, which trims the same
|
||||
# way.
|
||||
body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
|
||||
--arg url "$url" --arg name "$name" --argjson events "$events" \
|
||||
'{type: "gitea", name: $name, active: true, events: $events,
|
||||
|
||||
@@ -1,66 +1,50 @@
|
||||
{ config, ... }:
|
||||
|
||||
# CouchDB, tuned as the backend for Obsidian Self-hosted LiveSync
|
||||
# (vrtmrz/obsidian-livesync). The plugin replicates the vault into CouchDB
|
||||
# chunk-by-chunk over PouchDB's replication protocol, so this is a plain
|
||||
# CouchDB 3 node — nothing Obsidian-specific runs here.
|
||||
# Plain CouchDB 3 node, tuned as the backend for Obsidian Self-hosted LiveSync
|
||||
# (vrtmrz/obsidian-livesync), which replicates the vault into it via PouchDB.
|
||||
#
|
||||
# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy (see
|
||||
# hosts/neptun/configuration.nix), because Obsidian's mobile apps refuse
|
||||
# cleartext HTTP and jupiter's *.jupiter.sol names cannot get a real cert.
|
||||
# That makes the settings below security-relevant, not cosmetic:
|
||||
# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy, since
|
||||
# Obsidian's mobile apps refuse cleartext HTTP and jupiter's *.jupiter.sol
|
||||
# names can't get a real cert — so the settings below are security-relevant:
|
||||
# - `require_valid_user` in both [chttpd] and [chttpd_auth], else CouchDB
|
||||
# answers unauthenticated GETs on the open internet.
|
||||
# - neptun's vhost allowlists only the plugin's endpoints; Fauxton and
|
||||
# cluster/config are reachable only over the tailnet.
|
||||
# - Turn on the plugin's end-to-end encryption (+ "Obfuscate Properties"),
|
||||
# so this server only ever holds ciphertext — what makes a
|
||||
# publicly-reachable credentialed database an acceptable trade.
|
||||
#
|
||||
# - `require_valid_user` in BOTH [chttpd] and [chttpd_auth]: without it
|
||||
# CouchDB answers unauthenticated GETs on the open internet.
|
||||
# - neptun's vhost allowlists only the endpoints the plugin uses, so Fauxton
|
||||
# (/_utils) and the cluster/config endpoints are not reachable from
|
||||
# outside at all — reach them over the tailnet instead.
|
||||
# - Turn ON end-to-end encryption in the plugin (Settings → Remote Database
|
||||
# → End-to-End Encryption, plus "Obfuscate Properties", which covers the
|
||||
# paths and timestamps that E2EE alone leaves readable). Then this server
|
||||
# only ever holds ciphertext, which is what makes a publicly-reachable
|
||||
# credentialed database an acceptable trade rather than a bad one.
|
||||
#
|
||||
# Its passphrase is a SEPARATE secret from couchdb_admin_password below —
|
||||
# deliberately, and it must stay that way. The couchdb password
|
||||
# authenticates to this server and is stored here (hashed) and in
|
||||
# secrets/jupiter.yaml; the E2EE passphrase never leaves the Obsidian
|
||||
# clients and CouchDB has no idea it exists. Reusing one string for both
|
||||
# hands whoever obtains that credential the decryption key as well, which
|
||||
# is precisely the failure E2EE is here to prevent. The passphrase is
|
||||
# therefore NOT in sops (nothing on this host consumes it) — it lives in
|
||||
# the HomeLab Proton Pass vault, with the deploy credentials.
|
||||
#
|
||||
# Losing it costs the remote database, not the notes: wipe it and
|
||||
# Its passphrase must stay a SEPARATE secret from couchdb_admin_password:
|
||||
# the CouchDB password is stored here and in secrets/jupiter.yaml, while
|
||||
# the E2EE passphrase never leaves the clients (kept in the HomeLab Proton
|
||||
# Pass vault, not sops) — reusing one string for both would hand the
|
||||
# decryption key to whoever gets the CouchDB credential. Losing the
|
||||
# passphrase costs the remote database, not the notes: wipe and
|
||||
# re-initialize from a device that still holds the plaintext vault.
|
||||
{
|
||||
services.couchdb = {
|
||||
enable = true;
|
||||
|
||||
# Listens on all interfaces, same reasoning as immich: :5984 is NOT opened
|
||||
# in the firewall, so it is reachable over tailscale0 (trusted in
|
||||
# common.nix) and localhost only. That is the path neptun's caddy takes.
|
||||
# Listens on all interfaces, but :5984 is not opened in the firewall, so
|
||||
# it's reachable only over tailscale0 (trusted) and localhost — the path
|
||||
# neptun's caddy takes.
|
||||
bindAddress = "0.0.0.0";
|
||||
port = 5984;
|
||||
|
||||
# The vault database is the ONLY copy of the notes once LiveSync is the
|
||||
# source of truth, so it belongs on the array, not the 29G eMMC. All three
|
||||
# of these default under /var/lib/couchdb and have to move together —
|
||||
# configFile especially, since CouchDB writes to it at runtime (below).
|
||||
# source of truth, so it belongs on the array, not the 29G eMMC — all
|
||||
# three default under /var/lib/couchdb and must move together.
|
||||
databaseDir = "/mnt/data/AppData/couchdb";
|
||||
viewIndexDir = "/mnt/data/AppData/couchdb";
|
||||
configFile = "/mnt/data/AppData/couchdb/local.ini";
|
||||
|
||||
# The admin password, as an [admins] ini fragment from sops.
|
||||
# services.couchdb.adminPass would render it into the world-readable
|
||||
# store; extraConfigFiles is the module's own documented hook for this
|
||||
# (hosts/jupiter/secrets.nix renders the template).
|
||||
# [admins] ini fragment from sops; services.couchdb.adminPass would render
|
||||
# into the world-readable store instead.
|
||||
#
|
||||
# ⚠️ CouchDB hashes a plaintext admin password at startup and persists the
|
||||
# hash to the LAST, writable file in its ini chain — local.ini above,
|
||||
# which then takes precedence over this fragment. So changing the sops
|
||||
# value alone does NOT rotate the password: delete the `[admins]` line
|
||||
# from /mnt/data/AppData/couchdb/local.ini and restart as well.
|
||||
# ⚠️ CouchDB hashes the password at startup and persists it to local.ini
|
||||
# (above), which then takes precedence — so changing the sops value alone
|
||||
# does NOT rotate it. Also delete the `[admins]` line from
|
||||
# /mnt/data/AppData/couchdb/local.ini and restart.
|
||||
extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ];
|
||||
|
||||
# Values taken from LiveSync's own CouchDB setup documentation; the plugin
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Cinephage — indexer search + streaming/library manager. Runs the official
|
||||
# container image, not upstream's nix flake module: its npmDepsHash is stale
|
||||
# against its own package-lock.json, and a transitive dep hard-enforces pnpm,
|
||||
# breaking the nix-sandboxed npm build regardless. Docker is the actually-
|
||||
# maintained path. BETTER_AUTH_SECRET (paired sops secret in
|
||||
# hosts/jupiter/secrets.nix) signs sessions/encrypts stored API keys — must
|
||||
# be static, not app-generated, or losing it invalidates everything.
|
||||
# Cinephage — indexer search + streaming/library manager, run as the official
|
||||
# container image rather than upstream's nix flake module (its npmDepsHash is
|
||||
# stale and a transitive dep hard-enforces pnpm, breaking the sandboxed npm
|
||||
# build). BETTER_AUTH_SECRET (paired sops secret, hosts/jupiter/secrets.nix)
|
||||
# signs sessions and encrypts stored API keys — keep it static, since losing
|
||||
# it invalidates everything.
|
||||
{
|
||||
virtualisation.oci-containers.containers.cinephage = {
|
||||
image = "ghcr.io/moldytaint/cinephage:latest";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{ config, ... }:
|
||||
|
||||
# MediaManager — media request/library manager. Module comes from the
|
||||
# community flake input `mediamanager-nix`, not nixpkgs. Paired sops secret
|
||||
# in hosts/jupiter/secrets.nix — without it the module mints+discards a
|
||||
# random auth token_secret on every restart, logging everyone out.
|
||||
# Port 8010: 8000 is taken by audiobookshelf on this host.
|
||||
# MediaManager — media request/library manager (module from the
|
||||
# `mediamanager-nix` flake input, not nixpkgs). The paired sops secret
|
||||
# (hosts/jupiter/secrets.nix) is required — without it the module mints a
|
||||
# random token_secret every restart, logging everyone out; port 8010 since
|
||||
# audiobookshelf already holds 8000.
|
||||
{
|
||||
services.media-manager = {
|
||||
enable = true;
|
||||
@@ -45,9 +45,9 @@
|
||||
MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key}
|
||||
'';
|
||||
|
||||
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 on disk —
|
||||
# group has no write bit. media-manager is in "users" (below); the dirs
|
||||
# themselves were chmod g+w by hand once (not declarative — see CLAUDE.md
|
||||
# gotchas), since this is pre-existing data, not something tmpfiles owns.
|
||||
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 (no group
|
||||
# write bit); media-manager is in "users" (below), and the dirs were
|
||||
# chmod g+w by hand once since this is pre-existing data, not something
|
||||
# tmpfiles owns.
|
||||
users.users.media-manager.extraGroups = [ "users" ];
|
||||
}
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
{ config, pkgs, inputs, ... }:
|
||||
|
||||
# Authentik — self-hosted identity/OIDC provider.
|
||||
# Authentik — self-hosted identity/OIDC provider. Replaced Zitadel because
|
||||
# nixpkgs is stuck on 2.71 (no login-v2 split) with a forward-only db
|
||||
# migration; authentik-nix tracks upstream closely instead.
|
||||
#
|
||||
# Replaced Zitadel: nixpkgs only carries Zitadel 2.71 (no login-v2 split, and
|
||||
# a v3/v4 database migrates forward only, so an existing instance can't be
|
||||
# moved onto it). authentik-nix tracks upstream closely instead.
|
||||
# The upstream module owns postgres and its unit ordering, and needs no redis
|
||||
# (channels/cache run on postgres). TLS terminates at Caddy; every listener
|
||||
# below is pinned to loopback since only tailscale0 is trusted.
|
||||
#
|
||||
# The upstream module owns postgres (createDatabase) AND orders the units
|
||||
# against postgresql.target, so no manual After= is needed here. No redis —
|
||||
# recent authentik runs channels/cache on postgres.
|
||||
#
|
||||
# TLS terminates at Caddy; every listener is pinned to loopback below so
|
||||
# nothing is reachable from the tailnet (hosts trust tailscale0).
|
||||
#
|
||||
# Needs, wired via sops in the host's secrets.nix: an environmentFile carrying
|
||||
# - AUTHENTIK_SECRET_KEY (`openssl rand -base64 60`) — signs sessions
|
||||
# - AUTHENTIK_BOOTSTRAP_PASSWORD first-run akadmin password
|
||||
# systemd reads EnvironmentFile as root before dropping to the service's
|
||||
# DynamicUser, so the sops default root:root 0400 is correct — do NOT set
|
||||
# `owner` on it the way the headplane secrets need.
|
||||
# Needs an environmentFile from sops (host's secrets.nix) carrying
|
||||
# AUTHENTIK_SECRET_KEY and AUTHENTIK_BOOTSTRAP_PASSWORD. Keep it root:root
|
||||
# 0400 (systemd reads it as root before dropping to DynamicUser) — don't set
|
||||
# `owner` the way headplane's secrets need.
|
||||
{
|
||||
imports = [ inputs.authentik-nix.nixosModules.default ];
|
||||
|
||||
# Pinned explicitly: the default tracks system.stateVersion, so editing that
|
||||
# line would silently demand a pg_upgrade of the identity store. Bump this
|
||||
# deliberately, with a dump in hand.
|
||||
# would silently demand a pg_upgrade of the identity store.
|
||||
services.postgresql.package = pkgs.postgresql_17;
|
||||
|
||||
services.authentik = {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{ ... }:
|
||||
|
||||
# Audiobookshelf audiobook/podcast server.
|
||||
# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall),
|
||||
# but reachable over the trusted tailscale0 interface and via localhost (caddy).
|
||||
# Library/media paths are set in the web UI — point them at /mnt/data/...
|
||||
# Runs as user `audiobookshelf`; added to `users` so it can read group-owned
|
||||
# library dirs on the RAID.
|
||||
# Audiobookshelf audiobook/podcast server, listening on all interfaces but
|
||||
# reachable only via tailscale0 or local caddy (no openFirewall) — library
|
||||
# paths are set in the web UI, pointed at /mnt/data/... In the "users" group
|
||||
# so it can read the RAID's group-owned library dirs.
|
||||
{
|
||||
services.audiobookshelf = {
|
||||
enable = true;
|
||||
|
||||
+39
-64
@@ -1,30 +1,22 @@
|
||||
{ config, pkgs, inputs, ... }:
|
||||
|
||||
# Immich photo/video library. Native nixpkgs module (not the upstream compose
|
||||
# stack) — it owns its own postgres (with the pgvector + vectorchord extensions
|
||||
# it needs for search) and a unix-socket redis, so nothing else is required here.
|
||||
# Immich photo/video library. Native nixpkgs module, not the upstream compose
|
||||
# stack — it owns its own postgres (pgvector + vectorchord) and a unix-socket redis.
|
||||
#
|
||||
# Storage: everything lives under /mnt/data/AppData/immich, which is the media
|
||||
# store MIGRATED from the old ZimaOS/CasaOS install's UPLOAD_LOCATION
|
||||
# (/mnt/data/Immich/upload — same layout: library/ upload/ thumbs/
|
||||
# encoded-video/ profile/ backups/). See scripts/immich-import-legacy-db for the
|
||||
# matching database import. The postgres cluster itself stays on the OS disk.
|
||||
# Storage lives under /mnt/data/AppData/immich, migrated from the old ZimaOS/CasaOS
|
||||
# UPLOAD_LOCATION (same subfolder layout); see scripts/immich-import-legacy-db for
|
||||
# the matching DB import. The postgres cluster itself stays on the OS disk.
|
||||
#
|
||||
# ⚠️ The immich DB is the only copy of albums/faces/dates — the files alone
|
||||
# can't rebuild it. It joins the other unbacked databases on this network.
|
||||
let
|
||||
# The PACKAGE comes from nixpkgs-unstable (3.0.3); the MODULE comes from the
|
||||
# 26.05 pin (which ships 2.7.5). That combination is safe because the two
|
||||
# module files are byte-identical — verified by diffing them at the revisions
|
||||
# in flake.lock. RE-CHECK THAT DIFF on any input bump:
|
||||
# Package pinned to nixpkgs-unstable (3.0.3) while the module stays on the 26.05
|
||||
# pin (2.7.5) — safe only because the two module files are byte-identical
|
||||
# (verified by diff; re-check on any input bump). Needed because immich's
|
||||
# migrations are forward-only and jupiter's imported DB was last written by
|
||||
# 3.0.0, which 2.7.5 refuses to start against; drop once the pin ships >= 3.0.0.
|
||||
# diff <(nixpkgs)/nixos/modules/services/web-apps/immich.nix \
|
||||
# <(unstable)/nixos/modules/services/web-apps/immich.nix
|
||||
#
|
||||
# Why: jupiter's imported database was last written by immich 3.0.0, and
|
||||
# immich runs its migrations forward only — 2.7.5 refuses to start against it
|
||||
# with "corrupted migrations: previously executed migration
|
||||
# 1776217577402-DropAuditTable is missing". Drop this override once nixos-26.11
|
||||
# (or whatever the pin becomes) ships >= 3.0.0.
|
||||
unstable = import inputs.nixpkgs-unstable {
|
||||
inherit (pkgs.stdenv.hostPlatform) system;
|
||||
};
|
||||
@@ -42,27 +34,20 @@ in
|
||||
mediaLocation = "/mnt/data/AppData/immich";
|
||||
machine-learning.enable = true;
|
||||
|
||||
# ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE, and
|
||||
# that is ALL-OR-NOTHING (dist/utils/config.js: the config is
|
||||
# `configFile ? loadFromFile(...) : metadataRepo.get(SystemConfig)` — the
|
||||
# database copy is IGNORED, not merged). Two consequences:
|
||||
# 1. Anything not declared here falls back to immich's DEFAULTS, not to
|
||||
# whatever the admin UI had. The old settings stay in the
|
||||
# system_metadata table, so deleting this block restores them.
|
||||
# 2. The admin settings UI goes read-only — saving throws "Cannot update
|
||||
# configuration while IMMICH_CONFIG_FILE is in use". Change settings
|
||||
# HERE and redeploy.
|
||||
# An unknown/misspelled key is a HARD startup failure under a config file
|
||||
# (the same code path only logs a warning without one), so keys below are
|
||||
# taken verbatim from `defaults` in immich's dist/config.js.
|
||||
# ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE mode,
|
||||
# which is all-or-nothing: undeclared keys fall back to immich's defaults, not
|
||||
# the admin UI's saved values (which stay in system_metadata and return if
|
||||
# this block is deleted), and the admin settings UI goes read-only. An
|
||||
# unknown/misspelled key is a hard startup failure here (just a warning
|
||||
# without a config file), so keys are copied verbatim from `defaults` in
|
||||
# immich's dist/config.js.
|
||||
settings = {
|
||||
server.externalDomain = "https://immich.mgaction.town";
|
||||
newVersionCheck.enabled = false; # nixpkgs pins the version, not immich
|
||||
|
||||
# OIDC via Authentik on neptun. The Authentik application/provider is
|
||||
# created BY HAND in its UI — same as headscale's and headplane's, which
|
||||
# are also separate apps (hosts/neptun/secrets.nix). Only the client
|
||||
# secret is managed here.
|
||||
# OIDC via Authentik on neptun; the application/provider is created by hand
|
||||
# in its UI (like headscale's and headplane's, separate apps) — only the
|
||||
# client secret is managed here (hosts/neptun/secrets.nix).
|
||||
oauth = {
|
||||
enabled = true;
|
||||
# Authentik's per-application issuer. Trailing slash matters: immich
|
||||
@@ -76,24 +61,18 @@ in
|
||||
clientSecret._secret = config.sops.secrets.immich_oauth_client_secret.path;
|
||||
scope = "openid email profile";
|
||||
buttonText = "Login with Authentik";
|
||||
# Existing accounts (the 2 imported users) keep working: matching is by
|
||||
# email, so an Authentik user with the same address adopts that account
|
||||
# rather than creating a second one.
|
||||
# Matches by email, so the 2 imported users adopt their Authentik account
|
||||
# instead of getting a duplicate.
|
||||
autoRegister = true;
|
||||
# Leave the password form reachable — autoLaunch would bounce straight
|
||||
# to Authentik, locking everyone out if the OIDC app is misconfigured.
|
||||
autoLaunch = false;
|
||||
# Land back on immich's own login page after logout. Without this,
|
||||
# immich falls back to the IdP's discovered end_session_endpoint
|
||||
# (auth.service.js:320-326) and logout dumps you on Authentik's
|
||||
# "you've been logged out" page instead. Must be an ABSOLUTE url —
|
||||
# the config schema rejects a relative path — and mirrors immich's
|
||||
# internal LOGIN_URL, including autoLaunch=0.
|
||||
#
|
||||
# Note this ends the IMMICH session only; the Authentik SSO session
|
||||
# survives, so the next "Login with Authentik" click signs straight
|
||||
# back in without a credential prompt. To end both, drop this line and
|
||||
# let the IdP endpoint take over again.
|
||||
# Without this, immich falls back to the IdP's discovered
|
||||
# end_session_endpoint and logout dumps you on Authentik's own page
|
||||
# instead of back here — must be an absolute url, mirroring immich's
|
||||
# internal LOGIN_URL. This ends the immich session only; the Authentik
|
||||
# SSO session survives, so the next login skips the credential prompt —
|
||||
# drop this line to end both.
|
||||
endSessionEndpoint = "https://auth.mgaction.town/application/o/immich/end-session?post_logout_redirect_url=https://immich.mgaction.town";
|
||||
# The mobile app can't follow a browser redirect back to a custom
|
||||
# scheme through Authentik, so immich bounces it via this endpoint.
|
||||
@@ -101,28 +80,24 @@ in
|
||||
mobileRedirectUri = "https://immich.mgaction.town/api/oauth/mobile-redirect";
|
||||
};
|
||||
};
|
||||
# Hardware transcoding would need the iGPU passed in explicitly, e.g.
|
||||
# accelerationDevices = [ "/dev/dri/renderD128" ]; the default [ ] means
|
||||
# PrivateDevices=yes and CPU-only transcode. The ZimaBlade's Celeron does
|
||||
# this slowly but it only runs on upload.
|
||||
# Hardware transcoding needs accelerationDevices set explicitly (e.g.
|
||||
# "/dev/dri/renderD128"); default CPU-only transcode is slow on the
|
||||
# ZimaBlade's Celeron but only runs on upload.
|
||||
};
|
||||
|
||||
# /mnt/data/AppData is drwx--x--- darman:users — immich needs group "users"
|
||||
# just to TRAVERSE into its own media dir. The dir itself stays 0700
|
||||
# immich:immich (the module's tmpfiles rule re-asserts that every rebuild,
|
||||
# and UMask=0077 keeps new files private), so this grants nothing else.
|
||||
# /mnt/data/AppData is drwx--x--- darman:users; immich only needs group "users"
|
||||
# to traverse into it — the dir itself stays 0700 immich:immich (tmpfiles +
|
||||
# UMask=0077 reassert that), so this grants nothing else.
|
||||
users.users.immich.extraGroups = [ "users" ];
|
||||
|
||||
# mediaLocation is outside /var/lib, so the module won't create it — its own
|
||||
# tmpfiles entry only ADJUSTS an existing dir. Harmless no-op after the
|
||||
# legacy import, which puts the real store here.
|
||||
# mediaLocation is outside /var/lib, so the module won't create it — this rule
|
||||
# only adjusts perms on the dir the legacy import already created.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /mnt/data/AppData/immich 0700 immich immich -"
|
||||
];
|
||||
|
||||
# The unit's automatic RequiresMountsFor covers /run/immich and /var/lib/immich
|
||||
# only — nothing points it at mediaLocation. Without this immich starts with
|
||||
# the array missing and writes uploaded photos onto the 29G eMMC, into a
|
||||
# directory that becomes invisible the moment /mnt/data mounts over it.
|
||||
# The unit's automatic RequiresMountsFor doesn't cover mediaLocation — without
|
||||
# this, immich starts before /mnt/data mounts and writes uploads onto the 29G
|
||||
# eMMC, invisibly, under the future mountpoint.
|
||||
systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
|
||||
+10
-13
@@ -6,20 +6,17 @@
|
||||
dataDir = "/mnt/data/AppData/jellyfin";
|
||||
cacheDir = "/mnt/data/AppData/jellyfin/cache";
|
||||
};
|
||||
# "users" so the shared library stays readable (see the UMask note below);
|
||||
# "video"/"render" for the DRI nodes used by hardware transcoding. renderD128
|
||||
# happens to be 0666 so VAAPI alone would work without this, but card1 is
|
||||
# 0660 root:video — and neither mode is guaranteed, so don't rely on it. The
|
||||
# groups are harmless on a host with no GPU: they exist regardless, and this
|
||||
# module stays host-agnostic (the DRIVER is enabled per-host, e.g. jupiter's
|
||||
# hardware.graphics + intel-media-driver).
|
||||
# "users" keeps the shared library readable (see the UMask note below);
|
||||
# "video"/"render" cover the DRI nodes for hardware transcoding — card1 is
|
||||
# 0660 root:video (not guaranteed 0666 like renderD128), so don't rely on
|
||||
# device perms alone. Harmless on a GPU-less host: the driver itself is
|
||||
# enabled per-host (e.g. jupiter's hardware.graphics + intel-media-driver).
|
||||
users.users.jellyfin.extraGroups = [ "users" "video" "render" ];
|
||||
|
||||
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing
|
||||
# trickplay thumbnails into stray new show folders it invented itself,
|
||||
# owned jellyfin:jellyfin 700, invisible to every other service sharing
|
||||
# the library (cinephage, mediamanager, ...). New files/dirs it creates
|
||||
# from here on inherit group "users" (library roots are setgid, see the
|
||||
# one-time chmod g+s done by hand) and stay group-writable.
|
||||
# The upstream module hardcodes UMask=0077, which made jellyfin write
|
||||
# trickplay thumbnails into new folders owned jellyfin:jellyfin 700 —
|
||||
# invisible to every other service sharing the library (cinephage,
|
||||
# mediamanager). Forcing 0002 makes new files inherit group "users"
|
||||
# (library roots are setgid via a one-time chmod g+s) and stay group-writable.
|
||||
systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002";
|
||||
}
|
||||
|
||||
@@ -15,21 +15,16 @@
|
||||
{
|
||||
services.prowlarr.enable = true;
|
||||
|
||||
# `nofail` is NOT optional here: without it this bind is RequiredBy
|
||||
# local-fs.target, so an unassembled RAID array fails that target and drops
|
||||
# jupiter into emergency mode — which is a dead end, since root is locked and
|
||||
# sulogin has nothing to offer on a headless box. It defeats the `nofail` on
|
||||
# /mnt/data itself (a mount layered on the array is what actually took the
|
||||
# target down). Let this bind fail alone instead.
|
||||
# `nofail` is not optional: without it this bind is RequiredBy local-fs.target,
|
||||
# so an unassembled array drops jupiter into emergency mode — a dead end on a
|
||||
# headless box with root locked. Let this bind fail alone instead.
|
||||
fileSystems."/var/lib/private/prowlarr" = {
|
||||
device = "/mnt/data/AppData/prowlarr/config";
|
||||
fsType = "none";
|
||||
options = [ "bind" "nofail" ];
|
||||
};
|
||||
|
||||
# systemd derives RequiresMountsFor from the unit's own paths, which here is
|
||||
# only /var/lib/prowlarr on the eMMC — so without this prowlarr starts happily
|
||||
# with the array absent and writes its state onto the 29G OS disk. Pin it to
|
||||
# the array so it fails loudly instead.
|
||||
# systemd derives RequiresMountsFor only from /var/lib/prowlarr (eMMC) — pin
|
||||
# it to the array too, or prowlarr starts happily and writes state to the OS disk.
|
||||
systemd.services.prowlarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr. dataDir points
|
||||
# at the config migrated from the old ZimaOS docker stack (indexers/download
|
||||
# client/history already set up). Unlike prowlarr, this module uses a static
|
||||
# `radarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
|
||||
# module's own default path — since we point at a pre-existing migrated dir,
|
||||
# chown it by hand once after first deploy:
|
||||
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr; dataDir points
|
||||
# at config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
|
||||
# module uses a static `radarr` user (no DynamicUser) and only auto-chowns
|
||||
# dataDir at its own default path, so the migrated dir needs a manual
|
||||
# one-time chown after first deploy:
|
||||
# chown -R radarr:radarr /mnt/data/AppData/radarr/config
|
||||
{
|
||||
services.radarr = {
|
||||
|
||||
+13
-20
@@ -1,18 +1,13 @@
|
||||
{ config, ... }:
|
||||
|
||||
# SABnzbd — usenet downloader. Migrated off a reused hand-authored ini
|
||||
# (servers/API key/history originally imported from the old ZimaOS docker
|
||||
# stack) onto NixOS-managed `settings`, per the module's own deprecation
|
||||
# notice for `configFile`. Only the values that differ from SABnzbd's own
|
||||
# built-in defaults are declared here — everything else falls back to the
|
||||
# same defaults SABnzbd was already using.
|
||||
# SABnzbd — usenet downloader, migrated off a hand-authored ini (imported from
|
||||
# the old ZimaOS docker stack) onto NixOS-managed `settings`. Only values that
|
||||
# differ from SABnzbd's own defaults are declared here.
|
||||
#
|
||||
# `admin_dir`/`log_dir` MUST stay absolute: the module writes the merged ini
|
||||
# to /var/lib/sabnzbd/sabnzbd.ini (eMMC), and both dirs are otherwise
|
||||
# relative to wherever the ini lives. Pointing them back at the ORIGINAL
|
||||
# /mnt/data location keeps the existing download queue/history database
|
||||
# (admin_dir) intact — a relative default here would silently "reset"
|
||||
# SABnzbd to an empty queue on first switch, even though nothing was deleted.
|
||||
# `admin_dir`/`log_dir` must stay absolute: the module writes the merged ini to
|
||||
# /var/lib/sabnzbd/sabnzbd.ini (eMMC), so a relative default would resolve
|
||||
# there instead of the original /mnt/data location — silently "resetting"
|
||||
# SABnzbd to an empty queue/history on first switch, without deleting anything.
|
||||
{
|
||||
services.sabnzbd = {
|
||||
enable = true;
|
||||
@@ -73,17 +68,15 @@
|
||||
# Write access to the shared downloads dir (owned darman:users on disk).
|
||||
users.users.sabnzbd.extraGroups = [ "users" ];
|
||||
|
||||
# download/complete/admin dirs all live on the array, but systemd only
|
||||
# derives RequiresMountsFor from /var/lib/sabnzbd (eMMC) — so with the array
|
||||
# absent sabnzbd would start and download onto the 29G OS disk.
|
||||
# download/complete/admin dirs live on the array, but systemd only derives
|
||||
# RequiresMountsFor from /var/lib/sabnzbd (eMMC) — without this, a missing
|
||||
# array lets sabnzbd start and download onto the 29G OS disk instead.
|
||||
systemd.services.sabnzbd.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
systemd.services.fix-downloads-perms.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
||||
|
||||
# SABnzbd hardcodes completed job folders to 0700 on every job, ignoring
|
||||
# the ini's `umask` (that only covers files during unpack, not the job
|
||||
# dir itself). setgid on Downloads keeps the group as "users" but perm
|
||||
# bits still come back zeroed, locking out cinephage/mediamanager — sweep
|
||||
# it clean instead of fighting SABnzbd.
|
||||
# SABnzbd hardcodes completed job folders to 0700, ignoring the ini's `umask`
|
||||
# (unpack-only) — setgid keeps the group but perm bits still zero out and
|
||||
# lock out cinephage/mediamanager, so sweep it clean on a timer instead.
|
||||
systemd.services.fix-downloads-perms = {
|
||||
description = "Fix group perms SABnzbd resets on completed downloads";
|
||||
serviceConfig.Type = "oneshot";
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talks to
|
||||
# Sonarr/Radarr to fulfill requests. Fresh install, no migrated data.
|
||||
#
|
||||
# configDir stays at the module default; bind-mount AppData onto it instead
|
||||
# of overriding configDir, so data lives on the RAID array and survives an
|
||||
# OS-disk reinstall (same DynamicUser/StateDirectory issue as prowlarr.nix —
|
||||
# see that file for why, and why the mount targets /var/lib/private/seerr
|
||||
# rather than the public path).
|
||||
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talking to
|
||||
# Sonarr/Radarr; fresh install, no migrated data. configDir stays at the
|
||||
# module default, with AppData bind-mounted onto it instead (same
|
||||
# DynamicUser/StateDirectory issue as prowlarr.nix — see that file for why,
|
||||
# and why the mount targets /var/lib/private/seerr rather than the public path).
|
||||
{
|
||||
services.seerr.enable = true;
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{ ... }:
|
||||
|
||||
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr. dataDir points at
|
||||
# the config migrated from the old ZimaOS docker stack (indexers/download
|
||||
# client/history already set up). Unlike prowlarr, this module uses a static
|
||||
# `sonarr` user (no DynamicUser) and only auto-chowns dataDir when it's the
|
||||
# module's own default path — since we point at a pre-existing migrated dir,
|
||||
# chown it by hand once after first deploy:
|
||||
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr; dataDir points at
|
||||
# config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
|
||||
# module uses a static `sonarr` user (no DynamicUser) and only auto-chowns
|
||||
# dataDir at its own default path, so the migrated dir needs a manual
|
||||
# one-time chown after first deploy:
|
||||
# chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config
|
||||
{
|
||||
services.sonarr = {
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
|
||||
prometheusConfig = {
|
||||
global.scrape_interval = "5s";
|
||||
# Explicit, and equal to the interval on purpose. The Prometheus default
|
||||
# is 10s, and VictoriaMetrics silently clamps scrape_timeout down to
|
||||
# scrape_interval rather than erroring — so leaving it implicit means the
|
||||
# config says 10s while the scraper uses 5s. Say what actually happens.
|
||||
# Explicit and equal to the interval on purpose: VictoriaMetrics silently
|
||||
# clamps scrape_timeout down to scrape_interval, so leaving the Prometheus
|
||||
# default (10s) here would misstate what actually happens.
|
||||
global.scrape_timeout = "5s";
|
||||
|
||||
scrape_configs = [
|
||||
@@ -44,14 +43,10 @@
|
||||
];
|
||||
}
|
||||
# mercury is a Pi scraped over the tailnet, so it gets its own job at a
|
||||
# slower cadence: at the 5s global it would time out (see above) and
|
||||
# the series would show gaps rather than late samples.
|
||||
#
|
||||
# A separate cadence REQUIRES a separate job — scrape_interval is a
|
||||
# per-job setting and job_name has to be unique — which means mercury's
|
||||
# `job` label differs from every other host's. Select on `host` (set on
|
||||
# every target below) rather than job="node-exporter" in dashboards and
|
||||
# alerts, or mercury drops out of them silently.
|
||||
# slower cadence to avoid timing out at the 5s global. A separate cadence
|
||||
# requires a separate job (scrape_interval is per-job), so mercury's
|
||||
# `job` label differs from every other host's — select on `host` in
|
||||
# dashboards/alerts, not job="node-exporter", or mercury drops out silently.
|
||||
{
|
||||
job_name = "node-exporter-mercury";
|
||||
scrape_interval = "15s";
|
||||
@@ -81,31 +76,22 @@
|
||||
# another host or the tailnet is temporarily unavailable.
|
||||
systemd.services.victoriametrics.after = [ "tailscaled-autoconnect.service" ];
|
||||
|
||||
# Keep the TSDB off jupiter's 29G eMMC. The module hardcodes
|
||||
# -storageDataPath=/var/lib/<stateDir> and runs DynamicUser, so without this
|
||||
# the data lands on the OS disk — a continuous small-write workload aimed at
|
||||
# the one disk here with no headroom and finite write endurance. Same
|
||||
# bind-onto-/var/lib/private pattern as prowlarr.nix and seerr.nix; see
|
||||
# prowlarr.nix for why the mount targets the private path and not the public
|
||||
# /var/lib/victoriametrics.
|
||||
#
|
||||
# `nofail` is NOT optional — again see prowlarr.nix: without it this bind is
|
||||
# RequiredBy local-fs.target, so an unassembled array drops jupiter into an
|
||||
# emergency shell that a headless box cannot be rescued from.
|
||||
# Keep the TSDB off jupiter's 29G eMMC: the module hardcodes
|
||||
# -storageDataPath=/var/lib/<stateDir> under DynamicUser, so without this bind
|
||||
# a continuous small-write workload lands on the one disk with no headroom.
|
||||
# Same /var/lib/private bind pattern as prowlarr.nix and seerr.nix — see
|
||||
# prowlarr.nix for why it targets the private path, and why `nofail` here is
|
||||
# not optional.
|
||||
fileSystems."/var/lib/private/victoriametrics" = {
|
||||
device = "/mnt/data/AppData/victoriametrics";
|
||||
fsType = "none";
|
||||
options = [ "bind" "nofail" ];
|
||||
};
|
||||
|
||||
# The bind above needs its SOURCE to exist or the mount fails — and because
|
||||
# it is `nofail` that failure is quiet: RequiresMountsFor below is satisfied
|
||||
# by /mnt/data itself, so VictoriaMetrics would start regardless and write to
|
||||
# the eMMC, which is the exact thing the bind exists to prevent. prowlarr.nix
|
||||
# gets away without this only because its directory predates the module
|
||||
# (migrated from ZimaOS). This is a fresh service, so it creates its own,
|
||||
# same as seerr.nix. 0755 darman:users matches the other AppData dirs, which
|
||||
# matters because /mnt/data/AppData itself is drwx--x--- darman:users.
|
||||
# The bind above needs its source dir to exist or it quietly fails (`nofail`)
|
||||
# and VictoriaMetrics falls through to writing the eMMC anyway — this is a
|
||||
# fresh service so, unlike prowlarr.nix's pre-existing dir, it must create its
|
||||
# own (same as seerr.nix). 0755 darman:users matches the other AppData dirs.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /mnt/data/AppData/victoriametrics 0755 darman users -"
|
||||
];
|
||||
|
||||
@@ -53,17 +53,11 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
# Bind-mount source must exist (podman won't create it), and it must be
|
||||
# owned by 1000 — the `pihole` user FTL drops to after the entrypoint's root
|
||||
# phase. Podman here is rootful with no userns remapping, so that number is
|
||||
# the same inside and out (on the host it collides with darman, harmlessly).
|
||||
#
|
||||
# Ownership of gravity.db alone is not enough: sqlite creates a sibling
|
||||
# gravity.db-journal for every write transaction, so FTL needs to CREATE
|
||||
# files in this directory. Root-owned, it fails with
|
||||
# open(/etc/pihole/gravity.db-journal) - (14)
|
||||
# attempt to write a readonly database
|
||||
# which reads like a corrupt or read-only database and is neither.
|
||||
# Bind-mount source must exist (podman won't create it) and be owned by 1000,
|
||||
# the `pihole` user FTL drops to (rootful podman, no userns remapping, so the
|
||||
# uid is the same inside and out). Must be the whole DIRECTORY, not just
|
||||
# gravity.db — sqlite needs to create a sibling gravity.db-journal per write,
|
||||
# and a root-owned dir makes that fail with a misleading "readonly database".
|
||||
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0750 1000 1000 -" ];
|
||||
|
||||
# Seed the adlists above into gravity. `INSERT OR IGNORE` keyed on the URL
|
||||
|
||||
@@ -22,14 +22,13 @@
|
||||
};
|
||||
};
|
||||
|
||||
# Samba keeps its own NTLM password DB, separate from the system password;
|
||||
# `services.samba` never sets it, so logins fail until provisioned. Runs
|
||||
# AFTER samba-smbd so its state dir exists — an activation script runs too
|
||||
# early and smbpasswd fails to init the passdb. Reads a single-line
|
||||
# password from the first file that exists:
|
||||
# Samba keeps its own NTLM password DB, separate from the system password —
|
||||
# `services.samba` never sets it, and this runs as a service (not an
|
||||
# activation script, which fires too early for smbpasswd's passdb) after
|
||||
# samba-smbd. Reads a single-line password from the first existing file,
|
||||
# feeding it twice since smbpasswd prompts new+confirm:
|
||||
# Real host: /run/secrets/samba_password (sops-nix, see secrets.nix)
|
||||
# VM test: /etc/samba/smb-password (plaintext, see vm.nix)
|
||||
# smbpasswd prompts new + confirm, so the value is fed twice.
|
||||
systemd.services.samba-smbpasswd = {
|
||||
description = "Provision Samba password for darman";
|
||||
after = [ "samba-smbd.service" ];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{ ... }:
|
||||
|
||||
# Local recursive DNS resolver (privacy + DNSSEC). Your adblock DNS
|
||||
# (pihole/AdGuard) forwards to this instead of a public upstream.
|
||||
# Listens on 127.0.0.1:5335 — point the adblock engine's upstream there:
|
||||
# Local recursive DNS resolver (privacy + DNSSEC) that the adblock DNS
|
||||
# (pihole/AdGuard) forwards to instead of a public upstream — listens on
|
||||
# 127.0.0.1:5335, so point the adblock engine's upstream there:
|
||||
# AdGuard: dns.upstream_dns = [ "127.0.0.1:5335" ];
|
||||
# pihole: upstream = "127.0.0.1#5335";
|
||||
{
|
||||
|
||||
+12
-20
@@ -1,25 +1,20 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Headplane — web UI for headscale (services/vpn/headscale.nix; must be enabled
|
||||
# first), running as headscale's own OS user.
|
||||
# Headplane — web UI for headscale (services/vpn/headscale.nix; enable first),
|
||||
# running as headscale's OS user.
|
||||
#
|
||||
# It reads headscale's config from the nix store, which is read-only — so the
|
||||
# UI DISPLAYS the settings but can't change them. That's the intended shape
|
||||
# for a declaratively-configured box (config_strict already defaults off
|
||||
# upstream for exactly this reason); edit them here and rebuild instead.
|
||||
# DNS extra-records are the one thing worth making editable, since they're
|
||||
# data rather than config — hence the writable extra_records file below,
|
||||
# which also spares headplane from restarting headscale on every change.
|
||||
# It reads headscale's config from the nix store, so the UI DISPLAYS settings
|
||||
# but can't change them (edit here and rebuild instead) — except DNS
|
||||
# extra-records, which are data rather than config, hence the writable
|
||||
# extra_records file below.
|
||||
#
|
||||
# Served at vpn.mgaction.town/admin (path-routed alongside headscale itself,
|
||||
# see hosts/neptun/configuration.nix). base_url is the site root WITHOUT the
|
||||
# /admin prefix — Headplane appends that itself, including for the OIDC
|
||||
# callback.
|
||||
# Served at vpn.mgaction.town/admin (path-routed with headscale, see
|
||||
# hosts/neptun/configuration.nix); base_url excludes the /admin prefix, which
|
||||
# Headplane appends itself including for the OIDC callback.
|
||||
#
|
||||
# Auth is Authentik (services/identity/authentik.nix) via OIDC. client_id,
|
||||
# client_secret, and the headscale API key can't be known until
|
||||
# Authentik/headscale are actually deployed, so they're placeholders below;
|
||||
# direct API-key login still works as a fallback until then. Once live:
|
||||
# Auth is Authentik via OIDC; client_id/client_secret/API key are placeholders
|
||||
# until Authentik/headscale are deployed (direct API-key login works as a
|
||||
# fallback until then). Once live:
|
||||
# 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug
|
||||
# `headplane` and redirect URI
|
||||
# https://vpn.mgaction.town/admin/oidc/callback. Copy the generated
|
||||
@@ -28,9 +23,6 @@
|
||||
# headplane_oidc_client_secret with the provider's client secret.
|
||||
# 3. `headscale apikeys create` on the box, and replace
|
||||
# headplane_headscale_api_key the same way.
|
||||
#
|
||||
# NOTE: Authentik issues per-application, so the issuer carries the app slug —
|
||||
# it is NOT the bare host the way Zitadel's was.
|
||||
{
|
||||
# Writable DNS extra-records, shared by both services (they run as the same
|
||||
# user). tmpfiles seeds an empty JSON array — headscale won't start against
|
||||
|
||||
+39
-77
@@ -1,14 +1,10 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Headscale — self-hosted control server for the tailnet. Every host's
|
||||
# services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town
|
||||
# (this host). MagicDNS base_domain "orbit.sol" matches the
|
||||
# "jupiter.orbit.sol" names used in this repo's Caddy vhosts
|
||||
# (hosts/neptun/configuration.nix) — changing base_domain means changing
|
||||
# those too, and re-pointing neptun's dnsmasq stub at the new suffix.
|
||||
#
|
||||
# TLS terminates at Caddy (see the host's configuration.nix); headscale
|
||||
# itself only listens on localhost.
|
||||
# Headscale — self-hosted control server for the tailnet; every host's
|
||||
# services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town.
|
||||
# TLS terminates at Caddy; headscale itself only listens on localhost. Changing
|
||||
# base_domain below also means updating this repo's Caddy vhosts and neptun's
|
||||
# dnsmasq stub, which assume "orbit.sol".
|
||||
{
|
||||
services.headscale = {
|
||||
enable = true;
|
||||
@@ -18,89 +14,55 @@
|
||||
server_url = "https://vpn.mgaction.town";
|
||||
|
||||
dns = {
|
||||
# Deliberately OUTSIDE mgaction.town. That zone has a wildcard A+AAAA
|
||||
# pointing at neptun, and DNS wildcards match multi-label names — so
|
||||
# with base_domain = hosts.mgaction.town, `jupiter.hosts.mgaction.town`
|
||||
# resolved publicly to NEPTUN and Caddy proxied to itself: a silent
|
||||
# loop rather than a lookup failure.
|
||||
#
|
||||
# `.sol` is the LAN domain pihole serves, so this nests the tailnet
|
||||
# inside it: planets sit on the LAN as jupiter.sol, and reach each
|
||||
# other in orbit as jupiter.orbit.sol. Resolution is unambiguous
|
||||
# because tailscale matches routes by LONGEST suffix, so orbit.sol
|
||||
# goes to MagicDNS even when everything else funnels to pihole.
|
||||
#
|
||||
# Never give a LAN host the name `orbit`: pihole's
|
||||
# `address=/<host>.sol/<ip>` lines match a name AND everything under
|
||||
# it, so an `orbit` host would swallow this entire zone.
|
||||
# Deliberately outside mgaction.town: that zone has a wildcard A+AAAA at
|
||||
# neptun, so a name under it would resolve publicly to neptun and Caddy
|
||||
# would proxy to itself. Nested under `.sol` (pihole's LAN domain) so
|
||||
# jupiter.sol (LAN) and jupiter.orbit.sol (tailnet) resolve unambiguously
|
||||
# — tailscale matches by longest suffix. Never name a LAN host `orbit`:
|
||||
# pihole's `address=/<host>.sol/<ip>` would swallow this whole zone.
|
||||
base_domain = "orbit.sol";
|
||||
# pihole on mercury, over the tailnet — so every roaming device gets
|
||||
# ad blocking and .sol names wherever it is, not just on the LAN.
|
||||
# Deliberately NO public fallback: tailscale treats the list as a set,
|
||||
# so adding 9.9.9.9 here would let queries slip past the filter
|
||||
# whenever mercury is briefly slow. Strict blocking, at the cost of
|
||||
# mercury being a single point of failure for tailnet DNS.
|
||||
#
|
||||
# ⚠️ A hardcoded tailnet address, so it changes if mercury re-enrols
|
||||
# — check `headscale nodes list` if DNS dies tailnet-wide.
|
||||
# pihole on mercury, over the tailnet, so roaming devices get ad blocking
|
||||
# and .sol names everywhere. Deliberately no public fallback — tailscale
|
||||
# treats this as a set, so adding one would let queries slip past the
|
||||
# filter whenever mercury is briefly slow, at the cost of mercury being a
|
||||
# single point of failure for tailnet DNS.
|
||||
# ⚠️ Hardcoded tailnet address — check `headscale nodes list` if it
|
||||
# changes (mercury re-enrolled) and DNS dies tailnet-wide.
|
||||
nameservers.global = [ "100.64.0.7" ];
|
||||
|
||||
# Must be set, and must be HERE rather than via the module's
|
||||
# `dns.split` option. nixpkgs renders that option one level too high
|
||||
# (a sibling of `nameservers:`), but headscale reads
|
||||
# dns.nameservers.split (hscontrol/types/config.go:722) and so does
|
||||
# headplane. So the module's option is dead, and the missing key makes
|
||||
# headplane's DNS page die with
|
||||
# TypeError: Cannot convert undefined or null to object
|
||||
# from Object.keys(config.dns.nameservers.split).
|
||||
# Must be set here, not via the module's `dns.split` option — nixpkgs
|
||||
# renders that one level too high, but headscale (and headplane) read
|
||||
# dns.nameservers.split; the missing key crashes headplane's DNS page.
|
||||
nameservers.split = { };
|
||||
|
||||
# Point every node's resolver at MagicDNS, which forwards on to the
|
||||
# global nameserver above. That is the only way to get pihole onto a
|
||||
# roaming device: with this false, globalResolvers land in the
|
||||
# netmap's FallbackResolvers (hscontrol/types/config.go:826-830) and a
|
||||
# phone with carrier DNS never consults them.
|
||||
#
|
||||
# The cost is that every node's DNS now depends on mercury and on the
|
||||
# home connection, so mercury going down costs name resolution
|
||||
# everywhere, not just `.sol`. neptun and mercury opt out of this
|
||||
# individually with --accept-dns=false — see their configuration.nix.
|
||||
# Routes every node's resolver through MagicDNS to the global nameserver
|
||||
# above — the only way pihole reaches a roaming device (otherwise it
|
||||
# lands in netmap's FallbackResolvers and carrier DNS never consults it).
|
||||
# Cost: all DNS now depends on mercury and the home connection; neptun
|
||||
# and mercury opt out individually with --accept-dns=false.
|
||||
override_local_dns = true;
|
||||
};
|
||||
|
||||
# Authentik as the login provider, so `tailscale up --login-server ...`
|
||||
# sends you to a browser instead of needing a pre-auth key. This is a
|
||||
# SEPARATE Authentik application from headplane's — its own provider,
|
||||
# slug `headscale`, redirect https://vpn.mgaction.town/oidc/callback
|
||||
# (headscale's own callback; headplane's is under /admin).
|
||||
#
|
||||
# ⚠️ headscale performs OIDC discovery at STARTUP and a failure is
|
||||
# FATAL ("creating OIDC provider from issuer config: 404 Not Found") —
|
||||
# it will not boot, taking the whole tailnet's control plane with it.
|
||||
# Never point `issuer` at an application that doesn't exist yet; verify
|
||||
# with:
|
||||
# Authentik as the login provider (own application, slug `headscale`,
|
||||
# separate from headplane's) so `tailscale up --login-server ...` opens a
|
||||
# browser instead of needing a pre-auth key; headless hosts still use those.
|
||||
# ⚠️ headscale does OIDC discovery at startup and a failure is fatal — it
|
||||
# won't boot, taking the whole control plane with it. Never point `issuer`
|
||||
# at an application that doesn't exist yet; verify with
|
||||
# curl -s <issuer>.well-known/openid-configuration
|
||||
#
|
||||
# Headless hosts still enrol with pre-auth keys. Note also that users
|
||||
# created here are distinct from `headscale users create` ones: matching
|
||||
# is by the OIDC `sub` claim against the user's providerId, and 0.28
|
||||
# dropped map_legacy_users, so CLI-made users never gain one.
|
||||
# Users created here are matched by OIDC `sub`, so `headscale users
|
||||
# create`-made users never link to one (0.28 dropped map_legacy_users).
|
||||
oidc = {
|
||||
issuer = "https://auth.mgaction.town/application/o/headscale/";
|
||||
client_id = "14vhRYaLiONHmI2YFIxbQEveJDLu5cCvzSkTb9oq";
|
||||
client_secret_path = config.sops.secrets.headscale_oidc_client_secret.path;
|
||||
};
|
||||
|
||||
# Run our own DERP relay instead of pulling Tailscale's map.
|
||||
#
|
||||
# With the default (urls = [controlplane.tailscale.com/derpmap/default],
|
||||
# auto_update_enabled = true) headscale fetches that map at startup and
|
||||
# treats failure as FATAL — so a DNS blip or a Tailscale outage stops the
|
||||
# control server from booting at all. A self-hosted control plane that
|
||||
# can't start without Tailscale's infrastructure rather misses the point.
|
||||
#
|
||||
# The relay itself rides Caddy on :443 (hence the flush_interval -1 on
|
||||
# that vhost); only STUN needs its own UDP port.
|
||||
# Run our own DERP relay instead of pulling Tailscale's map: the default
|
||||
# fetches that map at startup and treats a failure as fatal, so a DNS blip
|
||||
# or Tailscale outage would stop this control server from booting at all.
|
||||
# The relay rides Caddy on :443 (hence flush_interval -1 on that vhost);
|
||||
# only STUN needs its own UDP port.
|
||||
derp = {
|
||||
urls = [ ];
|
||||
auto_update_enabled = false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{ config, ... }:
|
||||
|
||||
# Tailscale node joined to the self-hosted headscale control server.
|
||||
# Auto-registers on boot from a sops pre-auth key. Requires the importing host
|
||||
# to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
|
||||
# Not for the VM (no sops).
|
||||
# Tailscale node joined to the self-hosted headscale control server,
|
||||
# auto-registering on boot from a sops pre-auth key — importing hosts must
|
||||
# declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
|
||||
# Not used by the VM target (no sops there).
|
||||
{
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
@@ -14,11 +14,11 @@
|
||||
# Reach the host's services over the tailnet without opening LAN ports.
|
||||
networking.firewall.trustedInterfaces = [ "tailscale0" ];
|
||||
|
||||
# The upstream unit is a one-shot with no Restart, so a login attempt made
|
||||
# before the control server is reachable fails permanently until someone
|
||||
# starts it by hand. That's the norm on a first boot — neptun hosts headscale
|
||||
# itself, and the other hosts race it. 30s spacing also keeps restarts clear
|
||||
# of systemd's default start limit (5 within 10s).
|
||||
# The upstream unit is a one-shot with no Restart, so a login attempted
|
||||
# before the control server is up fails permanently until restarted by
|
||||
# hand — the norm on first boot, since neptun hosts headscale itself and
|
||||
# other hosts race it. 30s spacing keeps retries clear of systemd's default
|
||||
# start limit (5 within 10s).
|
||||
systemd.services.tailscaled-autoconnect.serviceConfig = {
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
|
||||
Reference in New Issue
Block a user