From 795ec9af053153176a46c8d0d03d9c4b111a6240 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 10 Apr 2026 11:12:54 +0800 Subject: [PATCH] fix(launcher): fall back to token auth on unsupported platforms (#2466) Handle platforms where the dashboard password store is unavailable by treating legacy token auth as initialized, rejecting password setup, and adding platform-specific store stubs and tests. --- web/backend/api/auth.go | 20 +++++- web/backend/api/auth_test.go | 61 +++++++++++++++++++ web/backend/dashboardauth/platform.go | 7 +++ web/backend/dashboardauth/store.go | 2 + .../dashboardauth/store_unsupported.go | 60 ++++++++++++++++++ web/backend/main.go | 20 ++++-- 6 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 web/backend/dashboardauth/platform.go create mode 100644 web/backend/dashboardauth/store_unsupported.go diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 0790a6b76..3cfc3e20d 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -81,8 +81,13 @@ type launcherAuthHandlers struct { loginLimit *loginRateLimiter } +func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool { + return h.store == nil && h.storeErr == nil && h.token != "" +} + // isStoreInitialized safely queries the store. -// Returns (false, nil) when no store is configured (storeErr also nil). +// Returns (true, nil) when legacy token auth is active without a password store. +// Returns (false, nil) when no store/token fallback is configured. // Returns (false, err) on store errors — callers must treat this as a 5xx, not as // "uninitialized", to keep auth fail-closed. // Exception: handleLogin swallows storeErr and falls back to token auth so @@ -95,6 +100,9 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er "to recover, stop the application, delete the database file and restart ", h.storeErr) } + if h.usesLegacyTokenAuth() { + return true, nil + } return false, nil } return h.store.IsInitialized(ctx) @@ -129,7 +137,7 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques } } - if initialized { + if initialized && h.store != nil { // Bcrypt path: verify against the stored hash. var err error ok, err = h.store.VerifyPassword(r.Context(), in) @@ -218,6 +226,14 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + if h.usesLegacyTokenAuth() { + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write( + []byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`), + ) + return + } + if h.store == nil { w.WriteHeader(http.StatusNotImplemented) _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go index 58ffb823a..58f819ec6 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -75,6 +75,67 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { }) } +func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { + key := make([]byte, 32) + const tok = "legacy-fallback-token" + sess := middleware.SessionCookieValue(key, tok) + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: tok, + SessionCookie: sess, + }) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if !body.Initialized { + t.Fatalf("initialized = false, want true in legacy token fallback mode") + } + if body.Authenticated { + t.Fatalf("unexpected authenticated=true: %+v", body) + } + + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { + key := make([]byte, 32) + sess := middleware.SessionCookieValue(key, "legacy-token") + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + DashboardToken: "legacy-token", + SessionCookie: sess, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusNotImplemented { + t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + } +} + func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { key := make([]byte, 32) sess := middleware.SessionCookieValue(key, "tok") diff --git a/web/backend/dashboardauth/platform.go b/web/backend/dashboardauth/platform.go new file mode 100644 index 000000000..25ba5da08 --- /dev/null +++ b/web/backend/dashboardauth/platform.go @@ -0,0 +1,7 @@ +package dashboardauth + +import "errors" + +// ErrUnsupportedPlatform reports that the SQLite-backed password store is not +// available for the current target platform. +var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform") diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go index 44605ba22..870796bba 100644 --- a/web/backend/dashboardauth/store.go +++ b/web/backend/dashboardauth/store.go @@ -1,3 +1,5 @@ +//go:build !mipsle && !netbsd && !(freebsd && arm) + // Package dashboardauth provides a bcrypt-backed SQLite store for the // launcher dashboard password. The database contains a single row (id=1) // with the bcrypt hash; no plaintext is ever persisted. diff --git a/web/backend/dashboardauth/store_unsupported.go b/web/backend/dashboardauth/store_unsupported.go new file mode 100644 index 000000000..204682020 --- /dev/null +++ b/web/backend/dashboardauth/store_unsupported.go @@ -0,0 +1,60 @@ +//go:build mipsle || netbsd || (freebsd && arm) + +package dashboardauth + +import ( + "context" + "fmt" + "path/filepath" + "runtime" +) + +// Store is unavailable on platforms where modernc sqlite/libc does not build. +type Store struct { + path string +} + +// New reports that the password store is unavailable on this platform. +func New(dir string) (*Store, error) { + path := filepath.Join(dir, DBFilename) + s, err := Open(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + return s, nil +} + +// Open reports that the password store is unavailable on this platform. +func Open(path string) (*Store, error) { + return nil, unsupportedPlatformError() +} + +// Close is a no-op for unsupported platforms. +func (s *Store) Close() error { return nil } + +// DBPath returns the configured path, if any. +func (s *Store) DBPath() string { + if s == nil { + return "" + } + return s.path +} + +// IsInitialized reports that the store is unavailable on this platform. +func (s *Store) IsInitialized(context.Context) (bool, error) { + return false, unsupportedPlatformError() +} + +// SetPassword reports that the store is unavailable on this platform. +func (s *Store) SetPassword(context.Context, string) error { + return unsupportedPlatformError() +} + +// VerifyPassword reports that the store is unavailable on this platform. +func (s *Store) VerifyPassword(context.Context, string) (bool, error) { + return false, unsupportedPlatformError() +} + +func unsupportedPlatformError() error { + return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH) +} diff --git a/web/backend/main.go b/web/backend/main.go index d9ea3474c..c5d25f6ef 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -229,11 +229,21 @@ func main() { // Open the bcrypt password store (creates the DB file on first run). authStore, authStoreErr := dashboardauth.New(picoHome) - if authStoreErr != nil { - logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) - authStore = nil - } else { + var passwordStore api.PasswordStore + if authStoreErr == nil { + passwordStore = authStore defer authStore.Close() + } else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) { + logger.InfoC( + "web", + fmt.Sprintf( + "Dashboard password store unavailable on this platform; falling back to token login: %v", + authStoreErr, + ), + ) + authStoreErr = nil + } else { + logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) } // Determine listen address @@ -250,7 +260,7 @@ func main() { api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ DashboardToken: dashboardToken, SessionCookie: dashboardSessionCookie, - PasswordStore: authStore, + PasswordStore: passwordStore, StoreError: authStoreErr, })