Files
HoneyOS/README.md
T
marcandClaude Opus 5 c11d5feafe docs: ein Einsetzer fuer alle Dienste, nicht einer je App
Am Host erkennt nginx den Dienst; eine neue App sind zwei Zeilen. Geprueft mit
zwei Diensten gleichzeitig: richtige data-app je Host, unbekannter Host 404,
WebSocket-Upgrade durchgereicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:31:02 +02:00

360 lines
16 KiB
Markdown

# HoneyOS
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 Anmeldung. HoneyOS ist der Startpunkt, der Umschalter und die
gemeinsame Leiste — **kein Rahmen, der die Apps einsperrt** (kein iframe).
**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
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
```
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.
`/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`.
## Anmeldung — zwei Wege, einer davon reicht
**A · Ein Proxy davor macht die Tür** (Pangolin, Authelia, …). Dann in der `.env`:
```dotenv
HONEYOS_PROXY_AUTH=true
# HONEYOS_USER_HEADER=Remote-User # optional, trennt die Einstellungen je Nutzer
```
HoneyOS fragt dann nicht noch einmal — kein zweiter Anmeldeschirm. **Der
Container darf in diesem Betrieb nur über den Proxy erreichbar sein**, denn er
prüft selbst nichts mehr.
**B · HoneyOS meldet selbst an**, mit eigenem Anmeldeschirm über deinen
OIDC-Anbieter. In PocketID einen Client anlegen und als Rückrufadresse **exakt**
`<PUBLIC_URL>/api/auth/callback` eintragen. HoneyOS schickt PKCE mit, braucht
aber trotzdem ein Client-Geheimnis — also **keinen „public client"**.
```dotenv
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32
HONEYOS_OIDC_ISSUER=https://id.example.com
HONEYOS_OIDC_CLIENT_ID=honeyos
HONEYOS_OIDC_CLIENT_SECRET=…
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
```
Wer nicht angemeldet ist, sieht nur den Anmeldeschirm — keine App-Liste, keine
Namen. Die Sitzung ist ein signiertes Cookie ohne Gegenstück im Server: ein
Neustart wirft niemanden hinaus. Sie hält 30 Tage.
**Weder A noch B** heißt: offen für jeden. Sagt beim Start deutlich, dass das
nur zum Entwickeln taugt.
## Wenn HoneyOS auf einer anderen Site liegt als die Apps
Der Browser behandelt `honeyos.duckdns.org` und `cloud.honeyy.duckdns.org` als
**fremde Sites** — `duckdns.org` steht auf der Public-Suffix-Liste, die
gemeinsame Endung zählt also nicht. Folge: Bei der Anfrage aus einer App heraus
schickt der Browser die HoneyOS-Sitzung **nicht** mit.
Damit die Leiste trotzdem eine App-Liste bekommt:
```dotenv
HONEYOS_APPS_KEY=… # openssl rand -hex 16
```
und derselbe Wert im Skript-Tag der App:
```html
<script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
data-app="aether" data-key="…"></script>
```
Der Schlüssel steht damit in der App, die ohnehin nur Berechtigte sehen. Ohne
ihn antwortet `/api/apps` mit 401 — auch im Proxy-Betrieb, gerade weil dieser
Pfad im Proxy von der Anmeldung befreit sein muss.
Zweite Schranke, unabhängig davon: HoneyOS gibt die Liste nur an Ursprünge
heraus, die **selbst als App eingetragen** sind. Eine fremde Seite scheitert
also an CORS, selbst mit richtigem Schlüssel.
**Was auf zwei Sites nicht geht:** der Anmeldestand auf den Kacheln des
Schreibtischs (fragt über die Domaingrenze). Der Name *in der Leiste* geht
weiterhin — den holt sie bei der App selbst, gleicher Ursprung.
Liegen alle Adressen unter derselben Domain (`os.honeyy…`, `cloud.honeyy…`),
entfällt der Schlüssel und alles funktioniert ohne Einschränkung.
## 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
| | Terminal-Modus | Desktop-Modus |
|---|---|---|
| Apps | Kacheln nebeneinander | Symbole auf der Fläche, Doppelklick öffnet |
| 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: 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.
## Wetter
Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
Leiste, in beidem oder nirgends. Zwei getrennte Schalter unter
*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim
Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer
Seite, die er selten aufruft.
Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert;
sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
statt einer. Ohne Stadt bleibt das Wetter aus.
**Der Server holt es, nicht der Browser.** Die Leiste steht auf jeder Seite
jeder App: Fragte sie selbst beim Wetterdienst an, ginge bei jedem Seitenaufruf
aus jeder App eine Anfrage samt IP dorthin, und die CSP jeder App müsste den
fremden Ursprung erlauben. So sieht der Dienst einen Server. Die Antwort gilt
zehn Minuten — das Wetter ändert sich langsamer, als Seiten neu geladen werden.
Verwendet wird [Open-Meteo](https://open-meteo.com): kein Zugangsschlüssel, kein
Konto, nichts einzurichten. Ist der Dienst nicht erreichbar oder das Gerät
offline, verschwindet das Symbol — lieber keins als eine Zahl von gestern.
Ändern lässt sich die Stadt nur auf dem Schreibtisch; ein Klick auf das Symbol
in der Leiste führt dorthin. Die Leiste liest über den Schlüssel und darf nichts
schreiben.
## Die Leiste in Aether / InkNote
Eine Zeile ins `frontend/index.html` der App:
```html
<script defer src="https://os.honeyy.duckdns.org/honeybar/honeybar.js"
data-app="aether"></script>
```
`defer` ist nicht optional: ohne wartet der Seitenaufbau der App auf das Skript.
Ist HoneyOS langsam oder aus, soll die Leiste den Start von Aether nicht bremsen.
`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
(`data-push="none"` legt sie stattdessen darüber). Rechnet die App mit voller Höhe:
```css
.app-shell { height: calc(100vh - var(--honeybar-h, 0px)); }
```
Vorschau ohne Einbau: `/honeybar/demo.html`.
## Die Leiste in fremde Apps einsetzen (Reverse Proxy)
Für Werkzeuge, die man **nicht selbst geschrieben hat**, gibt es keinen
`<script>`-Tag zum Ändern — den setzt der Reverse Proxy ein, während er die
Seite durchreicht. Getestet mit nginx gegen eine App mit gzip und eigener CSP:
```nginx
location / {
proxy_pass http://127.0.0.1:8181;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 1. Unkomprimiert anfordern — in einem gzip-Strom ist nichts zu ersetzen.
proxy_set_header Accept-Encoding "";
# 2. Die CSP der App ersetzen, nicht ergänzen. Zwei CSP-Kopfzeilen bedeuten
# nicht „mehr erlaubt", sondern „beide müssen zustimmen".
proxy_hide_header Content-Security-Policy;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://honeyos.example.com; connect-src 'self' https://honeyos.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" always;
# 3. Nur HTML anfassen; JSON, Bilder und Downloads bleiben unberührt.
sub_filter_types text/html;
sub_filter_once on;
sub_filter '</head>' '<script defer src="https://honeyos.example.com/honeybar/honeybar.js" data-app="werkzeug"></script></head>';
}
```
Dazu muss der Ursprung dieser App **in HoneyOS als App eingetragen** sein — die
Liste ist zugleich die CORS-Freigabe. **Auf den Ursprung genau**: `localhost` und
`127.0.0.1` sind für den Browser zwei verschiedene, ebenso `http` und `https`
oder ein abweichender Port. Passt er nicht, erscheint die Leiste mit leerer
App-Liste und in der Konsole steht ein CORS-Fehler.
**Warum `style-src 'unsafe-inline'`:** Die Leiste bringt ihre Stile mit, im
Shadow DOM. Eine strenge CSP verbietet die — gemessen: die Leiste erscheint,
aber schwarz auf schwarz, ohne Röhre. Das ist eine echte Abschwächung der
CSP dieser App; viele fertige Werkzeuge brauchen `unsafe-inline` ohnehin für
sich selbst, aber wissen sollte man es. (Auflösbar, indem die Leiste ihre Stile
als eigene Datei ausliefert — steht in [IDEAS.md](IDEAS.md).)
**Apps mit COEP** (alles mit WASM oder SharedArrayBuffer — BentoPDF etwa)
lehnen fremde Dateien ab, die sich nicht zur Einbettung bekennen. HoneyOS
liefert die Leiste deshalb mit `Cross-Origin-Resource-Policy: cross-origin`
aus. Ohne das erscheint dort keine Leiste, und in der Konsole steht nur
`ERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoep`.
Das COEP der App bleibt dabei unangetastet — ihre eigenen Fähigkeiten also auch.
**Fertige Beispiele:**
- [`beispiele/bentopdf/`](beispiele/bentopdf/) — ein Dienst, kürzestmöglich.
- [`beispiele/mehrere-apps/`](beispiele/mehrere-apps/) — **ein** Einsetzer für
alle Dienste. Am Host erkennt nginx, welcher gemeint ist; eine neue App sind
zwei Zeilen in den Tabellen oben in der Datei. Das ist der Aufbau, den man
haben will, sobald es mehr als ein Werkzeug ist — nicht ein Behälter je App.
Damit ein gemeinsamer Einsetzer die Dienste erreicht, teilen sie sich ein Netz
(`docker network create honeybar`, in jeder App-Compose-Datei als `external`
eintragen). Ein Host, der in der Tabelle fehlt, bekommt **404** statt einer
geratenen Weiterleitung — sonst wird aus einem Tippfehler in Pangolin eine
stille Umleitung irgendwohin. WebSocket-Verbindungen (Uptime Kuma, Dashboards,
alles mit Live-Anzeige) gehen durch, dafür sorgt `upgrade.conf`.
**Nur Pangolin davor?** Traefik kann Antwortkörper nicht von sich aus ändern;
dafür bräuchte es ein Plugin. Der kürzere Weg ist ein kleiner nginx *zwischen*
Pangolin und der App, der nichts tut außer einsetzen:
```yaml
leiste:
image: nginx:alpine
restart: unless-stopped
volumes: [./leiste.conf:/etc/nginx/conf.d/default.conf:ro]
# Pangolin zeigt jetzt hierher statt direkt auf die App.
```
Geprüft mit BentoPDF: Es schickt keine CSP, also genügen `proxy_pass`,
`Accept-Encoding ""` und die `sub_filter`-Zeile — die CSP-Zeilen oben braucht
nur, wessen App selbst eine schickt.
**Was dabei zu erwarten ist:**
- **Das Layout der fremden App kennt die Leiste nicht.** Rechnet sie mit
`100vh`, wird die Seite um 32 px zu hoch (Bildlaufleiste) — ihr CSS lässt
sich nicht anpassen wie das eigene. Dann hilft `data-push="none"`: Die Leiste
legt sich darüber, statt zu schieben. Verdeckt dafür die oberste Zeile.
- **Apps mit eigener Kopfleiste** sehen doppelt belegt aus. Geschmackssache.
- **Anmeldeseiten, iframes, Downloads** bekommen die Leiste ebenfalls, soweit
sie HTML sind. `sub_filter` unterscheidet das nicht.
- **Andere Proxys:** Caddy braucht ein Zusatzmodul (`replace-response`),
Traefik ein Plugin für den Antwortkörper. nginx kann es ohne Erweiterung.
## Wer ist wo angemeldet
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**:
1. **Die Leiste in der App** fragt ihre eigene App. Gleicher Ursprung → funktioniert
sofort, ohne Änderung an Aether oder InkNote.
2. **Der Desktop auf `os.…`** fragt über die Domaingrenze. Dafür muss die App diesen
Ursprung erlauben:
- 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.
**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
```
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.
```
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/
```
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
```bash
./build.sh # Tag kommt aus `const Version` in main.go, dazu :latest
./build.sh 1.0-test # abweichender Tag, falls nötig
```
**Die Version steht an genau einer Stelle:** `const Version` in `main.go`.
`build.sh` liest sie von dort, die Oberfläche holt sie über `/api/public`. Vorher
stand sie zusätzlich in `os.js` — dann hiess das Image 0.4 und der Schreibtisch
zeigte 0.2.
**Wann sie steigt (Marcs Regel, 25.08.2026): nicht bei jeder Kleinigkeit.**
Sonst steht in zwei Wochen 0.30 da, und die Zahl sagt nichts mehr — eine
Versionsnummer soll unterscheidbare Stände benennen, nicht Arbeitsschritte
zählen.
- **Steigt:** neue Fähigkeit, geänderte Bedienung, neuer Pfad oder neue
Einstellung — alles, wofür man wissen will, ob der Server es schon kann.
- **Steigt nicht:** Fehlerbehebung an frisch Gebautem, Feinschliff am Aussehen,
Text, Dokumentation, Aufräumen.
Das Abbild wird trotzdem gebaut und geschoben — nur unter demselben Tag und
`:latest`. Wer wissen will, was genau läuft, schaut in `git log`; dafür ist die
Versionsnummer zu grob.
## Grenzen, die man kennen sollte
- **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.
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".