From 283f9a1850a98d68108087c1f83f8f501703914c Mon Sep 17 00:00:00 2001 From: marc Date: Mon, 24 Aug 2026 15:20:55 +0200 Subject: [PATCH] feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 108 ++----- .gitignore | 1 + Dockerfile | 33 +- README.md | 168 +++++----- auth.go | 296 +++++++++++++++++ auth_test.go | 388 +++++++++++++++++++++++ docker-compose.dev.yml | 14 +- docker-compose.yml | 61 +--- docker-entrypoint.d/20-honeyos-config.sh | 79 ----- go.mod | 13 + go.sum | 18 ++ main.go | 269 ++++++++++++++++ nginx.conf | 30 -- store.go | 225 +++++++++++++ web/config.json | 26 -- web/honeybar/demo.html | 2 +- web/honeybar/honeybar.js | 33 +- web/index.html | 13 +- web/os.js | 369 ++++++++++++++++----- web/style.css | 123 +++++++ 20 files changed, 1831 insertions(+), 438 deletions(-) create mode 100644 auth.go create mode 100644 auth_test.go delete mode 100755 docker-entrypoint.d/20-honeyos-config.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go delete mode 100644 nginx.conf create mode 100644 store.go delete mode 100644 web/config.json diff --git a/.env.example b/.env.example index a38c3d1..7ac8211 100644 --- a/.env.example +++ b/.env.example @@ -1,88 +1,46 @@ # ───────────────────────────────────────────────────────────────────────────── -# HoneyOS — Konfiguration +# HoneyOS # -# cp .env.example .env && anpassen +# cp .env.example .env && ausfüllen # docker compose pull && docker compose up -d # -# Alles hier landet beim Containerstart in config.json und wird vom Browser -# gelesen. Das heißt: **es ist öffentlich.** Adressen und Namen gehören hier -# hinein, Geheimnisse niemals — HoneyOS hat kein Backend, das eines hüten -# könnte. Nach jeder Änderung: docker compose up -d (Neustart genügt). +# Apps werden NICHT hier eingetragen — die legst du in der Oberfläche an +# (Einstellungen → Programme). Sie liegen im Server unter /data/honeyos.json. # ───────────────────────────────────────────────────────────────────────────── HONEYOS_PORT=8088 -# Anzeigename, solange keine App einen besseren nennt (siehe _ME weiter unten). -HONEYOS_USER=marc -HONEYOS_HOSTNAME=honeyos -HONEYOS_VERSION=0.1 +# Wo HoneyOS von außen erreichbar ist — ohne Schrägstrich am Ende. +# Daraus entsteht die Rückrufadresse: /api/auth/callback +HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org -# ── Apps ───────────────────────────────────────────────────────────────────── -# Durchnummeriert ab 1. Eine App entsteht durch ihre _URL — fehlt die, wird die -# Nummer übersprungen. Bis zu 12 Stück. -# -# _URL Pflicht. Wohin der Start springt. -# _ID Kurzname, klein. Muss zu data-app der Leiste passen. Standard: appN -# _NAME Beschriftung. Standard: die ID in Großbuchstaben -# _SUBTITLE Zeile darunter auf der Kachel -# _GLYPH Symbol: cloud | note | box | chip | folder | gear -# _KEY Zifferntaste zum Starten im Terminal-Modus -# _ME optional, siehe „Anmeldung" unten -# _TOKEN_KEY optional, siehe „Anmeldung" unten +# Unterschreibt das Sitzungscookie. Mindestens 32 Zeichen. +# openssl rand -hex 32 +# Wird der Wert getauscht, sind alle Anmeldungen weg — sonst passiert nichts. +HONEYOS_SESSION_SECRET= -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 -HONEYOS_APP_1_KEY=1 -HONEYOS_APP_1_ME=/api/v1/auth/me +# Wo die Daten liegen (Apps + Einstellungen je Nutzer, eine JSON-Datei). +# Das ist das Einzige, was gesichert werden muss. +HONEYOS_DATA_DIR=/srv/honeyos -HONEYOS_APP_2_ID=inknote -HONEYOS_APP_2_NAME=INKNOTE -HONEYOS_APP_2_SUBTITLE=Notizen & Zeichnen -HONEYOS_APP_2_URL=https://notes.honeyy.duckdns.org -HONEYOS_APP_2_GLYPH=note -HONEYOS_APP_2_KEY=2 -HONEYOS_APP_2_ME=/api/auth/me -HONEYOS_APP_2_TOKEN_KEY=inknote_token +# ── Anmeldung ──────────────────────────────────────────────────────────────── +# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum +# Entwickeln gedacht und gehört nicht ins offene Netz. +# +# In PocketID einen Client anlegen und dort als Rückrufadresse exakt +# https://os.honeyy.duckdns.org/api/auth/callback +# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es +# trotzdem — also keinen „public client" anlegen. +HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org +HONEYOS_OIDC_CLIENT_ID=honeyos +HONEYOS_OIDC_CLIENT_SECRET= +# Nur nötig, wenn die Rückrufadresse von PUBLIC_URL abweicht: +# HONEYOS_OIDC_REDIRECT_URL= -# HONEYOS_APP_3_ID=paperwall -# HONEYOS_APP_3_URL=https://… -# HONEYOS_APP_3_GLYPH=box -# HONEYOS_APP_3_KEY=3 +# Beschriftung des einen Knopfes auf dem Anmeldeschirm. +HONEYOS_BUTTON_LABEL=Anmelden mit PocketID -# ───────────────────────────────────────────────────────────────────────────── -# Anmeldung — was HoneyOS kann und was nicht -# ───────────────────────────────────────────────────────────────────────────── -# -# HoneyOS meldet NIEMANDEN an. Es ist eine statische Seite; ein OIDC-Client -# bräuchte ein Backend und ein Client-Geheimnis, und beides gibt es hier nicht -# — absichtlich. Die Anmeldung bleibt Sache von Aether und InkNote, die beide -# an PocketID hängen. -# -# Was HoneyOS kann: **anzeigen**, wer angemeldet ist. Dafür ist _ME da — der -# Pfad, unter dem die App „wer bin ich" beantwortet. Relativ zu _URL oder -# absolut. Aus der Antwort wird der Anzeigename gefischt (display_name, -# displayName, name, username), verschachtelt unter `user` wird auch gefunden. -# -# Zwei Wege, wie das gelesen wird — und sie verhalten sich unterschiedlich: -# -# 1. Die Leiste IN der App (honeybar.js) fragt ihre eigene App. -# Gleicher Ursprung, also **funktioniert das sofort**, ohne dass an Aether -# oder InkNote etwas geändert werden muss. -# -# 2. Der Desktop auf os.… fragt über die Domaingrenze. -# Dafür muss die App diesen Ursprung erlauben, sonst blockt der Browser: -# InkNote: ALLOWED_ORIGINS=https://os.honeyy.duckdns.org -# Aether: CORS mit Access-Control-Allow-Credentials — noch nicht drin -# Ohne das bleibt die Anzeige einfach leer. Nichts geht kaputt, die -# Lampe „online" funktioniert weiter. -# -# _TOKEN_KEY brauchen nur Apps, die einen Bearer-Token im localStorage halten -# statt eines Cookies (InkNote tut das). Der Token ist pro Domain sichtbar — -# von os.… aus also NICHT lesbar. Für die Leiste in der App reicht es. -# -# Und wichtig: alle Adressen müssen unter derselben Domain liegen -# (…​.honeyy.duckdns.org). Sonst behandelt der Browser sie als fremde Site und -# schickt die Sitzung gar nicht erst mit. +# Zweiter Riegel neben den Gruppen in PocketID: nur diese Konten dürfen rein +# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter +# durchlässt. +# HONEYOS_ALLOWED_USERS=marc diff --git a/.gitignore b/.gitignore index 7d4cd07..8fb09dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +devdata/ .env *.tmp shots/ diff --git a/Dockerfile b/Dockerfile index 15078a5..93bc6bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,31 @@ -# HoneyOS — statisch. Kein Build, kein Backend: nginx liefert web/ aus. -# -# Die Konfiguration entsteht beim Start aus der .env — das Skript in -# /docker-entrypoint.d/ schreibt config.json, bevor nginx hochkommt. -FROM nginx:1.27-alpine +# HoneyOS — ein Binary, die Oberfläche steckt per embed mit drin. +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +COPY web/ ./web/ +RUN CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o /honeyos . + +FROM alpine:3.20 LABEL org.opencontainers.image.title="HoneyOS" \ org.opencontainers.image.description="Retro-Schreibtisch und Umschalter für die eigenen Apps" \ org.opencontainers.image.source="https://gitea.honeyy.duckdns.org/Docker/HoneyOS" -COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY docker-entrypoint.d/20-honeyos-config.sh /docker-entrypoint.d/20-honeyos-config.sh -COPY web/ /usr/share/nginx/html/ +# ca-certificates: ohne die scheitert die Anfrage an den OIDC-Anbieter über https. +RUN apk add --no-cache ca-certificates wget && \ + adduser -D -u 1000 honeyos && \ + mkdir -p /data && chown honeyos:honeyos /data -RUN chmod +x /docker-entrypoint.d/20-honeyos-config.sh +COPY --from=build /honeyos /usr/local/bin/honeyos -EXPOSE 80 +USER honeyos +VOLUME /data +EXPOSE 8080 +ENV HONEYOS_ADDR=:8080 HONEYOS_DATA_DIR=/data HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ - CMD wget -qO- http://127.0.0.1/healthz || exit 1 + CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1 + +ENTRYPOINT ["/usr/local/bin/honeyos"] diff --git a/README.md b/README.md index de51015..282751c 100644 --- a/README.md +++ b/README.md @@ -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"> ``` -`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". diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..dfeddef --- /dev/null +++ b/auth.go @@ -0,0 +1,296 @@ +package main + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Anmeldung: HoneyOS führt keine Nutzerverwaltung. Wer beim Anbieter durchkommt, +// ist drin — mit einer optionalen Namensliste als zweitem Riegel. Die Sitzung +// ist ein signiertes Cookie ohne Gegenstück auf dem Server: nichts zu putzen, +// nichts zu verlieren, ein Neustart wirft niemanden hinaus. + +const ( + sessionCookie = "honeyos_session" + csrfCookie = "honeyos_csrf" + stateCookie = "honeyos_state" + sessionTTL = 30 * 24 * time.Hour +) + +type Identity struct { + Sub string + Name string + Picture string +} + +type Auth struct { + cfg Config + secret []byte + + once sync.Once + initErr error + provider *gooidc.Provider + oauth *oauth2.Config + verifier *gooidc.IDTokenVerifier +} + +func NewAuth(cfg Config) *Auth { + return &Auth{cfg: cfg, secret: []byte(cfg.SessionSecret)} +} + +func (a *Auth) Enabled() bool { return a.cfg.OIDCIssuer != "" } + +// setup holt die Anbieterdaten beim ersten Bedarf statt beim Start. Ein +// Anbieter, der gerade neu startet, soll HoneyOS nicht mit hinunterreißen; +// beim nächsten Versuch geht es weiter. +func (a *Auth) setup(ctx context.Context) error { + a.once.Do(func() { + p, err := gooidc.NewProvider(ctx, a.cfg.OIDCIssuer) + if err != nil { + a.initErr = fmt.Errorf("OIDC-Anbieter %s: %w", a.cfg.OIDCIssuer, err) + return + } + a.provider = p + a.verifier = p.Verifier(&gooidc.Config{ClientID: a.cfg.OIDCClientID}) + a.oauth = &oauth2.Config{ + ClientID: a.cfg.OIDCClientID, + ClientSecret: a.cfg.OIDCClientSecret, + RedirectURL: a.cfg.RedirectURL(), + Endpoint: p.Endpoint(), + Scopes: []string{gooidc.ScopeOpenID, "profile"}, + } + }) + if a.initErr != nil { + // Nächster Aufruf darf es erneut versuchen. + a.once = sync.Once{} + err := a.initErr + a.initErr = nil + return err + } + return nil +} + +/* ── Sitzungscookie ──────────────────────────────────────────────────────── */ + +func (a *Auth) sign(payload string) string { + m := hmac.New(sha256.New, a.secret) + m.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func (a *Auth) issueSession(w http.ResponseWriter, r *http.Request, sub string) { + payload := base64.RawURLEncoding.EncodeToString([]byte(sub)) + "." + + strconv.FormatInt(time.Now().Add(sessionTTL).Unix(), 10) + setCookie(w, r, sessionCookie, payload+"."+a.sign(payload), int(sessionTTL.Seconds()), true) + + // Zweites Cookie, absichtlich lesbar: der Browser schickt es als Kopfzeile + // zurück, und nur wer die Seite wirklich geöffnet hat, kann das. + tok := randomString(24) + setCookie(w, r, csrfCookie, tok, int(sessionTTL.Seconds()), false) +} + +func (a *Auth) clearSession(w http.ResponseWriter, r *http.Request) { + setCookie(w, r, sessionCookie, "", -1, true) + setCookie(w, r, csrfCookie, "", -1, false) +} + +// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt. +func (a *Auth) SubFromRequest(r *http.Request) (string, bool) { + if !a.Enabled() { + return localSub, true // offener Betrieb, siehe Config.Open + } + c, err := r.Cookie(sessionCookie) + if err != nil { + return "", false + } + parts := strings.Split(c.Value, ".") + if len(parts) != 3 { + return "", false + } + payload := parts[0] + "." + parts[1] + if !hmac.Equal([]byte(a.sign(payload)), []byte(parts[2])) { + return "", false + } + exp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().Unix() > exp { + return "", false + } + sub, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + return string(sub), true +} + +func (a *Auth) checkCSRF(r *http.Request) error { + if !a.Enabled() { + return nil + } + c, err := r.Cookie(csrfCookie) + if err != nil || c.Value == "" { + return errors.New("kein CSRF-Cookie") + } + if r.Header.Get("X-CSRF-Token") != c.Value { + return errors.New("CSRF-Kopfzeile passt nicht") + } + return nil +} + +/* ── Ablauf ──────────────────────────────────────────────────────────────── */ + +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) { + if err := a.setup(r.Context()); err != nil { + log.Printf("anmeldung: %v", err) + http.Redirect(w, r, "/?fehler=anbieter", http.StatusFound) + return + } + + state := randomString(16) + verifier := oauth2.GenerateVerifier() + // State und Prüfwort reisen zusammen im selben Cookie — sie gehören zum + // selben Versuch und laufen zusammen ab. + setCookie(w, r, stateCookie, state+"|"+verifier, 600, true) + + http.Redirect(w, r, a.oauth.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)), http.StatusFound) +} + +func (a *Auth) Callback(w http.ResponseWriter, r *http.Request, store *Store) { + fail := func(reason string) { + setCookie(w, r, stateCookie, "", -1, true) + http.Redirect(w, r, "/?fehler="+reason, http.StatusFound) + } + + if err := a.setup(r.Context()); err != nil { + log.Printf("rückruf: %v", err) + fail("anbieter") + return + } + + c, err := r.Cookie(stateCookie) + if err != nil { + fail("abgelaufen") + return + } + saved := strings.SplitN(c.Value, "|", 2) + if len(saved) != 2 || r.URL.Query().Get("state") != saved[0] { + fail("state") + return + } + setCookie(w, r, stateCookie, "", -1, true) + + code := r.URL.Query().Get("code") + if code == "" { + fail("kein_code") + return + } + + token, err := a.oauth.Exchange(r.Context(), code, oauth2.VerifierOption(saved[1])) + if err != nil { + log.Printf("tausch fehlgeschlagen: %v", err) + fail("tausch") + return + } + rawID, ok := token.Extra("id_token").(string) + if !ok { + fail("kein_id_token") + return + } + idToken, err := a.verifier.Verify(r.Context(), rawID) + if err != nil { + log.Printf("id-token ungültig: %v", err) + fail("token") + return + } + + var claims struct { + Sub string `json:"sub"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` + } + if err := idToken.Claims(&claims); err != nil || claims.Sub == "" { + fail("claims") + return + } + + name := firstNonEmpty(claims.Name, claims.PreferredUsername, claims.Sub) + if !a.allowed(claims.PreferredUsername, claims.Sub) { + log.Printf("anmeldung abgelehnt: %q steht nicht in HONEYOS_ALLOWED_USERS", name) + fail("nicht_erlaubt") + return + } + + if err := store.SeenUser(claims.Sub, name, claims.Picture); err != nil { + log.Printf("konnte Nutzer nicht merken: %v", err) + } + a.issueSession(w, r, claims.Sub) + log.Printf("angemeldet: %s", name) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (a *Auth) allowed(username, sub string) bool { + if len(a.cfg.AllowedUsers) == 0 { + return true + } + for _, u := range a.cfg.AllowedUsers { + if strings.EqualFold(u, username) || u == sub { + return true + } + } + return false +} + +/* ── Kleinkram ───────────────────────────────────────────────────────────── */ + +func setCookie(w http.ResponseWriter, r *http.Request, name, value string, maxAge int, httpOnly bool) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: httpOnly, + Secure: isHTTPS(r), + SameSite: http.SameSiteLaxMode, + }) +} + +// isHTTPS glaubt der Kopfzeile des Reverse Proxy — ohne sie steht hier immer +// http, und ein Secure-Cookie käme nie an. +func isHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func randomString(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // Ohne Zufall ist jede Sitzung wertlos — dann lieber laut sterben. + panic("kein Zufall verfügbar: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..860da75 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,388 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// Ein Anbieter zum Anfassen: signiert echte RS256-Token und prüft PKCE. Damit +// lässt sich der ganze Anmeldeweg hier durchspielen, statt ihn erst in der +// Produktion gegen PocketID zu debuggen. +type fakeIdP struct { + *httptest.Server + key *rsa.PrivateKey + clientID string + + lastChallenge string // was der Client als code_challenge geschickt hat + verifierSeen string // was er beim Einlösen als code_verifier nachreicht +} + +func newFakeIdP(t *testing.T, clientID string) *fakeIdP { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + f := &fakeIdP{key: key, clientID: clientID} + mux := http.NewServeMux() + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{ + "issuer": f.URL, + "authorization_endpoint": f.URL + "/authorize", + "token_endpoint": f.URL + "/token", + "jwks_uri": f.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + n := base64.RawURLEncoding.EncodeToString(key.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}) // 65537 + writeJSON(w, 200, map[string]any{"keys": []map[string]string{ + {"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", "n": n, "e": e}, + }}) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + f.verifierSeen = r.Form.Get("code_verifier") + writeJSON(w, 200, map[string]any{ + "access_token": "at", + "token_type": "Bearer", + "id_token": f.idToken(t), + }) + }) + + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + return f +} + +func (f *fakeIdP) idToken(t *testing.T) string { + t.Helper() + header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test"} + claims := map[string]any{ + "iss": f.URL, + "sub": "sub-123", + "aud": f.clientID, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "name": "Marc Testfall", + "preferred_username": "marc", + "picture": "https://example.invalid/avatar.png", + } + seg := func(v any) string { + b, _ := json.Marshal(v) + return base64.RawURLEncoding.EncodeToString(b) + } + signing := seg(header) + "." + seg(claims) + sum := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, sum[:]) + if err != nil { + t.Fatal(err) + } + return signing + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +func newTestServer(t *testing.T, issuer string) *Server { + t.Helper() + store, err := OpenStore(dataPath(t.TempDir())) + if err != nil { + t.Fatal(err) + } + cfg := Config{ + PublicURL: "https://os.example.test", + SessionSecret: strings.Repeat("k", 40), + OIDCIssuer: issuer, + OIDCClientID: "honeyos", + OIDCClientSecret: "geheim", + } + return &Server{cfg: cfg, auth: NewAuth(cfg), store: store} +} + +// TestAnmeldeablauf geht den Weg einmal ganz durch: Umleitung zum Anbieter, +// Rückruf mit Code, Sitzungscookie, geschützter Endpunkt. +func TestAnmeldeablauf(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + h := s.routes() + + // 1. Umleitung zum Anbieter — mit State und PKCE + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("Umleitung erwartet, bekam %d: %s", rec.Code, rec.Body) + } + loc, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + q := loc.Query() + if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { + t.Errorf("PKCE fehlt: %v", q) + } + if q.Get("redirect_uri") != "https://os.example.test/api/auth/callback" { + t.Errorf("falsche Rückrufadresse: %q", q.Get("redirect_uri")) + } + state := q.Get("state") + + var stateCk *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == stateCookie { + stateCk = c + } + } + if stateCk == nil { + t.Fatal("kein State-Cookie gesetzt") + } + + // 2. Rückruf mit Code + req := httptest.NewRequest("GET", "/api/auth/callback?code=abc&state="+state, nil) + req.AddCookie(stateCk) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" { + t.Fatalf("Rückruf scheiterte: %d → %s", rec.Code, rec.Header().Get("Location")) + } + if idp.verifierSeen == "" { + t.Error("der Client hat kein code_verifier nachgereicht") + } + + var sess, csrf *http.Cookie + for _, c := range rec.Result().Cookies() { + switch c.Name { + case sessionCookie: + sess = c + case csrfCookie: + csrf = c + } + } + if sess == nil || csrf == nil { + t.Fatal("Sitzungs- oder CSRF-Cookie fehlt") + } + if !sess.HttpOnly { + t.Error("Sitzungscookie muss HttpOnly sein") + } + if csrf.HttpOnly { + t.Error("CSRF-Cookie muss lesbar sein, sonst kann die Seite es nicht mitschicken") + } + + // 3. Geschützter Endpunkt kennt den Nutzer + req = httptest.NewRequest("GET", "/api/me", nil) + req.AddCookie(sess) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("/api/me: %d", rec.Code) + } + var me struct{ Sub, Name, Picture string } + _ = json.Unmarshal(rec.Body.Bytes(), &me) + if me.Sub != "sub-123" || me.Name != "Marc Testfall" { + t.Errorf("falscher Nutzer: %+v", me) + } + if me.Picture == "" { + t.Error("Bild aus dem Token wurde nicht übernommen") + } +} + +func TestOhneAnmeldungKeineDaten(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + h := newTestServer(t, idp.URL).routes() + + for _, path := range []string{"/api/me", "/api/apps"} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", path, nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s ohne Anmeldung: %d, erwartet 401", path, rec.Code) + } + } +} + +func TestGefälschtesCookieZähltNicht(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + h := newTestServer(t, idp.URL).routes() + + req := httptest.NewRequest("GET", "/api/me", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c3ViLTk5OQ.9999999999.gefälscht"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("selbstgebautes Cookie wurde akzeptiert: %d", rec.Code) + } +} + +// TestSchreibenBrauchtCSRF: das Sitzungscookie allein darf nicht reichen, +// sonst könnte eine fremde Seite im Namen des Angemeldeten schreiben. +func TestSchreibenBrauchtCSRF(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + h := s.routes() + sess, csrf := signIn(t, h) + + body := `{"mode":"desktop"}` + req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) + req.AddCookie(sess) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("ohne CSRF-Kopfzeile: %d, erwartet 403", rec.Code) + } + + req = httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body)) + req.AddCookie(sess) + req.AddCookie(csrf) + req.Header.Set("X-CSRF-Token", csrf.Value) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("mit CSRF-Kopfzeile: %d — %s", rec.Code, rec.Body) + } + + // und die Einstellung überlebt + if got := s.store.User("sub-123").Settings["mode"]; got != "desktop" { + t.Errorf("Einstellung nicht gespeichert: %q", got) + } +} + +func TestAppsAnlegenUndPrüfen(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + h := s.routes() + sess, csrf := signIn(t, h) + + put := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(body)) + req.AddCookie(sess) + req.AddCookie(csrf) + req.Header.Set("X-CSRF-Token", csrf.Value) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + if rec := put(`[{"id":"aether","name":"AETHER","url":"https://cloud.example.test"}]`); rec.Code != 200 { + t.Fatalf("gültige App abgelehnt: %d — %s", rec.Code, rec.Body) + } + if apps := s.store.Apps(); len(apps) != 1 || apps[0].ID != "aether" { + t.Fatalf("nicht gespeichert: %+v", apps) + } + + schlecht := []struct{ name, body string }{ + {"leere Adresse", `[{"id":"x","url":""}]`}, + {"Adresse ohne Schema", `[{"id":"x","url":"cloud.example.test"}]`}, + {"javascript:", `[{"id":"x","url":"javascript:alert(1)"}]`}, + {"Kennung mit Leerzeichen", `[{"id":"mein app","url":"https://a.test"}]`}, + {"Kennung doppelt", `[{"id":"a","url":"https://a.test"},{"id":"a","url":"https://b.test"}]`}, + } + for _, c := range schlecht { + if rec := put(c.body); rec.Code != http.StatusBadRequest { + t.Errorf("%s wurde angenommen (%d)", c.name, rec.Code) + } + } + + // nach allen Fehlversuchen steht die gültige Liste unverändert da + if apps := s.store.Apps(); len(apps) != 1 { + t.Errorf("Liste beschädigt: %+v", apps) + } +} + +// TestOffenerBetrieb: ohne OIDC_ISSUER läuft alles ohne Anmeldung — das ist +// gewollt, muss aber auch wirklich funktionieren. +func TestOffenerBetrieb(t *testing.T) { + s := newTestServer(t, "") + h := s.routes() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil)) + if rec.Code != 200 { + t.Fatalf("offener Betrieb verweigert: %d", rec.Code) + } + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("PUT", "/api/settings", strings.NewReader(`{"phosphor":"green"}`))) + if rec.Code != http.StatusNoContent { + t.Fatalf("Schreiben im offenen Betrieb: %d", rec.Code) + } + if s.store.User(localSub).Settings["phosphor"] != "green" { + t.Error("Einstellung im offenen Betrieb nicht gespeichert") + } +} + +// TestLeisteNurFürEingetrageneApps: die App-Liste ist zugleich die +// CORS-Freigabe. Wer nicht drinsteht, bekommt keine Kopfzeile. +func TestLeisteNurFürEingetrageneApps(t *testing.T) { + idp := newFakeIdP(t, "honeyos") + s := newTestServer(t, idp.URL) + h := s.routes() + if err := s.store.SetApps([]App{{ID: "aether", Name: "AETHER", URL: "https://cloud.example.test/"}}); err != nil { + t.Fatal(err) + } + + for origin, want := range map[string]string{ + "https://cloud.example.test": "https://cloud.example.test", + "https://boese.example.test": "", + } { + req := httptest.NewRequest("GET", "/api/apps", nil) + req.Header.Set("Origin", origin) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != want { + t.Errorf("Origin %s: Freigabe %q, erwartet %q", origin, got, want) + } + } +} + +func TestStatischeOberflächeWirdAusgeliefert(t *testing.T) { + h := newTestServer(t, "").routes() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil)) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "HoneyOS") { + t.Fatalf("Startseite fehlt: %d", rec.Code) + } +} + +// signIn spielt eine Anmeldung durch und gibt die beiden Cookies zurück. +func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/auth/login", nil)) + loc, _ := url.Parse(rec.Header().Get("Location")) + var stateCk *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == stateCookie { + stateCk = c + } + } + if stateCk == nil { + t.Fatal("kein State-Cookie") + } + + req := httptest.NewRequest("GET", fmt.Sprintf("/api/auth/callback?code=abc&state=%s", loc.Query().Get("state")), nil) + req.AddCookie(stateCk) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + for _, c := range rec.Result().Cookies() { + switch c.Name { + case sessionCookie: + sess = c + case csrfCookie: + csrf = c + } + } + if sess == nil || csrf == nil { + t.Fatalf("Anmeldung fehlgeschlagen: %d", rec.Code) + } + return sess, csrf +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 68a54e7..b28db03 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,11 +1,17 @@ -# Entwicklung: web/ wird hineingemountet, ein Reload im Browser reicht. +# Entwicklung: baut aus dem Quellstand und läuft OHNE Anmeldung. +# +# docker compose -f docker-compose.dev.yml up -d --build → http://localhost:8088 +# +# Die Oberfläche steckt im Binary (go:embed), ein Reload im Browser reicht +# also nicht — nach Änderungen an web/ neu bauen. services: honeyos: build: . image: honeyos:dev container_name: honeyos-dev ports: - - "${HONEYOS_PORT:-8088}:80" + - "8088:8080" volumes: - - ./web:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./devdata:/data + environment: + - HONEYOS_ADDR=:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 98ea010..7bc90b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,14 @@ # ───────────────────────────────────────────── # HoneyOS — Betrieb aus der Gitea-Registry # -# cp .env.example .env && anpassen +# mkdir -p /srv/honeyos && chown 1000:1000 /srv/honeyos +# cp .env.example .env && ausfüllen # docker login gitea.honeyy.duckdns.org # docker compose pull && docker compose up -d # -# Es gibt nichts zu sichern: HoneyOS hat keine Datenbank und kein Volume. -# Der ganze Zustand ist die .env — und was der Browser sich pro Gerät in -# localStorage merkt (Betriebsart, Röhre). +# 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. # ───────────────────────────────────────────── services: @@ -15,45 +16,17 @@ services: image: gitea.honeyy.duckdns.org/docker/honeyos:latest container_name: honeyos restart: unless-stopped + user: "1000:1000" ports: - - "${HONEYOS_PORT:-8088}:80" + - "${HONEYOS_PORT:-8088}:8080" + volumes: + - ${HONEYOS_DATA_DIR:-/srv/honeyos}:/data environment: - - HONEYOS_USER=${HONEYOS_USER:-} - - HONEYOS_HOSTNAME=${HONEYOS_HOSTNAME:-honeyos} - - HONEYOS_VERSION=${HONEYOS_VERSION:-0.1} - - - HONEYOS_APP_1_ID=${HONEYOS_APP_1_ID:-} - - HONEYOS_APP_1_NAME=${HONEYOS_APP_1_NAME:-} - - HONEYOS_APP_1_SUBTITLE=${HONEYOS_APP_1_SUBTITLE:-} - - HONEYOS_APP_1_URL=${HONEYOS_APP_1_URL:-} - - HONEYOS_APP_1_GLYPH=${HONEYOS_APP_1_GLYPH:-} - - HONEYOS_APP_1_KEY=${HONEYOS_APP_1_KEY:-} - - HONEYOS_APP_1_ME=${HONEYOS_APP_1_ME:-} - - HONEYOS_APP_1_TOKEN_KEY=${HONEYOS_APP_1_TOKEN_KEY:-} - - - HONEYOS_APP_2_ID=${HONEYOS_APP_2_ID:-} - - HONEYOS_APP_2_NAME=${HONEYOS_APP_2_NAME:-} - - HONEYOS_APP_2_SUBTITLE=${HONEYOS_APP_2_SUBTITLE:-} - - HONEYOS_APP_2_URL=${HONEYOS_APP_2_URL:-} - - HONEYOS_APP_2_GLYPH=${HONEYOS_APP_2_GLYPH:-} - - HONEYOS_APP_2_KEY=${HONEYOS_APP_2_KEY:-} - - HONEYOS_APP_2_ME=${HONEYOS_APP_2_ME:-} - - HONEYOS_APP_2_TOKEN_KEY=${HONEYOS_APP_2_TOKEN_KEY:-} - - - HONEYOS_APP_3_ID=${HONEYOS_APP_3_ID:-} - - HONEYOS_APP_3_NAME=${HONEYOS_APP_3_NAME:-} - - HONEYOS_APP_3_SUBTITLE=${HONEYOS_APP_3_SUBTITLE:-} - - HONEYOS_APP_3_URL=${HONEYOS_APP_3_URL:-} - - HONEYOS_APP_3_GLYPH=${HONEYOS_APP_3_GLYPH:-} - - HONEYOS_APP_3_KEY=${HONEYOS_APP_3_KEY:-} - - HONEYOS_APP_3_ME=${HONEYOS_APP_3_ME:-} - - HONEYOS_APP_3_TOKEN_KEY=${HONEYOS_APP_3_TOKEN_KEY:-} - - - HONEYOS_APP_4_ID=${HONEYOS_APP_4_ID:-} - - HONEYOS_APP_4_NAME=${HONEYOS_APP_4_NAME:-} - - HONEYOS_APP_4_SUBTITLE=${HONEYOS_APP_4_SUBTITLE:-} - - HONEYOS_APP_4_URL=${HONEYOS_APP_4_URL:-} - - HONEYOS_APP_4_GLYPH=${HONEYOS_APP_4_GLYPH:-} - - HONEYOS_APP_4_KEY=${HONEYOS_APP_4_KEY:-} - - HONEYOS_APP_4_ME=${HONEYOS_APP_4_ME:-} - - HONEYOS_APP_4_TOKEN_KEY=${HONEYOS_APP_4_TOKEN_KEY:-} + - HONEYOS_PUBLIC_URL=${HONEYOS_PUBLIC_URL:-} + - HONEYOS_SESSION_SECRET=${HONEYOS_SESSION_SECRET:-} + - HONEYOS_OIDC_ISSUER=${HONEYOS_OIDC_ISSUER:-} + - HONEYOS_OIDC_CLIENT_ID=${HONEYOS_OIDC_CLIENT_ID:-} + - HONEYOS_OIDC_CLIENT_SECRET=${HONEYOS_OIDC_CLIENT_SECRET:-} + - HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-} + - HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden} + - HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-} diff --git a/docker-entrypoint.d/20-honeyos-config.sh b/docker-entrypoint.d/20-honeyos-config.sh deleted file mode 100755 index 76feadc..0000000 --- a/docker-entrypoint.d/20-honeyos-config.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/sh -# Schreibt config.json aus den Umgebungsvariablen — läuft vor nginx. -# -# Das nginx-Image führt alles in /docker-entrypoint.d/ beim Start aus. Damit -# braucht HoneyOS keinen eigenen Entrypoint und kein Backend: die Konfiguration -# entsteht einmal beim Hochfahren, danach ist es wieder reines Ausliefern. -# -# Ohne HONEYOS_APP_1_URL bleibt die eingebackene config.json unangetastet — -# ein `docker run` ohne .env zeigt also weiter die Standard-Apps. -set -eu - -TARGET=/usr/share/nginx/html/config.json -MAX_APPS=12 - -# Wert einer Variablen holen, deren Name erst zur Laufzeit feststeht. -get() { eval "printf '%s' \"\${$1:-}\""; } - -# Für JSON entschärfen: Backslash und Anführungszeichen. Zeilenumbrüche haben -# in diesen Feldern nichts zu suchen und fliegen raus. -esc() { printf '%s' "$1" | tr -d '\n\r' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'; } - -# Ein Feld nur schreiben, wenn es gesetzt ist — leere Schlüssel wären Lärm. -field() { - [ -n "$2" ] || return 0 - printf ',"%s":"%s"' "$1" "$(esc "$2")" -} - -if [ -z "$(get HONEYOS_APP_1_URL)" ]; then - echo "honeyos: keine HONEYOS_APP_*-Variablen gesetzt — eingebackene config.json bleibt" - exit 0 -fi - -if [ ! -w "$(dirname "$TARGET")" ]; then - echo "honeyos: $(dirname "$TARGET") ist nicht beschreibbar (read-only mount?) — config.json bleibt wie sie ist" - exit 0 -fi - -tmp="${TARGET}.tmp" -{ - printf '{' - printf '"user":"%s"' "$(esc "$(get HONEYOS_USER)")" - printf ',"hostname":"%s"' "$(esc "${HONEYOS_HOSTNAME:-honeyos}")" - printf ',"version":"%s"' "$(esc "${HONEYOS_VERSION:-0.1}")" - printf ',"apps":[' - - n=1 - first=1 - while [ "$n" -le "$MAX_APPS" ]; do - url=$(get "HONEYOS_APP_${n}_URL") - if [ -z "$url" ]; then - n=$((n + 1)) - continue - fi - - id=$(get "HONEYOS_APP_${n}_ID") - [ -n "$id" ] || id="app${n}" - name=$(get "HONEYOS_APP_${n}_NAME") - [ -n "$name" ] || name=$(printf '%s' "$id" | tr '[:lower:]' '[:upper:]') - - [ "$first" -eq 1 ] || printf ',' - first=0 - - printf '{"id":"%s","name":"%s","url":"%s"' "$(esc "$id")" "$(esc "$name")" "$(esc "$url")" - field subtitle "$(get "HONEYOS_APP_${n}_SUBTITLE")" - field glyph "$(get "HONEYOS_APP_${n}_GLYPH")" - field key "$(get "HONEYOS_APP_${n}_KEY")" - field me "$(get "HONEYOS_APP_${n}_ME")" - field tokenKey "$(get "HONEYOS_APP_${n}_TOKEN_KEY")" - printf '}' - - n=$((n + 1)) - done - - printf ']}' -} > "$tmp" - -mv "$tmp" "$TARGET" -count=$(tr -cd '{' < "$TARGET" | wc -c) -echo "honeyos: config.json geschrieben ($((count - 1)) App(s))" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f1049e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module honeyos + +go 1.23 + +require ( + github.com/coreos/go-oidc/v3 v3.11.0 + golang.org/x/oauth2 v0.23.0 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.2 // indirect + golang.org/x/crypto v0.25.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..316dce8 --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3dfa2df --- /dev/null +++ b/main.go @@ -0,0 +1,269 @@ +// HoneyOS — Schreibtisch und Umschalter für die eigenen Apps. +// +// Ein Binary: der Server hält Apps und Einstellungen, die Oberfläche steckt +// per embed mit drin. Angemeldet wird über den eigenen OIDC-Anbieter; ohne +// OIDC_ISSUER läuft alles offen — bequem zum Entwickeln, nichts für außen. +package main + +import ( + "embed" + "encoding/json" + "io/fs" + "log" + "net/http" + "os" + "strings" + "time" +) + +//go:embed all:web +var webFS embed.FS + +// localSub ist die Kennung im offenen Betrieb — dann gibt es genau einen Nutzer. +const localSub = "local" + +type Config struct { + Addr string + PublicURL string + DataDir string + SessionSecret string + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + OIDCRedirect string + ButtonLabel string + AllowedUsers []string +} + +func (c Config) RedirectURL() string { + if c.OIDCRedirect != "" { + return c.OIDCRedirect + } + return strings.TrimRight(c.PublicURL, "/") + "/api/auth/callback" +} + +type Server struct { + cfg Config + auth *Auth + store *Store +} + +func main() { + log.SetFlags(log.LstdFlags) + + cfg := Config{ + Addr: env("HONEYOS_ADDR", ":80"), + PublicURL: env("HONEYOS_PUBLIC_URL", ""), + DataDir: env("HONEYOS_DATA_DIR", "/data"), + SessionSecret: env("HONEYOS_SESSION_SECRET", ""), + OIDCIssuer: strings.TrimRight(env("HONEYOS_OIDC_ISSUER", ""), "/"), + OIDCClientID: env("HONEYOS_OIDC_CLIENT_ID", ""), + OIDCClientSecret: env("HONEYOS_OIDC_CLIENT_SECRET", ""), + OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""), + ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"), + AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")), + } + + if cfg.OIDCIssuer != "" { + switch { + case cfg.SessionSecret == "": + log.Fatal("HONEYOS_SESSION_SECRET fehlt — ohne Schlüssel keine Sitzung (openssl rand -hex 32)") + case len(cfg.SessionSecret) < 32: + log.Fatal("HONEYOS_SESSION_SECRET ist zu kurz — mindestens 32 Zeichen") + case cfg.OIDCClientID == "": + log.Fatal("HONEYOS_OIDC_CLIENT_ID fehlt") + case cfg.PublicURL == "" && cfg.OIDCRedirect == "": + log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse") + } + } else { + log.Println("⚠ kein HONEYOS_OIDC_ISSUER: HoneyOS läuft OHNE Anmeldung.") + log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.") + } + + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { + log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err) + } + store, err := OpenStore(dataPath(cfg.DataDir)) + if err != nil { + log.Fatal(err) + } + + s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store} + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: s.routes(), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("HoneyOS lauscht auf %s (Daten: %s, Anmeldung: %s)", + cfg.Addr, dataPath(cfg.DataDir), map[bool]string{true: cfg.OIDCIssuer, false: "aus"}[s.auth.Enabled()]) + log.Fatal(srv.ListenAndServe()) +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + }) + + // Ohne Anmeldung lesbar: nur was der Anmeldeschirm braucht. + mux.HandleFunc("GET /api/public", s.handlePublic) + mux.HandleFunc("GET /api/auth/login", s.auth.Login) + mux.HandleFunc("GET /api/auth/callback", func(w http.ResponseWriter, r *http.Request) { + s.auth.Callback(w, r, s.store) + }) + mux.HandleFunc("POST /api/auth/logout", s.handleLogout) + + mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe)) + mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings)) + mux.HandleFunc("GET /api/apps", s.requireAuth(s.handleApps)) + mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps)) + + web, err := fs.Sub(webFS, "web") + if err != nil { + log.Fatal(err) + } + files := http.FileServer(http.FS(web)) + mux.Handle("GET /", noCache(files)) + + return s.cors(mux) +} + +/* ── Zwischenschichten ───────────────────────────────────────────────────── */ + +// cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die +// App-Liste ist damit zugleich die Freigabeliste; wer nicht darin steht, kommt +// auch nicht an die Sitzung. +func (s *Server) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && s.store.AppOrigins()[origin] { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Set("Access-Control-Allow-Credentials", "true") + h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") + h.Set("Access-Control-Allow-Methods", "GET, PUT, POST") + h.Add("Vary", "Origin") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +} + +func noCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sub, ok := s.auth.SubFromRequest(r) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nicht angemeldet"}) + return + } + if r.Method != http.MethodGet { + if err := s.auth.checkCSRF(r); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + } + next(w, r, sub) + } +} + +/* ── Handler ─────────────────────────────────────────────────────────────── */ + +func (s *Server) handlePublic(w http.ResponseWriter, r *http.Request) { + _, signedIn := s.auth.SubFromRequest(r) + writeJSON(w, http.StatusOK, map[string]any{ + "oidc": s.auth.Enabled(), + "buttonLabel": s.cfg.ButtonLabel, + "signedIn": signedIn, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.auth.clearSession(w, r) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, sub string) { + u := s.store.User(sub) + writeJSON(w, http.StatusOK, map[string]any{ + "sub": sub, + "name": firstNonEmpty(u.Name, "marc"), + "picture": u.Picture, + "settings": u.Settings, + "oidc": s.auth.Enabled(), + }) +} + +func (s *Server) handleSetSettings(w http.ResponseWriter, r *http.Request, sub string) { + var in map[string]string + if err := decode(r, &in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := s.store.SetSettings(sub, in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, _ string) { + writeJSON(w, http.StatusOK, s.store.Apps()) +} + +func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, _ string) { + var in []App + if err := decode(r, &in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := s.store.SetApps(in); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, s.store.Apps()) +} + +/* ── Kleinkram ───────────────────────────────────────────────────────────── */ + +func decode(r *http.Request, into any) error { + defer r.Body.Close() + dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64<<10)) + return dec.Decode(into) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func splitList(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 06da419..0000000 --- a/nginx.conf +++ /dev/null @@ -1,30 +0,0 @@ -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - # Für den Healthcheck im Dockerfile und für den Reverse Proxy. - location = /healthz { - access_log off; - add_header Content-Type text/plain; - return 200 "ok\n"; - } - - # Das statische Blech: alles kurz cachen, nichts endlos. - location / { - try_files $uri $uri/ /index.html; - add_header Cache-Control "no-cache"; - } - - # config.json und honeybar.js werden von den anderen Domains geholt. - # Beides ist öffentliche Konfiguration ohne Geheimnisse — CORS offen ist - # hier die richtige Antwort und keine Bequemlichkeit. - location ~* \.(json|js)$ { - add_header Access-Control-Allow-Origin "*"; - add_header Cache-Control "no-cache"; - } - - gzip on; - gzip_types text/css application/javascript application/json image/svg+xml; -} diff --git a/store.go b/store.go new file mode 100644 index 0000000..c0463ac --- /dev/null +++ b/store.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +// Der ganze Zustand von HoneyOS passt in eine JSON-Datei: eine Handvoll Apps +// und pro Nutzer ein paar kosmetische Einstellungen. Eine Datenbank wäre hier +// mehr Betrieb als Nutzen — die Datei lässt sich lesen, sichern und im Zweifel +// mit einem Editor reparieren. Geschrieben wird atomar (tmp + rename), damit +// ein Absturz mitten im Schreiben keine halbe Datei hinterlässt. + +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Subtitle string `json:"subtitle,omitempty"` + URL string `json:"url"` + Glyph string `json:"glyph,omitempty"` + Key string `json:"key,omitempty"` + Me string `json:"me,omitempty"` + TokenKey string `json:"tokenKey,omitempty"` +} + +type User struct { + Name string `json:"name,omitempty"` + Picture string `json:"picture,omitempty"` + Settings map[string]string `json:"settings,omitempty"` +} + +type data struct { + Apps []App `json:"apps"` + Users map[string]*User `json:"users"` +} + +type Store struct { + mu sync.RWMutex + path string + d data +} + +const ( + maxApps = 24 + maxSettings = 40 + maxFieldLen = 200 +) + +var ( + idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,31}$`) + knownGlyphs = map[string]bool{"cloud": true, "note": true, "box": true, "chip": true, "folder": true, "gear": true, "globe": true, "disk": true} + settingKeyRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{0,31}$`) +) + +func OpenStore(path string) (*Store, error) { + s := &Store{path: path, d: data{Apps: []App{}, Users: map[string]*User{}}} + + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return s, s.save() // gleich anlegen, damit ein Rechteproblem sofort auffällt + } + if err != nil { + return nil, fmt.Errorf("lese %s: %w", path, err) + } + if err := json.Unmarshal(raw, &s.d); err != nil { + return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", path, err) + } + if s.d.Apps == nil { + s.d.Apps = []App{} + } + if s.d.Users == nil { + s.d.Users = map[string]*User{} + } + return s, nil +} + +// save schreibt die Datei. Aufrufer hält die Sperre. +func (s *Store) save() error { + raw, err := json.MarshalIndent(s.d, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, append(raw, '\n'), 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func (s *Store) Apps() []App { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]App, len(s.d.Apps)) + copy(out, s.d.Apps) + return out +} + +// SetApps ersetzt die ganze Liste. Die Oberfläche schickt sie immer vollständig +// — für ein Dutzend Einträge ist das einfacher und weniger fehleranfällig als +// einzelne Endpunkte für Anlegen, Ändern, Löschen und Umsortieren. +func (s *Store) SetApps(apps []App) error { + if len(apps) > maxApps { + return fmt.Errorf("höchstens %d Apps", maxApps) + } + seen := map[string]bool{} + cleaned := make([]App, 0, len(apps)) + + for i, a := range apps { + a.ID = strings.ToLower(strings.TrimSpace(a.ID)) + a.Name = strings.TrimSpace(a.Name) + a.Subtitle = strings.TrimSpace(a.Subtitle) + a.URL = strings.TrimSpace(a.URL) + a.Glyph = strings.TrimSpace(a.Glyph) + a.Key = strings.TrimSpace(a.Key) + a.Me = strings.TrimSpace(a.Me) + a.TokenKey = strings.TrimSpace(a.TokenKey) + + if !idPattern.MatchString(a.ID) { + return fmt.Errorf("App %d: Kennung muss klein geschrieben sein und mit Buchstabe oder Ziffer beginnen", i+1) + } + if seen[a.ID] { + return fmt.Errorf("Kennung %q kommt doppelt vor", a.ID) + } + seen[a.ID] = true + + if a.Name == "" { + a.Name = strings.ToUpper(a.ID) + } + u, err := url.Parse(a.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("App %q: Adresse muss mit http:// oder https:// beginnen", a.ID) + } + if a.Glyph != "" && !knownGlyphs[a.Glyph] { + a.Glyph = "" + } + if len([]rune(a.Key)) > 1 { + return fmt.Errorf("App %q: Taste ist ein einzelnes Zeichen", a.ID) + } + for _, f := range []string{a.Name, a.Subtitle, a.URL, a.Me, a.TokenKey} { + if len(f) > maxFieldLen { + return fmt.Errorf("App %q: ein Feld ist länger als %d Zeichen", a.ID, maxFieldLen) + } + } + cleaned = append(cleaned, a) + } + + s.mu.Lock() + defer s.mu.Unlock() + s.d.Apps = cleaned + return s.save() +} + +func (s *Store) User(sub string) User { + s.mu.RLock() + defer s.mu.RUnlock() + if u, ok := s.d.Users[sub]; ok { + cp := *u + cp.Settings = map[string]string{} + for k, v := range u.Settings { + cp.Settings[k] = v + } + return cp + } + return User{Settings: map[string]string{}} +} + +// SeenUser hält Name und Bild aus dem Anbieter nach, ohne Einstellungen anzufassen. +func (s *Store) SeenUser(sub, name, picture string) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.d.Users[sub] + if !ok { + u = &User{Settings: map[string]string{}} + s.d.Users[sub] = u + } + if u.Name == name && u.Picture == picture { + return nil // nichts zu schreiben + } + u.Name, u.Picture = name, picture + return s.save() +} + +func (s *Store) SetSettings(sub string, in map[string]string) error { + if len(in) > maxSettings { + return fmt.Errorf("zu viele Einstellungen") + } + clean := map[string]string{} + for k, v := range in { + if !settingKeyRe.MatchString(k) || len(v) > maxFieldLen { + continue // stillschweigend verwerfen: die Oberfläche schickt nur Bekanntes + } + clean[k] = v + } + + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.d.Users[sub] + if !ok { + u = &User{} + s.d.Users[sub] = u + } + u.Settings = clean + return s.save() +} + +// AppOrigins nennt die Ursprünge der eingetragenen Apps. Nur sie dürfen die +// Leiste mit Anmeldedaten abfragen — die Liste ist damit ihre eigene Freigabe. +func (s *Store) AppOrigins() map[string]bool { + s.mu.RLock() + defer s.mu.RUnlock() + out := map[string]bool{} + for _, a := range s.d.Apps { + if u, err := url.Parse(a.URL); err == nil && u.Host != "" { + out[u.Scheme+"://"+u.Host] = true + } + } + return out +} + +func dataPath(dir string) string { return filepath.Join(dir, "honeyos.json") } diff --git a/web/config.json b/web/config.json deleted file mode 100644 index 076caaf..0000000 --- a/web/config.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "user": "marc", - "hostname": "honeyos", - "version": "0.1", - "apps": [ - { - "id": "aether", - "name": "AETHER", - "subtitle": "Cloud & Dateien", - "url": "https://cloud.honeyy.duckdns.org", - "glyph": "cloud", - "key": "1", - "me": "/api/v1/auth/me" - }, - { - "id": "inknote", - "name": "INKNOTE", - "subtitle": "Notizen & Zeichnen", - "url": "https://notes.honeyy.duckdns.org", - "glyph": "note", - "key": "2", - "me": "/api/auth/me", - "tokenKey": "inknote_token" - } - ] -} diff --git a/web/honeybar/demo.html b/web/honeybar/demo.html index 48a56c5..2dfef5d 100644 --- a/web/honeybar/demo.html +++ b/web/honeybar/demo.html @@ -36,6 +36,6 @@

Eine App, die mit 100vh rechnet, nimmt --honeybar-h ab.

- + diff --git a/web/honeybar/honeybar.js b/web/honeybar/honeybar.js index 5785551..e97855e 100644 --- a/web/honeybar/honeybar.js +++ b/web/honeybar/honeybar.js @@ -12,7 +12,7 @@ * * data-Attribute am