feat: Betrieb hinter einem Anmelde-Proxy, Schlüssel für die App-Liste
Marc stellt Pangolin davor und will keinen zweiten Anmeldeschirm. - HONEYOS_PROXY_AUTH=true: HoneyOS prüft nicht mehr selbst, der Start sagt deutlich, dass der Container dann nur über den Proxy erreichbar sein darf. Mit HONEYOS_USER_HEADER bekommt jeder Name seine eigenen Einstellungen. - HONEYOS_APPS_KEY schützt /api/apps. Nötig, weil HoneyOS auf einer anderen Site liegt als die Apps (duckdns.org steht auf der Public-Suffix-Liste) – der Browser schickt die Sitzung dort nicht mit, und der Pfad muss im Proxy von der Anmeldung befreit sein. Beim Durchspielen mit zwei echten Hostnamen fiel ein Loch auf: Ohne eigene Anmeldung gilt jede Anfrage als angemeldet, also gab der Rückfall auf diese Prüfung die Liste an jeden heraus – auch ohne oder mit falschem Schlüssel. Der Rückfall greift jetzt nur noch, wo es wirklich eine Sitzung geben kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
905b136516
commit
1c3218939b
8 files changed
+382
-45
No files matched your search
@@ -6,6 +6,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
@@ -23,16 +25,34 @@ var webFS embed.FS
|
||||
const localSub = "local"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
PublicURL string
|
||||
DataDir string
|
||||
SessionSecret string
|
||||
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 {
|
||||
@@ -62,6 +82,9 @@ func main() {
|
||||
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 != "" {
|
||||
@@ -75,11 +98,23 @@ func main() {
|
||||
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("⚠ kein HONEYOS_OIDC_ISSUER: HoneyOS läuft OHNE Anmeldung.")
|
||||
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)
|
||||
}
|
||||
@@ -119,7 +154,7 @@ func (s *Server) routes() http.Handler {
|
||||
|
||||
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("GET /api/apps", s.appsReadable(s.handleApps))
|
||||
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
||||
|
||||
web, err := fs.Sub(webFS, "web")
|
||||
@@ -127,11 +162,43 @@ func (s *Server) routes() http.Handler {
|
||||
log.Fatal(err)
|
||||
}
|
||||
files := http.FileServer(http.FS(web))
|
||||
mux.Handle("GET /", noCache(files))
|
||||
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
|
||||
@@ -144,7 +211,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
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-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 {
|
||||
@@ -163,6 +230,36 @@ func noCache(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in new issue
Block a user