Skip to content

Commit 7f32ddf

Browse files
authored
Merge pull request #218 from kaleido-io/fftls-cert-expires
feat(fftls,ffresty,ffdns,ffnet) Networking Metrics and IP/Server Configs
2 parents f86c8c9 + f09a7d8 commit 7f32ddf

24 files changed

Lines changed: 1466 additions & 42 deletions

pkg/ffdns/config.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright © 2026 Kaleido, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package ffdns
18+
19+
import (
20+
"time"
21+
22+
"github.com/hyperledger/firefly-common/pkg/config"
23+
)
24+
25+
const (
26+
// Servers an optional list of DNS server addresses (host or host:port, port defaults
27+
// to 53). Setting this forces use of Go's built-in resolver.
28+
DNSServers = "servers"
29+
// Timeout the dial timeout when contacting a configured DNS server
30+
DNSTimeout = "timeout"
31+
)
32+
33+
type Config struct {
34+
Servers []string
35+
Timeout time.Duration
36+
}
37+
38+
func InitConfig(conf config.Section) {
39+
conf.AddKnownKey(DNSServers)
40+
conf.AddKnownKey(DNSTimeout)
41+
}
42+
43+
func GenerateConfig(conf config.Section) (*Config, error) {
44+
return &Config{
45+
Servers: conf.GetStringSlice(DNSServers),
46+
Timeout: conf.GetDuration(DNSTimeout),
47+
}, nil
48+
}

pkg/ffdns/ffdns.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright © 2026 Kaleido, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package ffdns
18+
19+
import (
20+
"context"
21+
"errors"
22+
"net"
23+
24+
"github.com/hyperledger/firefly-common/pkg/config"
25+
"github.com/hyperledger/firefly-common/pkg/metric"
26+
)
27+
28+
const (
29+
metricsDNSRequestsTotal = "dns_requests_total"
30+
metricsDNSResponsesTotal = "dns_responses_total"
31+
metricsDNSErrorsTotal = "dns_errors_total"
32+
)
33+
34+
var metricsManager metric.MetricsManager
35+
36+
func EnableResolverMetrics(ctx context.Context, metricsRegistry metric.MetricsRegistry) {
37+
if metricsManager != nil {
38+
return
39+
}
40+
metricsManager, _ = metricsRegistry.NewMetricsManagerForSubsystem(ctx, "dns")
41+
metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSRequestsTotal, "DNS requests", []string{"server"}, false)
42+
metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSResponsesTotal, "DNS responses", []string{"server", "status"}, false)
43+
metricsManager.NewCounterMetricWithLabels(ctx, metricsDNSErrorsTotal, "DNS errors", []string{"server", "error"}, false)
44+
}
45+
46+
// NewDNSResolver builds a pure-Go *net.Resolver for metrics instructmentation, custom timeouts, and/or custom servers.
47+
// The resolver will dial the given DNS servers (each host or host:port, port defaulting to 53) in order, failing over to the
48+
// next on error. Returns nil if none of the customizations (metrics, timeout, or servers) are enabeld.
49+
// Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same
50+
// DNS config as the HTTP client.
51+
func NewResolver(config config.Section) *net.Resolver {
52+
cfg, err := GenerateConfig(config)
53+
if err != nil {
54+
return nil
55+
}
56+
57+
return NewResolverWithConfig(cfg)
58+
}
59+
60+
func NewResolverWithConfig(cfg *Config) *net.Resolver {
61+
var servers []string
62+
if len(cfg.Servers) > 0 {
63+
servers = make([]string, len(cfg.Servers))
64+
for i, server := range cfg.Servers {
65+
servers[i] = withDefaultDNSPort(server)
66+
}
67+
}
68+
69+
// If we have nothing to layer on top of the system resolver — no configured servers, no
70+
// dial timeout, and metrics disabled — leave it untouched (callers treat nil as "use the
71+
// system resolver"). Returning a resolver here would force Go's built-in resolver
72+
// (PreferGo) in deployments that haven't opted into any of these.
73+
if len(servers) == 0 && cfg.Timeout <= 0 && metricsManager == nil {
74+
return nil
75+
}
76+
77+
return &net.Resolver{
78+
PreferGo: true,
79+
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
80+
d := net.Dialer{Timeout: cfg.Timeout}
81+
// When no servers are explicitly configured, wrap Go's built-in resolver: it has
82+
// already selected a nameserver from the system config (resolv.conf) and passes it
83+
// as address, so we dial that and still apply our timeout and metrics.
84+
dialServers := servers
85+
if len(dialServers) == 0 {
86+
dialServers = []string{address}
87+
}
88+
var err error
89+
// Go's built-in resolver dials a fresh connection per query exchange (escalating
90+
// from UDP to TCP for truncated responses), so each Dial maps to a DNS request. We
91+
// record metrics at this connection level.
92+
for _, server := range dialServers {
93+
recordDNSMetric(ctx, metricsDNSRequestsTotal, map[string]string{"server": server})
94+
var conn net.Conn
95+
if conn, err = d.DialContext(ctx, network, server); err == nil {
96+
recordDNSMetric(ctx, metricsDNSResponsesTotal, map[string]string{"server": server, "status": "success"})
97+
return conn, nil
98+
}
99+
recordDNSMetric(ctx, metricsDNSErrorsTotal, map[string]string{"server": server, "error": classifyDNSError(err)})
100+
}
101+
return nil, err
102+
},
103+
}
104+
}
105+
106+
// recordDNSMetric increments a DNS counter when resolver metrics have been enabled, and is a no-op otherwise.
107+
func recordDNSMetric(ctx context.Context, name string, labels map[string]string) {
108+
if metricsManager == nil {
109+
return
110+
}
111+
metricsManager.IncCounterMetricWithLabels(ctx, name, labels, nil)
112+
}
113+
114+
// classifyDNSError maps a dial error to a low-cardinality label so the dns_errors_total metric doesn't explode.
115+
func classifyDNSError(err error) string {
116+
var netErr net.Error
117+
if errors.As(err, &netErr) && netErr.Timeout() {
118+
return "timeout"
119+
}
120+
return "error"
121+
}
122+
123+
// withDefaultDNSPort ensures a DNS server address has a port, defaulting to 53.
124+
func withDefaultDNSPort(server string) string {
125+
if _, _, err := net.SplitHostPort(server); err == nil {
126+
return server
127+
}
128+
return net.JoinHostPort(server, "53")
129+
}

