mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
refactor(web): switch dashboard auth from tokens to passwords (#2608)
- replace token-based launcher auth with password-based login and sessions - migrate legacy launcher_token values into bcrypt-backed password storage - add one-shot local auto-login bootstrap - update config UI, i18n strings, docs, and auth-related tests
This commit is contained in:
+30
-62
@@ -12,9 +12,8 @@ import (
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
|
||||
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
|
||||
// static-token comparison.
|
||||
// PasswordStore is the interface for dashboard password persistence.
|
||||
// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
|
||||
type PasswordStore interface {
|
||||
IsInitialized(ctx context.Context) (bool, error)
|
||||
SetPassword(ctx context.Context, plain string) error
|
||||
@@ -23,18 +22,13 @@ type PasswordStore interface {
|
||||
|
||||
// LauncherAuthRouteOpts configures dashboard auth handlers.
|
||||
type LauncherAuthRouteOpts struct {
|
||||
// DashboardToken is the fallback plaintext token used when PasswordStore is
|
||||
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
|
||||
DashboardToken string
|
||||
SessionCookie string
|
||||
SecureCookie func(*http.Request) bool
|
||||
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
|
||||
// initialized, web-form login verifies against the stored hash instead of
|
||||
// the plaintext DashboardToken.
|
||||
SessionCookie string
|
||||
SecureCookie func(*http.Request) bool
|
||||
// PasswordStore enables password login. It must be non-nil for auth to work.
|
||||
PasswordStore PasswordStore
|
||||
// StoreError holds the error returned when opening the password store. When
|
||||
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
|
||||
// message instead of an opaque 501/503.
|
||||
// non-nil and PasswordStore is nil, auth endpoints fail closed with a
|
||||
// recovery message.
|
||||
StoreError error
|
||||
}
|
||||
|
||||
@@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
|
||||
secure = middleware.DefaultLauncherDashboardSecureCookie
|
||||
}
|
||||
h := &launcherAuthHandlers{
|
||||
token: opts.DashboardToken,
|
||||
sessionCookie: opts.SessionCookie,
|
||||
secureCookie: secure,
|
||||
store: opts.PasswordStore,
|
||||
@@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
|
||||
}
|
||||
|
||||
type launcherAuthHandlers struct {
|
||||
token string
|
||||
sessionCookie string
|
||||
secureCookie func(*http.Request) bool
|
||||
store PasswordStore
|
||||
@@ -81,29 +73,18 @@ 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 (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
|
||||
// that a corrupt DB does not lock out all access.
|
||||
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
|
||||
if h.store == nil {
|
||||
if h.storeErr != nil {
|
||||
return false, fmt.Errorf(
|
||||
"password store unavailable (%w); "+
|
||||
"to recover, stop the application, delete the database file and restart ",
|
||||
"to recover, stop the application, reset dashboard password storage, and restart",
|
||||
h.storeErr)
|
||||
}
|
||||
if h.usesLegacyTokenAuth() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
return false, fmt.Errorf("password store not configured")
|
||||
}
|
||||
return h.store.IsInitialized(ctx)
|
||||
}
|
||||
@@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
in := strings.TrimSpace(body.Password)
|
||||
var ok bool
|
||||
|
||||
initialized, initErr := h.isStoreInitialized(r.Context())
|
||||
if initErr != nil {
|
||||
if h.storeErr != nil {
|
||||
// Store failed to open at startup — token login remains available.
|
||||
initialized = false
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "%v", initErr)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeErrorf(w, "%v", initErr)
|
||||
return
|
||||
}
|
||||
if !initialized {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":"password has not been set"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if initialized && h.store != nil {
|
||||
// Bcrypt path: verify against the stored hash.
|
||||
var err error
|
||||
ok, err = h.store.VerifyPassword(r.Context(), in)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "password verification failed: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Fallback: constant-time compare against the plaintext token.
|
||||
ok = len(in) == len(h.token) &&
|
||||
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
|
||||
ok, err := h.store.VerifyPassword(r.Context(), in)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
writeErrorf(w, "password verification failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
|
||||
@@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
||||
// handleSetup sets or changes the dashboard password.
|
||||
//
|
||||
// Rules:
|
||||
// - If the store has no password yet, the endpoint is open (no session required).
|
||||
// - If the store has no password yet, anyone who can reach the setup endpoint
|
||||
// may initialize the password.
|
||||
// - If a password is already set, the caller must hold a valid session cookie.
|
||||
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"}`))
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
if h.storeErr != nil {
|
||||
writeErrorf(w, "password store unavailable: %v", h.storeErr)
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+142
-44
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -12,17 +14,43 @@ import (
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
)
|
||||
|
||||
func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = 0x55
|
||||
type fakePasswordStore struct {
|
||||
initialized bool
|
||||
password string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
const tok = "dashboard-test-token-9"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
return s.initialized, nil
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
s.password = plain
|
||||
s.initialized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
|
||||
if s.err != nil {
|
||||
return false, s.err
|
||||
}
|
||||
return s.initialized && plain == s.password, nil
|
||||
}
|
||||
|
||||
func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
const password = "dashboard-test-password"
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{initialized: true, password: password}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
t.Run("status_unauthenticated", func(t *testing.T) {
|
||||
@@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
|
||||
t.Run("login_ok", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
mux.ServeHTTP(rec, req)
|
||||
@@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
const tok = "legacy-fallback-token"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
||||
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.Initialized {
|
||||
t.Fatalf("initialized = true, want false before setup")
|
||||
}
|
||||
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 := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
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.StatusOK {
|
||||
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "legacy-token")
|
||||
func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
|
||||
const sess = "session-cookie-value"
|
||||
store := &fakePasswordStore{initialized: true, password: "old-password"}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "legacy-token",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: sess,
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
|
||||
rec = httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.password != "new-password" {
|
||||
t.Fatalf("password = %q, want new-password", store.password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
||||
)
|
||||
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())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
StoreError: errors.New("open auth store"),
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{name: "status", method: http.MethodGet, path: "/api/auth/status"},
|
||||
{name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
|
||||
{name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
||||
if tc.body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLauncherAuthLoginRateLimit(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
const tok = "rate-limit-tok-xxxxxxxx"
|
||||
sess := middleware.SessionCookieValue(key, tok)
|
||||
store := &fakePasswordStore{initialized: true, password: "correct-password"}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: tok,
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
|
||||
// 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
|
||||
wrongBody := `{"password":"wrong"}`
|
||||
for i := 0; i < loginAttemptsPerIP; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||
@@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
sess := middleware.SessionCookieValue(key, "tok")
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
DashboardToken: "tok",
|
||||
SessionCookie: sess,
|
||||
SessionCookie: "session-cookie-value",
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
|
||||
|
||||
@@ -4,16 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
type launcherConfigPayload struct {
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
LauncherToken string `json:"launcher_token"`
|
||||
Port int `json:"port"`
|
||||
Public bool `json:"public"`
|
||||
AllowedCIDRs []string `json:"allowed_cidrs"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
|
||||
@@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
LauncherToken: cfg.LauncherToken,
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
cfg := launcherconfig.Config{
|
||||
Port: payload.Port,
|
||||
Public: payload.Public,
|
||||
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
|
||||
LauncherToken: strings.TrimSpace(payload.LauncherToken),
|
||||
cfg, err := h.loadLauncherConfig()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
cfg.Port = payload.Port
|
||||
cfg.Public = payload.Public
|
||||
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
|
||||
cfg.LegacyLauncherToken = ""
|
||||
if err := launcherconfig.Validate(cfg); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(launcherConfigPayload{
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
LauncherToken: cfg.LauncherToken,
|
||||
Port: cfg.Port,
|
||||
Public: cfg.Public,
|
||||
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||
if got.Port != 19999 || !got.Public {
|
||||
t.Fatalf("response = %+v, want port=19999 public=true", got)
|
||||
}
|
||||
if got.LauncherToken != "" {
|
||||
t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken)
|
||||
}
|
||||
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
|
||||
}
|
||||
@@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
|
||||
|
||||
func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
path := launcherconfig.PathForAppConfig(configPath)
|
||||
if err := os.WriteFile(
|
||||
path,
|
||||
[]byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
|
||||
0o600,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
h := NewHandler(configPath)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
http.MethodPut,
|
||||
"/api/system/launcher-config",
|
||||
strings.NewReader(
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`,
|
||||
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
|
||||
),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
path := launcherconfig.PathForAppConfig(configPath)
|
||||
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("launcherconfig.Load() error = %v", err)
|
||||
@@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) {
|
||||
if cfg.Port != 18080 || !cfg.Public {
|
||||
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
|
||||
}
|
||||
if cfg.LauncherToken != "saved-token" {
|
||||
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token")
|
||||
if cfg.DashboardPasswordHash != "saved-hash" {
|
||||
t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
|
||||
}
|
||||
if cfg.LegacyLauncherToken != "" {
|
||||
t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
|
||||
}
|
||||
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
|
||||
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
|
||||
|
||||
Reference in New Issue
Block a user