Files
ziti/controller/handler_ctrl/accept.go
T
Paul Lorenz 6c20e68038 Apply managed link heartbeat and queue settings at runtime. For #3743
- applies the router.link.v1 heartbeat settings (sendInterval, checkInterval,
  closeUnresponsiveTimeout) and payload/ack sender queue sizes at runtime,
  replacing the hardcoded 10s/1s link heartbeat intervals and the ignored fields;
  new links take them at bind time, established links are re-tuned in place
  through the channel HeartbeatControl, and queue sizes reach new links only
- publishes heartbeat settings as an immutable generation in one atomic swap, so
  no reader pairs one generation's interval with another's timeout, and has each
  link enforce the timeout belonging to its own intervals
- pushes a generation and its intervals as one step under the link's lock and
  ignores a generation at or below the one applied, so an overtaken update cannot
  roll a link back; reconciles every channel of a link, including a split link
  whose channels bind on either side of a config change, to the newest generation
- adopts the first registered settings explicitly, since a router with no managed
  link config publishes its defaults at generation 0
- re-arms a link's liveness deadline when its generation changes, so age
  accumulated under the previous intervals cannot close a healthy link when a
  timeout is shortened
- honors a closeUnresponsiveTimeout below 30s, which previously sat behind the
  unhealthy threshold; treats a zero timeout as closing nothing, and reports a
  closing link unhealthy
- scales the unhealthy threshold with the intervals, to max(30s, 2 x (send +
  check)): reporting unhealthy poisons the latency metric the controller routes
  on, so a fixed 30s priced healthy links with a long send interval out of
  routing every cycle
- validates heartbeat timing on the controller and the router: durations must be
  positive, their sum representable, and the timeout at least 10s and greater
  than sendInterval + checkInterval; a timeout under two heartbeat cycles is
  accepted with a warning
- adds a per-config-type semantic validator registry to ConfigManager, run after
  schema validation, and moves the router.link config definition to
  common/config/routerlink so the controller applies the same rules
- treats a link section in router YAML, of any content, as local config that
  takes precedence over the controller's for the whole router.link type, since
  the loader defaults every field and so now records presence separately; warns
  at startup when that section has no listeners or dialers
- translates local heartbeat timing leniently, since a local failure stops the
  router from starting: keeps an explicit zero, raises a timeout below the floor
  to it, and falls back to the defaults for other invalid timing, each with a
  warning
- stamps UpdateLinkListeners with the config generation it came from and has the
  controller drop superseded sets, which a multi-underlay control channel can
  deliver out of order and which would otherwise send peers to withdrawn
  addresses
- logs every successful link config apply, including those that dispatch no work,
  and pins that a queue-size-only change triggers no GC pass
- deprecates split links, documents the feature in the changelog and the router
  config design doc, and brings both up to date

Builds on the channel/v5 HeartbeatControl from openziti/channel#265.
2026-09-23 13:35:30 -04:00

207 lines
7.9 KiB
Go

