From fc1b45058cb45f2d622ec7231eb4eea886fba1ae Mon Sep 17 00:00:00 2001 From: Klagarge Date: Sat, 10 Oct 2026 18:42:40 +0200 Subject: [PATCH] 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 --- .dockerignore | 6 + .env.example | 17 ++ .gitea/workflows/build.yml | 45 ++++ .gitignore | 15 ++ .idea/Yggdrasil.iml | 8 - .idea/codeStyles/codeStyleConfig.xml | 5 - .idea/discord.xml | 14 -- .idea/editor.xml | 329 ------------------------- .idea/modules.xml | 8 - .idea/runConfigurations/Docsify.xml | 17 -- .idea/vcs.xml | 12 - .idea/workspace.xml | 139 ----------- Dockerfile | 16 ++ Justfile | 36 +++ README.md | 194 ++++++++++++++- acl.json | 13 +- cmd/parser/main.go | 119 +++++---- cmd/server/main.go | 120 +++++++++ dist/.gitkeep | 0 docs/book.typ | 22 ++ docs/homelab/backup.typ | 11 + docs/homelab/self-hosting.typ | 7 + docs/index.typ | 17 ++ docs/security/opsec.typ | 7 + docs/security/ssh.typ | 11 + docs/template.typ | 44 ++++ docs/thermo/fluids.typ | 11 + docs/thermo/heat-pumps.typ | 7 + embed.go | 26 ++ go.mod | 6 +- go.sum | 2 + internal/acl/acl.go | 113 +++++++-- internal/acl/acl_test.go | 67 +++++ internal/middleware/access.go | 89 +++++++ internal/middleware/handlers.go | 141 +++++++++++ internal/middleware/middleware_test.go | 50 ++++ internal/middleware/sessions.go | 101 ++++++++ internal/oidc/oidc.go | 118 +++++++++ 38 files changed, 1366 insertions(+), 597 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore delete mode 100644 .idea/Yggdrasil.iml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/discord.xml delete mode 100644 .idea/editor.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/runConfigurations/Docsify.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/workspace.xml create mode 100644 Dockerfile create mode 100644 dist/.gitkeep create mode 100644 docs/book.typ create mode 100644 docs/homelab/backup.typ create mode 100644 docs/homelab/self-hosting.typ create mode 100644 docs/index.typ create mode 100644 docs/security/opsec.typ create mode 100644 docs/security/ssh.typ create mode 100644 docs/template.typ create mode 100644 docs/thermo/fluids.typ create mode 100644 docs/thermo/heat-pumps.typ create mode 100644 embed.go create mode 100644 internal/acl/acl_test.go create mode 100644 internal/middleware/access.go create mode 100644 internal/middleware/handlers.go create mode 100644 internal/middleware/middleware_test.go create mode 100644 internal/middleware/sessions.go create mode 100644 internal/oidc/oidc.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad02f84 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.delta +.idea +.vscode +docs +legacy_docs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8d87fe9 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Server configuration. Copy to .env (gitignored), export or source it. + +# Address the server listens on. +LISTEN_ADDR=:8080 +# Session lifetime. +SESSION_TTL=12h +# Set to false only when testing locally over plain http. +COOKIE_SECURE=false + +# OIDC (Authentik). Unset: public pages still work, protected pages answer 503. +OIDC_ISSUER_URL=https://auth.example.com/application/o/yggdrasil/ +OIDC_CLIENT_ID=yggdrasil +OIDC_CLIENT_SECRET=change-me +OIDC_REDIRECT_URL=http://localhost:8080/callback +# Comma/space separated scopes. The groups claim must be covered by a scope +# mapping on the provider for group checks to work. +# OIDC_SCOPES=openid profile email groups diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e69de29..cd92699 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -0,0 +1,45 @@ +name: build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # shiroa bundles its own Typst compiler, so no separate typst install. + - name: Install shiroa + run: | + curl -sSL -o /tmp/shiroa.tar.gz \ + https://github.com/Myriad-Dreamin/shiroa/releases/download/v0.4.0-rc2/shiroa-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/shiroa.tar.gz -C /tmp + install -m 755 /tmp/shiroa-x86_64-unknown-linux-musl/shiroa /usr/local/bin/shiroa + shiroa --version + + - name: Generate acl.json + run: go run ./cmd/parser -in docs -out acl.json + + - name: Build the book + run: shiroa build docs --dest-dir ../dist + + - name: Test and build the server + run: | + go vet ./... + go test ./... + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o yggdrasil-server ./cmd/server + + - name: Upload server artifact + uses: actions/upload-artifact@v3 + with: + name: yggdrasil-server-linux-amd64 + path: yggdrasil-server diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b08e70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# build outputs (dist is regenerated by shiroa; .gitkeep keeps go:embed happy) +/dist/* +!/dist/.gitkeep +/server +yggdrasil-server + +# secrets +.env + +# editors +.idea/ +.vscode/ + +# delta workspace +/.delta/ diff --git a/.idea/Yggdrasil.iml b/.idea/Yggdrasil.iml deleted file mode 100644 index bc2cd87..0000000 --- a/.idea/Yggdrasil.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index a55e7a1..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/discord.xml b/.idea/discord.xml deleted file mode 100644 index 104c42f..0000000 --- a/.idea/discord.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/editor.xml b/.idea/editor.xml deleted file mode 100644 index 0ef9a5a..0000000 --- a/.idea/editor.xml +++ /dev/null @@ -1,329 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6f94d59..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Docsify.xml b/.idea/runConfigurations/Docsify.xml deleted file mode 100644 index aa5aa10..0000000 --- a/.idea/runConfigurations/Docsify.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 7ddfc9e..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index d4d5e65..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - { - "lastFilter": { - "state": "OPEN", - "assignee": "Klagarge" - } -} - - - - { - "selectedUrlAndAccountId": { - "url": "git@github.com:Klagarge/Yggdrasil.git", - "accountId": "23ae37cb-e333-4d8c-8c08-dde8fa58b070" - } -} - - - { - "associatedIndex": 0 -} - - - - - - - - - - - - - - - 1736105231146 - - - - - - - - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..959e3cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# Rootless image of the Yggdrasil server. +# Build after `just parse build-docs` so that dist/ and acl.json are current: +# just parse build-docs && docker build -t yggdrasil . + +FROM golang:1.27 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/server /server +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/server"] diff --git a/Justfile b/Justfile index e69de29..01c95d7 100644 --- a/Justfile +++ b/Justfile @@ -0,0 +1,36 @@ +# Yggdrasil: Typst documentation behind an authenticated Go server. +# Run `just` alone to build everything. + +# Build everything: acl.json, the static site, and the server binary. +default: build +build: parse build-docs build-server + +# Scan docs/ for access-groups tags and regenerate acl.json. +parse: + go run ./cmd/parser -in docs -out acl.json + +# Compile the Typst book into dist/ (requires shiroa on PATH). +build-docs: + shiroa build docs --dest-dir ../dist + +# Build the server binary (embeds dist/ and acl.json). +build-server: + CGO_ENABLED=0 go build -trimpath -o server ./cmd/server + +# Rebuild everything and run the server locally. +run: build + ./server + +# Live-preview the book without authentication (shiroa dev server). +preview-docs: + shiroa serve docs + +# Run static checks and tests. +check: + go vet ./... + go test ./... + @test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; } + +# Remove build artifacts (keeps the committed acl.json and dist/.gitkeep). +clean: + rm -rf dist server diff --git a/README.md b/README.md index df08371..ca54210 100644 --- a/README.md +++ b/README.md @@ -1 +1,193 @@ -# Yggdrasil Document Base +# 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/.typ` is compiled +to `/.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//`) 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. diff --git a/acl.json b/acl.json index 9e26dfe..ddb36b3 100644 --- a/acl.json +++ b/acl.json @@ -1 +1,12 @@ -{} \ No newline at end of file +{ + "/homelab/backup.html": [ + "family" + ], + "/security/ssh.html": [ + "admin" + ], + "/thermo/fluids.html": [ + "family", + "admin" + ] +} diff --git a/cmd/parser/main.go b/cmd/parser/main.go index 2581d8a..c898af8 100644 --- a/cmd/parser/main.go +++ b/cmd/parser/main.go @@ -1,3 +1,13 @@ +// Command parser scans a directory of Typst sources (a shiroa book) for +// access-group tags and generates the acl.json consumed by the server. +// +// A page declares its access groups with a Typst let binding at the top of +// the file: +// +// #let access-groups = ("family", "admin") +// +// Pages without a tag are public. The generated acl.json maps the final URL +// of each page (e.g. "/thermo/fluids.html") to its authorized groups. package main import ( @@ -12,69 +22,86 @@ import ( "strings" ) +// Matches: #let access-groups = ("group1", "group2") +var accessGroupsRe = regexp.MustCompile(`(?m)^\s*#let\s+access-groups\s*=\s*\(([^)]*)\)`) + +// Files that are never pages: book metadata, page layout, and partials +// (files starting with "_") imported by other pages. +func isSkipped(base string) bool { + return base == "book.typ" || base == "template.typ" || strings.HasPrefix(base, "_") +} + func main() { - inDir := flag.String("in", "docs", "Input directory containing .typ files") - outFile := flag.String("out", "acl.json", "Output acl.json file") + inDir := flag.String("in", "docs", "input directory containing the shiroa book (.typ sources)") + outFile := flag.String("out", "acl.json", "output acl.json file") flag.Parse() acl := make(map[string][]string) - - // Match: #let access-groups = ("group1", "group2") - // The regex handles optional spaces and varying number of groups. - re := regexp.MustCompile(`(?m)^#let\s+access-groups\s*=\s*\(([^)]*)\)`) - err := filepath.WalkDir(*inDir, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(*inDir, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } - if !d.IsDir() && filepath.Ext(path) == ".typ" { - content, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("reading %s: %w", path, err) - } - - relPath, err := filepath.Rel(*inDir, path) - if err != nil { - return err - } - - // Map .typ to .html in the final URL structure - urlPath := "/" + strings.TrimSuffix(relPath, ".typ") + ".html" - // Ensure forward slashes for URLs - urlPath = strings.ReplaceAll(urlPath, "\\", "/") - - matches := re.FindSubmatch(content) - if len(matches) > 1 { - groupsStr := string(matches[1]) - var groups []string - // Split by comma and clean up quotes/spaces - for _, g := range strings.Split(groupsStr, ",") { - g = strings.TrimSpace(g) - g = strings.Trim(g, `"'`) - if g != "" { - groups = append(groups, g) - } - } - if len(groups) > 0 { - acl[urlPath] = groups - } + base := d.Name() + if d.IsDir() { + // Skip build output and hidden directories. + if p != *inDir && (base == "dist" || strings.HasPrefix(base, ".")) { + return filepath.SkipDir } + return nil } + if filepath.Ext(base) != ".typ" || isSkipped(base) { + return nil + } + + content, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("reading %s: %w", p, err) + } + groups := extractGroups(content) + if len(groups) == 0 { + return nil // public page, no rule needed + } + + relPath, err := filepath.Rel(*inDir, p) + if err != nil { + return err + } + // docs/.typ compiles to dist/.html, served at /.html + urlPath := "/" + strings.TrimSuffix(filepath.ToSlash(relPath), ".typ") + ".html" + + acl[urlPath] = groups + log.Printf("protected %s -> %s", urlPath, strings.Join(groups, ", ")) return nil }) - if err != nil { - log.Fatalf("Error scanning directory: %v", err) + log.Fatalf("scanning %s: %v", *inDir, err) } - outData, err := json.MarshalIndent(acl, "", " ") + out, err := json.MarshalIndent(acl, "", " ") if err != nil { - log.Fatalf("Error generating JSON: %v", err) + log.Fatalf("encoding acl: %v", err) + } + out = append(out, '\n') + if err := os.WriteFile(*outFile, out, 0o644); err != nil { + log.Fatalf("writing %s: %v", *outFile, err) } - if err := os.WriteFile(*outFile, outData, 0644); err != nil { - log.Fatalf("Error writing %s: %v", *outFile, err) + fmt.Printf("generated %s (%d protected pages)\n", *outFile, len(acl)) +} + +func extractGroups(content []byte) []string { + m := accessGroupsRe.FindSubmatch(content) + if m == nil { + return nil + } + var groups []string + for _, part := range strings.Split(string(m[1]), ",") { + part = strings.TrimSpace(part) + part = strings.Trim(part, "\"'") + if part != "" { + groups = append(groups, part) + } } - - fmt.Printf("Successfully generated %s with %d rules\n", *outFile, len(acl)) + return groups } diff --git a/cmd/server/main.go b/cmd/server/main.go index e69de29..32ceade 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -0,0 +1,120 @@ +// Command server is the Yggdrasil web server: a single static binary that +// embeds the compiled documentation (dist/) and its access control list +// (acl.json), serves public pages directly, and gates protected pages +// behind OIDC authentication (Authentik). +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Klagarge/yggdrasil" + "github.com/Klagarge/yggdrasil/internal/acl" + "github.com/Klagarge/yggdrasil/internal/middleware" + "github.com/Klagarge/yggdrasil/internal/oidc" +) + +func main() { + listenAddr := envOr("LISTEN_ADDR", ":8080") + sessionTTL := envDuration("SESSION_TTL", 12*time.Hour) + cookieSecure := envOr("COOKIE_SECURE", "true") == "true" + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + list, err := acl.Load(yggdrasil.ACLData()) + if err != nil { + log.Fatalf("refusing to start: %v", err) // fail closed + } + + sessions := middleware.NewSessionStore(sessionTTL) + go sessions.StartJanitor(ctx, 10*time.Minute) + + auth := setupOIDC(ctx) + + dist, err := yggdrasil.DistFS() + if err != nil { + log.Fatalf("embedded dist: %v", err) + } + + gate := middleware.NewAccessControl(list, sessions, auth, cookieSecure) + + mux := http.NewServeMux() + mux.HandleFunc("GET /login", gate.Login) + mux.HandleFunc("GET /callback", gate.Callback) + mux.HandleFunc("GET /logout", gate.Logout) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + // The search index contains the text of protected pages: never serve it. + mux.HandleFunc("/searchindex.json", http.NotFound) + mux.Handle("/", gate.Wrap(http.FileServerFS(dist))) + + srv := &http.Server{ + Addr: listenAddr, + Handler: gate.SecurityHeaders(mux), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 2 * time.Minute, + } + + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + log.Printf("listening on %s (%d protected pages)", listenAddr, len(list)) + + select { + case err := <-errCh: + if !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} + +// setupOIDC builds the OIDC client from the environment. Without OIDC +// configuration the server still serves public pages; protected pages +// answer 503 instead of being exposed. +func setupOIDC(ctx context.Context) *oidc.Authenticator { + cfg, err := oidc.LoadConfigFromEnv() + if err != nil { + log.Printf("OIDC disabled (%v) - protected pages will answer 503", err) + return nil + } + auth, err := oidc.NewAuthenticator(ctx, cfg) + if err != nil { + log.Fatalf("OIDC setup: %v", err) + } + log.Printf("OIDC enabled (issuer: %s)", cfg.IssuerURL) + return auth +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func envDuration(key string, def time.Duration) time.Duration { + if v := os.Getenv(key); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + log.Printf("invalid %s %q, using %s", key, v, def) + } + return def +} diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/book.typ b/docs/book.typ new file mode 100644 index 0000000..2670899 --- /dev/null +++ b/docs/book.typ @@ -0,0 +1,22 @@ +#import "@preview/shiroa:0.4.0": * +#show: book + +#book-meta( + title: "Yggdrasil", + authors: ("Klagarge",), + summary: [ + #prefix-chapter("index.typ")[Home] + + = Thermodynamics + - #chapter("thermo/heat-pumps.typ", section: "1")[Heat pumps] + - #chapter("thermo/fluids.typ", section: "2")[Fluid mechanics] + + = Security + - #chapter("security/opsec.typ", section: "3")[Opsec basics] + - #chapter("security/ssh.typ", section: "4")[SSH hardening] + + = Homelab + - #chapter("homelab/self-hosting.typ", section: "5")[Self-hosting] + - #chapter("homelab/backup.typ", section: "6")[Backups] + ], +) diff --git a/docs/homelab/backup.typ b/docs/homelab/backup.typ new file mode 100644 index 0000000..72acbd8 --- /dev/null +++ b/docs/homelab/backup.typ @@ -0,0 +1,11 @@ +// Access control: only members of these Authentik groups can read this page. +// Delete this line to make the page public. +#let access-groups = ("family",) + +#import "../template.typ": project +#show: project + += Backups + +Protected page placeholder about backups: the 3-2-1 rule, restic, +off-site replication... diff --git a/docs/homelab/self-hosting.typ b/docs/homelab/self-hosting.typ new file mode 100644 index 0000000..f496e30 --- /dev/null +++ b/docs/homelab/self-hosting.typ @@ -0,0 +1,7 @@ +#import "../template.typ": project +#show: project + += Self-hosting + +Public page placeholder about self-hosting: Docker/Podman, reverse +proxies, DNS, VLANs... diff --git a/docs/index.typ b/docs/index.typ new file mode 100644 index 0000000..1a27fe3 --- /dev/null +++ b/docs/index.typ @@ -0,0 +1,17 @@ +#import "template.typ": project +#show: project + += Yggdrasil + +Welcome to the Yggdrasil knowledge base, built with Typst, Shiroa and a Go +server. + +*How it works:* + +- Pages are written in Typst under `docs/` and compiled to static HTML. +- A Go server embeds the HTML and an access-control list (`acl.json`). +- Pages tagged with `#let access-groups = ("family", "admin")` are only + visible to authenticated users in those groups; everything else is + public. + +Browse the categories in the sidebar to get started. diff --git a/docs/security/opsec.typ b/docs/security/opsec.typ new file mode 100644 index 0000000..7693a6a --- /dev/null +++ b/docs/security/opsec.typ @@ -0,0 +1,7 @@ +#import "../template.typ": project +#show: project + += Opsec basics + +Public page placeholder about operational security: threat models, +password managers, 2FA... diff --git a/docs/security/ssh.typ b/docs/security/ssh.typ new file mode 100644 index 0000000..d807c19 --- /dev/null +++ b/docs/security/ssh.typ @@ -0,0 +1,11 @@ +// Access control: only members of these Authentik groups can read this page. +// Delete this line to make the page public. +#let access-groups = ("admin",) + +#import "../template.typ": project +#show: project + += SSH hardening + +Protected page placeholder about SSH: key management, ciphers, +session hardening, bastion hosts... diff --git a/docs/template.typ b/docs/template.typ new file mode 100644 index 0000000..de9f5cd --- /dev/null +++ b/docs/template.typ @@ -0,0 +1,44 @@ +#import "@preview/shiroa:0.4.0": get-page-width, is-html-target, is-pdf-target, is-web-target, templates, x-target +#import templates: * + +// Metadata +#let page-width = get-page-width() +#let is-html-target = is-html-target() // target.starts-with("html") +#let is-pdf-target = is-pdf-target() // target.starts-with("pdf") +#let is-web-target = is-web-target() // target.starts-with("web") + +#let project(body) = { + // set web/pdf page properties + set page( + width: page-width, + // for a website, we don't need pagination. + height: auto, + ) if is-pdf-target or is-web-target + + // remove margins for web target + set page(margin: ( + // reserved beautiful top margin + top: 20pt, + // Typst is setting the page's bottom to the baseline of the last line of text. So bad :(. + bottom: 0.5em, + // remove rest margins. + rest: 0pt, + )) if is-web-target + + let web-theme = if x-target.starts-with("html") and not x-target.starts-with("html-wrapper") { + "starlight" + } else { + "mdbook" + } + show: if web-theme == "starlight" { + import "@preview/shiroa-starlight:0.4.0": starlight + starlight.with(include "book.typ") + } else if web-theme == "mdbook" { + import "@preview/shiroa-mdbook:0.4.0": mdbook + mdbook.with(include "book.typ") + } else { + panic("Unknown web theme: " + web-theme) + } + + body +} diff --git a/docs/thermo/fluids.typ b/docs/thermo/fluids.typ new file mode 100644 index 0000000..4578c09 --- /dev/null +++ b/docs/thermo/fluids.typ @@ -0,0 +1,11 @@ +// Access control: only members of these Authentik groups can read this page. +// Delete this line to make the page public. +#let access-groups = ("family", "admin") + +#import "../template.typ": project +#show: project + += Fluid mechanics + +Protected page placeholder about fluid mechanics: statics, dynamics, +Bernoulli's principle, pressure losses... diff --git a/docs/thermo/heat-pumps.typ b/docs/thermo/heat-pumps.typ new file mode 100644 index 0000000..dac8449 --- /dev/null +++ b/docs/thermo/heat-pumps.typ @@ -0,0 +1,7 @@ +#import "../template.typ": project +#show: project + += Heat pumps + +Public page placeholder about heat pumps: COP, seasonal performance, +refrigerant cycles... diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..5a0bc69 --- /dev/null +++ b/embed.go @@ -0,0 +1,26 @@ +// Package yggdrasil embeds the build artifacts: the static site compiled by +// shiroa (dist/) and the access control list (acl.json). Embedding them at +// the repository root lets //go:embed see both directories relative to this +// file; `go build` requires acl.json and dist/ to exist (see the Justfile). +package yggdrasil + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var distFS embed.FS + +//go:embed acl.json +var aclData []byte + +// DistFS returns the embedded static site. +func DistFS() (fs.FS, error) { + return fs.Sub(distFS, "dist") +} + +// ACLData returns the embedded acl.json. +func ACLData() []byte { + return aclData +} diff --git a/go.mod b/go.mod index 8d935a2..57b43d9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/Klagarge/yggdrasil go 1.27.1 require ( - github.com/coreos/go-oidc/v3 v3.21.0 // indirect - golang.org/x/oauth2 v0.37.0 // indirect + github.com/coreos/go-oidc/v3 v3.21.0 + golang.org/x/oauth2 v0.37.0 ) + +require github.com/go-jose/go-jose/v4 v4.1.4 // indirect diff --git a/go.sum b/go.sum index 445d193..ec31947 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= diff --git a/internal/acl/acl.go b/internal/acl/acl.go index 9364f09..6c6dcd7 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -1,39 +1,122 @@ +// Package acl loads the access control list generated by cmd/parser and +// answers authorization questions for the HTTP middleware. +// +// acl.json maps the URL of each protected page ("/thermo/fluids.html") to +// the groups allowed to read it. URLs absent from the list are public. +// +// Keys are normalized to a "page key": the URL path reduced to the page it +// belongs to, e.g. "/thermo/fluids.html" and "/thermo/fluids.html" both +// become "/thermo/fluids". The shiroa renderer fetches the page content +// from companion files such as "/thermo/fluids.light.multi.sir.in", which +// reduce to the same key, so a protected page's content cannot be fetched +// directly while the page itself is protected. package acl import ( "encoding/json" "fmt" + "path" + "strings" ) +// ACL maps page keys ("/thermo/fluids") to the groups allowed to read them. type ACL map[string][]string +// Load parses acl.json and normalizes its keys. A malformed file is an error: +// the caller must refuse to start rather than serve protected pages as +// public (fail closed). func Load(data []byte) (ACL, error) { - var acl ACL - if err := json.Unmarshal(data, &acl); err != nil { - return nil, fmt.Errorf("parsing acl json: %w", err) + var raw map[string][]string + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing acl.json: %w", err) + } + acl := make(ACL, len(raw)) + for urlPath, groups := range raw { + if len(groups) == 0 { + continue + } + key, err := PageKey(urlPath) + if err != nil { + return nil, fmt.Errorf("acl entry %q: %w", urlPath, err) + } + // Merge entries that reduce to the same page (e.g. a page and + // its directory index): require the union of their groups. + acl[key] = appendUnique(acl[key], groups...) } return acl, nil } -// IsProtected checks if a path has specific access groups defined -func (a ACL) IsProtected(path string) bool { - groups, ok := a[path] - return ok && len(groups) > 0 +// PageKey reduces a URL path to the page it belongs to. +// +// "/thermo/fluids.html" -> "/thermo/fluids" +// "/thermo/fluids.light.multi.sir.in" -> "/thermo/fluids" +// "/" -> "/index" +// "/thermo/" -> "/thermo/index" +func PageKey(urlPath string) (string, error) { + if !strings.HasPrefix(urlPath, "/") { + return "", fmt.Errorf("not an absolute path") + } + // A request for a directory serves that directory's index page. + if urlPath != "/" && strings.HasSuffix(urlPath, "/") { + urlPath += "index.html" + } + p := path.Clean(urlPath) + if p == "/" { + return "/index", nil + } + dir, base := path.Split(p) + if i := strings.Index(base, "."); i >= 0 { + base = base[:i] + } + if base == "" { + base = "index" + } + return path.Join(dir, base), nil } -// Allowed checks if a list of user groups has any overlap with the required groups for a path -func (a ACL) Allowed(path string, userGroups []string) bool { - requiredGroups, ok := a[path] - if !ok || len(requiredGroups) == 0 { - return true // public +// GroupsFor returns the union of the groups required by any of the given +// page keys, and whether at least one of them is protected. +func (a ACL) GroupsFor(keys ...string) (required []string, protected bool) { + seen := make(map[string]struct{}) + for _, k := range keys { + for _, g := range a[k] { + if _, dup := seen[g]; !dup { + seen[g] = struct{}{} + required = append(required, g) + } + protected = true + } } + return required, protected +} - for _, req := range requiredGroups { - for _, user := range userGroups { - if req == user { +// Allows reports whether the user's groups include at least one of the +// required groups. +func Allows(userGroups, required []string) bool { + for _, u := range userGroups { + for _, r := range required { + if u == r { return true } } } return false } + +func appendUnique(dst []string, groups ...string) []string { + for _, g := range groups { + if !contains(dst, g) { + dst = append(dst, g) + } + } + return dst +} + +func contains(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go new file mode 100644 index 0000000..cd13115 --- /dev/null +++ b/internal/acl/acl_test.go @@ -0,0 +1,67 @@ +package acl + +import "testing" + +func TestPageKey(t *testing.T) { + cases := []struct{ in, want string }{ + {"/", "/index"}, + {"/index.html", "/index"}, + {"/thermo/fluids.html", "/thermo/fluids"}, + // The renderer fetches content from theme variants; they must map + // to the same page or the ACL would be bypassable. + {"/thermo/fluids.light.multi.sir.in", "/thermo/fluids"}, + {"/thermo/fluids.ayu.multi.sir.in", "/thermo/fluids"}, + // Directory requests resolve to the directory index page. + {"/thermo/", "/thermo/index"}, + {"/thermo", "/thermo"}, + // Path traversal is neutralized before matching. + {"/thermo/../security/ssh.html", "/security/ssh"}, + } + for _, c := range cases { + if got, err := PageKey(c.in); err != nil { + t.Errorf("PageKey(%q) error: %v", c.in, err) + } else if got != c.want { + t.Errorf("PageKey(%q) = %q, want %q", c.in, got, c.want) + } + } + if _, err := PageKey("relative.html"); err == nil { + t.Error("PageKey must reject relative paths") + } +} + +func TestLoadAndCheck(t *testing.T) { + // Keys in acl.json are final page URLs; Load normalizes them. + data := []byte(`{ + "/thermo/fluids.html": ["family", "admin"], + "/index.html": ["admin"], + "/public.html": ["family"] + }`) + a, err := Load(data) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got, _ := a.GroupsFor("/thermo/fluids"); len(got) != 2 { + t.Errorf("expected 2 groups for /thermo/fluids, got %v", got) + } + if _, protected := a.GroupsFor("/thermo/fluids"); !protected { + t.Error("/thermo/fluids should be protected") + } + if _, protected := a.GroupsFor("/thermo/heat-pumps"); protected { + t.Error("/thermo/heat-pumps should be public") + } + if !Allows([]string{"family"}, []string{"family", "admin"}) { + t.Error("family should be allowed") + } + if Allows([]string{"guest"}, []string{"family", "admin"}) { + t.Error("guest should be denied") + } + if Allows(nil, []string{"admin"}) { + t.Error("no groups should never be allowed on a protected page") + } +} + +func TestLoadMalformed(t *testing.T) { + if _, err := Load([]byte("not json")); err == nil { + t.Fatal("malformed acl.json must be an error (fail closed)") + } +} diff --git a/internal/middleware/access.go b/internal/middleware/access.go new file mode 100644 index 0000000..8787ffe --- /dev/null +++ b/internal/middleware/access.go @@ -0,0 +1,89 @@ +// Package middleware implements the access-control layer of the server: +// OIDC login/logout, cookie sessions, and the gate that decides for every +// request whether its URL may be served to the caller. +package middleware + +import ( + "net/http" + "net/url" + "sync" + "time" + + "github.com/Klagarge/yggdrasil/internal/acl" + "github.com/Klagarge/yggdrasil/internal/oidc" +) + +const ( + sessionCookie = "ygg_session" + stateTTL = 10 * time.Minute +) + +// AccessControl bundles everything the gate needs. +type AccessControl struct { + ACL acl.ACL + Sessions *SessionStore + Auth *oidc.Authenticator // nil: OIDC disabled + CookieSecure bool + + stateMu sync.Mutex + states map[string]loginState // pending OIDC flows, keyed by state +} + +// NewAccessControl wires the gate. auth may be nil (OIDC disabled): public +// pages keep working and protected pages answer 503. +func NewAccessControl(a acl.ACL, sessions *SessionStore, auth *oidc.Authenticator, cookieSecure bool) *AccessControl { + return &AccessControl{ + ACL: a, + Sessions: sessions, + Auth: auth, + CookieSecure: cookieSecure, + states: make(map[string]loginState), + } +} + +// Wrap returns a handler that serves next only when the request is allowed. +// +// The request path is reduced to a page key (see acl.PageKey) and looked up +// in the ACL. Public pages pass through; protected pages require a session +// whose groups include at least one of the groups required by the ACL. +func (ac *AccessControl) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key, err := acl.PageKey(r.URL.Path) + if err != nil { + http.Error(w, "invalid request path", http.StatusBadRequest) + return + } + required, protected := ac.ACL.GroupsFor(key) + if !protected { + next.ServeHTTP(w, r) + return + } + if ac.Auth == nil { + http.Error(w, "this page is protected, but the server has no OIDC configuration", + http.StatusServiceUnavailable) + return + } + sess, ok := ac.session(r) + if !ok { + http.Redirect(w, r, "/login?target="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound) + return + } + if !acl.Allows(sess.Groups, required) { + http.Error(w, "403 Forbidden: your account does not have access to this page.", + http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// SecurityHeaders sets conservative defaults on every response. +func (ac *AccessControl) SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/middleware/handlers.go b/internal/middleware/handlers.go new file mode 100644 index 0000000..3ccb77f --- /dev/null +++ b/internal/middleware/handlers.go @@ -0,0 +1,141 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/Klagarge/yggdrasil/internal/oidc" +) + +// loginState remembers where a pending OIDC flow must return. +type loginState struct { + target string + expires time.Time +} + +// session reads the session cookie and returns the session if valid. +func (ac *AccessControl) session(r *http.Request) (*Session, bool) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + return nil, false + } + return ac.Sessions.Get(c.Value) +} + +func (ac *AccessControl) setSessionCookie(w http.ResponseWriter, id string) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: id, + Path: "/", + HttpOnly: true, + Secure: ac.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ac.Sessions.TTL() / time.Second), + }) +} + +func (ac *AccessControl) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: "", + Path: "/", + HttpOnly: true, + Secure: ac.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// Login redirects to the OIDC provider, remembering where to return. +func (ac *AccessControl) Login(w http.ResponseWriter, r *http.Request) { + if ac.Auth == nil { + http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable) + return + } + target := sanitizeTarget(r.URL.Query().Get("target")) + state := randomID() + ac.saveState(state, target) + http.Redirect(w, r, ac.Auth.AuthCodeURL(state), http.StatusFound) +} + +// Callback finishes the OIDC flow: it verifies the provider's response and +// starts a session. +func (ac *AccessControl) Callback(w http.ResponseWriter, r *http.Request) { + if ac.Auth == nil { + http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable) + return + } + target, ok := ac.takeState(r.URL.Query().Get("state")) + if !ok { + http.Error(w, "unknown or expired login state; restart the login", http.StatusUnauthorized) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + return + } + token, err := ac.Auth.Exchange(r.Context(), code) + if err != nil { + http.Error(w, "token exchange failed", http.StatusBadGateway) + return + } + idToken, err := ac.Auth.VerifyIDToken(r.Context(), token) + if err != nil { + http.Error(w, "invalid identity token", http.StatusUnauthorized) + return + } + profile, err := oidc.ProfileFromToken(idToken) + if err != nil { + http.Error(w, "unreadable identity token claims", http.StatusUnauthorized) + return + } + ac.setSessionCookie(w, ac.Sessions.Create(profile)) + http.Redirect(w, r, target, http.StatusFound) +} + +// Logout ends the session. +func (ac *AccessControl) Logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { + ac.Sessions.Delete(c.Value) + } + ac.clearSessionCookie(w) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (ac *AccessControl) saveState(state, target string) { + ac.stateMu.Lock() + now := time.Now() + for k, v := range ac.states { // opportunistic purge + if now.After(v.expires) { + delete(ac.states, k) + } + } + ac.states[state] = loginState{target: target, expires: now.Add(stateTTL)} + ac.stateMu.Unlock() +} + +func (ac *AccessControl) takeState(state string) (string, bool) { + if state == "" { + return "", false + } + ac.stateMu.Lock() + defer ac.stateMu.Unlock() + s, ok := ac.states[state] + if ok { + delete(ac.states, state) // states are single use + } + if !ok || time.Now().After(s.expires) { + return "", false + } + return s.target, true +} + +// sanitizeTarget only accepts local redirect targets (open-redirect guard). +func sanitizeTarget(t string) string { + if t == "" || !strings.HasPrefix(t, "/") || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "/\\") { + return "/" + } + return t +} diff --git a/internal/middleware/middleware_test.go b/internal/middleware/middleware_test.go new file mode 100644 index 0000000..b7e2e47 --- /dev/null +++ b/internal/middleware/middleware_test.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "testing" + + "github.com/Klagarge/yggdrasil/internal/oidc" +) + +func TestSanitizeTarget(t *testing.T) { + cases := map[string]string{ + "": "/", + "/thermo/fluids.html": "/thermo/fluids.html", + "/a b/c.html?x=1": "/a b/c.html?x=1", + "//evil.example.com": "/", + "/\\evil.example.com": "/", + "https://evil.example.com": "/", + "relative.html": "/", + } + for in, want := range cases { + if got := sanitizeTarget(in); got != want { + t.Errorf("sanitizeTarget(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStateRoundTrip(t *testing.T) { + ac := NewAccessControl(nil, NewSessionStore(0), nil, false) + + ac.saveState("s1", "/thermo/fluids.html") + target, ok := ac.takeState("s1") + if !ok || target != "/thermo/fluids.html" { + t.Fatalf("state round trip failed: %q %v", target, ok) + } + // States are single use. + if _, ok := ac.takeState("s1"); ok { + t.Error("state must be single use") + } + // Unknown states are rejected (CSRF guard). + if _, ok := ac.takeState("nope"); ok { + t.Error("unknown state must be rejected") + } +} + +func TestSessionExpiry(t *testing.T) { + store := NewSessionStore(0) // zero TTL: expires immediately + id := store.Create(oidc.Profile{Subject: "sub", Email: "user@example.com", Groups: []string{"family"}}) + if _, ok := store.Get(id); ok { + t.Error("expired session must not validate") + } +} diff --git a/internal/middleware/sessions.go b/internal/middleware/sessions.go new file mode 100644 index 0000000..d3664db --- /dev/null +++ b/internal/middleware/sessions.go @@ -0,0 +1,101 @@ +package middleware + +import ( + "context" + "crypto/rand" + "encoding/base64" + "sync" + "time" + + "github.com/Klagarge/yggdrasil/internal/oidc" +) + +// Session is an authenticated user. +type Session struct { + Subject string + Email string + Name string + Groups []string + Expires time.Time +} + +// SessionStore keeps sessions in memory. Sessions do not survive server +// restarts; users simply log in again. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*Session + ttl time.Duration +} + +// NewSessionStore creates a store with the given session lifetime. +func NewSessionStore(ttl time.Duration) *SessionStore { + return &SessionStore{ + sessions: make(map[string]*Session), + ttl: ttl, + } +} + +// TTL reports the session lifetime. +func (s *SessionStore) TTL() time.Duration { return s.ttl } + +// Create registers a new session and returns its ID. +func (s *SessionStore) Create(p oidc.Profile) string { + id := randomID() + s.mu.Lock() + s.sessions[id] = &Session{ + Subject: p.Subject, + Email: p.Email, + Name: p.Name, + Groups: p.Groups, + Expires: time.Now().Add(s.ttl), + } + s.mu.Unlock() + return id +} + +// Get returns the session for id if it exists and has not expired. +func (s *SessionStore) Get(id string) (*Session, bool) { + s.mu.RLock() + sess, ok := s.sessions[id] + s.mu.RUnlock() + if !ok || time.Now().After(sess.Expires) { + return nil, false + } + return sess, true +} + +// Delete removes a session. +func (s *SessionStore) Delete(id string) { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() +} + +// StartJanitor purges expired sessions periodically until ctx is done. +// Call it in a goroutine. +func (s *SessionStore) StartJanitor(ctx context.Context, every time.Duration) { + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case now := <-t.C: + s.mu.Lock() + for id, sess := range s.sessions { + if now.After(sess.Expires) { + delete(s.sessions, id) + } + } + s.mu.Unlock() + } + } +} + +func randomID() string { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) // crypto/rand does not fail on supported platforms + } + return base64.RawURLEncoding.EncodeToString(b[:]) +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go new file mode 100644 index 0000000..aa11b33 --- /dev/null +++ b/internal/oidc/oidc.go @@ -0,0 +1,118 @@ +// Package oidc implements the OIDC client (authorization code flow) used to +// authenticate users. It is the only place that talks to the identity +// provider (e.g. Authentik). +package oidc + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Config is the OIDC client configuration, read from the environment by +// LoadConfigFromEnv. +type Config struct { + ClientID string + ClientSecret string + IssuerURL string + RedirectURL string + Scopes string +} + +// LoadConfigFromEnv reads the OIDC_* variables. It returns an error when +// OIDC is not (fully) configured; the caller decides whether that is fatal. +func LoadConfigFromEnv() (*Config, error) { + cfg := &Config{ + ClientID: os.Getenv("OIDC_CLIENT_ID"), + ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + IssuerURL: os.Getenv("OIDC_ISSUER_URL"), + RedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + Scopes: os.Getenv("OIDC_SCOPES"), + } + if cfg.Scopes == "" { + cfg.Scopes = "openid profile email groups" + } + if cfg.ClientID == "" || cfg.IssuerURL == "" || cfg.RedirectURL == "" { + return nil, errors.New("OIDC_CLIENT_ID, OIDC_ISSUER_URL and OIDC_REDIRECT_URL must be set") + } + return cfg, nil +} + +// Authenticator drives the authorization code flow and verifies ID tokens. +type Authenticator struct { + oauth2 oauth2.Config + verifier *oidc.IDTokenVerifier +} + +// NewAuthenticator discovers the provider at the issuer URL. It fails fast +// when the issuer is unreachable so that a misconfigured server never +// starts serving protected content. +func NewAuthenticator(ctx context.Context, cfg *Config) (*Authenticator, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("querying OIDC provider %s: %w", cfg.IssuerURL, err) + } + return &Authenticator{ + oauth2: oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: strings.Fields(cfg.Scopes), + }, + verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + }, nil +} + +// AuthCodeURL returns the URL the user is redirected to for login. +func (a *Authenticator) AuthCodeURL(state string) string { + return a.oauth2.AuthCodeURL(state) +} + +// Exchange trades an authorization code for tokens. +func (a *Authenticator) Exchange(ctx context.Context, code string) (*oauth2.Token, error) { + return a.oauth2.Exchange(ctx, code) +} + +// VerifyIDToken extracts and verifies the ID token issued in the code +// exchange. Signature, issuer, audience and expiry are checked. +func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("no id_token in OAuth2 token response") + } + return a.verifier.Verify(ctx, rawIDToken) +} + +// Profile holds the user attributes used for sessions and logging. +type Profile struct { + Subject string + Email string + Name string + Groups []string +} + +// ProfileFromToken reads user attributes from a verified ID token. A missing +// groups claim yields nil groups, which denies every protected page +// (fail closed). +func ProfileFromToken(idToken *oidc.IDToken) (Profile, error) { + var claims struct { + Name string `json:"name"` + Email string `json:"email"` + Groups []string `json:"groups"` + } + if err := idToken.Claims(&claims); err != nil { + return Profile{}, fmt.Errorf("reading ID token claims: %w", err) + } + return Profile{ + Subject: idToken.Subject, + Email: claims.Email, + Name: claims.Name, + Groups: claims.Groups, + }, nil +}