diff --git a/management/server/types/account.go b/management/server/types/account.go index 51313819f4c..9e86d89366c 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -40,8 +40,20 @@ const ( // firewallRuleMinPortRangesVer defines the minimum peer version that supports port range rules. firewallRuleMinPortRangesVer = "0.48.0" + // firewallRuleMinNativeSSHVer defines the minimum peer version that supports native SSH features in the firewall rules. + firewallRuleMinNativeSSHVer = "0.60.0" + + // nativeSSHPortString defines the default port number as a string used for native SSH connections; this port is used by clients when hijacking ssh connections. + nativeSSHPortString = "22022" + // defaultSSHPortString defines the standard SSH port number as a string, commonly used for default SSH connections. + defaultSSHPortString = "22" ) +type supportedFeatures struct { + nativeSSH bool + portRanges bool +} + type LookupMap map[string]struct{} // AccountMeta is a struct that contains a stripped down version of the Account object. @@ -1650,22 +1662,24 @@ func (a *Account) AddAllGroup(disableDefaultPolicy bool) error { // expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { + features := peerSupportedFirewallFeatures(peer.Meta.WtVersion) + var expanded []*FirewallRule - if len(rule.Ports) > 0 { - for _, port := range rule.Ports { - fr := base - fr.Port = port - expanded = append(expanded, &fr) - } - return expanded + for _, port := range rule.Ports { + fr := base + fr.Port = port + expanded = append(expanded, &fr) } - supportPortRanges := peerSupportsPortRanges(peer.Meta.WtVersion) for _, portRange := range rule.PortRanges { + // prefer PolicyRule.Ports + if len(rule.Ports) > 0 { + break + } fr := base - if supportPortRanges { + if features.portRanges { fr.PortRange = portRange } else { // Peer doesn't support port ranges, only allow single-port ranges @@ -1677,17 +1691,63 @@ func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer expanded = append(expanded, &fr) } + if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) { + expanded = addNativeSSHRule(base, expanded) + } + return expanded } -// peerSupportsPortRanges checks if the peer version supports port ranges. -func peerSupportsPortRanges(peerVer string) bool { +// addNativeSSHRule adds a native SSH rule (port 22022) to the expanded rules if the base rule has port 22 configured. +func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule { + shouldAdd := false + for _, fr := range expanded { + if isPortInRule(nativeSSHPortString, 22022, fr) { + return expanded + } + if isPortInRule(defaultSSHPortString, 22, fr) { + shouldAdd = true + } + } + if !shouldAdd { + return expanded + } + + fr := base + fr.Port = nativeSSHPortString + return append(expanded, &fr) +} + +func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool { + return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End) +} + +// shouldCheckRulesForNativeSSH determines whether specific policy rules should be checked for native SSH support. +// While users can add the nativeSSHPortString, we look for cases when they used port 22 and based on SSH enabled +// in both management and client, we indicate to add the native port. +func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *nbpeer.Peer) bool { + return supportsNative && peer.SSHEnabled && peer.Meta.Flags.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP +} + +// peerSupportedFirewallFeatures checks if the peer version supports port ranges. +func peerSupportedFirewallFeatures(peerVer string) supportedFeatures { if strings.Contains(peerVer, "dev") { - return true + return supportedFeatures{true, true} + } + + var features supportedFeatures + + meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinNativeSSHVer, peerVer) + features.nativeSSH = err == nil && meetMinVer + + if features.nativeSSH { + features.portRanges = true + } else { + meetMinVer, err = posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) + features.portRanges = err == nil && meetMinVer } - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) - return err == nil && meetMinVer + return features } // filterZoneRecordsForPeers filters DNS records to only include peers to connect. diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 32538933a28..f9aa6a1c22a 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -839,6 +839,272 @@ func Test_NetworksNetMapGenShouldExcludeOtherRouters(t *testing.T) { assert.Len(t, sourcePeers, 2, "expected source peers don't match") } +func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { + tests := []struct { + name string + peer *nbpeer.Peer + rule *PolicyRule + base FirewallRule + expectedPorts []string + }{ + { + name: "adds port 22022 when SSH enabled on modern peer with port 22", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "adds port 22022 once when port 22 is duplicated within policy", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22", "80", "22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "80", "22", "22022"}, + }, + { + name: "does not add 22022 for peer with old version", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.59.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when SSHEnabled is false", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: false, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when ServerSSHAllowed is false", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: false}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 for UDP protocol", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolUDP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "udp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when port 22 not in rule", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"80", "443"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"80", "443"}, + }, + { + name: "does not duplicate 22022 when already present", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22", "22022"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "does not duplicate 22022 when already within a port range", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 32000}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-32000"}, + }, + { + name: "adds 22022 when port 22 in port range", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 25}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-25", "22022"}, + }, + { + name: "adds single 22022 once when port 22 in multiple port ranges", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 25}, {Start: 10, End: 100}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-25", "10-100", "22022"}, + }, + { + name: "dev suffix version supports all features", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.50.0-dev", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "dev suffix version supports all features", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "dev", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "development suffix version supports all features", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "development", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandPortsAndRanges(tt.base, tt.rule, tt.peer) + + var ports []string + for _, fr := range result { + if fr.Port != "" { + ports = append(ports, fr.Port) + } else if fr.PortRange.Start > 0 { + ports = append(ports, fmt.Sprintf("%d-%d", fr.PortRange.Start, fr.PortRange.End)) + } + } + + assert.Equal(t, tt.expectedPorts, ports, "expanded ports should match expected") + }) + } +} + func Test_FilterZoneRecordsForPeers(t *testing.T) { tests := []struct { name string