/*
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 handler_ctrl
import (
"github.com/google/uuid"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/channel/v5"
"github.com/openziti/ziti/v2/common/capabilities"
"github.com/openziti/ziti/v2/common/ctrlchan"
"github.com/openziti/ziti/v2/common/pb/ctrl_pb"
"github.com/openziti/ziti/v2/controller/change"
"github.com/openziti/ziti/v2/controller/network"
"github.com/openziti/ziti/v2/controller/xctrl"
"github.com/pkg/errors"
"google.golang.org/protobuf/proto"
)
type CtrlAccepter struct {
network *network.Network
xctrls []xctrl.Xctrl
options *channel.Options
heartbeatOptions *channel.HeartbeatOptions
traceHandler *channel.TraceHandler
}
func NewCtrlAccepter(network *network.Network,
xctrls []xctrl.Xctrl,
options *channel.Options,
heartbeatOptions *channel.HeartbeatOptions,
traceHandler *channel.TraceHandler) *CtrlAccepter {
return &CtrlAccepter{
network: network,
xctrls: xctrls,
options: options,
heartbeatOptions: heartbeatOptions,
traceHandler: traceHandler,
}
}
// NewMultiListener returns a HelloAcceptor that handles both grouped (multi-underlay) and
// ungrouped (single underlay) connections from routers. As a HelloAcceptor it defers the
// hello acknowledgement until the group is registered, closing the race where a second
// underlay for the same group could arrive before the group was known.
func (self *CtrlAccepter) NewMultiListener() channel.HelloAcceptor {
return channel.NewMultiListener(self.HandleGroupedUnderlay, self.AcceptUnderlay)
}
// HandleGroupedUnderlay handles incoming grouped connections from routers that support
// multi-underlay control channels. It creates a MultiChannel with ListenerCtrlChannel.
func (self *CtrlAccepter) HandleGroupedUnderlay(underlay channel.Underlay, closeCallback func()) (channel.Channel, error) {
if _, hasSecret := underlay.Headers()[channel.GroupSecretHeader]; !hasSecret {
underlay.Headers()[channel.GroupSecretHeader] = []byte(uuid.NewString())
}
listenerCtrlChan := ctrlchan.NewListenerCtrlChannel()
multiConfig := channel.Config{
LogicalName: "ctrl/" + underlay.Id(),
Options: self.options,
Underlay: underlay,
Binder: channel.MakeBinder(channel.BindHandlerF(func(binding channel.Binding) error {
binding.AddCloseHandler(channel.CloseHandlerF(func(ch channel.Channel) {
closeCallback()
}))
return self.Bind(binding)
})),
Senders: listenerCtrlChan,
MessageSourceProvider: listenerCtrlChan,
UnderlayEventListeners: []channel.UnderlayEventListener{listenerCtrlChan},
// Multi-underlay-capable so the high/low-priority underlays are accepted;
// MinTotalUnderlays closes the channel only when its last underlay is lost.
Constraints: listenerCtrlChan.GetConstraints(),
MinTotalUnderlays: 1,
}
mc, err := channel.NewChannel(&multiConfig)
if err != nil {
pfxlog.Logger().WithError(err).Errorf("failure accepting ctrl channel %v with multi-underlay", underlay.Label())
return nil, err
}
return mc, nil
}
// AcceptUnderlay handles incoming ungrouped connections from routers that don't support
// multi-underlay control channels (backward compatibility).
func (self *CtrlAccepter) AcceptUnderlay(underlay channel.Underlay) error {
_, err := self.HandleGroupedUnderlay(underlay, func() {})
return err
}
func (self *CtrlAccepter) Bind(binding channel.Binding) error {
binding.GetChannel().SetLogicalName(binding.GetChannel().Id())
ch := binding.GetChannel()
log := pfxlog.Logger().WithField("routerId", ch.Id())
// A fresh instance per connection, carrying this channel: that is what lets connect and disconnect tell
// two connections for one router apart, and keeps them from writing over each other's state.
r, err := self.network.NewCtrlChanRouter(ch)
if err != nil {
return err
}
var ctrlChanListeners map[string][]string
if ch.Underlay().Headers() != nil {
if versionValue, found := ch.Underlay().Headers()[channel.HelloVersionHeader]; found {
if versionInfo, err := self.network.VersionProvider.EncoderDecoder().Decode(versionValue); err == nil {
r.VersionInfo = versionInfo
log = log.WithField("version", r.VersionInfo.Version).
WithField("revision", r.VersionInfo.Revision).
WithField("buildDate", r.VersionInfo.BuildDate).
WithField("os", r.VersionInfo.OS).
WithField("arch", r.VersionInfo.Arch)
} else {
return errors.Wrap(err, "could not parse version info from router hello, not accepting router connection")
}
} else {
return errors.New("no version info header, not accepting router connection")
}
// 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
// send a CapabilitiesHeader with RouterMultiChannel set and use header IDs
// in the 1000+ range. Pre-2.0 routers use legacy IDs (10-12) and don't
// send a CapabilitiesHeader.
r.Capabilities = capabilities.GetCapabilities[capabilities.RouterCapability](headers)
useNewHeaders := r.Capabilities.IsSet(capabilities.RouterMultiChannel)
listenersHeaderId := ctrl_pb.LegacyListenersHeader
if useNewHeaders {
listenersHeaderId = int32(ctrl_pb.ControlHeaders_ListenersHeader)
}
if val, found := headers[listenersHeaderId]; found {
listeners := &ctrl_pb.Listeners{}
if err = proto.Unmarshal(val, listeners); err != nil {
log.WithError(err).Error("unable to unmarshall listeners value")
} else {
r.SetLinkListeners(listeners.Listeners, listeners.Generation)
for _, listener := range listeners.Listeners {
log.WithField("address", listener.GetAddress()).
WithField("protocol", listener.GetProtocol()).
Debug("router listener")
}
}
} else {
log.Debug("no advertised listeners")
}
if val, found := ch.Underlay().Headers()[int32(ctrl_pb.ControlHeaders_CtrlChanListenersHeader)]; found {
ctrlListeners := &ctrl_pb.CtrlChanListeners{}
if err = proto.Unmarshal(val, ctrlListeners); err != nil {
log.WithError(err).Error("unable to unmarshal ctrl chan listeners value")
} else {
ctrlChanListeners = make(map[string][]string, len(ctrlListeners.Listeners))
for _, listener := range ctrlListeners.Listeners {
ctrlChanListeners[listener.Address] = listener.Groups
}
}
}
changeCtx := change.NewControlChannelChange(r.Id, r.Name, "router.connect", ch)
self.network.Router.UpdateCtrlChanListeners(r, ctrlChanListeners, changeCtx)
} else {
return errors.New("channel provided no headers, not accepting router connection as version info not provided")
}
if err = newBindHandler(self.heartbeatOptions, r, self.network, self.xctrls).BindChannel(binding); err != nil {
return errors.Wrap(err, "error binding router")
}
if self.traceHandler != nil {
binding.AddPeekHandler(self.traceHandler)
}
if err = self.network.ConnectRouter(r); err != nil {
if network.IsConnectRejected(err) {
log.Info("router connect rejected; another connection is already current, router will redial")
}
// Returning the error fails the bind, so NewChannel closes this channel's underlay without
// starting rx or registering it. That preserves the rx-gate for a rejected connection.
return err
}
log.Info("accepted new router connection")
return nil
}