picoclaw/web/backend/launcherconfig/config.go

162 lines
4.5 KiB
Go

package launcherconfig
import (
"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
// EnvLauncherHost overrides launcher listen host.
EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
)
// 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"`
AllowLocalhostBypass bool `json:"allow_localhost_bypass"`
AllowLocalhostBypassSource BoolFieldSource `json:"-"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
// LegacyLauncherToken is read only for one-time migration from the removed
// token login flow. Save always clears it so new configs do not persist it.
LegacyLauncherToken string `json:"launcher_token,omitempty"`
}
// BoolFieldSource tracks whether a JSON boolean field was omitted, explicitly
// provided, or explicitly set to null. This is only used for diagnostics.
type BoolFieldSource uint8
const (
BoolFieldAbsent BoolFieldSource = iota
BoolFieldPresent
BoolFieldNull
)
// Default returns default launcher settings.
func Default() Config {
return Config{Port: DefaultPort, Public: false, AllowLocalhostBypass: true}
}
// 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)
}
}
for _, cidr := range cfg.TrustedProxyCIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("invalid trusted proxy CIDR %q", cidr)
}
}
return 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
cfg.AllowLocalhostBypassSource = detectBoolFieldSource(data, "allow_localhost_bypass")
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func detectBoolFieldSource(data []byte, field string) BoolFieldSource {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return BoolFieldAbsent
}
value, ok := raw[field]
if !ok {
return BoolFieldAbsent
}
if string(value) == "null" {
return BoolFieldNull
}
return BoolFieldPresent
}
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.TrustedProxyCIDRs = NormalizeCIDRs(cfg.TrustedProxyCIDRs)
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
cfg.LegacyLauncherToken = ""
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)
}