-
Notifications
You must be signed in to change notification settings - Fork 16
feat(fftls,ffresty,ffdns,ffnet) Networking Metrics and IP/Server Configs #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
EnriqueL8
merged 11 commits into
hyperledger-firefly:main
from
kaleido-io:fftls-cert-expires
Jun 24, 2026
+1,466
−42
Merged
Changes from 5 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
8e2cb97
[fftls] Metrics for CA/Client/Server Certificate Expiry
onelapahead e91681d
make tests with static expected expiries; ensure CA and leaf bundles …
onelapahead 486053d
[ffresty] Custom DNS servers and resolver support
onelapahead 14f2a49
minor fixes
onelapahead a7b7682
ffdns and ffnet packages for instrumented resolver and dialer
onelapahead 314928c
empty default; lots of useful lists to use by default in app config
onelapahead 9ef1a4b
PR feedback; separated dns from net config and simplified tests
onelapahead 60f4015
clean up comments and fix names for config reference
onelapahead edffad1
fix config for ffnet
onelapahead b0364c7
fix copyright
onelapahead f09a7d8
Merge branch 'main' into fftls-cert-expires
onelapahead File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| // DNSServers 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 rather than the system one. | ||
| DNSServers = "dnsServers" | ||
| // DNSTimeout the dial timeout when contacting a configured DNS server | ||
| DNSTimeout = "dnsTimeout" | ||
| ) | ||
|
|
||
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| // 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 that dials the given DNS servers | ||
| // (each host or host:port, port defaulting to 53) in order, failing over to the | ||
| // next on error. Returns nil when no servers are given (use the system resolver). | ||
| // Exported so non-ffresty dialers — e.g. a WebSocket dialer — can honour the same | ||
| // dnsServers 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 { | ||
| if len(cfg.Servers) == 0 { | ||
| return nil // TODO no matter what do we want / need DNS metrics ? | ||
| } | ||
| servers := make([]string, len(cfg.Servers)) | ||
| for i, server := range cfg.Servers { | ||
| servers[i] = withDefaultDNSPort(server) | ||
| } | ||
| return &net.Resolver{ | ||
| PreferGo: true, | ||
| Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { | ||
| d := net.Dialer{Timeout: cfg.Timeout} | ||
| 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; richer rcode-level metrics would require | ||
| // parsing the DNS response off the returned conn. | ||
| for _, server := range servers { | ||
| 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") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| // Copyright © 2025 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 } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.