Files
picoclaw/web/backend/launcherconfig/config.go
T
wenjie 7f7b4c430b 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
2026-04-03 14:54:27 +08:00

167 lines
4.5 KiB
Go

package launcherconfig
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
const (
// FileName is the launcher-specific settings file name.
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
// dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
dashboardSigningKeyBytes = 32
// dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
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"`
LauncherToken string `json:"launcher_token,omitempty"`
}
// Default returns default launcher settings.
func Default() Config {
return Config{Port: DefaultPort, Public: false}
}
// Validate checks if launcher settings are valid.
func Validate(cfg Config) error {
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port)
}
for _, cidr := range cfg.AllowedCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("invalid CIDR %q", cidr)
}
}
return nil
}
// 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
// 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, "", err
}
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
if effectiveToken != "" {
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, "", genErr
}
return tok, signingKey, DashboardTokenSourceRandom, nil
}
func randomDashboardToken() (string, error) {
buf := make([]byte, dashboardTokenEntropyBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
return nil
}
out := make([]string, 0, len(cidrs))
seen := make(map[string]struct{}, len(cidrs))
for _, raw := range cidrs {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
if len(out) == 0 {
return nil
}
return out
}
// PathForAppConfig returns launcher-config path near the app config file.
func PathForAppConfig(appConfigPath string) string {
dir := filepath.Dir(appConfigPath)
if dir == "" || dir == "." {
dir = "."
}
return filepath.Join(dir, FileName)
}
// Load reads launcher settings; fallback is returned when file does not exist.
func Load(path string, fallback Config) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fallback, nil
}
return Config{}, err
}
cfg := fallback
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
// 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
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o600)
}