Просмотр исходного кода

Merge pull request #739 from rod-hynes/iptables-rate-limit

Add configureIptablesAcceptRateLimitChain and PR_SET_THP_DISABLE
Rod Hynes 9 месяцев назад
Родитель
Сommit
2e9065fd6f

+ 17 - 1
psiphon/server/config.go

@@ -388,7 +388,9 @@ type Config struct {
 	PacketTunnelSessionIdleExpirySeconds int
 
 	// PacketTunnelSudoNetworkConfigCommands sets
-	// tun.ServerConfig.SudoNetworkConfigCommands.
+	// tun.ServerConfig.SudoNetworkConfigCommands,
+	// packetman.Config.SudoNetworkConfigCommands, and
+	// SudoNetworkConfigCommands for configureIptablesAcceptRateLimitChain.
 	PacketTunnelSudoNetworkConfigCommands bool
 
 	// RunPacketManipulator specifies whether to run a packet manipulator.
@@ -503,6 +505,20 @@ type Config struct {
 	// required when running in-proxy tunnel protocols.
 	InproxyServerObfuscationRootSecret string
 
+	// IptablesAcceptRateLimitChainName, when set, enables programmatic
+	// configuration of iptables rules to allow and apply rate limits to
+	// tunnel protocol network ports. The configuration is applied to the
+	// specified chain.
+	//
+	// For details, see configureIptablesAcceptRateLimitChain.
+	IptablesAcceptRateLimitChainName string
+
+	// IptablesAcceptRateLimitTunnelProtocolRateLimits specifies custom
+	// iptables rate limits by tunnel protocol name. See
+	// configureIptablesAcceptRateLimitChain details about the rate limit
+	// values.
+	IptablesAcceptRateLimitTunnelProtocolRateLimits map[string][2]int
+
 	sshBeginHandshakeTimeout                       time.Duration
 	sshHandshakeTimeout                            time.Duration
 	peakUpstreamFailureRateMinimumSampleSize       int

+ 217 - 0
psiphon/server/hostConfig_linux.go

@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2025, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package server
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
+	"golang.org/x/sys/unix"
+)
+
+func addHostConfig(config *Config) error {
+
+	// Disable Transparent Huge Pages; huge pages can result in false
+	// positives in "low free memory" checks performed by load limiting
+	// scripts which inspect host/server process memory usage, further
+	// resulting in improper SIGTSTP signals.
+
+	err := unix.Prctl(unix.PR_SET_THP_DISABLE, 1, 0, 0, 0)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	// Programmatically configure iptables rules to allow and apply rate
+	// limits to tunnel protocol ports.
+
+	err = configureIptablesAcceptRateLimitChain(config, true)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	return nil
+}
+
+func removeHostConfig(config *Config) error {
+
+	err := configureIptablesAcceptRateLimitChain(config, false)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	return nil
+}
+
+func configureIptablesAcceptRateLimitChain(config *Config, add bool) error {
+
+	// Adapted from:
+	// https://github.com/Psiphon-Inc/psiphon-automation/blob/8fce7c72/Automation/psi_ops_install.py#L936
+
+	// The chain is assumed to be created by the host (iptables -N); the host
+	// is also responsible any default DROP rule and for jumping to the
+	// specified chain.
+
+	chainName := config.IptablesAcceptRateLimitChainName
+
+	if chainName == "" {
+		return nil
+	}
+
+	for _, c := range chainName {
+		if !(c == '_' || c == '-' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
+			return errors.TraceNew("invalid chain name")
+		}
+	}
+
+	// Direct protocols in which the original client IP directly connects to the Psiphon server will
+	// use the "recent" rule, which limits connections by client IP. Fronted and other indirect
+	// protocols where many clients can arrive from a few intermediate IPs use a simple frequency
+	// rate limit; this rule is also used for direct meek protocols, since one client tunnel can
+	// consist of many TCP connections, with meek resiliency.
+	//
+	// Custom rate limits may be set, per tunnel protocol, in IptablesAcceptRateLimitTunnelProtocolRateLimits.
+	// When no custom rate is set, or if IptablesAcceptRateLimitTunnelProtocolRateLimits contains zero
+	// values, default values are used.
+	//
+	// For the [2]int value in IptablesAcceptRateLimitTunnelProtocolRateLimits:
+	// - In the "recent" rule case, value[0] specifies --seconds N and value[1] specifies --hitcount N.
+	// - In the other case, value[0] specifies --limit N/sec and value[1] is ignored.
+
+	inproxyAcceptRateLimitRules := func(networkProtocol string, portNumber int, rateLimit [2]int) ([]string, error) {
+		if rateLimit[0] == 0 {
+			rateLimit[0] = 1000
+		}
+		return []string{
+			fmt.Sprintf("-A %s -p %s -m state --state NEW -m %s --dport %d -m limit --limit %d/sec -j ACCEPT",
+				chainName, networkProtocol, networkProtocol, portNumber, rateLimit[0]),
+		}, nil
+	}
+
+	meekAcceptRateLimitRules := func(portNumber int, rateLimit [2]int) ([]string, error) {
+		if rateLimit[0] == 0 {
+			rateLimit[0] = 1000
+		}
+		return []string{
+			fmt.Sprintf("-A %s -p tcp -m state --state NEW -m tcp --dport %d -m limit --limit %d/sec -j ACCEPT",
+				chainName, portNumber, rateLimit[0]),
+		}, nil
+	}
+
+	refractionNetworkingRateLimitRules := meekAcceptRateLimitRules
+
+	directAcceptRateLimitRules := func(networkProtocol string, portNumber int, rateLimit [2]int) ([]string, error) {
+		if rateLimit[0] == 0 {
+			rateLimit[0] = 60
+		}
+		if rateLimit[1] == 0 {
+			rateLimit[1] = 3
+		}
+		name := fmt.Sprintf("LIMIT-%s-%d", networkProtocol, portNumber)
+		return []string{
+			fmt.Sprintf("-A %s -p %s -m state --state NEW -m %s --dport %d -m recent --set --name %s",
+				chainName, networkProtocol, networkProtocol, portNumber, name),
+			fmt.Sprintf("-A %s -p %s -m state --state NEW -m %s --dport %d -m recent --update --name %s --seconds %d --hitcount %d -j DROP",
+				chainName, networkProtocol, networkProtocol, portNumber, name, rateLimit[0], rateLimit[1]),
+			fmt.Sprintf("-A %s -p %s -m state --state NEW -m %s --dport %d -j ACCEPT",
+				chainName, networkProtocol, networkProtocol, portNumber),
+		}, nil
+	}
+
+	rules := []string{fmt.Sprintf("-F %s", chainName)}
+
+	if add {
+
+		for tunnelProtocol, portNumber := range config.TunnelProtocolPorts {
+
+			rateLimit := config.IptablesAcceptRateLimitTunnelProtocolRateLimits[tunnelProtocol]
+			var protocolRules []string
+			var err error
+
+			if protocol.TunnelProtocolUsesInproxy(tunnelProtocol) {
+
+				networkProtocol := "tcp"
+				if !protocol.TunnelProtocolUsesTCP(tunnelProtocol) {
+					networkProtocol = "udp"
+				}
+				protocolRules, err = inproxyAcceptRateLimitRules(networkProtocol, portNumber, rateLimit)
+				if err != nil {
+					return errors.Trace(err)
+				}
+
+			} else if protocol.TunnelProtocolUsesMeek(tunnelProtocol) {
+
+				// Assumes all FRONTED-MEEK is HTTPS over TCP between the edge
+				// and Psiphon server.
+
+				protocolRules, err = meekAcceptRateLimitRules(portNumber, rateLimit)
+				if err != nil {
+					return errors.Trace(err)
+				}
+
+			} else if protocol.TunnelProtocolUsesRefractionNetworking(tunnelProtocol) {
+
+				protocolRules, err = refractionNetworkingRateLimitRules(portNumber, rateLimit)
+				if err != nil {
+					return errors.Trace(err)
+				}
+
+			} else {
+
+				networkProtocol := "tcp"
+				if !protocol.TunnelProtocolUsesTCP(tunnelProtocol) {
+					networkProtocol = "udp"
+				}
+				protocolRules, err = directAcceptRateLimitRules(networkProtocol, portNumber, rateLimit)
+				if err != nil {
+					return errors.Trace(err)
+				}
+			}
+
+			rules = append(rules, protocolRules...)
+		}
+
+		rules = append(rules, fmt.Sprintf("-A %s -j RETURN", chainName))
+	}
+
+	for _, rule := range rules {
+
+		// While the config values IptablesAcceptRateLimitChainName and
+		// IptablesAcceptRateLimitTunnelProtocolRateLimits are considered
+		// trusted inputs, the risk of command injection is mitigated by
+		// input validation and common.RunNetworkConfigCommand using
+		// exec.Command and not invoking a shell.
+		//
+		// The command will be logged at log level debug.
+
+		err := common.RunNetworkConfigCommand(
+			CommonLogger(log),
+			config.PacketTunnelSudoNetworkConfigCommands,
+			"iptables",
+			strings.Split(rule, " ")...)
+		if err != nil {
+			return errors.Trace(err)
+		}
+	}
+
+	return nil
+}

+ 33 - 0
psiphon/server/hostConfig_other.go

@@ -0,0 +1,33 @@
+//go:build !linux
+// +build !linux
+
+/*
+ * Copyright (c) 2025, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package server
+
+func addHostConfig(config *Config) error {
+	// No-op on this platform
+	return nil
+}
+
+func removeHostConfig(config *Config) error {
+	// No-op on this platform
+	return nil
+}

+ 11 - 0
psiphon/server/services.go

@@ -72,6 +72,17 @@ func RunServices(configJSON []byte) (retErr error) {
 
 	loggingInitialized = true
 
+	err = addHostConfig(config)
+	if err != nil {
+		return errors.Trace(err)
+	}
+	defer func() {
+		err := removeHostConfig(config)
+		if err != nil {
+			log.WithTraceFields(LogFields{"error": retErr}).Error("removeHostConfig failed")
+		}
+	}()
+
 	support, err := NewSupportServices(config)
 	if err != nil {
 		return errors.Trace(err)