Author SHA1 Message Date
Klagarge 9ffc6a14d2 fix: apply fresh-start review findings
Independent review (second attempt) passed all 7 verification steps at
19f6007, including the fresh-clone embed check, full build from clean
state (9.7s), server curl matrix (public 200, protected + all theme
sidecars 503, searchindex 404, no content in 503 bodies), and just check.

Findings applied:
- README: prerequisite Go version corrected to 1.27.1+ to match go.mod
  (a Go 1.22-1.27 user got a hard 'requires go >= 1.27.1' failure).
- CI: upload-artifact v3 -> v4 with a fallback note for older runners;
  CI now also runs the gofmt guard like 'just check'.
- Justfile: curl -fsSL so a bad download fails at curl instead of tar;
  comment clarifying that --dest-dir is resolved relative to docs/.
- AGENT.md: version-pairing trap now covers both pin locations
  (Justfile and .gitea/workflows/build.yml).
2026-10-10 19:05:34 +02:00
Klagarge 19f6007113 fix: preserve tracked dist/.gitkeep across clean cycles
Commit 6472890 accidentally deleted the committed dist/.gitkeep: the
clean-state test ran 'rm -rf dist' (which contains the tracked .gitkeep)
and the following 'git add -A' recorded the deletion. Fresh clones then
had no dist/ and 'go build'/'just check' failed before the first shiroa
run ('pattern all:dist: no matching files found').

- Justfile: 'clean' now recreates dist/.gitkeep, so the tracked file
  survives clean/build cycles.
- AGENT.md: clean-state procedure uses 'just clean' and requires a clean
  'git status' after test cycles, with the reason documented.
- Verified: with dist/ holding only .gitkeep, 'go build' and 'go vet'
  succeed (fresh-clone state).
2026-10-10 18:58:45 +02:00
Klagarge 6472890fa2 fix: self-bootstrapping shiroa, help on bare just, AGENT.md
- Justfile: pinned shiroa (v0.4.0-rc2, arch-aware) bootstrapped into
  tools/ by the 'tools' recipe; build-docs/preview-docs use it, so a
  fresh clone needs only Go and just. Bare 'just' now shows the recipe
  overview instead of starting a build.
- AGENT.md: operating rules for agents (commands, layout, security
  invariants, shiroa version-pairing trap, testing expectations).
- README: prerequisites updated (no global shiroa), bare-just behavior,
  tools/ in the layout. gitignore tools/ and docs/dist/.
