Files
Yggdrasil/README.md
T
Klagarge fc1b45058c feat: shiroa book, ACL parser, and OIDC-gated Go server
- 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
2026-10-10 18:42:40 +02:00

194 lines
7.9 KiB
Markdown

# Yggdrasil
A personal knowledge base written in [Typst](https://typst.app), compiled to
a static site by [shiroa](https://github.com/Myriad-Dreamin/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
```mermaid
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 in `docs/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/parser` scans `docs/`, extracts the tags and generates
`acl.json` mapping final page URLs to allowed groups.
- The Go server embeds both `dist/` and `acl.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 `groups` claim 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](https://github.com/casey/just), and
[shiroa](https://github.com/Myriad-Dreamin/shiroa/releases) on your `PATH`.
```sh
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:
1. Create the file, e.g. `docs/security/wireguard.typ`, starting with:
```typst
#import "../template.typ": project
#show: project
= WireGuard
Your content...
```
2. Reference it in `docs/book.typ`. A `=` heading creates a category
(part) in the sidebar; each `- #chapter(...)` is a page:
```typst
= Security
- #chapter("security/ssh.typ", section: "4")[SSH hardening]
- #chapter("security/wireguard.typ", section: "5")[WireGuard]
```
3. Only pages listed in `book.typ` are built. Keep `section:` numbers
consistent (sub-chapters like `"4.1"` nest under `"4"`).
To restrict a page, add the tag on the first line of its file:
```typst
// Only readable by members of these groups.
#let access-groups = ("family", "admin")
```
- The groups are compared against the `groups` claim 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.in` files, 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
1. **Applications -> Providers -> Create**: type *OAuth2/OpenID Connect*.
2. Authorization flow: *implicit/explicit consent* (default is fine).
3. Redirect URI: exactly `https://your-docs-host/callback`.
4. Client type: *Confidential*; note the client id and secret.
5. **Scope mappings**: make sure the issued ID token contains a `groups`
claim. Recent Authentik versions ship a scope mapping that adds
`groups`; 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.
6. Note the issuer URL shown on the provider page
(`.../application/o/<slug>/`) and set `OIDC_ISSUER_URL` to it.
7. Create groups in **Directory -> Groups** (e.g. `family`, `admin`) whose
names match the `access-groups` in 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:
```sh
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.json` is 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.