diff --git a/core/nylon.go b/core/nylon.go index 61553d2..a13b4ba 100644 --- a/core/nylon.go +++ b/core/nylon.go @@ -56,6 +56,8 @@ type Nylon struct { DispatchChannel chan func() error Log *slog.Logger ConfigPath string + configFetcher *configFetcher + configPollDelay atomic.Int64 // resources Tun tun.Device @@ -156,6 +158,8 @@ func NewNylon(ccfg state.CentralCfg, ncfg state.LocalCfg, logLevel slog.Level, c DNSResolver: dnsResolver, EndpointResolver: state.NewEndpointResolver(dnsResolver), } + n.configFetcher = newConfigFetcher(dnsResolver) + n.updateConfigPollDelay(&n.CentralCfg) n.Log.Info("init modules") @@ -245,7 +249,10 @@ func (n *Nylon) Init() error { for _, repo := range n.CentralCfg.Dist.Repos { n.Log.Info("config source", "repo", repo) } - n.RepeatTask(func() error { return checkForConfigUpdates(n) }, n.CentralUpdateDelay) + n.RepeatTaskDynamic( + func() error { return checkForConfigUpdates(n) }, + func() time.Duration { return time.Duration(n.configPollDelay.Load()) }, + ) } return nil } @@ -330,6 +337,9 @@ func (n *Nylon) Cleanup() error { if n.observability != nil { n.observability.close() } + if n.configFetcher != nil { + n.configFetcher.client.CloseIdleConnections() + } n.PingBuf.Stop() for _, health := range n.prefixHealth { health.monitor.Stop() diff --git a/core/nylon_apply.go b/core/nylon_apply.go index 3ed33ed..5a59f85 100644 --- a/core/nylon_apply.go +++ b/core/nylon_apply.go @@ -38,6 +38,7 @@ func (n *Nylon) ApplyCentralConfig(cfg *state.CentralCfg) (ApplyResult, error) { } n.reconcileAdvertisedPrefixes(candidate) n.CentralCfg = *candidate + n.updateConfigPollDelay(candidate) // From here on the candidate is the accepted desired state. Failures while // converging WireGuard or OS state are retryable and must not describe the diff --git a/core/nylon_distribution.go b/core/nylon_distribution.go index 7f2ef4e..ca205a2 100644 --- a/core/nylon_distribution.go +++ b/core/nylon_distribution.go @@ -1,64 +1,361 @@ package core import ( + "context" "errors" "fmt" "io" "net/http" "net/url" "os" - "slices" + "strconv" + "strings" + "sync" + "time" "github.com/encodeous/nylon/state" "github.com/goccy/go-yaml" ) -// fetches and unbundles central config from url +const configFetchTimeout = 30 * time.Second + +type configFetchResult struct { + config *state.CentralCfg + notModified bool +} + +type configCacheKey struct { + repo string + key state.NyPublicKey +} + +type configCacheEntry struct { + mu sync.Mutex + + valid bool + etag string + lastModified string + cacheControl string + pragma string + expires string + date string + age string + vary string + freshUntil time.Time +} + +type configFetcher struct { + client *http.Client + now func() time.Time + + mu sync.Mutex + cache map[configCacheKey]*configCacheEntry +} + +func newConfigFetcher(resolver *state.DNSResolver) *configFetcher { + if resolver == nil { + resolver = state.NewDNSResolver(nil) + } + transport := new(http.Transport) + if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { + transport = defaultTransport.Clone() + } + // Config repository lookups must use Nylon's configured DNS resolver. + // Preserve the previous no-proxy behavior so a proxy cannot bypass it. + transport.Proxy = nil + transport.DialContext = resolver.DialContext + return &configFetcher{ + client: &http.Client{ + Transport: transport, + Timeout: configFetchTimeout, + }, + now: time.Now, + cache: make(map[configCacheKey]*configCacheEntry), + } +} + +func (f *configFetcher) cacheEntry(repo string, key state.NyPublicKey) *configCacheEntry { + f.mu.Lock() + defer f.mu.Unlock() + cacheKey := configCacheKey{repo: repo, key: key} + entry := f.cache[cacheKey] + if entry == nil { + entry = new(configCacheEntry) + f.cache[cacheKey] = entry + } + return entry +} + +// FetchConfig fetches and verifies a bundled central config. func FetchConfig(repoStr string, key state.NyPublicKey, maxSize int64) (*state.CentralCfg, error) { return fetchConfig(repoStr, key, maxSize, state.NewDNSResolver(nil)) } +// fetchConfig is used for one-shot fetches such as initial bootstrap. Runtime +// polling uses the persistent fetcher on Nylon so validators survive each poll. func fetchConfig(repoStr string, key state.NyPublicKey, maxSize int64, resolver *state.DNSResolver) (*state.CentralCfg, error) { + fetcher := newConfigFetcher(resolver) + defer fetcher.client.CloseIdleConnections() + result, err := fetcher.fetch(context.Background(), repoStr, key, maxSize) + if err != nil { + return nil, err + } + if result.notModified || result.config == nil { + return nil, fmt.Errorf("repository %s returned not modified without a cached config", repoStr) + } + return result.config, nil +} + +func (f *configFetcher) fetch(ctx context.Context, repoStr string, key state.NyPublicKey, maxSize int64) (configFetchResult, error) { + if maxSize <= 0 { + return configFetchResult{}, fmt.Errorf("maximum config size must be greater than 0") + } repo, err := url.Parse(repoStr) if err != nil { - return nil, fmt.Errorf("failed to parse repo URL %s: %w", repoStr, err) + return configFetchResult{}, fmt.Errorf("failed to parse repo URL %s: %w", repoStr, err) } - cfgBody := make([]byte, 0) - if repo.Scheme == "file" { - file, err := os.ReadFile(repo.Opaque) - if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", repo.Opaque, err) + switch repo.Scheme { + case "file": + filePath := repo.Opaque + if filePath == "" { + filePath = repo.Path } - cfgBody = file - } else if repo.Scheme == "http" || repo.Scheme == "https" { - client := &http.Client{ - Transport: &http.Transport{ - DialContext: resolver.DialContext, - }, - } - res, err := client.Get(repo.String()) + file, err := os.Open(filePath) if err != nil { - return nil, fmt.Errorf("failed to fetch %s: %w", repo.String(), err) + return configFetchResult{}, fmt.Errorf("failed to read file %s: %w", filePath, err) } - cfgBody, err = io.ReadAll(io.LimitReader(res.Body, maxSize)) + defer file.Close() + body, err := readLimitedConfig(file, maxSize) if err != nil { - res.Body.Close() - return nil, fmt.Errorf("failed to read response from %s: %w", repo.String(), err) + return configFetchResult{}, fmt.Errorf("failed to read file %s: %w", filePath, err) } - err = res.Body.Close() - if err != nil { - return nil, fmt.Errorf("failed to close response from %s: %w", repo.String(), err) + config, err := unbundleFetchedConfig(repoStr, body, key) + return configFetchResult{config: config}, err + case "http", "https": + return f.fetchHTTP(ctx, repo.String(), key, maxSize) + default: + return configFetchResult{}, fmt.Errorf("unsupported config repository scheme %q", repo.Scheme) + } +} + +func (f *configFetcher) fetchHTTP(ctx context.Context, repo string, key state.NyPublicKey, maxSize int64) (configFetchResult, error) { + entry := f.cacheEntry(repo, key) + if !entry.mu.TryLock() { + // A previous poll of this repository is still in flight. It will publish + // any update it finds, so starting another identical request adds no value. + return configFetchResult{notModified: true}, nil + } + defer entry.mu.Unlock() + + now := f.now() + if entry.valid && now.Before(entry.freshUntil) { + return configFetchResult{notModified: true}, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, repo, nil) + if err != nil { + return configFetchResult{}, fmt.Errorf("failed to create request for %s: %w", repo, err) + } + if entry.valid { + if entry.etag != "" { + req.Header.Set("If-None-Match", entry.etag) + } + if entry.lastModified != "" { + req.Header.Set("If-Modified-Since", entry.lastModified) } } + conditional := req.Header.Get("If-None-Match") != "" || req.Header.Get("If-Modified-Since") != "" - config, err := state.UnbundleConfig(string(cfgBody), key) + res, err := f.client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to unbundle config from %s: %w", repoStr, err) + return configFetchResult{}, fmt.Errorf("failed to fetch %s: %w", repo, err) + } + defer res.Body.Close() + + switch res.StatusCode { + case http.StatusNotModified: + if !entry.valid || !conditional { + return configFetchResult{}, fmt.Errorf("repository %s returned 304 without a conditional request", repo) + } + entry.update(res.Header, true, f.now()) + return configFetchResult{notModified: true}, nil + case http.StatusOK: + // Continue below. + default: + _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 4<<10)) + return configFetchResult{}, fmt.Errorf("failed to fetch %s: unexpected HTTP status %s", repo, res.Status) + } + + body, err := readLimitedConfig(res.Body, maxSize) + if err != nil { + return configFetchResult{}, fmt.Errorf("failed to read response from %s: %w", repo, err) + } + config, err := unbundleFetchedConfig(repo, body, key) + if err != nil { + return configFetchResult{}, err + } + + // Only cache validators after the body has passed cryptographic verification. + // Otherwise an invalid response with a stable ETag could become permanently + // hidden behind 304 responses. + entry.update(res.Header, false, f.now()) + return configFetchResult{config: config}, nil +} + +func readLimitedConfig(reader io.Reader, maxSize int64) ([]byte, error) { + limit := maxSize + if maxSize < int64(^uint64(0)>>1) { + limit++ + } + body, err := io.ReadAll(io.LimitReader(reader, limit)) + if err != nil { + return nil, err + } + if int64(len(body)) > maxSize { + return nil, fmt.Errorf("config exceeds maximum size of %d bytes", maxSize) + } + return body, nil +} + +func unbundleFetchedConfig(repo string, body []byte, key state.NyPublicKey) (*state.CentralCfg, error) { + config, err := state.UnbundleConfig(string(body), key) + if err != nil { + return nil, fmt.Errorf("failed to unbundle config from %s: %w", repo, err) } return config, nil } +func (entry *configCacheEntry) update(header http.Header, notModified bool, now time.Time) { + if !notModified { + entry.etag = header.Get("ETag") + entry.lastModified = header.Get("Last-Modified") + entry.cacheControl = header.Get("Cache-Control") + entry.pragma = header.Get("Pragma") + entry.expires = header.Get("Expires") + entry.date = header.Get("Date") + entry.age = header.Get("Age") + entry.vary = header.Get("Vary") + } else { + updateCacheField(header, "ETag", &entry.etag) + updateCacheField(header, "Last-Modified", &entry.lastModified) + updateCacheField(header, "Cache-Control", &entry.cacheControl) + updateCacheField(header, "Pragma", &entry.pragma) + updateCacheField(header, "Expires", &entry.expires) + updateCacheField(header, "Date", &entry.date) + updateCacheField(header, "Age", &entry.age) + updateCacheField(header, "Vary", &entry.vary) + } + + cacheable, freshUntil := configFreshness(entry, now) + if !cacheable { + entry.valid = false + entry.etag = "" + entry.lastModified = "" + entry.cacheControl = "" + entry.pragma = "" + entry.expires = "" + entry.date = "" + entry.age = "" + entry.vary = "" + entry.freshUntil = time.Time{} + return + } + entry.valid = true + entry.freshUntil = freshUntil +} + +func updateCacheField(header http.Header, name string, destination *string) { + if _, ok := header[http.CanonicalHeaderKey(name)]; ok { + *destination = header.Get(name) + } +} + +func configFreshness(entry *configCacheEntry, now time.Time) (bool, time.Time) { + directives := parseCacheControl(entry.cacheControl) + if _, ok := directives["no-store"]; ok || strings.TrimSpace(entry.vary) == "*" { + return false, time.Time{} + } + if _, ok := directives["no-cache"]; ok || strings.EqualFold(strings.TrimSpace(entry.pragma), "no-cache") { + return true, time.Time{} + } + + currentAge := responseCurrentAge(entry.date, entry.age, now) + if rawMaxAge, ok := directives["max-age"]; ok { + seconds, err := strconv.ParseInt(strings.Trim(rawMaxAge, `"`), 10, 64) + if err == nil && seconds >= 0 { + return true, freshnessDeadline(now, secondsDuration(seconds)-currentAge) + } + } + + expires, expiresErr := http.ParseTime(entry.expires) + if expiresErr == nil { + date, dateErr := http.ParseTime(entry.date) + if dateErr != nil { + date = now + } + return true, freshnessDeadline(now, expires.Sub(date)-currentAge) + } + return true, time.Time{} +} + +func parseCacheControl(value string) map[string]string { + directives := make(map[string]string) + for part := range strings.SplitSeq(value, ",") { + name, rawValue, found := strings.Cut(strings.TrimSpace(part), "=") + name = strings.ToLower(name) + if name == "" { + continue + } + if found { + directives[name] = strings.TrimSpace(rawValue) + } else { + directives[name] = "" + } + } + return directives +} + +func responseCurrentAge(dateValue, ageValue string, now time.Time) time.Duration { + var apparentAge time.Duration + if date, err := http.ParseTime(dateValue); err == nil && now.After(date) { + apparentAge = now.Sub(date) + } + if seconds, err := strconv.ParseInt(strings.TrimSpace(ageValue), 10, 64); err == nil && seconds >= 0 { + age := secondsDuration(seconds) + if age > apparentAge { + return age + } + } + return apparentAge +} + +func secondsDuration(seconds int64) time.Duration { + maxSeconds := int64(^uint64(0)>>1) / int64(time.Second) + if seconds > maxSeconds { + seconds = maxSeconds + } + return time.Duration(seconds) * time.Second +} + +func freshnessDeadline(now time.Time, remaining time.Duration) time.Time { + if remaining <= 0 { + return time.Time{} + } + return now.Add(remaining) +} + +func (n *Nylon) updateConfigPollDelay(cfg *state.CentralCfg) { + delay := n.CentralUpdateDelay + if cfg != nil && cfg.Dist != nil && cfg.Dist.PollInterval != nil { + delay = *cfg.Dist.PollInterval + } + if n.LocalCfg.Dist != nil && n.LocalCfg.Dist.PollInterval != nil { + delay = *n.LocalCfg.Dist.PollInterval + } + n.configPollDelay.Store(int64(delay)) +} + // responsible for central config distribution func checkForConfigUpdates(n *Nylon) error { if n.CentralCfg.Dist == nil { @@ -66,14 +363,28 @@ func checkForConfigUpdates(n *Nylon) error { } key := n.CentralCfg.Dist.Key currentTimestamp := n.Timestamp - repos := slices.Clone(n.CentralCfg.Dist.Repos) + repos := append([]string(nil), n.CentralCfg.Dist.Repos...) + if n.configFetcher == nil { + n.configFetcher = newConfigFetcher(n.DNSResolver) + } + ctx := n.Context + if ctx == nil { + ctx = context.Background() + } for _, repoStr := range repos { go func(repo string) { err := func() error { - config, err := fetchConfig(repo, key, n.MaxConfigSize, n.DNSResolver) + result, err := n.configFetcher.fetch(ctx, repo, key, n.MaxConfigSize) if err != nil { return err } + if result.notModified { + if n.DBG_log_repo_updates { + n.Log.Debug("config repository has not changed", "repo", repo) + } + return nil + } + config := result.config if config.Timestamp <= currentTimestamp { if n.DBG_log_repo_updates { n.Log.Debug(fmt.Sprintf("found old update bundle at %s, skipping", repo)) diff --git a/core/nylon_distribution_test.go b/core/nylon_distribution_test.go new file mode 100644 index 0000000..7b44175 --- /dev/null +++ b/core/nylon_distribution_test.go @@ -0,0 +1,241 @@ +package core + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testConfigURL = "https://config.example/config.nybundle" + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func testConfigFetcher(handler http.Handler) *configFetcher { + fetcher := newConfigFetcher(state.NewDNSResolver(nil)) + fetcher.client.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder.Result(), nil + }) + return fetcher +} + +func testConfigBundle(t *testing.T) (string, state.NyPrivateKey) { + t.Helper() + key := state.GenerateKey() + cfg := state.CentralCfg{ + Routers: []state.RouterCfg{{ + NodeCfg: state.NodeCfg{Id: "node"}, + }}, + Graph: []string{"node, node"}, + } + data, err := yaml.Marshal(cfg) + require.NoError(t, err) + bundle, err := state.BundleConfig(string(data), key) + require.NoError(t, err) + return bundle, key +} + +func TestConfigFetcherUsesETagAndHandlesNotModified(t *testing.T) { + bundle, key := testConfigBundle(t) + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", `"config-v1"`) + if r.Header.Get("If-None-Match") == `"config-v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + assert.Empty(t, r.Header.Get("If-None-Match")) + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + first, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + require.NotNil(t, first.config) + assert.False(t, first.notModified) + + second, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + assert.Nil(t, second.config) + assert.True(t, second.notModified) + assert.Equal(t, int32(2), requests.Load()) +} + +func TestConfigFetcherUsesLastModified(t *testing.T) { + bundle, key := testConfigBundle(t) + lastModified := time.Now().Add(-time.Hour).UTC().Format(http.TimeFormat) + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Last-Modified", lastModified) + if r.Header.Get("If-Modified-Since") == lastModified { + w.WriteHeader(http.StatusNotModified) + return + } + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + result, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + assert.True(t, result.notModified) + assert.Equal(t, int32(2), requests.Load()) +} + +func TestConfigFetcherHonorsFreshnessLifetime(t *testing.T) { + bundle, key := testConfigBundle(t) + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + w.Header().Set("Cache-Control", "max-age=60") + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + result, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + assert.True(t, result.notModified) + assert.Equal(t, int32(1), requests.Load(), "a fresh cached response should avoid a network request") +} + +func TestConfigFreshnessHonorsExpiresAndAge(t *testing.T) { + now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) + entry := &configCacheEntry{ + date: now.Add(-10 * time.Second).Format(http.TimeFormat), + expires: now.Add(50 * time.Second).Format(http.TimeFormat), + age: "20", + } + + cacheable, freshUntil := configFreshness(entry, now) + assert.True(t, cacheable) + assert.Equal(t, now.Add(40*time.Second), freshUntil) +} + +func TestConfigFetcherHonorsNoStore(t *testing.T) { + bundle, key := testConfigBundle(t) + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + assert.Empty(t, r.Header.Get("If-None-Match")) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", `"config-v1"`) + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + result, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + assert.NotNil(t, result.config) + assert.Equal(t, int32(2), requests.Load()) +} + +func TestConfigFetcherRejectsNonSuccessfulStatus(t *testing.T) { + bundle, key := testConfigBundle(t) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + assert.ErrorContains(t, err, "unexpected HTTP status 500 Internal Server Error") +} + +func TestConfigFetcherDoesNotCacheInvalidBundleValidator(t *testing.T) { + bundle, key := testConfigBundle(t) + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := requests.Add(1) + assert.Empty(t, r.Header.Get("If-None-Match")) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", `"config-v1"`) + if request == 1 { + _, _ = fmt.Fprint(w, "not a valid bundle") + return + } + _, _ = fmt.Fprint(w, bundle) + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.Error(t, err) + result, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 1<<20) + require.NoError(t, err) + assert.NotNil(t, result.config) + assert.Equal(t, int32(2), requests.Load()) +} + +func TestConfigFetcherRejectsOversizedResponse(t *testing.T) { + _, key := testConfigBundle(t) + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, "12345") + }) + + fetcher := testConfigFetcher(handler) + _, err := fetcher.fetch(context.Background(), testConfigURL, key.Pubkey(), 4) + assert.ErrorContains(t, err, "config exceeds maximum size of 4 bytes") +} + +func TestConfigFetcherRejectsOversizedFile(t *testing.T) { + _, key := testConfigBundle(t) + configPath := filepath.Join(t.TempDir(), "config.nybundle") + require.NoError(t, os.WriteFile(configPath, []byte("12345"), 0600)) + + fetcher := newConfigFetcher(state.NewDNSResolver(nil)) + _, err := fetcher.fetch(context.Background(), "file:"+configPath, key.Pubkey(), 4) + assert.ErrorContains(t, err, "config exceeds maximum size of 4 bytes") +} + +func TestUpdateConfigPollDelay(t *testing.T) { + tunables := state.DefaultRouterTunables() + n := &Nylon{RouterTunables: tunables} + n.updateConfigPollDelay(nil) + assert.Equal(t, 10*time.Second, time.Duration(n.configPollDelay.Load())) + + interval := 45 * time.Second + n.updateConfigPollDelay(&state.CentralCfg{Dist: &state.DistributionCfg{PollInterval: &interval}}) + assert.Equal(t, interval, time.Duration(n.configPollDelay.Load())) +} + +func TestNodePollIntervalOverridesCentralConfig(t *testing.T) { + tunables := state.DefaultRouterTunables() + centralInterval := 45 * time.Second + nodeInterval := 2 * time.Minute + n := &Nylon{ + RouterTunables: tunables, + ConfigState: state.ConfigState{LocalCfg: state.LocalCfg{ + Dist: &state.LocalDistributionCfg{PollInterval: &nodeInterval}, + }}, + } + + n.updateConfigPollDelay(&state.CentralCfg{ + Dist: &state.DistributionCfg{PollInterval: ¢ralInterval}, + }) + + assert.Equal(t, nodeInterval, time.Duration(n.configPollDelay.Load())) +} diff --git a/core/nylon_scheduler.go b/core/nylon_scheduler.go index 944c252..986627f 100644 --- a/core/nylon_scheduler.go +++ b/core/nylon_scheduler.go @@ -8,6 +8,8 @@ import ( "time" ) +const dynamicTaskFallbackDelay = time.Second + func NewDispatchFuture[T any](n *Nylon, fun func() (T, error)) Future[T] { future, complete := NewFuture[T]() dispatch := func() error { @@ -69,3 +71,33 @@ func (n *Nylon) repeatedTask(fun func() error, delay time.Duration) { func (n *Nylon) RepeatTask(fun func() error, delay time.Duration) { go n.repeatedTask(fun, delay) } + +func (n *Nylon) repeatedTaskDynamic(fun func() error, delay func() time.Duration) { + // run immediately + n.Dispatch(fun) + for n.Context.Err() == nil { + timer := time.NewTimer(safeDynamicTaskDelay(delay())) + select { + case <-n.Context.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + n.Dispatch(fun) + } + } +} + +func safeDynamicTaskDelay(delay time.Duration) time.Duration { + if delay <= 0 { + return dynamicTaskFallbackDelay + } + return delay +} + +// RepeatTaskDynamic repeats fun using the latest delay returned after each run. +// It is useful for schedules controlled by live-reloadable configuration. +func (n *Nylon) RepeatTaskDynamic(fun func() error, delay func() time.Duration) { + go n.repeatedTaskDynamic(fun, delay) +} diff --git a/core/nylon_scheduler_test.go b/core/nylon_scheduler_test.go index 7ec40f3..a613e68 100644 --- a/core/nylon_scheduler_test.go +++ b/core/nylon_scheduler_test.go @@ -122,3 +122,16 @@ loop: t.Fatalf("Expected 3 executions, got %d", count) } } + +func TestSafeDynamicTaskDelay(t *testing.T) { + for _, delay := range []time.Duration{0, -time.Second} { + if got := safeDynamicTaskDelay(delay); got != dynamicTaskFallbackDelay { + t.Fatalf("safeDynamicTaskDelay(%s) = %s, want %s", delay, got, dynamicTaskFallbackDelay) + } + } + + delay := 50 * time.Millisecond + if got := safeDynamicTaskDelay(delay); got != delay { + t.Fatalf("safeDynamicTaskDelay(%s) = %s", delay, got) + } +} diff --git a/docs/guides/config-distribution.mdx b/docs/guides/config-distribution.mdx index 284a0bf..f91655e 100644 --- a/docs/guides/config-distribution.mdx +++ b/docs/guides/config-distribution.mdx @@ -57,6 +57,7 @@ Despite being called a "public" key, the distribution key also acts as the share dist: url: "https://your-server.com/central.nybundle" key: "" + poll_interval: 2m # optional per-node override ``` #### Automatic Updates (`central.yaml`) @@ -65,10 +66,16 @@ Despite being called a "public" key, the distribution key also acts as the share ```yaml title="central.yaml" dist: key: "" + poll_interval: 30s # optional; defaults to 10s repos: - "https://your-server.com/central.nybundle" ``` - Nylon polls for updates every 10 seconds and applies them. + Nylon polls for updates every 10 seconds by default and applies them. Set + `poll_interval` to any positive duration such as `30s` or `2m` to change + that cadence. A node can override the central interval by setting + `dist.poll_interval` in its `node.yaml`. For HTTP repositories, Nylon reuses + connections, honors freshness headers, and uses `ETag` or `Last-Modified` + validators to avoid downloading an unchanged bundle. - \ No newline at end of file + diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx index afc1e4b..d308cb0 100644 --- a/docs/reference/config.mdx +++ b/docs/reference/config.mdx @@ -28,6 +28,7 @@ observability_addr: "" # e.g. "0.0.0.0:9090"; enables /metrics, /healthz, /ready dist: url: https://static.example.com/network1.nybundle key: 7PaN6DmAayz4KnDnsXSXJH+Oy0TFGeoM4FEbQfLriVY= # distribution public key + poll_interval: 2m # optional: override central.yaml's polling interval for this node # Split tunneling (per-node overrides) exclude_ips: # add to the central exclude list @@ -54,7 +55,9 @@ dist: # The distribution key is used to verify and decrypt sealed bundles. # Despite being called "public", treat it as a shared secret. It can decrypt your topology. key: 7PaN6DmAayz4KnDnsXSXJH+Oy0TFGeoM4FEbQfLriVY= - # Nylon polls these URLs every 10 seconds for config updates + # How often Nylon polls for config updates (optional, default: 10s) + poll_interval: 30s + # HTTP sources use cache freshness and conditional requests when supported repos: - file:central.nybundle # local file - https://static.example.com/network1.nybundle # remote URL (HTTP GET) diff --git a/example/sample-central.yaml b/example/sample-central.yaml index 09d2cfe..e832a37 100644 --- a/example/sample-central.yaml +++ b/example/sample-central.yaml @@ -2,6 +2,8 @@ dist: # This is the central distribution public key. Although it is a "public" key, it is also used as a shared secret within the network, so that no outsiders can decrypt the distributed configuration. key: 7PaN6DmAayz4KnDnsXSXJH+Oy0TFGeoM4FEbQfLriVY= # Nylon will frequently check these repos/files for updates + # How often Nylon checks for updates. Defaults to 10s. + poll_interval: 10s repos: - file:central.nybundle # use local file system - https://static.example.com/network1.nybundle # GET from a server @@ -57,4 +59,4 @@ graph: # The graph determines which nodes will attempt/can peer with each other. - Group1, Group1 # You can emulate that behaviour by connecting a group to its self - Group2 = Group1, client1 # You can use groups within groups - client1, eve, alice # Here, client1, eve and alice will all connect to each other -timestamp: 1740832962209309000 # The timestamp is updated by "nylon seal" and is used as a version number when checking for config updates. \ No newline at end of file +timestamp: 1740832962209309000 # The timestamp is updated by "nylon seal" and is used as a version number when checking for config updates. diff --git a/example/sample-node.yaml b/example/sample-node.yaml index 473f82e..19bdb42 100644 --- a/example/sample-node.yaml +++ b/example/sample-node.yaml @@ -13,6 +13,7 @@ observability_addr: "" # e.g. "0.0.0.0:9090" - enables /metrics, /healthz, /read dist: # Optional: If set, Nylon will bootstrap central.yaml from this URL if it does not exist already url: https://static.example.com/network1.nybundle key: 7PaN6DmAayz4KnDnsXSXJH+Oy0TFGeoM4FEbQfLriVY= + # poll_interval: 30s # Optional: Override central.yaml's polling interval for this node unexclude_ips: [] # split tunnel, subtracts from centrally excluded ip ranges exclude_ips: # split tunnel, adds to the centrally excluded ip ranges - 192.168.0.0/24 # e.g here, we exclude the local ip range diff --git a/state/config.go b/state/config.go index a185378..8316c8c 100644 --- a/state/config.go +++ b/state/config.go @@ -6,6 +6,7 @@ import ( "net/netip" "slices" "strings" + "time" "github.com/goccy/go-yaml" "go4.org/netipx" @@ -28,13 +29,15 @@ type ClientCfg struct { } type DistributionCfg struct { - Key NyPublicKey // also used as shared secret, so, although its "public", it's not a good idea to share it. - Repos []string + Key NyPublicKey // also used as shared secret, so, although its "public", it's not a good idea to share it. + Repos []string + PollInterval *time.Duration `yaml:"poll_interval,omitempty"` } type LocalDistributionCfg struct { - Key NyPublicKey - Url string + Key NyPublicKey + Url string + PollInterval *time.Duration `yaml:"poll_interval,omitempty"` } type CentralCfg struct { @@ -49,23 +52,23 @@ type CentralCfg struct { // LocalCfg represents local node-level configuration type LocalCfg struct { // Node Private Key - Key NyPrivateKey - Id NodeId // unique id for this node - Port uint16 // Address that the data plane can be accessed by - Dist *LocalDistributionCfg `yaml:",omitempty"` // distribution configuration - UseSystemRouting bool `yaml:"use_system_routing,omitempty"` // all packets from peers will come out of the TUN interface - NoTun bool `yaml:"no_tun,omitempty"` // relay-only mode; requires no advertised addresses or prefixes - NoNetConfigure bool `yaml:"no_net_configure,omitempty"` // do not configure system networking at all - DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // DNS resolvers used for endpoints and config repositories - InterfaceName string `yaml:"interface_name,omitempty"` // the name of the nylon interface - LogPath string `yaml:"log_path,omitempty"` // if not empty, nylon will write to this file + Key NyPrivateKey + Id NodeId // unique id for this node + Port uint16 // Address that the data plane can be accessed by + Dist *LocalDistributionCfg `yaml:",omitempty"` // distribution configuration + UseSystemRouting bool `yaml:"use_system_routing,omitempty"` // all packets from peers will come out of the TUN interface + NoTun bool `yaml:"no_tun,omitempty"` // relay-only mode; requires no advertised addresses or prefixes + NoNetConfigure bool `yaml:"no_net_configure,omitempty"` // do not configure system networking at all + DnsResolvers []string `yaml:"dns_resolvers,omitempty"` // DNS resolvers used for endpoints and config repositories + InterfaceName string `yaml:"interface_name,omitempty"` // the name of the nylon interface + LogPath string `yaml:"log_path,omitempty"` // if not empty, nylon will write to this file ObservabilityAddr string `yaml:"observability_addr,omitempty"` // HTTP address for metrics, health, readiness, and service discovery - UnexcludeIPs []netip.Prefix `yaml:"unexclude_ips,omitempty"` // split tunnel, subtracts from centrally excluded ip ranges - ExcludeIPs []netip.Prefix `yaml:"exclude_ips,omitempty"` // split tunnel, adds to the centrally excluded ip ranges - PreUp []string `yaml:"pre_up,omitempty"` // a list of commands executed in order before the nylon interface is brought up - PreDown []string `yaml:"pre_down,omitempty"` // a list of commands executed in order before the nylon interface is brought down - PostUp []string `yaml:"post_up,omitempty"` // a list of commands executed in order after the nylon interface is brought up - PostDown []string `yaml:"post_down,omitempty"` // a list of commands executed in order after the nylon interface is brought down + UnexcludeIPs []netip.Prefix `yaml:"unexclude_ips,omitempty"` // split tunnel, subtracts from centrally excluded ip ranges + ExcludeIPs []netip.Prefix `yaml:"exclude_ips,omitempty"` // split tunnel, adds to the centrally excluded ip ranges + PreUp []string `yaml:"pre_up,omitempty"` // a list of commands executed in order before the nylon interface is brought up + PreDown []string `yaml:"pre_down,omitempty"` // a list of commands executed in order before the nylon interface is brought down + PostUp []string `yaml:"post_up,omitempty"` // a list of commands executed in order after the nylon interface is brought up + PostDown []string `yaml:"post_down,omitempty"` // a list of commands executed in order after the nylon interface is brought down } func (c *CentralCfg) Clone() (error, *CentralCfg) { diff --git a/state/config_distribution_test.go b/state/config_distribution_test.go new file mode 100644 index 0000000..3c038f4 --- /dev/null +++ b/state/config_distribution_test.go @@ -0,0 +1,58 @@ +package state + +import ( + "testing" + "time" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDistributionPollIntervalYAML(t *testing.T) { + var cfg CentralCfg + err := yaml.Unmarshal([]byte(` +dist: + repos: [https://example.com/config.nybundle] + poll_interval: 45s +`), &cfg) + require.NoError(t, err) + require.NotNil(t, cfg.Dist) + require.NotNil(t, cfg.Dist.PollInterval) + assert.Equal(t, 45*time.Second, *cfg.Dist.PollInterval) +} + +func TestDistributionPollIntervalMustBePositive(t *testing.T) { + interval := time.Duration(0) + cfg := CentralCfg{Dist: &DistributionCfg{PollInterval: &interval}} + err := CentralConfigValidator(&cfg) + assert.ErrorContains(t, err, "poll_interval must be greater than 0") +} + +func TestLocalDistributionPollIntervalYAML(t *testing.T) { + var cfg LocalCfg + err := yaml.Unmarshal([]byte(` +dist: + url: https://example.com/config.nybundle + poll_interval: 2m +`), &cfg) + require.NoError(t, err) + require.NotNil(t, cfg.Dist) + require.NotNil(t, cfg.Dist.PollInterval) + assert.Equal(t, 2*time.Minute, *cfg.Dist.PollInterval) +} + +func TestLocalDistributionPollIntervalMustBePositive(t *testing.T) { + interval := time.Duration(0) + cfg := LocalCfg{ + Key: GenerateKey(), + Id: "node", + Port: 1, + Dist: &LocalDistributionCfg{ + Url: "https://example.com/config.nybundle", + PollInterval: &interval, + }, + } + err := NodeConfigValidator(nil, &cfg) + assert.ErrorContains(t, err, "poll_interval must be greater than 0") +} diff --git a/state/validation.go b/state/validation.go index 79e5bbb..c9abb2e 100644 --- a/state/validation.go +++ b/state/validation.go @@ -51,6 +51,9 @@ func NodeConfigValidator(central *CentralCfg, node *LocalCfg) error { if err != nil { return err } + if node.Dist.PollInterval != nil && *node.Dist.PollInterval <= 0 { + return fmt.Errorf("distribution poll_interval must be greater than 0") + } } if len(node.DnsResolvers) != 0 { for _, resolver := range node.DnsResolvers { @@ -144,6 +147,9 @@ func CentralConfigValidator(cfg *CentralCfg) error { } if cfg.Dist != nil { + if cfg.Dist.PollInterval != nil && *cfg.Dist.PollInterval <= 0 { + return fmt.Errorf("distribution poll_interval must be greater than 0") + } // validate repos for _, repo := range cfg.Dist.Repos { _, err := url.Parse(repo)