feat(worker): adopt canonical public URL
Все проверки выполнены успешно
CI / test (push) Successful in 10m15s
Docker / Build and publish worker image (push) Successful in 34m59s

Этот коммит содержится в:
Gleb Tv
2026-08-12 20:48:01 +03:00
родитель a1ccd50aaf
Коммит cb23f123ae
19 изменённых файлов: 804 добавлений и 74 удалений

Просмотреть файл

@@ -21,8 +21,18 @@ const (
// same host. Operators are still free to override via WORKER_PORT.
DefaultHTTPPort = 27401
// EnvPublicURL is the canonical environment variable for the
// publicly advertised origin. It wins over the legacy
// EnvWorkerURLLegacy when both are set.
EnvPublicURL = "PUBLIC_URL"
// EnvWorkerURLLegacy is the legacy name for the advertised public
// origin. It is accepted only for the bounded migration defined in
// docs/public-endpoint-and-identity.md milestone 1 and is
// deprecated: new configuration must set EnvPublicURL.
EnvWorkerURLLegacy = "WORKER_URL"
// schemeHTTP / schemeHTTPS are the only schemes accepted on
// WORKER_URL. Peer workers and the main app need an http(s) origin
// PUBLIC_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"
@@ -34,20 +44,55 @@ const (
// 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
// Host/Port are the bind interface. PublicURL is the publicly-advertised
// origin 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.
//
// PublicURL is sourced from PUBLIC_URL (strict origin) or, for the
// bounded migration, from the deprecated WORKER_URL (legacy-tolerant
// absolute URL). URLSource records which variable supplied the value so
// validation and diagnostics name the correct source.
//
// 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
Host string
Port int
PublicURL string
URLSource PublicURLSource
Login string
Password string
}
// PublicURLSource identifies which environment variable supplied the
// advertised public origin, so callers can log the legacy deprecation
// exactly once.
type PublicURLSource int
const (
// PublicURLSourceNone means no advertised origin is configured.
PublicURLSourceNone PublicURLSource = iota
// PublicURLSourceCanonical means PUBLIC_URL was configured.
PublicURLSourceCanonical
// PublicURLSourceLegacy means only WORKER_URL was configured; the
// value is accepted for the bounded migration.
PublicURLSourceLegacy
)
// PublicURLFromEnv reads the advertised public origin. PUBLIC_URL is
// the canonical source; the deprecated WORKER_URL is used only when
// PUBLIC_URL is unset. The returned source lets the caller warn about
// the legacy fallback without re-reading the environment.
func PublicURLFromEnv() (string, PublicURLSource) {
if v := strings.TrimSpace(os.Getenv(EnvPublicURL)); v != "" {
return v, PublicURLSourceCanonical
}
if v := strings.TrimSpace(os.Getenv(EnvWorkerURLLegacy)); v != "" {
return v, PublicURLSourceLegacy
}
return "", PublicURLSourceNone
}
// IsAuthConfigured reports whether both WORKER_LOGIN and WORKER_PASSWORD
@@ -91,7 +136,9 @@ func ConfigFromEnv() Config {
// 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.
// The public origin is resolved from PUBLIC_URL (canonical) with the
// deprecated WORKER_URL as the migration fallback; it is parsed loosely
// here — ValidateHTTPConfig does the real check.
func HTTPConfigFromEnv() HTTPConfig {
port := DefaultHTTPPort
if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" {
@@ -103,16 +150,18 @@ func HTTPConfigFromEnv() HTTPConfig {
if host == "" {
host = DefaultHTTPHost
}
publicURL, source := PublicURLFromEnv()
return HTTPConfig{
Host: host,
Port: port,
URL: strings.TrimSpace(os.Getenv("WORKER_URL")),
Login: os.Getenv("WORKER_LOGIN"),
Password: os.Getenv("WORKER_PASSWORD"),
Host: host,
Port: port,
PublicURL: publicURL,
URLSource: source,
Login: os.Getenv("WORKER_LOGIN"),
Password: os.Getenv("WORKER_PASSWORD"),
}
}
// ValidateHTTPConfig enforces the Task 2 invariants:
// ValidateHTTPConfig enforces the local HTTP-listener 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
@@ -121,12 +170,15 @@ func HTTPConfigFromEnv() HTTPConfig {
// - 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).
// - A configured advertised origin must validate. The canonical
// PUBLIC_URL is held to the strict origin rules of ValidatePublicURL
// and, in an explicitly production environment
// (DEPLOY_ENV/RSMON_ENV/GO_ENV = production), plain HTTP on a
// non-loopback host is rejected. A legacy WORKER_URL (URLSource is
// PublicURLSourceLegacy) is held only to the tolerant
// ValidateAdvertisedURL so shapes that previously ran keep running;
// it never fails production startup, preserving the historical
// warn-only behavior.
func ValidateHTTPConfig(c HTTPConfig, willListen bool) error {
loginSet := c.Login != ""
passSet := c.Password != ""
@@ -138,23 +190,103 @@ func ValidateHTTPConfig(c HTTPConfig, willListen bool) error {
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 == "" {
if c.PublicURL == "" {
return nil
}
u, err := url.Parse(c.URL)
if err != nil {
return fmt.Errorf("WORKER_URL is not a valid URL: %v", err)
if c.URLSource == PublicURLSourceLegacy {
if err := ValidateAdvertisedURL(c.PublicURL); err != nil {
return fmt.Errorf("%s: %w", EnvWorkerURLLegacy, err)
}
return nil
}
if u.Scheme == "" || u.Host == "" {
return fmt.Errorf("WORKER_URL must be an absolute URL with scheme and host (got %q)", c.URL)
if err := ValidatePublicURL(c.PublicURL); err != nil {
return err
}
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
return fmt.Errorf("WORKER_URL scheme must be http or https (got %q)", u.Scheme)
if productionEnv() {
if host, warn := WarnInsecurePublicURL(c.PublicURL); warn {
return fmt.Errorf(
"%s uses plain HTTP on a non-loopback host (%s), which is not permitted in production; front %s with a TLS-terminating reverse proxy",
EnvPublicURL, host, EnvPublicURL,
)
}
}
return nil
}
// WarnInsecurePublicURL logs a warning when WORKER_URL uses plain http
// ValidatePublicURL enforces the strict origin shape required by
// docs/public-endpoint-and-identity.md ("One Worker, One Public URL")
// for the canonical PUBLIC_URL configuration: an absolute http(s) URL
// with a scheme and authority and nothing else. Userinfo, query,
// fragment, any path other than "/", and a missing hostname (for example
// "https://:27401") are rejected because every route lives on the origin
// root and credentials must not leak into the advertised endpoint.
func ValidatePublicURL(raw string) error {
u, err := parseURL(raw, EnvPublicURL)
if err != nil {
return err
}
if u.User != nil {
return fmt.Errorf("%s must not contain userinfo (got %q)", EnvPublicURL, raw)
}
if u.RawQuery != "" {
return fmt.Errorf("%s must not contain a query (got %q)", EnvPublicURL, raw)
}
if u.Fragment != "" {
return fmt.Errorf("%s must not contain a fragment (got %q)", EnvPublicURL, raw)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("%s must not contain a path (got %q)", EnvPublicURL, raw)
}
return nil
}
// ValidateAdvertisedURL enforces the tolerant absolute-URL shape used
// for the legacy WORKER_URL and for endpoints supplied by the control
// plane: an http(s) URL with a scheme, a host, and a non-empty hostname.
// Unlike ValidatePublicURL it does not reject a path, userinfo, query,
// or fragment, because legacy configurations and old control planes may
// carry such shapes and must not newly fail. It still rejects values
// that could never be dialed, such as "https://:27401".
func ValidateAdvertisedURL(raw string) error {
_, err := parseURL(raw, EnvWorkerURLLegacy)
return err
}
// parseURL parses an absolute http(s) advertised URL. Both validators
// share the scheme/host/hostname rules; label names the source in error
// messages so diagnostics point at the variable the operator set.
func parseURL(raw, label string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("%s is not a valid URL: %v", label, err)
}
if u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("%s must be an absolute URL with scheme and host (got %q)", label, raw)
}
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
return nil, fmt.Errorf("%s scheme must be http or https (got %q)", label, u.Scheme)
}
if u.Hostname() == "" {
return nil, fmt.Errorf("%s must include a host (got %q)", label, raw)
}
return u, nil
}
// productionEnv reports whether the worker is explicitly configured for
// production. Only an explicit literal enables the HTTPS-only PUBLIC_URL
// policy; an unset variable keeps the historical warn-on-plain-HTTP
// behavior so existing installations upgrade without surprise.
func productionEnv() bool {
for _, key := range []string{"DEPLOY_ENV", "RSMON_ENV", "GO_ENV"} {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "production", "prod":
return true
}
}
return false
}
// WarnInsecurePublicURL logs a warning when PUBLIC_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) {