Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ffc6a14d2
|
||
|
|
19f6007113
|
||
|
|
6472890fa2
|
||
|
|
fc1b45058c
|
||
|
|
3601def1f6
|
||
|
|
1292597e09
|
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.delta
|
||||
.idea
|
||||
.vscode
|
||||
docs
|
||||
legacy_docs
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
Generated
-17
@@ -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>
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -1,33 +1,200 @@
|
||||
<header>
|
||||
<h1>Yggdrasil</h1>
|
||||
</header>
|
||||
Yggdrasil is a collections of notes around IT, OT and other geeks stuff
|
||||
<br>
|
||||
<br>
|
||||
<br>
|
||||
# Yggdrasil
|
||||
|
||||
# [Bachelor in embedded system](/infotro/)
|
||||
Here notes I take during my bachelor in embedded systems
|
||||
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.
|
||||
|
||||
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/)
|
||||
No search, no database, no Node.js: one binary, one configuration.
|
||||
|
||||
# [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
|
||||
## How it works
|
||||
|
||||
Mainly some topics:
|
||||
- [SRE - Software Reverse Engineering](/MSE/SRE/)
|
||||
```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
|
||||
```
|
||||
|
||||
# [Embedded Systems](/Embed/)
|
||||
Here some note specific about some Embedded System
|
||||
- [STM32](/Embed/STM32/)
|
||||
- [nRF](/Embed/nRF/)
|
||||
- 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.
|
||||
|
||||
# [Working environment at HES-SO](/HES-SO/)
|
||||
Here some note about the working environment at HES-SO
|
||||
## Repository layout
|
||||
|
||||
- [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.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/homelab/backup.html": [
|
||||
"family"
|
||||
],
|
||||
"/security/ssh.html": [
|
||||
"admin"
|
||||
],
|
||||
"/thermo/fluids.html": [
|
||||
"family",
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
File renamed without changes.
@@ -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]
|
||||
],
|
||||
)
|
||||
@@ -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...
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "../template.typ": project
|
||||
#show: project
|
||||
|
||||
= Self-hosting
|
||||
|
||||
Public page placeholder about self-hosting: Docker/Podman, reverse
|
||||
proxies, DNS, VLANs...
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "../template.typ": project
|
||||
#show: project
|
||||
|
||||
= Opsec basics
|
||||
|
||||
Public page placeholder about operational security: threat models,
|
||||
password managers, 2FA...
|
||||
@@ -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...
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...
|
||||
@@ -0,0 +1,7 @@
|
||||
#import "../template.typ": project
|
||||
#show: project
|
||||
|
||||
= Heat pumps
|
||||
|
||||
Public page placeholder about heat pumps: COP, seasonal performance,
|
||||
refrigerant cycles...
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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.
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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.
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.
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.
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.
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user