pkg/ffdns/ffdns_test.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright © 2026 Kaleido, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
17+
package ffdns
18+
19+
import (
20+
"context"
21+
"net"
22+
"strings"
23+
"testing"
24+
"time"
25+
26+
"github.com/hyperledger/firefly-common/pkg/config"
27+
"github.com/hyperledger/firefly-common/pkg/metric"
28+
"github.com/stretchr/testify/assert"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
// counterTotal sums the values of all series of a counter whose metric family name ends with
33+
// the given suffix (the registry prefixes names with component + subsystem).
34+
func counterTotal(t *testing.T, mr metric.MetricsRegistry, nameSuffix string) float64 {
35+
families, err := mr.GetGatherer().Gather()
36+
require.NoError(t, err)
37+
var total float64
38+
for _, mf := range families {
39+
if strings.HasSuffix(mf.GetName(), nameSuffix) {
40+
for _, m := range mf.GetMetric() {
41+
if c := m.GetCounter(); c != nil {
42+
total += c.GetValue()
43+
}
44+
}
45+
}
46+
}
47+
return total
48+
}
49+
50+
var utConf = config.RootSection("dns_unit_tests")
51+
52+
func resetConf() {
53+
config.RootConfigReset()
54+
InitConfig(utConf)
55+
}
56+
57+
func TestWithDefaultDNSPort(t *testing.T) {
58+
assert.Equal(t, "8.8.8.8:53", withDefaultDNSPort("8.8.8.8"))
59+
assert.Equal(t, "8.8.8.8:5353", withDefaultDNSPort("8.8.8.8:5353"))
60+
assert.Equal(t, "[2001:db8::1]:53", withDefaultDNSPort("2001:db8::1"))
61+
assert.Equal(t, "[2001:db8::1]:5353", withDefaultDNSPort("[2001:db8::1]:5353"))
62+
}
63+
64+
func TestNewResolverWithConfig(t *testing.T) {
65+
// No servers -> nil, leaving Go's default system resolver selection in place
66+
assert.Nil(t, NewResolverWithConfig(&Config{}))
67+
68+
// Servers configured -> pure-Go resolver
69+
r := NewResolverWithConfig(&Config{Servers: []string{"8.8.8.8"}})
70+
require.NotNil(t, r)
71+
assert.True(t, r.PreferGo)
72+
assert.NotNil(t, r.Dial)
73+
}
74+
75+
func TestNewResolverFromConfigSection(t *testing.T) {
76+
resetConf()
77+
utConf.Set(DNSServers, []string{"8.8.8.8", "1.1.1.1:53"})
78+
r := NewResolver(utConf)
79+
require.NotNil(t, r)
80+
assert.True(t, r.PreferGo)
81+
82+
resetConf()
83+
assert.Nil(t, NewResolver(utConf))
84+
}
85+
86+
func TestResolverDialFailover(t *testing.T) {
87+
// Stand up a listener acting as the "good" DNS server
88+
ln, err := net.Listen("tcp", "127.0.0.1:0")
89+
require.NoError(t, err)
90+
defer ln.Close()
91+
92+
accepted := make(chan struct{}, 1)
93+
go func() {
94+
conn, acceptErr := ln.Accept()
95+
if acceptErr == nil {
96+
accepted <- struct{}{}
97+
_ = conn.Close()
98+
}
99+
}()
100+
101+
// First server is unroutable so the dialer must fail over to the live listener
102+
r := NewResolverWithConfig(&Config{
103+
Timeout: 5 * time.Second,
104+
Servers: []string{"127.0.0.1:1", ln.Addr().String()},
105+
})
106+
require.NotNil(t, r)
107+
108+
conn, err := r.Dial(context.Background(), "tcp", "ignored:53")
109+
require.NoError(t, err)
110+
defer conn.Close()
111+
assert.Equal(t, ln.Addr().String(), conn.RemoteAddr().String())
112+
113+
select {
114+
case <-accepted:
115+
case <-time.After(5 * time.Second):
116+
t.Fatal("DNS dial did not reach the configured server")
117+
}
118+
}
119+
120+
func TestResolverDialAllFail(t *testing.T) {
121+
r := NewResolverWithConfig(&Config{
122+
Timeout: 250 * time.Millisecond,
123+
Servers: []string{"127.0.0.1:1"},
124+
})
125+
require.NotNil(t, r)
126+
_, err := r.Dial(context.Background(), "tcp", "ignored:53")
127+
assert.Error(t, err)
128+
}
129+
130+
func TestEnableResolverMetrics(t *testing.T) {
131+
metricsManager = nil
132+
defer func() { metricsManager = nil }()
133+
134+
ctx := context.Background()
135+
mr := metric.NewPrometheusMetricsRegistry("test")
136+
EnableResolverMetrics(ctx, mr)
137+
require.NotNil(t, metricsManager)
138+
139+
// Idempotent - a second call is a no-op rather than re-registering
140+
EnableResolverMetrics(ctx, mr)
141+
}
142+
143+
func TestResolverDialRecordsMetrics(t *testing.T) {
144+
metricsManager = nil
145+
defer func() { metricsManager = nil }()
146+
147+
ctx := context.Background()
148+
mr := metric.NewPrometheusMetricsRegistry("test")
149+
EnableResolverMetrics(ctx, mr)
150+
151+
// Live listener acts as the second (good) DNS server; the first is unroutable so a single
152+
// Dial exercises the request, error (failover), and response metric paths together.
153+
ln, err := net.Listen("tcp", "127.0.0.1:0")
154+
require.NoError(t, err)
155+
defer ln.Close()
156+
go func() {
157+
if conn, acceptErr := ln.Accept(); acceptErr == nil {
158+
_ = conn.Close()
159+
}
160+
}()
161+
162+
r := NewResolverWithConfig(&Config{
163+
Timeout: 5 * time.Second,
164+
Servers: []string{"127.0.0.1:1", ln.Addr().String()},
165+
})
166+
require.NotNil(t, r)
167+
conn, err := r.Dial(ctx, "tcp", "ignored:53")
168+
require.NoError(t, err)
169+
defer conn.Close()
170+
171+
assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_requests_total"), float64(2), "one request per server attempted")
172+
assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_responses_total"), float64(1), "one successful response")
173+
assert.GreaterOrEqual(t, counterTotal(t, mr, "dns_errors_total"), float64(1), "first server failed over")
174+
}
175+
176+
func TestResolverDialNoMetricsWhenDisabled(t *testing.T) {
177+
metricsManager = nil // metrics not enabled -> recording is a no-op, no panic
178+
r := NewResolverWithConfig(&Config{
179+
Timeout: 250 * time.Millisecond,
180+
Servers: []string{"127.0.0.1:1"},
181+
})
182+
require.NotNil(t, r)
183+
_, err := r.Dial(context.Background(), "tcp", "ignored:53")
184+
assert.Error(t, err)
185+
}
186+
187+
func TestClassifyDNSError(t *testing.T) {
188+
assert.Equal(t, "error", classifyDNSError(assertAnErr{}))
189+
assert.Equal(t, "timeout", classifyDNSError(timeoutErr{}))
190+
}
191+
192+
type assertAnErr struct{}
193+
194+
func (assertAnErr) Error() string { return "boom" }
195+
196+
type timeoutErr struct{}
197+
198+
func (timeoutErr) Error() string { return "i/o timeout" }
199+
func (timeoutErr) Timeout() bool { return true }
200+
func (timeoutErr) Temporary() bool { return true }

0 commit comments

Comments
 (0)