From 82e95e721dbffd9177c51fba83dfe1b1c83260d7 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Mon, 10 Aug 2026 19:24:41 +0800 Subject: [PATCH] feat(runtime): secure Nacos registration transport --- internal/nacosregistration/config.go | 68 +++++++ internal/nacosregistration/config_test.go | 55 ++++- internal/nacosregistration/http_client.go | 113 +++++++++++ .../nacosregistration/http_client_test.go | 190 ++++++++++++++++++ runtime-a/README.md | 13 ++ runtime-a/cmd/runtime-a/main.go | 19 +- runtime-b/README.md | 13 ++ runtime-b/cmd/runtime-b/main.go | 20 +- runtime-b/registration_config.go | 4 + 9 files changed, 466 insertions(+), 29 deletions(-) create mode 100644 internal/nacosregistration/http_client.go create mode 100644 internal/nacosregistration/http_client_test.go diff --git a/internal/nacosregistration/config.go b/internal/nacosregistration/config.go index c824703..1a034f7 100644 --- a/internal/nacosregistration/config.go +++ b/internal/nacosregistration/config.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "path/filepath" "regexp" "strconv" "strings" @@ -47,6 +48,10 @@ type Config struct { RequestTimeout time.Duration AuthMode string AccessToken string + TLSCAFile string + TLSServerName string + TLSClientCertFile string + TLSClientKeyFile string } func Load(lookup func(string) (string, bool), prefix, agentID, instanceID string) (Config, error) { @@ -64,6 +69,7 @@ func Load(lookup func(string) (string, bool), prefix, agentID, instanceID string "NACOS_API_ORIGIN", "NACOS_NAMESPACE_ID", "NACOS_GROUP_NAME", "NACOS_SERVICE_NAME", "NACOS_CLUSTER_NAME", "NACOS_PORT_NAME", "NACOS_ADVERTISED_IP", "NACOS_ADVERTISED_PORT", "NACOS_WEIGHT", "NACOS_HEARTBEAT_INTERVAL_MS", "NACOS_HEARTBEAT_TIMEOUT_MS", "NACOS_IP_DELETE_TIMEOUT_MS", "NACOS_REQUEST_TIMEOUT_MS", "NACOS_AUTH_MODE", "NACOS_ACCESS_TOKEN", + "NACOS_TLS_CA_FILE", "NACOS_TLS_SERVER_NAME", "NACOS_TLS_CLIENT_CERT_FILE", "NACOS_TLS_CLIENT_KEY_FILE", } if mode == ModeDisabled { for _, suffix := range nacosSuffixes { @@ -94,6 +100,34 @@ func Load(lookup func(string) (string, bool), prefix, agentID, instanceID string if err := validateOrigin(config.APIOrigin, name("NACOS_API_ORIGIN")); err != nil { return Config{}, err } + parsedOrigin, _ := url.Parse(config.APIOrigin) + tlsNames := []string{name("NACOS_TLS_CA_FILE"), name("NACOS_TLS_SERVER_NAME"), name("NACOS_TLS_CLIENT_CERT_FILE"), name("NACOS_TLS_CLIENT_KEY_FILE")} + if parsedOrigin.Scheme == "http" { + for _, environment := range tlsNames { + if _, exists := lookup(environment); exists { + return Config{}, fmt.Errorf("%s must be absent for HTTP Nacos registration", environment) + } + } + } else { + if config.TLSCAFile, err = required(lookup, tlsNames[0]); err != nil { + return Config{}, err + } + if !validTLSPath(config.TLSCAFile) { + return Config{}, fmt.Errorf("%s must be a clean absolute path", tlsNames[0]) + } + if config.TLSServerName, err = required(lookup, tlsNames[1]); err != nil { + return Config{}, err + } + var certExists, keyExists bool + config.TLSClientCertFile, certExists = lookup(tlsNames[2]) + config.TLSClientKeyFile, keyExists = lookup(tlsNames[3]) + if certExists != keyExists || certExists && (!validTLSPath(config.TLSClientCertFile) || !validTLSPath(config.TLSClientKeyFile)) { + return Config{}, fmt.Errorf("%s and %s must be a complete non-empty pair", tlsNames[2], tlsNames[3]) + } + if !validTLSServerName(config.TLSServerName) { + return Config{}, fmt.Errorf("%s must be a valid DNS name or IP address", tlsNames[1]) + } + } for environment, destination := range map[string]*string{ name("NACOS_NAMESPACE_ID"): &config.NamespaceID, name("NACOS_GROUP_NAME"): &config.GroupName, @@ -176,9 +210,43 @@ func (config Config) Validate() error { if config.AuthMode != AuthNone && config.AuthMode != AuthAccessToken || config.AuthMode == AuthNone && config.AccessToken != "" || config.AuthMode == AuthAccessToken && strings.TrimSpace(config.AccessToken) == "" { return errorsFor("runtime", "Nacos authentication configuration is invalid") } + parsedOrigin, _ := url.Parse(config.APIOrigin) + if parsedOrigin.Scheme == "http" && (config.TLSCAFile != "" || config.TLSServerName != "" || config.TLSClientCertFile != "" || config.TLSClientKeyFile != "") { + return errorsFor("runtime", "Nacos HTTP registration cannot contain TLS configuration") + } + if parsedOrigin.Scheme == "https" && (!validTLSPath(config.TLSCAFile) || !validTLSServerName(config.TLSServerName) || (config.TLSClientCertFile == "") != (config.TLSClientKeyFile == "") || config.TLSClientCertFile != "" && (!validTLSPath(config.TLSClientCertFile) || !validTLSPath(config.TLSClientKeyFile))) { + return errorsFor("runtime", "Nacos HTTPS registration TLS configuration is invalid") + } return nil } +func validTLSPath(value string) bool { + return value != "" && strings.TrimSpace(value) == value && filepath.IsAbs(value) && filepath.Clean(value) == value +} + +func validTLSServerName(value string) bool { + if parsed := net.ParseIP(value); parsed != nil { + return parsed.String() == value + } + if len(value) == 0 || len(value) > 253 || value != strings.ToLower(value) || strings.Contains(value, "..") || strings.Contains(value, ":") { + return false + } + if strings.Trim(value, "0123456789.") == "" { + return false + } + for _, label := range strings.Split(value, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, character := range label { + if character != '-' && (character < '0' || character > '9') && (character < 'A' || character > 'Z') && (character < 'a' || character > 'z') { + return false + } + } + } + return true +} + func required(lookup func(string) (string, bool), name string) (string, error) { value, exists := lookup(name) if !exists || value == "" || strings.TrimSpace(value) != value { diff --git a/internal/nacosregistration/config_test.go b/internal/nacosregistration/config_test.go index 5d29d66..f8ae149 100644 --- a/internal/nacosregistration/config_test.go +++ b/internal/nacosregistration/config_test.go @@ -1,6 +1,9 @@ package nacosregistration -import "testing" +import ( + "path/filepath" + "testing" +) func TestLoadRequiresExactReleaseAndExplicitFreshness(t *testing.T) { values := validEnvironment() @@ -23,6 +26,56 @@ func TestLoadRequiresExactReleaseAndExplicitFreshness(t *testing.T) { } } +func TestLoadRequiresExplicitHTTPSRegistrationTrust(t *testing.T) { + values := validEnvironment() + values["RUNTIME_B_NACOS_API_ORIGIN"] = "https://nacos.internal:8848/nacos" + values["RUNTIME_B_NACOS_TLS_CA_FILE"] = filepath.Join(t.TempDir(), "ca.pem") + values["RUNTIME_B_NACOS_TLS_SERVER_NAME"] = "nacos.internal" + config, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary") + if err != nil || config.TLSCAFile == "" || config.TLSServerName != "nacos.internal" { + t.Fatalf("HTTPS config=%#v error=%v", config, err) + } + + for name, mutate := range map[string]func(map[string]string){ + "missing CA": func(values map[string]string) { delete(values, "RUNTIME_B_NACOS_TLS_CA_FILE") }, + "missing server name": func(values map[string]string) { delete(values, "RUNTIME_B_NACOS_TLS_SERVER_NAME") }, + "relative CA": func(values map[string]string) { values["RUNTIME_B_NACOS_TLS_CA_FILE"] = "ca.pem" }, + "invalid server name": func(values map[string]string) { values["RUNTIME_B_NACOS_TLS_SERVER_NAME"] = "nacos_internal" }, + "client cert only": func(values map[string]string) { + values["RUNTIME_B_NACOS_TLS_CLIENT_CERT_FILE"] = filepath.Join(t.TempDir(), "client.pem") + }, + "client key only": func(values map[string]string) { + values["RUNTIME_B_NACOS_TLS_CLIENT_KEY_FILE"] = filepath.Join(t.TempDir(), "client-key.pem") + }, + } { + t.Run(name, func(t *testing.T) { + invalid := make(map[string]string, len(values)) + for key, value := range values { + invalid[key] = value + } + mutate(invalid) + if _, err := Load(mapLookup(invalid), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { + t.Fatal("invalid HTTPS registration trust was accepted") + } + }) + } +} + +func TestLoadRejectsTLSFieldsForHTTPAndDisabledRegistration(t *testing.T) { + for _, mode := range []string{"http", "disabled"} { + t.Run(mode, func(t *testing.T) { + values := validEnvironment() + if mode == "disabled" { + values = map[string]string{"RUNTIME_B_REGISTRATION_MODE": ModeDisabled} + } + values["RUNTIME_B_NACOS_TLS_CA_FILE"] = filepath.Join(t.TempDir(), "ca.pem") + if _, err := Load(mapLookup(values), "RUNTIME_B", "runtime-b", "runtime-b-primary"); err == nil { + t.Fatal("non-HTTPS registration accepted TLS fields") + } + }) + } +} + func TestLoadRejectsMismatchedTargetAndFreshnessOrder(t *testing.T) { for name, mutate := range map[string]func(map[string]string){ "audience": func(values map[string]string) { values["RUNTIME_B_AUDIENCE"] = "http://runtime-a:8091" }, diff --git a/internal/nacosregistration/http_client.go b/internal/nacosregistration/http_client.go new file mode 100644 index 0000000..04ae124 --- /dev/null +++ b/internal/nacosregistration/http_client.go @@ -0,0 +1,113 @@ +package nacosregistration + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +const maximumTLSMaterialBytes int64 = 1 << 20 + +// NewHTTPClient constructs the deployment-owned Nacos registration transport. +// HTTPS never falls back to system roots; all trust material is explicit. +func NewHTTPClient(config Config) (*http.Client, error) { + if config.Mode != ModeNacos || config.Validate() != nil { + return nil, errors.New("Nacos registration transport configuration is invalid") + } + origin, _ := url.Parse(config.APIOrigin) + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DisableKeepAlives = true + transport.TLSClientConfig = nil + if origin.Scheme == "https" { + roots, err := loadCAPool(config.TLSCAFile) + if err != nil { + return nil, err + } + tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots, ServerName: config.TLSServerName} + if config.TLSClientCertFile != "" { + certificatePEM, err := readTLSMaterial(config.TLSClientCertFile, "client certificate") + if err != nil { + return nil, err + } + keyPEM, err := readTLSMaterial(config.TLSClientKeyFile, "client key") + if err != nil { + return nil, err + } + certificate, err := tls.X509KeyPair(certificatePEM, keyPEM) + if err != nil { + return nil, errors.New("Nacos TLS client certificate pair is invalid") + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + transport.TLSClientConfig = tlsConfig + } + return &http.Client{ + Transport: transport, + Timeout: config.RequestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errors.New("Nacos redirects are disabled") + }, + }, nil +} + +func loadCAPool(path string) (*x509.CertPool, error) { + content, err := readTLSMaterial(path, "CA") + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + certificates := 0 + for len(strings.TrimSpace(string(content))) != 0 { + block, rest := pem.Decode(content) + if block == nil || block.Type != "CERTIFICATE" { + return nil, errors.New("Nacos TLS CA material is invalid") + } + parsed, err := x509.ParseCertificates(block.Bytes) + if err != nil || len(parsed) == 0 { + return nil, errors.New("Nacos TLS CA material is invalid") + } + for _, certificate := range parsed { + if !certificate.IsCA || !certificate.BasicConstraintsValid || certificate.KeyUsage&x509.KeyUsageCertSign == 0 { + return nil, errors.New("Nacos TLS CA material is invalid") + } + pool.AddCert(certificate) + certificates++ + } + content = rest + } + if certificates == 0 { + return nil, errors.New("Nacos TLS CA material is invalid") + } + return pool, nil +} + +func readTLSMaterial(path, kind string) ([]byte, error) { + pathInfo, err := os.Lstat(path) + if err != nil { + return nil, errors.New("Nacos TLS " + kind + " material is unavailable") + } + if !pathInfo.Mode().IsRegular() || pathInfo.Size() <= 0 || pathInfo.Size() > maximumTLSMaterialBytes { + return nil, errors.New("Nacos TLS " + kind + " material is invalid") + } + file, err := os.Open(path) + if err != nil { + return nil, errors.New("Nacos TLS " + kind + " material is unavailable") + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || !os.SameFile(pathInfo, info) || info.Size() <= 0 || info.Size() > maximumTLSMaterialBytes { + return nil, errors.New("Nacos TLS " + kind + " material is invalid") + } + content, err := io.ReadAll(io.LimitReader(file, maximumTLSMaterialBytes+1)) + if err != nil || int64(len(content)) != info.Size() || int64(len(content)) > maximumTLSMaterialBytes { + return nil, errors.New("Nacos TLS " + kind + " material is invalid") + } + return content, nil +} diff --git a/internal/nacosregistration/http_client_test.go b/internal/nacosregistration/http_client_test.go new file mode 100644 index 0000000..a754c98 --- /dev/null +++ b/internal/nacosregistration/http_client_test.go @@ -0,0 +1,190 @@ +package nacosregistration + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewHTTPClientDisablesAmbientNetworkBehavior(t *testing.T) { + config := validHTTPClientConfig() + client, err := NewHTTPClient(config) + if err != nil { + t.Fatal(err) + } + transport, ok := client.Transport.(*http.Transport) + if !ok || transport.Proxy != nil || transport.TLSClientConfig != nil || !transport.DisableKeepAlives || client.Timeout != time.Second { + t.Fatalf("client=%#v transport=%#v", client, transport) + } + if err := client.CheckRedirect(httptest.NewRequest(http.MethodGet, "http://nacos.test/next", nil), nil); err == nil { + t.Fatal("redirect was accepted") + } +} + +func TestNewHTTPClientAuthenticatesPrivateCAAndOptionalClient(t *testing.T) { + material := newTLSMaterial(t) + for _, test := range []struct { + name, caFile, serverName string + clientCertificate bool + requireClient bool + wantError bool + }{ + {name: "TLS", caFile: material.caFile, serverName: "nacos.internal"}, + {name: "mTLS", caFile: material.caFile, serverName: "nacos.internal", clientCertificate: true, requireClient: true}, + {name: "wrong CA", caFile: newTLSMaterial(t).caFile, serverName: "nacos.internal", wantError: true}, + {name: "wrong server name", caFile: material.caFile, serverName: "other.internal", wantError: true}, + {name: "missing client certificate", caFile: material.caFile, serverName: "nacos.internal", requireClient: true, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusNoContent) })) + server.TLS = material.serverTLS(test.requireClient) + server.StartTLS() + defer server.Close() + + config := validHTTPClientConfig() + config.APIOrigin = "https://nacos.internal:8848/nacos" + config.TLSCAFile = test.caFile + config.TLSServerName = test.serverName + if test.clientCertificate { + config.TLSClientCertFile = material.clientCertFile + config.TLSClientKeyFile = material.clientKeyFile + } + client, err := NewHTTPClient(config) + if err != nil { + t.Fatal(err) + } + response, err := client.Get(server.URL) + if response != nil { + _ = response.Body.Close() + } + if (err != nil) != test.wantError { + t.Fatalf("request error=%v wantError=%v", err, test.wantError) + } + }) + } +} + +func TestNewHTTPClientRejectsUnsafeMaterialWithoutPathLeakage(t *testing.T) { + directory := t.TempDir() + secretPath := filepath.Join(directory, "secret-marker.pem") + if err := os.WriteFile(secretPath, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + config := validHTTPClientConfig() + config.APIOrigin = "https://nacos.internal:8848/nacos" + config.TLSCAFile = secretPath + config.TLSServerName = "nacos.internal" + if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), secretPath) || strings.Contains(err.Error(), "not a certificate") { + t.Fatalf("unsafe or leaking error=%v", err) + } + config.TLSCAFile = directory + if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), directory) { + t.Fatalf("non-regular material error=%v", err) + } + for name, content := range map[string][]byte{ + "empty": {}, + "oversized": make([]byte, maximumTLSMaterialBytes+1), + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(directory, name+".pem") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + config.TLSCAFile = path + if _, err := NewHTTPClient(config); err == nil || strings.Contains(err.Error(), path) { + t.Fatalf("bounded material error=%v", err) + } + }) + } +} + +func validHTTPClientConfig() Config { + config, err := Load(mapLookup(validEnvironment()), "RUNTIME_B", "runtime-b", "runtime-b-primary") + if err != nil { + panic(err) + } + return config +} + +type tlsMaterial struct { + caFile, clientCertFile, clientKeyFile string + serverCertificate tls.Certificate + caPool *x509.CertPool +} + +func newTLSMaterial(t *testing.T) tlsMaterial { + t.Helper() + directory := t.TempDir() + caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + caTemplate := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "NeKiro test CA"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign} + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + caCertificate, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + caFile := filepath.Join(directory, "ca.pem") + if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0o600); err != nil { + t.Fatal(err) + } + issue := func(name string, serial int64, usage x509.ExtKeyUsage, dnsNames []string) (string, string, tls.Certificate) { + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: name}, DNSNames: dnsNames, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage}} + der, err := x509.CreateCertificate(rand.Reader, template, caCertificate, public, caPrivate) + if err != nil { + t.Fatal(err) + } + certificatePEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalPKCS8PrivateKey(private) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + certificateFile, keyFile := filepath.Join(directory, name+".pem"), filepath.Join(directory, name+"-key.pem") + if err := os.WriteFile(certificateFile, certificatePEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil { + t.Fatal(err) + } + certificate, err := tls.X509KeyPair(certificatePEM, keyPEM) + if err != nil { + t.Fatal(err) + } + return certificateFile, keyFile, certificate + } + _, _, serverCertificate := issue("server", 2, x509.ExtKeyUsageServerAuth, []string{"nacos.internal"}) + clientCertificateFile, clientKeyFile, _ := issue("client", 3, x509.ExtKeyUsageClientAuth, nil) + pool := x509.NewCertPool() + pool.AddCert(caCertificate) + return tlsMaterial{caFile: caFile, clientCertFile: clientCertificateFile, clientKeyFile: clientKeyFile, serverCertificate: serverCertificate, caPool: pool} +} + +func (material tlsMaterial) serverTLS(requireClient bool) *tls.Config { + configuration := &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{material.serverCertificate}} + if requireClient { + configuration.ClientAuth = tls.RequireAndVerifyClientCert + configuration.ClientCAs = material.caPool + } + return configuration +} diff --git a/runtime-a/README.md b/runtime-a/README.md index bec70d3..70c5a4c 100644 --- a/runtime-a/README.md +++ b/runtime-a/README.md @@ -36,6 +36,19 @@ selected authentication mode. Runtime A uses Core's `InstanceRegistrar` and `InstanceLease`, fails startup if the initial publish fails, becomes not-ready and stops on terminal lease failure, and explicitly deregisters on shutdown. +The `RUNTIME_A_NACOS_API_ORIGIN` scheme explicitly selects the registration +transport. An `http` origin is controlled plaintext and every Nacos TLS field +must be absent. An `https` origin requires +`RUNTIME_A_NACOS_TLS_CA_FILE` and `RUNTIME_A_NACOS_TLS_SERVER_NAME`. +Mutual TLS additionally requires the complete +`RUNTIME_A_NACOS_TLS_CLIENT_CERT_FILE` and +`RUNTIME_A_NACOS_TLS_CLIENT_KEY_FILE` pair. TLS uses only the configured +private CA, TLS 1.2 or later, and exact hostname verification; system roots, +proxy discovery, redirects, insecure verification, and HTTPS downgrade are +disabled. TLS files must be clean absolute paths to regular, non-empty files +of at most 1 MiB. Startup errors do not include paths, PEM data, key bytes, or +file contents. + `NEKIRO_AGENT_CHALLENGE_DIRECTORY` is an absolute, explicitly configured directory used only to serve provider-owned one-time HTTP ownership proofs at `/.well-known/nekiro/challenges/{challengeId}`. It has no default and is not a diff --git a/runtime-a/cmd/runtime-a/main.go b/runtime-a/cmd/runtime-a/main.go index ea9912c..97b9bc6 100644 --- a/runtime-a/cmd/runtime-a/main.go +++ b/runtime-a/cmd/runtime-a/main.go @@ -34,7 +34,11 @@ func run() error { var registration *nacosregistration.Registration var readiness runtimea.Readiness = ready(true) if registrationConfig.Mode == nacosregistration.ModeNacos { - registration, err = nacosregistration.New(registrationConfig, newNacosHTTPClient(registrationConfig.RequestTimeout)) + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return fmt.Errorf("runtime-a Nacos registration transport: %w", clientErr) + } + registration, err = nacosregistration.New(registrationConfig, registrationClient) if err != nil { return fmt.Errorf("runtime-a Nacos registration config: %w", err) } @@ -104,16 +108,3 @@ func run() error { type ready bool func (value ready) Ready() bool { return bool(value) } - -func newNacosHTTPClient(timeout time.Duration) *http.Client { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = nil - transport.DisableKeepAlives = true - return &http.Client{ - Transport: transport, - Timeout: timeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return errors.New("Nacos redirects are disabled") - }, - } -} diff --git a/runtime-b/README.md b/runtime-b/README.md index 4c0e0f2..0a8de9a 100644 --- a/runtime-b/README.md +++ b/runtime-b/README.md @@ -50,6 +50,10 @@ RUNTIME_B_NACOS_IP_DELETE_TIMEOUT_MS RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS RUNTIME_B_NACOS_AUTH_MODE RUNTIME_B_NACOS_ACCESS_TOKEN +RUNTIME_B_NACOS_TLS_CA_FILE +RUNTIME_B_NACOS_TLS_SERVER_NAME +RUNTIME_B_NACOS_TLS_CLIENT_CERT_FILE +RUNTIME_B_NACOS_TLS_CLIENT_KEY_FILE ``` All values are required and validated. Credentials have no default and must @@ -69,6 +73,15 @@ alternate Nacos endpoint, stale lease, or Release fallback. `RUNTIME_B_NACOS_ACCESS_TOKEN` is required only for `access_token` mode and is never logged. +The Nacos API origin scheme is the explicit transport boundary. `http` permits +controlled plaintext only when all four TLS fields are absent. `https` +requires a private CA file and exact TLS server name; a client certificate and +key are optional only as a complete mTLS pair. The client uses TLS 1.2 or +later, never uses system roots, proxy discovery, insecure verification, +redirects, or downgrade, and reads each TLS file from a clean absolute regular +path with a 1 MiB limit. Validation and startup failures never expose a file +path, PEM block, private key, or file content. + ## Test Runtime B From the Samples repository root: diff --git a/runtime-b/cmd/runtime-b/main.go b/runtime-b/cmd/runtime-b/main.go index e691fd0..36f3c23 100644 --- a/runtime-b/cmd/runtime-b/main.go +++ b/runtime-b/cmd/runtime-b/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/NeKiro-project/NeKiro-Samples/internal/challengeproof" + "github.com/NeKiro-project/NeKiro-Samples/internal/nacosregistration" runtimeb "github.com/NeKiro-project/NeKiro-Samples/runtime-b" "github.com/NeKiro-project/nekiro-sdk-go/agent/routerauth" ) @@ -42,7 +43,11 @@ func run() error { var registration *runtimeb.NacosRegistration var readiness runtimeb.Readiness = ready(true) if registrationConfig.Mode == runtimeb.RegistrationModeNacos { - registration, err = runtimeb.NewNacosRegistration(registrationConfig, newNacosHTTPClient(registrationConfig.RequestTimeout)) + registrationClient, clientErr := nacosregistration.NewHTTPClient(registrationConfig) + if clientErr != nil { + return fmt.Errorf("runtime-b Nacos registration transport: %w", clientErr) + } + registration, err = runtimeb.NewNacosRegistration(registrationConfig, registrationClient) if err != nil { return fmt.Errorf("runtime-b Nacos registration config: %w", err) } @@ -116,16 +121,3 @@ func run() error { type ready bool func (value ready) Ready() bool { return bool(value) } - -func newNacosHTTPClient(timeout time.Duration) *http.Client { - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = nil - transport.DisableKeepAlives = true - return &http.Client{ - Transport: transport, - Timeout: timeout, - CheckRedirect: func(*http.Request, []*http.Request) error { - return errors.New("Nacos redirects are disabled") - }, - } -} diff --git a/runtime-b/registration_config.go b/runtime-b/registration_config.go index 7202870..3a65299 100644 --- a/runtime-b/registration_config.go +++ b/runtime-b/registration_config.go @@ -24,6 +24,10 @@ const ( NacosRequestTimeoutEnvironment = "RUNTIME_B_NACOS_REQUEST_TIMEOUT_MS" NacosAuthModeEnvironment = "RUNTIME_B_NACOS_AUTH_MODE" NacosAccessTokenEnvironment = "RUNTIME_B_NACOS_ACCESS_TOKEN" + NacosTLSCAFileEnvironment = "RUNTIME_B_NACOS_TLS_CA_FILE" + NacosTLSServerNameEnvironment = "RUNTIME_B_NACOS_TLS_SERVER_NAME" + NacosTLSClientCertEnvironment = "RUNTIME_B_NACOS_TLS_CLIENT_CERT_FILE" + NacosTLSClientKeyEnvironment = "RUNTIME_B_NACOS_TLS_CLIENT_KEY_FILE" RegistrationModeDisabled = nacosregistration.ModeDisabled RegistrationModeNacos = nacosregistration.ModeNacos NacosAuthNone = nacosregistration.AuthNone