diff --git a/pkg/ffdns/config.go b/pkg/ffdns/config.go new file mode 100644 index 0000000..af7833f --- /dev/null +++ b/pkg/ffdns/config.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ffdns + +import ( + "time" + + "github.com/hyperledger/firefly-common/pkg/config" +) + +const ( + // Servers an optional list of DNS server addresses (host or host:port, port defaults + // to 53). Setting this forces use of Go's built-in resolver. + DNSServers = "servers" + // Timeout the dial timeout when contacting a configured DNS server + DNSTimeout = "timeout" +) + +type Config struct { + Servers []string + Timeout time.Duration +} + +func InitConfig(conf config.Section) { + conf.AddKnownKey(DNSServers) + conf.AddKnownKey(DNSTimeout) +} + +func GenerateConfig(conf config.Section) (*Config, error) { + return &Config{ + Servers: conf.GetStringSlice(DNSServers), + Timeout: conf.GetDuration(DNSTimeout), + }, nil +} diff --git a/pkg/ffdns/ffdns.go b/pkg/ffdns/ffdns.go new file mode 100644 index 0000000..3f7c5c1 --- /dev/null +++ b/pkg/ffdns/ffdns.go @@ -0,0 +1,129 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ffdns + +import ( + "context" + "errors" + "net" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" +) + +const ( + metricsDNSRequestsTotal = "dns_requests_total" + metricsDNSResponsesTotal = "dns_responses_total" + metricsDNSErrorsTotal = "dns_errors_total" +) + +var metricsManager metric.MetricsManager + +func EnableResolverMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) { + if metricsManager != nil { + return + } + metricsManager, _ = metricsRegistry.NewMetricsManagerForSubsystem(ctx, "dns") + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSRequestsTotal, "DNS requests", []string{"server"}, false) + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSResponsesTotal, "DNS responses", []string{"server", "status"}, false) + metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSErrorsTotal, "DNS errors", []string{"server", "error"}, false) +} + +// NewDNSResolver builds a pure-Go *net.Resolver for metrics instructmentation, custom timeouts, and/or custom servers. +// The resolver will dial the given DNS servers (each host or host:port, port defaulting to 53) in order, failing over to the +// next on error. Returns nil if none of the customizations (metrics, timeout, or servers) are enabeld. +// Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same +// DNS config as the HTTP client. +func NewResolver(config config.Section) *net.Resolver { + cfg, err := GenerateConfig(config) + if err != nil { + return nil + } + + return NewResolverWithConfig(cfg) +} + +func NewResolverWithConfig(cfg *Config) *net.Resolver { + var servers []string + if len(cfg.Servers) > 0 { + servers = make([]string, len(cfg.Servers)) + for i, server := range cfg.Servers { + servers[i] = withDefaultDNSPort(server) + } + } + + // If we have nothing to layer on top of the system resolver — no configured servers, no + // dial timeout, and metrics disabled — leave it untouched (callers treat nil as "use the + // system resolver"). Returning a resolver here would force Go's built-in resolver + // (PreferGo) in deployments that haven't opted into any of these. + if len(servers) == 0 && cfg.Timeout <= 0 && metricsManager == nil { + return nil + } + + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: cfg.Timeout} + // When no servers are explicitly configured, wrap Go's built-in resolver: it has + // already selected a nameserver from the system config (resolv.conf) and passes it + // as address, so we dial that and still apply our timeout and metrics. + dialServers := servers + if len(dialServers) == 0 { + dialServers = []string{address} + } + var err error + // Go's built-in resolver dials a fresh connection per query exchange (escalating + // from UDP to TCP for truncated responses), so each Dial maps to a DNS request. We + // record metrics at this connection level. + for _, server := range dialServers { + recordDNSMetric(ctx, metricsDNSRequestsTotal, map[string]string{"server": server}) + var conn net.Conn + if conn, err = d.DialContext(ctx, network, server); err == nil { + recordDNSMetric(ctx, metricsDNSResponsesTotal, map[string]string{"server": server, "status": "success"}) + return conn, nil + } + recordDNSMetric(ctx, metricsDNSErrorsTotal, map[string]string{"server": server, "error": classifyDNSError(err)}) + } + return nil, err + }, + } +} + +// recordDNSMetric increments a DNS counter when resolver metrics have been enabled, and is a no-op otherwise. +func recordDNSMetric(ctx context.Context, name string, labels map[string]string) { + if metricsManager == nil { + return + } + metricsManager.IncCounterMetricWithLabels(ctx, name, labels, nil) +} + +// classifyDNSError maps a dial error to a low-cardinality label so the dns_errors_total metric doesn't explode. +func classifyDNSError(err error) string { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "timeout" + } + return "error" +} + +// withDefaultDNSPort ensures a DNS server address has a port, defaulting to 53. +func withDefaultDNSPort(server string) string { + if _, _, err := net.SplitHostPort(server); err == nil { + return server + } + return net.JoinHostPort(server, "53") +} diff --git a/pkg/ffdns/ffdns_test.go b/pkg/ffdns/ffdns_test.go new file mode 100644 index 0000000..2355709 --- /dev/null +++ b/pkg/ffdns/ffdns_test.go @@ -0,0 +1,200 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ffdns + +import ( + "context" + "net" + "strings" + "testing" + "time" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// counterTotal sums the values of all series of a counter whose metric family name ends with +// the given suffix (the registry prefixes names with component + subsystem). +func counterTotal(t *testing.T, mr metric.MetricsRegistry, nameSuffix string) float64 { + families, err := mr.GetGatherer().Gather() + require.NoError(t, err) + var total float64 + for _, mf := range families { + if strings.HasSuffix(mf.GetName(), nameSuffix) { + for _, m := range mf.GetMetric() { + if c := m.GetCounter(); c != nil { + total += c.GetValue() + } + } + } + } + return total +} + +var utConf = config.RootSection("dns_unit_tests") + +func resetConf() { + config.RootConfigReset() + InitConfig(utConf) +} + +func TestWithDefaultDNSPort(t *testing.T) { + assert.Equal(t, "8.8.8.8:53", withDefaultDNSPort("8.8.8.8")) + assert.Equal(t, "8.8.8.8:5353", withDefaultDNSPort("8.8.8.8:5353")) + assert.Equal(t, "[2001:db8::1]:53", withDefaultDNSPort("2001:db8::1")) + assert.Equal(t, "[2001:db8::1]:5353", withDefaultDNSPort("[2001:db8::1]:5353")) +} + +func TestNewResolverWithConfig(t *testing.T) { + // No servers -> nil, leaving Go's default system resolver selection in place + assert.Nil(t, NewResolverWithConfig(&Config{})) + + // Servers configured -> pure-Go resolver + r := NewResolverWithConfig(&Config{Servers: []string{"8.8.8.8"}}) + require.NotNil(t, r) + assert.True(t, r.PreferGo) + assert.NotNil(t, r.Dial) +} + +func TestNewResolverFromConfigSection(t *testing.T) { + resetConf() + utConf.Set(DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) + r := NewResolver(utConf) + require.NotNil(t, r) + assert.True(t, r.PreferGo) + + resetConf() + assert.Nil(t, NewResolver(utConf)) +} + +func TestResolverDialFailover(t *testing.T) { + // Stand up a listener acting as the "good" DNS server + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + accepted := make(chan struct{}, 1) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr == nil { + accepted <- struct{}{} + _ = conn.Close() + } + }() + + // First server is unroutable so the dialer must fail over to the live listener + r := NewResolverWithConfig(&Config{ + Timeout: 5 * time.Second, + Servers: []string{"127.0.0.1:1", ln.Addr().String()}, + }) + require.NotNil(t, r) + + conn, err := r.Dial(context.Background(), "tcp", "ignored:53") + require.NoError(t, err) + defer conn.Close() + assert.Equal(t, ln.Addr().String(), conn.RemoteAddr().String()) + + select { + case <-accepted: + case <-time.After(5 * time.Second): + t.Fatal("DNS dial did not reach the configured server") + } +} + +func TestResolverDialAllFail(t *testing.T) { + r := NewResolverWithConfig(&Config{ + Timeout: 250 * time.Millisecond, + Servers: []string{"127.0.0.1:1"}, + }) + require.NotNil(t, r) + _, err := r.Dial(context.Background(), "tcp", "ignored:53") + assert.Error(t, err) +} + +func TestEnableResolverMetrics(t *testing.T) { + metricsManager = nil + defer func() { metricsManager = nil }() + + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test") + EnableResolverMetrics(ctx, mr) + require.NotNil(t, metricsManager) + + // Idempotent - a second call is a no-op rather than re-registering + EnableResolverMetrics(ctx, mr) +} + +func TestResolverDialRecordsMetrics(t *testing.T) { + metricsManager = nil + defer func() { metricsManager = nil }() + + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test") + EnableResolverMetrics(ctx, mr) + + // Live listener acts as the second (good) DNS server; the first is unroutable so a single + // Dial exercises the request, error (failover), and response metric paths together. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + if conn, acceptErr := ln.Accept(); acceptErr == nil { + _ = conn.Close() + } + }() + + r := NewResolverWithConfig(&Config{ + Timeout: 5 * time.Second, + Servers: []string{"127.0.0.1:1", ln.Addr().String()}, + }) + require.NotNil(t, r) + conn, err := r.Dial(ctx, "tcp", "ignored:53") + require.NoError(t, err) + defer conn.Close() + + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_requests_total"), float64(2), "one request per server attempted") + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_responses_total"), float64(1), "one successful response") + assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_errors_total"), float64(1), "first server failed over") +} + +func TestResolverDialNoMetricsWhenDisabled(t *testing.T) { + metricsManager = nil // metrics not enabled -> recording is a no-op, no panic + r := NewResolverWithConfig(&Config{ + Timeout: 250 * time.Millisecond, + Servers: []string{"127.0.0.1:1"}, + }) + require.NotNil(t, r) + _, err := r.Dial(context.Background(), "tcp", "ignored:53") + assert.Error(t, err) +} + +func TestClassifyDNSError(t *testing.T) { + assert.Equal(t, "error", classifyDNSError(assertAnErr{})) + assert.Equal(t, "timeout", classifyDNSError(timeoutErr{})) +} + +type assertAnErr struct{} + +func (assertAnErr) Error() string { return "boom" } + +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "i/o timeout" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } diff --git a/pkg/ffnet/config.go b/pkg/ffnet/config.go new file mode 100644 index 0000000..92844b5 --- /dev/null +++ b/pkg/ffnet/config.go @@ -0,0 +1,48 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ffnet builds outbound net.Dialers and their egress controls: a custom DNS resolver +// (via ffdns) plus a CIDR egress denylist for SSRF protection. It is the single place to +// configure how — and where — a client is allowed to make outbound connections. +package ffnet + +import ( + "github.com/hyperledger/firefly-common/pkg/config" +) + +const ( + // CIDRDenylist is the list of CIDR ranges to which outbound connections are blocked, as a + // core SSRF mitigation. It is empty by default. Callers should + // compose an appropriate denylist depending on the client's use case. + NetCIDRDenylist = "cidrDenylist" +) + +// Config is the outbound-dialer configuration. +type Config struct { + // CIDRDenylist is the set of CIDR ranges to block outbound connections to. Empty means no + // restriction. + CIDRDenylist []string +} + +func InitConfig(conf config.Section) { + conf.AddKnownKey(NetCIDRDenylist) +} + +func GenerateConfig(conf config.Section) (*Config, error) { + cfg := &Config{} + cfg.CIDRDenylist = conf.GetStringSlice(NetCIDRDenylist) + return cfg, nil +} diff --git a/pkg/ffnet/ffnet.go b/pkg/ffnet/ffnet.go new file mode 100644 index 0000000..b3f4ae1 --- /dev/null +++ b/pkg/ffnet/ffnet.go @@ -0,0 +1,78 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ffnet + +import ( + "context" + "net" + "syscall" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// NewDialer builds a *net.Dialer wired with the CIDR egress guard and provided the DNS resolver (if any). +// The caller is responsible for setting Timeout / KeepAlive to suit its protocol. Exported so any dialer-based +// client — HTTP, WebSocket, etc. — can apply identical outbound protection from the same config. +func NewDialer(ctx context.Context, cfg *Config, resolver *net.Resolver) (*net.Dialer, error) { + control, err := NewDialControl(ctx, cfg) + if err != nil { + return nil, err + } + return &net.Dialer{ + Resolver: resolver, + Control: control, + }, nil +} + +type DialControl func(network, address string, c syscall.RawConn) error + +// NewDialControl builds a net.Dialer Control function that rejects connections to any address +// inside the effective CIDR denylist. It runs after DNS resolution +// against the actual resolved IP, so it also defeats DNS-rebinding and literal-IP bypasses. +// Returns (nil, nil) when the effective denylist is empty (no restrictions). +func NewDialControl(ctx context.Context, cfg *Config) (DialControl, error) { + entries := cfg.CIDRDenylist + if len(entries) == 0 { + return nil, nil + } + denied := make([]*net.IPNet, 0, len(entries)) + for _, entry := range entries { + _, ipNet, err := net.ParseCIDR(entry) + if err != nil { + return nil, i18n.NewError(ctx, i18n.MsgInvalidCIDR, entry) + } + denied = append(denied, ipNet) + } + return func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + host = address + } + ip := net.ParseIP(host) + if ip == nil { + // Control is always invoked with a resolved IP literal; if it isn't one, fail + // closed rather than allow an unexpected target through. + return i18n.NewError(ctx, i18n.MsgConnectionToCIDRBlocked, address, "unparseable address") + } + for _, ipNet := range denied { + if ipNet.Contains(ip) { + return i18n.NewError(ctx, i18n.MsgConnectionToCIDRBlocked, ip.String(), ipNet.String()) + } + } + return nil + }, nil +} diff --git a/pkg/ffnet/ffnet_test.go b/pkg/ffnet/ffnet_test.go new file mode 100644 index 0000000..08af0e2 --- /dev/null +++ b/pkg/ffnet/ffnet_test.go @@ -0,0 +1,238 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ffnet + +import ( + "context" + "net" + "testing" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var utConf = config.RootSection("net_unit_tests") + +var testSSRDenylist = []string{ + "0.0.0.0/8", // unspecified / "this host" (RFC 1122) + "127.0.0.0/8", // IPv4 loopback + "169.254.0.0/16", // IPv4 link-local, incl. cloud metadata 169.254.169.254 + "10.0.0.0/8", // IPv4 private RFC1918 + "172.16.0.0/12", // IPv4 private RFC1918 + "192.168.0.0/16", // IPv4 private RFC1918 + "100.64.0.0/10", // IPv4 CGNAT + "224.0.0.0/4", // IPv4 multicast + "240.0.0.0/4", // IPv4 reserved (incl. 255.255.255.255 broadcast) + "fc00::/7", // IPv6 ULA + "fe00::/8", // IPv6 private RFC4193 + "ff00::/8", // IPv6 reserved + "::ffff:127.0.0.1/128", // IPv4-mapped IPv6 loopback + "::1/128", // IPv6 loopback + "::/0", // IPv6 unspecified + "::/128", // IPv6 unspecified +} + +func resetConf() { + config.RootConfigReset() + InitConfig(utConf) +} + +func TestNewDialControlEmptyByDefault(t *testing.T) { + // No denylist configured => no egress restriction at all + control, err := NewDialControl(context.Background(), &Config{}) + require.NoError(t, err) + assert.Nil(t, control) +} + +func TestNewDialControlSSRFDenylist(t *testing.T) { + // The test denylist blocks every reserved/internal category + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) + require.NoError(t, err) + require.NotNil(t, control) + + assert.Error(t, control("tcp", "127.0.0.1:8080", nil)) // loopback + assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // cloud metadata + assert.Error(t, control("tcp", "0.0.0.0:80", nil)) // unspecified + assert.Error(t, control("tcp", "[::1]:443", nil)) // IPv6 loopback + assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // AWS IMDS IPv6 + assert.Error(t, control("tcp", "224.0.0.1:80", nil)) // IPv4 multicast + assert.Error(t, control("tcp", "255.255.255.255:80", nil)) // IPv4 broadcast (reserved) + assert.Error(t, control("tcp", "240.0.0.1:80", nil)) // IPv4 reserved + assert.Error(t, control("tcp", "[ff02::1]:80", nil)) // IPv6 multicast + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // RFC1918 private + assert.Error(t, control("tcp", "192.168.1.5:80", nil)) // RFC1918 private + assert.Error(t, control("tcp", "100.64.1.1:80", nil)) // CGNAT + assert.Error(t, control("tcp", "[fc00::1]:80", nil)) // IPv6 ULA + + // Public addresses still allowed + assert.NoError(t, control("tcp", "8.8.8.8:443", nil)) + + // IPv4-mapped IPv6 loopback is normalized and still blocked + assert.Error(t, control("tcp", "[::ffff:127.0.0.1]:80", nil)) +} + +func TestNewDialControlCloudMetadataOnly(t *testing.T) { + // The minimal protection: block only the cloud metadata endpoints + cloudMetadataCIDRs := []string{ + "169.254.169.254/32", + "fd00:ec2::254/128", + } + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: cloudMetadataCIDRs}) + require.NoError(t, err) + require.NotNil(t, control) + + assert.Error(t, control("tcp", "169.254.169.254:80", nil)) // metadata blocked + assert.Error(t, control("tcp", "[fd00:ec2::254]:80", nil)) // IMDS IPv6 blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // loopback reachable + assert.NoError(t, control("tcp", "169.254.1.1:80", nil)) // other link-local reachable +} + +func TestNewDialControlOverride(t *testing.T) { + // Explicit empty list disables denylisting entirely + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{}}) + require.NoError(t, err) + assert.Nil(t, control) + + // A custom list is used exactly as given + ipv4PrivateCIDRs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + control, err = NewDialControl(context.Background(), &Config{CIDRDenylist: ipv4PrivateCIDRs}) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // in list + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // not in list +} + +func TestNewDialControlInvalidCIDR(t *testing.T) { + _, err := NewDialControl(context.Background(), &Config{CIDRDenylist: []string{"not-a-cidr"}}) + assert.Regexp(t, "FF00260", err) +} + +func TestDialControlBlocksUnparseableAddress(t *testing.T) { + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) + require.NoError(t, err) + // Fail closed if an address somehow isn't a resolved IP literal + assert.Regexp(t, "FF00261", control("tcp", "not-an-ip:80", nil)) +} + +func TestDialControlBareIPNoPort(t *testing.T) { + // Addresses without a port still resolve their IP (SplitHostPort error fallback) + control, err := NewDialControl(context.Background(), &Config{CIDRDenylist: testSSRDenylist}) + require.NoError(t, err) + assert.Error(t, control("tcp", "127.0.0.1", nil)) // blocked + assert.NoError(t, control("tcp", "8.8.8.8", nil)) // allowed + assert.Error(t, control("tcp", "169.254.169.254", nil)) // metadata blocked +} + +func TestNewDialerEndToEndBlocks(t *testing.T) { + // The guard fires through a real net.Dialer dial, before any connection is made + loopbackCIDRs := []string{ + "127.0.0.0/8", + } + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: loopbackCIDRs}, nil) + require.NoError(t, err) + require.NotNil(t, d.Control) + _, err = d.DialContext(context.Background(), "tcp", "127.0.0.1:1") + assert.Regexp(t, "FF00261", err) +} + +func TestNewDialerAllowsAndConnects(t *testing.T) { + // With the denylist disabled, the dialer connects normally to a loopback listener + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + if conn, acceptErr := ln.Accept(); acceptErr == nil { + _ = conn.Close() + } + }() + + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: []string{}}, nil) + require.NoError(t, err) + assert.Nil(t, d.Control) + conn, err := d.DialContext(context.Background(), "tcp", ln.Addr().String()) + require.NoError(t, err) + require.NotNil(t, conn) + _ = conn.Close() +} + +func TestGenerateConfigDenylistFromConfig(t *testing.T) { + resetConf() + ipv4PrivateCIDRs := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + utConf.Set(NetCIDRDenylist, ipv4PrivateCIDRs) + cfg, err := GenerateConfig(utConf) + require.NoError(t, err) + assert.Equal(t, ipv4PrivateCIDRs, cfg.CIDRDenylist) + + control, err := NewDialControl(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "10.1.2.3:80", nil)) // configured entry blocked + assert.NoError(t, control("tcp", "127.0.0.1:80", nil)) // loopback not in the configured list +} + +func TestNewDialer(t *testing.T) { + // SSRF denylist => dialer with the egress guard wired (resolver nil since no DNS servers) + d, err := NewDialer(context.Background(), &Config{CIDRDenylist: testSSRDenylist}, nil) + require.NoError(t, err) + require.NotNil(t, d) + assert.Nil(t, d.Resolver) + require.NotNil(t, d.Control) + assert.Error(t, d.Control("tcp", "127.0.0.1:80", nil)) + + resolver := ffdns.NewResolverWithConfig(&ffdns.Config{Servers: []string{"8.8.8.8"}}) + // DNS servers => resolver attached; no denylist => no control + d, err = NewDialer(context.Background(), &Config{}, resolver) + require.NoError(t, err) + require.NotNil(t, d.Resolver) + assert.Nil(t, d.Control) + + // Invalid CIDR propagates as an error + _, err = NewDialer(context.Background(), &Config{CIDRDenylist: []string{"bad"}}, resolver) + assert.Regexp(t, "FF00260", err) +} + +func TestGenerateConfigDenylistSemantics(t *testing.T) { + // Unset cidrDenylist => empty, no guard + resetConf() + cfg, err := GenerateConfig(utConf) + require.NoError(t, err) + assert.Empty(t, cfg.CIDRDenylist) + control, err := NewDialControl(context.Background(), cfg) + require.NoError(t, err) + assert.Nil(t, control) + + // Configured denylist => guard active + resetConf() + utConf.Set(NetCIDRDenylist, testSSRDenylist) + cfg, err = GenerateConfig(utConf) + require.NoError(t, err) + assert.NotEmpty(t, cfg.CIDRDenylist) + control, err = NewDialControl(context.Background(), cfg) + require.NoError(t, err) + require.NotNil(t, control) + assert.Error(t, control("tcp", "127.0.0.1:80", nil)) +} diff --git a/pkg/ffresty/config.go b/pkg/ffresty/config.go index 0550dd7..05bbef2 100644 --- a/pkg/ffresty/config.go +++ b/pkg/ffresty/config.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -20,6 +20,8 @@ import ( "context" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/fftypes" ) @@ -120,6 +122,12 @@ func InitConfig(conf config.Section) { tlsConfig := conf.SubSection("tls") fftls.InitTLSConfig(tlsConfig) + + dnsConfig := conf.SubSection("dns") + ffdns.InitConfig(dnsConfig) + + netConfig := conf.SubSection("net") + ffnet.InitConfig(netConfig) } func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { @@ -157,5 +165,21 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*Config, error) { ffrestyConfig.TLSClientConfig = tlsClientConfig + dnsCfg, err := ffdns.GenerateConfig(conf.SubSection("dns")) + if err != nil { + return nil, err + } + ffrestyConfig.Resolver = ffdns.NewResolverWithConfig(dnsCfg) + + netCfg, err := ffnet.GenerateConfig(conf.SubSection("net")) + if err != nil { + return nil, err + } + dialControl, err := ffnet.NewDialControl(ctx, netCfg) + if err != nil { + return nil, err + } + ffrestyConfig.DialControl = dialControl + return ffrestyConfig, nil } diff --git a/pkg/ffresty/config_test.go b/pkg/ffresty/config_test.go index 7cb2117..f4a20a6 100644 --- a/pkg/ffresty/config_test.go +++ b/pkg/ffresty/config_test.go @@ -20,6 +20,7 @@ import ( "context" "testing" + "github.com/hyperledger/firefly-common/pkg/ffdns" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/fftypes" "github.com/stretchr/testify/assert" @@ -47,6 +48,7 @@ func TestWSConfigGeneration(t *testing.T) { utConf.Set(HTTPTLSHandshakeTimeout, 1) utConf.Set(HTTPExpectContinueTimeout, 1) utConf.Set(HTTPPassthroughHeadersEnabled, true) + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8", "1.1.1.1:53"}) ctx := context.Background() config, err := GenerateConfig(ctx, utConf) @@ -68,6 +70,8 @@ func TestWSConfigGeneration(t *testing.T) { assert.Equal(t, fftypes.FFDuration(1000000), config.HTTPConnectionTimeout) assert.Equal(t, 1, config.HTTPMaxIdleConns) assert.Equal(t, "custom value", config.HTTPHeaders.GetString("custom-header")) + // dns.servers drives a programmatic resolver built via ffdns + assert.NotNil(t, config.Resolver) } func TestWSConfigTLSGenerationFail(t *testing.T) { diff --git a/pkg/ffresty/ffresty.go b/pkg/ffresty/ffresty.go index 2c7fad4..3d2ea09 100644 --- a/pkg/ffresty/ffresty.go +++ b/pkg/ffresty/ffresty.go @@ -1,4 +1,4 @@ -// Copyright © 2025 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -28,6 +28,7 @@ import ( "net/url" "regexp" "strings" + "syscall" "time" "github.com/go-resty/resty/v2" @@ -70,31 +71,33 @@ var ( // HTTPConfig is all the optional configuration separate to the URL you wish to invoke. // This is JSON serializable with docs, so you can embed it into API objects. type HTTPConfig struct { - ProxyURL string `ffstruct:"RESTConfig" json:"proxyURL,omitempty"` - HTTPRequestTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"requestTimeout,omitempty"` - HTTPIdleConnTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"idleTimeout,omitempty"` - HTTPMaxIdleTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"maxIdleTimeout,omitempty"` - HTTPConnectionTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"connectionTimeout,omitempty"` - HTTPExpectContinueTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"expectContinueTimeout,omitempty"` - AuthUsername string `ffstruct:"RESTConfig" json:"authUsername,omitempty"` - AuthPassword string `ffstruct:"RESTConfig" json:"authPassword,omitempty"` - ThrottleRequestsPerSecond int `ffstruct:"RESTConfig" json:"requestsPerSecond,omitempty"` - ThrottleBurst int `ffstruct:"RESTConfig" json:"burst,omitempty"` - Retry bool `ffstruct:"RESTConfig" json:"retry,omitempty"` - RetryCount int `ffstruct:"RESTConfig" json:"retryCount,omitempty"` - RetryInitialDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryInitialDelay,omitempty"` - RetryMaximumDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryMaximumDelay,omitempty"` - RetryErrorStatusCodeRegex string `ffstruct:"RESTConfig" json:"retryErrorStatusCodeRegex,omitempty"` - HTTPMaxIdleConns int `ffstruct:"RESTConfig" json:"maxIdleConns,omitempty"` - HTTPMaxConnsPerHost int `ffstruct:"RESTConfig" json:"maxConnsPerHost,omitempty"` - HTTPMaxIdleConnsPerHost int `ffstruct:"RESTConfig" json:"maxIdleConnsPerHost,omitempty"` - HTTPPassthroughHeadersEnabled bool `ffstruct:"RESTConfig" json:"httpPassthroughHeadersEnabled,omitempty"` - HTTPHeaders fftypes.JSONObject `ffstruct:"RESTConfig" json:"headers,omitempty"` - HTTPTLSHandshakeTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"tlsHandshakeTimeout,omitempty"` - HTTPCustomClient interface{} `json:"-"` - TLSClientConfig *tls.Config `json:"-"` // should be built from separate TLSConfig using fftls utils - OnCheckRetry func(res *resty.Response, err error) bool `json:"-"` // response could be nil on err - OnBeforeRequest func(req *resty.Request) error `json:"-"` // called before each request, even retry + ProxyURL string `ffstruct:"RESTConfig" json:"proxyURL,omitempty"` + HTTPRequestTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"requestTimeout,omitempty"` + HTTPIdleConnTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"idleTimeout,omitempty"` + HTTPMaxIdleTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"maxIdleTimeout,omitempty"` + HTTPConnectionTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"connectionTimeout,omitempty"` + HTTPExpectContinueTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"expectContinueTimeout,omitempty"` + AuthUsername string `ffstruct:"RESTConfig" json:"authUsername,omitempty"` + AuthPassword string `ffstruct:"RESTConfig" json:"authPassword,omitempty"` + ThrottleRequestsPerSecond int `ffstruct:"RESTConfig" json:"requestsPerSecond,omitempty"` + ThrottleBurst int `ffstruct:"RESTConfig" json:"burst,omitempty"` + Retry bool `ffstruct:"RESTConfig" json:"retry,omitempty"` + RetryCount int `ffstruct:"RESTConfig" json:"retryCount,omitempty"` + RetryInitialDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryInitialDelay,omitempty"` + RetryMaximumDelay fftypes.FFDuration `ffstruct:"RESTConfig" json:"retryMaximumDelay,omitempty"` + RetryErrorStatusCodeRegex string `ffstruct:"RESTConfig" json:"retryErrorStatusCodeRegex,omitempty"` + HTTPMaxIdleConns int `ffstruct:"RESTConfig" json:"maxIdleConns,omitempty"` + HTTPMaxConnsPerHost int `ffstruct:"RESTConfig" json:"maxConnsPerHost,omitempty"` + HTTPMaxIdleConnsPerHost int `ffstruct:"RESTConfig" json:"maxIdleConnsPerHost,omitempty"` + HTTPPassthroughHeadersEnabled bool `ffstruct:"RESTConfig" json:"httpPassthroughHeadersEnabled,omitempty"` + HTTPHeaders fftypes.JSONObject `ffstruct:"RESTConfig" json:"headers,omitempty"` + HTTPTLSHandshakeTimeout fftypes.FFDuration `ffstruct:"RESTConfig" json:"tlsHandshakeTimeout,omitempty"` + HTTPCustomClient interface{} `json:"-"` + TLSClientConfig *tls.Config `json:"-"` // should be built from separate TLSConfig using fftls utils + Resolver *net.Resolver `json:"-"` // programmatic DNS resolver override; takes precedence over DNSServers + DialControl func(network, address string, c syscall.RawConn) error `json:"-"` // SSRF CIDR-denylist guard applied to the dialer; built from the dns config via ffdns + OnCheckRetry func(res *resty.Response, err error) bool `json:"-"` // response could be nil on err + OnBeforeRequest func(req *resty.Request) error `json:"-"` // called before each request, even retry } func EnableClientMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) error { @@ -209,12 +212,23 @@ func NewWithConfig(ctx context.Context, ffrestyConfig Config) (client *resty.Cli if client == nil { + dialer := &net.Dialer{ + Timeout: time.Duration(ffrestyConfig.HTTPConnectionTimeout), + KeepAlive: time.Duration(ffrestyConfig.HTTPConnectionTimeout), + } + // An explicit programmatic resolver wins; otherwise build one from any configured DNS servers. + // Either way the system resolver is replaced with Go's built-in resolver. + if ffrestyConfig.Resolver != nil { + dialer.Resolver = ffrestyConfig.Resolver + } + // CIDR-denylist guard for SSRF and/or high-trust, checked against the resolved IP just before connect. + if ffrestyConfig.DialControl != nil { + dialer.Control = ffrestyConfig.DialControl + } + httpTransport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: time.Duration(ffrestyConfig.HTTPConnectionTimeout), - KeepAlive: time.Duration(ffrestyConfig.HTTPConnectionTimeout), - }).DialContext, + Proxy: http.ProxyFromEnvironment, + DialContext: dialer.DialContext, ForceAttemptHTTP2: true, MaxIdleConns: ffrestyConfig.HTTPMaxIdleConns, MaxConnsPerHost: ffrestyConfig.HTTPMaxConnsPerHost, diff --git a/pkg/ffresty/ffresty_test.go b/pkg/ffresty/ffresty_test.go index 199bc7c..d60de28 100644 --- a/pkg/ffresty/ffresty_test.go +++ b/pkg/ffresty/ffresty_test.go @@ -42,6 +42,8 @@ import ( "github.com/go-resty/resty/v2" "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffapi" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-common/pkg/metric" @@ -819,3 +821,45 @@ func TestTrace(t *testing.T) { require.JSONEq(t, `{"some":"data"}`, traceBody(map[string]string{"some": "data"})) require.Equal(t, `(binary reader)`, traceBody(strings.NewReader("data to stream"))) } + +func TestNewWithConfigResolverWired(t *testing.T) { + // A programmatic resolver (e.g. built from the dns config subsection by ffdns) is + // attached to the transport's dialer rather than forcing the all-or-nothing custom client. + ctx := context.Background() + c := NewWithConfig(ctx, Config{HTTPConfig: HTTPConfig{ + Resolver: &net.Resolver{PreferGo: true}, + }}) + require.NotNil(t, c) + transport, ok := c.GetClient().Transport.(*http.Transport) + require.True(t, ok) + assert.NotNil(t, transport.DialContext) +} + +func TestDialControlBlocksLoopbackWhenConfigured(t *testing.T) { + // With an SSRF denylist configured, the client blocks loopback before connecting + resetConf() + ssrfDenylist := []string{ + "127.0.0.0/8", + } + utConf.SubSection("net").Set(ffnet.NetCIDRDenylist, ssrfDenylist) + utConf.Set(HTTPConfigURL, "http://127.0.0.1:1") + c, err := New(context.Background(), utConf) + require.NoError(t, err) + _, err = c.R().Get("/") + assert.Regexp(t, "FF00261", err) +} + +func TestGenerateConfigDNSResolver(t *testing.T) { + // With dns.servers configured, GenerateConfig populates a resolver via ffdns + resetConf() + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + cfg, err := GenerateConfig(context.Background(), utConf) + assert.NoError(t, err) + assert.NotNil(t, cfg.Resolver) + + // With no dns.servers, no resolver is built and Go's default selection stays in place + resetConf() + cfg, err = GenerateConfig(context.Background(), utConf) + assert.NoError(t, err) + assert.Nil(t, cfg.Resolver) +} diff --git a/pkg/fftls/certexpiry_test.go b/pkg/fftls/certexpiry_test.go new file mode 100644 index 0000000..eb7efd3 --- /dev/null +++ b/pkg/fftls/certexpiry_test.go @@ -0,0 +1,238 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fftls + +import ( + "context" + _ "embed" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/metric" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The certificates below are static self-signed fixtures generated once with openssl. The CA +// fixtures are long-lived (valid until 2036); the leaf is deliberately generated to expire on +// 2026-06-10, i.e. it is already expired. This proves we record the expiry of an expired cert +// just the same - we only parse the NotAfter, we never validate the cert, so loading and +// recording succeed regardless. Example commands: +// +// openssl req -x509 -newkey rsa:2048 -keyout leaf-key.pem -out leaf-cert.pem \ +// -not_before 20260609000000Z -not_after 20260610000000Z -nodes \ +// -subj "/CN=leaf/O=FireFly" -addext "subjectAltName=IP:127.0.0.1" +// +// ca-bundle.pem is the concatenation of ca-a-cert.pem and ca-b-cert.pem. +// +// The expiry timestamps below are the NotAfter (unix) of each fixture, hard-coded so the test +// asserts against a fixed expectation rather than re-deriving it from the same parsing logic +// under test. DO NOT regenerate the fixtures without also updating these constants. +var ( + //go:embed testdata/ca-a-cert.pem + caACertPEM []byte + //go:embed testdata/ca-bundle.pem + caBundlePEM []byte + //go:embed testdata/leaf-cert.pem + leafCertPEM []byte + //go:embed testdata/leaf-key.pem + leafKeyPEM []byte + // leaf-bundle.pem is leaf-cert.pem followed by ca-a-cert.pem, standing in for a leaf cert file + // that bundles the leaf with its intermediate chain. + //go:embed testdata/leaf-bundle.pem + leafBundlePEM []byte +) + +const ( + caAExpiryUnix = float64(2096370463) // CN=ca-a, notAfter=2036-06-06 13:07:43Z + caBExpiryUnix = float64(2096370464) // CN=ca-b, notAfter=2036-06-06 13:07:44Z + leafExpiryUnix = float64(1781049600) // CN=leaf, notAfter=2026-06-10 00:00:00Z (already expired) +) + +func writeTemp(t *testing.T, dir, name string, content []byte) string { + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, content, 0600)) + return p +} + +// findCertExpiry gathers from the registry and returns the value of the ff_tls_certificate_expiry +// gauge for the series matching the given "type" label and whose "subject" label contains the supplied +// substring. +func findCertExpiry(t *testing.T, mr metric.MetricsRegistry, certType, subjectSubstr string) (float64, bool) { + mfs, err := mr.GetGatherer().Gather() + require.NoError(t, err) + for _, mf := range mfs { + if mf.GetName() != "ff_tls_certificate_expiry" { + continue + } + for _, m := range mf.GetMetric() { + var typeMatch, subjectMatch bool + for _, lp := range m.GetLabel() { + if lp.GetName() == "type" && lp.GetValue() == certType { + typeMatch = true + } + if lp.GetName() == "subject" && strings.Contains(lp.GetValue(), subjectSubstr) { + subjectMatch = true + } + } + if typeMatch && subjectMatch { + return m.GetGauge().GetValue(), true + } + } + } + return 0, false +} + +// enableTestCertMetrics resets the package-level manager and binds it to a fresh registry for the test. +func enableTestCertMetrics(t *testing.T, component string) metric.MetricsRegistry { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + mr := metric.NewPrometheusMetricsRegistry(component) + require.NoError(t, EnableCertificateMetrics(context.Background(), mr)) + return mr +} + +func TestEnableCertificateMetricsRecordsCAAndClientExpiry(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_client") + + dir := t.TempDir() + caBundleFile := writeTemp(t, dir, "ca-bundle.pem", caBundlePEM) + leafCertFile := writeTemp(t, dir, "leaf-cert.pem", leafCertPEM) + leafKeyFile := writeTemp(t, dir, "leaf-key.pem", leafKeyPEM) + + conf := config.RootSection("fftls_metrics_client") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCAFile, caBundleFile) + conf.Set(HTTPConfTLSCertFile, leafCertFile) + conf.Set(HTTPConfTLSKeyFile, leafKeyFile) + + _, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + + // Each certificate in the CA bundle gets its own series under the type="ca" label + caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") + require.True(t, ok, "ca-a expiry gauge not found") + assert.Equal(t, caAExpiryUnix, caAVal) + + caBVal, ok := findCertExpiry(t, mr, "ca", "ca-b") + require.True(t, ok, "ca-b expiry gauge not found") + assert.Equal(t, caBExpiryUnix, caBVal) + + // A client TLS config records the leaf under type="client" + clientVal, ok := findCertExpiry(t, mr, "client", "leaf") + require.True(t, ok, "client expiry gauge not found") + assert.Equal(t, leafExpiryUnix, clientVal) + + // ... and nothing should have been recorded under type="server" + _, ok = findCertExpiry(t, mr, "server", "leaf") + assert.False(t, ok, "server gauge should not be set for a client config") +} + +func TestEnableCertificateMetricsBundledLeafRecordsLeafNotChain(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_leaf_bundle") + + dir := t.TempDir() + // The cert file bundles the leaf with an intermediate (ca-a). We must record the leaf's expiry, + // not the intermediate's. + leafCertFile := writeTemp(t, dir, "leaf-bundle.pem", leafBundlePEM) + leafKeyFile := writeTemp(t, dir, "leaf-key.pem", leafKeyPEM) + + conf := config.RootSection("fftls_metrics_leaf_bundle") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCertFile, leafCertFile) + conf.Set(HTTPConfTLSKeyFile, leafKeyFile) + + _, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + + // The recorded client expiry is the leaf's, even though ca-a appears later in the bundle + clientVal, ok := findCertExpiry(t, mr, "client", "leaf") + require.True(t, ok, "client expiry gauge not found") + assert.Equal(t, leafExpiryUnix, clientVal) + + // The intermediate in the cert bundle must not be recorded as a client (or ca) series here + _, ok = findCertExpiry(t, mr, "client", "ca-a") + assert.False(t, ok, "intermediate from the leaf bundle should not be recorded") +} + +func TestEnableCertificateMetricsRecordsServerExpiryInline(t *testing.T) { + mr := enableTestCertMetrics(t, "test_fftls_server") + + // Inline (non-file) PEM material is recorded the same way + conf := &Config{ + Enabled: true, + CA: string(caACertPEM), + Cert: string(leafCertPEM), + Key: string(leafKeyPEM), + } + _, err := NewTLSConfig(context.Background(), conf, ServerType) + require.NoError(t, err) + + caAVal, ok := findCertExpiry(t, mr, "ca", "ca-a") + require.True(t, ok, "ca-a expiry gauge not found") + assert.Equal(t, caAExpiryUnix, caAVal) + + // A server TLS config records the leaf under type="server" + serverVal, ok := findCertExpiry(t, mr, "server", "leaf") + require.True(t, ok, "server expiry gauge not found") + assert.Equal(t, leafExpiryUnix, serverVal) + + _, ok = findCertExpiry(t, mr, "client", "leaf") + assert.False(t, ok, "client gauge should not be set for a server config") +} + +func TestEnableCertificateMetricsIdempotent(t *testing.T) { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test_fftls_idem") + require.NoError(t, EnableCertificateMetrics(ctx, mr)) + // Second call is a no-op and must not error (would otherwise fail re-registering the subsystem) + require.NoError(t, EnableCertificateMetrics(ctx, mr)) +} + +func TestEnableCertificateMetricsError(t *testing.T) { + certMetricsManager = nil + t.Cleanup(func() { certMetricsManager = nil }) + ctx := context.Background() + mr := metric.NewPrometheusMetricsRegistry("test_fftls_err") + // Claim the "tls" subsystem before fftls can, forcing a registration error + _, _ = mr.NewMetricsManagerForSubsystem(ctx, CertMetricsSubsystem) + err := EnableCertificateMetrics(ctx, mr) + assert.Error(t, err) +} + +func TestNewTLSConfigNoMetricsManagerNoPanic(t *testing.T) { + // With metrics disabled (the default), loading certs must not panic + certMetricsManager = nil + dir := t.TempDir() + caFile := writeTemp(t, dir, "ca-a-cert.pem", caACertPEM) + + conf := config.RootSection("fftls_no_metrics") + InitTLSConfig(conf) + conf.Set(HTTPConfTLSEnabled, true) + conf.Set(HTTPConfTLSCAFile, caFile) + + tlsConfig, err := ConstructTLSConfig(context.Background(), conf, ClientType) + require.NoError(t, err) + assert.NotNil(t, tlsConfig) +} diff --git a/pkg/fftls/fftls.go b/pkg/fftls/fftls.go index abcafcc..034b7e1 100644 --- a/pkg/fftls/fftls.go +++ b/pkg/fftls/fftls.go @@ -21,6 +21,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/pem" "os" "regexp" "strings" @@ -28,6 +29,7 @@ import ( "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/i18n" "github.com/hyperledger/firefly-common/pkg/log" + "github.com/hyperledger/firefly-common/pkg/metric" ) type TLSType string @@ -37,6 +39,45 @@ const ( ClientType TLSType = "client" ) +const ( + // CertMetricsSubsystem is the metrics subsystem under which the certificate expiry gauge is + // registered, i.e. ff_tls_certificate_expiry. + CertMetricsSubsystem = "tls" + + metricsTLSCertificateExpiry = "certificate_expiry" + + // Values for the "type" label on the certificate expiry gauge + certTypeCA = "ca" + certTypeClient = "client" + certTypeServer = "server" +) + +// certMetricsManager is the optional, process-wide metrics manager used to emit certificate expiry +// gauges as TLS configs are loaded. It is nil until EnableCertificateMetrics is called. +var certMetricsManager metric.MetricsManager + +// EnableCertificateMetrics registers a gauge (in the "tls" subsystem) that is set to the unix +// timestamp at which loaded TLS certificates expire. Once enabled, every subsequent NewTLSConfig / +// ConstructTLSConfig call records the gauge for each CA certificate, and for the configured client or +// server certificate - distinguished by the "type" label (ca/client/server). Because certificate +// expiry is static, these are only recorded once when the certificate material is read - never +// per-connection. +// +// It is safe to call this multiple times; only the first call registers the metric. Callers that +// build both client and server TLS configs only need to call it once for the shared registry. +func EnableCertificateMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) error { + if certMetricsManager != nil { + return nil + } + mm, err := metricsRegistry.NewMetricsManagerForSubsystem(ctx, CertMetricsSubsystem) + if err != nil { + return err + } + mm.NewGaugeMetricWithLabels(ctx, metricsTLSCertificateExpiry, "TLS certificate expiry as a unix timestamp", []string{"type", "subject", "issuer"}, false) + certMetricsManager = mm + return nil +} + func ConstructTLSConfig(ctx context.Context, conf config.Section, tlsType TLSType) (*tls.Config, error) { return NewTLSConfig(ctx, GenerateConfig(conf), tlsType) } @@ -72,6 +113,9 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co ok := rootCAs.AppendCertsFromPEM(caBytes) if !ok { err = i18n.NewError(ctx, i18n.MsgInvalidCAFile) + } else { + // The CA bundle may contain multiple certificates - record an expiry gauge for each + recordCACertExpiryMetrics(ctx, caBytes) } } case config.CA != "": @@ -79,6 +123,9 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co ok := rootCAs.AppendCertsFromPEM([]byte(config.CA)) if !ok { err = i18n.NewError(ctx, i18n.MsgInvalidCAFile) + } else { + // The CA bundle may contain multiple certificates - record an expiry gauge for each + recordCACertExpiryMetrics(ctx, []byte(config.CA)) } default: rootCAs, err = x509.SystemCertPool() @@ -109,6 +156,10 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co } if configuredCert != nil { + // Record an expiry gauge for the configured leaf certificate (client cert for ClientType, + // server cert for ServerType). The corresponding key must also have been provided to reach here. + recordLeafCertExpiryMetric(ctx, configuredCert, tlsType) + // Rather than letting Golang pick a certificate it thinks matches from the list of one, // we directly supply it the one we have in all cases. tlsConfig.GetClientCertificate = func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { @@ -146,6 +197,70 @@ func NewTLSConfig(ctx context.Context, config *Config, tlsType TLSType) (*tls.Co } +// recordCACertExpiryMetrics decodes a PEM bundle (which may contain one or more certificates) and +// records a CA certificate expiry gauge for each. It is best-effort and never fails the TLS config +// construction: it is a no-op if metrics are not enabled, and certificates that cannot be parsed are +// skipped with a warning. +func recordCACertExpiryMetrics(ctx context.Context, pemBytes []byte) { + if certMetricsManager == nil { + return + } + rest := pemBytes + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + log.L(ctx).Warnf("Skipping certificate that could not be parsed for expiry metric: %s", err) + continue + } + setCertExpiryGauge(ctx, certTypeCA, cert) + } +} + +// recordLeafCertExpiryMetric records the expiry gauge for the leaf certificate of a configured key +// pair - using the client gauge for ClientType TLS and the server gauge for ServerType TLS. +// +// The configured cert/key may be a bundle (leaf followed by its intermediate chain). crypto/tls +// guarantees the leaf is first: X509KeyPair/LoadX509KeyPair require Certificate[0] to be the +// certificate that matches the private key, so we record the expiry of Certificate[0] - never an +// intermediate from the chain. +func recordLeafCertExpiryMetric(ctx context.Context, cert *tls.Certificate, tlsType TLSType) { + if certMetricsManager == nil || len(cert.Certificate) == 0 { + return + } + leaf := cert.Leaf + if leaf == nil { + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + log.L(ctx).Warnf("Unable to parse leaf certificate for expiry metric: %s", err) + return + } + leaf = parsed + } + certType := certTypeClient + if tlsType == ServerType { + certType = certTypeServer + } + setCertExpiryGauge(ctx, certType, leaf) +} + +// setCertExpiryGauge sets the certificate expiry gauge to the unix timestamp of the certificate's +// expiry, labelled with the certificate type (ca/client/server). +func setCertExpiryGauge(ctx context.Context, certType string, cert *x509.Certificate) { + certMetricsManager.SetGaugeMetricWithLabels(ctx, metricsTLSCertificateExpiry, float64(cert.NotAfter.Unix()), map[string]string{ + "type": certType, + "subject": cert.Subject.String(), + "issuer": cert.Issuer.String(), + }, nil) +} + var SubjectDNKnownAttributes = map[string]func(pkix.Name) []string{ "C": func(n pkix.Name) []string { return n.Country diff --git a/pkg/fftls/testdata/ca-a-cert.pem b/pkg/fftls/testdata/ca-a-cert.pem new file mode 100644 index 0000000..7510a6e --- /dev/null +++ b/pkg/fftls/testdata/ca-a-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/ca-b-cert.pem b/pkg/fftls/testdata/ca-b-cert.pem new file mode 100644 index 0000000..ea3b84f --- /dev/null +++ b/pkg/fftls/testdata/ca-b-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUW9DuVZAuPaofedNa3DOi527uvVEwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGNhLWIxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCv20fYBlUp +fa/fj3JfMf9auGsoKq/+CPRza0ZZrZ8qY5+Idh5/cJkPuY7N2ipP3Wljo+49dSpu +CmF/ac2JIFYmJMyyolFZTHsR7WVeaEWC/u+/gY9IM9K2qXkgF9rZGVSUJ3sPO8yA +QV8x5VrAUGDxqVKvswwORZCVGXqRfGyb57PnxS9zAkvsfuMGsFAhcY6gK3QHrH3o +LEzs8KkfuCZO0wtxuuV+vrwPUUtJ3c7A9DjT1WkOhDZdDvvN7YUczKafQpLqgVcb +ARc9v9nRNOCdPdxL8O4ylO5nNeh4nriQJVEy/pZcsVDw/EGk3otm9vSkY6TOqp2P +vqXfWleXCr7xAgMBAAGjZDBiMB0GA1UdDgQWBBR8eVe2ZVlqNGnlDqPATlemtxqc +bjAfBgNVHSMEGDAWgBR8eVe2ZVlqNGnlDqPATlemtxqcbjAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJHqec/bxjqk +5VLFcYX+1SnUIt8fXB3qN7B8GWH35ol0CSwEkuztdiaAU1FnKRpqD+H4/d8Fejs3 +h4dXuVGGu2OM6zl/NJfn6YhVqVJa6H3sW+vO/SdAsNC+CCu8+5E3p71UhwDtHTZ4 +kZi89j6zTzenbz0M27UkuiIJP5W51C1qSQ+yKqxESzAlF4UNq4eP7bF2g1p09MMc +ob/SbQ4PonUL0XFqYcaaJJVDiRGWvmjOWr/ubkc1DJ3mjv9yAZL7uhSolHqadxXe +XqIhHJdXdDbHpSRFjvP1cEHPun18Bz6kMMgBOWOoIruai1ZSMnR8pFeYN6rGgPDh +euFg46XnTNc= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/ca-bundle.pem b/pkg/fftls/testdata/ca-bundle.pem new file mode 100644 index 0000000..f36ec09 --- /dev/null +++ b/pkg/fftls/testdata/ca-bundle.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUW9DuVZAuPaofedNa3DOi527uvVEwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDRaFw0zNjA2MDYxMzA3NDRaMCExDTALBgNVBAMMBGNhLWIxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCv20fYBlUp +fa/fj3JfMf9auGsoKq/+CPRza0ZZrZ8qY5+Idh5/cJkPuY7N2ipP3Wljo+49dSpu +CmF/ac2JIFYmJMyyolFZTHsR7WVeaEWC/u+/gY9IM9K2qXkgF9rZGVSUJ3sPO8yA +QV8x5VrAUGDxqVKvswwORZCVGXqRfGyb57PnxS9zAkvsfuMGsFAhcY6gK3QHrH3o +LEzs8KkfuCZO0wtxuuV+vrwPUUtJ3c7A9DjT1WkOhDZdDvvN7YUczKafQpLqgVcb +ARc9v9nRNOCdPdxL8O4ylO5nNeh4nriQJVEy/pZcsVDw/EGk3otm9vSkY6TOqp2P +vqXfWleXCr7xAgMBAAGjZDBiMB0GA1UdDgQWBBR8eVe2ZVlqNGnlDqPATlemtxqc +bjAfBgNVHSMEGDAWgBR8eVe2ZVlqNGnlDqPATlemtxqcbjAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAJHqec/bxjqk +5VLFcYX+1SnUIt8fXB3qN7B8GWH35ol0CSwEkuztdiaAU1FnKRpqD+H4/d8Fejs3 +h4dXuVGGu2OM6zl/NJfn6YhVqVJa6H3sW+vO/SdAsNC+CCu8+5E3p71UhwDtHTZ4 +kZi89j6zTzenbz0M27UkuiIJP5W51C1qSQ+yKqxESzAlF4UNq4eP7bF2g1p09MMc +ob/SbQ4PonUL0XFqYcaaJJVDiRGWvmjOWr/ubkc1DJ3mjv9yAZL7uhSolHqadxXe +XqIhHJdXdDbHpSRFjvP1cEHPun18Bz6kMMgBOWOoIruai1ZSMnR8pFeYN6rGgPDh +euFg46XnTNc= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-bundle.pem b/pkg/fftls/testdata/leaf-bundle.pem new file mode 100644 index 0000000..c9fdfbc --- /dev/null +++ b/pkg/fftls/testdata/leaf-bundle.pem @@ -0,0 +1,40 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUYxML980GdHFqGitUAPHINb3GkbwwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkw +MDAwMDBaFw0yNjA2MTAwMDAwMDBaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPhp081QNR +SohFXbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QB +yO8684W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24 +vbNnHMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65 +ATEyww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP +0tvh3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrH +Pc4Sb2oKz10XAgMBAAGjZDBiMB0GA1UdDgQWBBTyL6MXE14N6vO8xpEz7NCCxPdJ +1TAfBgNVHSMEGDAWgBTyL6MXE14N6vO8xpEz7NCCxPdJ1TAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIYvN8yTffAl +ww+stDY5osAPMl0wymg0SV4qsJ/yhEFk652WXEPBi9iV5V893tzQ34pDU60/8qmd +BIYr1PDNYaJxBzAc/JA8/8XZVBitt0bAtuvLWhqD2X644zbiI1Dtz7IEOCzPKy3g +zt6xAmXZul7ZyXRdQdYktu7O7PlN+0I9texGSY/T1125YXq+GCtHG5LTz3FCAHOH +b+BRVVANwv+VWCFAUHJMRuAdv661LOuC8kK453ia9hTnFq04mfxf+bjeOYAvHnN/ +SRZop8ROJwEHSd/coRHJlnNqfeNDlGSzZeISkaQfl7X6LXqQwxMx35NDQdTugbBF +KRzI+gXOZ6o= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUfUmbqnf6PZ7EhgZPujfRp9V6AogwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEY2EtYTEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkx +MzA3NDNaFw0zNjA2MDYxMzA3NDNaMCExDTALBgNVBAMMBGNhLWExEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCupW7v2AdA +tcfCiK+Wm3a75WE8LgSJNchbKBpotqZ22xRomdREH7e39GiE9Kj6FDKBWptJQje0 +33bWwjoB5Zd5wmXcxq0uhst88c+DBwJ6t8SoSSnt5uCRZ0neHdGSvNFSP2yUHE9/ +1r94ZNMfKvGSyKbvL8WBqIEhMudTkGjkXn5GKCmGYCtXM7vmQV+kbqK5x/mcMEwj +xBXzfg3g5wAHmwciEiMUelBE5FLCHD7tuMcN+QrA0+oq37SgA3BXdHVJGzvAvvEw +sjtzabv2Op0pmYk4jWXrLCRrGbVTjJlhU7ZpMFQvNSLciYTk0dtRtuA1zJU7VtfC +dAU7d5qRBWwXAgMBAAGjZDBiMB0GA1UdDgQWBBTcOiO/3zPacrsEyQmcXQTPfmuM +sDAfBgNVHSMEGDAWgBTcOiO/3zPacrsEyQmcXQTPfmuMsDAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAHhRD+IkWHhh +qk933B/of/wi6BXhTGWUVF10167UaiKmgOa3IfXXZCYoP0zSUHfEi2nXWZdw+1FJ +2+J+yH4vALIHSbNLU5et2WwJEQn94Stfsu1yvzL26zcGlFFakYTI4ndC00M9GIYL +PELpMlymoQH9nY/297WiXQ9fUfB4KoNqCB4kV7MEaeeZGou5RIpfGQluRMbCmskh +aaJwQONjFjr1iAyIvbgn0G/Yo0cemt3AbSSsob1OcHCjFy8l9auNt0yKpGA+ZhzM +YY/IkN/5oUfQHXgKLSRYq3++7cIUaIdU9oBFjJP6jS/sWHEvxjO0a/ti/q4cwc+F +8zTwc2S45Po= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-cert.pem b/pkg/fftls/testdata/leaf-cert.pem new file mode 100644 index 0000000..cff8670 --- /dev/null +++ b/pkg/fftls/testdata/leaf-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDNDCCAhygAwIBAgIUYxML980GdHFqGitUAPHINb3GkbwwDQYJKoZIhvcNAQEL +BQAwITENMAsGA1UEAwwEbGVhZjEQMA4GA1UECgwHRmlyZUZseTAeFw0yNjA2MDkw +MDAwMDBaFw0yNjA2MTAwMDAwMDBaMCExDTALBgNVBAMMBGxlYWYxEDAOBgNVBAoM +B0ZpcmVGbHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDPhp081QNR +SohFXbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QB +yO8684W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24 +vbNnHMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65 +ATEyww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP +0tvh3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrH +Pc4Sb2oKz10XAgMBAAGjZDBiMB0GA1UdDgQWBBTyL6MXE14N6vO8xpEz7NCCxPdJ +1TAfBgNVHSMEGDAWgBTyL6MXE14N6vO8xpEz7NCCxPdJ1TAPBgNVHRMBAf8EBTAD +AQH/MA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggEBAIYvN8yTffAl +ww+stDY5osAPMl0wymg0SV4qsJ/yhEFk652WXEPBi9iV5V893tzQ34pDU60/8qmd +BIYr1PDNYaJxBzAc/JA8/8XZVBitt0bAtuvLWhqD2X644zbiI1Dtz7IEOCzPKy3g +zt6xAmXZul7ZyXRdQdYktu7O7PlN+0I9texGSY/T1125YXq+GCtHG5LTz3FCAHOH +b+BRVVANwv+VWCFAUHJMRuAdv661LOuC8kK453ia9hTnFq04mfxf+bjeOYAvHnN/ +SRZop8ROJwEHSd/coRHJlnNqfeNDlGSzZeISkaQfl7X6LXqQwxMx35NDQdTugbBF +KRzI+gXOZ6o= +-----END CERTIFICATE----- diff --git a/pkg/fftls/testdata/leaf-key.pem b/pkg/fftls/testdata/leaf-key.pem new file mode 100644 index 0000000..8715a5b --- /dev/null +++ b/pkg/fftls/testdata/leaf-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPhp081QNRSohF +XbogM1+qoEuN+bbkKUjrRSZ3r4JK+S86b6tZPU+qoLWc0A8n1luZ7WZva0QByO86 +84W6Vf9vxQzKA/l3yHzFx20cEHtGRBYkJuZyzv7Xe9NTHSjvR6LiWz/xBR24vbNn +HMtCNpvcv31q4h7RDDX6ifrxB5jKHSUFexO/D0UeAbgALjtI8gSdNXJ+QS65ATEy +ww9S9wJJgEGdeMvHJwWq7tCmRBXjQDWCzqwxafX8V97+VWh5Wz9v5eYxM0cP0tvh +3l1Ka/Vu8h/O7VzXZ9quzJlNfkPE5ZBwjp0hbedu9UiNaszcBOp3QPB4QkrHPc4S +b2oKz10XAgMBAAECggEAAfSz1gHv8RExwu2aNnl6z6yJFG0jvczBz4MsVKPGfOxn +oeEG4rrC3cnRTF64Sy4oWNq1mhvkXTFGnUNJobLzywWMFE9V8jy6faaz2Y2Hi+b9 +Cm7aFyo/mUpPzeW6yrPdziJWskUpEuJUJtxM8h0k+j9NGvfHRehxOHZFHjEYeIx/ +a/ZXiAwfm936ITtH6w3p9YIBp6xj5aMN5387YtAOkldoPVBLf4NRqSf/o66bzF9j +WynBaPqh0v6gEFzGvfbxHi5ii2vZwJLpSA0vJ1u7+K/CF65ZsHT5or+XrphbOBDj +P6a6WESO3hVY5dlo0AHcwyBWgv7p91Peqk8KuEW3KQKBgQDwOy1sqR2h6QQsyLVI +We2gYx2NOsUaxqw0ek6PlF3BW29aj6H5q4DAkkGAidH5F7Xe2tinJey7nkgLCaE1 +Oav26UriYzQuSgEF5g7KXTxpo5QwSbPDcG+7QNHBG2g96NVSpVEN80tRSyK6VPYD +bfCR2gL9Ly5me33FrOQkiU4CzQKBgQDdJdwF+X0tgN7f9Pz2Yv+M8iEVnLax++KC +XRQdouwgBbL+PdVhCgqi2Cl10krQ0WdcF4sggmwtrHxBPbcyQDrsU2sNAM/Gq9kU +ceDCFq2uthCpJ+zogaJKXz2rxTRbEGE1JKLT+5xh7ZOz/k3UT0SE92pPS81y6MLv +3ozq4nKHcwKBgHReFhjmqsX9W9pdtwK/HQ5uNKhu6X+Y8V3SSS/fzLKXGg+iN/H7 +E7k0n6omGKIyzBSRqhT9l/kiKP+/wGlJ8HUAeRfEukgZ7PjwggWguFzrsiLZ8Mwh +MN5h/bkvD4W9vWf1UJgTXE6auM3NzgXHQZtFIeGG81ENTNVudG0GXdWZAoGAbxlB ++85m0KFZVnGhU7ZQY+KQNGdScP/1v0A7htf+f+fdEFTICcQdq8mkqohHBbjtkBpT +zrU224s3sR7sFdamw2r08Mdjmo9isx6yp071WjwlCpYAMp5NhcdrGAwuTUFhVG1f +T9errJbKCTbMqshXx+T0B3oxcHT22cKYULgKiXkCgYEAwj7XjQ5G13TyqUfO8Lua +I8HTb5ApnPUO5k0Tuk5U6rhuq3GgFs/i94lVsjq2oRgyWr7r8srOZd2xBITYaLxw +vBF+5GUteWuHwC89cP/+21DIrb9g30ueSjyZqVpNsMhZfAyi0tYVQuqSPUN41sCy +HuXOTPwj8eDqJgg2AHGUjHs= +-----END PRIVATE KEY----- diff --git a/pkg/i18n/en_base_config_descriptions.go b/pkg/i18n/en_base_config_descriptions.go index 09df6ab..1ef4b70 100644 --- a/pkg/i18n/en_base_config_descriptions.go +++ b/pkg/i18n/en_base_config_descriptions.go @@ -90,6 +90,7 @@ var ( ConfigGlobalMaxIdleConns = ffc("config.global.maxIdleConns", "The max number of idle connections to hold pooled", IntType) ConfigGlobalMaxConnsPerHost = ffc("config.global.maxConnsPerHost", "The max number of connections, per unique hostname. Zero means no limit", IntType) ConfigGlobalMaxIdleConnsPerHost = ffc("config.global.maxIdleConnsPerHost", "The max number of idle connections, per unique hostname. Zero means net/http uses the default of only 2.", IntType) + ConfigGlobalDNSServers = ffc("config.global.dnsServers", "An optional list of DNS server addresses (host or host:port, port defaults to 53) to use instead of the system resolver. Setting this forces use of Go's built-in DNS resolver.", ArrayStringType) ConfigGlobalMethod = ffc("config.global.method", "The HTTP method to use when making requests to the Address Resolver", StringType) ConfigGlobalAuthType = ffc("config.global.auth.type", "The auth plugin to use for server side authentication of requests", StringType) ConfigGlobalPassthroughHeadersEnabled = ffc("config.global.passthroughHeadersEnabled", "Enable passing through the set of allowed HTTP request headers", BooleanType) diff --git a/pkg/i18n/en_base_error_messages.go b/pkg/i18n/en_base_error_messages.go index 2cf646f..b251b30 100644 --- a/pkg/i18n/en_base_error_messages.go +++ b/pkg/i18n/en_base_error_messages.go @@ -194,4 +194,6 @@ var ( MsgInvalidLogLevel = ffe("FF00257", "Invalid log level: '%s'", http.StatusBadRequest) MsgFFExtensionsInvalid = ffe("FF00258", "Invalid extension '%s' - extensions should be RFC 3986 compliant query parameter format (e.g. x-name=value with percent-encoding for special characters)", http.StatusBadRequest) MsgFFExtensionsInvalidEncoding = ffe("FF00259", "Invalid extension key '%s' - extension keys must follow the format 'x-'", http.StatusBadRequest) + MsgInvalidCIDR = ffe("FF00260", "Invalid CIDR '%s' in denylist configuration", http.StatusBadRequest) + MsgConnectionToCIDRBlocked = ffe("FF00261", "Connection to '%s' blocked by CIDR denylist (%s)", http.StatusForbidden) ) diff --git a/pkg/i18n/en_base_field_descriptions.go b/pkg/i18n/en_base_field_descriptions.go index 639d9dc..76df5cf 100644 --- a/pkg/i18n/en_base_field_descriptions.go +++ b/pkg/i18n/en_base_field_descriptions.go @@ -87,6 +87,7 @@ var ( RESTConfigAuthPassword = ffm("RESTConfig.authPassword", "Password for the HTTP/HTTPS Basic Auth header") RESTConfigAuthUsername = ffm("RESTConfig.authUsername", "Username for the HTTP/HTTPS Basic Auth header") RESTConfigConnectionTimeout = ffm("RESTConfig.connectionTimeout", "HTTP connection timeout") + RESTConfigDNSServers = ffm("RESTConfig.dnsServers", "An optional list of DNS server addresses (host or host:port, port defaults to 53) to use for name resolution. Setting this forces use of Go's built-in DNS resolver rather than the system resolver") RESTConfigExpectContinueTimeout = ffm("RESTConfig.expectContinueTimeout", "Time to wait for the first response from the server after connecting") RESTConfigExpectHeaders = ffm("RESTConfig.headers", "Headers to add to the HTTP call") RESTConfigHTTPPassthroughHeadersEnabled = ffm("RESTConfig.httpPassthroughHeadersEnabled", "Proxy request ID or other configured headers from an upstream microservice connection") diff --git a/pkg/wsclient/wsclient.go b/pkg/wsclient/wsclient.go index 71c94d8..bf082ce 100644 --- a/pkg/wsclient/wsclient.go +++ b/pkg/wsclient/wsclient.go @@ -1,4 +1,4 @@ -// Copyright © 2024 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -22,6 +22,7 @@ import ( "encoding/base64" "fmt" "io" + "net" "net/http" "net/url" "strings" @@ -57,6 +58,10 @@ type WSConfig struct { HeartbeatInterval time.Duration `json:"heartbeatInterval,omitempty"` TLSClientConfig *tls.Config `json:"tlsClientConfig,omitempty"` ConnectionTimeout time.Duration `json:"connectionTimeout,omitempty"` + // NetDialer carries the custom DNS resolver and SSRF egress guard (CIDR denylist) for the + // underlying TCP connection. Built by GenerateConfig from the net config; cannot be set in + // JSON. Left nil for hand-built configs, in which case the default net dialer is used. + NetDialer *net.Dialer `json:"-"` // This one cannot be set in JSON - must be configured on the code interface ReceiveExt bool } @@ -143,15 +148,22 @@ func New(ctx context.Context, config *WSConfig, beforeConnect WSPreConnectHandle return nil, err } + wsDialer := &websocket.Dialer{ + ReadBufferSize: config.ReadBufferSize, + WriteBufferSize: config.WriteBufferSize, + TLSClientConfig: config.TLSClientConfig, + HandshakeTimeout: config.ConnectionTimeout, + } + // Route the TCP connection through the configured dialer so the custom DNS resolver and + // SSRF egress guard apply (TLS is still layered on top by gorilla via TLSClientConfig). + if config.NetDialer != nil { + wsDialer.NetDialContext = config.NetDialer.DialContext + } + w := &wsClient{ - ctx: ctx, - url: wsURL, - wsdialer: &websocket.Dialer{ - ReadBufferSize: config.ReadBufferSize, - WriteBufferSize: config.WriteBufferSize, - TLSClientConfig: config.TLSClientConfig, - HandshakeTimeout: config.ConnectionTimeout, - }, + ctx: ctx, + url: wsURL, + wsdialer: wsDialer, connRetry: retry.Retry{ InitialDelay: config.InitialDelay, MaximumDelay: config.MaximumDelay, diff --git a/pkg/wsclient/wsconfig.go b/pkg/wsclient/wsconfig.go index bf08795..1d3d0fd 100644 --- a/pkg/wsclient/wsconfig.go +++ b/pkg/wsclient/wsconfig.go @@ -1,4 +1,4 @@ -// Copyright © 2023 Kaleido, Inc. +// Copyright © 2026 Kaleido, Inc. // // SPDX-License-Identifier: Apache-2.0 // @@ -21,6 +21,8 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" ) @@ -109,5 +111,25 @@ func GenerateConfig(ctx context.Context, conf config.Section) (*WSConfig, error) wsConfig.TLSClientConfig = tlsClientConfig + // Build the underlying TCP dialer with the custom DNS resolver and SSRF egress guard, + // from the same "net" subsection that ffresty.InitConfig set up on this config tree. + netCfg, err := ffnet.GenerateConfig(conf.SubSection("net")) + if err != nil { + return nil, err + } + + dnsCfg, err := ffdns.GenerateConfig(conf.SubSection("dns")) + if err != nil { + return nil, err + } + resolver := ffdns.NewResolverWithConfig(dnsCfg) + + netDialer, err := ffnet.NewDialer(ctx, netCfg, resolver) + if err != nil { + return nil, err + } + netDialer.Timeout = wsConfig.ConnectionTimeout + wsConfig.NetDialer = netDialer + return wsConfig, nil } diff --git a/pkg/wsclient/wsconfig_test.go b/pkg/wsclient/wsconfig_test.go index e22d91b..e49986a 100644 --- a/pkg/wsclient/wsconfig_test.go +++ b/pkg/wsclient/wsconfig_test.go @@ -6,9 +6,12 @@ import ( "time" "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/ffdns" + "github.com/hyperledger/firefly-common/pkg/ffnet" "github.com/hyperledger/firefly-common/pkg/ffresty" "github.com/hyperledger/firefly-common/pkg/fftls" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var utConf = config.RootSection("ws") @@ -66,6 +69,41 @@ func TestWSConfigGenerationDefaults(t *testing.T) { assert.Equal(t, 30*time.Second, wsConfig.HeartbeatInterval) } +func TestWSConfigNetDialerDefaults(t *testing.T) { + resetConf() + + ctx := context.Background() + wsConfig, err := GenerateConfig(ctx, utConf) + require.NoError(t, err) + + // No egress denylist or DNS servers configured by default => no guard, system resolver + require.NotNil(t, wsConfig.NetDialer) + assert.Nil(t, wsConfig.NetDialer.Resolver) + assert.Nil(t, wsConfig.NetDialer.Control) + assert.Equal(t, defaultConnectionTimeout, wsConfig.NetDialer.Timeout) +} + +func TestWSConfigNetDialerCustom(t *testing.T) { + resetConf() + ssrfDenylist := []string{ + "0.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "224.0.0.0/4", + "240.0.0.0/4", + } + utConf.SubSection("dns").Set(ffdns.DNSServers, []string{"8.8.8.8"}) + utConf.SubSection("net").Set(ffnet.NetCIDRDenylist, ssrfDenylist) // opt in to the egress guard + + ctx := context.Background() + wsConfig, err := GenerateConfig(ctx, utConf) + require.NoError(t, err) + require.NotNil(t, wsConfig.NetDialer) + assert.NotNil(t, wsConfig.NetDialer.Resolver) // custom DNS servers + require.NotNil(t, wsConfig.NetDialer.Control) // denylist active + assert.Error(t, wsConfig.NetDialer.Control("tcp", "169.254.169.254:80", nil)) +} + func TestWSConfigTLSGenerationFail(t *testing.T) { resetConf()