2026-10-10 18:56:05 +02:00
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
Klagarge 3601def1f6 chore: move legacy documentation to legacy_docs 2026-10-10 18:29:20 +02:00
Klagarge 1292597e09 chore: setup go module and structure 2026-10-10 13:57:15 +02:00
215 changed files with 1572 additions and 43 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
.delta
.idea
.vscode
docs
legacy_docs
+17
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
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 ./...
test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o yggdrasil-server ./cmd/server
# upload-artifact v4 needs a reasonably current Gitea/act_runner;
# fall back to v3 if your runner is too old to serve v4 artifacts.
- name: Upload server artifact
uses: actions/upload-artifact@v4
with:
name: yggdrasil-server-linux-amd64
path: yggdrasil-server
+21
View File
@@ -0,0 +1,21 @@
# 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/
# pinned toolchain downloads (shiroa), managed by the Justfile
tools/
# shiroa serve default output
docs/dist/
-17
View File
@@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docsify" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="start http://localhost:3000 ; docsify serve " />
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/.idea/runConfigurations/run.sh" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="powershell.exe" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
</component>
+80
View File
@@ -0,0 +1,80 @@
# AGENT.md — operating rules for agents working on Yggdrasil
Personal knowledge base: Typst sources (`docs/`, a shiroa book) compiled to
a static site, embedded into a Go binary that enforces per-page OIDC group
permissions (Authentik). README.md is the human-facing documentation; this
file is the working rulebook. Follow it; when it conflicts with the user,
the user wins.
## Commands
- `just` — shows the command overview (bare `just` never builds anything).
- `just build` — parse ACL, compile the book, build `./server`.
- `just run` — build everything, then serve on :8080.
- `just check` — go vet, go test, gofmt. Run it before every commit.
- `just clean` — removes `dist/` and `server` (keeps `tools/` and
restores the committed `dist/.gitkeep`).
- The shiroa toolchain is pinned and self-bootstrapped into `tools/` by the
`tools` recipe. Never assume a global shiroa/typst install exists; do not
install shiroa globally; do not bump the pin casually (see Traps).
## Layout
- `docs/` — shiroa book. `book.typ` lists every built page; files not listed
there are not part of the site. Categories are `=` part headers in the
summary, pages are `# chapter("path.typ")[Title]` entries. Final URL =
source path with `.typ` replaced by `.html`.
- `cmd/parser` — build step: scans `docs/` for `#let access-groups = (..)`
tags and emits `acl.json`. Untagged pages are public.
- `cmd/server` + `embed.go` (repo root) — `//go:embed` of `dist/` and
`acl.json` into a single binary.
- `internal/acl` — ACL loading and page-key normalization (security critical).
- `internal/oidc` — OIDC authorization-code client.
- `internal/middleware` — sessions, login/callback/logout, access gate.
- `legacy_docs/` — frozen previous site. Never modify or migrate it unless
the user asks explicitly.
## Security invariants — do not break
- Fail closed: a malformed `acl.json` aborts startup; protected pages with
no configured IdP answer 503, never the content.
- Page keys: shiroa pages keep their rendered content in sibling
`<page>.<theme>.multi.sir.in` files fetched by client-side JS.
`acl.PageKey` reduces any URL to a page key so those files inherit the
page's protection. If the shiroa output format changes, re-verify this
mapping: content in any new sidecar file must be covered or blocked.
- `/searchindex.json` contains the text of protected pages: blocked at the
router, always.
- Session cookies: HttpOnly, SameSite=Lax, Secure unless COOKIE_SECURE=false.
OAuth2 states are random and single use; post-login redirect targets are
sanitized (open-redirect guard).
## Traps
- Shiroa version pairing: the CLI and the `@preview/shiroa:*` typst package
must match (CLI v0.4.0-rc2 pairs with package 0.4.0; the v0.3.0 release
CLI rejects 0.4.0 books). The pin lives in two places: the Justfile
`shiroa-version` and `.gitea/workflows/build.yml`. Bump all of them
together with the package import and rebuild.
- Typst call syntax: no space between `)` and the trailing content block —
`#chapter("x.typ")[Title]`, not `#chapter("x.typ") [Title]`.
- `dist/` and `acl.json` are embedded at Go build time; there is no runtime
reload. After changing docs or ACL, rebuild the binary.
- `go build` needs `dist/` and `acl.json` to exist: fresh clones carry
`dist/.gitkeep` and a committed `acl.json` for exactly this reason.
Never gitignore those two.
## Testing expectations
- Test what the user runs, not a proxy for it: from a clean state
(`rm -rf tools && just clean`), `just build` and `just run` must succeed
with no global shiroa on PATH. Test the bare `just` output too. After
any clean/test cycle, `git status` must be clean: `dist/.gitkeep` is
tracked, and committing a worktree where it is missing breaks fresh
clones (`go:embed` needs `dist/`).
- Server behavior: curl a public page (200), a protected page (503 without
OIDC env; it must never serve protected content), and `/searchindex.json`
(404). Extend the unit tests in `internal/acl` and `internal/middleware`
whenever touching those paths.
- A live OIDC round trip needs a real IdP and is not testable here. Say
that explicitly when reporting what was verified.
+16
View File
@@ -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"]
+52
View File
@@ -0,0 +1,52 @@
# Yggdrasil: Typst documentation behind an authenticated Go server.
# Pinned shiroa toolchain, self-managed under tools/ (no global install).
# The CLI and the @preview/shiroa package version must match (see AGENT.md).
shiroa-version := "v0.4.0-rc2"
shiroa-target := if arch() == "x86_64" { "x86_64-unknown-linux-musl" } else if arch() == "aarch64" { "aarch64-unknown-linux-musl" } else { error("unsupported architecture " + arch()) }
shiroa-dir := "tools/shiroa"
shiroa-bin := shiroa-dir + "/shiroa-" + shiroa-target + "/shiroa"
shiroa-url := "https://github.com/Myriad-Dreamin/shiroa/releases/download/" + shiroa-version + "/shiroa-" + shiroa-target + ".tar.gz"
# Show the available commands.
default:
@just --list
# Build everything: acl.json, the static site, and the server binary.
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
# Download the pinned shiroa into tools/ if missing.
tools:
@mkdir -p {{shiroa-dir}}
@test -x {{shiroa-bin}} || { echo "Downloading shiroa {{shiroa-version}} ({{shiroa-target}})..."; curl -fsSL -o {{shiroa-dir}}/shiroa.tar.gz {{shiroa-url}}; tar -xzf {{shiroa-dir}}/shiroa.tar.gz -C {{shiroa-dir}}; rm {{shiroa-dir}}/shiroa.tar.gz; {{shiroa-bin}} --version; }
# Compile the Typst book into dist/.
# (--dest-dir is resolved relative to the book directory, docs/, hence ../dist)
build-docs: tools
{{shiroa-bin}} 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: tools
{{shiroa-bin}} 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 tools/ and the committed dist/.gitkeep).
clean:
rm -rf dist server && mkdir -p dist && touch dist/.gitkeep
+193 -26
View File
@@ -1,33 +1,200 @@
<header> # Yggdrasil
<h1>Yggdrasil</h1>
</header>
Yggdrasil is a collections of notes around IT, OT and other geeks stuff
<br>
<br>
<br>
# [Bachelor in embedded system](/infotro/) A personal knowledge base written in [Typst](https://typst.app), compiled to
Here notes I take during my bachelor in embedded systems 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.
Mainly some topics: No search, no database, no Node.js: one binary, one configuration.
- [PTR - Programmation Temps Réel](/infotro/PTR/)
- [SDi - Système Distribué](/infotro/SDi/)
- [Eln - Électronique](/infotro/Eln/)
- [SEm - Systèmes Embarqué](/infotro/SEm/)
# [Master of science in engineering in embedded system](/MSE/) ## How it works
Here note I take during my Master of science un engineering with embedded system as orientation
Mainly some topics: ```mermaid
- [SRE - Software Reverse Engineering](/MSE/SRE/) 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
```
# [Embedded Systems](/Embed/) - Pages are written in Typst under `docs/` and listed in `docs/book.typ`.
Here some note specific about some Embedded System - A page can declare who may read it with a tag:
- [STM32](/Embed/STM32/) `#let access-groups = ("family", "admin")`. Pages without a tag are public.
- [nRF](/Embed/nRF/) - 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.
# [Working environment at HES-SO](/HES-SO/) ## Repository layout
Here some note about the working environment at HES-SO
- [OneDrive - Linux](/HES-SO/OneDrive.md) ```
- [Teams - Linux](/HES-SO/Teams.md) 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)
tools/ pinned shiroa binary (downloaded by `just tools`)
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.27.1+ (the version required by `go.mod`) and
[just](https://github.com/casey/just). The
pinned shiroa toolchain is downloaded automatically into `tools/` on the
first build (needs `curl` + `tar` and network access); nothing is
installed globally.
```sh
just # show the available commands
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`. Bare `just` prints the
command overview. The pinned shiroa lives in `tools/` (gitignored) and is
downloaded on demand.
`.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.
+12
View File
@@ -0,0 +1,12 @@
{
"/homelab/backup.html": [
"family"
],
"/security/ssh.html": [
"admin"
],
"/thermo/fluids.html": [
"family",
"admin"
]
}
+107
View File
@@ -0,0 +1,107 @@
// 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 (
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"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 the shiroa book (.typ sources)")
outFile := flag.String("out", "acl.json", "output acl.json file")
flag.Parse()
acl := make(map[string][]string)
err := filepath.WalkDir(*inDir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
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/<path>.typ compiles to dist/<path>.html, served at /<path>.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("scanning %s: %v", *inDir, err)
}
out, err := json.MarshalIndent(acl, "", " ")
if err != nil {
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)
}
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)
}
}
return groups
}
+120
View File
@@ -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
}
View File
File renamed without changes.
+22
View File
@@ -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]
],
)
+11
View File
@@ -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...
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Self-hosting
Public page placeholder about self-hosting: Docker/Podman, reverse
proxies, DNS, VLANs...
+17
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Opsec basics
Public page placeholder about operational security: threat models,
password managers, 2FA...
+11
View File
@@ -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...
+44
View File
@@ -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
}
+11
View File
@@ -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...
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Heat pumps
Public page placeholder about heat pumps: COP, seasonal performance,
refrigerant cycles...
+26
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
module github.com/Klagarge/yggdrasil
go 1.27.1
require (
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
+6
View File
@@ -0,0 +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=
+122
View File
@@ -0,0 +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 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
}
// 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
}
// 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
}
// 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
}
+67
View File
@@ -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)")
}
}
+89
View File
@@ -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)
})
}
+141
View File
@@ -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
}
+50
View File
@@ -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")
}
}
+101
View File
@@ -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[:])
}
+118
View File
@@ -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
}
File renamed without changes.
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+33
View File
@@ -0,0 +1,33 @@
<header>
<h1>Yggdrasil</h1>
</header>
Yggdrasil is a collections of notes around IT, OT and other geeks stuff
<br>
<br>
<br>
# [Bachelor in embedded system](/infotro/)
Here notes I take during my bachelor in embedded systems
Mainly some topics:
- [PTR - Programmation Temps Réel](/infotro/PTR/)
- [SDi - Système Distribué](/infotro/SDi/)
- [Eln - Électronique](/infotro/Eln/)
- [SEm - Systèmes Embarqué](/infotro/SEm/)
# [Master of science in engineering in embedded system](/MSE/)
Here note I take during my Master of science un engineering with embedded system as orientation
Mainly some topics:
- [SRE - Software Reverse Engineering](/MSE/SRE/)
# [Embedded Systems](/Embed/)
Here some note specific about some Embedded System
- [STM32](/Embed/STM32/)
- [nRF](/Embed/nRF/)
# [Working environment at HES-SO](/HES-SO/)
Here some note about the working environment at HES-SO
- [OneDrive - Linux](/HES-SO/OneDrive.md)
- [Teams - Linux](/HES-SO/Teams.md)
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more