Merge pull request #4215 from openziti/router-link-runtime-config

Apply managed link heartbeat and queue settings at runtime
This commit is contained in:
Paul Lorenz
2026-09-28 17:46:38 -04:00
committed by GitHub
31 changed files with 2510 additions and 334 deletions
+111 -10
View File
@@ -8,9 +8,8 @@
* [Cluster Quorum Recovery](#cluster_quorum_recovery) - A mechanism for recovering clusters that have irrevocably lost the ability to form a quorum
* [Quickstart Cluster](#quickstart-cluster) - `ziti run quickstart cluster` brings up a multi-node HA cluster in a single command for testing and development and learning
* [Fully Connected Controller Mesh](#fully-connected-controller-mesh) - Controllers now proactively keep the cluster mesh fully connected
* [Config Type Target Field](#config-type-target-field) - Config types now have a target field indicating whether they apply to services, routers or other entities
* [Wildcard OIDC Issuers](#wildcard-oidc-issuers) - Controllers with a wildcard server-certificate SAN can serve OIDC for explicitly allow-listed hostnames
* [Router Configs](#router-configs) - Allow routers to have a list of associated configs
* [Controller Managed Router Configuration](#controller-managed-router-configuration) - (beta) Routers can take configuration from the controller and apply it at runtime, starting with link listeners, dialers and heartbeats, with automatic collection of links the new config leaves stale
* [Multiple LAN Interfaces for tproxy](#multiple-lan-interfaces-for-tproxy) - `lanIf` now accepts a single interface or a list of interfaces
* [Multiple Resolver Addresses for tproxy](#multiple-resolver-addresses-for-tproxy) - `resolver` now accepts a single address or a list of addresses
* [DNS Upstream Query Modes](#dns-upstream-query-modes) - choose how multiple DNS upstreams are queried: parallel fan-out (default) or serial fail-through
@@ -263,10 +262,8 @@ Config types now have an optional `target` field that indicates what kind of ent
is intended for. Valid values are `"service"`, `"router"`, and `"other"`. The field is set on creation
and is immutable afterward.
This is the first step toward controller-managed router configuration. The `target` field lets us
distinguish between config types meant for services, config types meant for routers, and config types
meant for other purposes, which keeps UIs, APIs, and validation clean. See
`doc/design/ctrl-managed-router-config.md` for the full design.
The `target` field distinguishes config types meant for services, config types meant for routers,
and config types meant for other purposes, which keeps UIs, APIs, and validation clean.
A database migration sets `target = "service"` on all existing config types. Services and identity
service config overrides now require that referenced configs have a config type with
@@ -280,8 +277,7 @@ The CLI has been updated to support the new field:
### Router Configs
Routers (edge, transit, and fabric) now have a `configs` field that holds a list of config IDs the
router should use. This is the second step toward controller-managed router configuration: routers
can now be associated with configs in the same way services already can.
router should use, associating routers with configs in the same way services already are.
Validation rules:
@@ -344,9 +340,45 @@ rolled back raises an alert to the controller rather than leaving the router in
`router.link.v1` is the first controller-managed config type, covering a router's link listeners and
dialers. It is built in, so no config type has to be created before use.
A config looks like this:
```json
{
"listeners": [
{
"binding": "transport",
"bind": "tls:0.0.0.0:6004",
"advertise": "tls:router-east.example.com:6004",
"groups": ["east"]
}
],
"dialers": [
{ "binding": "transport", "groups": ["east", "west"] }
],
"heartbeats": { "sendInterval": "10s", "checkInterval": "1s", "closeUnresponsiveTimeout": "60s" },
"gcMode": "orphaned"
}
```
To put one on a router, create the config, attach it, and allow the type in the router's own config
file (see above):
```
ziti edge create config east-links router.link.v1 "$(cat east-links.json)"
ziti edge update edge-router router-east --config east-links
```
Applying one reconciles the router's link surface in place: listeners whose definitions changed are
rebuilt, listeners that are unchanged are left running, and dialers are replaced. A change confined
to fields that do not describe a listener or dialer does not disturb either.
to fields that do not describe a listener or dialer does not disturb either. Every successful apply
is logged at info level as `applied link configuration`, with the generation and what changed, so
you can confirm a router received a config even when the change asked nothing of its links.
For links, local config means the router's config file has a `link` section at all. Anything in
it, even only heartbeat or queue settings, takes precedence over the controller's `router.link`
config for the whole type. A `link` section with no listeners or dialers therefore leaves the
router with no links, and the router warns at startup when that happens. To hand a router's links to
the controller, remove its `link` section.
Removing the config from a router removes its link surface entirely.
@@ -381,6 +413,43 @@ A link is only closed when both of its endpoints agree it is stale and both judg
incarnation of it. An endpoint that is offline, too old to answer, or outside `--filter` leaves the
link reported as partial and never closes it.
### Link Heartbeat and Queue Settings
`router.link.v1` also carries the link heartbeat timing (`sendInterval`, `checkInterval`,
`closeUnresponsiveTimeout`) and the `payloadSenderQueueSize` / `ackSenderQueueSize` queue sizes.
Heartbeat timing is applied to links that are already established, retuning them in place, rather
than only reaching links formed after the change. It is applied as a single generation, so a link
never pairs one version's interval with another version's timeout.
**Queue sizes apply to new links only.** `payloadSenderQueueSize` and `ackSenderQueueSize` are read
when a link is built, so changing them has no effect on links that are already established. Those
keep the queues they were built with until they are re-established for some other reason. A change
confined to queue sizes does not trigger stale-link collection, since nothing about an existing link
has become stale.
The timing is validated on both the controller and the router, and some combinations that were
accepted before are now rejected. `closeUnresponsiveTimeout` must be greater than `sendInterval +
checkInterval`, because a responsive link's own heartbeat gap already reaches that sum, and it must
be at least 10s.
**A `closeUnresponsiveTimeout` under 30s now takes effect.** The close check previously sat behind a
fixed 30s threshold, so any shorter timeout silently behaved as 30s. A router configured with a
short timeout will start closing unresponsive links sooner than it did before.
The 10s minimum is there because a healthy connection can legitimately go quiet for several seconds
while TCP recovers from ordinary packet loss, and a timeout inside that window turns routine loss
into a closed link and the cost of re-establishing it. A controller config below the minimum is
rejected. A value below it in a router's own YAML is raised to the minimum with a warning instead,
so a router that started yesterday still starts today.
**Link `sendInterval` and `checkInterval` in a router's own YAML now take effect.** Links
previously ran a fixed 10s send and 1s check interval whatever the config said, reading only
`closeUnresponsiveTimeout`. Because those values were never in force, local timing that fails the
validation above is not allowed to stop a router from starting: the router logs a warning and uses
the defaults instead. That includes an explicit `0s`, which was previously treated as if the field
were absent.
## Wildcard OIDC Issuers
Controllers can serve OIDC for hostnames covered by a wildcard server-certificate SAN. Prior to 2.1,
@@ -634,6 +703,15 @@ in a future release. Plan to migrate off them.
sessions, is deprecated and will be removed in OpenZiti 3.0, along with the
supporting infrastructure built around it.
* **Split links** - The split-channel link implementation (separate payload and
ack connections) is a fallback used only when dialing a router that doesn't
support multi-underlay links. The multi-underlay link implementation is a
superset of what split links provide, so nothing is lost by moving off them.
The deprecation proceeds in two steps: in OpenZiti 3.0 the `split` option will
be interpreted as a multi-underlay link with one ack and one payload channel,
retiring the separate split implementation; in OpenZiti 4.0 the `split`
configuration option will be removed entirely.
## Removed Features
* **Link `costTags`** - The `costTags` option on router link listeners has been
@@ -676,7 +754,7 @@ Thanks to the community members who contributed to this release.
* [Issue #241](https://github.com/openziti/channel/issues/241) - Allow calling LoadOptions on an Options instance
* [Issue #238](https://github.com/openziti/channel/issues/238) - Channelv5
* github.com/openziti/edge-api: [v0.31.0 -> v0.35.2](https://github.com/openziti/edge-api/compare/v0.31.0...v0.35.2)
* github.com/openziti/edge-api: [v0.31.0 -> v0.36.0](https://github.com/openziti/edge-api/compare/v0.31.0...v0.36.0)
* [Issue #198](https://github.com/openziti/edge-api/issues/198) - Advertise edge router capabilities in the service edge-router list
* github.com/openziti/foundation/v2: [v2.0.91 -> v2.0.100](https://github.com/openziti/foundation/compare/v2.0.91...v2.0.100)
@@ -716,7 +794,30 @@ Thanks to the community members who contributed to this release.
* github.com/openziti/xweb/v3: [v3.0.4 -> v3.0.5](https://github.com/openziti/xweb/compare/v3.0.4...v3.0.5)
* github.com/openziti/ziti/v2: [v2.0.0 -> v2.1.0](https://github.com/openziti/ziti/compare/v2.0.0...v2.1.0)
* [Issue #4378](https://github.com/openziti/ziti/issues/4378) - Api session enforcer delete meter only marks when the batch delete fails
* [Issue #4434](https://github.com/openziti/ziti/issues/4434) - Update to Go 1.27
* [Issue #4413](https://github.com/openziti/ziti/issues/4413) - debian controller & router packages hang on post-install script when installed via ansible
* [Issue #4410](https://github.com/openziti/ziti/issues/4410) - REST error responder logs ApiError.Code as a method value
* [Issue #4349](https://github.com/openziti/ziti/issues/4349) - Router process check reports configured hashes as the expected signer fingerprints
* [Issue #4348](https://github.com/openziti/ziti/issues/4348) - Single-process posture check with no signer fingerprint always fails at the router
* [Issue #4005](https://github.com/openziti/ziti/issues/4005) - Auto-GC stale router links
* [Issue #4246](https://github.com/openziti/ziti/issues/4246) - Router connect scans the entire link table, making a reconnect wave scale with the cube of the router count
* [Issue #4365](https://github.com/openziti/ziti/issues/4365) - REST requests no longer reset the legacy API session timeout
* [Issue #4350](https://github.com/openziti/ziti/issues/4350) - Policy enforcers leak a metrics reference on every run
* [Issue #4369](https://github.com/openziti/ziti/issues/4369) - Router control channel connectivity events can be delivered out of order
* [Issue #4323](https://github.com/openziti/ziti/issues/4323) - Router posture cache grows without bound as clients re-authenticate
* [Issue #4321](https://github.com/openziti/ziti/issues/4321) - Process hashes and signer fingerprints are compared case-sensitively at the router
* [Issue #4314](https://github.com/openziti/ziti/issues/4314) - Router posture cache corrupts process posture state when applying posture responses
* [Issue #4318](https://github.com/openziti/ziti/issues/4318) - Mac and Domain posture checks panic when the client has not reported that data
* [Issue #4319](https://github.com/openziti/ziti/issues/4319) - OS posture check with no versions fails at the router, passes at the controller
* [Issue #4320](https://github.com/openziti/ziti/issues/4320) - Router does not normalize MAC addresses from posture responses
* [Issue #4222](https://github.com/openziti/ziti/issues/4222) - OIDC cert authentication always reports isCertExtendable as true, including for 3rd Party CA certificates
* [Issue #4094](https://github.com/openziti/ziti/issues/4094) - Edge router rejects valid first-party client certificates when the edge signing CA differs from the ctrl-channel CA
* [Issue #4304](https://github.com/openziti/ziti/issues/4304) - Support build time provided capabilities in the /version capabilities list
* [Issue #4296](https://github.com/openziti/ziti/issues/4296) - Bind message with a short cost header panics the router
* [Issue #4199](https://github.com/openziti/ziti/issues/4199) - Add in-place upgrade smoke test
* [Issue #4291](https://github.com/openziti/ziti/issues/4291) - Admin MFA removal doesn't replicate across HA cluster members
* [Issue #4202](https://github.com/openziti/ziti/issues/4202) - wss edge listener rejects all clients on 2.0: certValidatingIdentity forces client-cert verification on a listener that intentionally sets NoClientCert
* [Issue #4184](https://github.com/openziti/ziti/issues/4184) - Router leaks LinkSendBuffer goroutines in `drainDeadlines()` — circuits accumulate until the router OOMs
* [Issue #4278](https://github.com/openziti/ziti/issues/4278) - fabric inspect data-model-index doesn't move for writes outside the router data model
* [Issue #4196](https://github.com/openziti/ziti/issues/4196) - Router control-channel connect/disconnect race can leave a reconnected router de-registered
+345
View File
@@ -0,0 +1,345 @@
/*
Copyright NetFoundry Inc.
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
https://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 routerlink
import (
"encoding/json"
"fmt"
"math"
"time"
"github.com/openziti/channel/v5"
)
// DefaultCloseUnresponsiveTimeout is the fallback when a config supplies no
// heartbeats.closeUnresponsiveTimeout, matching the router env default
// (DefaultLinkUnresponsiveTimeout).
const DefaultCloseUnresponsiveTimeout = time.Minute
// Heartbeat interval fallbacks, matching the channel package defaults, used when
// a config supplies no heartbeats.sendInterval / checkInterval.
const (
DefaultHeartbeatSendInterval = 10 * time.Second
DefaultHeartbeatCheckInterval = time.Second
)
// MinCloseUnresponsiveTimeout is the floor for heartbeats.closeUnresponsiveTimeout.
// A healthy connection can go quiet for several seconds while TCP recovers from
// ordinary loss, and a shorter timeout closes links that are only doing that.
const MinCloseUnresponsiveTimeout = 10 * time.Second
// ConfigBaseType is the un-versioned config family this package owns.
const ConfigBaseType = "router.link"
// ConfigTypeV1 is the controller-defined ConfigType.Name for version 1.
const ConfigTypeV1 = "router.link.v1"
// Config is the typed view of router.link.v1 JSON. Field tags match the
// schema property names. Strings rather than time.Duration are used for
// duration fields so they round-trip cleanly into the existing
// channel.LoadOptions parser, which already understands "30s" / "100ms".
type Config struct {
Listeners []ListenerConfig `json:"listeners,omitempty"`
Dialers []DialerConfig `json:"dialers,omitempty"`
Heartbeats *HeartbeatsConfig `json:"heartbeats,omitempty"`
PayloadSenderQueueSize int `json:"payloadSenderQueueSize,omitempty"`
AckSenderQueueSize int `json:"ackSenderQueueSize,omitempty"`
// GcMode is the auto-GC policy for links this router's own configuration
// has made stale: "preserve" (default, never close on that basis),
// "orphaned" (close links whose supporting listener/dialer is entirely
// gone), or "changed" (close links whose details have shifted). Empty
// string is treated as "preserve".
//
// The policy covers local staleness only. A peer withdrawing or moving a
// listener still closes the affected link at once, under every mode,
// because the peer's advertised listener set is authoritative about what
// it will accept. "preserve" is therefore not a guarantee that links
// survive every config change.
GcMode string `json:"gcMode,omitempty"`
}
// GcMode names the auto-GC policy applied by the router after each
// successful link-config Apply. Mirrors the CLI `--mode` for
// `ziti ops verify stale-links`, plus a `Preserve` value that disables
// local-staleness GC entirely. It governs only links made stale by this
// router's own configuration; peer-driven closures are unaffected.
type GcMode int
const (
GcModePreserve GcMode = iota
GcModeOrphaned
GcModeChanged
)
func (m GcMode) String() string {
switch m {
case GcModeOrphaned:
return "orphaned"
case GcModeChanged:
return "changed"
default:
return "preserve"
}
}
// ParseGcMode normalizes the string form (as it appears in the JSON
// config or local YAML) into the enum. Unknown values return
// GcModePreserve and an error; the caller decides whether to fall back
// or reject the config.
func ParseGcMode(s string) (GcMode, error) {
switch s {
case "", "preserve":
return GcModePreserve, nil
case "orphaned":
return GcModeOrphaned, nil
case "changed":
return GcModeChanged, nil
default:
return GcModePreserve, fmt.Errorf("unknown gcMode %q (expected preserve|orphaned|changed)", s)
}
}
// ListenerConfig matches the schema's listener entry. Bind is the only
// required field.
type ListenerConfig struct {
Binding string `json:"binding,omitempty"`
Bind string `json:"bind"`
Advertise string `json:"advertise,omitempty"`
BindInterface string `json:"bindInterface,omitempty"`
Groups Groups `json:"groups,omitempty"`
Options *ChannelOptions `json:"options,omitempty"`
}
// DialerConfig matches the schema's dialer entry.
//
// Split is carried here for local-config fidelity only. The router's own YAML
// may set the transport's `split` dialer flag (the legacy split-vs-single
// fallback used when dialing a router too old for multi-underlay links);
// dropping it on translation would silently flip an explicit `split: false`
// back to the transport default of true. It is deliberately absent from the
// controller-managed router.link schema, so it cannot be set from the
// controller. A nil pointer means "unset", letting the transport default apply.
type DialerConfig struct {
Binding string `json:"binding,omitempty"`
MaxDefaultConnections int `json:"maxDefaultConnections,omitempty"`
MaxAckConnections *int `json:"maxAckConnections,omitempty"`
Split *bool `json:"split,omitempty"`
StartupDelay string `json:"startupDelay,omitempty"`
BindInterface string `json:"bindInterface,omitempty"`
Groups Groups `json:"groups,omitempty"`
HealthyDialBackoff *BackoffConfig `json:"healthyDialBackoff,omitempty"`
UnhealthyDialBackoff *BackoffConfig `json:"unhealthyDialBackoff,omitempty"`
Options *ChannelOptions `json:"options,omitempty"`
}
// ChannelOptions matches the shared channelOptions definition.
//
// OutQueueSize and MaxQueuedConnects are pointers because zero is a valid
// setting for both — each sizes a buffered channel, and zero means unbuffered.
// A plain int with omitempty would make an explicit 0 indistinguishable from
// unset and silently substitute the default.
type ChannelOptions struct {
OutQueueSize *int `json:"outQueueSize,omitempty"`
MaxQueuedConnects *int `json:"maxQueuedConnects,omitempty"`
MaxOutstandingConnects int `json:"maxOutstandingConnects,omitempty"`
ConnectTimeout string `json:"connectTimeout,omitempty"`
WriteTimeout string `json:"writeTimeout,omitempty"`
}
// BackoffConfig matches the shared backoff definition.
type BackoffConfig struct {
RetryBackoffFactor float64 `json:"retryBackoffFactor,omitempty"`
MinRetryInterval string `json:"minRetryInterval,omitempty"`
MaxRetryInterval string `json:"maxRetryInterval,omitempty"`
}
// HeartbeatsConfig matches the heartbeats definition.
type HeartbeatsConfig struct {
SendInterval string `json:"sendInterval,omitempty"`
CheckInterval string `json:"checkInterval,omitempty"`
CloseUnresponsiveTimeout string `json:"closeUnresponsiveTimeout,omitempty"`
}
// Groups is a list of group names. The schema allows the JSON value to be
// either a single string or an array of strings; UnmarshalJSON normalizes
// both to a slice. MarshalJSON always emits an array for downstream
// consumers that prefer one shape.
type Groups []string
// UnmarshalJSON accepts either a string or an array of strings per schema.
func (g *Groups) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*g = Groups{single}
return nil
}
var slice []string
if err := json.Unmarshal(data, &slice); err != nil {
return err
}
*g = Groups(slice)
return nil
}
// MarshalJSON always emits the array form for stable downstream parsing.
func (g Groups) MarshalJSON() ([]byte, error) {
if g == nil {
return []byte("null"), nil
}
return json.Marshal([]string(g))
}
// ParseConfig unmarshals raw JSON into a Config. Returns an error if the
// JSON is malformed; does not enforce schema semantics (the schema is
// enforced at the controller).
func ParseConfig(data string) (*Config, error) {
var c Config
if err := json.Unmarshal([]byte(data), &c); err != nil {
return nil, err
}
return &c, nil
}
// Validate checks values the controller JSON schema can't express and that
// would otherwise fail silently downstream. Callers run it before applying a
// config so a bad value fails the apply rather than degrading behavior.
func (c *Config) Validate() error {
for i := range c.Listeners {
if err := validateChannelOptions(c.Listeners[i].Options); err != nil {
return fmt.Errorf("listeners[%d].options: %w", i, err)
}
}
for i := range c.Dialers {
if err := validateChannelOptions(c.Dialers[i].Options); err != nil {
return fmt.Errorf("dialers[%d].options: %w", i, err)
}
}
if _, err := c.EffectiveHeartbeats(); err != nil {
return err
}
return nil
}
// HeartbeatDurations is the heartbeat timing a config resolves to, with absent
// fields filled from the defaults.
type HeartbeatDurations struct {
SendInterval time.Duration
CheckInterval time.Duration
CloseUnresponsiveTimeout time.Duration
}
// HasThinMargin reports whether the timeout leaves less than one further
// heartbeat cycle beyond the gap a responsive link already shows. Such a config
// is accepted — it works while nothing is lost — but a single dropped response
// can close a healthy link, so callers log it.
func (h HeartbeatDurations) HasThinMargin() bool {
return h.CloseUnresponsiveTimeout < 2*(h.SendInterval+h.CheckInterval)
}
// EffectiveHeartbeats resolves the heartbeat fields against their defaults, so
// callers judge the timing the router will actually run with.
//
// Returns an error when a duration doesn't parse or isn't positive, when
// sendInterval + checkInterval isn't representable, or when
// closeUnresponsiveTimeout is below MinCloseUnresponsiveTimeout or not greater
// than sendInterval + checkInterval, the gap a responsive link already shows.
func (c *Config) EffectiveHeartbeats() (HeartbeatDurations, error) {
var hb HeartbeatsConfig
if c.Heartbeats != nil {
hb = *c.Heartbeats
}
send, err := parseHeartbeatDuration("sendInterval", hb.SendInterval, DefaultHeartbeatSendInterval)
if err != nil {
return HeartbeatDurations{}, err
}
check, err := parseHeartbeatDuration("checkInterval", hb.CheckInterval, DefaultHeartbeatCheckInterval)
if err != nil {
return HeartbeatDurations{}, err
}
closeTimeout, err := parseHeartbeatDuration("closeUnresponsiveTimeout", hb.CloseUnresponsiveTimeout, DefaultCloseUnresponsiveTimeout)
if err != nil {
return HeartbeatDurations{}, err
}
if closeTimeout < MinCloseUnresponsiveTimeout {
return HeartbeatDurations{}, fmt.Errorf(
"heartbeats: closeUnresponsiveTimeout %s is below the %s minimum; "+
"a shorter timeout closes links that are only recovering from ordinary packet loss",
closeTimeout, MinCloseUnresponsiveTimeout)
}
// The sum of two positive durations can only wrap negative, which would make
// the check below accept every timeout.
if send > math.MaxInt64-check {
return HeartbeatDurations{}, fmt.Errorf(
"heartbeats: sendInterval %s + checkInterval %s exceeds the largest representable duration (%s)",
send, check, time.Duration(math.MaxInt64))
}
if closeTimeout <= send+check {
return HeartbeatDurations{}, fmt.Errorf(
"heartbeats: closeUnresponsiveTimeout %s must be greater than sendInterval + checkInterval (%s + %s = %s); "+
"a responsive link's own heartbeat gap reaches that sum, so links would be closed while healthy",
closeTimeout, send, check, send+check)
}
return HeartbeatDurations{
SendInterval: send,
CheckInterval: check,
CloseUnresponsiveTimeout: closeTimeout,
}, nil
}
// parseHeartbeatDuration resolves one heartbeat field, substituting def when it
// is absent. A non-positive value is rejected rather than defaulted: zero or
// negative reaches time.NewTicker in ConfigureHeartbeat, which panics on a bare
// goroutine.
func parseHeartbeatDuration(name, value string, def time.Duration) (time.Duration, error) {
if value == "" {
return def, nil
}
d, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid heartbeats.%s %q: %w", name, value, err)
}
if d <= 0 {
return 0, fmt.Errorf("heartbeats.%s must be positive, got %s", name, d)
}
return d, nil
}
// validateChannelOptions rejects a connectTimeout that the downstream channel
// can't honor: it must parse as a duration and fall within
// [channel.MinConnectTimeout, channel.MaxConnectTimeout], the range the channel
// loader accepts.
func validateChannelOptions(o *ChannelOptions) error {
if o == nil || o.ConnectTimeout == "" {
return nil
}
d, err := time.ParseDuration(o.ConnectTimeout)
if err != nil {
return fmt.Errorf("invalid connectTimeout %q: %w", o.ConnectTimeout, err)
}
if d < channel.MinConnectTimeout {
return fmt.Errorf("connectTimeout %s is below the minimum of %s", d, channel.MinConnectTimeout)
}
if d > channel.MaxConnectTimeout {
return fmt.Errorf("connectTimeout %s is above the maximum of %s", d, channel.MaxConnectTimeout)
}
return nil
}
+20 -4
View File
@@ -2178,8 +2178,14 @@ func (x *Listener) GetLocalBinding() string {
}
type Listeners struct {
state protoimpl.MessageState `protogen:"open.v1"`
Listeners []*Listener `protobuf:"bytes,1,rep,name=listeners,proto3" json:"listeners,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
Listeners []*Listener `protobuf:"bytes,1,rep,name=listeners,proto3" json:"listeners,omitempty"`
// generation identifies the router link config this set came from. It
// increases with each applied config and restarts with the router process, so
// a receiver compares it only within one control channel session. Updates can
// arrive out of order (a multi-underlay channel gives no ordering across
// underlays), so a receiver drops a generation it has already passed.
Generation uint64 `protobuf:"varint,2,opt,name=generation,proto3" json:"generation,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -2221,6 +2227,13 @@ func (x *Listeners) GetListeners() []*Listener {
return nil
}
func (x *Listeners) GetGeneration() uint64 {
if x != nil {
return x.Generation
}
return 0
}
type CheckStaleLinksRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Mode StaleLinkMatchMode `protobuf:"varint,1,opt,name=mode,proto3,enum=ziti.ctrl.pb.StaleLinkMatchMode" json:"mode,omitempty"`
@@ -3636,9 +3649,12 @@ const file_ctrl_proto_rawDesc = "" +
"\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1a\n" +
"\bprotocol\x18\x02 \x01(\tR\bprotocol\x12\x16\n" +
"\x06groups\x18\x04 \x03(\tR\x06groups\x12\"\n" +
"\flocalBinding\x18\x05 \x01(\tR\flocalBindingJ\x04\b\x03\x10\x04R\bcostTags\"A\n" +
"\flocalBinding\x18\x05 \x01(\tR\flocalBindingJ\x04\b\x03\x10\x04R\bcostTags\"a\n" +
"\tListeners\x124\n" +
"\tlisteners\x18\x01 \x03(\v2\x16.ziti.ctrl.pb.ListenerR\tlisteners\"N\n" +
"\tlisteners\x18\x01 \x03(\v2\x16.ziti.ctrl.pb.ListenerR\tlisteners\x12\x1e\n" +
"\n" +
"generation\x18\x02 \x01(\x04R\n" +
"generation\"N\n" +
"\x16CheckStaleLinksRequest\x124\n" +
"\x04mode\x18\x01 \x01(\x0e2 .ziti.ctrl.pb.StaleLinkMatchModeR\x04mode\"\xa6\x01\n" +
"\x0fLinkStaleReport\x12\x16\n" +
+6
View File
@@ -300,6 +300,12 @@ message Listener {
message Listeners {
repeated Listener listeners = 1;
// generation identifies the router link config this set came from. It
// increases with each applied config and restarts with the router process, so
// a receiver compares it only within one control channel session. Updates can
// arrive out of order (a multi-underlay channel gives no ordering across
// underlays), so a receiver drops a generation it has already passed.
uint64 generation = 2;
}
// StaleLinkMatchMode controls when an established link is considered
+5 -2
View File
@@ -131,7 +131,10 @@ func (self *CtrlAccepter) Bind(binding channel.Binding) error {
return errors.New("no version info header, not accepting router connection")
}
r.SetLinkListeners(nil)
// A new control channel starts a new session: the router mints listener
// generations per process, so anything remembered from a previous one
// would reject its first updates.
r.ResetLinkListeners()
headers := ch.Underlay().Headers()
// Determine header locations based on router capabilities. 2.0+ routers
@@ -151,7 +154,7 @@ func (self *CtrlAccepter) Bind(binding channel.Binding) error {
if err = proto.Unmarshal(val, listeners); err != nil {
log.WithError(err).Error("unable to unmarshall listeners value")
} else {
r.SetLinkListeners(listeners.Listeners)
r.SetLinkListeners(listeners.Listeners, listeners.Generation)
for _, listener := range listeners.Listeners {
log.WithField("address", listener.GetAddress()).
WithField("protocol", listener.GetProtocol()).
@@ -52,8 +52,16 @@ func (self *updateLinkListenersHandler) HandleReceive(msg *channel.Message, ch c
return
}
self.router.SetLinkListeners(listeners.Listeners)
if !self.router.SetLinkListeners(listeners.Listeners, listeners.Generation) {
// Superseded by an update that arrived first. Redistributing this set
// would push peers back onto listeners the router has already replaced.
log.WithField("generation", listeners.Generation).
Info("ignoring superseded router link listener update")
return
}
log.WithField("listenerCount", len(listeners.Listeners)).
WithField("generation", listeners.Generation).
Info("updated router link listeners; redistributing to peers")
// Trigger the existing peer-redistribution path: peers receive a
@@ -0,0 +1,117 @@
/*
Copyright NetFoundry Inc.
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
https://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 model
import (
"errors"
"testing"
"github.com/openziti/ziti/v2/common/config/routerlink"
"github.com/stretchr/testify/require"
)
func TestConfigManager_DataValidatorRegistry(t *testing.T) {
req := require.New(t)
manager := &ConfigManager{dataValidators: map[string]ConfigDataValidator{}}
// An unregistered type is not an error; most config types have no rules
// beyond their schema.
req.NoError(manager.validateData("some.other.type", map[string]interface{}{}))
sentinel := errors.New("nope")
manager.RegisterDataValidator("my.type.v1", func(map[string]interface{}) error {
return sentinel
})
req.ErrorIs(manager.validateData("my.type.v1", nil), sentinel)
req.NoError(manager.validateData("my.other.type.v1", nil), "validators are scoped to their type")
// Re-registering replaces, so init order can't leave two validators fighting.
manager.RegisterDataValidator("my.type.v1", func(map[string]interface{}) error { return nil })
req.NoError(manager.validateData("my.type.v1", nil))
}
func TestValidateRouterLinkConfigData(t *testing.T) {
tests := []struct {
name string
data map[string]interface{}
expectErr string
}{
{
name: "empty config is valid",
data: map[string]interface{}{},
},
{
name: "sane heartbeats",
data: map[string]interface{}{
"heartbeats": map[string]interface{}{
"sendInterval": "10s",
"checkInterval": "1s",
"closeUnresponsiveTimeout": "60s",
},
},
},
{
// The check interval outruns the timeout, so every pulse would send a
// heartbeat and condemn the link before a response could arrive.
name: "check interval past the timeout",
data: map[string]interface{}{
"heartbeats": map[string]interface{}{
"checkInterval": "2m",
"closeUnresponsiveTimeout": "30s",
},
},
expectErr: "closeUnresponsiveTimeout",
},
{
name: "non-positive interval",
data: map[string]interface{}{
"heartbeats": map[string]interface{}{"checkInterval": "0s"},
},
expectErr: "must be positive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := require.New(t)
err := validateRouterLinkConfigData(tt.data)
if tt.expectErr == "" {
req.NoError(err)
return
}
req.Error(err)
req.Contains(err.Error(), tt.expectErr)
})
}
}
func TestNewConfigManager_RegistersRouterLinkValidator(t *testing.T) {
// The built-in validator is wired at construction rather than by a separate
// init step, so guard that the wiring exists and is keyed correctly.
req := require.New(t)
manager := &ConfigManager{dataValidators: map[string]ConfigDataValidator{}}
manager.RegisterDataValidator(routerlink.ConfigTypeV1, validateRouterLinkConfigData)
err := manager.validateData(routerlink.ConfigTypeV1, map[string]interface{}{
"heartbeats": map[string]interface{}{
"checkInterval": "2m",
"closeUnresponsiveTimeout": "30s",
},
})
req.Error(err, "a router.link.v1 config with unsafe timing must be rejected")
}
+49 -1
View File
@@ -18,32 +18,80 @@ package model
import (
"encoding/json"
"fmt"
"strings"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/openziti/ziti/v2/common/config/routerlink"
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
"github.com/openziti/ziti/v2/controller/change"
"github.com/openziti/ziti/v2/controller/command"
"github.com/openziti/ziti/v2/controller/db"
"github.com/openziti/ziti/v2/controller/fields"
"github.com/openziti/ziti/v2/controller/models"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"go.etcd.io/bbolt"
"google.golang.org/protobuf/proto"
)
// ConfigDataValidator applies rules to a config's data that its JSON schema
// can't express: relationships between fields, combinations of values, anything
// needing more than per-field checks. Registered against a config type name and
// run on create and update, after schema validation has passed.
//
// Return a field error (errorz.NewFieldError) to have the REST layer report the
// offending field; a plain error is reported against the data as a whole.
type ConfigDataValidator func(data map[string]interface{}) error
func NewConfigManager(env Env) *ConfigManager {
manager := &ConfigManager{
baseEntityManager: newBaseEntityManager[*Config, *db.Config](env, env.GetStores().Config),
dataValidators: map[string]ConfigDataValidator{},
}
manager.impl = manager
RegisterManagerDecoder[*Config](env, manager)
manager.RegisterDataValidator(routerlink.ConfigTypeV1, validateRouterLinkConfigData)
return manager
}
// validateRouterLinkConfigData applies the router.link rules its JSON schema
// can't express, notably heartbeat timing, using the router's own validation.
func validateRouterLinkConfigData(data map[string]interface{}) error {
js, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("unable to re-encode config data: %w", err)
}
cfg, err := routerlink.ParseConfig(string(js))
if err != nil {
return err
}
return cfg.Validate()
}
type ConfigManager struct {
baseEntityManager[*Config, *db.Config]
// dataValidators is keyed by config type name, which is stable across networks
// where ids are not. Written only during controller init, so reads need no lock.
dataValidators map[string]ConfigDataValidator
}
// RegisterDataValidator installs a semantic validator for the named config
// type, to run after schema validation on every create and update. Call during
// controller init; it is not safe to call once requests are being served.
// Registering a second validator for the same type replaces the first.
func (self *ConfigManager) RegisterDataValidator(configTypeName string, v ConfigDataValidator) {
self.dataValidators[configTypeName] = v
}
// validateData runs the validator registered for configTypeName, if any.
func (self *ConfigManager) validateData(configTypeName string, data map[string]interface{}) error {
if v, found := self.dataValidators[configTypeName]; found {
return v(data)
}
return nil
}
func (self *ConfigManager) NewModelEntity() *Config {
+19 -2
View File
@@ -17,11 +17,13 @@
package model
import (
"errors"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/openziti/ziti/v2/controller/apierror"
"github.com/openziti/ziti/v2/controller/db"
"github.com/openziti/ziti/v2/controller/models"
"github.com/openziti/ziti/v2/controller/storage/boltz"
"github.com/xeipuuv/gojsonschema"
"go.etcd.io/bbolt"
)
@@ -50,7 +52,9 @@ func (entity *Config) toBoltEntity(tx *bbolt.Tx, env Env) (*db.Config, error) {
entity.TypeId = currentConfig.TypeId
}
if configType, _ := env.GetManagers().ConfigType.readInTx(tx, entity.TypeId); configType != nil && len(configType.Schema) > 0 {
configType, _ := env.GetManagers().ConfigType.readInTx(tx, entity.TypeId)
if configType != nil && len(configType.Schema) > 0 {
compileSchema, err := configType.GetCompiledSchema()
if err != nil {
return nil, err
@@ -65,6 +69,19 @@ func (entity *Config) toBoltEntity(tx *bbolt.Tx, env Env) (*db.Config, error) {
}
}
// Semantic validation runs after the schema, so a validator can assume the
// shape is already right and only judge the values. Keyed on the type name,
// which is stable across networks in a way the id is not.
if configType != nil {
if err := env.GetManagers().Config.validateData(configType.Name, entity.Data); err != nil {
var fieldErr *errorz.FieldError
if errors.As(err, &fieldErr) {
return nil, fieldErr
}
return nil, errorz.NewFieldError(err.Error(), db.FieldConfigData, entity.Data)
}
}
return &db.Config{
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
Name: entity.Name,
+76
View File
@@ -0,0 +1,76 @@
/*
Copyright NetFoundry Inc.
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
https://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 model
import (
"testing"
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
"github.com/stretchr/testify/require"
)
func listenerSet(addr string) []*ctrl_pb.Listener {
return []*ctrl_pb.Listener{{Address: addr, Protocol: "tls"}}
}
func TestRouter_SetLinkListeners_DropsSupersededGeneration(t *testing.T) {
req := require.New(t)
r := &Router{}
req.True(r.SetLinkListeners(listenerSet("a"), 1))
req.Equal("a", r.GetLinkListeners()[0].Address)
req.True(r.SetLinkListeners(listenerSet("b"), 2))
req.Equal("b", r.GetLinkListeners()[0].Address)
// An update that lost the race must not push the router back onto listeners
// it has already replaced, since the set is redistributed to every peer.
req.False(r.SetLinkListeners(listenerSet("a"), 1))
req.Equal("b", r.GetLinkListeners()[0].Address, "the newer set must survive")
// The same generation is also refused: nothing new to record.
req.False(r.SetLinkListeners(listenerSet("c"), 2))
req.Equal("b", r.GetLinkListeners()[0].Address)
req.True(r.SetLinkListeners(listenerSet("d"), 3))
req.Equal("d", r.GetLinkListeners()[0].Address)
}
func TestRouter_ResetLinkListeners_AllowsRestartedRouter(t *testing.T) {
// A router mints generations per process, so a restart begins at 1 again. If
// the controller kept the generation from the previous session, every update
// from the restarted router would be refused as superseded.
req := require.New(t)
r := &Router{}
req.True(r.SetLinkListeners(listenerSet("old"), 47))
req.False(r.SetLinkListeners(listenerSet("new"), 1), "without a reset, a restart looks superseded")
r.ResetLinkListeners()
req.Empty(r.GetLinkListeners(), "reset clears the recorded set")
req.True(r.SetLinkListeners(listenerSet("new"), 1), "after a reset the new session starts fresh")
req.Equal("new", r.GetLinkListeners()[0].Address)
}
func TestRouter_SetLinkListeners_FirstUpdateAlwaysRecorded(t *testing.T) {
// Generation zero from a router that predates the field must still land,
// rather than being read as "already superseded".
req := require.New(t)
r := &Router{}
req.True(r.SetLinkListeners(listenerSet("a"), 0))
req.Equal("a", r.GetLinkListeners()[0].Address)
}
+24 -1
View File
@@ -42,6 +42,9 @@ type Router struct {
// mid-session changes from the router. Guarded by mu, so it is
// unexported: reach it through SetLinkListeners / GetLinkListeners.
listeners []*ctrl_pb.Listener
// listenerGeneration is the generation of the recorded listener set, scoped
// to the current control channel session. Zero means nothing recorded yet.
listenerGeneration uint64
// mu guards the fields a connected Router mutates mid-session, which
// today is just listeners. Fields added later should share it rather
@@ -167,10 +170,30 @@ func (entity *Router) addLinkListener(addr, linkProtocol string, groups []string
// SetLinkListeners atomically replaces the router's link listener slice.
// Callers do not mutate the previous slice — readers may still hold and
// iterate it safely after a Set.
func (entity *Router) SetLinkListeners(listeners []*ctrl_pb.Listener) {
// SetLinkListeners records listeners when generation is newer than the last
// recorded for this control channel session, and reports whether it did. Older
// generations are dropped: a multi-underlay control channel doesn't order updates
// across underlays, and a stale set would be redistributed to peers.
func (entity *Router) SetLinkListeners(listeners []*ctrl_pb.Listener, generation uint64) bool {
entity.mu.Lock()
defer entity.mu.Unlock()
if generation <= entity.listenerGeneration && entity.listenerGeneration != 0 {
return false
}
entity.listeners = listeners
entity.listenerGeneration = generation
return true
}
// ResetLinkListeners clears the listener set and the generation it was recorded
// at, for a newly-connected control channel. The router mints generations per
// process, so they restart when it does; a generation remembered from a
// previous session would reject a restarted router's first updates.
func (entity *Router) ResetLinkListeners() {
entity.mu.Lock()
defer entity.mu.Unlock()
entity.listeners = nil
entity.listenerGeneration = 0
}
// GetLinkListeners returns the current link listener slice under a read
+74 -10
View File
@@ -214,8 +214,8 @@ the router has final say on what it applies.
If a router has both local and controller-managed config for the same subsystem, local always wins.
Routers are often deployed on systems owned by someone other than the network operator (one entity
runs the network and uses it to manage devices for their customers). The customer needs control
over what the router is capable of. Controller-managed config fills in gaps where local config is
silent; it doesn't override what the operator has explicitly set.
over what the router is capable of. Controller-managed config configures the subsystems local config
leaves alone; it never overrides a subsystem the operator has configured.
### Source Tracking and Selection
@@ -230,8 +230,14 @@ Local takes precedence at the **base type level**. If the operator set anything
`router.link`, controller versions are ignored for that base. Matches operator intent: someone who
set v1 locally probably doesn't want the controller silently upgrading them to v2.
Per-field merge — the richest interpretation of "fills in gaps where local config is silent" — is
out of scope for now. Wait for operator demand.
For `router.link`, "set anything locally" means the router's config file has a `link` section at
all. The env loader fills every link field with a default whether or not the section is present, so
it records presence separately, and a section that only tunes heartbeats or queue sizes still counts.
Such a section leaves the router with no listeners or dialers of its own while ignoring the
controller's, so the router warns at startup when a `link` section has neither.
Per-field merge, where local config would win only for the fields it sets, is out of scope for now.
Wait for operator demand.
### YAML → JSON Translation
@@ -243,6 +249,13 @@ default `binding` if absent, and preserve the deprecated `split` dialer flag as
from the controller). `costTags` was unused and has been removed from the router outright rather
than translated. Per-type code, lives next to each handler. The registry itself is agnostic.
Heartbeat timing is translated leniently, because local link config is applied synchronously and a
failure stops the router from starting. An explicit zero is carried through as `0s` rather than
dropped, so it isn't mistaken for an absent field. A `closeUnresponsiveTimeout` below the floor is
raised to it, and any other timing that fails validation is replaced by the defaults, each with a
warning. The controller path rejects the same values outright, where the error comes back on the
API call.
### Hot Reconfiguration
Every subsystem must handle config updates at runtime: accept new config, tear down and recreate
@@ -258,8 +271,9 @@ General approach:
### Reconciliation Strategy
The link handler takes the simple route: on Apply, build the new listener / dialer set, close the
old listeners, swap in the new state, then `Listen()` on the new listeners. No per-item diffing.
The link handler takes the simple route: on Apply, if the listener or dialer definitions changed,
build the new listener / dialer set, close the old listeners, swap in the new state, then `Listen()`
on the new listeners. No per-item diffing.
This works without disturbing traffic because `xlink.Listener.Close()` only closes the listener's
accept loop — already-accepted Xlinks survive. So the operator sees:
@@ -269,14 +283,59 @@ accept loop — already-accepted Xlinks survive. So the operator sees:
- **Remove a listener**: same; established links on it stay up.
- **Modify a listener** (advertise change, etc.): the new accept loop is bound with the new
config; old established links unaffected.
- **No actual change**: the deep-equality check in `notifyChange` no-ops the post-apply notify, but
the listener rebuild still runs. Cheap enough that we don't optimize for it.
- **No change to listeners or dialers**: the rebuild is skipped, so a change confined to heartbeats,
queue sizes, or `gcMode` never rebinds a listen socket. Re-applying identical config returns
before doing anything at all.
Per-item diffing (a `ReconcileSet[K, V]` helper that fires add/update/remove callbacks) is a
plausible refinement when we have multiple subsystems with item-level identity (xgress listeners,
ctrl-channel listeners), but the link subsystem doesn't need it. Add it when a second consumer
shows up and the simple-rebuild approach has actual cost there.
### Heartbeats and Queue Sizes
Beyond listeners and dialers, the link handler applies the `router.link.v1` `heartbeats` settings
and sender queue sizes from the effective config. These follow full-config (PUT) semantics: a field
absent from the applied config reverts to its default rather than retaining a previously-applied
value.
- **Heartbeat timing** (`sendInterval`, `checkInterval`, `closeUnresponsiveTimeout`) is published as
one generation, stamped with the applied config's generation. New links take the current
generation at bind time; established links are pushed it in place through the channel
`HeartbeatControl` handle, with no teardown. Every channel backing a link is re-tuned, including
both channels of a split link, and a channel bound on the far side of a config change is
reconciled to the newest generation when it registers. A link ignores a generation at or below the
one it holds, so an overtaken update cannot roll it back.
- **`payloadSenderQueueSize` / `ackSenderQueueSize`** are read when a link channel is built, so a
change applies to links established afterward; an established channel's send queues aren't resized.
A change confined to them asks nothing of established links and so triggers no notification or
GC pass, though the apply is still logged.
A new generation re-arms each link's liveness deadline. Under the previous intervals a healthy
link's last response can be most of a send + check cycle old, and a newly shortened timeout would
read that accumulated age as unresponsiveness. The re-arm grants one fresh timeout window, not
standing immunity.
Two thresholds are evaluated against time since the last response, independently:
- **Close**: `closeUnresponsiveTimeout`, as configured. A zero timeout closes nothing, since it
means no settings have been published yet.
- **Unhealthy**: `max(30s, 2 × (sendInterval + checkInterval))`, meaning a response has actually
been missed. Reporting unhealthy poisons the link's latency metric, which the controller routes on,
so the threshold has to scale with the intervals; a fixed 30s would price a healthy link with a
long send interval out of routing every cycle. A link being closed is always reported unhealthy.
Timing is validated on both the controller and the router: every duration must be positive,
`sendInterval + checkInterval` must be representable, and `closeUnresponsiveTimeout` must be at least
10s and greater than `sendInterval + checkInterval`, since a responsive link's own gap already reaches
that sum. The floor exists because a healthy connection can go quiet for several seconds while TCP
recovers from ordinary loss. A timeout under two heartbeat cycles is accepted with a warning, since
a single lost response can close the link.
Split links (the legacy fallback for peers without multi-underlay support) are deprecated;
multi-underlay is a superset of split links, and the `split` option is slated for removal in
OpenZiti 4.0.
### Config Application Strategy
The router maintains a config handler registry keyed by config type. When a config event arrives:
@@ -339,6 +398,11 @@ rather than requiring peer agreement, because if the local side can no longer su
the peer was going to see it disconnect anyway. Each closure logs `linkId`, `linkKey`, `side`,
`mode`, and `reason`.
Removing the config always sweeps, under `orphaned`, whatever mode the removed config carried. A
removal leaves no listeners and no dialers, so every remaining link is one the router can never
re-establish; `gcMode` governs links the router's configuration made stale, and after a removal there
is no configuration.
Operators who want a two-sided, controller-aggregated sweep use the `ziti ops verify stale-links`
CLI (which fans CheckStaleLinks out to all routers and only GCs when both endpoints agree).
@@ -353,8 +417,8 @@ is this subsystem configured this way?":
- What's currently applied (source + version).
- Recent alerts (parse failures, rollbacks, offline subsystems).
Wired into the existing inspect framework: `ziti fabric inspect <router> managed-config` returns
the registry view.
Wired into the existing inspect framework:
`ziti fabric inspect router-config-registry [router id regex]` returns the registry view.
### Startup Behavior
+5
View File
@@ -152,6 +152,10 @@ type Config struct {
Listeners []*CtrlListenerConfig
}
Link struct {
// Configured reports that the config file has a link section at all. The
// other fields are filled with defaults either way, so this is the only
// record of whether the operator wrote one.
Configured bool
Listeners []map[interface{}]interface{}
Dialers []map[interface{}]interface{}
Heartbeats channel.HeartbeatOptions
@@ -586,6 +590,7 @@ func LoadConfigWithOptions(path string, loadIdentity bool) (*Config, error) {
cfg.Link.AckSenderQueueSize = DefaultLinkAckSenderQueueSize
if value, found := cfgmap["link"]; found {
cfg.Link.Configured = true
if submap, ok := value.(map[interface{}]interface{}); ok {
if value, found := submap["listeners"]; found {
if subarr, ok := value.([]interface{}); ok {
+71
View File
@@ -0,0 +1,71 @@
/*
Copyright NetFoundry Inc.
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
https://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 env
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
const minimalRouterConfig = `v: 3
identity:
cert: c.pem
server_cert: s.pem
key: k.pem
ca: ca.pem
ctrl:
endpoint: tls:127.0.0.1:6262
csr:
country: US
province: NC
locality: Charlotte
organization: NetFoundry
organizationalUnit: Ops
sans:
dns: [localhost]
`
func loadRouterConfig(t *testing.T, yaml string) *Config {
t.Helper()
path := filepath.Join(t.TempDir(), "router.yml")
require.NoError(t, os.WriteFile(path, []byte(yaml), 0600))
cfg, err := LoadConfigWithOptions(path, false)
require.NoError(t, err)
return cfg
}
func Test_LoadConfig_RecordsWhetherLinkSectionIsPresent(t *testing.T) {
// Every link field is filled with a default whether or not the section was
// written, so Configured is the only way to tell them apart.
t.Run("absent", func(t *testing.T) {
req := require.New(t)
cfg := loadRouterConfig(t, minimalRouterConfig)
req.False(cfg.Link.Configured)
req.NotZero(cfg.Link.Heartbeats.SendInterval, "defaults are filled in regardless")
})
t.Run("present with only heartbeats", func(t *testing.T) {
req := require.New(t)
cfg := loadRouterConfig(t, minimalRouterConfig+"link:\n heartbeats:\n sendInterval: 5s\n")
req.True(cfg.Link.Configured)
req.Empty(cfg.Link.Listeners)
req.Empty(cfg.Link.Dialers)
})
}
+100 -23
View File
@@ -2,6 +2,7 @@ package handler_link
import (
"crypto/x509"
"math"
"time"
"github.com/michaelquigley/pfxlog"
@@ -13,6 +14,7 @@ import (
"github.com/openziti/metrics"
"github.com/openziti/sdk-golang/v2/xgress"
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
"github.com/openziti/ziti/v2/common/servermetrics"
"github.com/openziti/ziti/v2/common/trace"
"github.com/openziti/ziti/v2/router/env"
"github.com/openziti/ziti/v2/router/forwarder"
@@ -22,22 +24,43 @@ import (
"github.com/sirupsen/logrus"
)
func NewBindHandlerFactory(c env.NetworkControllers, f *forwarder.Forwarder, hbo *channel.HeartbeatOptions, mr metrics.Registry, registry xlink.Registry) *bindHandlerFactory {
// LinkSettings supplies the link settings the bind handler reads, which can
// change at runtime through controller-managed config. An interface so
// handler_link doesn't depend on the link package, whose Subsystem implements it.
// Implementations must be safe for concurrent reads.
type LinkSettings interface {
// Heartbeats is the heartbeat timing currently in force, as one snapshot.
// Taken whole so a link is never configured from a mix of generations.
Heartbeats() xlink.HeartbeatSettings
}
// LinkChannelEnv bundles what the link bind handler needs. The router satisfies
// it directly; GetForwarderImpl returns the concrete *forwarder.Forwarder, which
// GetForwarder can't without an env->forwarder import cycle.
type LinkChannelEnv interface {
GetNetworkControllers() env.NetworkControllers
GetForwarderImpl() *forwarder.Forwarder
GetMetricsRegistry() servermetrics.UsageRegistry
GetXlinkRegistry() xlink.Registry
GetLinkSettings() LinkSettings
}
func NewBindHandlerFactory(linkEnv LinkChannelEnv) *bindHandlerFactory {
return &bindHandlerFactory{
ctrl: c,
forwarder: f,
metricsRegistry: mr,
xlinkRegistry: registry,
heartbeatOptions: hbo,
ctrl: linkEnv.GetNetworkControllers(),
forwarder: linkEnv.GetForwarderImpl(),
metricsRegistry: linkEnv.GetMetricsRegistry(),
xlinkRegistry: linkEnv.GetXlinkRegistry(),
linkSettings: linkEnv.GetLinkSettings(),
}
}
type bindHandlerFactory struct {
ctrl env.NetworkControllers
forwarder *forwarder.Forwarder
metricsRegistry metrics.Registry
xlinkRegistry xlink.Registry
heartbeatOptions *channel.HeartbeatOptions
ctrl env.NetworkControllers
forwarder *forwarder.Forwarder
metricsRegistry metrics.Registry
xlinkRegistry xlink.Registry
linkSettings LinkSettings
}
func (self *bindHandlerFactory) NewBindHandler(link xlink.Xlink, latency bool, listenerSide bool) channel.BindHandler {
@@ -97,16 +120,23 @@ func (self *bindHandler) BindChannel(binding channel.Binding) error {
}))
log.Info("link destination support heartbeats")
// One sample, used for both the ticker and the seed, and handed to the link
// so its callbacks enforce the timeout belonging to these intervals.
settings := self.linkSettings.Heartbeats()
cb := &heartbeatCallback{
linkId: self.xlink.Id(),
latencyMetric: latencyMetric,
queueTimeMetric: queueTimeMetric,
ch: binding.GetChannel(),
heartbeatOptions: self.heartbeatOptions,
link: self.xlink,
latencySemaphore: concurrenz.NewSemaphore(2),
lastResponse: time.Now().Add(self.heartbeatOptions.CloseUnresponsiveTimeout * 2).UnixMilli(),
lastResponse: time.Now().Add(settings.CloseUnresponsiveTimeout * 2).UnixMilli(),
generation: settings.Generation,
}
channel.ConfigureHeartbeat(binding, 10*time.Second, time.Second, cb)
hc := channel.ConfigureHeartbeat(binding, settings.SendInterval, settings.CheckInterval, cb)
self.xlink.SetHeartbeatControl(hc, settings)
return nil
}
@@ -162,12 +192,35 @@ func leafFingerprint(certs []*x509.Certificate) (string, error) {
return nfpem.FingerprintFromCertificate(certs[0]), nil
}
// minUnhealthyThreshold is the floor for reporting a silent link as unhealthy.
const minUnhealthyThreshold = 30 * time.Second
// unhealthyThresholdMs is how long a link can go without a heartbeat response
// before it is reported unhealthy, which poisons its latency metric and so its
// routing cost: twice the send + check gap a healthy link already shows, so a
// response has actually been missed, and never less than minUnhealthyThreshold.
func unhealthyThresholdMs(settings xlink.HeartbeatSettings) int64 {
threshold := minUnhealthyThreshold
if gap := settings.SendInterval + settings.CheckInterval; gap > threshold/2 {
threshold = 2 * gap
if threshold < gap { // validation bounds the gap, not its double
threshold = math.MaxInt64
}
}
return threshold.Milliseconds()
}
// heartbeatCallback enforces one channel's heartbeat liveness. Every method runs
// on that channel's heartbeat pulse goroutine, including the response callbacks,
// which the heartbeater queues rather than calling on the rxer, so lastResponse
// and generation need no synchronization.
type heartbeatCallback struct {
linkId string
latencyMetric metrics.Histogram
queueTimeMetric metrics.Histogram
lastResponse int64
heartbeatOptions *channel.HeartbeatOptions
generation uint64
link xlink.Xlink
ch channel.Channel
latencySemaphore concurrenz.Semaphore
}
@@ -186,23 +239,47 @@ func (self *heartbeatCallback) HeartbeatRespRx(ts int64) {
func (self *heartbeatCallback) CheckHeartBeat() {
log := pfxlog.Logger().WithField("channelId", self.ch.Label())
now := time.Now().UnixMilli()
if delta := now - self.lastResponse; delta > 30000 {
unhealthy, shouldClose := self.evaluate(time.Now().UnixMilli(), self.link.HeartbeatSettings())
if unhealthy {
log.Warn("heartbeat not received in time, link may be unhealthy")
self.latencyMetric.Clear()
self.latencyMetric.Update(8888888888888)
if delta > self.heartbeatOptions.CloseUnresponsiveTimeout.Milliseconds() {
log.Error("heartbeat not received in time, closing router link connection")
if err := self.ch.Close(); err != nil {
log.WithError(err).Error("error while closing router link connection")
}
}
if shouldClose {
log.Error("heartbeat not received in time, closing router link connection")
if err := self.ch.Close(); err != nil {
log.WithError(err).Error("error while closing router link connection")
}
}
go self.checkQueueTime()
}
// evaluate advances the deadline state for a check run at now and reports what
// that check should do. Split from CheckHeartBeat so the timing rules can be
// exercised without a live channel.
//
// A change of generation re-arms the deadline, granting one fresh timeout window:
// under the previous intervals a healthy link's last response can be most of a
// send+check cycle old, and the channel only picks up a new check interval on its
// next pulse.
func (self *heartbeatCallback) evaluate(now int64, settings xlink.HeartbeatSettings) (unhealthy, shouldClose bool) {
if settings.Generation != self.generation {
self.generation = settings.Generation
self.lastResponse = now
}
delta := now - self.lastResponse
// The thresholds are independent. A zero timeout means no settings have been
// published yet, so it closes nothing.
if timeout := settings.CloseUnresponsiveTimeout.Milliseconds(); timeout > 0 && delta > timeout {
return true, true
}
return delta > unhealthyThresholdMs(settings), false
}
func (self *heartbeatCallback) checkQueueTime() {
log := pfxlog.Logger().WithField("linkId", self.linkId)
if !self.latencySemaphore.TryAcquire() {
+157
View File
@@ -19,9 +19,12 @@ package handler_link
import (
"crypto/x509"
"crypto/x509/pkix"
"math"
"testing"
"time"
nfpem "github.com/openziti/foundation/v2/pem"
"github.com/openziti/ziti/v2/router/xlink"
"github.com/stretchr/testify/require"
)
@@ -61,3 +64,157 @@ func Test_leafFingerprint(t *testing.T) {
req.Error(err)
})
}
func Test_heartbeatCallback_GenerationChangeRearmsDeadline(t *testing.T) {
hb := func(gen uint64, send, check, closeTimeout time.Duration) xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
Generation: gen,
SendInterval: send,
CheckInterval: check,
CloseUnresponsiveTimeout: closeTimeout,
}
}
// Under send 30s / check 20s a healthy link's last response is legitimately
// up to ~50s old, and the heartbeat sent on this very pulse cannot have been
// answered yet.
const healthyAgeMs = 50_000
now := time.Now().UnixMilli()
t.Run("tightening does not close a healthy link", func(t *testing.T) {
req := require.New(t)
cb := &heartbeatCallback{generation: 1, lastResponse: now - healthyAgeMs}
_, shouldClose := cb.evaluate(now, hb(2, time.Second, 500*time.Millisecond, 10*time.Second))
req.False(shouldClose, "accumulated age under the old intervals must not condemn the link")
req.Equal(now, cb.lastResponse, "the deadline is re-armed to this check")
req.Equal(uint64(2), cb.generation)
})
t.Run("the grace is one window, not immunity", func(t *testing.T) {
req := require.New(t)
cb := &heartbeatCallback{generation: 1, lastResponse: now - healthyAgeMs}
// Above the unhealthy threshold on purpose: a timeout below it is inert
// today, which is a separate defect this test should not depend on.
settings := hb(2, 30*time.Second, 10*time.Second, 90*time.Second)
cb.evaluate(now, settings)
// Still silent a full new timeout later: detection must resume.
later := now + settings.CloseUnresponsiveTimeout.Milliseconds() + 1
unhealthy, shouldClose := cb.evaluate(later, settings)
req.True(unhealthy)
req.True(shouldClose, "a genuinely dead link still closes once the new timeout elapses")
})
t.Run("no generation change, no re-arm", func(t *testing.T) {
req := require.New(t)
cb := &heartbeatCallback{generation: 2, lastResponse: now - 90_000}
unhealthy, shouldClose := cb.evaluate(now, hb(2, 30*time.Second, 20*time.Second, time.Minute))
req.True(unhealthy)
req.True(shouldClose, "an unresponsive link on a steady generation is still closed")
})
}
func Test_heartbeatCallback_HonorsConfiguredTimeout(t *testing.T) {
hb := func(closeTimeout time.Duration) xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
Generation: 1,
SendInterval: 5 * time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: closeTimeout,
}
}
now := time.Now().UnixMilli()
silentFor := func(d time.Duration) *heartbeatCallback {
return &heartbeatCallback{generation: 1, lastResponse: now - d.Milliseconds()}
}
t.Run("a timeout under the unhealthy threshold still closes", func(t *testing.T) {
req := require.New(t)
// The close timeout is evaluated on its own, not behind the 30s unhealthy
// threshold.
_, shouldClose := silentFor(11*time.Second).evaluate(now, hb(10*time.Second))
req.True(shouldClose, "a 10s timeout must close at 11s, not wait for 30s")
})
t.Run("closing always reports unhealthy", func(t *testing.T) {
req := require.New(t)
// Otherwise a short timeout closes the link with no preceding warning
// and without poisoning the latency metric watchers key off.
unhealthy, shouldClose := silentFor(11*time.Second).evaluate(now, hb(10*time.Second))
req.True(shouldClose)
req.True(unhealthy)
})
t.Run("still healthy before the timeout", func(t *testing.T) {
req := require.New(t)
unhealthy, shouldClose := silentFor(9*time.Second).evaluate(now, hb(10*time.Second))
req.False(shouldClose)
req.False(unhealthy)
})
t.Run("a zero timeout closes nothing", func(t *testing.T) {
req := require.New(t)
// Zero means no settings have been published yet, not that the link
// should go immediately. Without the guard, decoupling the thresholds
// turns "unset" into "close on the first check".
_, shouldClose := silentFor(5*time.Minute).evaluate(now, hb(0))
req.False(shouldClose, "an unset timeout must never close a link")
})
t.Run("unhealthy is still reported on a long timeout", func(t *testing.T) {
req := require.New(t)
unhealthy, shouldClose := silentFor(31*time.Second).evaluate(now, hb(60*time.Second))
req.True(unhealthy, "the 30s unhealthy signal survives the decoupling")
req.False(shouldClose)
})
}
func Test_heartbeatCallback_UnhealthyThresholdScalesWithIntervals(t *testing.T) {
hb := func(send, check, closeTimeout time.Duration) xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{Generation: 1, SendInterval: send, CheckInterval: check, CloseUnresponsiveTimeout: closeTimeout}
}
now := time.Now().UnixMilli()
silentFor := func(d time.Duration) *heartbeatCallback {
return &heartbeatCallback{generation: 1, lastResponse: now - d.Milliseconds()}
}
t.Run("a long send interval is not unhealthy between heartbeats", func(t *testing.T) {
req := require.New(t)
// Reporting unhealthy poisons the latency metric the controller routes on, so
// a healthy link must not trip it while waiting for a heartbeat that isn't due.
settings := hb(45*time.Second, time.Second, 2*time.Minute)
unhealthy, _ := silentFor(44*time.Second).evaluate(now, settings)
req.False(unhealthy, "the next heartbeat is not even due yet")
unhealthy, shouldClose := silentFor(93*time.Second).evaluate(now, settings)
req.True(unhealthy, "a whole cycle with no response is a missed response")
req.False(shouldClose)
})
t.Run("the defaults keep the 30s floor", func(t *testing.T) {
req := require.New(t)
settings := hb(10*time.Second, time.Second, time.Minute)
unhealthy, _ := silentFor(29*time.Second).evaluate(now, settings)
req.False(unhealthy)
unhealthy, _ = silentFor(31*time.Second).evaluate(now, settings)
req.True(unhealthy)
})
t.Run("a thin margin still reports unhealthy on close", func(t *testing.T) {
req := require.New(t)
// The threshold (92s) lands past the timeout (90s), leaving no early
// warning, but a close is always reported unhealthy.
unhealthy, shouldClose := silentFor(91*time.Second).evaluate(now, hb(45*time.Second, time.Second, 90*time.Second))
req.True(shouldClose)
req.True(unhealthy)
})
t.Run("doubling a huge gap does not wrap", func(t *testing.T) {
req := require.New(t)
huge := time.Duration(math.MaxInt64 / 2)
req.Equal(time.Duration(math.MaxInt64).Milliseconds(), unhealthyThresholdMs(hb(huge, time.Second, time.Duration(math.MaxInt64))))
})
}
+25 -207
View File
@@ -17,219 +17,37 @@
package link
import (
"encoding/json"
"fmt"
"time"
"github.com/openziti/channel/v5"
"github.com/openziti/ziti/v2/common/config/routerlink"
)
// ConfigBaseType is the un-versioned config family this package owns.
const ConfigBaseType = "router.link"
// ConfigTypeV1 is the controller-defined ConfigType.Name for version 1.
const ConfigTypeV1 = "router.link.v1"
// Config is the typed view of router.link.v1 JSON. Field tags match the
// schema property names. Strings rather than time.Duration are used for
// duration fields so they round-trip cleanly into the existing
// channel.LoadOptions parser, which already understands "30s" / "100ms".
type Config struct {
Listeners []ListenerConfig `json:"listeners,omitempty"`
Dialers []DialerConfig `json:"dialers,omitempty"`
Heartbeats *HeartbeatsConfig `json:"heartbeats,omitempty"`
PayloadSenderQueueSize int `json:"payloadSenderQueueSize,omitempty"`
AckSenderQueueSize int `json:"ackSenderQueueSize,omitempty"`
// GcMode is the auto-GC policy for links this router's own configuration
// has made stale: "preserve" (default, never close on that basis),
// "orphaned" (close links whose supporting listener/dialer is entirely
// gone), or "changed" (close links whose details have shifted). Empty
// string is treated as "preserve".
//
// The policy covers local staleness only. A peer withdrawing or moving a
// listener still closes the affected link at once, under every mode,
// because the peer's advertised listener set is authoritative about what
// it will accept. "preserve" is therefore not a guarantee that links
// survive every config change.
GcMode string `json:"gcMode,omitempty"`
}
// GcMode names the auto-GC policy applied by the router after each
// successful link-config Apply. Mirrors the CLI `--mode` for
// `ziti ops verify stale-links`, plus a `Preserve` value that disables
// local-staleness GC entirely. It governs only links made stale by this
// router's own configuration; peer-driven closures are unaffected.
type GcMode int
// The router.link config definition and validation live in common/config/routerlink
// so the controller applies the same rules without importing the link subsystem.
const (
GcModePreserve GcMode = iota
GcModeOrphaned
GcModeChanged
ConfigBaseType = routerlink.ConfigBaseType
ConfigTypeV1 = routerlink.ConfigTypeV1
)
func (m GcMode) String() string {
switch m {
case GcModeOrphaned:
return "orphaned"
case GcModeChanged:
return "changed"
default:
return "preserve"
}
}
type (
Config = routerlink.Config
ListenerConfig = routerlink.ListenerConfig
DialerConfig = routerlink.DialerConfig
ChannelOptions = routerlink.ChannelOptions
BackoffConfig = routerlink.BackoffConfig
HeartbeatsConfig = routerlink.HeartbeatsConfig
HeartbeatDurations = routerlink.HeartbeatDurations
Groups = routerlink.Groups
GcMode = routerlink.GcMode
)
// ParseGcMode normalizes the string form (as it appears in the JSON
// config or local YAML) into the enum. Unknown values return
// GcModePreserve and an error; the caller decides whether to fall back
// or reject the config.
func ParseGcMode(s string) (GcMode, error) {
switch s {
case "", "preserve":
return GcModePreserve, nil
case "orphaned":
return GcModeOrphaned, nil
case "changed":
return GcModeChanged, nil
default:
return GcModePreserve, fmt.Errorf("unknown gcMode %q (expected preserve|orphaned|changed)", s)
}
}
const (
GcModePreserve = routerlink.GcModePreserve
GcModeOrphaned = routerlink.GcModeOrphaned
GcModeChanged = routerlink.GcModeChanged
)
// ListenerConfig matches the schema's listener entry. Bind is the only
// required field.
type ListenerConfig struct {
Binding string `json:"binding,omitempty"`
Bind string `json:"bind"`
Advertise string `json:"advertise,omitempty"`
BindInterface string `json:"bindInterface,omitempty"`
Groups Groups `json:"groups,omitempty"`
Options *ChannelOptions `json:"options,omitempty"`
}
// ParseConfig unmarshals raw JSON into a Config.
func ParseConfig(data string) (*Config, error) { return routerlink.ParseConfig(data) }
// DialerConfig matches the schema's dialer entry.
//
// Split is carried here for local-config fidelity only. The router's own YAML
// may set the transport's `split` dialer flag (the legacy split-vs-single
// fallback used when dialing a router too old for multi-underlay links);
// dropping it on translation would silently flip an explicit `split: false`
// back to the transport default of true. It is deliberately absent from the
// controller-managed router.link schema, so it cannot be set from the
// controller. A nil pointer means "unset", letting the transport default apply.
type DialerConfig struct {
Binding string `json:"binding,omitempty"`
MaxDefaultConnections int `json:"maxDefaultConnections,omitempty"`
MaxAckConnections *int `json:"maxAckConnections,omitempty"`
Split *bool `json:"split,omitempty"`
StartupDelay string `json:"startupDelay,omitempty"`
BindInterface string `json:"bindInterface,omitempty"`
Groups Groups `json:"groups,omitempty"`
HealthyDialBackoff *BackoffConfig `json:"healthyDialBackoff,omitempty"`
UnhealthyDialBackoff *BackoffConfig `json:"unhealthyDialBackoff,omitempty"`
Options *ChannelOptions `json:"options,omitempty"`
}
// ChannelOptions matches the shared channelOptions definition.
//
// OutQueueSize and MaxQueuedConnects are pointers because zero is a valid
// setting for both — each sizes a buffered channel, and zero means unbuffered.
// A plain int with omitempty would make an explicit 0 indistinguishable from
// unset and silently substitute the default.
type ChannelOptions struct {
OutQueueSize *int `json:"outQueueSize,omitempty"`
MaxQueuedConnects *int `json:"maxQueuedConnects,omitempty"`
MaxOutstandingConnects int `json:"maxOutstandingConnects,omitempty"`
ConnectTimeout string `json:"connectTimeout,omitempty"`
WriteTimeout string `json:"writeTimeout,omitempty"`
}
// BackoffConfig matches the shared backoff definition.
type BackoffConfig struct {
RetryBackoffFactor float64 `json:"retryBackoffFactor,omitempty"`
MinRetryInterval string `json:"minRetryInterval,omitempty"`
MaxRetryInterval string `json:"maxRetryInterval,omitempty"`
}
// HeartbeatsConfig matches the heartbeats definition.
type HeartbeatsConfig struct {
SendInterval string `json:"sendInterval,omitempty"`
CheckInterval string `json:"checkInterval,omitempty"`
CloseUnresponsiveTimeout string `json:"closeUnresponsiveTimeout,omitempty"`
}
// Groups is a list of group names. The schema allows the JSON value to be
// either a single string or an array of strings; UnmarshalJSON normalizes
// both to a slice. MarshalJSON always emits an array for downstream
// consumers that prefer one shape.
type Groups []string
// UnmarshalJSON accepts either a string or an array of strings per schema.
func (g *Groups) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
*g = Groups{single}
return nil
}
var slice []string
if err := json.Unmarshal(data, &slice); err != nil {
return err
}
*g = Groups(slice)
return nil
}
// MarshalJSON always emits the array form for stable downstream parsing.
func (g Groups) MarshalJSON() ([]byte, error) {
if g == nil {
return []byte("null"), nil
}
return json.Marshal([]string(g))
}
// ParseConfig unmarshals raw JSON into a Config. Returns an error if the
// JSON is malformed; does not enforce schema semantics (the schema is
// enforced at the controller).
func ParseConfig(data string) (*Config, error) {
var c Config
if err := json.Unmarshal([]byte(data), &c); err != nil {
return nil, err
}
return &c, nil
}
// Validate checks values the controller JSON schema can't express and that
// would otherwise fail silently downstream. Callers run it before applying a
// config so a bad value fails the apply rather than degrading behavior.
func (c *Config) Validate() error {
for i := range c.Listeners {
if err := validateChannelOptions(c.Listeners[i].Options); err != nil {
return fmt.Errorf("listeners[%d].options: %w", i, err)
}
}
for i := range c.Dialers {
if err := validateChannelOptions(c.Dialers[i].Options); err != nil {
return fmt.Errorf("dialers[%d].options: %w", i, err)
}
}
return nil
}
// validateChannelOptions rejects a connectTimeout that the downstream channel
// can't honor: it must parse as a duration and fall within
// [channel.MinConnectTimeout, channel.MaxConnectTimeout], the range the channel
// loader accepts.
func validateChannelOptions(o *ChannelOptions) error {
if o == nil || o.ConnectTimeout == "" {
return nil
}
d, err := time.ParseDuration(o.ConnectTimeout)
if err != nil {
return fmt.Errorf("invalid connectTimeout %q: %w", o.ConnectTimeout, err)
}
if d < channel.MinConnectTimeout {
return fmt.Errorf("connectTimeout %s is below the minimum of %s", d, channel.MinConnectTimeout)
}
if d > channel.MaxConnectTimeout {
return fmt.Errorf("connectTimeout %s is above the maximum of %s", d, channel.MaxConnectTimeout)
}
return nil
}
// ParseGcMode normalizes the string form of gcMode into the enum.
func ParseGcMode(s string) (GcMode, error) { return routerlink.ParseGcMode(s) }
+6
View File
@@ -46,6 +46,7 @@ type Env interface {
GetLinkDialerPool() goroutines.Pool
GetRateLimiterPool() goroutines.Pool
GetMetricsRegistry() servermetrics.UsageRegistry
GetHeartbeatSettings() xlink.HeartbeatSettings
}
func NewLinkRegistry(routerEnv Env) xlink.Registry {
@@ -289,6 +290,11 @@ func (self *linkRegistryImpl) applyLink(link xlink.Xlink) (xlink.Xlink, bool) {
self.updateLinkStateEstablished(link)
// A link samples its heartbeat settings while binding and only joins the
// registry afterwards, so a change pushed in between missed it. A no-op when the
// link already has the current generation.
link.UpdateHeartbeat(self.env.GetHeartbeatSettings())
log.Info("link registered")
return nil, true
+60 -6
View File
@@ -110,14 +110,25 @@ func (self *testEnv) GetRateLimiterPool() goroutines.Pool {
panic("implement me")
}
func (self *testEnv) GetHeartbeatSettings() xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
Generation: 1,
SendInterval: 10 * time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: time.Minute,
}
}
func (self *testEnv) GetMetricsRegistry() servermetrics.UsageRegistry {
return self.metricsRegistry
}
type testLink struct {
id string
key string
destId string
id string
key string
destId string
hbLock sync.Mutex
hbPushed []xlink.HeartbeatSettings
}
func (self *testLink) GetDestinationType() string {
@@ -164,6 +175,28 @@ func (self *testLink) DestinationId() string {
return self.destId
}
func (self *testLink) SetHeartbeatControl(channel.HeartbeatControl, xlink.HeartbeatSettings) {}
func (self *testLink) HeartbeatSettings() xlink.HeartbeatSettings {
self.hbLock.Lock()
defer self.hbLock.Unlock()
if len(self.hbPushed) == 0 {
return xlink.HeartbeatSettings{}
}
return self.hbPushed[len(self.hbPushed)-1]
}
func (self *testLink) heartbeatPushes() []xlink.HeartbeatSettings {
self.hbLock.Lock()
defer self.hbLock.Unlock()
return append([]xlink.HeartbeatSettings(nil), self.hbPushed...)
}
func (self *testLink) UpdateHeartbeat(settings xlink.HeartbeatSettings) {
self.hbLock.Lock()
defer self.hbLock.Unlock()
self.hbPushed = append(self.hbPushed, settings)
}
func (self *testLink) DestVersion() string {
panic("implement me")
}
@@ -185,7 +218,7 @@ func (self *testLink) CloseOnce(func()) {
}
func (self *testLink) IsClosed() bool {
panic("implement me")
return false
}
func (self *testLink) InspectLink() *inspect.LinkInspectDetail {
@@ -197,11 +230,11 @@ func (self *testLink) GetLinkConnState() *ctrl_pb.LinkConnState {
}
func (self *testLink) IsDialed() bool {
panic("implement me")
return false
}
func (self *testLink) Iteration() uint32 {
panic("implement me")
return 0
}
func (self *testLink) AreFaultsSent() bool {
@@ -804,3 +837,24 @@ func Test_LinkRegistry_GetDestinationListeners_OmitsUnhealthyDestinations(t *tes
return !present
}, 2*time.Second, 25*time.Millisecond, "unhealthy destination should drop out of the snapshot")
}
func Test_LinkRegistry_ReconcilesHeartbeatsOnRegistration(t *testing.T) {
// A link samples its heartbeat settings while binding and only reaches the
// registry afterwards. A change pushed in that window iterates the registry
// and cannot see it, so registration has to reconcile or the link runs the
// previous generation forever while reading the current timeout.
req := require.New(t)
tenv := newTestEnv()
defer close(tenv.closeNotify)
reg := NewLinkRegistry(tenv).(*linkRegistryImpl)
link := &testLink{id: "l1", key: "k1", destId: "dest1"}
existing, applied := reg.DialSucceeded(link)
req.Nil(existing)
req.True(applied)
pushes := link.heartbeatPushes()
req.Len(pushes, 1, "registration must push the current generation")
req.Equal(tenv.GetHeartbeatSettings(), pushes[0])
}
+36 -31
View File
@@ -22,7 +22,9 @@ import (
"math"
"time"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/channel/v5"
"github.com/openziti/ziti/v2/common/config/routerlink"
)
// LocalYamlConfig is the slice of router env config we need to translate
@@ -30,6 +32,8 @@ import (
// from router/env (which would create a circular import via router/link's
// other consumers).
type LocalYamlConfig struct {
// Configured reports that the router's config file has a link section.
Configured bool
Listeners []map[interface{}]interface{}
Dialers []map[interface{}]interface{}
Heartbeats channel.HeartbeatOptions
@@ -40,20 +44,21 @@ type LocalYamlConfig struct {
// ConfigFromLocalYaml converts the YAML-decoded router config slice into a
// typed Config and serializes it to JSON, ready for
// managedconfig.Registry.ApplyLocal. Returns ("", nil) when no listeners
// or dialers are configured locally — caller treats that as "no local
// config; controller may manage."
// managedconfig.Registry.ApplyLocal. Returns ("", nil) when the router config
// has no link section, which leaves the router's links to the controller.
//
// Heartbeats and queue sizes alone don't count as local content: the
// router env loader auto-fills heartbeat defaults regardless of whether
// the YAML has a `link:` section, so treating those as "operator set
// local config" would suppress controller management for every router.
// Operator intent for local-wins requires explicit listeners or dialers.
// A link section of any content is local config, and local config takes
// precedence over the controller's for the whole router.link type, so one that
// sets only heartbeats or queue sizes still leaves the router with no listeners
// or dialers of its own. That is warned about rather than rejected.
func ConfigFromLocalYaml(in LocalYamlConfig) (string, error) {
hasContent := len(in.Listeners) > 0 || len(in.Dialers) > 0
if !hasContent {
if !in.Configured && len(in.Listeners) == 0 && len(in.Dialers) == 0 {
return "", nil
}
if len(in.Listeners) == 0 && len(in.Dialers) == 0 {
pfxlog.Logger().Warn("router config has a link section with no listeners or dialers; it takes precedence " +
"over any controller-managed link config, so this router will have no links")
}
cfg := Config{
PayloadSenderQueueSize: in.PayloadSenderQueueSize,
@@ -214,30 +219,30 @@ func yamlBackoff(m map[interface{}]interface{}, key string) (*BackoffConfig, err
return out, nil
}
// heartbeatsFromYamlOptions converts the channel-package HeartbeatOptions
// (already parsed by the router env loader) into the typed
// HeartbeatsConfig in duration-string form for round-trip through the
// router.link.v1 JSON shape.
// heartbeatsFromYamlOptions converts env-loaded HeartbeatOptions into a
// HeartbeatsConfig. The env loader defaults absent fields, so a zero is explicit
// and is kept as "0s". A closeUnresponsiveTimeout below the floor is raised to it,
// and any other invalid timing is replaced by the defaults, both with a warning,
// since local link config that fails validation stops the router from starting.
// Returns nil when the defaults apply.
func heartbeatsFromYamlOptions(h channel.HeartbeatOptions) *HeartbeatsConfig {
if !heartbeatsConfigured(h) {
closeTimeout := h.CloseUnresponsiveTimeout
if closeTimeout > 0 && closeTimeout < routerlink.MinCloseUnresponsiveTimeout {
pfxlog.Logger().Warnf("link heartbeat closeUnresponsiveTimeout %s in router config is below the %s minimum, using the minimum",
closeTimeout, routerlink.MinCloseUnresponsiveTimeout)
closeTimeout = routerlink.MinCloseUnresponsiveTimeout
}
hb := &HeartbeatsConfig{
SendInterval: h.SendInterval.String(),
CheckInterval: h.CheckInterval.String(),
CloseUnresponsiveTimeout: closeTimeout.String(),
}
if _, err := (&Config{Heartbeats: hb}).EffectiveHeartbeats(); err != nil {
pfxlog.Logger().WithError(err).Warn("link heartbeat timing in router config is invalid, using the defaults")
return nil
}
return &HeartbeatsConfig{
SendInterval: durationString(h.SendInterval),
CheckInterval: durationString(h.CheckInterval),
CloseUnresponsiveTimeout: durationString(h.CloseUnresponsiveTimeout),
}
}
func heartbeatsConfigured(h channel.HeartbeatOptions) bool {
return h.SendInterval != 0 || h.CheckInterval != 0 || h.CloseUnresponsiveTimeout != 0
}
func durationString(d time.Duration) string {
if d == 0 {
return ""
}
return d.String()
return hb
}
// yamlString reads m[key] as string; returns "" if absent.
+14 -8
View File
@@ -20,6 +20,7 @@ import (
"testing"
"time"
"github.com/openziti/channel/v5"
"github.com/openziti/metrics"
"github.com/openziti/sdk-golang/v2/xgress"
"github.com/openziti/ziti/v2/common/inspect"
@@ -40,14 +41,19 @@ type stubXlink struct {
closed bool
}
func (*stubXlink) GetDestinationType() string { return "link" }
func (s *stubXlink) Id() string { return s.id }
func (s *stubXlink) Key() string { return s.linkKey.String() }
func (s *stubXlink) DialAddress() string { return s.dialAddress }
func (s *stubXlink) LinkKey() xlink.LinkKey { return s.linkKey }
func (s *stubXlink) LinkProtocol() string { return s.linkProtocol }
func (s *stubXlink) IsDialed() bool { return s.dialed }
func (s *stubXlink) IsClosed() bool { return s.closed }
func (*stubXlink) GetDestinationType() string { return "link" }
func (*stubXlink) SetHeartbeatControl(channel.HeartbeatControl, xlink.HeartbeatSettings) {}
func (*stubXlink) UpdateHeartbeat(xlink.HeartbeatSettings) {}
func (*stubXlink) HeartbeatSettings() xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{}
}
func (s *stubXlink) Id() string { return s.id }
func (s *stubXlink) Key() string { return s.linkKey.String() }
func (s *stubXlink) DialAddress() string { return s.dialAddress }
func (s *stubXlink) LinkKey() xlink.LinkKey { return s.linkKey }
func (s *stubXlink) LinkProtocol() string { return s.linkProtocol }
func (s *stubXlink) IsDialed() bool { return s.dialed }
func (s *stubXlink) IsClosed() bool { return s.closed }
// Required by the interface but not exercised by these tests.
func (*stubXlink) Iteration() uint32 { return 0 }
+176 -17
View File
@@ -22,11 +22,29 @@ import (
"sync"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/foundation/v2/concurrenz"
"github.com/openziti/identity"
"github.com/openziti/transport/v2"
"github.com/openziti/ziti/v2/common/config/routerlink"
"github.com/openziti/ziti/v2/router/xlink"
)
// Aliases for the heartbeat fallbacks, which routerlink owns as part of the
// config contract.
const (
defaultCloseUnresponsiveTimeout = routerlink.DefaultCloseUnresponsiveTimeout
defaultHeartbeatSendInterval = routerlink.DefaultHeartbeatSendInterval
defaultHeartbeatCheckInterval = routerlink.DefaultHeartbeatCheckInterval
)
// Link sender queue size fallbacks, matching the router env defaults
// (DefaultLinkPayloadSenderQueueSize / DefaultLinkAckSenderQueueSize), used
// when no link config supplies them.
const (
defaultPayloadSenderQueueSize = 128
defaultAckSenderQueueSize = 64
)
// Subsystem owns the router's configurable link surface: the set of
// xlink.Factory implementations registered for each binding name, the
// currently-applied router.link.v1 config, and the listener / dialer
@@ -57,9 +75,84 @@ type Subsystem struct {
listeners []xlink.Listener
dialers []xlink.Dialer
// heartbeats is the heartbeat timing in force, swapped whole on Apply and
// Remove so a reader never pairs one generation's interval with another's
// timeout.
heartbeats concurrenz.AtomicValue[xlink.HeartbeatSettings]
// generation identifies the applied config. Bumped under mu with the listener
// and dialer swap, so a reader holding mu sees a generation and the state it
// describes together.
generation uint64
changeHandler ConfigurationChangeHandler
}
// Heartbeats returns the heartbeat timing currently in force as one snapshot.
// Lock-free. Until a config has been applied it returns the defaults at
// generation zero.
func (self *Subsystem) Heartbeats() xlink.HeartbeatSettings {
settings := self.heartbeats.Load()
if settings.Generation == 0 {
return xlink.HeartbeatSettings{
SendInterval: defaultHeartbeatSendInterval,
CheckInterval: defaultHeartbeatCheckInterval,
CloseUnresponsiveTimeout: defaultCloseUnresponsiveTimeout,
}
}
return settings
}
// applyHeartbeats resolves cfg's heartbeat timing and publishes it as the next
// generation; a nil cfg, on Remove, publishes the defaults. Validate has already
// accepted cfg, so a resolution failure falls back to the defaults.
func (self *Subsystem) applyHeartbeats(cfg *Config, generation uint64) {
timings := HeartbeatDurations{
SendInterval: defaultHeartbeatSendInterval,
CheckInterval: defaultHeartbeatCheckInterval,
CloseUnresponsiveTimeout: defaultCloseUnresponsiveTimeout,
}
if cfg != nil {
if resolved, err := cfg.EffectiveHeartbeats(); err == nil {
timings = resolved
} else {
pfxlog.Logger().WithError(err).
Error("applied link config has unresolvable heartbeat timing; using defaults")
}
}
self.heartbeats.Store(xlink.HeartbeatSettings{
Generation: generation,
SendInterval: timings.SendInterval,
CheckInterval: timings.CheckInterval,
CloseUnresponsiveTimeout: timings.CloseUnresponsiveTimeout,
})
}
// PayloadSenderQueueSize returns the effective link payload sender queue size
// from the applied config, falling back to the default when unset. Read when a
// link channel is built, so a change takes effect on links established after
// it; existing links keep the size they were built with.
func (self *Subsystem) PayloadSenderQueueSize() int {
self.mu.RLock()
defer self.mu.RUnlock()
if self.config != nil && self.config.PayloadSenderQueueSize > 0 {
return self.config.PayloadSenderQueueSize
}
return defaultPayloadSenderQueueSize
}
// AckSenderQueueSize returns the effective link ack sender queue size from the
// applied config, falling back to the default when unset. Like
// PayloadSenderQueueSize, it applies to links established after a change.
func (self *Subsystem) AckSenderQueueSize() int {
self.mu.RLock()
defer self.mu.RUnlock()
if self.config != nil && self.config.AckSenderQueueSize > 0 {
return self.config.AckSenderQueueSize
}
return defaultAckSenderQueueSize
}
// ConfigurationChange describes which parts of the link configuration
// changed during an Apply / Remove. Any flag may be true; consumers
// inspect them to decide what work to do.
@@ -81,6 +174,9 @@ type ConfigurationChange struct {
// to read. A removal uses the policy of the config being removed, which is
// the last one the operator set.
GcMode GcMode
// HeartbeatsChanged is true when any heartbeat field differs from the
// previous state; the handler pushes the new generation to established links.
HeartbeatsChanged bool
}
// ConfigurationChangeHandler is invoked asynchronously after a successful
@@ -199,9 +295,25 @@ func (self *Subsystem) Apply(version int, data string) error {
}
self.config = cfg
self.appliedData = data
self.generation++
generation := self.generation
handler := self.changeHandler
self.mu.Unlock()
// Published whether or not the listener and dialer set moved, since it applies
// to established links too.
self.applyHeartbeats(cfg, generation)
// A thin margin is warned about rather than rejected: the config works until a
// response is lost, and on the local path a rejection stops startup.
if timings, err := cfg.EffectiveHeartbeats(); err == nil && timings.HasThinMargin() {
pfxlog.Logger().
WithField("sendInterval", timings.SendInterval).
WithField("checkInterval", timings.CheckInterval).
WithField("closeUnresponsiveTimeout", timings.CloseUnresponsiveTimeout).
Warn("link heartbeat timeout leaves less than one heartbeat cycle of margin; a single lost response may close a healthy link")
}
if rebuild {
closeListeners(oldListeners)
if err := startListeners(newListeners); err != nil {
@@ -209,7 +321,21 @@ func (self *Subsystem) Apply(version int, data string) error {
}
}
notifyChange(handler, prevConfig, cfg)
change := newConfigurationChange(prevConfig, cfg)
// Logged on every apply, including one that dispatches nothing, so there is
// always a record that the router received the config.
pfxlog.Logger().
WithField("generation", generation).
WithField("listenersChanged", change.ListenersChanged).
WithField("dialersChanged", change.DialersChanged).
WithField("gcModeChanged", change.GcModeChanged).
WithField("heartbeatsChanged", change.HeartbeatsChanged).
WithField("payloadSenderQueueSize", cfg.PayloadSenderQueueSize).
WithField("ackSenderQueueSize", cfg.AckSenderQueueSize).
Info("applied link configuration")
notifyChange(handler, change)
return nil
}
@@ -224,35 +350,58 @@ func (self *Subsystem) Remove() error {
self.dialers = nil
self.config = nil
self.appliedData = ""
self.generation++
generation := self.generation
handler := self.changeHandler
self.mu.Unlock()
// A removed config's heartbeat settings revert to the defaults, published as a
// new generation so established links are re-tuned off the removed values.
self.applyHeartbeats(nil, generation)
closeListeners(oldListeners)
notifyChange(handler, prevConfig, nil)
notifyChange(handler, newConfigurationChange(prevConfig, nil))
return nil
}
// notifyChange invokes handler asynchronously when prev and next differ.
// nil handler is a no-op. Compares listeners and dialers slices by
// deep-equality; identical state is a no-op (avoids spurious peer
// notifications and dialer rescans when an Apply was just re-applying
// the same config).
func notifyChange(handler ConfigurationChangeHandler, prev, next *Config) {
if handler == nil {
return
// newConfigurationChange describes how next differs from prev. Listeners and
// dialers are compared by deep equality. Queue sizes are not represented, since
// they only affect links created later, so a change confined to them reports no
// work.
func newConfigurationChange(prev, next *Config) ConfigurationChange {
return ConfigurationChange{
ListenersChanged: !listenerSlicesEqual(getListeners(prev), getListeners(next)),
DialersChanged: !dialerSlicesEqual(getEffectiveDialers(prev), getEffectiveDialers(next)),
GcModeChanged: getGcMode(prev) != getGcMode(next),
GcMode: effectiveGcMode(next),
HeartbeatsChanged: getHeartbeats(prev) != getHeartbeats(next),
}
change := ConfigurationChange{
ListenersChanged: !listenerSlicesEqual(getListeners(prev), getListeners(next)),
DialersChanged: !dialerSlicesEqual(getEffectiveDialers(prev), getEffectiveDialers(next)),
GcModeChanged: getGcMode(prev) != getGcMode(next),
GcMode: effectiveGcMode(next),
}
if !change.ListenersChanged && !change.DialersChanged && !change.GcModeChanged {
}
// hasWork reports whether the change asks anything of the handler. A change
// that asks nothing is not dispatched.
func (self ConfigurationChange) hasWork() bool {
return self.ListenersChanged || self.DialersChanged || self.GcModeChanged || self.HeartbeatsChanged
}
// notifyChange invokes handler asynchronously when change asks for work. A nil
// handler is a no-op.
func notifyChange(handler ConfigurationChangeHandler, change ConfigurationChange) {
if handler == nil || !change.hasWork() {
return
}
go handler(change)
}
// getHeartbeats returns a comparable value of the config's heartbeat settings
// (zero value when unset), so notifyChange can detect heartbeat changes.
func getHeartbeats(c *Config) HeartbeatsConfig {
if c == nil || c.Heartbeats == nil {
return HeartbeatsConfig{}
}
return *c.Heartbeats
}
func getGcMode(c *Config) string {
if c == nil {
return ""
@@ -358,6 +507,16 @@ func (self *Subsystem) Listeners() []xlink.Listener {
return out
}
// ListenerSnapshot returns the current listener set and the config generation it
// belongs to, read under one lock so the pair is consistent.
func (self *Subsystem) ListenerSnapshot() (uint64, []xlink.Listener) {
self.mu.RLock()
defer self.mu.RUnlock()
out := make([]xlink.Listener, len(self.listeners))
copy(out, self.listeners)
return self.generation, out
}
// Dialers returns a snapshot of the current dialer slice.
func (self *Subsystem) Dialers() []xlink.Dialer {
self.mu.RLock()
+464
View File
@@ -26,6 +26,7 @@ import (
"github.com/openziti/foundation/v2/util"
"github.com/openziti/identity"
"github.com/openziti/transport/v2"
"github.com/openziti/ziti/v2/common/config/routerlink"
"github.com/openziti/ziti/v2/router/xlink"
"github.com/stretchr/testify/require"
@@ -151,6 +152,80 @@ func Test_Subsystem_Apply_BuildsListenersAndDialers(t *testing.T) {
req.Equal("tls:0.0.0.0:6262", f.createdListeners[0].bind)
}
func Test_Subsystem_CloseUnresponsiveTimeout(t *testing.T) {
req := require.New(t)
r, _ := newTestRegistry(t)
// before any apply, falls back to the default
req.Equal(defaultCloseUnresponsiveTimeout, r.Heartbeats().CloseUnresponsiveTimeout)
// an applied config with the field takes effect (for established links too)
req.NoError(r.Apply(1, `{"heartbeats":{"closeUnresponsiveTimeout":"45s"}}`))
req.Equal(45*time.Second, r.Heartbeats().CloseUnresponsiveTimeout)
// the value comes from the active config, so a later apply that omits it
// reverts to the default
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`))
req.Equal(defaultCloseUnresponsiveTimeout, r.Heartbeats().CloseUnresponsiveTimeout)
}
func Test_Subsystem_HeartbeatIntervals(t *testing.T) {
req := require.New(t)
r, _ := newTestRegistry(t)
// before any apply, fall back to the channel defaults
req.Equal(defaultHeartbeatSendInterval, r.Heartbeats().SendInterval)
req.Equal(defaultHeartbeatCheckInterval, r.Heartbeats().CheckInterval)
// applied intervals take effect
req.NoError(r.Apply(1, `{"heartbeats":{"sendInterval":"3s","checkInterval":"250ms"}}`))
req.Equal(3*time.Second, r.Heartbeats().SendInterval)
req.Equal(250*time.Millisecond, r.Heartbeats().CheckInterval)
// the settings come from the active config, so a later apply that omits
// them reverts to the defaults
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`))
req.Equal(defaultHeartbeatSendInterval, r.Heartbeats().SendInterval)
req.Equal(defaultHeartbeatCheckInterval, r.Heartbeats().CheckInterval)
}
func Test_Subsystem_Remove_RevertsHeartbeatsToDefaults(t *testing.T) {
req := require.New(t)
r, _ := newTestRegistry(t)
req.NoError(r.Apply(1, `{"heartbeats":{"sendInterval":"3s","checkInterval":"250ms","closeUnresponsiveTimeout":"45s"}}`))
req.Equal(3*time.Second, r.Heartbeats().SendInterval)
req.Equal(250*time.Millisecond, r.Heartbeats().CheckInterval)
req.Equal(45*time.Second, r.Heartbeats().CloseUnresponsiveTimeout)
// removing the config drops its heartbeat settings, reverting to the
// defaults just as a later apply that omits them would
req.NoError(r.Remove())
req.Equal(defaultHeartbeatSendInterval, r.Heartbeats().SendInterval)
req.Equal(defaultHeartbeatCheckInterval, r.Heartbeats().CheckInterval)
req.Equal(defaultCloseUnresponsiveTimeout, r.Heartbeats().CloseUnresponsiveTimeout)
}
func Test_Subsystem_QueueSizes(t *testing.T) {
req := require.New(t)
r, _ := newTestRegistry(t)
// before any apply, fall back to the defaults
req.Equal(defaultPayloadSenderQueueSize, r.PayloadSenderQueueSize())
req.Equal(defaultAckSenderQueueSize, r.AckSenderQueueSize())
// applied sizes take effect (for links established afterward)
req.NoError(r.Apply(1, `{"payloadSenderQueueSize":256,"ackSenderQueueSize":96}`))
req.Equal(256, r.PayloadSenderQueueSize())
req.Equal(96, r.AckSenderQueueSize())
// the sizes come from the active config, so a later apply that omits them
// reverts to the defaults
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`))
req.Equal(defaultPayloadSenderQueueSize, r.PayloadSenderQueueSize())
req.Equal(defaultAckSenderQueueSize, r.AckSenderQueueSize())
}
func Test_Subsystem_Apply_MapsDialerBindInterfaceToTransportBind(t *testing.T) {
req := require.New(t)
r, f := newTestRegistry(t)
@@ -991,3 +1066,392 @@ func Test_EffectiveGcMode(t *testing.T) {
// policy could apply.
req.Equal(GcModeOrphaned, effectiveGcMode(nil))
}
func Test_Config_Validate_HeartbeatTimings(t *testing.T) {
tests := []struct {
name string
data string
expectErr string
}{
{
name: "no heartbeats section uses defaults",
data: `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`,
},
{
name: "sane explicit timings",
data: `{"heartbeats":{"sendInterval":"10s","checkInterval":"1s","closeUnresponsiveTimeout":"60s"}}`,
},
{
// The finding's case: the check interval outruns the timeout, so the
// pulse sends a heartbeat and condemns the link on the same tick.
name: "check interval past the timeout",
data: `{"heartbeats":{"checkInterval":"2m","closeUnresponsiveTimeout":"30s"}}`,
expectErr: "closeUnresponsiveTimeout",
},
{
name: "timeout equal to send plus check",
data: `{"heartbeats":{"sendInterval":"20s","checkInterval":"10s","closeUnresponsiveTimeout":"30s"}}`,
expectErr: "must be greater than sendInterval + checkInterval",
},
{
// A timeout below the default send interval is broken even though the
// config never names sendInterval. Caught by the floor first.
name: "timeout under the default send interval",
data: `{"heartbeats":{"closeUnresponsiveTimeout":"5s"}}`,
expectErr: "below the 10s minimum",
},
{
// Intervals tight enough that the sum rule would pass: only the
// absolute floor catches it. A timeout this short closes links that
// are merely recovering from ordinary packet loss.
name: "timeout below the floor despite roomy intervals",
data: `{"heartbeats":{"sendInterval":"100ms","checkInterval":"100ms","closeUnresponsiveTimeout":"1s"}}`,
expectErr: "below the 10s minimum",
},
{
name: "timeout exactly at the floor is accepted",
data: `{"heartbeats":{"sendInterval":"5s","checkInterval":"1s","closeUnresponsiveTimeout":"10s"}}`,
},
{
// Durations are int64 nanoseconds and the sum of two positive ones can
// only wrap negative, so the sum rule would compare against a negative
// and accept every timeout.
name: "interval sum overflows",
data: `{"heartbeats":{"sendInterval":"2562047h","checkInterval":"1h","closeUnresponsiveTimeout":"60s"}}`,
expectErr: "exceeds the largest representable duration",
},
{
// Representable, so the overflow guard stands aside and the sum rule
// judges it.
name: "interval sum at the maximum is judged on its merits",
data: `{"heartbeats":{"sendInterval":"2562047h47m16.854775806s","checkInterval":"1ns","closeUnresponsiveTimeout":"60s"}}`,
expectErr: "must be greater than sendInterval + checkInterval",
},
{
name: "zero check interval",
data: `{"heartbeats":{"checkInterval":"0s"}}`,
expectErr: "checkInterval must be positive",
},
{
name: "negative send interval",
data: `{"heartbeats":{"sendInterval":"-5s"}}`,
expectErr: "sendInterval must be positive",
},
{
name: "unparseable duration",
data: `{"heartbeats":{"checkInterval":"soon"}}`,
expectErr: "invalid heartbeats.checkInterval",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := require.New(t)
cfg, err := ParseConfig(tt.data)
req.NoError(err)
err = cfg.Validate()
if tt.expectErr == "" {
req.NoError(err)
return
}
req.Error(err)
req.Contains(err.Error(), tt.expectErr)
})
}
}
func Test_Subsystem_Apply_RejectsUnsafeHeartbeatTimings(t *testing.T) {
// Validation has to run on the apply path, not just as a standalone call,
// and a rejected apply must leave the previous settings in place.
req := require.New(t)
r, _ := newTestRegistry(t)
req.NoError(r.Apply(1, `{"heartbeats":{"sendInterval":"10s","checkInterval":"1s","closeUnresponsiveTimeout":"60s"}}`))
req.Equal(time.Second, r.Heartbeats().CheckInterval)
req.Equal(60*time.Second, r.Heartbeats().CloseUnresponsiveTimeout)
err := r.Apply(1, `{"heartbeats":{"checkInterval":"2m","closeUnresponsiveTimeout":"30s"}}`)
req.Error(err)
req.Contains(err.Error(), "closeUnresponsiveTimeout")
req.Equal(time.Second, r.Heartbeats().CheckInterval, "a rejected apply must not change the live intervals")
req.Equal(60*time.Second, r.Heartbeats().CloseUnresponsiveTimeout)
}
func Test_HeartbeatDurations_HasThinMargin(t *testing.T) {
req := require.New(t)
mk := func(data string) HeartbeatDurations {
cfg, err := ParseConfig(data)
req.NoError(err)
timings, err := cfg.EffectiveHeartbeats()
req.NoError(err)
return timings
}
// Defaults: 60s over a 11s healthy gap, comfortably more than one cycle.
req.False(mk(`{}`).HasThinMargin())
// Valid, but one lost response reaches the timeout.
req.True(mk(`{"heartbeats":{"sendInterval":"30s","checkInterval":"5s","closeUnresponsiveTimeout":"60s"}}`).HasThinMargin())
}
func Test_Subsystem_Heartbeats_GenerationAdvancesPerApply(t *testing.T) {
req := require.New(t)
r, _ := newTestRegistry(t)
// Before any apply: defaults at generation zero, so a link binding now and
// a link binding after the first apply are distinguishable.
initial := r.Heartbeats()
req.Zero(initial.Generation)
req.Equal(defaultHeartbeatSendInterval, initial.SendInterval)
req.Equal(defaultCloseUnresponsiveTimeout, initial.CloseUnresponsiveTimeout)
req.NoError(r.Apply(1, `{"heartbeats":{"sendInterval":"3s","checkInterval":"250ms","closeUnresponsiveTimeout":"45s"}}`))
first := r.Heartbeats()
req.Greater(first.Generation, initial.Generation)
req.Equal(3*time.Second, first.SendInterval)
req.Equal(250*time.Millisecond, first.CheckInterval)
req.Equal(45*time.Second, first.CloseUnresponsiveTimeout)
// A later apply that omits them reverts to defaults, as a new generation.
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`))
second := r.Heartbeats()
req.Greater(second.Generation, first.Generation)
req.Equal(defaultHeartbeatSendInterval, second.SendInterval)
// Remove also publishes a generation, so links are re-tuned off the removed
// values rather than keeping them.
req.NoError(r.Remove())
third := r.Heartbeats()
req.Greater(third.Generation, second.Generation)
req.Equal(defaultCloseUnresponsiveTimeout, third.CloseUnresponsiveTimeout)
}
func Test_Subsystem_Heartbeats_SnapshotIsCoherent(t *testing.T) {
// The point of one atomic swap: a reader can never see one generation's
// interval with another's timeout, however many applies race with it.
req := require.New(t)
r, _ := newTestRegistry(t)
configs := []string{
`{"heartbeats":{"sendInterval":"1s","checkInterval":"100ms","closeUnresponsiveTimeout":"10s"}}`,
`{"heartbeats":{"sendInterval":"20s","checkInterval":"5s","closeUnresponsiveTimeout":"5m"}}`,
}
valid := map[xlink.HeartbeatSettings]bool{}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 200; i++ {
_ = r.Apply(1, configs[i%len(configs)])
}
}()
for i := 0; i < 2000; i++ {
s := r.Heartbeats()
// Every observed snapshot must be one of the coherent combinations,
// never a mix.
coherent := (s.SendInterval == time.Second && s.CheckInterval == 100*time.Millisecond && s.CloseUnresponsiveTimeout == 10*time.Second) ||
(s.SendInterval == 20*time.Second && s.CheckInterval == 5*time.Second && s.CloseUnresponsiveTimeout == 5*time.Minute) ||
(s.SendInterval == defaultHeartbeatSendInterval && s.CheckInterval == defaultHeartbeatCheckInterval && s.CloseUnresponsiveTimeout == defaultCloseUnresponsiveTimeout)
req.True(coherent, "observed a mixed snapshot: %+v", s)
valid[s] = true
}
<-done
req.NotEmpty(valid)
}
func Test_Subsystem_ListenerSnapshot_PairsGenerationWithItsListeners(t *testing.T) {
// Sampling the generation and the listeners separately would let a publisher
// stamp one generation's listeners with another's number, which a receiver
// discriminating on generation would then accept as the newest state.
req := require.New(t)
r, _ := newTestRegistry(t)
gen0, listeners0 := r.ListenerSnapshot()
req.Zero(gen0)
req.Empty(listeners0)
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`))
gen1, listeners1 := r.ListenerSnapshot()
req.Greater(gen1, gen0)
req.Len(listeners1, 1)
req.NoError(r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6263"},{"bind":"tls:0.0.0.0:6264"}]}`))
gen2, listeners2 := r.ListenerSnapshot()
req.Greater(gen2, gen1)
req.Len(listeners2, 2)
// Under concurrent applies, every snapshot must be a real pairing: the
// two-listener set never appears stamped with the one-listener generation.
seen := map[uint64]int{}
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 200; i++ {
if i%2 == 0 {
_ = r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6262"}]}`)
} else {
_ = r.Apply(1, `{"listeners":[{"bind":"tls:0.0.0.0:6263"},{"bind":"tls:0.0.0.0:6264"}]}`)
}
}
}()
for i := 0; i < 2000; i++ {
gen, listeners := r.ListenerSnapshot()
if prev, found := seen[gen]; found {
req.Equal(prev, len(listeners),
"generation %d reported %d listeners and then %d", gen, prev, len(listeners))
}
seen[gen] = len(listeners)
}
<-done
req.NotEmpty(seen)
}
func Test_LocalYaml_ClampsTimeoutBelowFloor(t *testing.T) {
// Local link config is applied synchronously and a failure stops the router
// from starting, so a below-floor value from router YAML is raised to the
// floor rather than rejected. The managed path rejects it instead, where the
// operator sees the error on the API call and nothing is already running.
req := require.New(t)
hb := heartbeatsFromYamlOptions(channel.HeartbeatOptions{
SendInterval: time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: 5 * time.Second,
})
req.NotNil(hb)
req.Equal(routerlink.MinCloseUnresponsiveTimeout.String(), hb.CloseUnresponsiveTimeout)
// And the clamped result must survive the validation it was clamped to pass.
cfg := &Config{Heartbeats: hb}
req.NoError(cfg.Validate())
// A value at or above the floor is passed through untouched.
hb = heartbeatsFromYamlOptions(channel.HeartbeatOptions{
SendInterval: 10 * time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: 45 * time.Second,
})
req.NotNil(hb)
req.Equal("45s", hb.CloseUnresponsiveTimeout)
}
func Test_LocalYaml_InvalidTimingFallsBackToDefaults(t *testing.T) {
// The env loader fills absent fields with non-zero defaults, so every input
// here is timing an operator wrote. Local link config failing validation
// stops the router from starting, and the send and check intervals were once
// ignored for links entirely, so timing that was never in force must not
// start blocking boot on upgrade: it falls back to the defaults instead.
cases := []struct {
name string
in channel.HeartbeatOptions
}{
{
// Kept as "0s" so validation sees it, as the controller does, rather than
// read as an absent field and quietly defaulted.
name: "explicit zero check interval",
in: channel.HeartbeatOptions{SendInterval: 10 * time.Second, CheckInterval: 0, CloseUnresponsiveTimeout: time.Minute},
},
{
name: "every field explicitly zero",
in: channel.HeartbeatOptions{},
},
{
// Ignored before these settings reached links, and so harmless then.
name: "check interval past the timeout",
in: channel.HeartbeatOptions{SendInterval: 10 * time.Second, CheckInterval: 2 * time.Minute, CloseUnresponsiveTimeout: 30 * time.Second},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := require.New(t)
req.Nil(heartbeatsFromYamlOptions(c.in), "invalid local timing must yield the defaults")
// And the router must still start: the translated config validates.
js, err := ConfigFromLocalYaml(LocalYamlConfig{
Listeners: []map[interface{}]interface{}{{"binding": "transport", "bind": "tls:0.0.0.0:6262"}},
Heartbeats: c.in,
})
req.NoError(err)
cfg, err := ParseConfig(js)
req.NoError(err)
req.NoError(cfg.Validate())
timings, err := cfg.EffectiveHeartbeats()
req.NoError(err)
req.Equal(routerlink.DefaultCloseUnresponsiveTimeout, timings.CloseUnresponsiveTimeout)
req.Equal(routerlink.DefaultHeartbeatCheckInterval, timings.CheckInterval)
})
}
}
func Test_LocalYaml_ValidTimingPassesThrough(t *testing.T) {
req := require.New(t)
hb := heartbeatsFromYamlOptions(channel.HeartbeatOptions{
SendInterval: 5 * time.Second,
CheckInterval: 500 * time.Millisecond,
CloseUnresponsiveTimeout: 20 * time.Second,
})
req.NotNil(hb)
req.Equal(&HeartbeatsConfig{SendInterval: "5s", CheckInterval: "500ms", CloseUnresponsiveTimeout: "20s"}, hb)
}
func Test_Subsystem_QueueSizeOnlyChange_AppliesWithoutDispatching(t *testing.T) {
// Queue sizes are read when a link is created, so a change to them reaches
// new links only. There is nothing to do for established links and nothing
// about them has become stale, so the change must not reach the handler:
// that would run a GC pass and, under a non-preserve gcMode, close healthy
// links to resize a buffer.
//
// This is emergent from which flags hasWork consults, so it is pinned here
// rather than left for someone adding a flag to rediscover.
req := require.New(t)
r, _ := newTestRegistry(t)
const listener = `{"bind":"tls:0.0.0.0:6262"}`
req.NoError(r.Apply(1, fmt.Sprintf(`{"listeners":[%s],"gcMode":"changed","payloadSenderQueueSize":128}`, listener)))
rec := newChangeRecorder()
r.SetConfigurationChangeHandler(rec.handle)
req.NoError(r.Apply(1, fmt.Sprintf(`{"listeners":[%s],"gcMode":"changed","payloadSenderQueueSize":512,"ackSenderQueueSize":256}`, listener)))
req.False(rec.waitForChange(250*time.Millisecond),
"a queue-size-only change must not dispatch, or a tuning edit would close healthy links")
// Applied all the same: the next link built picks the new sizes up.
req.Equal(512, r.PayloadSenderQueueSize())
req.Equal(256, r.AckSenderQueueSize())
req.Equal(512, r.GetConfig().PayloadSenderQueueSize)
}
func Test_ConfigFromLocalYaml_LinkSectionWithoutSurfaceIsLocalConfig(t *testing.T) {
// A link section of any content takes precedence over the controller's
// router.link config, including one that only tunes heartbeats, so it is applied
// as local config rather than dropped.
req := require.New(t)
js, err := ConfigFromLocalYaml(LocalYamlConfig{
Configured: true,
Heartbeats: channel.HeartbeatOptions{
SendInterval: 5 * time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: 45 * time.Second,
},
PayloadSenderQueueSize: 256,
AckSenderQueueSize: 64,
})
req.NoError(err)
req.NotEmpty(js, "a link section must count as local config even with no listeners or dialers")
cfg, err := ParseConfig(js)
req.NoError(err)
req.NoError(cfg.Validate())
req.Empty(cfg.Listeners)
req.Empty(cfg.Dialers)
req.Equal("45s", cfg.Heartbeats.CloseUnresponsiveTimeout)
req.Equal(256, cfg.PayloadSenderQueueSize)
}
+39 -11
View File
@@ -156,6 +156,12 @@ func (self *Router) GetXlinkDialers() []xlink.Dialer {
return self.linkSubsystem.Dialers()
}
// GetHeartbeatSettings returns the heartbeat generation currently in force,
// for reconciling a link that registered while a change was being pushed.
func (self *Router) GetHeartbeatSettings() xlink.HeartbeatSettings {
return self.linkSubsystem.Heartbeats()
}
func (self *Router) GetXlinkListeners() []xlink.Listener {
return self.linkSubsystem.Listeners()
}
@@ -181,11 +187,11 @@ func (self *Router) GetMetricsRegistry() servermetrics.UsageRegistry {
}
func (self *Router) GetLinkPayloadSenderQueueSize() int {
return self.config.Link.PayloadSenderQueueSize
return self.linkSubsystem.PayloadSenderQueueSize()
}
func (self *Router) GetLinkAckSenderQueueSize() int {
return self.config.Link.AckSenderQueueSize
return self.linkSubsystem.AckSenderQueueSize()
}
func (self *Router) GetXgressRegistry() *env.Registry {
@@ -244,6 +250,20 @@ func (self *Router) GetForwarder() env.Forwarder {
return self.forwarder
}
// GetForwarderImpl returns the concrete forwarder. It exists alongside
// GetForwarder (which returns the env.Forwarder interface to avoid an
// env->forwarder import cycle) so callers needing the concrete type — like the
// link bind handler — can get it without a separate adapter.
func (self *Router) GetForwarderImpl() *forwarder.Forwarder {
return self.forwarder
}
// GetLinkSettings returns the live, managed-config-driven link settings (backed
// by the link subsystem) for the link bind handler.
func (self *Router) GetLinkSettings() handler_link.LinkSettings {
return self.linkSubsystem
}
func (self *Router) GetXgressMetrics() env.XgressMetrics {
return self.xgMetrics
}
@@ -752,13 +772,7 @@ func (self *Router) NotifyCertsUpdated() {
func (self *Router) registerComponents() error {
acceptor := newXlinkAccepter(self.forwarder)
xlinkChAccepter := handler_link.NewBindHandlerFactory(
self.ctrls,
self.forwarder,
&self.config.Link.Heartbeats,
self.metricsRegistry,
self.xlinkRegistry,
)
xlinkChAccepter := handler_link.NewBindHandlerFactory(self)
linkTransportConfig := map[interface{}]interface{}{}
for k, v := range self.config.Transport {
@@ -852,8 +866,12 @@ func (self *Router) registerPlugins() error {
// header packing and the post-Apply publish path so both wire shapes
// stay aligned.
func (self *Router) currentLinkListeners() *ctrl_pb.Listeners {
listeners := &ctrl_pb.Listeners{}
for _, listener := range self.linkSubsystem.Listeners() {
// Generation and listeners come from one read: an update carries the
// generation of the set it actually contains, so a controller comparing
// generations can drop a superseded one.
generation, current := self.linkSubsystem.ListenerSnapshot()
listeners := &ctrl_pb.Listeners{Generation: generation}
for _, listener := range current {
listeners.Listeners = append(listeners.Listeners, &ctrl_pb.Listener{
Address: listener.GetAdvertisement(),
Protocol: listener.GetLinkProtocol(),
@@ -883,6 +901,15 @@ func (self *Router) onLinkSubsystemChanged(change link.ConfigurationChange) {
if change.DialersChanged {
self.xlinkRegistry.RescanForDialOpportunities()
}
if change.HeartbeatsChanged {
// Push the whole generation, so intervals and the timeout they pair with
// arrive together. Links ignore generations they have already passed, and new
// links take the current one at bind time.
settings := self.linkSubsystem.Heartbeats()
for xl := range self.xlinkRegistry.Iter() {
xl.UpdateHeartbeat(settings)
}
}
link.RunStaleLinkGc(self, change.GcMode)
}
@@ -917,6 +944,7 @@ func (self *Router) publishLinkListeners() {
// bind address) surfaces as a startup error rather than just a log line.
func (self *Router) applyLocalLinkConfig() error {
js, err := link.ConfigFromLocalYaml(link.LocalYamlConfig{
Configured: self.config.Link.Configured,
Listeners: self.config.Link.Listeners,
Dialers: self.config.Link.Dialers,
Heartbeats: self.config.Link.Heartbeats,
+8
View File
@@ -18,6 +18,7 @@ import (
metrics2 "github.com/openziti/ziti/v2/router/metrics"
"github.com/openziti/ziti/v2/router/state"
"github.com/openziti/ziti/v2/router/xgress_router"
"github.com/openziti/ziti/v2/router/xlink"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -79,6 +80,13 @@ func (link *mirrorLink) DestinationId() string {
return "test"
}
func (link *mirrorLink) SetHeartbeatControl(channel.HeartbeatControl, xlink.HeartbeatSettings) {}
func (link *mirrorLink) UpdateHeartbeat(xlink.HeartbeatSettings) {}
func (link *mirrorLink) HeartbeatSettings() xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{}
}
func (link *mirrorLink) Id() string {
return "router1"
}
+24
View File
@@ -82,6 +82,17 @@ type Registry interface {
RescanForDialOpportunities()
}
// HeartbeatSettings is the heartbeat timing a link runs with, as one immutable
// snapshot. Generation increases with each applied link config. The values
// travel together because they are only valid as a set: the timeout must exceed
// the gap a responsive link shows, roughly sendInterval + checkInterval.
type HeartbeatSettings struct {
Generation uint64
SendInterval time.Duration
CheckInterval time.Duration
CloseUnresponsiveTimeout time.Duration
}
type Forwarder interface {
ForwardPayload(srcAddr xgress.Address, payload *xgress.Payload, timeout time.Duration) error
ForwardAcknowledgement(srcAddr xgress.Address, acknowledgement *xgress.Acknowledgement) error
@@ -164,6 +175,19 @@ type Xlink interface {
LinkKey() LinkKey
CloseOnce(f func())
IsClosed() bool
// SetHeartbeatControl registers one channel's heartbeat control handle and the
// settings that channel was configured from. Call once per channel backing the
// link (a split link has two); handles accumulate.
SetHeartbeatControl(hc channel.HeartbeatControl, settings HeartbeatSettings)
// UpdateHeartbeat applies settings to every channel backing the link,
// retuning intervals and publishing the timeout its callbacks read, so a
// managed-config change reaches an established link without rebuilding it.
// Settings at or below the generation already applied are ignored.
UpdateHeartbeat(settings HeartbeatSettings)
// HeartbeatSettings returns the settings currently in force for this link.
// Read by each channel's heartbeat callback, so the timeout it enforces is
// always the one belonging to the intervals its ticker is running.
HeartbeatSettings() HeartbeatSettings
InspectLink() *inspect.LinkInspectDetail
GetLinkConnState() *ctrl_pb.LinkConnState
IsDialed() bool
@@ -0,0 +1,287 @@
/*
Copyright NetFoundry Inc.
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
https://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 xlink_transport
import (
"sync"
"testing"
"time"
"github.com/openziti/ziti/v2/router/xlink"
"github.com/stretchr/testify/require"
)
// settings builds a generation with intervals whose sum stays under the
// timeout, the relationship the real config validation enforces.
func settings(gen uint64, send, check, closeTimeout time.Duration) xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
Generation: gen,
SendInterval: send,
CheckInterval: check,
CloseUnresponsiveTimeout: closeTimeout,
}
}
type recordingHeartbeatControl struct {
send time.Duration
check time.Duration
calls int
}
func (self *recordingHeartbeatControl) UpdateIntervals(send, check time.Duration) {
self.send = send
self.check = check
self.calls++
}
func Test_heartbeatControl_UpdatesCurrent(t *testing.T) {
req := require.New(t)
var hc heartbeatControl
a := &recordingHeartbeatControl{}
hc.SetHeartbeatControl(a, settings(1, 10*time.Second, time.Second, time.Minute))
next := settings(2, 2*time.Second, 200*time.Millisecond, 30*time.Second)
hc.UpdateHeartbeat(next)
req.Equal(1, a.calls)
req.Equal(2*time.Second, a.send)
req.Equal(200*time.Millisecond, a.check)
req.Equal(next, hc.HeartbeatSettings(), "the link adopts the pushed generation")
}
// A split link binds a control per channel, payload and ack. Both must be
// re-tuned: every channel's callback reads the close-unresponsive timeout live,
// so one left at an older, longer interval can close a healthy link when the
// timeout shortens.
func Test_heartbeatControl_RetunesEveryRegisteredControl(t *testing.T) {
req := require.New(t)
var hc heartbeatControl
payload := &recordingHeartbeatControl{}
ack := &recordingHeartbeatControl{}
base := settings(1, 10*time.Second, time.Second, time.Minute)
hc.SetHeartbeatControl(payload, base)
hc.SetHeartbeatControl(ack, base)
hc.UpdateHeartbeat(settings(2, 3*time.Second, 300*time.Millisecond, 30*time.Second))
for name, ctrl := range map[string]*recordingHeartbeatControl{"payload": payload, "ack": ack} {
req.Equal(1, ctrl.calls, "%s channel must be re-tuned", name)
req.Equal(3*time.Second, ctrl.send, "%s channel", name)
req.Equal(300*time.Millisecond, ctrl.check, "%s channel", name)
}
// A second retune reaches both again, rather than only the newest.
hc.UpdateHeartbeat(settings(3, time.Second, time.Second, 10*time.Second))
req.Equal(2, payload.calls)
req.Equal(2, ack.calls)
}
func Test_heartbeatControl_IgnoresNilControl(t *testing.T) {
req := require.New(t)
var hc heartbeatControl
hc.SetHeartbeatControl(nil, settings(1, 10*time.Second, time.Second, time.Minute))
a := &recordingHeartbeatControl{}
hc.SetHeartbeatControl(a, settings(1, 10*time.Second, time.Second, time.Minute))
// A nil handle must not be stored, or the retune loop would panic on it.
hc.UpdateHeartbeat(settings(2, time.Second, time.Second, 10*time.Second))
req.Equal(1, a.calls)
}
func Test_heartbeatControl_NoControlIsNoop(t *testing.T) {
var hc heartbeatControl
// must not panic with nothing registered
hc.UpdateHeartbeat(settings(1, time.Second, time.Second, 10*time.Second))
}
func Test_heartbeatControl_IgnoresStaleGeneration(t *testing.T) {
// Change notifications run on independent goroutines, so an older one can
// land after a newer. It must not roll the link back onto older settings.
req := require.New(t)
var hc heartbeatControl
a := &recordingHeartbeatControl{}
hc.SetHeartbeatControl(a, settings(1, 10*time.Second, time.Second, time.Minute))
newer := settings(5, time.Second, 100*time.Millisecond, 10*time.Second)
hc.UpdateHeartbeat(newer)
req.Equal(newer, hc.HeartbeatSettings())
older := settings(4, 30*time.Second, 20*time.Second, 5*time.Minute)
hc.UpdateHeartbeat(older)
req.Equal(newer, hc.HeartbeatSettings(), "an overtaken update must not be applied")
req.Equal(1, a.calls, "and must not retune the ticker either")
// The same generation is also a no-op, which is what makes the
// post-registration reconcile free in the common case.
hc.UpdateHeartbeat(newer)
req.Equal(1, a.calls)
}
// gatedControl records the generation implied by the intervals it was re-tuned
// to, and holds the first caller inside UpdateIntervals until released. That is
// how a second update gets the chance to overtake one already dispatching.
type gatedControl struct {
lock sync.Mutex
recorded []uint64
entered chan struct{}
gate chan struct{}
seen bool
}
func newGatedControl() *gatedControl {
return &gatedControl{
entered: make(chan struct{}),
gate: make(chan struct{}),
}
}
func (self *gatedControl) UpdateIntervals(send, _ time.Duration) {
self.lock.Lock()
first := !self.seen
self.seen = true
self.lock.Unlock()
if first {
close(self.entered)
<-self.gate
}
self.lock.Lock()
defer self.lock.Unlock()
self.recorded = append(self.recorded, uint64(send/time.Millisecond))
}
func (self *gatedControl) lastRecorded() uint64 {
self.lock.Lock()
defer self.lock.Unlock()
if len(self.recorded) == 0 {
return 0
}
return self.recorded[len(self.recorded)-1]
}
// generationSettings encodes the generation into the send interval so a control
// can report which generation its ticker was last re-tuned to.
func generationSettings(gen uint64) xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
Generation: gen,
SendInterval: time.Duration(gen) * time.Millisecond,
CheckInterval: time.Millisecond,
CloseUnresponsiveTimeout: 5 * time.Minute,
}
}
func Test_heartbeatControl_OvertakenUpdateCannotDragControlsBack(t *testing.T) {
// Config changes are notified on independent goroutines, so two updates can
// be in flight at once. Whichever generation the callbacks end up reporting
// must be the one the tickers were last re-tuned to: an update that publishes
// its generation and dispatches separately can be overtaken in between and
// then retune the controls back to its own intervals.
req := require.New(t)
var hc heartbeatControl
ctrl := newGatedControl()
hc.SetHeartbeatControl(ctrl, generationSettings(1))
secondDone := make(chan struct{})
go func() {
defer close(secondDone)
hc.UpdateHeartbeat(generationSettings(2))
}()
<-ctrl.entered // generation 2 is now inside the dispatch
thirdDone := make(chan struct{})
go func() {
defer close(thirdDone)
hc.UpdateHeartbeat(generationSettings(3))
}()
// Give generation 3 the chance to overtake. With the dispatch serialized it
// cannot, which is the property under test; without that it runs to
// completion here and generation 2 then overwrites its intervals.
select {
case <-thirdDone:
case <-time.After(50 * time.Millisecond):
}
close(ctrl.gate)
<-secondDone
<-thirdDone
reported := hc.HeartbeatSettings().Generation
req.Equal(reported, ctrl.lastRecorded(),
"callbacks report generation %d while the ticker was last re-tuned to %d",
reported, ctrl.lastRecorded())
req.Equal(uint64(3), reported, "the newest generation must win")
}
func Test_heartbeatControl_FirstRegistrationAdoptsGenerationZero(t *testing.T) {
// A router with no managed link config hands out the real defaults carrying
// generation 0. Treating that as "nothing registered" leaves the link on
// zero-valued settings, and a zero timeout condemns the first late
// heartbeat instead of waiting out the configured minute.
req := require.New(t)
var hc heartbeatControl
defaults := settings(0, 10*time.Second, time.Second, time.Minute)
hc.SetHeartbeatControl(&recordingHeartbeatControl{}, defaults)
req.Equal(defaults, hc.HeartbeatSettings())
}
func Test_heartbeatControl_LaterChannelReconcilesGenerations(t *testing.T) {
// A split link binds its payload and ack channels one at a time, so config
// can move in between and the two arrive carrying different generations.
// Whichever order that happens in, the link must end up holding the newer
// generation with every channel tuned to it.
older := settings(1, 30*time.Second, 5*time.Second, 2*time.Minute)
newer := settings(2, 2*time.Second, 500*time.Millisecond, 30*time.Second)
t.Run("second channel is newer", func(t *testing.T) {
req := require.New(t)
var hc heartbeatControl
first, second := &recordingHeartbeatControl{}, &recordingHeartbeatControl{}
hc.SetHeartbeatControl(first, older)
hc.SetHeartbeatControl(second, newer)
req.Equal(newer, hc.HeartbeatSettings())
req.Equal(newer.SendInterval, first.send, "the already-bound channel must be brought forward")
req.Equal(newer.CheckInterval, first.check)
// An update at the adopted generation is a no-op, so registration is the only
// chance to bring the first channel forward.
hc.UpdateHeartbeat(newer)
req.Equal(newer.SendInterval, first.send)
})
t.Run("second channel is older", func(t *testing.T) {
req := require.New(t)
var hc heartbeatControl
first, second := &recordingHeartbeatControl{}, &recordingHeartbeatControl{}
hc.SetHeartbeatControl(first, newer)
hc.SetHeartbeatControl(second, older)
req.Equal(newer, hc.HeartbeatSettings(), "an older registration must not roll the link back")
req.Equal(newer.SendInterval, second.send, "the late channel must be brought up to current")
req.Equal(newer.CheckInterval, second.check)
req.Equal(0, first.calls, "and the channel already on that generation is left alone")
})
}
+76
View File
@@ -17,6 +17,7 @@
package xlink_transport
import (
"sync"
"sync/atomic"
"time"
@@ -28,6 +29,80 @@ import (
"github.com/openziti/ziti/v2/router/xlink"
)
// heartbeatControl holds a link's channel heartbeat handles and the settings
// generation they run. Embedded into the link impls to satisfy xlink.Xlink's
// SetHeartbeatControl / UpdateHeartbeat. A link has one control per channel: one
// for a single or multi-underlay link, two for a split link.
type heartbeatControl struct {
lock sync.Mutex
controls []channel.HeartbeatControl
settings xlink.HeartbeatSettings
// haveSettings distinguishes "nothing registered yet" from settings at
// generation 0, which is what a router with no managed link config publishes.
haveSettings bool
}
// SetHeartbeatControl registers one channel's heartbeat handle and the settings
// it was configured from. Calls accumulate, one per channel. Registration
// reconciles: the link keeps the newest generation any channel was configured
// from, and every channel is re-tuned to it.
func (self *heartbeatControl) SetHeartbeatControl(hc channel.HeartbeatControl, settings xlink.HeartbeatSettings) {
if hc == nil {
return
}
self.lock.Lock()
defer self.lock.Unlock()
self.controls = append(self.controls, hc)
if !self.haveSettings {
// Nothing to reconcile against, and hc is already tuned to these.
self.settings = settings
self.haveSettings = true
return
}
if settings.Generation > self.settings.Generation {
self.applyLocked(settings)
return
}
if settings.Generation < self.settings.Generation {
hc.UpdateIntervals(self.settings.SendInterval, self.settings.CheckInterval)
}
}
// HeartbeatSettings implements xlink.Xlink.
func (self *heartbeatControl) HeartbeatSettings() xlink.HeartbeatSettings {
self.lock.Lock()
defer self.lock.Unlock()
return self.settings
}
// UpdateHeartbeat implements xlink.Xlink. A generation at or below the one
// applied is ignored, so a late or duplicate update is harmless. Runs under the
// lock, so a generation and its intervals are applied as one step; UpdateIntervals
// never blocks, so holding the lock is cheap.
func (self *heartbeatControl) UpdateHeartbeat(next xlink.HeartbeatSettings) {
self.lock.Lock()
defer self.lock.Unlock()
if next.Generation <= self.settings.Generation {
return
}
self.applyLocked(next)
}
// applyLocked publishes next and re-tunes every registered control. Caller holds
// the lock. The order of the two steps doesn't matter: a retune lands on the
// channel's next pulse regardless, and the callback re-arms its deadline when the
// generation changes.
func (self *heartbeatControl) applyLocked(next xlink.HeartbeatSettings) {
self.settings = next
self.haveSettings = true
for _, hc := range self.controls {
hc.UpdateIntervals(next.SendInterval, next.CheckInterval)
}
}
type impl struct {
id string
ch LinkChannel
@@ -45,6 +120,7 @@ type impl struct {
dialed bool
iteration uint32
dupsRejected uint32
heartbeatControl
droppedMsgMeter metrics.Meter
droppedXgMsgMeter metrics.Meter
+1
View File
@@ -50,6 +50,7 @@ type splitImpl struct {
iteration uint32
dupsRejected uint32
lock sync.Mutex
heartbeatControl
droppedMsgMeter metrics.Meter
droppedXgMsgMeter metrics.Meter
+10
View File
@@ -87,6 +87,16 @@ func (self *testRegistryEnv) GetMetricsRegistry() servermetrics.UsageRegistry {
return self.metricsRegistry
}
// GetHeartbeatSettings mirrors what a router reports before any link config has
// been applied: the defaults, at generation zero.
func (self *testRegistryEnv) GetHeartbeatSettings() xlink.HeartbeatSettings {
return xlink.HeartbeatSettings{
SendInterval: 10 * time.Second,
CheckInterval: time.Second,
CloseUnresponsiveTimeout: time.Minute,
}
}
type testDial struct {
Key string
LinkId string
@@ -0,0 +1,96 @@
//go:build apitests
/*
Copyright NetFoundry Inc.
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
https://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 tests
import (
"testing"
"github.com/openziti/foundation/v2/errorz"
"github.com/openziti/ziti/v2/common/config/routerlink"
)
// Semantic validation of router.link.v1 has to happen where the operator wrote
// the config. A JSON schema can't express a relationship between two fields, so
// without this the controller accepts a config that every router then rejects,
// and the only sign is a line in each router's log.
func Test_RouterLinkConfigValidation(t *testing.T) {
ctx := NewTestContext(t)
defer ctx.Teardown()
ctx.StartServer()
ctx.RequireAdminManagementApiLogin()
heartbeats := func(fields map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"heartbeats": fields}
}
t.Run("accepts sane heartbeat timing", func(t *testing.T) {
ctx.testContextChanged(t)
config := ctx.newConfig(routerlink.ConfigTypeV1, heartbeats(map[string]interface{}{
"sendInterval": "10s",
"checkInterval": "1s",
"closeUnresponsiveTimeout": "60s",
}))
config.Id = ctx.AdminManagementSession.requireCreateEntity(config)
ctx.Req.NotEmpty(config.Id)
})
t.Run("rejects a check interval past the timeout", func(t *testing.T) {
ctx.testContextChanged(t)
config := ctx.newConfig(routerlink.ConfigTypeV1, heartbeats(map[string]interface{}{
"checkInterval": "2m",
"closeUnresponsiveTimeout": "30s",
}))
resp := ctx.AdminManagementSession.createEntity(config)
ctx.requireFieldError(resp.StatusCode(), resp.Body(), errorz.CouldNotValidateCode, "data")
})
t.Run("rejects a non-positive interval", func(t *testing.T) {
ctx.testContextChanged(t)
config := ctx.newConfig(routerlink.ConfigTypeV1, heartbeats(map[string]interface{}{
"checkInterval": "0s",
}))
resp := ctx.AdminManagementSession.createEntity(config)
ctx.requireFieldError(resp.StatusCode(), resp.Body(), errorz.CouldNotValidateCode, "data")
})
t.Run("rejects a timeout under the default send interval", func(t *testing.T) {
// The config names no intervals, so validation has to resolve the
// defaults to see that this combination is broken.
ctx.testContextChanged(t)
config := ctx.newConfig(routerlink.ConfigTypeV1, heartbeats(map[string]interface{}{
"closeUnresponsiveTimeout": "5s",
}))
resp := ctx.AdminManagementSession.createEntity(config)
ctx.requireFieldError(resp.StatusCode(), resp.Body(), errorz.CouldNotValidateCode, "data")
})
t.Run("rejects an unsafe update to a valid config", func(t *testing.T) {
ctx.testContextChanged(t)
config := ctx.newConfig(routerlink.ConfigTypeV1, heartbeats(map[string]interface{}{
"closeUnresponsiveTimeout": "60s",
}))
config.Id = ctx.AdminManagementSession.requireCreateEntity(config)
// Validation runs on update as well as create, so a config can't be
// walked into an unsafe state after the fact.
config.Data = heartbeats(map[string]interface{}{"closeUnresponsiveTimeout": "1s"})
resp := ctx.AdminManagementSession.updateEntity(config)
ctx.requireFieldError(resp.StatusCode(), resp.Body(), errorz.CouldNotValidateCode, "data")
})
}