Die Oberfläche zeigte 0.2, während die Images schon 0.4 hiessen: os.js trug eine eigene Konstante, die ich beim Taggen nie mitgezogen habe. Jetzt steht sie nur noch in main.go. build.sh liest sie von dort und vergibt denselben Tag, die Oberfläche holt sie über /api/public und /api/me. Ein Test schlaegt an, falls je wieder eine zweite Version in os.js auftaucht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
13 KiB
Go
385 lines
13 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"
|
|
"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 := `<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 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
|
|
}
|