package distworker import ( "fmt" "net/url" "os" "strconv" "strings" ) // DefaultMaxConcurrency is the default upper bound for the worker pool size // and the number of dispatch goroutines that drain the job queue. const DefaultMaxConcurrency = 32 const ( // DefaultHTTPHost is the bind address used when WORKER_HOST is unset. DefaultHTTPHost = "0.0.0.0" // DefaultHTTPPort is the bind port used when WORKER_PORT is unset. // Picked >20000 to avoid colliding with the main RSMon app (which // binds 7401 by default) when the worker is co-located on the // same host. Operators are still free to override via WORKER_PORT. DefaultHTTPPort = 27401 // schemeHTTP / schemeHTTPS are the only schemes accepted on // WORKER_URL. Peer workers and the main app need an http(s) origin // they can dial with Go's net/http stack. schemeHTTP = "http" schemeHTTPS = "https" // loopbackHostnames lists hostnames treated as loopback for the // http-on-non-loopback warning emitted by ValidateHTTPConfig. loopbackHostnames = "localhost,127.0.0.1,::1,0.0.0.0" ) // HTTPConfig holds the settings that govern the worker's local HTTP // listener (web app MVP in Task 3 and Raft peer connections in Task 4). // Host/Port are the bind interface. URL is the publicly-advertised // location peers and the main app use to reach the worker; it is NOT // derived from Host:Port because workers commonly sit behind a reverse // proxy / Traefik with HTTPS while listening on plain HTTP internally. // // Login/Password are basic auth credentials for the worker web app API // (Task 3). They are kept in memory only; Task 3 may hash them before // any persistent store. type HTTPConfig struct { Host string Port int URL string Login string Password string } // IsAuthConfigured reports whether both WORKER_LOGIN and WORKER_PASSWORD // are set. The HTTP listener refuses to start otherwise. func (c HTTPConfig) IsAuthConfigured() bool { return c.Login != "" && c.Password != "" } // IsListenConfigured reports whether the listener should bind at all. // Currently always true while WORKER_PORT > 0; placeholder for Task 3 // to wire "skip listen" semantics. func (c HTTPConfig) IsListenConfigured() bool { return c.Port > 0 } // Config holds the local worker connection settings. // Runtime settings are delivered by the control plane over websocket. type Config struct { URL string Token string // MaxConcurrency caps the worker pool size and the number of dispatcher // goroutines. A value <= 0 falls back to DefaultMaxConcurrency. // Runtime configuration delivered by the control plane via the websocket // "init" or "config" message is clamped to this value when resizing. MaxConcurrency int // HTTP holds the local web app / Raft listener settings (Task 2). HTTP HTTPConfig } // ConfigFromEnv creates a Config from environment variables. func ConfigFromEnv() Config { return Config{ URL: normalizeURL(os.Getenv("RSMON_URL")), Token: os.Getenv("RSMON_TOKEN"), HTTP: HTTPConfigFromEnv(), } } // HTTPConfigFromEnv reads HTTP listener settings from the environment. // Empty WORKER_HOST defaults to DefaultHTTPHost; empty WORKER_PORT defaults // to DefaultHTTPPort. A malformed WORKER_PORT falls back to the default. // URL is parsed loosely here; ValidateHTTPConfig does the real check. func HTTPConfigFromEnv() HTTPConfig { port := DefaultHTTPPort if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" { if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 { port = v } } host := strings.TrimSpace(os.Getenv("WORKER_HOST")) if host == "" { host = DefaultHTTPHost } return HTTPConfig{ Host: host, Port: port, URL: strings.TrimSpace(os.Getenv("WORKER_URL")), Login: os.Getenv("WORKER_LOGIN"), Password: os.Getenv("WORKER_PASSWORD"), } } // ValidateHTTPConfig enforces the Task 2 invariants: // // - WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty. // A mixed state (XOR) is a config bug and must fail fast so an // operator notices immediately. Both empty is allowed while the HTTP // listener is not started yet. // - When the listener would actually start (WORKER_PORT > 0), both // must be set; otherwise we are going to expose an unauthenticated // endpoint. // - WORKER_URL, if set, must be a parseable absolute URL. Relative // URLs are rejected because peer workers and the main app need a // concrete origin to dial. // - A WORKER_URL with scheme=http on a non-loopback host logs a // warning: production deployments normally terminate TLS at a // reverse proxy (Traefik, nginx). func ValidateHTTPConfig(c HTTPConfig, willListen bool) error { loginSet := c.Login != "" passSet := c.Password != "" if loginSet != passSet { return fmt.Errorf( "WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)", boolStr(loginSet), boolStr(passSet)) } if willListen && !c.IsAuthConfigured() { return fmt.Errorf("HTTP listener refused to start: WORKER_LOGIN and WORKER_PASSWORD must be set when WORKER_PORT > 0") } if c.URL == "" { return nil } u, err := url.Parse(c.URL) if err != nil { return fmt.Errorf("WORKER_URL is not a valid URL: %v", err) } if u.Scheme == "" || u.Host == "" { return fmt.Errorf("WORKER_URL must be an absolute URL with scheme and host (got %q)", c.URL) } if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS { return fmt.Errorf("WORKER_URL scheme must be http or https (got %q)", u.Scheme) } return nil } // WarnInsecurePublicURL logs a warning when WORKER_URL uses plain http // for a non-loopback host. Returns the host so the caller can log it. // A no-op for the loopback case (typical local dev) and for https URLs. func WarnInsecurePublicURL(rawURL string) (host string, shouldWarn bool) { if rawURL == "" { return "", false } u, err := url.Parse(rawURL) if err != nil { return "", false } if u.Scheme != schemeHTTP { return u.Host, false } if isLoopbackHost(u.Hostname()) { return u.Host, false } return u.Host, true } func isLoopbackHost(host string) bool { host = strings.ToLower(host) for _, h := range strings.Split(loopbackHostnames, ",") { if host == strings.TrimSpace(h) { return true } } return false } func boolStr(b bool) string { if b { return "set" } return "empty" } func normalizeURL(endpoint string) string { endpoint = strings.TrimRight(endpoint, "/") if strings.HasSuffix(endpoint, "/api/worker") { return strings.TrimSuffix(endpoint, "/api/worker") } if strings.HasSuffix(endpoint, "/worker") { return strings.TrimSuffix(endpoint, "/worker") } return endpoint }