feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server

Aus der statischen Seite wird ein Go-Binary mit eingebetteter Oberfläche.
Grund: Apps über .env zu pflegen wurde unübersichtlich, und kosmetische
Einstellungen sollen auf jedem Gerät gleich sein.

- Anmeldung über den eigenen OIDC-Anbieter (PocketID), mit PKCE. Ohne
  Anmeldung gibt es nichts zu sehen – auch keine App-Liste.
- Sitzung als signiertes Cookie ohne Gegenstück im Server; Schreibzugriffe
  zusätzlich über ein lesbares CSRF-Cookie abgesichert
- Apps werden in der Oberfläche angelegt und bearbeitet, nicht mehr in der
  .env. Der Server prüft jeden Eintrag und meldet zurück, was er behält.
- Einstellungen liegen je Nutzer im Server; localStorage ist nur noch der
  Vorschuss, damit die Röhre beim Laden nicht in der falschen Farbe blitzt
- die Leiste holt die App-Liste aus /api/apps – freigegeben sind allein die
  Ursprünge, die selbst als App eingetragen sind
- Tests fahren einen echten OIDC-Anbieter hoch und gehen den Anmeldeweg
  einmal ganz durch

Ohne HONEYOS_OIDC_ISSUER läuft alles offen – für die Entwicklung, mit
deutlicher Warnung beim Start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-24 15:20:55 +02:00
1 parent 84cc5d619e
commit 283f9a1850
20 files changed
+1831 -438

No files matched your search

