- docs/: shiroa book with three example categories (thermo, security, homelab); pages declare access via '#let access-groups = (...)' - cmd/parser: scans docs/ and generates acl.json (public by default, skips book.typ/template.typ/partials) - internal/acl: loads acl.json, normalizes URLs to page keys so a page's renderer data (*.multi.sir.in) inherits its protection; fail closed on malformed input - internal/oidc: authorization-code flow client, verified ID tokens, groups claim from Authentik - internal/middleware: in-memory sessions, login/logout/callback handlers, CSRF states, access gate + security headers - cmd/server + embed.go: single binary embedding dist/ and acl.json; /searchindex.json blocked; graceful shutdown - Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README - legacy docs moved to legacy_docs/; .idea untracked
7.9 KiB
Yggdrasil
A personal knowledge base written in Typst, compiled to a static site by shiroa, and served by a small Go binary that embeds the whole site and gates protected pages behind OIDC login (Authentik) with per-page group permissions.
No search, no database, no Node.js: one binary, one configuration.
How it works
flowchart TD
A["docs/*.typ (Typst sources)"] -->|"just build-docs (shiroa)"| B["dist/ (static site)"]
A -->|"just parse (cmd/parser, access-groups tags)"| C["acl.json"]
B -->|"go build (//go:embed)"| E["server binary"]
C --> E
E --> F{"request /page.html"}
F -->|"public page"| G["serve page"]
F -->|"protected page"| H{"session + groups ok?"}
H -->|"no session"| I["redirect to /login, then Authentik"]
H -->|"not authorized"| J["403"]
H -->|"authorized"| G
- Pages are written in Typst under
docs/and listed indocs/book.typ. - A page can declare who may read it with a tag:
#let access-groups = ("family", "admin"). Pages without a tag are public. - At build time,
cmd/parserscansdocs/, extracts the tags and generatesacl.jsonmapping final page URLs to allowed groups. - The Go server embeds both
dist/andacl.json. For every request it reduces the URL to a page key and looks it up in the ACL: public pages are served directly; protected pages require a session whose groups include at least one of the allowed groups. - Users authenticate with OIDC. The server reads the
groupsclaim of the verified ID token to build the session.
Repository layout
docs/ Typst sources (shiroa book)
book.typ book metadata + chapter list (the sidebar)
template.typ page layout (from the shiroa minimal template)
index.typ home page (public)
thermo/, security/, homelab/ example categories
cmd/parser/ generates acl.json from access-groups tags
cmd/server/ the web server
internal/acl/ ACL loading, page-key normalization, group checks
internal/oidc/ OIDC client (authorization code flow)
internal/middleware/ sessions, login/logout/callback, access gate
dist/ compiled site (generated, embedded at build time)
acl.json generated ACL (embedded at build time)
legacy_docs/ the old documentation base (kept for manual migration)
.gitea/workflows/ Gitea Actions CI
Quick start
Prerequisites: Go 1.22+, just, and
shiroa on your PATH.
just run # parse ACL, compile the book, build and start the server
Then open http://localhost:8080. The home page and public pages render;
protected pages (/thermo/fluids.html, /security/ssh.html,
/homelab/backup.html in the example book) return 503 until OIDC is
configured.
For pure writing previews without authentication, use just preview-docs.
Writing documentation
The book structure mirrors the URL structure: docs/<path>.typ is compiled
to /<path>.html. To add a page:
-
Create the file, e.g.
docs/security/wireguard.typ, starting with:#import "../template.typ": project #show: project = WireGuard Your content... -
Reference it in
docs/book.typ. A=heading creates a category (part) in the sidebar; each- #chapter(...)is a page:= Security - #chapter("security/ssh.typ", section: "4")[SSH hardening] - #chapter("security/wireguard.typ", section: "5")[WireGuard] -
Only pages listed in
book.typare built. Keepsection:numbers consistent (sub-chapters like"4.1"nest under"4").
To restrict a page, add the tag on the first line of its file:
// Only readable by members of these groups.
#let access-groups = ("family", "admin")
- The groups are compared against the
groupsclaim of the OIDC ID token (in Authentik: your user's group memberships). - A page without the tag is public.
- Everything served for a protected page is protected too: the shiroa
renderer fetches page content from
*.multi.sir.infiles, and those reduce to the same ACL entry as the page itself. - The search index (
searchindex.json) would also contain protected text, so the server never serves it.
Configuration
Environment variables (see .env.example):
| Variable | Default | Meaning |
|---|---|---|
LISTEN_ADDR |
:8080 |
listen address |
SESSION_TTL |
12h |
session lifetime |
COOKIE_SECURE |
true |
Secure cookie flag; use false only on plain-http local tests |
OIDC_ISSUER_URL |
(unset) | e.g. https://auth.example.com/application/o/yggdrasil/ |
OIDC_CLIENT_ID |
(unset) | OAuth2 client id |
OIDC_CLIENT_SECRET |
(unset) | OAuth2 client secret |
OIDC_REDIRECT_URL |
(unset) | e.g. https://docs.example.com/callback |
OIDC_SCOPES |
openid profile email groups |
requested scopes |
Without the OIDC_* variables the server runs in public-only mode:
public pages are served, protected pages answer 503. The server refuses to
start on a malformed acl.json or an unreachable OIDC issuer (fail closed).
Setting up Authentik
- Applications -> Providers -> Create: type OAuth2/OpenID Connect.
- Authorization flow: implicit/explicit consent (default is fine).
- Redirect URI: exactly
https://your-docs-host/callback. - Client type: Confidential; note the client id and secret.
- Scope mappings: make sure the issued ID token contains a
groupsclaim. Recent Authentik versions ship a scope mapping that addsgroups; otherwise create a Property Mapping (Scope) with e.g.return {"groups": [g.name for g in user.ak_groups.all()]}and add it to the provider. - Note the issuer URL shown on the provider page
(
.../application/o/<slug>/) and setOIDC_ISSUER_URLto it. - Create groups in Directory -> Groups (e.g.
family,admin) whose names match theaccess-groupsin your pages, and add users to them.
The redirect after login returns the user to the page they originally
requested. Logout is available at /logout.
Build and CI
Local commands (see Justfile): just build, just run, just parse,
just check, just clean, just preview-docs.
.gitea/workflows/build.yml (Gitea Actions, not GitLab CI) runs on push to
main and on pull requests: installs shiroa, regenerates acl.json,
compiles the book, runs go vet/go test, builds the static binary and
uploads it as an artifact.
A rootless Docker/Podman image is available:
just parse build-docs
docker build -t yggdrasil .
docker run --rm -p 8080:8080 --env-file .env yggdrasil
Note that the binary embeds the site at build time: publishing new or edited pages always requires rebuilding and redeploying the server.
Security notes and limitations
- Sessions live in memory: they vanish on restart and do not scale past one instance (users simply log in again).
- Page titles and URLs of protected pages are visible in the sidebar of public pages (that is inherent to a static site). Page content is not: neither the HTML nor the renderer data files can be fetched without authorization.
- Directory listing is enabled (file names only, e.g.
/thermo/). - Logout is a GET request (
/logout), like most documentation sites. - No search is implemented (
searchindex.jsonis explicitly blocked). - Static assets referenced by pages (theme CSS/JS, fonts, renderer wasm) are public; they contain no page content.
Legacy documentation
The previous documentation base lives in legacy_docs/ and is kept only
for reference; migrate pages into docs/ manually, then delete the folder.