feat(worker): adopt canonical public URL
Все проверки выполнены успешно
CI / test (push) Successful in 10m15s
Docker / Build and publish worker image (push) Successful in 34m59s
Все проверки выполнены успешно
CI / test (push) Successful in 10m15s
Docker / Build and publish worker image (push) Successful in 34m59s
Этот коммит содержится в:
@@ -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) {
|
||||
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
)
|
||||
|
||||
// TestHTTPConfigFromEnv_Defaults verifies the documented defaults when no
|
||||
// HTTP-related env vars are set: 0.0.0.0:27401 and empty URL / login /
|
||||
// password. The default port was bumped from 7401 to 27401 to avoid
|
||||
// HTTP-related env vars are set: 0.0.0.0:27401 and empty public URL /
|
||||
// login / password. The default port was bumped from 7401 to 27401 to avoid
|
||||
// colliding with the main RSMon app when the worker is co-located.
|
||||
func TestHTTPConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("WORKER_HOST", "")
|
||||
t.Setenv("WORKER_PORT", "")
|
||||
t.Setenv("PUBLIC_URL", "")
|
||||
t.Setenv("WORKER_URL", "")
|
||||
t.Setenv("WORKER_LOGIN", "")
|
||||
t.Setenv("WORKER_PASSWORD", "")
|
||||
@@ -22,7 +23,7 @@ func TestHTTPConfigFromEnv_Defaults(t *testing.T) {
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, DefaultHTTPHost, cfg.Host, "host should default to 0.0.0.0")
|
||||
assert.Equal(t, DefaultHTTPPort, cfg.Port, "port should default to 27401")
|
||||
assert.Equal(t, "", cfg.URL)
|
||||
assert.Equal(t, "", cfg.PublicURL)
|
||||
assert.Equal(t, "", cfg.Login)
|
||||
assert.Equal(t, "", cfg.Password)
|
||||
assert.False(t, cfg.IsAuthConfigured())
|
||||
@@ -33,14 +34,14 @@ func TestHTTPConfigFromEnv_Defaults(t *testing.T) {
|
||||
func TestHTTPConfigFromEnv_Overrides(t *testing.T) {
|
||||
t.Setenv("WORKER_HOST", "127.0.0.1")
|
||||
t.Setenv("WORKER_PORT", "9100")
|
||||
t.Setenv("WORKER_URL", "https://worker.example.com")
|
||||
t.Setenv("PUBLIC_URL", "https://worker.example.com")
|
||||
t.Setenv("WORKER_LOGIN", "ops")
|
||||
t.Setenv("WORKER_PASSWORD", "s3cret")
|
||||
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, "127.0.0.1", cfg.Host)
|
||||
assert.Equal(t, 9100, cfg.Port)
|
||||
assert.Equal(t, "https://worker.example.com", cfg.URL)
|
||||
assert.Equal(t, "https://worker.example.com", cfg.PublicURL)
|
||||
assert.Equal(t, "ops", cfg.Login)
|
||||
assert.Equal(t, "s3cret", cfg.Password)
|
||||
assert.True(t, cfg.IsAuthConfigured())
|
||||
@@ -70,6 +71,44 @@ func TestHTTPConfigFromEnv_PortOutOfRangeFallsBackToDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPublicURLFromEnv_PublicURLWins pins the canonical-source rule:
|
||||
// when both PUBLIC_URL and the legacy WORKER_URL are set, PUBLIC_URL wins
|
||||
// and the source is reported as canonical.
|
||||
func TestPublicURLFromEnv_PublicURLWins(t *testing.T) {
|
||||
t.Setenv("PUBLIC_URL", "https://canonical.example.com")
|
||||
t.Setenv("WORKER_URL", "http://legacy.example.com:27401")
|
||||
|
||||
url, source := PublicURLFromEnv()
|
||||
assert.Equal(t, "https://canonical.example.com", url)
|
||||
assert.Equal(t, PublicURLSourceCanonical, source)
|
||||
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, "https://canonical.example.com", cfg.PublicURL)
|
||||
}
|
||||
|
||||
// TestPublicURLFromEnv_LegacyFallback verifies the bounded migration: the
|
||||
// legacy WORKER_URL is resolved when PUBLIC_URL is unset and the source is
|
||||
// reported as legacy so the caller can emit the deprecation warning.
|
||||
func TestPublicURLFromEnv_LegacyFallback(t *testing.T) {
|
||||
t.Setenv("PUBLIC_URL", "")
|
||||
t.Setenv("WORKER_URL", "https://legacy.example.com")
|
||||
|
||||
url, source := PublicURLFromEnv()
|
||||
assert.Equal(t, "https://legacy.example.com", url)
|
||||
assert.Equal(t, PublicURLSourceLegacy, source)
|
||||
}
|
||||
|
||||
// TestPublicURLFromEnv_None verifies that no configured origin reports the
|
||||
// none source and an empty value.
|
||||
func TestPublicURLFromEnv_None(t *testing.T) {
|
||||
t.Setenv("PUBLIC_URL", "")
|
||||
t.Setenv("WORKER_URL", "")
|
||||
|
||||
url, source := PublicURLFromEnv()
|
||||
assert.Equal(t, "", url)
|
||||
assert.Equal(t, PublicURLSourceNone, source)
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_LoginXORPasswordRejected covers the central
|
||||
// invariant: a mixed login/password state is a config bug and must
|
||||
// fail fast.
|
||||
@@ -130,7 +169,7 @@ func TestValidateHTTPConfig_URLAbsoluteRequired(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, URL: tc.url}
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: tc.url}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
// empty url is allowed
|
||||
if tc.url == "" {
|
||||
@@ -148,7 +187,7 @@ func TestValidateHTTPConfig_URLSchemeAllowed(t *testing.T) {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
cfg := HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
URL: scheme + "://localhost:27401",
|
||||
PublicURL: scheme + "://localhost:27401",
|
||||
}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false))
|
||||
})
|
||||
@@ -161,7 +200,7 @@ func TestValidateHTTPConfig_URLSchemeRejected(t *testing.T) {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
cfg := HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
URL: scheme + "://localhost:27401",
|
||||
PublicURL: scheme + "://localhost:27401",
|
||||
}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
require.Error(t, err)
|
||||
@@ -170,6 +209,151 @@ func TestValidateHTTPConfig_URLSchemeRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePublicURL_OriginShapeRejected covers the plan's negative
|
||||
// cases: the advertised origin must be scheme + authority only, with no
|
||||
// userinfo, query, fragment, or ambiguous path.
|
||||
func TestValidatePublicURL_OriginShapeRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"userinfo", "https://user:pass@worker.example.com", "userinfo"},
|
||||
{"query", "https://worker.example.com?x=1", "query"},
|
||||
{"fragment", "https://worker.example.com#frag", "fragment"},
|
||||
{"path", "https://worker.example.com/web", "path"},
|
||||
{"path nested", "https://worker.example.com/raft/", "path"},
|
||||
{"missing scheme", "worker.example.com", "absolute url"},
|
||||
{"missing host", "https://", "absolute url"},
|
||||
{"bad scheme", "ftp://worker.example.com", "scheme must be http or https"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidatePublicURL(tc.url)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), tc.want)
|
||||
assert.Contains(t, err.Error(), EnvPublicURL,
|
||||
"error must name the canonical PUBLIC_URL variable")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePublicURL_RootSlashAllowed confirms that an empty path and
|
||||
// the root path "/" are both accepted as an origin.
|
||||
func TestValidatePublicURL_RootSlashAllowed(t *testing.T) {
|
||||
for _, u := range []string{"https://worker.example.com", "https://worker.example.com/"} {
|
||||
assert.NoError(t, ValidatePublicURL(u))
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePublicURL_HostnameRequired pins the hostname rule: an
|
||||
// authority that parses to no hostname (for example "https://:27401")
|
||||
// must be rejected even though it has a host:port string.
|
||||
func TestValidatePublicURL_HostnameRequired(t *testing.T) {
|
||||
for _, u := range []string{"https://:27401", "http://:7401"} {
|
||||
t.Run(u, func(t *testing.T) {
|
||||
err := ValidatePublicURL(u)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "must include a host")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAdvertisedURL_Tolerant pins the legacy-tolerant shape used
|
||||
// for WORKER_URL and control-plane-supplied endpoints: an absolute
|
||||
// http(s) URL with a scheme, host, and hostname. Paths, userinfo, query,
|
||||
// and fragments that previously ran must keep validating; only values
|
||||
// that could never be dialed (relative, bad scheme, missing hostname)
|
||||
// are rejected.
|
||||
func TestValidateAdvertisedURL_Tolerant(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"https://worker.example.com",
|
||||
"https://worker.example.com/web",
|
||||
"https://worker.example.com/raft/",
|
||||
"http://localhost:27401",
|
||||
"https://user:pass@worker.example.com/web?x=1#frag",
|
||||
} {
|
||||
assert.NoError(t, ValidateAdvertisedURL(u), "tolerant validator must accept %q", u)
|
||||
}
|
||||
for _, u := range []string{
|
||||
"worker.example.com",
|
||||
"https://",
|
||||
"https://:27401",
|
||||
"ftp://worker.example.com",
|
||||
"file:///tmp/x",
|
||||
} {
|
||||
assert.Error(t, ValidateAdvertisedURL(u), "tolerant validator must reject %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_LegacySourceTolerant is the core bounded-migration
|
||||
// guarantee: a legacy WORKER_URL that previously ran (path, userinfo, even
|
||||
// plain HTTP in production) must not newly fail startup. Only genuinely
|
||||
// unusable values are rejected.
|
||||
func TestValidateHTTPConfig_LegacySourceTolerant(t *testing.T) {
|
||||
t.Setenv("DEPLOY_ENV", "production")
|
||||
|
||||
for _, u := range []string{
|
||||
"https://worker.example.com",
|
||||
"https://worker.example.com/web",
|
||||
"http://worker.example.com",
|
||||
} {
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: u, URLSource: PublicURLSourceLegacy}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false),
|
||||
"legacy WORKER_URL=%q must not newly fail startup", u)
|
||||
}
|
||||
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: "https://:27401", URLSource: PublicURLSourceLegacy}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), EnvWorkerURLLegacy,
|
||||
"legacy diagnostics must name the WORKER_URL source")
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_CanonicalSourceStrict verifies the canonical
|
||||
// PUBLIC_URL stays strict even when the same value would be tolerated as
|
||||
// a legacy WORKER_URL.
|
||||
func TestValidateHTTPConfig_CanonicalSourceStrict(t *testing.T) {
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: "https://worker.example.com/web", URLSource: PublicURLSourceCanonical}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), EnvPublicURL,
|
||||
"canonical diagnostics must name the PUBLIC_URL source")
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_ProductionRejectsPlainHTTP pins the production
|
||||
// HTTPS policy for the canonical PUBLIC_URL: an explicitly production
|
||||
// worker must not advertise plain HTTP on a non-loopback host. Loopback
|
||||
// and https stay accepted.
|
||||
func TestValidateHTTPConfig_ProductionRejectsPlainHTTP(t *testing.T) {
|
||||
t.Setenv("DEPLOY_ENV", "production")
|
||||
|
||||
err := ValidateHTTPConfig(HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
PublicURL: "http://worker.example.com",
|
||||
URLSource: PublicURLSourceCanonical,
|
||||
}, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not permitted in production")
|
||||
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: "https://worker.example.com", URLSource: PublicURLSourceCanonical}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false))
|
||||
|
||||
cfg = HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: "http://localhost:27401", URLSource: PublicURLSourceCanonical}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false))
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_NonProductionAllowsPlainHTTP keeps the historical
|
||||
// warn-only behavior when no explicit production environment is configured.
|
||||
func TestValidateHTTPConfig_NonProductionAllowsPlainHTTP(t *testing.T) {
|
||||
t.Setenv("DEPLOY_ENV", "")
|
||||
t.Setenv("RSMON_ENV", "development")
|
||||
t.Setenv("GO_ENV", "")
|
||||
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, PublicURL: "http://worker.example.com"}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false))
|
||||
}
|
||||
|
||||
// TestWarnInsecurePublicURL exercises the warning helper: only http on
|
||||
// non-loopback hosts should warn. The returned host is the parsed host
|
||||
// (host:port when present) so the caller can log a useful target.
|
||||
|
||||
@@ -990,7 +990,24 @@ func (r *Runner) applyInit(init *wire.WorkerInit) {
|
||||
r.systemContactsMu.Unlock()
|
||||
|
||||
r.urlMu.Lock()
|
||||
r.url = init.URL
|
||||
accepted, field := init.PublicURL, "public_url"
|
||||
if accepted == "" {
|
||||
accepted, field = init.URL, "url" // legacy wire field (bounded migration)
|
||||
}
|
||||
// Validate the control-plane-supplied endpoint before accepting it.
|
||||
// On an unusable value keep the previous accepted URL (never regress
|
||||
// to a blank or garbage endpoint) and surface the rejection. A valid
|
||||
// empty value still clears the stored URL.
|
||||
var err error
|
||||
if accepted != "" {
|
||||
err = ValidateAdvertisedURL(accepted)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("worker: ignoring invalid advertised URL from control plane (%s=%q): %v",
|
||||
field, accepted, err)
|
||||
} else {
|
||||
r.url = accepted
|
||||
}
|
||||
r.urlMu.Unlock()
|
||||
|
||||
// Peers is the slice of other workers this node can reach for
|
||||
|
||||
@@ -1149,3 +1149,59 @@ func TestApplyInitStoresURLInMemory(t *testing.T) {
|
||||
assert.Equal(t, "", r.URL(),
|
||||
"URL() must return empty after applyInit with empty URL")
|
||||
}
|
||||
|
||||
// TestApplyInitPrefersPublicURLOverLegacyURL verifies the bounded-migration
|
||||
// precedence on the init/config frame: PublicURL (canonical) wins whenever
|
||||
// it is non-empty, and the legacy URL field remains the fallback for old
|
||||
// control planes.
|
||||
func TestApplyInitPrefersPublicURLOverLegacyURL(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
PublicURL: "https://canonical.example.com",
|
||||
URL: "https://legacy.example.com",
|
||||
})
|
||||
assert.Equal(t, "https://canonical.example.com", r.URL(),
|
||||
"PublicURL must win over the legacy URL field")
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
URL: "https://legacy.example.com",
|
||||
})
|
||||
assert.Equal(t, "https://legacy.example.com", r.URL(),
|
||||
"legacy URL field must be used when PublicURL is empty")
|
||||
}
|
||||
|
||||
// TestApplyInitInvalidAcceptedURLKeepsPrior verifies the safe-fallback
|
||||
// behavior when the control plane supplies an unusable advertised URL:
|
||||
// the previous accepted value is kept (never regressed to a garbage
|
||||
// endpoint), while a valid empty init still clears it.
|
||||
func TestApplyInitInvalidAcceptedURLKeepsPrior(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2, PublicURL: "https://worker.example.com"})
|
||||
assert.Equal(t, "https://worker.example.com", r.URL())
|
||||
|
||||
// Invalid value: keep the previous accepted URL.
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2, PublicURL: "https://:27401"})
|
||||
assert.Equal(t, "https://worker.example.com", r.URL(),
|
||||
"invalid accepted URL must not replace the stored value")
|
||||
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2, URL: "not a url"})
|
||||
assert.Equal(t, "https://worker.example.com", r.URL(),
|
||||
"invalid legacy url field must not replace the stored value")
|
||||
|
||||
// Valid empty init clears, as before.
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2})
|
||||
assert.Equal(t, "", r.URL(),
|
||||
"valid empty init must clear the stored URL")
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user