Files
HoneyOS/main.go
T
marcandClaude Opus 5 5dbadf212f fix(leiste): mit Cross-Origin-Resource-Policy ausliefern
Apps, die COEP „require-corp" setzen — alles mit WASM oder SharedArrayBuffer,
etwa BentoPDF — lehnen jede fremde Datei ab, die sich nicht ausdruecklich zur
Einbettung bekennt. Genau das ist der Zweck der Leiste, also sagt sie es jetzt.

Die Alternative waere gewesen, im Proxy das COEP der App zu entfernen. Das
haette ihr die Faehigkeiten genommen, fuer die sie es setzt.

Gefunden beim Einsetzen in BentoPDF hinter einem nginx.

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

433 lines
15 KiB
Go

// 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 (
"bytes"
"crypto/subtle"
"embed"
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"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"
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
const Version = "0.9"
type Config struct {
Addr string
PublicURL string
DataDir string
SessionSecret string
// Anmeldung — genau einer der beiden Wege:
//
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
// schon erledigt. HoneyOS fragt dann nicht nach.
//
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
// beim Start deutlich, dass es so nicht ins Netz gehört.
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCRedirect string
ButtonLabel string
AllowedUsers []string
ProxyAuth bool
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
UserHeader string
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
AppsKey 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
wetter *wetterDienst
}
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", "")),
ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true",
UserHeader: env("HONEYOS_USER_HEADER", ""),
AppsKey: env("HONEYOS_APPS_KEY", ""),
}
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 if cfg.ProxyAuth {
log.Println("Anmeldung: übernimmt der vorgelagerte Proxy (HONEYOS_PROXY_AUTH=true).")
if cfg.UserHeader != "" {
log.Printf("Nutzername aus der Kopfzeile %s", cfg.UserHeader)
} else {
log.Println("Kein HONEYOS_USER_HEADER: alle teilen einen Satz Einstellungen.")
}
log.Println("⚠ HoneyOS prüft dann selbst nichts mehr — es darf nur über den Proxy erreichbar sein.")
} else {
log.Println("⚠ weder HONEYOS_OIDC_ISSUER noch HONEYOS_PROXY_AUTH: HoneyOS läuft OHNE Anmeldung.")
log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.")
}
if cfg.AppsKey == "" && (cfg.ProxyAuth || cfg.OIDCIssuer == "") {
log.Println("Hinweis: ohne HONEYOS_APPS_KEY liest die App-Liste jeder, der ihren Pfad kennt.")
}
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, wetter: neuerWetterdienst()}
srv := &http.Server{
Addr: cfg.Addr,
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("HoneyOS %s lauscht auf %s (Daten: %s, Anmeldung: %s)",
Version, 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.appsReadable(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
// Dieselbe Tür wie die App-Liste: Die Leiste steht in einer fremden App und
// kommt dort nur mit dem Schlüssel an Daten.
mux.HandleFunc("GET /api/weather", s.appsReadable(s.handleWeather))
// Die Ortssuche dagegen nur für Angemeldete. Sie reicht einen Suchbegriff an
// einen fremden Dienst weiter — offen stünde damit ein Weg, über HoneyOS
// beliebig dort anzufragen.
mux.HandleFunc("GET /api/weather/search", s.requireAuth(s.handleWeatherSearch))
web, err := fs.Sub(webFS, "web")
if err != nil {
log.Fatal(err)
}
files := http.FileServer(http.FS(web))
index := s.indexHTML(web)
// Die Startseite kommt aus dem Speicher, weil der Schlüssel hineingehört.
// Alles andere liefert der Dateiserver unverändert aus.
mux.Handle("GET /", noCache(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(index))
return
}
files.ServeHTTP(w, r)
})))
return s.cors(mux)
}
// indexHTML holt die Startseite aus dem Binary und legt den Schlüssel als
// Meta-Angabe hinein. Die Seite selbst ist im Proxy geschützt; wer sie sieht,
// darf die Liste ohnehin lesen.
func (s *Server) indexHTML(web fs.FS) []byte {
raw, err := fs.ReadFile(web, "index.html")
if err != nil {
log.Fatalf("index.html fehlt im Binary: %v", err)
}
if s.cfg.AppsKey == "" {
return raw
}
meta := `<meta name="honeyos-key" content="` + htmlAttr(s.cfg.AppsKey) + `" />`
return bytes.Replace(raw, []byte("</head>"), []byte(" "+meta+"\n </head>"), 1)
}
// htmlAttr entschärft den Wert für ein Attribut in doppelten Anführungszeichen.
func htmlAttr(v string) string {
r := strings.NewReplacer(`&`, "&amp;", `"`, "&quot;", `<`, "&lt;", `>`, "&gt;")
return r.Replace(v)
}
/* ── 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, X-HoneyOS-Key")
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")
// Die Leiste wird von fremden Ursprüngen geladen — das ist ihr Zweck.
// Apps, die selbst COEP „require-corp" setzen (alles mit WASM oder
// SharedArrayBuffer, etwa BentoPDF), lehnen sonst jede fremde Datei ab,
// die sich nicht ausdrücklich dazu bekennt. Ohne diese Zeile erscheint
// dort keine Leiste, und im Fehlerprotokoll steht nur ein Kürzel.
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
next.ServeHTTP(w, r)
})
}
// appsReadable lässt die App-Liste zusätzlich mit dem Schlüssel durch.
//
// Der Grund ist eine Browserregel, keine Bequemlichkeit: Liegt HoneyOS auf
// einer anderen Site als die Apps, schickt der Browser die Sitzung bei der
// Anfrage aus einer App heraus nicht mit. Ohne einen zweiten Ausweis bekäme die
// Leiste dort nie eine Liste. Der Schlüssel steht im Skript-Tag der App und ist
// damit nur für den lesbar, der ohnehin an die App herankommt.
func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if key := s.cfg.AppsKey; key != "" {
given := r.Header.Get("X-HoneyOS-Key")
if given == "" {
given = r.URL.Query().Get("key")
}
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
next(w, r, localSub)
return
}
// Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel
// ersetzen könnte: dort gilt jede Anfrage als angemeldet, und der
// Rückfall würde die Liste an jeden herausgeben. Hier ist Schluss.
if !s.auth.Enabled() {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Schlüssel fehlt oder passt nicht"})
return
}
}
s.requireAuth(next)(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,
"version": Version,
})
}
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(),
"version": Version,
})
}
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)
}
// handleApps liefert die Liste **und** das Aussehen. Beides zusammen, weil die
// Leiste in einer fremden App genau eine Anfrage stellen kann: Sie liegt oft auf
// einer anderen Site, kommt also nur mit dem Schlüssel an Daten und hat keine
// Sitzung, aus der sich der Rest ergäbe.
//
// Welche Einstellungen? Die des Angemeldeten, wenn es einen gibt — sonst die des
// Standardnutzers. Bei mehreren Nutzern trägt die Leiste damit die Farbe des
// Standardnutzers; für eine Person stimmt es immer.
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string) {
writeJSON(w, http.StatusOK, map[string]any{
"apps": s.store.Apps(),
"settings": s.store.SettingsFor(sub),
})
}
// handleWeather liefert das Wetter für den eingestellten Ort — oder nichts.
//
// „Nichts" ist hier ein 204 und kein Fehler: Kein Ort eingestellt ist der
// Normalfall, solange niemand einen gewählt hat, und die Leiste soll dafür
// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
set := s.store.SettingsFor(sub)
lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
lon, err2 := strconv.ParseFloat(set["weatherLon"], 64)
if set["weatherCity"] == "" || err1 != nil || err2 != nil {
w.WriteHeader(http.StatusNoContent)
return
}
wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon)
if err != nil {
log.Printf("wetter: %v", err)
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, wetter)
}
func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub string) {
orte, err := s.wetter.Suche(r.Context(), r.URL.Query().Get("q"))
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Ortssuche nicht erreichbar"})
return
}
writeJSON(w, http.StatusOK, orte)
}
func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub 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
}
s.handleApps(w, r, sub)
}
/* ── 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
}