// 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" "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.5" 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 } 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} 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)) 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 := `` return bytes.Replace(raw, []byte(""), []byte(" "+meta+"\n "), 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 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") 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.User(sub).Settings, }) } 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 }