- 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
194 lines
7.9 KiB
Markdown
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.
|