Files
HoneyOS/main.go
T
marcandClaude Opus 5 283f9a1850 feat: Backend mit OIDC-Anmeldung, Apps und Einstellungen im Server
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 <noreply@anthropic.com>
2026-08-24 15:20:55 +02:00

270 lines
8.3 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 (
"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
}