Der Schluessel kennt keinen Nutzer und landete beim Standardnutzer — auch dann, wenn die App unter derselben Site liegt und der Browser das HoneyOS-Cookie laengst mitschickt (cloud.honeyos.… und honeyos.… sind dieselbe Site, duckdns.org steht auf der Public-Suffix-Liste). Damit trug die HoneyBar in Aether die Farben eines fremden Nutzers. Jetzt sieht /api/apps erst nach der Sitzung, und nur bei eigener Anmeldung: Im offenen Betrieb und hinter einem Anmelde-Proxy gilt jede Anfrage als angemeldet, dort bleibt der Schluessel das letzte Wort. Test dazu, mit zwei Nutzern, damit der Rueckfall auf den einzigen Nutzer das Ergebnis nicht zufaellig richtig aussehen laesst.
434 lines
16 KiB
Go
434 lines
16 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 (
|
|
"cmp"
|
|
"honeyos/internal/auth"
|
|
"honeyos/internal/config"
|
|
"honeyos/internal/store"
|
|
"honeyos/internal/weather"
|
|
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"embed"
|
|
"encoding/json"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed all:web
|
|
var webFS embed.FS
|
|
|
|
// glyphen sind die Pixelraster aus web/glyphs.json. Der Schreibtisch holt sich
|
|
// dieselbe Datei selbst; die HoneyBar in einer fremden App kann das nicht — sie
|
|
// ist bewusst eine einzelne Datei ohne zweite Anfrage. Also reist das Raster
|
|
// mit der App-Liste mit. Eine Quelle, zwei Wege, kein Auseinanderlaufen.
|
|
var glyphen = ladeGlyphen()
|
|
|
|
func ladeGlyphen() map[string][]string {
|
|
b, err := webFS.ReadFile("web/glyphs.json")
|
|
if err != nil {
|
|
log.Printf("glyphs.json fehlt: %v", err)
|
|
return nil
|
|
}
|
|
var g map[string][]string
|
|
if err := json.Unmarshal(b, &g); err != nil {
|
|
log.Printf("glyphs.json unlesbar: %v", err)
|
|
return nil
|
|
}
|
|
return g
|
|
}
|
|
|
|
// 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 = "1.1"
|
|
|
|
type Server struct {
|
|
cfg config.Config
|
|
auth *auth.Auth
|
|
store *store.Store
|
|
wetter *weather.Dienst
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags)
|
|
|
|
cfg := config.Config{
|
|
Addr: config.Env("HONEYOS_ADDR", ":80"),
|
|
PublicURL: config.Env("HONEYOS_PUBLIC_URL", ""),
|
|
DataDir: config.Env("HONEYOS_DATA_DIR", "/data"),
|
|
SessionSecret: config.Env("HONEYOS_SESSION_SECRET", ""),
|
|
OIDCIssuer: strings.TrimRight(config.Env("HONEYOS_OIDC_ISSUER", ""), "/"),
|
|
OIDCClientID: config.Env("HONEYOS_OIDC_CLIENT_ID", ""),
|
|
OIDCClientSecret: config.Env("HONEYOS_OIDC_CLIENT_SECRET", ""),
|
|
OIDCRedirect: config.Env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
|
ButtonLabel: config.Env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
|
AllowedUsers: splitList(config.Env("HONEYOS_ALLOWED_USERS", "")),
|
|
ProxyAuth: config.Env("HONEYOS_PROXY_AUTH", "") == "true",
|
|
UserHeader: config.Env("HONEYOS_USER_HEADER", ""),
|
|
AppsKey: config.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)
|
|
}
|
|
st, err := store.Open(store.DataPath(cfg.DataDir))
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
s := &Server{cfg: cfg, auth: auth.New(cfg), store: st, wetter: weather.Neu()}
|
|
|
|
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, store.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 HoneyBar 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(`&`, "&", `"`, """, `<`, "<", `>`, ">")
|
|
return r.Replace(v)
|
|
}
|
|
|
|
/* ── Zwischenschichten ───────────────────────────────────────────────────── */
|
|
|
|
// cors öffnet die HoneyBar 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) {
|
|
// no-store, nicht no-cache: Die Oberflaeche und vor allem honeybar.js
|
|
// muessen bei jedem Aufruf frisch kommen. Sie werden ueber den eigenen
|
|
// Ursprung der fremden App ausgeliefert und geraten damit in deren
|
|
// Zwischenspeicher — und in den ihres Service Workers, falls sie einen
|
|
// hat. Eine alte HoneyBar dort ist von aussen nicht zu erkennen: Sie
|
|
// sieht aus wie ein Fehler in HoneyOS.
|
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
|
// Die HoneyBar 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 HoneyBar, 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
|
|
// HoneyBar 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) {
|
|
// Erst die Sitzung, dann der Schlüssel. Umgekehrt wäre bequemer zu
|
|
// lesen, aber falsch: Der Schlüssel kennt keinen Nutzer und landet
|
|
// beim Standardnutzer. Liegt die App unter derselben Site wie HoneyOS
|
|
// (`cloud.honeyos.…` und `honeyos.…` sind dieselbe), schickt der
|
|
// Browser die Sitzung längst mit — dann soll die HoneyBar dort auch
|
|
// die Farben, das Wetter und später die Reihenfolge **dieses** Nutzers
|
|
// bekommen und nicht die eines fremden.
|
|
//
|
|
// Nur bei eigener Anmeldung: Ohne sie gilt jede Anfrage als angemeldet
|
|
// (offener Betrieb, Proxy davor), und das hier wäre eine offene Tür.
|
|
if s.auth.Enabled() {
|
|
if sub, ok := s.auth.SubFromRequest(r); ok {
|
|
next(w, r, sub)
|
|
return
|
|
}
|
|
}
|
|
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, store.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": cmp.Or(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
|
|
// HoneyBar 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 HoneyBar 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),
|
|
"glyphs": glyphen,
|
|
})
|
|
}
|
|
|
|
// 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 HoneyBar 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 []store.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 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
|
|
}
|