Clone this repository
For self-hosted knots, clone URLs may differ based on your setup.
idps.<name>.type = "oidc" with an issuer, a literal clientId as
alternative to clientIdFile, a display name independent of the attribute
name and per-IdP providerOptions (default unchanged: linking, creation and
auto update allowed). Generic OIDC IdPs use PKCE and ID token mapping.
Zitadel 2.71 does not fetch the issuer's discovery document when the IdP
is created, only at login, so the reconciler needs no ordering on the
provider.
byoidc lets people sign in with their own OpenID provider; Zitadel will
offer it as one generic OIDC IdP. The downstream client "zitadel" gets its
secret from a prompt-less clan var, which root-owned is fine for both
byoidc (LoadCredential) and zitadel-reconcile (root).
Its state (cached upstream clients, signing key) is not backed up: subjects
are derived from issuer and upstream sub, a new signing key is picked up
through JWKS, and upstream clients are registered again on demand.
id.lassul.us is Zitadel now; its endpoints are /oauth/v2/authorize,
/oauth/v2/token and /oidc/v1/userinfo (from its discovery document). The
pad project is public: every registered user may log in. zitadel-reconcile
creates the HedgeDoc app and writes its client id/secret into the env file
HedgeDoc reads, so there is no prompt var for them; hedgedoc starts after
it. HedgeDoc posts the client secret in the token request body, hence the
POST auth method.
lass.zitadel describes Zitadel's login policy, its external IdPs and one
project per service with OIDC apps and an access mode:
- public: no "check authorization on authentication", every registered
user may log in.
- declarative: the check is on and the project's user grants are exactly
the listed users (login name or verified email address). Removing a
user from the list revokes the grant; grants given in the console are
removed as well.
- managed: the check is on, the grants are left to the console.
zitadel-reconcile, a oneshot after zitadel, applies it at boot and on
every deploy that changes it. It authenticates with the PAT of a service
user (IAM_OWNER) that zitadel's setup creates with the first instance
(FirstInstance.Org.Machine, PatPath). Setup writes that file world
readable, so /var/lib/zitadel becomes a 0700 state directory. An empty Pat
section creates no token, hence the explicit expiration date (Zitadel's
own default).
A small Python script against the admin/management v1 REST APIs rather
than OpenTofu with the zitadel provider:
- "declarative" has to own all grants of a project, including ones made
in the console; terraform only removes what is in its state.
- Users are listed before they register. A failed user lookup would fail
the whole plan; the script skips such users until the next run.
- No state file that has to match Zitadel: objects are found by name, so
e.g. an app deleted in the console is created again.
- The provider talks gRPC to the external domain, through nginx and TLS;
the script talks plain HTTP to 127.0.0.1 with the Host header.
- Projects and apps removed from the config stay in Zitadel instead of
being destroyed together with their grants.
Zitadel shows a client secret only when it creates one. The script keeps a
root-only copy per app in /var/lib/zitadel-apps and renders the app's env
file from it (path, owner and variable names configurable); without a copy
for the current client id it generates a new secret. Services on other
hosts find their secret there too, for now.
The login policy moves from DefaultInstance, which only applies when the
instance is created, to lass.zitadel.loginPolicy. GitHub and Google logins
come from persisted prompt vars; an empty prompt leaves the IdP out, so
the first deploy works before the OAuth apps exist. Their callback is
/ui/login/login/externalidp/callback (login UI v1, the default in 2.71).
Users may create accounts with them and link them to existing ones.
The script waits for /admin/v1/healthz rather than /debug/ready: the HTTP
gateway's connection to the gRPC API comes up later.
Zitadel 2.71 from nixpkgs as a federated IdP with open self-registration:
registration, external IdPs and passkeys allowed in the default login
policy, first instance "lassul.us" with admin user lass (lass@lassul.us).
The masterkey and the admin's initial password come from the zitadel clan
vars generator, the password via an extra steps file so it never hits the
store.
Postgres via peer auth over the local socket. ensureDatabases/ensureUsers
create the database and owner role, so the zitadel role gets no
CREATEROLE/CREATEDB (on PG15 CREATEROLE is close to superuser). That needs
`init zitadel` + start-from-setup instead of the module's start-from-init,
whose init always tries CREATE USER/CREATE DATABASE and fails on the
permission check before the "already exists" check.
nginx terminates TLS and talks h2c to Zitadel via grpc_pass, as Zitadel's
reverse-proxy docs require for its gRPC APIs. Machine identification by
hostname, since the default private-IP/GCP-webhook probes don't fit
neoprism.
The database is pg_dumped by the borg preHook; gatus checks
/debug/healthz. Zitadel 2.71 does not work with PostgreSQL 18 (migration
34_add_cache_schema creates unlogged partitioned tables), neoprism runs 15.
For when.lassul.us (timeful) to show lass's availability without event
details, at https://cal.lassul.us/busy/<token>.ics.
With --busy-token-file, GET /busy/<token>.ics returns the busy time book
itself uses for free slots (CalDAV.busy plus held requests), so the feed
and the booking page agree on what blocks time: recurrences expanded,
all-day events from local midnight, TRANSPARENT and CANCELLED events
left out, TENTATIVE events (own ones and pending booking requests) kept
as busy, since book holds those slots too.
Intervals are merged so overlapping events cannot be counted, and each
VEVENT carries only DTSTART/DTEND in UTC, DTSTAMP (required by RFC
5545), SUMMARY "Busy", TRANSP OPAQUE and a UID hashed from its times.
The window runs from 7 days back to 90 days ahead: book's own 28 day
horizon is too short for polls planned weeks ahead, and the past week
keeps polls that are still open showing sensible data.
The feed is generated per request, so it is as fresh as the calendar;
an unreadable calendar gives 503, never an empty feed. Any other path
under /busy/ gives 404; the token is compared in constant time.
configs/book.nix: the token comes from the prompt-less vars generator
busy-feed and reaches book as a systemd credential, so it never lands in
the Nix store; nginx proxies all of cal.lassul.us/busy/ to book.
book.lassul.us returns 404 for /busy/ so the feed has a single URL.
In the benchmark run (PR 68) the neoprism job spent 29 s between
fetching lazy-store and mounting: the dry-run for the paths to
substitute (13 s in the shared job for the same machine) and their
narinfo closure (the rest, 2,878 paths), both after the 21 s hestia
prefetch of the drvs, which the dry-run needs.
The eval job has the drvs and dry-runs the machines anyway for the
shared derivations; it now keeps each machine's "will be fetched" list
and uploads it with the shared ones. The machine job collects the
closure of that list in the background while hestia prefetches the
drvs, then mounts. Computing the closure in the eval job too would only
move its time onto the eval job, before every machine job starts.
For a single machine the eval job now dry-runs it (about 13 s), against
about 29 s less in the machine job; with more machines the dry-runs ran
already, and in parallel.
The shared job dry-ran every machine to find the derivations needed by
more than one, after a runner start and a prefetch of all drv closures
from hestia. In the benchmark run (PR 68, only neoprism uncached) it
built nothing and still cost the machine job 61 s: 5 s queueing, 6 s
setup, 21 s prefetch (mostly the 47 MiB nixpkgs source), 13 s dry-run,
then 6 s queueing of the machine job.
The eval job has the drvs already, so it dry-runs the machines itself,
in parallel, and only for two or more (one machine shares nothing). It
hands the list to the shared job as an artifact; with an empty list the
shared job is skipped, and the machine jobs wait for the manifest
version of the eval job instead of the shared job's.