From 7f7b4c430bb59bc553f19d5cdd231487118a6a47 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 3 Apr 2026 14:54:27 +0800 Subject: [PATCH] feat(web): persist dashboard token in launcher config (#2304) - add `launcher_token` to launcher config API/schema and save/load flow - update dashboard token resolution order: env var -> launcher config -> random - expose token source in startup logs and auth help metadata (including config path) - add launcher token input to the config page and wire frontend form/API updates - update login help/i18n copy and extend backend tests for new token-source behavior --- web/backend/api/auth.go | 1 + web/backend/api/launcher_config.go | 29 ++--- web/backend/api/launcher_config_test.go | 10 +- web/backend/launcherconfig/config.go | 38 +++++-- web/backend/launcherconfig/config_test.go | 43 +++++--- web/backend/main.go | 31 ++++-- web/backend/main_test.go | 40 ++++++- web/frontend/src/api/launcher-auth.ts | 1 + web/frontend/src/api/system.ts | 1 + .../src/components/config/config-page.tsx | 15 +-- .../src/components/config/config-sections.tsx | 24 ++++- .../src/components/config/form-model.ts | 2 + web/frontend/src/i18n/locales/en.json | 9 +- web/frontend/src/i18n/locales/zh.json | 101 +++++++++--------- web/frontend/src/routes/launcher-login.tsx | 14 ++- 15 files changed, 252 insertions(+), 107 deletions(-) diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index b9b4d5f66..22f7ec2c2 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -23,6 +23,7 @@ type LauncherAuthRouteOpts struct { type LauncherAuthTokenHelp struct { EnvVarName string `json:"env_var_name"` LogFileAbs string `json:"log_file,omitempty"` + ConfigFileAbs string `json:"config_file,omitempty"` TrayCopyMenu bool `json:"tray_copy_menu"` ConsoleStdout bool `json:"console_stdout"` } diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index e149d5671..d16cd9267 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -4,14 +4,16 @@ 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"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` + LauncherToken string `json:"launcher_token"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -48,9 +50,10 @@ 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...), + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + LauncherToken: cfg.LauncherToken, }) } @@ -62,9 +65,10 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ } cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + Port: payload.Port, + Public: payload.Public, + AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + LauncherToken: strings.TrimSpace(payload.LauncherToken), } if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -78,8 +82,9 @@ 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...), + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + LauncherToken: cfg.LauncherToken, }) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 0d6af823c..4e0acf5d0 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -34,6 +34,9 @@ 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) } @@ -50,7 +53,9 @@ func TestPutLauncherConfigPersists(t *testing.T) { req := httptest.NewRequest( http.MethodPut, "/api/system/launcher-config", - strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`), + strings.NewReader( + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`, + ), ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) @@ -67,6 +72,9 @@ 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 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) } diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index b8465ef74..60c369f4f 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -23,11 +23,20 @@ const ( dashboardTokenEntropyBytes = 32 ) +type DashboardTokenSource string + +const ( + DashboardTokenSourceEnv DashboardTokenSource = "env" + DashboardTokenSourceConfig DashboardTokenSource = "config" + DashboardTokenSourceRandom DashboardTokenSource = "random" +) + // Config stores launch parameters for the web backend service. type Config struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + LauncherToken string `json:"launcher_token,omitempty"` } // Default returns default launcher settings. @@ -49,23 +58,30 @@ func Validate(cfg Config) error { } // EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this -// process. The signing key is freshly random each call; the token comes from the environment -// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token. -func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) { +// process. The signing key is freshly random each call; the token comes from +// PICOCLAW_LAUNCHER_TOKEN when set, otherwise launcher-config.json launcher_token, +// otherwise a new random token. +func EnsureDashboardSecrets( + cfg Config, +) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) { signingKey = make([]byte, dashboardSigningKeyBytes) if _, err = rand.Read(signingKey); err != nil { - return "", nil, false, err + return "", nil, "", err } effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN")) if effectiveToken != "" { - return effectiveToken, signingKey, false, nil + return effectiveToken, signingKey, DashboardTokenSourceEnv, nil + } + effectiveToken = strings.TrimSpace(cfg.LauncherToken) + if effectiveToken != "" { + return effectiveToken, signingKey, DashboardTokenSourceConfig, nil } tok, genErr := randomDashboardToken() if genErr != nil { - return "", nil, false, genErr + return "", nil, "", genErr } - return tok, signingKey, true, nil + return tok, signingKey, DashboardTokenSourceRandom, nil } func randomDashboardToken() (string, error) { @@ -124,6 +140,7 @@ func Load(path string, fallback Config) (Config, error) { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) if err := Validate(cfg); err != nil { return Config{}, err } @@ -133,6 +150,7 @@ func Load(path string, fallback Config) (Config, error) { // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) if err := Validate(cfg); err != nil { return err } diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index 4e8a54e41..528116417 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -25,9 +25,10 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "launcher-config.json") want := Config{ - Port: 18080, - Public: true, - AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + LauncherToken: "saved-launcher-token", } if err := Save(path, want); err != nil { @@ -40,6 +41,9 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } + if got.LauncherToken != want.LauncherToken { + t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken) + } if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) } @@ -80,24 +84,24 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - tok, key, newTok, err := EnsureDashboardSecrets() + tok, key, source, err := EnsureDashboardSecrets(Default()) if err != nil { t.Fatalf("EnsureDashboardSecrets() error = %v", err) } - if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes { - t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key)) + if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes { + t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key)) } mac := middleware.SessionCookieValue(key, tok) if mac == "" { t.Fatal("empty session mac") } - tok2, key2, newTok2, err := EnsureDashboardSecrets() + tok2, key2, source2, err := EnsureDashboardSecrets(Default()) if err != nil { t.Fatalf("EnsureDashboardSecrets() second error = %v", err) } - if !newTok2 { - t.Fatal("second call without env should generate another random token") + if source2 != DashboardTokenSourceRandom { + t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom) } if tok2 == tok { t.Fatal("expected a new random dashboard token") @@ -110,15 +114,30 @@ func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") - tok, _, newTok, err := EnsureDashboardSecrets() + tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) if err != nil { t.Fatalf("EnsureDashboardSecrets() error = %v", err) } if tok != "env-only-token-override" { t.Fatalf("token = %q, want env value", tok) } - if newTok { - t.Fatal("newRandomDashboardToken should be false when env is set") + if source != DashboardTokenSourceEnv { + t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv) + } +} + +func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) { + t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") + + tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) + if err != nil { + t.Fatalf("EnsureDashboardSecrets() error = %v", err) + } + if tok != "config-token" { + t.Fatalf("token = %q, want config value", tok) + } + if source != DashboardTokenSourceConfig { + t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig) } } diff --git a/web/backend/main.go b/web/backend/main.go index 218e3bfce..5e9f3315f 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -59,6 +59,13 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } +func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string { + if source != launcherconfig.DashboardTokenSourceConfig { + return "" + } + return launcherPath +} + func main() { port := flag.String("port", "18800", "Port to listen on") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") @@ -195,7 +202,9 @@ func main() { logger.Fatalf("Invalid port %q: %v", effectivePort, err) } - dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets() + dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets( + launcherCfg, + ) if dashErr != nil { logger.Fatalf("Dashboard auth setup failed: %v", dashErr) } @@ -223,6 +232,7 @@ func main() { TokenHelp: api.LauncherAuthTokenHelp{ EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", LogFileAbs: tokenLogFileAbs, + ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath), TrayCopyMenu: trayOffersDashboardTokenCopy(), ConsoleStdout: enableConsole, }, @@ -272,19 +282,26 @@ func main() { } } fmt.Println() - if newDashTok { + switch dashboardTokenSource { + case launcherconfig.DashboardTokenSourceRandom: fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) - } else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + case launcherconfig.DashboardTokenSourceEnv: fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken) + case launcherconfig.DashboardTokenSourceConfig: + fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath) } fmt.Println() } - if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" { + switch dashboardTokenSource { + case launcherconfig.DashboardTokenSourceEnv: logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") - } - if !enableConsole && newDashTok { - logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) + case launcherconfig.DashboardTokenSourceConfig: + logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath)) + case launcherconfig.DashboardTokenSourceRandom: + if !enableConsole { + logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) + } } // Log startup info to file diff --git a/web/backend/main_test.go b/web/backend/main_test.go index c24a53704..f69705179 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "testing" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) func TestShouldEnableLauncherFileLogging(t *testing.T) { tests := []struct { @@ -29,3 +33,37 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) { }) } } + +func TestDashboardTokenConfigHelpPath(t *testing.T) { + const launcherPath = "/tmp/launcher-config.json" + + tests := []struct { + name string + source launcherconfig.DashboardTokenSource + want string + }{ + { + name: "env token does not expose config path", + source: launcherconfig.DashboardTokenSourceEnv, + want: "", + }, + { + name: "config token exposes config path", + source: launcherconfig.DashboardTokenSourceConfig, + want: launcherPath, + }, + { + name: "random token does not expose config path", + source: launcherconfig.DashboardTokenSourceRandom, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want { + t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want) + } + }) + } +} diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index 247d5ab9e..4ca51993b 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -17,6 +17,7 @@ export async function postLauncherDashboardLogin( export type LauncherAuthTokenHelp = { env_var_name: string log_file?: string + config_file?: string tray_copy_menu: boolean console_stdout: boolean } diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index dfc48b6b8..8623c7e78 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,6 +11,7 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] + launcher_token: string } export interface SystemVersionInfo { diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 7c2cb263e..0ad2031f7 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -94,6 +94,7 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), + launcherToken: launcherConfig.launcher_token ?? "", } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -264,6 +265,7 @@ export function ConfigPage() { port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, + launcher_token: launcherForm.launcherToken.trim(), }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -271,6 +273,7 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), + launcherToken: savedLauncherConfig.launcher_token ?? "", } setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) @@ -343,6 +346,12 @@ export function ConfigPage() { )} + + @@ -351,12 +360,6 @@ export function ConfigPage() { - - - onFieldChange("splitOnMarker", checked) - } + onCheckedChange={(checked) => onFieldChange("splitOnMarker", checked)} /> + + + onFieldChange("launcherToken", e.target.value)} + /> + + ( - null, - ) + const [tokenHelp, setTokenHelp] = + React.useState(null) React.useEffect(() => { let cancelled = false @@ -155,6 +154,13 @@ function LauncherLoginPage() { {tokenHelp.tray_copy_menu ? (
  • {t("launcherLogin.helpTray")}
  • ) : null} + {tokenHelp.config_file ? ( +
  • + {t("launcherLogin.helpConfig", { + path: tokenHelp.config_file, + })} +
  • + ) : null} {tokenHelp.log_file ? (
  • {t("launcherLogin.helpLogFile", {