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", {