Commits
Sets the knot's owner DID (lass.pds.lassul.us), which turns the knot on.
knot-rs binds one SSH address, so it stays on [::]:2222 and port 22 on
neoprism's two public addresses is redirected to it (configs/ssh-redirect.nix
only rejects 22 that still reaches sshd). Admin SSH is unchanged.
pds.lassul.us with handles <name>.pds.lassul.us (wildcard cert via
DNS-01). Login through the zitadel project pds (access = declarative,
only lass@lassul.us), via Tranquil's generic OIDC SSO. Accounts are
created only through the SSO signup, without invite codes; nginx
returns 403 for createAccount, createPasskeyAccount and
createDelegatedAccount (this also blocks migrating accounts in).
SMTP username and SSO_OIDC_ENABLED are set via the environment
because the nixpkgs module validates the config file at build time
without the secrets they require. Mail goes out as send-only
pds@lassul.us, a mailbox on neoprism's mailserver defined next to the
PDS.
Same delegation and TSIG key as for *.review.lassul.us, so lego can get
the wildcard cert for PDS handles via DNS-01.
nixpkgs has tranquil-pds 0.6.7 with its frontend and a NixOS module;
this bumps both to the 0.7.0 release. The frontend is passthru.frontend,
for services.tranquil-pds.settings.frontend.dir.
When VictoriaMetrics has no sample shortly before the increase() window
(the monitoring server was down, or the series is new), MetricsQL's
increase() treats the first sample in the window as an increase from 0 and
returns the whole counter. After styx lost power for ~9 minutes, OOMKill
fired for coaxmetal with all 5 OOM kills since its boot 49 days ago,
although the counter never moved.
Only alert when the series has a sample in the minute before the window
(two minutes for the 60s smartctl scrape). Then increase() has a real
previous value, and still handles counter resets on a monitored host's
reboot. increase_pure() does not help: it assumes counters start at 0
and so counts the whole value as well.
NvmeMediaErrors uses increase() on a counter the same way and would
fire for any drive with past media errors after a gap, so it gets the
same guard. MemoryPressure's rate() is not affected: rate() does not
count the first sample in the window as an increase.
Mounts an empty auth_providers list to /config/auth_providers.yaml. homeassistant: auth_providers is read only from configuration.yaml, packages can't set it, so the volume's configuration.yaml includes it once (auth_providers: !include auth_providers.yaml under homeassistant:). Without the homeassistant provider only auth_oidc remains: the welcome page redirects straight to id.lassul.us and no password form is offered.
Long-lived access tokens have no credential and are not checked against a provider, so they keep working. Refresh tokens from earlier password logins fail on the next refresh and need one id.lassul.us login. The password credentials stay in the auth store, so a revert brings the password login back.
Zitadel project hass on neoprism with a public app (PKCE, no secret) and access = "managed": users get access via a user grant an admin gives in the id.lassul.us console. On acheron the auth_oidc custom component from nixpkgs is mounted into the Home Assistant container and configured via /config/packages, next to the username/password login.
modules.zitadel gets authMethod = "none" for public clients: reconcile generates no secret for them and writes only the client id to the env file, so nothing secret has to reach acheron.
Zitadel generates the client id, so it cannot be known at eval time on acheron. It is not secret; after neoprism is deployed it is copied from /var/lib/zitadel-apps/hass-web.env into the prompted hass-oidc var and passed to the container as HASS_OIDC_CLIENT_ID (!env_var in the yaml).
automatic_user_linking reuses the existing HA user whose username equals the preferred_username claim instead of creating a new one; it stays on because only users granted the project can log in. The groups scope is not requested since Zitadel has no groups claim.
Keeps Login V1 (also for a new instance) and turns off the service ping
that v4 sends to zitadel.com by default.
Based on nixpkgs PR 561044 (zitadel 4.17.3), built from source with
the console and protoc-gen-js 4.0.2; without the Login V2 app.
Each machine gets a backup.nix with its storage box sub-account
(coaxmetal sub2, styx sub3, acheron sub4, the yellow container sub5);
what is backed up is declared next to the services:
- coaxmetal: the hermes home without caches, toolchains, git clones,
node_modules and target dirs. Every *.db under .hermes (except cache/),
also in subdirs such as cron/ and the matrix crypto store, is dumped
with sqlite3 .backup into /var/backup/hermes, recreated each run so
removed databases don't linger; the live files are excluded.
- styx: n8n's DynamicUser state (/var/lib/private/n8n), its default
SQLite database dumped.
- acheron: zigbee2mqtt (devices, coordinator backup; database.db is JSON)
and mosquitto's retained messages as plain copies.
- yellow: jellyfin, sonarr, radarr, prowlarr, jellyseerr and sabnzbd
configs with their SQLite databases dumped. Excluded: jellyfin metadata,
logs and transcodes, servarr MediaCover and logs (also logs.db),
jellyseerr cache and logs, sabnzbd's incomplete Downloads and logs.
Media and downloads live in /var/download and are not backed up. A
missing database fails the hook instead of sqlite3 creating an empty one.
Grafana (coaxmetal, styx) is not backed up: datasources and dashboards
are provisioned from the repo, admin password and secret key come from
vars, anonymous users are viewers; both instances only have the four
provisioned dashboards.
Every machine now backs up into its own Hetzner storage box sub-account
(isolation: one machine's ssh key can't read or delete another's
archives), into ./borg in the sub-account's home. lass.backup.user replaces
lass.backup.repo and enables the job; host, port 23 and the box's host key
are module defaults, so a machine's backup.nix is only the sub-account.
Repos are always encrypted (repokey-blake2). The passphrase comes from a
per-machine clan vars generator borgbackup-repokey; it lives in the
password store, so passphrase + repo are enough to restore. The borgbackup
ssh key generator is unchanged.
neoprism moves to a new repo on u550643-sub1 (doInit creates it); the old
unencrypted repo u550643@...:/./neoprism is no longer referenced and is
removed by hand. Its paths, excludes and hooks are unchanged. The job stays
`hetzner`: with fresh repos nothing gains from a rename, and the unit name
is used for the postBackup hooks.
The timers get a fixed random delay of up to 1h after 03:00, so the
machines sharing the box don't all start at once.
lass.backup.state.<name> lets every service declare its folders, excludes,
pre/post backup hooks and the paths its pre hook writes to next to its
definition. The borg job (still `hetzner`, so archive names and pruning
continue) and the borgbackup ssh key generator exist only on machines that
set lass.backup.repo; neoprism's backup.nix now only points to the storage
box.
Pre hooks run inside the borg unit as before; each runs on its own with
pipefail, all of them run and the job fails if any did (a failing pg_dump
used to go unnoticed behind the gzip pipe). Post hooks run in ExecStopPost
as root, also after a failed backup.
The four bridge dumps are split from the old loop into the bridges' files.
The dump subdirs are created by the hooks instead of tmpfiles. hedgedoc's
uploads directory is backed up now.
The imgur items (hedgedoc image uploads) are backed up now too.
The voice model sometimes answered a repeated light command with "on it" without emitting a delegation, so the backend never saw it. The persona now says the voice model can do nothing itself, that every action request is delegated every time, and that it may only say it is on it together with the delegation.
The Android app holds the call itself instead of opening the page in a
Custom Tab: hold the headset's voice button (HFP AT+BVRA=1 starts
VOICE_COMMAND), talk, short press to hang up, all with the phone locked
and without looking at it.
- VoiceCall: the page's Call in Java over WebRTC (webrtc-sdk/android):
delegation to Hermes, voice approvals, end_call, idle hang-up.
- CallService: a self-managed Telecom call in the foreground, so the
audio goes to the headset and its hang-up (AT+CHUP) ends the call; a
tone says it listens.
- Screens: the current conversation with Call / Hang up, the list of
conversations, and the settings (app token, permissions).
- hermes-voice accepts the app's own token (--app-token-file, clan var
hermes-voice-app on coaxmetal); assist.lassul.us/app/ passes it
through without the web login.
- The page keeps Talk / Hang up in view: only the transcript scrolls.
assetlinks.json, the TWA and the page's ?listen go away with the
Custom Tab.
The app holds each call of the page as a self-managed Telecom call
(transactional API, foreground service), so a headset's AT+CHUP reaches
it. Page and app talk over the Custom Tabs postMessage channel: the page
reports started/ended, the app sends hangup. assetlinks.json grants the
app use_as_origin for that.
/?listen, which the Android app opens, matched no static file and got 404.
The Android app now handles VOICE_COMMAND (HFP AT+BVRA from headsets),
shows over the lock screen and asks to unlock before opening the page.
It opens /?listen, which starts the call without pressing Talk.
.github/workflows/assist-android.yml builds packages.hermes-voice-android
on master pushes that touch it (and on demand) with the run number as
versionCode and the short commit as versionName, signs it with
hermes-voice-android-sign from the ANDROID_KEYSTORE_B64 and
ANDROID_KEYSTORE_PASSWORD secrets (the clan var
hermes-voice-android-signing of coaxmetal) and uploads it as assist.apk
of the rolling release assist-android.
The package takes versionCode and versionName (default 1), so
`nix build .#hermes-voice-android` stays pure; CI overrides them through
--impure --expr. The unsigned APK is also a check, so PR CI builds it.
assist.lassul.us now sits behind oauth2-proxy on neoprism (provider
oidc, issuer https://id.lassul.us), wired in with nginx auth_request on
that vhost only. The nixpkgs nginx helper (oauth2-proxy.nginx) is not
used because it turns on services.nginx.recommendedProxySettings for
every vhost on the host; the same few locations are written out here.
Unauthenticated requests are redirected (307) to /oauth2/start with a
relative rd, which oauth2-proxy accepts without a whitelist-domain;
the provider button is skipped so it goes straight to Zitadel.
Access: the Zitadel project "assist" is declarative with a grant for
lass@lassul.us only, and its projectRoleCheck blocks everyone else at
the IdP. oauth2-proxy additionally allows only that address
(authenticated-emails-file, no email domains); its OIDC provider rejects
emails whose email_verified is false. The session user is the `sub`
claim. The client id/secret come from zitadel-reconcile's env file
(/var/lib/zitadel-apps/assist-web.env, OAUTH2_PROXY_CLIENT_ID/SECRET),
used as the unit's EnvironmentFile; the cookie secret from a new
neoprism vars generator (oauth2-proxy/cookie-secret, via LoadCredential).
Public without login, as Chrome fetches them without cookies to install
the page and verify the TWA: /.well-known/assetlinks.json,
/manifest.webmanifest, /icon-192.png, /icon-512.png.
After the login, nginx sets Authorization: Bearer <access token> towards
hermes-voice, from an include in /run written at boot by a root oneshot
(hermes-voice-auth-header, owned by nginx, mode 0600), so the token
never enters the store. For that the hermes-voice generator is now a
shared var (configs/hermes-voice-vars.nix, imported by coaxmetal and
neoprism); a shared generator gets a new value, so the token is rotated.
hermes-voice keeps checking the token, which still guards its retiolum
port; the gateway plugin keeps calling /approve on localhost with
API_SERVER_KEY.
The page no longer asks for or stores the token. Its fetches use
redirect: "manual", so an expired login (redirect to id.lassul.us,
which fetch cannot follow cross-origin) shows up as an opaqueredirect
and the page reloads into the login.
packages.hermes-voice-android builds an unsigned APK (us.lassul.assist,
"Assist") that opens https://assist.lassul.us/ as a Trusted Web
Activity, so Chrome handles the mic permission and the stored access
token exactly as in the browser. Its single activity is the launcher
entry and handles android.intent.action.ASSIST. That is enough to be
offered under Default apps > Digital assistant app: the ASSISTANT
role's AssistantRoleBehavior (packages/modules/Permission) qualifies a
package either by an assist-capable VoiceInteractionService or by an
exported activity resolving ACTION_ASSIST, with no system-app check.
The activity binds the first browser offering the TWA category, opens
a session and launches the TWA intent; without such a browser it opens
a Custom Tab, which other browsers treat as a plain VIEW intent. It
stays below the page in the task and finishes when returned to.
The build is plain aapt2 + javac + d8 from androidenv (build-tools and
platform 36; the SDK is unfree, so androidenv comes from a nixpkgs
instance with allowUnfree and the license accepted). The only library
is androidx.browser 1.10.0's classes.jar (AAR from Google Maven, 289
KB): the client, session and TWA intent builder used here need none of
its dependencies (androidx.core is only referenced by launchUrl-style
helpers, which are not called). The APK is 72 KB and byte-identical
across rebuilds (fixed zip timestamps).
packages.hermes-voice-android-sign zipaligns and signs it with
apksigner (v1-v3, no .idsig) from a keystore and password file, by
default the packaged unsigned APK, and prints the certificate so it
can be compared with assetlinks.json.
The voice page gets a web app manifest (standalone, colors of
style.css) and 192/512 px icons, drawn with imagemagick at build time
(date chunks excluded so the PNGs are reproducible). Both are needed
for the page to run as a Trusted Web Activity in the Android wrapper.
/.well-known/assetlinks.json delegates handle_all_urls to the wrapper
(us.lassul.assist) with the SHA-256 fingerprint of its signing
certificate, so Chrome verifies the app and drops the URL bar. Like
the static files it is served without the access token, and neoprism
already proxies every path of assist.lassul.us.
The signing key comes from the new hermes-voice-android-signing
generator on coaxmetal: a PKCS12 keystore and its password (secret,
deploy = false since only signing reads them) and the fingerprint as a
public file. The package reads the fingerprint from vars/ with
pathExists like the nix-cache key, so evaluation works before the
generator ran; assetlinks.json is then simply missing (404), which is
why the static routes now answer 404 for files not in the static dir.
Paperless-ngx at https://paperless.lassul.us, behind nginx with ACME
(client_max_body_size 100M for phone photo uploads). OCR in German and
English; the nixpkgs module builds tesseract with the languages from
PAPERLESS_OCR_LANGUAGE. Data stays at the module's default
/var/lib/paperless (static paperless user, no DynamicUser) with SQLite.
Login via Zitadel (id.lassul.us): lass.zitadel project "paperless" with
access = "declarative", so only users granted on the project (by
verified email) can log in. allauth's openid_connect provider keys
social accounts by uid_field, set explicitly to the token's sub.
paperless' environmentFile is a single env file and the client secret
has to sit inside the PAPERLESS_SOCIALACCOUNT_PROVIDERS JSON, so the
paperless-oidc-env oneshot builds it from zitadel-reconcile's env file
into /run/paperless-oidc, keeping the secret out of the store.
The regular login form is disabled; DRF token and basic auth do not go
through allauth's pre_authenticate, so API tokens keep working. The
admin password comes from the paperless-admin vars generator via the
module's passwordFile (LoadCredential).
Backups: /var/lib/paperless plus a consistent .backup of its WAL-mode
SQLite database (the live db files are excluded).
Mailbox scan@lassul.us with a vars-generated password
(mailserver-scan) for paperless' IMAP mail fetching, configured later
in paperless' database.
DNS: paperless CNAME neoprism like the other neoprism services; Gatus
checks the login page.
Keep a confirmed UDP path when a rekey relayed through another node
carries a different address hint: the hint flipped the path between
IPv6 and IPv4 on every rekey and stuck on a degraded one until the
peer's own rekey. Also stop starting tunnels to ourselves.
nixos-unstable has omp 18.11.0, whose fetchFromGitHub hash no longer
matches the tarball GitHub serves (Hydra rejects the output as well), so
coaxmetal, neoprism and barnacle fail to build. Pin the version from
nixpkgs master (18.11.2: new src and cargoDeps hashes, node_modules hash
unchanged) as an override of nixpkgs' omp. The assert breaks evaluation
as soon as nixpkgs moves away from 18.11.0, so the pin gets dropped with
the next flake update that brings a fixed omp.
We don't use the Matrix-Zulip bridge anymore, and its dependency python3.14-zulip-emoji-mapping fails to build with the current nixpkgs, which blocks the flake update.
Zitadel asks every user with an unverified address (registration, all
byoidc logins) for a code before the login finishes. Without an SMTP
provider the code was never sent (Errors.SMTPConfig.NotFound) and these
users were stuck. They now come from the send-only account id@lassul.us
on the mailserver, over TLS on mail.lassul.us:465.
lass.zitadel.smtp creates, updates and activates the instance's SMTP
provider. Zitadel 2.71 skips providers without a password, so the
password comes from passwordFile; a hash of the one Zitadel has is kept
in the state directory to update it when the file changes.
pds.lassul.us with handles <name>.pds.lassul.us (wildcard cert via
DNS-01). Login through the zitadel project pds (access = declarative,
only lass@lassul.us), via Tranquil's generic OIDC SSO. Accounts are
created only through the SSO signup, without invite codes; nginx
returns 403 for createAccount, createPasskeyAccount and
createDelegatedAccount (this also blocks migrating accounts in).
SMTP username and SSO_OIDC_ENABLED are set via the environment
because the nixpkgs module validates the config file at build time
without the secrets they require. Mail goes out as send-only
pds@lassul.us, a mailbox on neoprism's mailserver defined next to the
PDS.
When VictoriaMetrics has no sample shortly before the increase() window
(the monitoring server was down, or the series is new), MetricsQL's
increase() treats the first sample in the window as an increase from 0 and
returns the whole counter. After styx lost power for ~9 minutes, OOMKill
fired for coaxmetal with all 5 OOM kills since its boot 49 days ago,
although the counter never moved.
Only alert when the series has a sample in the minute before the window
(two minutes for the 60s smartctl scrape). Then increase() has a real
previous value, and still handles counter resets on a monitored host's
reboot. increase_pure() does not help: it assumes counters start at 0
and so counts the whole value as well.
NvmeMediaErrors uses increase() on a counter the same way and would
fire for any drive with past media errors after a gap, so it gets the
same guard. MemoryPressure's rate() is not affected: rate() does not
count the first sample in the window as an increase.
Mounts an empty auth_providers list to /config/auth_providers.yaml. homeassistant: auth_providers is read only from configuration.yaml, packages can't set it, so the volume's configuration.yaml includes it once (auth_providers: !include auth_providers.yaml under homeassistant:). Without the homeassistant provider only auth_oidc remains: the welcome page redirects straight to id.lassul.us and no password form is offered.
Long-lived access tokens have no credential and are not checked against a provider, so they keep working. Refresh tokens from earlier password logins fail on the next refresh and need one id.lassul.us login. The password credentials stay in the auth store, so a revert brings the password login back.
Zitadel project hass on neoprism with a public app (PKCE, no secret) and access = "managed": users get access via a user grant an admin gives in the id.lassul.us console. On acheron the auth_oidc custom component from nixpkgs is mounted into the Home Assistant container and configured via /config/packages, next to the username/password login.
modules.zitadel gets authMethod = "none" for public clients: reconcile generates no secret for them and writes only the client id to the env file, so nothing secret has to reach acheron.
Zitadel generates the client id, so it cannot be known at eval time on acheron. It is not secret; after neoprism is deployed it is copied from /var/lib/zitadel-apps/hass-web.env into the prompted hass-oidc var and passed to the container as HASS_OIDC_CLIENT_ID (!env_var in the yaml).
automatic_user_linking reuses the existing HA user whose username equals the preferred_username claim instead of creating a new one; it stays on because only users granted the project can log in. The groups scope is not requested since Zitadel has no groups claim.
Each machine gets a backup.nix with its storage box sub-account
(coaxmetal sub2, styx sub3, acheron sub4, the yellow container sub5);
what is backed up is declared next to the services:
- coaxmetal: the hermes home without caches, toolchains, git clones,
node_modules and target dirs. Every *.db under .hermes (except cache/),
also in subdirs such as cron/ and the matrix crypto store, is dumped
with sqlite3 .backup into /var/backup/hermes, recreated each run so
removed databases don't linger; the live files are excluded.
- styx: n8n's DynamicUser state (/var/lib/private/n8n), its default
SQLite database dumped.
- acheron: zigbee2mqtt (devices, coordinator backup; database.db is JSON)
and mosquitto's retained messages as plain copies.
- yellow: jellyfin, sonarr, radarr, prowlarr, jellyseerr and sabnzbd
configs with their SQLite databases dumped. Excluded: jellyfin metadata,
logs and transcodes, servarr MediaCover and logs (also logs.db),
jellyseerr cache and logs, sabnzbd's incomplete Downloads and logs.
Media and downloads live in /var/download and are not backed up. A
missing database fails the hook instead of sqlite3 creating an empty one.
Grafana (coaxmetal, styx) is not backed up: datasources and dashboards
are provisioned from the repo, admin password and secret key come from
vars, anonymous users are viewers; both instances only have the four
provisioned dashboards.
Every machine now backs up into its own Hetzner storage box sub-account
(isolation: one machine's ssh key can't read or delete another's
archives), into ./borg in the sub-account's home. lass.backup.user replaces
lass.backup.repo and enables the job; host, port 23 and the box's host key
are module defaults, so a machine's backup.nix is only the sub-account.
Repos are always encrypted (repokey-blake2). The passphrase comes from a
per-machine clan vars generator borgbackup-repokey; it lives in the
password store, so passphrase + repo are enough to restore. The borgbackup
ssh key generator is unchanged.
neoprism moves to a new repo on u550643-sub1 (doInit creates it); the old
unencrypted repo u550643@...:/./neoprism is no longer referenced and is
removed by hand. Its paths, excludes and hooks are unchanged. The job stays
`hetzner`: with fresh repos nothing gains from a rename, and the unit name
is used for the postBackup hooks.
The timers get a fixed random delay of up to 1h after 03:00, so the
machines sharing the box don't all start at once.
lass.backup.state.<name> lets every service declare its folders, excludes,
pre/post backup hooks and the paths its pre hook writes to next to its
definition. The borg job (still `hetzner`, so archive names and pruning
continue) and the borgbackup ssh key generator exist only on machines that
set lass.backup.repo; neoprism's backup.nix now only points to the storage
box.
Pre hooks run inside the borg unit as before; each runs on its own with
pipefail, all of them run and the job fails if any did (a failing pg_dump
used to go unnoticed behind the gzip pipe). Post hooks run in ExecStopPost
as root, also after a failed backup.
The four bridge dumps are split from the old loop into the bridges' files.
The dump subdirs are created by the hooks instead of tmpfiles. hedgedoc's
uploads directory is backed up now.
The imgur items (hedgedoc image uploads) are backed up now too.
The voice model sometimes answered a repeated light command with "on it" without emitting a delegation, so the backend never saw it. The persona now says the voice model can do nothing itself, that every action request is delegated every time, and that it may only say it is on it together with the delegation.
The Android app holds the call itself instead of opening the page in a
Custom Tab: hold the headset's voice button (HFP AT+BVRA=1 starts
VOICE_COMMAND), talk, short press to hang up, all with the phone locked
and without looking at it.
- VoiceCall: the page's Call in Java over WebRTC (webrtc-sdk/android):
delegation to Hermes, voice approvals, end_call, idle hang-up.
- CallService: a self-managed Telecom call in the foreground, so the
audio goes to the headset and its hang-up (AT+CHUP) ends the call; a
tone says it listens.
- Screens: the current conversation with Call / Hang up, the list of
conversations, and the settings (app token, permissions).
- hermes-voice accepts the app's own token (--app-token-file, clan var
hermes-voice-app on coaxmetal); assist.lassul.us/app/ passes it
through without the web login.
- The page keeps Talk / Hang up in view: only the transcript scrolls.
assetlinks.json, the TWA and the page's ?listen go away with the
Custom Tab.
The app holds each call of the page as a self-managed Telecom call
(transactional API, foreground service), so a headset's AT+CHUP reaches
it. Page and app talk over the Custom Tabs postMessage channel: the page
reports started/ended, the app sends hangup. assetlinks.json grants the
app use_as_origin for that.
.github/workflows/assist-android.yml builds packages.hermes-voice-android
on master pushes that touch it (and on demand) with the run number as
versionCode and the short commit as versionName, signs it with
hermes-voice-android-sign from the ANDROID_KEYSTORE_B64 and
ANDROID_KEYSTORE_PASSWORD secrets (the clan var
hermes-voice-android-signing of coaxmetal) and uploads it as assist.apk
of the rolling release assist-android.
The package takes versionCode and versionName (default 1), so
`nix build .#hermes-voice-android` stays pure; CI overrides them through
--impure --expr. The unsigned APK is also a check, so PR CI builds it.
assist.lassul.us now sits behind oauth2-proxy on neoprism (provider
oidc, issuer https://id.lassul.us), wired in with nginx auth_request on
that vhost only. The nixpkgs nginx helper (oauth2-proxy.nginx) is not
used because it turns on services.nginx.recommendedProxySettings for
every vhost on the host; the same few locations are written out here.
Unauthenticated requests are redirected (307) to /oauth2/start with a
relative rd, which oauth2-proxy accepts without a whitelist-domain;
the provider button is skipped so it goes straight to Zitadel.
Access: the Zitadel project "assist" is declarative with a grant for
lass@lassul.us only, and its projectRoleCheck blocks everyone else at
the IdP. oauth2-proxy additionally allows only that address
(authenticated-emails-file, no email domains); its OIDC provider rejects
emails whose email_verified is false. The session user is the `sub`
claim. The client id/secret come from zitadel-reconcile's env file
(/var/lib/zitadel-apps/assist-web.env, OAUTH2_PROXY_CLIENT_ID/SECRET),
used as the unit's EnvironmentFile; the cookie secret from a new
neoprism vars generator (oauth2-proxy/cookie-secret, via LoadCredential).
Public without login, as Chrome fetches them without cookies to install
the page and verify the TWA: /.well-known/assetlinks.json,
/manifest.webmanifest, /icon-192.png, /icon-512.png.
After the login, nginx sets Authorization: Bearer <access token> towards
hermes-voice, from an include in /run written at boot by a root oneshot
(hermes-voice-auth-header, owned by nginx, mode 0600), so the token
never enters the store. For that the hermes-voice generator is now a
shared var (configs/hermes-voice-vars.nix, imported by coaxmetal and
neoprism); a shared generator gets a new value, so the token is rotated.
hermes-voice keeps checking the token, which still guards its retiolum
port; the gateway plugin keeps calling /approve on localhost with
API_SERVER_KEY.
The page no longer asks for or stores the token. Its fetches use
redirect: "manual", so an expired login (redirect to id.lassul.us,
which fetch cannot follow cross-origin) shows up as an opaqueredirect
and the page reloads into the login.
packages.hermes-voice-android builds an unsigned APK (us.lassul.assist,
"Assist") that opens https://assist.lassul.us/ as a Trusted Web
Activity, so Chrome handles the mic permission and the stored access
token exactly as in the browser. Its single activity is the launcher
entry and handles android.intent.action.ASSIST. That is enough to be
offered under Default apps > Digital assistant app: the ASSISTANT
role's AssistantRoleBehavior (packages/modules/Permission) qualifies a
package either by an assist-capable VoiceInteractionService or by an
exported activity resolving ACTION_ASSIST, with no system-app check.
The activity binds the first browser offering the TWA category, opens
a session and launches the TWA intent; without such a browser it opens
a Custom Tab, which other browsers treat as a plain VIEW intent. It
stays below the page in the task and finishes when returned to.
The build is plain aapt2 + javac + d8 from androidenv (build-tools and
platform 36; the SDK is unfree, so androidenv comes from a nixpkgs
instance with allowUnfree and the license accepted). The only library
is androidx.browser 1.10.0's classes.jar (AAR from Google Maven, 289
KB): the client, session and TWA intent builder used here need none of
its dependencies (androidx.core is only referenced by launchUrl-style
helpers, which are not called). The APK is 72 KB and byte-identical
across rebuilds (fixed zip timestamps).
packages.hermes-voice-android-sign zipaligns and signs it with
apksigner (v1-v3, no .idsig) from a keystore and password file, by
default the packaged unsigned APK, and prints the certificate so it
can be compared with assetlinks.json.
The voice page gets a web app manifest (standalone, colors of
style.css) and 192/512 px icons, drawn with imagemagick at build time
(date chunks excluded so the PNGs are reproducible). Both are needed
for the page to run as a Trusted Web Activity in the Android wrapper.
/.well-known/assetlinks.json delegates handle_all_urls to the wrapper
(us.lassul.assist) with the SHA-256 fingerprint of its signing
certificate, so Chrome verifies the app and drops the URL bar. Like
the static files it is served without the access token, and neoprism
already proxies every path of assist.lassul.us.
The signing key comes from the new hermes-voice-android-signing
generator on coaxmetal: a PKCS12 keystore and its password (secret,
deploy = false since only signing reads them) and the fingerprint as a
public file. The package reads the fingerprint from vars/ with
pathExists like the nix-cache key, so evaluation works before the
generator ran; assetlinks.json is then simply missing (404), which is
why the static routes now answer 404 for files not in the static dir.
Paperless-ngx at https://paperless.lassul.us, behind nginx with ACME
(client_max_body_size 100M for phone photo uploads). OCR in German and
English; the nixpkgs module builds tesseract with the languages from
PAPERLESS_OCR_LANGUAGE. Data stays at the module's default
/var/lib/paperless (static paperless user, no DynamicUser) with SQLite.
Login via Zitadel (id.lassul.us): lass.zitadel project "paperless" with
access = "declarative", so only users granted on the project (by
verified email) can log in. allauth's openid_connect provider keys
social accounts by uid_field, set explicitly to the token's sub.
paperless' environmentFile is a single env file and the client secret
has to sit inside the PAPERLESS_SOCIALACCOUNT_PROVIDERS JSON, so the
paperless-oidc-env oneshot builds it from zitadel-reconcile's env file
into /run/paperless-oidc, keeping the secret out of the store.
The regular login form is disabled; DRF token and basic auth do not go
through allauth's pre_authenticate, so API tokens keep working. The
admin password comes from the paperless-admin vars generator via the
module's passwordFile (LoadCredential).
Backups: /var/lib/paperless plus a consistent .backup of its WAL-mode
SQLite database (the live db files are excluded).
Mailbox scan@lassul.us with a vars-generated password
(mailserver-scan) for paperless' IMAP mail fetching, configured later
in paperless' database.
DNS: paperless CNAME neoprism like the other neoprism services; Gatus
checks the login page.
nixos-unstable has omp 18.11.0, whose fetchFromGitHub hash no longer
matches the tarball GitHub serves (Hydra rejects the output as well), so
coaxmetal, neoprism and barnacle fail to build. Pin the version from
nixpkgs master (18.11.2: new src and cargoDeps hashes, node_modules hash
unchanged) as an override of nixpkgs' omp. The assert breaks evaluation
as soon as nixpkgs moves away from 18.11.0, so the pin gets dropped with
the next flake update that brings a fixed omp.
Zitadel asks every user with an unverified address (registration, all
byoidc logins) for a code before the login finishes. Without an SMTP
provider the code was never sent (Errors.SMTPConfig.NotFound) and these
users were stuck. They now come from the send-only account id@lassul.us
on the mailserver, over TLS on mail.lassul.us:465.