+92 -76
View File
@@ -1,55 +1,80 @@
# HoneyOS
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten —
und dieselbe Leiste zum Einhängen in Aether und InkNote.
Ein Retro-Schreibtisch vor den eigenen Apps. Amber-CRT, Boot-Log, zwei Betriebsarten,
Anmeldung über den eigenen OIDC-Anbieter — und dieselbe Leiste zum Einhängen in die Apps.
**Was es ist:** eine Oberfläche davor. Jede App bleibt ihre eigene PWA auf ihrer eigenen
Domain mit ihrer eigenen Arbeitsumgebung und ihrer eigenen Anmeldung. HoneyOS ist der
Startpunkt, der Umschalter und die gemeinsame Leiste — **kein Rahmen, der die Apps
einsperrt** (kein iframe).
Domain mit ihrer eigenen Anmeldung. HoneyOS ist der Startpunkt, der Umschalter und die
gemeinsame Leiste — **kein Rahmen, der die Apps einsperrt** (kein iframe).
**Was es nicht ist:** eine Anmeldung. HoneyOS ist eine statische Seite ohne Backend; ein
OIDC-Client bräuchte ein Client-Geheimnis und eine Sitzung, und beides gehört nicht in
etwas, das der Browser vollständig lesen kann. Angemeldet wird bei Aether und InkNote,
die beide an PocketID hängen. HoneyOS kann den Stand nur **anzeigen** — siehe unten.
**Wo der Zustand liegt:** im Server. Die App-Liste gilt für alle, die Einstellungen gehören
je Nutzer — deshalb sieht dein HoneyOS auf jedem Gerät gleich aus, an dem du angemeldet bist.
Der Browser hält dieselben Einstellungen nur als Vorschuss, damit die Röhre beim Laden nicht
in der falschen Farbe aufflackert.
## Betrieb
```bash
cp .env.example .env # Adressen und Namen eintragen
mkdir -p /srv/honeyos && chown 1000:1000 /srv/honeyos
cp .env.example .env # PUBLIC_URL, SESSION_SECRET, OIDC-Zugang
docker login gitea.honeyy.duckdns.org
docker compose pull && docker compose up -d
```
Es gibt nichts zu sichern: keine Datenbank, kein Volume. Der ganze Zustand ist die `.env` —
und was sich der Browser pro Gerät merkt (Betriebsart, Röhrenfarbe).
Zu sichern ist genau eine Datei: `${HONEYOS_DATA_DIR}/honeyos.json`. Darin stehen die Apps
und die Einstellungen je Nutzer. Sichere sie zusammen mit der `.env` — ohne
`HONEYOS_SESSION_SECRET` müssen sich alle neu anmelden, mehr passiert aber nicht.
Hinter deinem Reverse Proxy erwartet HoneyOS nichts Besonderes: `proxy_pass` auf den Port,
fertig. `/healthz` antwortet mit `ok`.
`/healthz` antwortet mit `ok`. Hinter deinem Reverse Proxy braucht es nichts Besonderes,
außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie
ohne `Secure`.
## Konfiguration
## Anmeldung
Alles kommt aus der `.env`. Beim Containerstart schreibt
[`docker-entrypoint.d/20-honeyos-config.sh`](docker-entrypoint.d/20-honeyos-config.sh)
daraus `config.json`, die der Browser liest. Nach jeder Änderung `docker compose up -d`.
In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen:
**Alles darin ist öffentlich.** Adressen und Namen gehören hinein, Geheimnisse niemals.
Eine App entsteht durch ihre `_URL`; fehlt die, wird die Nummer übersprungen (bis 12):
```dotenv
HONEYOS_APP_1_ID=aether
HONEYOS_APP_1_NAME=AETHER
HONEYOS_APP_1_SUBTITLE=Cloud & Dateien
HONEYOS_APP_1_URL=https://cloud.honeyy.duckdns.org
HONEYOS_APP_1_GLYPH=cloud # cloud | note | box | chip | folder | gear
HONEYOS_APP_1_KEY=1 # Zifferntaste im Terminal-Modus
HONEYOS_APP_1_ME=/api/v1/auth/me # optional, siehe „Wer ist angemeldet"
```
https://os.honeyy.duckdns.org/api/auth/callback
```
Ohne `HONEYOS_APP_1_URL` bleibt die eingebackene `web/config.json` gültig — ein
`docker run` ohne `.env` zeigt also die Standard-Apps. Adressen lassen sich zusätzlich im
Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Gerät.
HoneyOS schickt PKCE mit, braucht aber trotzdem ein Client-Geheimnis — also **keinen
„public client"** anlegen. Danach in die `.env`:
```dotenv
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org
HONEYOS_OIDC_CLIENT_ID=honeyos
HONEYOS_OIDC_CLIENT_SECRET=…
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den PocketID-Gruppen
```
Wer nicht angemeldet ist, sieht **nur** den Anmeldeschirm — keine App-Liste, keine Namen.
Die Sitzung ist ein signiertes Cookie ohne Gegenstück auf dem Server: ein Neustart wirft
niemanden hinaus, und es gibt nichts aufzuräumen. Sie hält 30 Tage.
**Ohne `HONEYOS_OIDC_ISSUER` läuft HoneyOS ganz ohne Anmeldung.** Das ist zum Entwickeln
gedacht und sagt beim Start deutlich, dass es so nicht ins offene Netz gehört.
## Apps anlegen
**In der Oberfläche**, nicht in der `.env`: *Einstellungen → Programme* oder die Kachel
„+ app hinzufügen". Pro App:
| Feld | wofür |
|---|---|
| Kennung | klein, ohne Leerzeichen — muss zu `data-app` der Leiste passen |
| Name, Untertitel | Beschriftung auf Kachel und Symbol |
| Adresse | wohin der Start springt |
| Symbol | cloud · note · box · chip · folder · gear · globe · disk |
| Taste | Zifferntaste im Terminal-Modus |
| Wer-bin-ich | optional, siehe unten |
| Token-Schlüssel | nur für Apps mit Bearer-Token im localStorage statt Cookie |
Gespeichert wird die Liste als Ganzes; der Server prüft sie noch einmal und antwortet mit
dem, was er wirklich behalten hat. Eine Adresse ohne `https://` kommt als Fehlermeldung
zurück, nicht als kaputter Eintrag.
## Zwei Betriebsarten, ein Datenstand
@@ -59,11 +84,8 @@ Browser unter *Einstellungen* überschreiben; das gilt dann nur für dieses Ger
| Fenster | Dialog in der Mitte | verschiebbar, minimierbar |
| unten | Statuszeile mit Tastenhinweisen | Taskleiste mit Startmenü |
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü.
Gleich bleiben in beiden: die Menüleiste oben, die Palette, die Einstellungen, `config.json`.
`renderTiles()` und `renderDeskIcons()` in `os.js` zeichnen aus denselben Daten — eine neue
App erscheint ohne Zutun in beiden.
Umschalten mit **`M`**, über *Einstellungen → Betriebsart* oder im Startmenü. Gleich bleiben
in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
@@ -77,8 +99,10 @@ Eine Zeile ins `frontend/index.html` der App:
data-app="aether"></script>
```
`data-app` ist die `id` aus der Konfiguration — danach markiert sich die Leiste selbst als
aktiv. Die App-Liste holt sie von allein neben ihrem eigenen Skript.
`data-app` ist die Kennung aus der App-Liste. Die Liste holt sich die Leiste selbst von
`/api/apps` — mit deiner HoneyOS-Sitzung, weil alle Adressen unter derselben Domain liegen.
**Freigegeben sind nur die Ursprünge, die selbst als App eingetragen sind**: die App-Liste
ist zugleich ihre eigene CORS-Freigabe.
Sie rendert in einem **Shadow DOM**: Tailwind (Aether) und die InkNote-Palette kommen nicht
hinein, ihre Stile nicht hinaus. Sie schiebt den Seiteninhalt um 32 px nach unten
@@ -90,11 +114,11 @@ hinein, ihre Stile nicht hinaus. Sie schiebt den Seiteninhalt um 32 px nach unte
Vorschau ohne Einbau: `/honeybar/demo.html`.
## Wer ist angemeldet
## Wer ist wo angemeldet
`_ME` ist der Pfad, unter dem eine App „wer bin ich" beantwortet (relativ zur `_URL` oder
absolut). Aus der Antwort wird der Anzeigename gefischt — `display_name`, `displayName`,
`name`, `username`, auch verschachtelt unter `user`.
Das Feld **Wer-bin-ich** ist der Pfad, unter dem eine App „wer bin ich" beantwortet
(relativ zur Adresse oder absolut). Aus der Antwort wird der Anzeigename gefischt —
`display_name`, `displayName`, `name`, `username`, auch verschachtelt unter `user`.
Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**:
@@ -105,38 +129,32 @@ Gelesen wird das an zwei Stellen, und die verhalten sich **unterschiedlich**:
- InkNote: `ALLOWED_ORIGINS=https://os.honeyy.duckdns.org`
- Aether: CORS mit `Access-Control-Allow-Credentials` — noch nicht vorhanden
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts. Die Lampe „online"
funktioniert unabhängig davon weiter.
Fehlt das, bleibt die Anzeige leer und sonst passiert nichts.
`_TOKEN_KEY` brauchen nur Apps, die einen Bearer-Token im localStorage halten statt eines
Cookies (InkNote: `inknote_token`). Der ist pro Domain sichtbar — von `os.…` aus also nicht
lesbar, für die Leiste in der App dagegen schon.
**Voraussetzung für all das:** alle Adressen liegen unter derselben Domain
(`*.honeyy.duckdns.org`). Sonst behandelt der Browser sie als fremde Site und schickt die
Sitzung gar nicht erst mit.
**Voraussetzung:** alle Adressen unter derselben Domain (`*.honeyy.duckdns.org`). Sonst
behandelt der Browser sie als fremde Site und schickt die Sitzung gar nicht erst mit.
## Entwickeln
```bash
docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:8088
go test ./... # Anmeldeablauf, CSRF, Prüfungen
```
`web/` ist gemountet: Datei ändern, Seite neu laden. Weil der Mount schreibgeschützt ist,
greift dort die `.env`-Konfiguration **nicht** — es gilt die eingebackene `web/config.json`.
Zum Prüfen der `.env` das echte Image nehmen (`docker compose up -d --build`).
Der Dev-Betrieb läuft **ohne Anmeldung**. Die Oberfläche steckt per `go:embed` im Binary —
nach Änderungen an `web/` also neu bauen, ein Reload allein genügt nicht.
```
web/
index.html Desktop
style.css alle Farben in den --p*-Variablen ganz oben
os.js Boot, beide Ansichten, Fenster, Einstellungen
config.json Standardkonfiguration (wird zur Laufzeit aus der .env ersetzt)
honeybar/ die einbettbare Leiste + Vorschauseite
looks/ zwei verworfene Entwürfe (System 7, Win95) — nur Ansicht
main.go Konfiguration, Routen, statische Auslieferung
auth.go OIDC mit PKCE, Sitzungscookie, CSRF
store.go die JSON-Datei: Apps, Nutzer, Einstellungen
auth_test.go falscher OIDC-Anbieter — der ganze Anmeldeweg im Test
web/ index.html · style.css · os.js · honeybar/ · looks/
```
Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`).
Die Tests fahren einen echten OIDC-Anbieter mit eigenem Schlüssel hoch und gehen den Weg
einmal ganz durch. Wenn die grün sind, liegt ein Anmeldeproblem am Anbieter oder an der
Konfiguration, nicht am Code.
## Bauen und schieben
@@ -147,18 +165,16 @@ Neue Symbole sind 12×12-Pixelraster in `os.js` (`GLYPHS`).
## Grenzen, die man kennen sollte
- **localStorage ist pro Domain.** Betriebsart und Röhrenfarbe auf `os.…` kennt die Leiste
auf `notes.…` nicht. Der Umschalter ist ein Schalter, kein geteilter Zustand.
- **Die Lampe ist kein Health-Check.** Bei einer fremden Domain darf der Browser nur sehen,
*dass* geantwortet wurde, nicht *was*. Ein 500er sähe aus wie „online".
- **`config.json` und `honeybar.js` gehen mit `Access-Control-Allow-Origin: *` raus**, weil
die anderen Domains sie holen. Öffentliche Konfiguration — nichts Vertrauliches hinein.
- **Die Lampe an der Kachel ist kein Health-Check.** Bei einer fremden Domain darf der
Browser nur sehen, *dass* geantwortet wurde. Ein 500er sähe aus wie „online".
- **Die App-Liste ist für alle gleich.** Wer angemeldet ist, darf sie ändern — das ist für
eine Handvoll Konten gewollt und wäre für mehr Leute zu wenig.
- **Ein Angemeldeter kann jede Adresse eintragen.** Der Server prüft nur, dass es eine
http(s)-Adresse ist. Das ist zugleich die CORS-Freigabe der Leiste, also nicht
gedankenlos etwas Fremdes eintragen.
## Was als Nächstes ansteht
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann. InkNote
hat mit `ALLOWED_ORIGINS` schon den Haken dafür.
2. **Überall abmelden** in der Leiste. Braucht pro App den CSRF-Weg zum Logout — bei Aether
ein `POST` mit Token, das geht nicht nebenbei über die Domaingrenze.
3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen". Fenster
zeigen dann Daten aus den APIs — die Apps selbst bleiben in ihrem eigenen Tab.
1. **CORS in Aether** für `os.…`, damit der Desktop den Anmeldestand sehen kann.
2. **Überall abmelden** in der Leiste — braucht pro App den CSRF-Weg zum Logout.
3. **Fenster mit Inhalt** statt nur Verweisen: „letzte Dateien", „letzte Notizen".