Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/nacosregistration/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 54 additions & 1 deletion internal/nacosregistration/config_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package nacosregistration

import "testing"
import (
"path/filepath"
"testing"
)

func TestLoadRequiresExactReleaseAndExplicitFreshness(t *testing.T) {
values := validEnvironment()
Expand All @@ -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" },
Expand Down
113 changes: 113 additions & 0 deletions internal/nacosregistration/http_client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading