mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
302 lines
9.1 KiB
Go
302 lines
9.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"tailscale.com/net/netmon"
|
|
)
|
|
|
|
// interfaceChange describes one interface that a network delta changed.
|
|
type interfaceChange struct {
|
|
Name string
|
|
Action string
|
|
Class string
|
|
HardwarePort string
|
|
Service string
|
|
IPsBefore []string
|
|
IPsAfter []string
|
|
Flags string
|
|
MTU int
|
|
IsDefaultRoute bool
|
|
}
|
|
|
|
// interfaceMeta holds the platform description of one interface. An empty
|
|
// Class asks the caller to read the class from the interface name.
|
|
type interfaceMeta struct {
|
|
Class string
|
|
HardwarePort string
|
|
Service string
|
|
LinkType string
|
|
}
|
|
|
|
// interfaceMetaFunc reads the platform description of one interface.
|
|
type interfaceMetaFunc func(name string) interfaceMeta
|
|
|
|
// virtualInterfaceSetFn names the virtual adapters of the host. Each platform
|
|
// file fills it, because a name alone does not tell a container bridge from a
|
|
// physical port.
|
|
var virtualInterfaceSetFn = func() map[string]struct{} { return nil }
|
|
|
|
// interfaceSnapshot holds the part of one interface state that the diff
|
|
// compares.
|
|
type interfaceSnapshot struct {
|
|
present bool
|
|
up bool
|
|
mac string
|
|
mtu int
|
|
flags string
|
|
ips []string
|
|
}
|
|
|
|
// interfaceClass groups an interface by the traffic it carries. The class
|
|
// decides which changes are noise.
|
|
func interfaceClass(name string, virtual bool) string {
|
|
switch {
|
|
case strings.HasPrefix(name, "lo"):
|
|
return "loopback"
|
|
case hasInterfacePrefix(name, "awdl", "llw"):
|
|
return "airdrop"
|
|
case hasInterfacePrefix(name, "utun", "tun", "tap", "wg", "ipsec", "ppp"):
|
|
return "tunnel"
|
|
case virtual:
|
|
return "virtual"
|
|
default:
|
|
return "hardware"
|
|
}
|
|
}
|
|
|
|
// deltaBeforeState picks the state that the diff of one callback compares
|
|
// against, and makes a current callback the baseline of the next one. netmon
|
|
// caches major snapshots only, so every minor callback of an epoch carries the
|
|
// same Old, and a difference that lasts would look new in each of them. A
|
|
// callback that a newer snapshot replaced reads the baseline and leaves it.
|
|
// The caller holds networkSourceMu.
|
|
//
|
|
// netmon runs its callbacks in their own goroutines, so a minor callback of a
|
|
// new epoch can run before the major callback that opened the epoch. That
|
|
// major then reports its own changes against its Old and leaves the newer
|
|
// baseline in place. A major whose Old is the cache of the epoch before
|
|
// continues from the last reported state of that epoch, so a change that a
|
|
// minor callback reported does not appear again.
|
|
func (p *prog) deltaBeforeState(delta *netmon.ChangeDelta, epoch *netmon.State, current, major bool) *netmon.State {
|
|
before := p.networkDeltaState
|
|
switch {
|
|
case before == nil:
|
|
before = delta.Old
|
|
case current && major && p.networkDeltaEpoch == epoch:
|
|
return delta.Old
|
|
case p.networkDeltaEpoch != epoch && delta.Old != p.networkDeltaEpoch:
|
|
before = delta.Old
|
|
}
|
|
if current {
|
|
p.networkDeltaState, p.networkDeltaEpoch = delta.New, epoch
|
|
}
|
|
return before
|
|
}
|
|
|
|
// diffNetworkDelta describes every interface that changed between two states.
|
|
// It walks every interface, not only the valid hardware ports, because the
|
|
// journal must show the port that went away too.
|
|
func diffNetworkDelta(beforeState, afterState *netmon.State) []interfaceChange {
|
|
before, after := networkStateOrEmpty(beforeState), networkStateOrEmpty(afterState)
|
|
virtual := virtualInterfaceSetFn()
|
|
var changes []interfaceChange
|
|
for _, name := range interfaceNameUnion(before, after) {
|
|
snapBefore, snapAfter := readInterfaceSnapshot(before, name), readInterfaceSnapshot(after, name)
|
|
action := interfaceAction(snapBefore, snapAfter)
|
|
if action == "" {
|
|
continue
|
|
}
|
|
known := snapAfter
|
|
if !snapAfter.present {
|
|
known = snapBefore
|
|
}
|
|
_, isVirtual := virtual[name]
|
|
changes = append(changes, interfaceChange{
|
|
Name: name,
|
|
Action: action,
|
|
Class: interfaceClass(name, isVirtual),
|
|
IPsBefore: snapBefore.ips,
|
|
IPsAfter: snapAfter.ips,
|
|
Flags: known.flags,
|
|
MTU: known.mtu,
|
|
IsDefaultRoute: name == after.DefaultRouteInterface,
|
|
})
|
|
}
|
|
return changes
|
|
}
|
|
|
|
// noiseDelta reports a delta that no part of the daemon must act on. AirDrop
|
|
// and the virtual adapters of a container host change every few seconds, and
|
|
// each change would otherwise start the whole reconcile chain. A time jump
|
|
// means the host woke, so the daemon acts on the delta whatever it holds.
|
|
func noiseDelta(beforeState, afterState *netmon.State, timeJumped bool, changes []interfaceChange) bool {
|
|
if len(changes) == 0 || timeJumped {
|
|
return false
|
|
}
|
|
before, after := networkStateOrEmpty(beforeState), networkStateOrEmpty(afterState)
|
|
if before.DefaultRouteInterface != after.DefaultRouteInterface {
|
|
return false
|
|
}
|
|
if before.HaveV4 != after.HaveV4 || before.HaveV6 != after.HaveV6 {
|
|
return false
|
|
}
|
|
source4, source6 := ctrld.GetDefaultLocalIPv4(), ctrld.GetDefaultLocalIPv6()
|
|
for _, change := range changes {
|
|
if !noiseClass(change.Class) || change.IsDefaultRoute {
|
|
return false
|
|
}
|
|
if carriesHostTraffic(change.IPsBefore, source4, source6) {
|
|
return false
|
|
}
|
|
if carriesHostTraffic(change.IPsAfter, source4, source6) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// noiseClass names the classes that change on their own. The kernel moves the
|
|
// link-local address of an AirDrop or a container adapter every few seconds.
|
|
func noiseClass(class string) bool {
|
|
return class == "airdrop" || class == "virtual"
|
|
}
|
|
|
|
// carriesHostTraffic reports an address list that reaches another host. A
|
|
// bridge, a bond, or a tunnel adapter can own the traffic of the host, so a
|
|
// routable address or the resolver source address makes the interface real
|
|
// whatever its name says.
|
|
func carriesHostTraffic(ips []string, source4, source6 net.IP) bool {
|
|
for _, ip := range ips {
|
|
prefix, err := netip.ParsePrefix(ip)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
addr := prefix.Addr()
|
|
if addr.IsGlobalUnicast() || sameAddress(addr, source4) || sameAddress(addr, source6) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sameAddress compares one interface address with a resolver source address.
|
|
func sameAddress(addr netip.Addr, ip net.IP) bool {
|
|
source, ok := netip.AddrFromSlice(ip)
|
|
return ok && source.Unmap() == addr
|
|
}
|
|
|
|
// describeInterfaceChanges fills the platform fields of the changes that reach
|
|
// the journal. macOS reads them with two subprocesses, so only a delta that
|
|
// the daemon acts on pays for them. The platform class wins where it knows
|
|
// one, because a name rule cannot see a virtual adapter on Windows.
|
|
func describeInterfaceChanges(changes []interfaceChange, meta interfaceMetaFunc) {
|
|
if meta == nil {
|
|
return
|
|
}
|
|
for i := range changes {
|
|
info := meta(changes[i].Name)
|
|
changes[i].HardwarePort, changes[i].Service = info.HardwarePort, info.Service
|
|
if info.Class != "" {
|
|
changes[i].Class = info.Class
|
|
}
|
|
}
|
|
}
|
|
|
|
// interfaceAction names the first difference that the journal reports. The
|
|
// order runs from the coarse change to the fine one, so one interface gives
|
|
// one action.
|
|
func interfaceAction(before, after interfaceSnapshot) string {
|
|
switch {
|
|
case before.present && !after.present:
|
|
return "removed"
|
|
case !before.present && after.present:
|
|
return "added"
|
|
case before.up && !after.up:
|
|
return "down"
|
|
case !before.up && after.up:
|
|
return "up"
|
|
case hasExtraEntry(before.ips, after.ips):
|
|
return "ip_removed"
|
|
case hasExtraEntry(after.ips, before.ips):
|
|
return "ip_added"
|
|
case before.mac != after.mac:
|
|
return "mac_changed"
|
|
case before.mtu != after.mtu:
|
|
return "mtu_changed"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func readInterfaceSnapshot(state *netmon.State, name string) interfaceSnapshot {
|
|
snapshot := interfaceSnapshot{ips: sortedPrefixStrings(state.InterfaceIPs[name])}
|
|
iface, ok := state.Interface[name]
|
|
if !ok {
|
|
return snapshot
|
|
}
|
|
snapshot.present = true
|
|
if iface.Interface == nil {
|
|
return snapshot
|
|
}
|
|
snapshot.up = iface.IsUp()
|
|
snapshot.mtu = iface.MTU
|
|
snapshot.flags = iface.Flags.String()
|
|
if len(iface.HardwareAddr) > 0 {
|
|
snapshot.mac = iface.HardwareAddr.String()
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
func interfaceNameUnion(before, after *netmon.State) []string {
|
|
names := make([]string, 0, len(before.Interface)+len(after.Interface))
|
|
for name := range before.Interface {
|
|
names = append(names, name)
|
|
}
|
|
for name := range after.Interface {
|
|
if _, both := before.Interface[name]; !both {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
func sortedPrefixStrings(prefixes []netip.Prefix) []string {
|
|
ips := prefixStrings(prefixes)
|
|
slices.Sort(ips)
|
|
return ips
|
|
}
|
|
|
|
// networkStateOrEmpty keeps the diff free of nil checks. The first delta after
|
|
// start carries no old state.
|
|
func networkStateOrEmpty(state *netmon.State) *netmon.State {
|
|
if state != nil {
|
|
return state
|
|
}
|
|
return &netmon.State{}
|
|
}
|
|
|
|
func hasInterfacePrefix(name string, prefixes ...string) bool {
|
|
for _, prefix := range prefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasExtraEntry reports an entry of list that other does not hold.
|
|
func hasExtraEntry(list, other []string) bool {
|
|
for _, entry := range list {
|
|
if !slices.Contains(other, entry) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|