Compare 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
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -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)
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |