From c59331e668002937eab41e066b3281585bdb2c7a Mon Sep 17 00:00:00 2001 From: etm Date: Thu, 6 Aug 2026 16:50:33 +0330 Subject: [PATCH 1/4] fix: accept boundary ports in forwarding mappings --- internal/server/transport/kcp.go | 7 +---- internal/server/transport/portmapping.go | 19 +++++++++++++ internal/server/transport/portmapping_test.go | 28 +++++++++++++++++++ internal/server/transport/quic.go | 7 +---- internal/server/transport/tcp.go | 7 +---- internal/server/transport/tcpmux.go | 7 +---- internal/server/transport/udp.go | 7 +---- internal/server/transport/ws.go | 7 +---- internal/server/transport/wsmux.go | 7 +---- 9 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 internal/server/transport/portmapping.go create mode 100644 internal/server/transport/portmapping_test.go diff --git a/internal/server/transport/kcp.go b/internal/server/transport/kcp.go index e8ba7dd..f297c1c 100644 --- a/internal/server/transport/kcp.go +++ b/internal/server/transport/kcp.go @@ -572,12 +572,7 @@ func (s *KcpTransport) parsePortMappings(g *kcpGen) { continue } - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) } diff --git a/internal/server/transport/portmapping.go b/internal/server/transport/portmapping.go new file mode 100644 index 0000000..e6a9c36 --- /dev/null +++ b/internal/server/transport/portmapping.go @@ -0,0 +1,19 @@ +package transport + +import ( + "fmt" + "strconv" + "strings" +) + +// mappingListenAddress converts the numeric shorthand used by forwarded-port +// mappings into a wildcard listen address. Non-numeric values are already full +// listen addresses and are returned unchanged. +func mappingListenAddress(value string) string { + value = strings.TrimSpace(value) + port, err := strconv.Atoi(value) + if err == nil && port >= 1 && port <= 65535 { + return fmt.Sprintf(":%d", port) + } + return value +} diff --git a/internal/server/transport/portmapping_test.go b/internal/server/transport/portmapping_test.go new file mode 100644 index 0000000..579ec42 --- /dev/null +++ b/internal/server/transport/portmapping_test.go @@ -0,0 +1,28 @@ +package transport + +import "testing" + +func TestMappingListenAddress(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {"lowest port", "1", ":1"}, + {"highest port", "65535", ":65535"}, + {"typical port", "443", ":443"}, + {"whitespace", " 443 ", ":443"}, + {"IPv4 address", "127.0.0.1:443", "127.0.0.1:443"}, + {"IPv6 address", "[::1]:443", "[::1]:443"}, + {"below range", "0", "0"}, + {"above range", "65536", "65536"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mappingListenAddress(tt.value); got != tt.want { + t.Fatalf("mappingListenAddress(%q) = %q, want %q", tt.value, got, tt.want) + } + }) + } +} diff --git a/internal/server/transport/quic.go b/internal/server/transport/quic.go index 47b3643..46cd16f 100644 --- a/internal/server/transport/quic.go +++ b/internal/server/transport/quic.go @@ -459,12 +459,7 @@ func (s *QuicTransport) parsePortMappings(g *quicGen) { continue } - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) } diff --git a/internal/server/transport/tcp.go b/internal/server/transport/tcp.go index add3d34..acc9ba4 100644 --- a/internal/server/transport/tcp.go +++ b/internal/server/transport/tcp.go @@ -577,12 +577,7 @@ func (s *TcpTransport) parsePortMappings(g *tcpGen) { continue } else { // Handle single local port case - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) diff --git a/internal/server/transport/tcpmux.go b/internal/server/transport/tcpmux.go index 1c025e1..34de84c 100644 --- a/internal/server/transport/tcpmux.go +++ b/internal/server/transport/tcpmux.go @@ -601,12 +601,7 @@ func (s *TcpMuxTransport) parsePortMappings(g *tcpMuxGen) { continue } else { // Handle single local port case - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) diff --git a/internal/server/transport/udp.go b/internal/server/transport/udp.go index 56b6d6c..3f791e8 100644 --- a/internal/server/transport/udp.go +++ b/internal/server/transport/udp.go @@ -536,12 +536,7 @@ func (s *UdpTransport) parsePortMappings(g *udpGen) { continue } else { // Handle single local port case - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) diff --git a/internal/server/transport/ws.go b/internal/server/transport/ws.go index e70908e..ec403e4 100644 --- a/internal/server/transport/ws.go +++ b/internal/server/transport/ws.go @@ -451,12 +451,7 @@ func (s *WsTransport) parsePortMappings(g *wsGen) { continue } else { // Handle single local port case - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) diff --git a/internal/server/transport/wsmux.go b/internal/server/transport/wsmux.go index 876ea93..df96226 100644 --- a/internal/server/transport/wsmux.go +++ b/internal/server/transport/wsmux.go @@ -476,12 +476,7 @@ func (s *WsMuxTransport) parsePortMappings(g *wsMuxGen) { continue } else { // Handle single local port case - port, err := strconv.Atoi(localPortOrRange) - if err == nil && port > 1 && port < 65535 { // format port=remoteAddress - localAddr = fmt.Sprintf(":%d", port) - } else { - localAddr = localPortOrRange // format ip:port=remoteAddress - } + localAddr = mappingListenAddress(localPortOrRange) } } else { s.logger.Fatalf("invalid port mapping format: %s", portMapping) From 621f9cc186948107af72932bdce71c8f9241405b Mon Sep 17 00:00:00 2001 From: etm Date: Thu, 6 Aug 2026 22:18:36 +0330 Subject: [PATCH 2/4] feat: add extensible iptables direct-forward engine --- .gitattributes | 2 + README.md | 4 +- cmd/cmd.go | 113 +- cmd/defaults.go | 3 + config/config.go | 238 ++- config/engine_test.go | 122 ++ docs/README.md | 1 + docs/direct-forward.md | 82 + install.sh | 45 +- internal/engine/engine.go | 101 ++ internal/engine/iptables_linux.go | 1518 ++++++++++++++++++ internal/engine/iptables_linux_test.go | 238 +++ internal/engine/iptables_netns_linux_test.go | 313 ++++ internal/engine/iptables_other.go | 29 + internal/engine/iptables_state.go | 103 ++ internal/engine/reverse.go | 66 + internal/instanceid/identity.go | 130 ++ internal/instanceid/identity_test.go | 29 + internal/manage/backup.go | 193 ++- internal/manage/diagnose.go | 63 + internal/manage/direct.go | 278 ++++ internal/manage/health.go | 28 + internal/manage/menu.go | 26 +- internal/manage/status.go | 13 +- internal/manage/tunnel.go | 53 +- internal/manage/watchdog.go | 45 +- internal/menu/menu.go | 59 +- internal/metrics/metrics.go | 12 +- internal/telegram/telegram.go | 12 +- internal/webui/assets/dashboard.html | 20 +- internal/webui/direct_api_test.go | 35 + internal/webui/prometheus.go | 22 +- internal/webui/stats.go | 50 +- main.go | 11 +- 34 files changed, 3873 insertions(+), 184 deletions(-) create mode 100644 .gitattributes create mode 100644 config/engine_test.go create mode 100644 docs/direct-forward.md create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/iptables_linux.go create mode 100644 internal/engine/iptables_linux_test.go create mode 100644 internal/engine/iptables_netns_linux_test.go create mode 100644 internal/engine/iptables_other.go create mode 100644 internal/engine/iptables_state.go create mode 100644 internal/engine/reverse.go create mode 100644 internal/instanceid/identity.go create mode 100644 internal/instanceid/identity_test.go create mode 100644 internal/manage/direct.go create mode 100644 internal/webui/direct_api_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b02a463 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.go text eol=lf +*.sh text eol=lf diff --git a/README.md b/README.md index edf040c..86fe2b2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Total downloads across all releases

-**Backpack** is a high-performance **reverse tunnel** engine written entirely in +**Backpack** is a high-performance **reverse tunnel and direct-forward** engine written entirely in **Go**, purpose-built for Iran ⇄ abroad (kharej) server setups. It ships as a single self-contained binary with an interactive CLI **and** a secured web dashboard — so you can run and manage everything with or without a terminal. @@ -34,6 +34,8 @@ the **real service**. The tunnel is always dialed **by the client** ## Why Backpack? +- **Kernel direct forwarding** — dual-stack TCP/UDP DNAT with iptables, + transactional rule generations, ownership-safe cleanup and persistent counters. - **Multi-transport** — nine transports across TCP, UDP and WebSocket, so you match the route instead of fighting it. - **Automatic rollback** — an update or edit that breaks a tunnel reverts itself, diff --git a/cmd/cmd.go b/cmd/cmd.go index 92c8d28..14c1a3f 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,50 +2,18 @@ package cmd import ( "context" - "path/filepath" - "strings" "time" - "github.com/backpack/backpack/internal/metrics" - "github.com/backpack/backpack/config" - "github.com/backpack/backpack/internal/client" - - "github.com/backpack/backpack/internal/server" + "github.com/backpack/backpack/internal/engine" "github.com/backpack/backpack/internal/utils" "github.com/backpack/backpack/internal/utils/handlers" - - "github.com/BurntSushi/toml" ) var ( logger = utils.NewLogger("info") ) -// tunnelNameFromPath derives a tunnel's name from its config path, which is -// how the rest of the tool identifies it. -func tunnelNameFromPath(configPath string) string { - base := filepath.Base(configPath) - return strings.TrimSuffix(base, filepath.Ext(base)) -} - -// startMetrics records what the tunnel carries so the CLI can show it later. -// It is best-effort: a tunnel must never fail because diagnostics could not be -// written. -func startMetrics(ctx context.Context, configPath, transport, role string) { - name := tunnelNameFromPath(configPath) - if name == "" { - return - } - c := metrics.NewCollector(filepath.Dir(configPath), name, transport, role, nil, nil) - go func() { - done := make(chan struct{}) - go func() { <-ctx.Done(); close(done) }() - _ = c.Write() // an immediate first reading, so the file exists right away - c.Run(done, 30*time.Second) - }() -} - // Run keeps one tunnel running from a configuration file, restarting it in // place whenever the file changes. See reload.go for why the file is watched at // all, and for the two rules that keep watching it from being a liability: a @@ -80,7 +48,9 @@ func Run(configPath string, ctx context.Context) { done := make(chan struct{}) go func() { defer close(done) - runEngine(&running, runCtx, configPath, applyTuning) + if err := runEngine(&running, runCtx, configPath, applyTuning); err != nil { + logger.Fatalf("engine failed: %v", err) + } }() next := awaitConfigChange(ctx, configPath, cfg) @@ -98,64 +68,37 @@ func Run(configPath string, ctx context.Context) { } // runEngine runs one tunnel until ctx ends. -func runEngine(cfg *config.Config, ctx context.Context, configPath string, applyTuning bool) { - configType := "" - if cfg.Server.BindAddr != "" { - configType = "server" - } else if cfg.Client.RemoteAddr != "" { - configType = "client" - } else { - logger.Fatalf("neither server nor client configuration is properly set.") +func runEngine(cfg *config.Config, ctx context.Context, configPath string, applyTuning bool) error { + provider, err := engine.Resolve(cfg) + if err != nil { + return err } - - // Determine whether to run as a server or client - switch configType { - case "server": - // Apply temporary TCP optimizations at startup - if applyTuning && !cfg.Server.SkipOptz { - ApplyTCPTuning() - } - - startMetrics(ctx, configPath, string(cfg.Server.Transport), "server") - - srv := server.NewServer(&cfg.Server, ctx) // server - reportZeroCopy(ctx) - go srv.Start() - - // Wait for shutdown signal - <-ctx.Done() - srv.Stop() - logger.Println("shutting down server...") - case "client": - // Apply temporary TCP optimizations at startup - if applyTuning && !cfg.Client.SkipOptz { - ApplyTCPTuning() + if cfg.EffectiveEngine() == config.EngineReverse { + if cfg.HasServer() { + // Apply temporary TCP optimizations at startup + if applyTuning && !cfg.Server.SkipOptz { + ApplyTCPTuning() + } + } else { + // Apply temporary TCP optimizations at startup + if applyTuning && !cfg.Client.SkipOptz { + ApplyTCPTuning() + } } - - startMetrics(ctx, configPath, string(cfg.Client.Transport), "client") - - clnt := client.NewClient(&cfg.Client, ctx) // client - reportZeroCopy(ctx) - go clnt.Start() - - // Wait for shutdown signal - <-ctx.Done() - clnt.Stop() - logger.Println("shutting down client...") - - default: - logger.Fatalf("neither server nor client configuration is properly set.") - + go func() { + select { + case <-ctx.Done(): + case <-time.After(100 * time.Millisecond): + reportZeroCopy(ctx) + } + }() } + return provider.Run(ctx, engine.Request{ConfigPath: configPath, Config: cfg}) } // loadConfig loads and parses the TOML configuration file. func loadConfig(configPath string) (*config.Config, error) { - var cfg config.Config - if _, err := toml.DecodeFile(configPath, &cfg); err != nil { - return &cfg, err - } - return &cfg, nil + return config.LoadFile(configPath) } // reportZeroCopy says, periodically and in the tunnel's own journal, whether diff --git a/cmd/defaults.go b/cmd/defaults.go index 7faf224..39b3cdd 100644 --- a/cmd/defaults.go +++ b/cmd/defaults.go @@ -41,6 +41,9 @@ const ( // Default values ) func applyDefaults(cfg *config.Config) { + if cfg.EffectiveEngine() != config.EngineReverse { + return + } // Token if cfg.Server.Token == "" { cfg.Server.Token = defaultToken diff --git a/config/config.go b/config/config.go index 86e46d7..3d5f9cb 100644 --- a/config/config.go +++ b/config/config.go @@ -1,5 +1,90 @@ package config +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/BurntSushi/toml" +) + +// EngineType selects the implementation that owns an instance. An empty +// value is intentionally meaningful: it is the legacy spelling of reverse. +type EngineType string + +const ( + EngineReverse EngineType = "reverse" + EngineIPTables EngineType = "iptables" +) + +const ( + MaxPortsPerMapping = 1024 + MaxPortsPerInstance = 4096 +) + +// ForwardMapping is one offset-preserving direct forwarding rule. +type ForwardMapping struct { + ListenAddress string `toml:"listen_address" json:"listenAddress"` + ListenPorts string `toml:"listen_ports" json:"listenPorts"` + TargetAddress string `toml:"target_address" json:"targetAddress"` + TargetPorts string `toml:"target_ports" json:"targetPorts"` + Protocols []string `toml:"protocols" json:"protocols"` +} + +type ForwardConfig struct { + Mappings []ForwardMapping `toml:"mappings" json:"mappings"` +} + +// PortRange is the normalised inclusive form used by the netfilter engine. +type PortRange struct{ Start, End uint16 } + +func (r PortRange) Len() int { return int(r.End-r.Start) + 1 } + +func ParsePortRange(raw string) (PortRange, error) { + parts := strings.Split(strings.TrimSpace(raw), "-") + if len(parts) < 1 || len(parts) > 2 { + return PortRange{}, fmt.Errorf("invalid port range %q", raw) + } + parse := func(s string) (uint16, error) { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Errorf("invalid port %q", s) + } + return uint16(n), nil + } + lo, err := parse(parts[0]) + if err != nil { + return PortRange{}, err + } + hi := lo + if len(parts) == 2 { + hi, err = parse(parts[1]) + if err != nil { + return PortRange{}, err + } + } + if hi < lo { + return PortRange{}, fmt.Errorf("port range %q ends before it starts", raw) + } + return PortRange{Start: lo, End: hi}, nil +} + +func (m ForwardMapping) Ranges() (PortRange, PortRange, error) { + l, err := ParsePortRange(m.ListenPorts) + if err != nil { + return PortRange{}, PortRange{}, fmt.Errorf("listen_ports: %w", err) + } + t, err := ParsePortRange(m.TargetPorts) + if err != nil { + return PortRange{}, PortRange{}, fmt.Errorf("target_ports: %w", err) + } + if l.Len() != t.Len() { + return PortRange{}, PortRange{}, fmt.Errorf("listen and target ranges must contain the same number of ports") + } + return l, t, nil +} + // TransportType defines the type of transport. type TransportType string @@ -298,6 +383,155 @@ type ClientConfig struct { // Config represents the complete configuration, including both server and client settings. type Config struct { - Server ServerConfig `toml:"server"` - Client ClientConfig `toml:"client"` + Engine EngineType `toml:"engine"` + Server ServerConfig `toml:"server"` + Client ClientConfig `toml:"client"` + Forward ForwardConfig `toml:"forward"` + + sections sectionPresence `toml:"-"` +} + +type sectionPresence struct{ server, client, forward bool } + +// LoadFile is the canonical decoder. Besides decoding values it records table +// presence, which is required to distinguish an absent table from an empty but +// invalid one. +func LoadFile(path string) (*Config, error) { + var c Config + md, err := toml.DecodeFile(path, &c) + if err != nil { + return &c, err + } + c.sections = sectionPresence{ + server: md.IsDefined("server"), client: md.IsDefined("client"), forward: md.IsDefined("forward"), + } + if err := c.ValidateStructure(); err != nil { + return &c, err + } + return &c, nil +} + +// EffectiveEngine preserves the meaning of every pre-engine configuration. +func (c *Config) EffectiveEngine() EngineType { + if c.Engine == "" { + return EngineReverse + } + return c.Engine +} + +func (c *Config) HasForward() bool { return c.sections.forward || len(c.Forward.Mappings) > 0 } +func (c *Config) HasServer() bool { return c.sections.server || c.Server.BindAddr != "" } +func (c *Config) HasClient() bool { return c.sections.client || c.Client.RemoteAddr != "" } + +// ValidateStructure validates the engine/section matrix and portable direct +// mapping rules. It is deliberately side-effect free. +func (c *Config) ValidateStructure() error { + hasServer, hasClient, hasForward := c.HasServer(), c.HasClient(), c.HasForward() + if hasServer && hasClient { + return fmt.Errorf("[server] and [client] cannot exist together") + } + if hasForward && (hasServer || hasClient) { + return fmt.Errorf("[forward] cannot exist with [server] or [client]") + } + switch c.Engine { + case "": + if hasForward { + return fmt.Errorf("[forward] requires engine = %q", EngineIPTables) + } + if hasServer == hasClient { + return fmt.Errorf("a reverse instance requires exactly one of [server] or [client]") + } + case EngineReverse: + if hasForward || hasServer == hasClient { + return fmt.Errorf("engine %q requires exactly one of [server] or [client]", EngineReverse) + } + case EngineIPTables: + if !hasForward || hasServer || hasClient { + return fmt.Errorf("engine %q requires [forward] and no reverse section", EngineIPTables) + } + default: + return fmt.Errorf("unknown engine %q", c.Engine) + } + if c.EffectiveEngine() == EngineIPTables { + return ValidateForward(c.Forward) + } + return nil +} + +func ValidateForward(f ForwardConfig) error { + if len(f.Mappings) == 0 { + return fmt.Errorf("[forward] requires at least one mapping") + } + total := 0 + type tuple struct { + family, proto, addr string + ports PortRange + } + var seen []tuple + for i, m := range f.Mappings { + prefix := fmt.Sprintf("forward mapping %d", i+1) + listen, target := net.ParseIP(strings.TrimSpace(m.ListenAddress)), net.ParseIP(strings.TrimSpace(m.TargetAddress)) + if listen == nil { + return fmt.Errorf("%s: listen_address must be an explicit IPv4 or IPv6 address", prefix) + } + if target == nil { + return fmt.Errorf("%s: target_address must be an explicit IPv4 or IPv6 address", prefix) + } + lf, tf := "ipv6", "ipv6" + if listen.To4() != nil { + lf = "ipv4" + } + if target.To4() != nil { + tf = "ipv4" + } + if lf != tf { + return fmt.Errorf("%s: listen and target addresses must use the same family", prefix) + } + if target.IsUnspecified() || target.IsMulticast() || target.IsLoopback() || target.IsInterfaceLocalMulticast() || target.IsLinkLocalMulticast() { + return fmt.Errorf("%s: target_address must be a non-loopback unicast address", prefix) + } + if v4 := target.To4(); v4 != nil && v4.Equal(net.IPv4bcast) { + return fmt.Errorf("%s: IPv4 broadcast targets are not supported", prefix) + } + lr, _, err := m.Ranges() + if err != nil { + return fmt.Errorf("%s: %w", prefix, err) + } + if lr.Len() > MaxPortsPerMapping { + return fmt.Errorf("%s expands to %d ports; maximum per mapping is %d", prefix, lr.Len(), MaxPortsPerMapping) + } + total += lr.Len() + if total > MaxPortsPerInstance { + return fmt.Errorf("forward instance expands to %d ports; maximum is %d", total, MaxPortsPerInstance) + } + if len(m.Protocols) == 0 { + return fmt.Errorf("%s: at least one protocol is required", prefix) + } + protos := map[string]bool{} + for _, raw := range m.Protocols { + p := strings.ToLower(strings.TrimSpace(raw)) + if p != "tcp" && p != "udp" { + return fmt.Errorf("%s: unsupported protocol %q (use tcp or udp)", prefix, raw) + } + if protos[p] { + return fmt.Errorf("%s: protocol %q is duplicated", prefix, p) + } + protos[p] = true + addr := listen.String() + wild := listen.IsUnspecified() + for _, old := range seen { + if old.family != lf || old.proto != p || old.ports.End < lr.Start || lr.End < old.ports.Start { + continue + } + if wild || old.addr == "*" || old.addr == addr { + return fmt.Errorf("%s overlaps an earlier %s mapping on %s ports %d-%d", prefix, p, lf, lr.Start, lr.End) + } + } + if wild { + addr = "*" + } + seen = append(seen, tuple{family: lf, proto: p, addr: addr, ports: lr}) + } + } + return nil } diff --git a/config/engine_test.go b/config/engine_test.go new file mode 100644 index 0000000..61fba28 --- /dev/null +++ b/config/engine_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "x.toml") + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLegacyEngineDefaultsToReverse(t *testing.T) { + c, err := LoadFile(writeConfig(t, "[server]\nbind_addr=':1'\ntransport='tcp'\n")) + if err != nil { + t.Fatal(err) + } + if got := c.EffectiveEngine(); got != EngineReverse { + t.Fatalf("engine=%q", got) + } +} + +func TestEngineSectionMatrix(t *testing.T) { + cases := []string{ + "[server]\nbind_addr=':1'\n[client]\nremote_addr='x:1'\n", + "[server]\n[client]\n", + "[forward]\n[[forward.mappings]]\nlisten_address='0.0.0.0'\nlisten_ports='1'\ntarget_address='192.0.2.1'\ntarget_ports='1'\nprotocols=['tcp']\n", + "engine='iptables'\n[server]\nbind_addr=':1'\n", + "engine='iptables'\n", + "engine='iptables'\n[forward]\n[server]\nbind_addr=':1'\n", + "engine='reverse'\n[forward]\n", + "engine='unknown'\n[client]\nremote_addr='x:1'\n", + } + for _, body := range cases { + if _, err := LoadFile(writeConfig(t, body)); err == nil { + t.Errorf("accepted invalid config:\n%s", body) + } + } +} + +func TestExplicitReverseAndLegacyClientRemainValid(t *testing.T) { + for _, body := range []string{ + "engine='reverse'\n[server]\nbind_addr=':1'\ntransport='tcp'\n", + "[client]\nremote_addr='192.0.2.1:1'\ntransport='tcp'\n", + } { + cfg, err := LoadFile(writeConfig(t, body)) + if err != nil { + t.Fatalf("valid reverse config rejected: %v\n%s", err, body) + } + if cfg.EffectiveEngine() != EngineReverse || cfg.HasForward() { + t.Fatalf("legacy meaning changed: %#v", cfg) + } + } +} + +func TestForwardValidation(t *testing.T) { + body := "engine='iptables'\n[forward]\n[[forward.mappings]]\nlisten_address='0.0.0.0'\nlisten_ports='1000-1002'\ntarget_address='192.0.2.10'\ntarget_ports='2000-2002'\nprotocols=['tcp','udp']\n" + if _, err := LoadFile(writeConfig(t, body)); err != nil { + t.Fatal(err) + } + bad := strings.Replace(body, "2000-2002", "2000-2003", 1) + if _, err := LoadFile(writeConfig(t, bad)); err == nil || !strings.Contains(err.Error(), "same number") { + t.Fatalf("range error=%v", err) + } +} + +func TestForwardOverlapAndLimit(t *testing.T) { + f := ForwardConfig{Mappings: []ForwardMapping{ + {ListenAddress: "0.0.0.0", ListenPorts: "100-200", TargetAddress: "192.0.2.1", TargetPorts: "100-200", Protocols: []string{"tcp"}}, + {ListenAddress: "192.0.2.5", ListenPorts: "150", TargetAddress: "192.0.2.2", TargetPorts: "150", Protocols: []string{"tcp"}}, + }} + if err := ValidateForward(f); err == nil || !strings.Contains(err.Error(), "overlaps") { + t.Fatalf("overlap error=%v", err) + } + f.Mappings = []ForwardMapping{{ListenAddress: "::", ListenPorts: "1-1025", TargetAddress: "2001:db8::1", TargetPorts: "1-1025", Protocols: []string{"udp"}}} + if err := ValidateForward(f); err == nil || !strings.Contains(err.Error(), "maximum") { + t.Fatalf("limit error=%v", err) + } + f.Mappings = nil + for i := 0; i < 5; i++ { + lo, hi := i*1000+1, (i+1)*1000 + f.Mappings = append(f.Mappings, ForwardMapping{ + ListenAddress: "0.0.0.0", ListenPorts: fmt.Sprintf("%d-%d", lo, hi), + TargetAddress: "192.0.2.1", TargetPorts: fmt.Sprintf("%d-%d", lo, hi), Protocols: []string{"udp"}, + }) + } + if err := ValidateForward(f); err == nil || !strings.Contains(err.Error(), "maximum is 4096") { + t.Fatalf("instance expansion limit error=%v", err) + } +} + +func TestForwardRejectsInvalidTargetsAndProtocols(t *testing.T) { + base := ForwardMapping{ListenAddress: "0.0.0.0", ListenPorts: "80", TargetAddress: "192.0.2.1", TargetPorts: "8080", Protocols: []string{"tcp"}} + for name, mutate := range map[string]func(*ForwardMapping){ + "domain": func(m *ForwardMapping) { m.TargetAddress = "example.com" }, + "loopback": func(m *ForwardMapping) { m.TargetAddress = "127.0.0.1" }, + "multicast": func(m *ForwardMapping) { m.TargetAddress = "224.0.0.1" }, + "unspecified": func(m *ForwardMapping) { m.TargetAddress = "0.0.0.0" }, + "mixed-family": func(m *ForwardMapping) { + m.TargetAddress = "2001:db8::1" + }, + "protocol": func(m *ForwardMapping) { m.Protocols = []string{"sctp"} }, + "empty-protocol": func(m *ForwardMapping) { + m.Protocols = nil + }, + } { + t.Run(name, func(t *testing.T) { + m := base + mutate(&m) + if err := ValidateForward(ForwardConfig{Mappings: []ForwardMapping{m}}); err == nil { + t.Fatalf("invalid mapping accepted: %#v", m) + } + }) + } +} diff --git a/docs/README.md b/docs/README.md index d12e5c9..41892b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ Detailed pages for each part of Backpack. Start with the ### Setup & management - [The CLI menu](cli-menu.md) +- [Direct forwarding with iptables](direct-forward.md) - [Failover & load balancing](failover-load-balancing.md) - [Per-tunnel limits](limits.md) - [Server layout (file locations)](server-layout.md) diff --git a/docs/direct-forward.md b/docs/direct-forward.md new file mode 100644 index 0000000..f548cae --- /dev/null +++ b/docs/direct-forward.md @@ -0,0 +1,82 @@ +# Direct forwarding (iptables) + +Backpack can forward TCP and UDP traffic directly through the Linux kernel in +addition to its existing reverse-tunnel mode. Each direct-forward config is a +long-running systemd instance: it installs and watches its own rules, records +traffic counters, and removes only its owned rules when stopped. + +Create one from **Setup Direct Forward** in the CLI, or use this TOML form: + +```toml +engine = "iptables" + +[forward] + +[[forward.mappings]] +listen_address = "0.0.0.0" +listen_ports = "443" +target_address = "203.0.113.10" +target_ports = "8443" +protocols = ["tcp", "udp"] +``` + +`0.0.0.0` listens on every local IPv4 address and `::` does the same for IPv6. +A specific listen address must exist on a local interface when the instance +starts. The target must be an explicit, same-family unicast IP address; host +names, loopback, multicast, unspecified addresses and IPv4/IPv6 translation +are intentionally rejected. + +Ports can be single values or equal-length ranges. Range mapping preserves the +offset, for example `10000-10009` to `20000-20009`. A mapping is limited to +1,024 expanded ports and an instance to 4,096. Only `tcp` and `udp` are valid. + +## Behaviour and requirements + +- Linux and root privileges are required. The installer provides the iptables + suite. Both `iptables-nft` and `iptables-legacy` are supported, but the + command, save and restore tools must use the same backend. +- IPv4-only configs do not require IPv6 tools. IPv6 tools are checked only + when an IPv6 mapping is used. +- Backpack enables `net.ipv4.ip_forward` and, when needed, IPv6 forwarding on + every start. Stop and uninstall do not turn these shared host settings off. +- Direct forwarding applies only to ingress traffic. Backpack creates no + general `OUTPUT` rule, so locally generated host traffic is not redirected. +- DNAT connections receive an instance-specific connection mark. FORWARD and + MASQUERADE rules require that mark and the exact configured tuple. +- Rule changes are prepared in detached chains. Hooks are activated last and + failures roll back the new generation. Rules carry a structured ownership + comment, so cleanup never flushes a table or deletes an unrelated rule. + +Backpack refuses to start if a mapping overlaps another Backpack config, a +local TCP/UDP listener, or an existing DNAT rule. If an nftables, multiport or +ipset expression may overlap but cannot be interpreted safely, validation +fails closed and reports the rule that needs review. + +## Health and counters + +Direct health represents local desired state: service, forwarding sysctls, +backend, chains, hooks and the desired rule hash. Target reachability does not +make the service unhealthy. **Diagnose** reports routes and an optional TCP +probe separately; UDP reachability remains unknown because a connected UDP +socket is not proof of a reachable service. + +RX/TX packets and bytes are counted by dedicated FORWARD accounting rules. +Cumulative values are persisted under `/etc/backpack/forward-state` before a +generation is removed, so restart, reconcile and reboot do not reduce the +Prometheus totals. + +Legacy configs with no `engine` field remain reverse tunnels and need no +migration. `mode` is display-only metadata and must not be written to TOML. + +## Integration test + +On a disposable Linux CI runner with root and network namespaces enabled, run: + +```bash +sudo BACKPACK_NETNS_TEST=1 go test ./internal/engine -run TestDirectNetNSAcceptance -v +``` + +The test builds isolated client, ingress and target namespaces and covers IPv4 +and IPv6, TCP and UDP, single-port and range remapping, MASQUERADE source +visibility, stop cleanup and restart recovery. It is skipped during ordinary +unprivileged unit-test runs. diff --git a/install.sh b/install.sh index 056385e..624160e 100755 --- a/install.sh +++ b/install.sh @@ -28,9 +28,9 @@ err() { echo -e "${RED}[x]${NC} $*" >&2; } REPO="AminMGMT/BackPack" BIN_PATH="/usr/local/bin/backpack" INSTALL_DIR="/root/BackPack" -GO_VERSION="1.24.5" +GO_VERSION="1.25.0" # toolchain already on the machine is not usable for a source build. -GO_MIN_MINOR=24 +GO_MIN_MINOR=25 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-/tmp}")" 2>/dev/null && pwd || echo /tmp)" if [[ $EUID -ne 0 ]]; then err "Please run as root (sudo)."; exit 1; fi @@ -95,7 +95,8 @@ install_release() { cp "$cand" "$INSTALL_DIR/$ASSET" # An offline install can carry SHA256SUMS beside the archive; verify it # when it is there, and say plainly when it is not. - local localsums="$(dirname "$cand")/SHA256SUMS" + local localsums + localsums="$(dirname "$cand")/SHA256SUMS" if [[ -f "$localsums" ]]; then # `|| rc=$?` rather than a bare call: `set -e` is currently suppressed # here because install_release runs inside `if`, so a bare call happens @@ -178,6 +179,43 @@ build_from_source() { echo "$INSTALL_DIR" > /etc/backpack/install_path } +# Direct-forward instances use the distribution's iptables compatibility +# suite. The engine itself detects nft vs legacy at runtime and verifies that +# command/save/restore all belong to the same backend. We deliberately do not +# write sysctl.d here: forwarding is enabled and verified by each running +# direct instance, and it is a shared host setting that uninstall must not +# disable. +ensure_netfilter_tools() { + if command -v iptables >/dev/null 2>&1 \ + && command -v iptables-save >/dev/null 2>&1 \ + && command -v iptables-restore >/dev/null 2>&1; then + info "Netfilter tools: $(iptables --version 2>/dev/null | head -1)" + return + fi + + warn "iptables tools are missing; installing the distribution package..." + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y iptables + elif command -v dnf >/dev/null 2>&1; then + dnf install -y iptables + elif command -v yum >/dev/null 2>&1; then + yum install -y iptables + elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm iptables + else + err "No supported package manager was found. Install iptables manually." + exit 1 + fi + + if ! command -v iptables >/dev/null 2>&1 \ + || ! command -v iptables-save >/dev/null 2>&1 \ + || ! command -v iptables-restore >/dev/null 2>&1; then + err "iptables command/save/restore are still unavailable." + exit 1 + fi +} + if install_release; then install_binary_from_tar info "Installed release binary -> ${BIN_PATH}" @@ -194,6 +232,7 @@ else fi chmod +x "$BIN_PATH" +ensure_netfilter_tools echo echo -e "${WHITE}Done!${NC}" diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..0677814 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,101 @@ +// Package engine is the registry shared by the runtime and management planes. +// Engine selection is config-driven; mode is metadata and never persisted in +// the TOML. +package engine + +import ( + "context" + "fmt" + "sync" + + "github.com/backpack/backpack/config" +) + +type Metadata struct { + Name string + Mode string +} + +type Request struct { + ConfigPath string + Config *config.Config +} + +type Health struct { + Ready bool + Backend string + Detail string + Drift []string +} + +type Counters struct { + RXBytes, TXBytes uint64 + RXPackets, TXPackets uint64 +} + +// Provider defines lifecycle semantics for every implementation. Validate and +// Health are read-only. Run owns a long-lived instance and returns startup or +// runtime failures. Cleanup is safe without a running process. +type Provider interface { + Metadata() Metadata + Validate(context.Context, Request) error + Run(context.Context, Request) error + Health(context.Context, Request) (Health, error) + Counters(context.Context, Request) (Counters, error) + Cleanup(context.Context, Request) error +} + +var ( + mu sync.RWMutex + providers = map[config.EngineType]Provider{} +) + +func Register(name config.EngineType, provider Provider) { + mu.Lock() + defer mu.Unlock() + if name == "" || provider == nil { + panic("engine: invalid registration") + } + if _, exists := providers[name]; exists { + panic("engine: duplicate registration: " + string(name)) + } + providers[name] = provider +} + +func Get(name config.EngineType) (Provider, error) { + if name == "" { + name = config.EngineReverse + } + mu.RLock() + p := providers[name] + mu.RUnlock() + if p == nil { + return nil, fmt.Errorf("engine %q is not registered", name) + } + return p, nil +} + +func Resolve(cfg *config.Config) (Provider, error) { + if cfg == nil { + return nil, fmt.Errorf("nil configuration") + } + if err := cfg.ValidateStructure(); err != nil { + return nil, err + } + return Get(cfg.EffectiveEngine()) +} + +func MetadataFor(cfg *config.Config) (Metadata, error) { + p, err := Resolve(cfg) + if err != nil { + return Metadata{}, err + } + return p.Metadata(), nil +} + +// CleanupOrphans removes only netfilter objects whose full structured +// ownership matches an identity in configDir. With all=false, live configs are +// left alone; uninstall passes all=true after stopping known services. +func CleanupOrphans(ctx context.Context, configDir string, all bool) error { + return cleanupOrphans(ctx, configDir, all) +} diff --git a/internal/engine/iptables_linux.go b/internal/engine/iptables_linux.go new file mode 100644 index 0000000..cad6cf0 --- /dev/null +++ b/internal/engine/iptables_linux.go @@ -0,0 +1,1518 @@ +//go:build linux + +package engine + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/instanceid" + "golang.org/x/sys/unix" +) + +type iptablesProvider struct{} + +func init() { Register(config.EngineIPTables, iptablesProvider{}) } +func (iptablesProvider) Metadata() Metadata { return Metadata{Name: "iptables", Mode: "direct"} } + +type familyTools struct { + family string + cmd, save, restore string + backend string +} + +type expandedRule struct { + family, proto, listen, target string + listenPort, targetPort uint16 +} + +type generation struct { + num uint64 + key string + chains map[string]map[string]string // family -> N/F/P -> chain +} + +type createdChain struct { + family, purpose, name string +} + +type commandRunner interface { + Combined(context.Context, string, ...string) ([]byte, error) +} +type osRunner struct{} + +func (osRunner) Combined(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + +var nfRunner commandRunner = osRunner{} + +const netfilterLockPath = "/run/backpack/netfilter.lock" + +// RemoveRuntimeArtifacts removes the global netfilter lock after a full +// uninstall. Callers must stop all Backpack services and clean every owned +// instance first; normal instance stop/delete deliberately leaves this shared +// lock in place for the other instances. +func RemoveRuntimeArtifacts() error { + if err := os.Remove(netfilterLockPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove netfilter lock: %w", err) + } + if err := os.Remove(filepath.Dir(netfilterLockPath)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove netfilter runtime directory: %w", err) + } + return nil +} + +func withNetfilterLock(fn func() error) error { + if err := os.MkdirAll(filepath.Dir(netfilterLockPath), 0o755); err != nil { + return err + } + f, err := os.OpenFile(netfilterLockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer f.Close() + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + return err + } + defer unix.Flock(int(f.Fd()), unix.LOCK_UN) + return fn() +} + +func neededFamilies(cfg *config.Config) []string { + set := map[string]bool{} + for _, m := range cfg.Forward.Mappings { + ip := net.ParseIP(m.ListenAddress) + if ip != nil && ip.To4() != nil { + set["ipv4"] = true + } else { + set["ipv6"] = true + } + } + var out []string + for _, f := range []string{"ipv4", "ipv6"} { + if set[f] { + out = append(out, f) + } + } + return out +} + +func detectTools(ctx context.Context, cfg *config.Config) (map[string]familyTools, error) { + out := map[string]familyTools{} + for _, family := range neededFamilies(cfg) { + prefix := "iptables" + if family == "ipv6" { + prefix = "ip6tables" + } + t := familyTools{family: family, cmd: prefix, save: prefix + "-save", restore: prefix + "-restore"} + for _, bin := range []string{t.cmd, t.save, t.restore} { + if _, err := exec.LookPath(bin); err != nil { + return nil, fmt.Errorf("%s forward requires %s: %w", family, bin, err) + } + } + versions := map[string]bool{} + for _, bin := range []string{t.cmd, t.save, t.restore} { + b, err := nfRunner.Combined(ctx, bin, "--version") + if err != nil { + return nil, fmt.Errorf("cannot run %s --version: %s", bin, strings.TrimSpace(string(b))) + } + v := strings.ToLower(string(b)) + backend := "legacy" + if strings.Contains(v, "nf_tables") { + backend = "nft" + } + versions[backend] = true + } + if len(versions) != 1 { + return nil, fmt.Errorf("%s command/save/restore tools use mixed backends", family) + } + for v := range versions { + t.backend = v + } + if t.backend == "nft" { + if _, err := exec.LookPath("nft"); err != nil { + return nil, fmt.Errorf("iptables-nft conflict inspection requires the nft command") + } + } + out[family] = t + } + return out, nil +} + +func augmentOptionalTools(ctx context.Context, tools map[string]familyTools) map[string]familyTools { + out := map[string]familyTools{} + for k, v := range tools { + out[k] = v + } + for _, family := range []string{"ipv4", "ipv6"} { + if _, exists := out[family]; exists { + continue + } + listen, target := "0.0.0.0", "192.0.2.1" + if family == "ipv6" { + listen, target = "::", "2001:db8::1" + } + dummy := &config.Config{Engine: config.EngineIPTables, Forward: config.ForwardConfig{Mappings: []config.ForwardMapping{{ListenAddress: listen, ListenPorts: "1", TargetAddress: target, TargetPorts: "1", Protocols: []string{"tcp"}}}}} + if detected, err := detectTools(ctx, dummy); err == nil { + for k, v := range detected { + out[k] = v + } + } + } + return out +} + +func checkCapabilities(ctx context.Context, tools map[string]familyTools) error { + for _, t := range tools { + for _, args := range [][]string{{"-m", "conntrack", "-h"}, {"-m", "comment", "-h"}, {"-j", "CONNMARK", "-h"}, {"-t", "nat", "-j", "DNAT", "-h"}, {"-t", "nat", "-j", "MASQUERADE", "-h"}} { + b, err := nfRunner.Combined(ctx, t.cmd, args...) + if err != nil { + return fmt.Errorf("%s backend lacks %s: %s", t.family, strings.Join(args, " "), strings.TrimSpace(string(b))) + } + } + } + return nil +} + +func expand(cfg *config.Config) ([]expandedRule, error) { + var out []expandedRule + for _, m := range cfg.Forward.Mappings { + lr, tr, err := m.Ranges() + if err != nil { + return nil, err + } + family := "ipv6" + if net.ParseIP(m.ListenAddress).To4() != nil { + family = "ipv4" + } + for _, raw := range m.Protocols { + p := strings.ToLower(strings.TrimSpace(raw)) + for n := 0; n < lr.Len(); n++ { + out = append(out, expandedRule{family: family, proto: p, listen: net.ParseIP(m.ListenAddress).String(), target: net.ParseIP(m.TargetAddress).String(), listenPort: lr.Start + uint16(n), targetPort: tr.Start + uint16(n)}) + } + } + } + return out, nil +} + +func desiredHash(cfg *config.Config) string { + b, _ := json.Marshal(cfg.Forward) + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +func genKey(n uint64) (string, error) { + const max = 36*36*36*36 - 1 + if n > max { + return "", fmt.Errorf("netfilter generation limit reached") + } + s := strings.ToLower(strconv.FormatUint(n, 36)) + return strings.Repeat("0", 4-len(s)) + s, nil +} + +func makeGeneration(id instanceid.Identity, n uint64, families []string) (generation, error) { + key, err := genKey(n) + if err != nil { + return generation{}, err + } + g := generation{num: n, key: key, chains: map[string]map[string]string{}} + h := instanceid.Hash80(id.InstanceID) + for _, f := range families { + digit := "4" + if f == "ipv6" { + digit = "6" + } + g.chains[f] = map[string]string{} + for _, p := range []string{"N", "F", "P"} { + g.chains[f][p] = "B" + digit + p + h + key + } + } + return g, nil +} + +func comment(id instanceid.Identity, purpose, gen string) string { + return "backpack:" + id.InstanceID + ":" + purpose + ":" + gen +} +func markText(mark uint32) string { return fmt.Sprintf("0x%x/0xffffffff", mark) } + +func runNF(ctx context.Context, bin string, args ...string) error { + b, err := nfRunner.Combined(ctx, bin, args...) + if err != nil { + return fmt.Errorf("%s %s: %w: %s", bin, strings.Join(args, " "), err, strings.TrimSpace(string(b))) + } + return nil +} + +func appendRule(ctx context.Context, t familyTools, table, chain string, args ...string) error { + a := []string{"-w", "5", "-t", table, "-A", chain} + a = append(a, args...) + return runNF(ctx, t.cmd, a...) +} + +func targetArg(r expandedRule) string { + if r.family == "ipv6" { + return fmt.Sprintf("[%s]:%d", r.target, r.targetPort) + } + return fmt.Sprintf("%s:%d", r.target, r.targetPort) +} + +func buildDetached(ctx context.Context, id instanceid.Identity, g generation, tools map[string]familyTools, rules []expandedRule) error { + var created []createdChain + rollbackCreated := func() { + for i := len(created) - 1; i >= 0; i-- { + item := created[i] + table := "nat" + if item.purpose == "F" { + table = "filter" + } + t := tools[item.family] + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-F", item.name) + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-X", item.name) + } + } + for family, chains := range g.chains { + t := tools[family] + for purpose, chain := range chains { + table := "nat" + if purpose == "F" { + table = "filter" + } + if err := runNF(ctx, t.cmd, "-w", "5", "-t", table, "-N", chain); err != nil { + rollbackCreated() + return err + } + created = append(created, createdChain{family: family, purpose: purpose, name: chain}) + } + } + for _, r := range rules { + t, c := tools[r.family], g.chains[r.family] + base := []string{"-p", r.proto} + if !net.ParseIP(r.listen).IsUnspecified() { + base = append(base, "-d", r.listen) + } + base = append(base, "--dport", strconv.Itoa(int(r.listenPort)), "-m", "conntrack", "--ctstate", "NEW") + markRule := append(append([]string{}, base...), "-m", "comment", "--comment", comment(id, "mark", g.key), "-j", "CONNMARK", "--set-xmark", markText(id.Connmark)) + if err := appendRule(ctx, t, "nat", c["N"], markRule...); err != nil { + rollbackCreated() + return err + } + dnat := append(append([]string{}, base...), "-m", "connmark", "--mark", markText(id.Connmark), "-m", "comment", "--comment", comment(id, "dnat", g.key), "-j", "DNAT", "--to-destination", targetArg(r)) + if err := appendRule(ctx, t, "nat", c["N"], dnat...); err != nil { + rollbackCreated() + return err + } + + rx := []string{"-m", "connmark", "--mark", markText(id.Connmark), "-p", r.proto, "-d", r.target, "--dport", strconv.Itoa(int(r.targetPort)), "-m", "conntrack", "--ctstate", "NEW,ESTABLISHED,RELATED", "-m", "comment", "--comment", comment(id, "acct-rx", g.key), "-j", "ACCEPT"} + if err := appendRule(ctx, t, "filter", c["F"], rx...); err != nil { + rollbackCreated() + return err + } + tx := []string{"-m", "connmark", "--mark", markText(id.Connmark), "-p", r.proto, "-s", r.target, "--sport", strconv.Itoa(int(r.targetPort)), "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-m", "comment", "--comment", comment(id, "acct-tx", g.key), "-j", "ACCEPT"} + if err := appendRule(ctx, t, "filter", c["F"], tx...); err != nil { + rollbackCreated() + return err + } + masq := []string{"-m", "connmark", "--mark", markText(id.Connmark), "-p", r.proto, "-d", r.target, "--dport", strconv.Itoa(int(r.targetPort)), "-m", "comment", "--comment", comment(id, "masquerade", g.key), "-j", "MASQUERADE"} + if err := appendRule(ctx, t, "nat", c["P"], masq...); err != nil { + rollbackCreated() + return err + } + } + return nil +} + +func installHooks(ctx context.Context, id instanceid.Identity, g generation, tools map[string]familyTools) error { + for family, c := range g.chains { + t := tools[family] + if err := runNF(ctx, t.cmd, "-w", "5", "-t", "nat", "-I", "POSTROUTING", "1", "-m", "connmark", "--mark", markText(id.Connmark), "-m", "comment", "--comment", comment(id, "hook-postrouting", g.key), "-j", c["P"]); err != nil { + return err + } + if err := runNF(ctx, t.cmd, "-w", "5", "-t", "filter", "-I", "FORWARD", "1", "-m", "connmark", "--mark", markText(id.Connmark), "-m", "comment", "--comment", comment(id, "hook-forward", g.key), "-j", c["F"]); err != nil { + return err + } + // PREROUTING is the ingress gate and is deliberately installed last. + if err := runNF(ctx, t.cmd, "-w", "5", "-t", "nat", "-I", "PREROUTING", "1", "-m", "comment", "--comment", comment(id, "hook-prerouting", g.key), "-j", c["N"]); err != nil { + return err + } + } + return nil +} + +func saveTable(ctx context.Context, t familyTools, table string) (string, error) { + b, err := nfRunner.Combined(ctx, t.save, "-c", "-t", table) + if err != nil { + return "", fmt.Errorf("%s -c -t %s: %w: %s", t.save, table, err, strings.TrimSpace(string(b))) + } + return string(b), nil +} + +func verifyGeneration(ctx context.Context, id instanceid.Identity, g generation, tools map[string]familyTools, rules []expandedRule, hooked bool) error { + for family, c := range g.chains { + t := tools[family] + nat, err := saveTable(ctx, t, "nat") + if err != nil { + return err + } + filter, err := saveTable(ctx, t, "filter") + if err != nil { + return err + } + for _, ch := range []string{c["N"], c["P"]} { + if !strings.Contains(nat, ":"+ch+" ") { + return fmt.Errorf("verification failed: nat chain %s missing", ch) + } + } + if !strings.Contains(filter, ":"+c["F"]+" ") { + return fmt.Errorf("verification failed: filter chain %s missing", c["F"]) + } + expected := 0 + for _, r := range rules { + if r.family == family { + expected++ + } + } + for purpose, source := range map[string]string{ + "mark": nat, "dnat": nat, "masquerade": nat, "acct-rx": filter, "acct-tx": filter, + } { + if got := strings.Count(source, comment(id, purpose, g.key)); got != expected { + return fmt.Errorf("verification failed: generation %s %s has %d rules, want %d", g.key, purpose, got, expected) + } + } + if hooked { + for purpose, source := range map[string]string{"hook-prerouting": nat, "hook-postrouting": nat, "hook-forward": filter} { + if got := strings.Count(source, comment(id, purpose, g.key)); got != 1 { + return fmt.Errorf("verification failed: generation %s %s count is %d", g.key, purpose, got) + } + } + } + } + return nil +} + +func liveRulesHash(ctx context.Context, id instanceid.Identity, g generation, tools map[string]familyTools) (string, error) { + var lines []string + for family, t := range tools { + owned := map[string]bool{} + for _, ch := range g.chains[family] { + owned[ch] = true + } + for _, table := range []string{"nat", "filter"} { + raw, err := saveTable(ctx, t, table) + if err != nil { + return "", err + } + for _, line := range strings.Split(raw, "\n") { + normal := line + if i := strings.Index(normal, "] "); i >= 0 && strings.HasPrefix(normal, "[") { + normal = normal[i+2:] + } + f := splitRule(normal) + isOwnedChain := len(f) >= 2 && f[0] == "-A" && owned[f[1]] + isHook := strings.Contains(normal, "backpack:"+id.InstanceID+":hook-") && strings.Contains(normal, ":"+g.key) + if isOwnedChain || isHook { + lines = append(lines, family+"/"+table+":"+normal) + } + } + } + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:]), nil +} + +var counterLine = regexp.MustCompile(`^\[(\d+):(\d+)\].*--comment "?backpack:([^:\s"]+):(acct-rx|acct-tx):([^\s"]+)"?`) + +func scrape(ctx context.Context, id instanceid.Identity, tools map[string]familyTools) (map[string]kernelCount, error) { + out := map[string]kernelCount{} + for _, t := range tools { + s, err := saveTable(ctx, t, "filter") + if err != nil { + return nil, err + } + for _, line := range strings.Split(s, "\n") { + m := counterLine.FindStringSubmatch(line) + if len(m) != 6 || m[3] != id.InstanceID { + continue + } + pk, _ := strconv.ParseUint(m[1], 10, 64) + by, _ := strconv.ParseUint(m[2], 10, 64) + c := out[m[5]] + if m[4] == "acct-rx" { + c.RXPackets += pk + c.RXBytes += by + } else { + c.TXPackets += pk + c.TXBytes += by + } + out[m[5]] = c + } + } + return out, nil +} + +func updateCountersLocked(ctx context.Context, r Request, id instanceid.Identity, tools map[string]familyTools, s *forwardState) error { + return updateCountersLockedMode(ctx, r, id, tools, s, true) +} + +func updateCountersLockedMode(ctx context.Context, r Request, id instanceid.Identity, tools map[string]familyTools, s *forwardState, includeSession bool) error { + current, err := scrape(ctx, id, tools) + if err != nil { + return err + } + for gen, c := range current { + prev := s.Last[gen] + addDelta(&s.Cumulative, prev, c) + if includeSession { + addDelta(&s.Session, prev, c) + } + s.Last[gen] = c + } + if err := saveForwardState(r.ConfigPath, *s); err != nil { + return err + } + return persistMetrics(r, *s) +} + +func requireStateTools(s forwardState, tools map[string]familyTools) error { + for _, family := range s.Families { + if _, ok := tools[family]; !ok { + name := "iptables" + if family == "ipv6" { + name = "ip6tables" + } + return fmt.Errorf("persisted generation uses %s but compatible %s command/save/restore tools are unavailable", family, name) + } + } + return nil +} + +// splitRule is sufficient for iptables-save output (quoted comments contain no +// spaces in Backpack ownership strings). +func splitRule(s string) []string { return strings.Fields(strings.ReplaceAll(s, "\"", "")) } + +func deleteOwnedHooks(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, purpose string) error { + for _, t := range tools { + for _, table := range []string{"nat", "filter"} { + s, err := saveTable(ctx, t, table) + if err != nil { + return err + } + for _, line := range strings.Split(s, "\n") { + if !strings.HasPrefix(line, "[") || !strings.Contains(line, "backpack:"+id.InstanceID+":"+purpose+":") { + continue + } + i := strings.Index(line, "] ") + if i < 0 { + continue + } + args := splitRule(line[i+2:]) + if len(args) < 2 || args[0] != "-A" { + continue + } + args[0] = "-D" + cmd := append([]string{"-w", "5", "-t", table}, args...) + if err := runNF(ctx, t.cmd, cmd...); err != nil { + return err + } + } + } + } + return nil +} + +func deleteGenerationHooks(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, purpose, gen string) error { + needle := "backpack:" + id.InstanceID + ":" + purpose + ":" + gen + for _, t := range tools { + for _, table := range []string{"nat", "filter"} { + s, err := saveTable(ctx, t, table) + if err != nil { + return err + } + for _, line := range strings.Split(s, "\n") { + if !strings.HasPrefix(line, "[") || !strings.Contains(line, needle) { + continue + } + i := strings.Index(line, "] ") + if i < 0 { + continue + } + args := splitRule(line[i+2:]) + if len(args) < 2 || args[0] != "-A" { + continue + } + args[0] = "-D" + cmd := append([]string{"-w", "5", "-t", table}, args...) + if err := runNF(ctx, t.cmd, cmd...); err != nil { + return err + } + } + } + } + return nil +} + +func deleteOtherGenerationHooks(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, purpose, keepGen string) error { + prefix := "backpack:" + id.InstanceID + ":" + purpose + ":" + keep := prefix + keepGen + for _, t := range tools { + for _, table := range []string{"nat", "filter"} { + s, err := saveTable(ctx, t, table) + if err != nil { + return err + } + for _, line := range strings.Split(s, "\n") { + if !strings.HasPrefix(line, "[") || !strings.Contains(line, prefix) || strings.Contains(line, keep) { + continue + } + i := strings.Index(line, "] ") + if i < 0 { + continue + } + args := splitRule(line[i+2:]) + if len(args) < 2 || args[0] != "-A" { + continue + } + args[0] = "-D" + cmd := append([]string{"-w", "5", "-t", table}, args...) + if err := runNF(ctx, t.cmd, cmd...); err != nil { + return err + } + } + } + } + return nil +} + +func ownedChains(ctx context.Context, id instanceid.Identity, tools map[string]familyTools) (map[string]map[string][]string, error) { + out := map[string]map[string][]string{} + prefixes := map[string]string{} + h := instanceid.Hash80(id.InstanceID) + for _, f := range []string{"ipv4", "ipv6"} { + digit := "4" + if f == "ipv6" { + digit = "6" + } + for _, p := range []string{"N", "F", "P"} { + prefixes["B"+digit+p+h] = p + } + } + for family, t := range tools { + out[family] = map[string][]string{"nat": {}, "filter": {}} + for _, table := range []string{"nat", "filter"} { + s, err := saveTable(ctx, t, table) + if err != nil { + return nil, err + } + candidates := map[string]bool{} + valid, invalid := map[string]bool{}, map[string]bool{} + for _, line := range strings.Split(s, "\n") { + if !strings.HasPrefix(line, ":") { + if strings.Contains(line, " -A ") || strings.HasPrefix(line, "-A ") { + ruleText := line + if i := strings.Index(ruleText, "] "); i >= 0 { + ruleText = ruleText[i+2:] + } + f := splitRule(ruleText) + if len(f) >= 2 && f[0] == "-A" && candidates[f[1]] { + if strings.Contains(line, "backpack:"+id.InstanceID+":") { + valid[f[1]] = true + } else { + invalid[f[1]] = true + } + } + } + continue + } + name := strings.Fields(strings.TrimPrefix(line, ":"))[0] + for pre, p := range prefixes { + if strings.HasPrefix(name, pre) { + expected := "nat" + if p == "F" { + expected = "filter" + } + if table == expected { + candidates[name] = true + } + } + } + } + // A hash-shaped name alone is never sufficient ownership. At least one + // rule must carry the full instance ID and no rule may be unowned. + for name := range candidates { + if valid[name] && !invalid[name] { + out[family][table] = append(out[family][table], name) + } + } + } + } + return out, nil +} + +func removeOwnedChains(ctx context.Context, id instanceid.Identity, tools map[string]familyTools) error { + chains, err := ownedChains(ctx, id, tools) + if err != nil { + return err + } + for family, tables := range chains { + t := tools[family] + for _, table := range []string{"filter", "nat"} { + for _, ch := range tables[table] { + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-F", ch) + if err := runNF(ctx, t.cmd, "-w", "5", "-t", table, "-X", ch); err != nil { + return err + } + } + } + } + return nil +} + +func removeOtherChains(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, keep generation) error { + chains, err := ownedChains(ctx, id, tools) + if err != nil { + return err + } + for family, tables := range chains { + current := map[string]bool{} + for _, ch := range keep.chains[family] { + current[ch] = true + } + t := tools[family] + for table, list := range tables { + for _, ch := range list { + if current[ch] { + continue + } + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-F", ch) + if err := runNF(ctx, t.cmd, "-w", "5", "-t", table, "-X", ch); err != nil { + return err + } + } + } + } + return nil +} + +func reconcileCrashLeftovers(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, s forwardState, families []string) error { + if s.Generation == 0 { + _ = deleteOwnedHooks(ctx, id, tools, "hook-prerouting") + _ = deleteOwnedHooks(ctx, id, tools, "hook-forward") + _ = deleteOwnedHooks(ctx, id, tools, "hook-postrouting") + return removeOwnedChains(ctx, id, tools) + } + keep, err := makeGeneration(id, s.Generation, families) + if err != nil { + return err + } + if err = deleteOtherGenerationHooks(ctx, id, tools, "hook-prerouting", keep.key); err != nil { + return err + } + if err = deleteOtherGenerationHooks(ctx, id, tools, "hook-forward", keep.key); err != nil { + return err + } + if err = deleteOtherGenerationHooks(ctx, id, tools, "hook-postrouting", keep.key); err != nil { + return err + } + return removeOtherChains(ctx, id, tools, keep) +} + +func rollbackGeneration(ctx context.Context, id instanceid.Identity, g generation, tools map[string]familyTools) { + _ = deleteGenerationHooks(ctx, id, tools, "hook-prerouting", g.key) + _ = deleteGenerationHooks(ctx, id, tools, "hook-forward", g.key) + _ = deleteGenerationHooks(ctx, id, tools, "hook-postrouting", g.key) + for family, c := range g.chains { + t := tools[family] + for p, ch := range c { + table := "nat" + if p == "F" { + table = "filter" + } + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-F", ch) + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-X", ch) + } + } +} + +func localAddressPresent(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil || ip.IsUnspecified() { + return true + } + ifaces, _ := net.Interfaces() + for _, ifi := range ifaces { + addrs, _ := ifi.Addrs() + for _, a := range addrs { + raw := a.String() + if h, _, e := net.ParseCIDR(raw); e == nil && h.Equal(ip) { + return true + } + } + } + return false +} + +func overlapAddr(a, b net.IP) bool { return a.IsUnspecified() || b.IsUnspecified() || a.Equal(b) } +func overlapRange(a, b config.PortRange) bool { return a.Start <= b.End && b.Start <= a.End } + +func conflictConfigs(r Request) error { + dir := filepath.Dir(r.ConfigPath) + files, _ := filepath.Glob(filepath.Join(dir, "*.toml")) + for _, p := range files { + if filepath.Clean(p) == filepath.Clean(r.ConfigPath) { + continue + } + other, err := config.LoadFile(p) + if err != nil { + return fmt.Errorf("cannot safely analyse Backpack config %s: %w", p, err) + } + if other.EffectiveEngine() != config.EngineIPTables { + continue + } + for _, a := range r.Config.Forward.Mappings { + ar, _, _ := a.Ranges() + ai := net.ParseIP(a.ListenAddress) + for _, b := range other.Forward.Mappings { + br, _, _ := b.Ranges() + bi := net.ParseIP(b.ListenAddress) + if (ai.To4() != nil) != (bi.To4() != nil) || !overlapAddr(ai, bi) || !overlapRange(ar, br) { + continue + } + for _, ap := range a.Protocols { + for _, bp := range b.Protocols { + if strings.EqualFold(ap, bp) { + return fmt.Errorf("%s %s ports %s conflict with Backpack instance %s", map[bool]string{true: "IPv4", false: "IPv6"}[ai.To4() != nil], strings.ToLower(ap), a.ListenPorts, instanceid.Name(p)) + } + } + } + } + } + } + return nil +} + +func parseHostPortLoose(raw string) (net.IP, config.PortRange, bool) { + h, p, err := net.SplitHostPort(raw) + if err != nil { + return nil, config.PortRange{}, false + } + if p == "*" { + return net.ParseIP(strings.Trim(h, "[]")), config.PortRange{Start: 1, End: 65535}, true + } + r, err := config.ParsePortRange(strings.ReplaceAll(p, ":", "-")) + return net.ParseIP(strings.Trim(h, "[]")), r, err == nil +} + +func conflictListeners(ctx context.Context, rules []expandedRule) error { + v6only := "0" + if b, e := nfRunner.Combined(ctx, "sysctl", "-n", "net.ipv6.bindv6only"); e == nil { + v6only = strings.TrimSpace(string(b)) + } + for _, proto := range []string{"tcp", "udp"} { + flag := "-H -ln" + if proto == "tcp" { + flag += "t" + } else { + flag += "u" + } + b, err := nfRunner.Combined(ctx, "ss", strings.Fields(flag)...) + if err != nil { + return fmt.Errorf("cannot inspect local %s listeners: %w", proto, err) + } + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 2 { + continue + } + ip, pr, ok := parseHostPortLoose(f[len(f)-2]) + if !ok { + continue + } + for _, r := range rules { + if r.proto != proto || uint16(r.listenPort) < pr.Start || uint16(r.listenPort) > pr.End { + continue + } + want := net.ParseIP(r.listen) + if ip == nil { + return fmt.Errorf("%s %s %s:%d conflicts with wildcard local listener %q", r.family, proto, r.listen, r.listenPort, line) + } + same := (want.To4() != nil) == (ip != nil && ip.To4() != nil) + dual := ip != nil && ip.To4() == nil && ip.IsUnspecified() && v6only == "0" && want.To4() != nil + if (same && overlapAddr(want, ip)) || dual { + return fmt.Errorf("%s %s %s:%d conflicts with local listener %q", r.family, proto, r.listen, r.listenPort, line) + } + } + } + } + return nil +} + +func findArg(f []string, key string) string { + for i := 0; i+1 < len(f); i++ { + if f[i] == key { + return f[i+1] + } + } + return "" +} + +func ruleUsesConnmark(line string, wanted uint32) bool { + f := splitRule(line) + for _, key := range []string{"--mark", "--set-mark", "--set-xmark"} { + raw := findArg(f, key) + if raw == "" { + continue + } + n, err := strconv.ParseUint(strings.SplitN(raw, "/", 2)[0], 0, 32) + if err == nil && uint32(n) == wanted { + return true + } + } + return false +} + +func detectChainHashCollision(ctx context.Context, id instanceid.Identity, tools map[string]familyTools) error { + hash := instanceid.Hash80(id.InstanceID) + for family, t := range tools { + familyDigit := "4" + if family == "ipv6" { + familyDigit = "6" + } + for _, table := range []string{"nat", "filter"} { + raw, err := saveTable(ctx, t, table) + if err != nil { + return err + } + for _, line := range strings.Split(raw, "\n") { + fields := splitRule(line) + if len(fields) == 0 { + continue + } + name := "" + if strings.HasPrefix(line, ":") { + name = strings.TrimPrefix(fields[0], ":") + } else if len(fields) >= 2 && fields[0] == "-A" { + name = fields[1] + } else if len(fields) >= 3 && strings.HasPrefix(fields[0], "[") && fields[1] == "-A" { + name = fields[2] + } + if name == "" { + continue + } + for _, purpose := range []string{"N", "F", "P"} { + if strings.HasPrefix(name, "B"+familyDigit+purpose+hash) && strings.Contains(line, "backpack:") && !strings.Contains(line, "backpack:"+id.InstanceID+":") { + return fmt.Errorf("chain hash collision in %s/%s chain %s: ownership comment belongs to a different instance", family, table, name) + } + } + } + } + } + return nil +} + +func conflictDNAT(ctx context.Context, id instanceid.Identity, tools map[string]familyTools, rules []expandedRule) error { + for family, t := range tools { + for _, table := range []string{"nat", "filter", "mangle"} { + raw, err := saveTable(ctx, t, table) + if err != nil { + return err + } + for _, line := range strings.Split(raw, "\n") { + if ruleUsesConnmark(line, id.Connmark) && !strings.Contains(line, "backpack:"+id.InstanceID+":") { + return fmt.Errorf("connmark %#x conflicts with %s/%s rule: %s", id.Connmark, family, table, line) + } + } + } + s, err := saveTable(ctx, t, "nat") + if err != nil { + return err + } + for _, line := range strings.Split(s, "\n") { + if !strings.Contains(line, "-j DNAT") || strings.Contains(line, "backpack:"+id.InstanceID+":") { + continue + } + f := splitRule(line) + proto, port, dst := findArg(f, "-p"), findArg(f, "--dport"), findArg(f, "-d") + if proto == "" || port == "" || strings.Contains(line, "--match-set") || strings.Contains(line, "multiport") { + return fmt.Errorf("cannot safely analyse possible %s DNAT conflict in nat rule: %s", family, line) + } + pr, e := config.ParsePortRange(strings.ReplaceAll(port, ":", "-")) + if e != nil { + return fmt.Errorf("cannot safely analyse DNAT port in %s rule: %s", family, line) + } + var dip net.IP + var dnet *net.IPNet + if dst != "" { + if x, network, e := net.ParseCIDR(dst); e == nil { + dip = x + dnet = network + } else { + dip = net.ParseIP(dst) + } + } + for _, r := range rules { + if r.family != family || r.proto != proto || r.listenPort < pr.Start || r.listenPort > pr.End { + continue + } + listen := net.ParseIP(r.listen) + if dip == nil || dip.IsUnspecified() || overlapAddr(listen, dip) || (dnet != nil && (listen.IsUnspecified() || dnet.Contains(listen))) { + return fmt.Errorf("%s %s port %d conflicts with nat rule: %s", family, proto, r.listenPort, line) + } + } + } + if t.backend == "nft" { + b, e := nfRunner.Combined(ctx, "nft", "-j", "list", "ruleset") + if e != nil { + return fmt.Errorf("cannot inspect native nftables rules: %w", e) + } + var root any + if json.Unmarshal(b, &root) != nil { + return fmt.Errorf("cannot parse nft JSON ruleset") + } + if conflict := nativeDNATConflict(root, family, id.InstanceID); conflict != "" { + return fmt.Errorf("%s native nftables DNAT expression cannot be safely proven non-overlapping: %s", family, conflict) + } + } + } + return nil +} + +func nativeDNATConflict(v any, family, currentID string) string { + switch x := v.(type) { + case map[string]any: + if rule, ok := x["rule"]; ok { + b, _ := json.Marshal(rule) + s := string(b) + matchesFamily := true + if rm, ok := rule.(map[string]any); ok { + if raw, ok := rm["family"].(string); ok { + matchesFamily = raw == "inet" || (family == "ipv4" && raw == "ip") || (family == "ipv6" && raw == "ip6") + } + } + if matchesFamily && strings.Contains(s, "\"dnat\"") && !strings.Contains(s, "backpack:"+currentID+":") { + return s + } + } + for _, z := range x { + if conflict := nativeDNATConflict(z, family, currentID); conflict != "" { + return conflict + } + } + case []any: + for _, z := range x { + if conflict := nativeDNATConflict(z, family, currentID); conflict != "" { + return conflict + } + } + } + return "" +} + +func conflictIdentity(configPath string, id instanceid.Identity) error { + files, _ := filepath.Glob(filepath.Join(instanceid.Dir(configPath), "*.json")) + for _, p := range files { + if filepath.Clean(p) == filepath.Clean(instanceid.Path(configPath)) { + continue + } + b, e := os.ReadFile(p) + if e != nil { + continue + } + var other instanceid.Identity + if json.Unmarshal(b, &other) != nil { + continue + } + if other.InstanceID == id.InstanceID { + return fmt.Errorf("instance identity collision with %s", p) + } + if other.Connmark == id.Connmark { + return fmt.Errorf("connmark %#x collides with %s", id.Connmark, p) + } + } + return nil +} + +func validateSystem(ctx context.Context, r Request, id instanceid.Identity, tools map[string]familyTools, rules []expandedRule) error { + for _, m := range r.Config.Forward.Mappings { + if !localAddressPresent(m.ListenAddress) { + return fmt.Errorf("listen address %s is not assigned to a local interface", m.ListenAddress) + } + familyFlag := "-4" + if net.ParseIP(m.TargetAddress).To4() == nil { + familyFlag = "-6" + } + if route, routeErr := nfRunner.Combined(ctx, "ip", familyFlag, "route", "get", m.TargetAddress); routeErr == nil { + fields := strings.Fields(strings.ToLower(string(route))) + for _, field := range fields { + if field == "broadcast" || field == "multicast" { + return fmt.Errorf("target_address %s resolves to a kernel %s route and is not unicast", m.TargetAddress, field) + } + } + } + } + if err := conflictIdentity(r.ConfigPath, id); err != nil { + return err + } + if err := detectChainHashCollision(ctx, id, tools); err != nil { + return err + } + if err := conflictConfigs(r); err != nil { + return err + } + if err := conflictListeners(ctx, rules); err != nil { + return err + } + return conflictDNAT(ctx, id, tools, rules) +} + +func setForwarding(ctx context.Context, families []string) error { + for _, f := range families { + key := "net.ipv4.ip_forward" + if f == "ipv6" { + key = "net.ipv6.conf.all.forwarding" + } + b, e := nfRunner.Combined(ctx, "sysctl", "-w", key+"=1") + if e != nil { + return fmt.Errorf("enable %s: %w: %s", key, e, strings.TrimSpace(string(b))) + } + b, e = nfRunner.Combined(ctx, "sysctl", "-n", key) + if e != nil || strings.TrimSpace(string(b)) != "1" { + return fmt.Errorf("%s did not remain enabled", key) + } + } + return nil +} + +func (iptablesProvider) Validate(ctx context.Context, r Request) error { + if r.Config == nil { + return fmt.Errorf("nil iptables configuration") + } + if err := r.Config.ValidateStructure(); err != nil { + return err + } + tools, err := detectTools(ctx, r.Config) + if err != nil { + return err + } + if err = checkCapabilities(ctx, tools); err != nil { + return err + } + rules, err := expand(r.Config) + if err != nil { + return err + } + id, err := instanceid.Resolve(r.ConfigPath, false) + if err != nil { + return err + } + return withNetfilterLock(func() error { return validateSystem(ctx, r, id, tools, rules) }) +} + +func (iptablesProvider) Run(ctx context.Context, r Request) error { + if r.Config == nil { + return fmt.Errorf("nil iptables configuration") + } + if err := r.Config.ValidateStructure(); err != nil { + return err + } + tools, err := detectTools(ctx, r.Config) + if err != nil { + return err + } + if err = checkCapabilities(ctx, tools); err != nil { + return err + } + var backendSummary []string + for family, tool := range tools { + backendSummary = append(backendSummary, family+"=iptables-"+tool.backend) + } + sort.Strings(backendSummary) + log.Printf("backpack direct engine: instance=%s backend=%s", instanceid.Name(r.ConfigPath), strings.Join(backendSummary, ",")) + rules, err := expand(r.Config) + if err != nil { + return err + } + id, err := instanceid.Resolve(r.ConfigPath, true) + if err != nil { + return err + } + families := neededFamilies(r.Config) + cleanupTools := augmentOptionalTools(ctx, tools) + var state forwardState + err = withNetfilterLock(func() error { + if err := validateSystem(ctx, r, id, tools, rules); err != nil { + return err + } + state = loadForwardState(r.ConfigPath, id) + if err := requireStateTools(state, cleanupTools); err != nil { + return err + } + if err := updateCountersLockedMode(ctx, r, id, cleanupTools, &state, false); err != nil { + return fmt.Errorf("scrape previous generation counters: %w", err) + } + state.Session = kernelCount{} + state.StartedAt = time.Now() + if err := reconcileCrashLeftovers(ctx, id, cleanupTools, state, families); err != nil { + return err + } + if err := setForwarding(ctx, families); err != nil { + return err + } + g, err := makeGeneration(id, state.Generation+1, families) + if err != nil { + return err + } + if err = buildDetached(ctx, id, g, tools, rules); err != nil { + return err + } + if err = verifyGeneration(ctx, id, g, tools, rules, false); err != nil { + rollbackGeneration(ctx, id, g, tools) + return err + } + // Conflict state may have changed while detached chains were being built. + if err = validateSystem(ctx, r, id, tools, rules); err != nil { + rollbackGeneration(ctx, id, g, tools) + return err + } + if err = installHooks(ctx, id, g, tools); err != nil { + rollbackGeneration(ctx, id, g, tools) + return err + } + if err = verifyGeneration(ctx, id, g, tools, rules, true); err != nil { + rollbackGeneration(ctx, id, g, tools) + return err + } + state.Generation = g.num + state.Families = append([]string(nil), families...) + state.DesiredHash = desiredHash(r.Config) + state.Last[g.key] = kernelCount{} + if err = saveForwardState(r.ConfigPath, state); err != nil { + rollbackGeneration(ctx, id, g, tools) + return err + } + // New ingress is live. Retire older hooks without ever detaching the + // verified current generation. + if err = deleteOtherGenerationHooks(ctx, id, cleanupTools, "hook-prerouting", g.key); err != nil { + return err + } + if err = updateCountersLocked(ctx, r, id, cleanupTools, &state); err != nil { + return err + } + if err = deleteOtherGenerationHooks(ctx, id, cleanupTools, "hook-forward", g.key); err != nil { + return err + } + if err = deleteOtherGenerationHooks(ctx, id, cleanupTools, "hook-postrouting", g.key); err != nil { + return err + } + // Remove old chains only; preserve the current generation. + chains, e := ownedChains(ctx, id, cleanupTools) + if e != nil { + return e + } + for family, tables := range chains { + t := cleanupTools[family] + for table, list := range tables { + for _, ch := range list { + keep := false + for _, cur := range g.chains[family] { + if ch == cur { + keep = true + } + } + if keep { + continue + } + _ = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-F", ch) + if e = runNF(ctx, t.cmd, "-w", "5", "-t", table, "-X", ch); e != nil { + return e + } + } + } + } + if err = verifyGeneration(ctx, id, g, tools, rules, true); err != nil { + return err + } + state.RulesHash, err = liveRulesHash(ctx, id, g, tools) + if err != nil { + return err + } + return saveForwardState(r.ConfigPath, state) + }) + if err != nil { + return err + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return withNetfilterLock(func() error { + // Quiesce ingress, scrape, then remove the remaining hooks and chains. + var cleanupErrs []error + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(context.Background(), id, cleanupTools, "hook-prerouting")) + cleanupErrs = append(cleanupErrs, updateCountersLocked(context.Background(), r, id, cleanupTools, &state)) + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(context.Background(), id, cleanupTools, "hook-forward")) + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(context.Background(), id, cleanupTools, "hook-postrouting")) + cleanupErrs = append(cleanupErrs, removeOwnedChains(context.Background(), id, cleanupTools)) + if joined := errors.Join(cleanupErrs...); joined != nil { + return joined + } + state.Families = nil + state.RulesHash = "" + return saveForwardState(r.ConfigPath, state) + }) + case <-ticker.C: + _ = withNetfilterLock(func() error { return updateCountersLocked(ctx, r, id, cleanupTools, &state) }) + } + } +} + +func (iptablesProvider) Counters(ctx context.Context, r Request) (Counters, error) { + id, err := instanceid.Resolve(r.ConfigPath, false) + if err != nil { + return Counters{}, err + } + tools := map[string]familyTools{} + if r.Config != nil { + tools, err = detectTools(ctx, r.Config) + if err != nil { + return Counters{}, err + } + } + tools = augmentOptionalTools(ctx, tools) + var result Counters + err = withNetfilterLock(func() error { + s := loadForwardState(r.ConfigPath, id) + if e := requireStateTools(s, tools); e != nil { + return e + } + if len(tools) > 0 { + if e := updateCountersLocked(ctx, r, id, tools, &s); e != nil { + return e + } + } + result = Counters{RXBytes: s.Cumulative.RXBytes, TXBytes: s.Cumulative.TXBytes, RXPackets: s.Cumulative.RXPackets, TXPackets: s.Cumulative.TXPackets} + return nil + }) + return result, err +} + +func (iptablesProvider) Health(ctx context.Context, r Request) (Health, error) { + var result Health + err := withNetfilterLock(func() error { + var inner error + result, inner = iptablesHealthLocked(ctx, r) + return inner + }) + return result, err +} + +func iptablesHealthLocked(ctx context.Context, r Request) (Health, error) { + if r.Config == nil { + return Health{}, fmt.Errorf("nil config") + } + tools, err := detectTools(ctx, r.Config) + if err != nil { + return Health{Detail: err.Error()}, nil + } + if err := checkCapabilities(ctx, tools); err != nil { + return Health{Detail: err.Error(), Drift: []string{err.Error()}}, nil + } + inspectionTools := augmentOptionalTools(ctx, tools) + id, _ := instanceid.Resolve(r.ConfigPath, false) + s := loadForwardState(r.ConfigPath, id) + if err := requireStateTools(s, inspectionTools); err != nil { + return Health{Detail: err.Error(), Drift: []string{err.Error()}}, nil + } + families := neededFamilies(r.Config) + g, e := makeGeneration(id, s.Generation, families) + if e != nil { + return Health{}, e + } + var drift []string + for _, f := range families { + key := "net.ipv4.ip_forward" + if f == "ipv6" { + key = "net.ipv6.conf.all.forwarding" + } + b, e := nfRunner.Combined(ctx, "sysctl", "-n", key) + if e != nil || strings.TrimSpace(string(b)) != "1" { + drift = append(drift, key+" is not enabled") + } + } + if s.DesiredHash != desiredHash(r.Config) { + drift = append(drift, "desired-state hash differs from persisted generation") + } + if live, le := liveRulesHash(ctx, id, g, inspectionTools); le != nil { + drift = append(drift, le.Error()) + } else if s.RulesHash == "" || live != s.RulesHash { + drift = append(drift, "live rule-set differs from the desired-state hash") + } + rules, expandErr := expand(r.Config) + if expandErr != nil { + drift = append(drift, expandErr.Error()) + } + if e = verifyGeneration(ctx, id, g, tools, rules, true); e != nil { + drift = append(drift, e.Error()) + } + if chains, ce := ownedChains(ctx, id, inspectionTools); ce == nil { + for family, tables := range chains { + current := map[string]bool{} + for _, ch := range g.chains[family] { + current[ch] = true + } + for _, list := range tables { + for _, ch := range list { + if !current[ch] { + drift = append(drift, "stale generation chain "+ch+" remains") + } + } + } + } + } else { + drift = append(drift, ce.Error()) + } + for _, t := range inspectionTools { + for _, table := range []string{"nat", "filter"} { + raw, he := saveTable(ctx, t, table) + if he != nil { + continue + } + for _, line := range strings.Split(raw, "\n") { + if strings.Contains(line, "backpack:"+id.InstanceID+":hook-") && !strings.Contains(line, ":"+g.key) { + drift = append(drift, "stale generation hook remains in "+table) + break + } + } + } + } + backs := map[string]bool{} + for _, t := range tools { + backs[t.backend] = true + } + var names []string + for b := range backs { + names = append(names, "iptables-"+b) + } + sort.Strings(names) + return Health{Ready: len(drift) == 0, Backend: strings.Join(names, ","), Detail: map[bool]string{true: "local netfilter desired state is ready", false: "local netfilter drift detected"}[len(drift) == 0], Drift: drift}, nil +} + +func (iptablesProvider) Cleanup(ctx context.Context, r Request) error { + tools := map[string]familyTools{} + var err error + if r.Config != nil { + tools, err = detectTools(ctx, r.Config) + if err != nil { + return err + } + } + tools = augmentOptionalTools(ctx, tools) + if len(tools) == 0 { + return fmt.Errorf("cleanup cannot inspect either iptables family") + } + id, err := instanceid.Resolve(r.ConfigPath, false) + if err != nil { + return err + } + return withNetfilterLock(func() error { + s := loadForwardState(r.ConfigPath, id) + if err := requireStateTools(s, tools); err != nil { + return err + } + var errs []error + errs = append(errs, deleteOwnedHooks(ctx, id, tools, "hook-prerouting")) + errs = append(errs, updateCountersLocked(ctx, r, id, tools, &s)) + errs = append(errs, deleteOwnedHooks(ctx, id, tools, "hook-forward")) + errs = append(errs, deleteOwnedHooks(ctx, id, tools, "hook-postrouting")) + errs = append(errs, removeOwnedChains(ctx, id, tools)) + if joined := errors.Join(errs...); joined != nil { + return joined + } + s.Families = nil + s.RulesHash = "" + return saveForwardState(r.ConfigPath, s) + }) +} + +var _ Provider = iptablesProvider{} + +func cleanupOrphans(ctx context.Context, configDir string, all bool) error { + files, _ := filepath.Glob(filepath.Join(configDir, "instances", "*.json")) + var errs []error + for _, identityPath := range files { + name := strings.TrimSuffix(filepath.Base(identityPath), ".json") + configPath := filepath.Join(configDir, name+".toml") + if !all { + if _, err := os.Stat(configPath); err == nil { + continue + } + } + b, err := os.ReadFile(identityPath) + if err != nil { + errs = append(errs, err) + continue + } + var id instanceid.Identity + if json.Unmarshal(b, &id) != nil || id.InstanceID == "" || id.Connmark == 0 { + continue + } + + // Probe each family independently: absence of ip6tables must not prevent + // IPv4 orphan cleanup, and vice versa. + tools := map[string]familyTools{} + for _, f := range []string{"ipv4", "ipv6"} { + listen, target := "0.0.0.0", "192.0.2.1" + if f == "ipv6" { + listen, target = "::", "2001:db8::1" + } + dummy := &config.Config{Engine: config.EngineIPTables, Forward: config.ForwardConfig{Mappings: []config.ForwardMapping{{ListenAddress: listen, ListenPorts: "1", TargetAddress: target, TargetPorts: "1", Protocols: []string{"tcp"}}}}} + if detected, e := detectTools(ctx, dummy); e == nil { + for k, t := range detected { + tools[k] = t + } + } + } + if len(tools) == 0 { + errs = append(errs, fmt.Errorf("cannot inspect orphan %s: no compatible netfilter tools", name)) + continue + } + r := Request{ConfigPath: configPath, Config: &config.Config{Engine: config.EngineIPTables}} + s := loadForwardState(configPath, id) + err = withNetfilterLock(func() error { + if toolErr := requireStateTools(s, tools); toolErr != nil { + return toolErr + } + var cleanupErrs []error + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(ctx, id, tools, "hook-prerouting")) + cleanupErrs = append(cleanupErrs, updateCountersLocked(ctx, r, id, tools, &s)) + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(ctx, id, tools, "hook-forward")) + cleanupErrs = append(cleanupErrs, deleteOwnedHooks(ctx, id, tools, "hook-postrouting")) + cleanupErrs = append(cleanupErrs, removeOwnedChains(ctx, id, tools)) + return errors.Join(cleanupErrs...) + }) + if err != nil { + errs = append(errs, fmt.Errorf("cleanup orphan %s: %w", name, err)) + continue + } + _ = os.Remove(identityPath) + _ = os.Remove(statePath(configPath, id.InstanceID)) + } + return errors.Join(errs...) +} diff --git a/internal/engine/iptables_linux_test.go b/internal/engine/iptables_linux_test.go new file mode 100644 index 0000000..08a9acf --- /dev/null +++ b/internal/engine/iptables_linux_test.go @@ -0,0 +1,238 @@ +//go:build linux + +package engine + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/instanceid" +) + +type recordingRunner struct { + mu sync.Mutex + calls []string + out map[string]string + fail string +} + +func (r *recordingRunner) Combined(_ context.Context, name string, args ...string) ([]byte, error) { + r.mu.Lock() + defer r.mu.Unlock() + call := name + " " + strings.Join(args, " ") + r.calls = append(r.calls, call) + if r.fail != "" && strings.Contains(call, r.fail) { + return nil, errors.New("injected command failure") + } + return []byte(r.out[call]), nil +} + +func directTestConfig() *config.Config { + return &config.Config{ + Engine: config.EngineIPTables, + Forward: config.ForwardConfig{Mappings: []config.ForwardMapping{{ + ListenAddress: "0.0.0.0", ListenPorts: "443-445", + TargetAddress: "192.0.2.8", TargetPorts: "8443-8445", + Protocols: []string{"tcp", "udp"}, + }}}, + } +} + +func TestExpandPreservesPortOffsetAndProtocol(t *testing.T) { + rules, err := expand(directTestConfig()) + if err != nil { + t.Fatal(err) + } + if len(rules) != 6 { + t.Fatalf("got %d expanded rules, want 6", len(rules)) + } + for _, r := range rules { + if int(r.targetPort)-int(r.listenPort) != 8000 { + t.Fatalf("offset changed for %#v", r) + } + if r.family != "ipv4" || (r.proto != "tcp" && r.proto != "udp") { + t.Fatalf("unexpected expansion: %#v", r) + } + } +} + +func TestGenerationNamesAreStableBoundedAndSeparated(t *testing.T) { + id := instanceid.Identity{InstanceID: "42a27077-dfa3-45dc-a4a3-fc78f622c725", Connmark: 7} + g1, err := makeGeneration(id, 12, []string{"ipv4", "ipv6"}) + if err != nil { + t.Fatal(err) + } + g2, _ := makeGeneration(id, 12, []string{"ipv4", "ipv6"}) + for family, chains := range g1.chains { + for purpose, name := range chains { + if name != g2.chains[family][purpose] { + t.Fatalf("chain name is not deterministic: %q != %q", name, g2.chains[family][purpose]) + } + if len(name) > 28 { + t.Fatalf("chain %q exceeds iptables limit", name) + } + } + } + if g1.chains["ipv4"]["N"] == g1.chains["ipv6"]["N"] || g1.chains["ipv4"]["N"] == g1.chains["ipv4"]["F"] { + t.Fatal("family and purpose must produce distinct chains") + } +} + +func TestDetachedRulesArePreciselyMarkedAndAccounted(t *testing.T) { + old := nfRunner + recorder := &recordingRunner{out: map[string]string{}} + nfRunner = recorder + t.Cleanup(func() { nfRunner = old }) + + id := instanceid.Identity{InstanceID: "4c33bb4a-2a7a-4d3b-85f2-e65e77302289", Connmark: 0x10203} + g, _ := makeGeneration(id, 1, []string{"ipv4"}) + rules, _ := expand(&config.Config{Forward: config.ForwardConfig{Mappings: []config.ForwardMapping{{ + ListenAddress: "0.0.0.0", ListenPorts: "443", TargetAddress: "192.0.2.8", TargetPorts: "8443", Protocols: []string{"tcp"}, + }}}}) + tools := map[string]familyTools{"ipv4": {family: "ipv4", cmd: "iptables", save: "iptables-save", restore: "iptables-restore"}} + if err := buildDetached(context.Background(), id, g, tools, rules); err != nil { + t.Fatal(err) + } + joined := strings.Join(recorder.calls, "\n") + for _, want := range []string{ + "--set-xmark 0x10203/0xffffffff", + "--comment backpack:" + id.InstanceID + ":dnat:0001", + "--to-destination 192.0.2.8:8443", + "--comment backpack:" + id.InstanceID + ":acct-rx:0001", + "--ctstate NEW,ESTABLISHED,RELATED", + "--comment backpack:" + id.InstanceID + ":acct-tx:0001", + "--ctstate ESTABLISHED,RELATED", + "--comment backpack:" + id.InstanceID + ":masquerade:0001", + } { + if !strings.Contains(joined, want) { + t.Errorf("missing rule fragment %q\ncommands:\n%s", want, joined) + } + } + if strings.Contains(joined, " OUTPUT ") { + t.Fatal("direct forwarding must not install OUTPUT rules") + } +} + +func TestCounterDeltaSurvivesResetWithoutDoubleCounting(t *testing.T) { + total := kernelCount{} + first := kernelCount{RXBytes: 100, TXBytes: 50, RXPackets: 10, TXPackets: 5} + addDelta(&total, kernelCount{}, first) + addDelta(&total, first, first) // repeated scrape + addDelta(&total, first, kernelCount{RXBytes: 20, TXBytes: 8, RXPackets: 2, TXPackets: 1}) + if total != (kernelCount{RXBytes: 120, TXBytes: 58, RXPackets: 12, TXPackets: 6}) { + t.Fatalf("unexpected cumulative count after reset: %#v", total) + } +} + +func TestAccountingParserDoesNotCountDNATOrMasquerade(t *testing.T) { + id := instanceid.Identity{InstanceID: "5ef3576d-b7f6-4c6e-854c-c4550db0e124"} + old := nfRunner + filter := "[4:400] -A B4F -m comment --comment backpack:" + id.InstanceID + ":acct-rx:0002 -j ACCEPT\n" + + "[2:100] -A B4F -m comment --comment backpack:" + id.InstanceID + ":acct-tx:0002 -j ACCEPT\n" + + "[99:9999] -A B4F -m comment --comment backpack:" + id.InstanceID + ":dnat:0002 -j DNAT\n" + recorder := &recordingRunner{out: map[string]string{"iptables-save -c -t filter": filter}} + nfRunner = recorder + t.Cleanup(func() { nfRunner = old }) + got, err := scrape(context.Background(), id, map[string]familyTools{"ipv4": {save: "iptables-save"}}) + if err != nil { + t.Fatal(err) + } + if got["0002"] != (kernelCount{RXBytes: 400, TXBytes: 100, RXPackets: 4, TXPackets: 2}) { + t.Fatalf("wrong accounting counters: %#v", got["0002"]) + } +} + +func TestConnmarkCollisionUsesNumericTokenNotSubstring(t *testing.T) { + if ruleUsesConnmark("-A X -m connmark --mark 0x10/0xffffffff -j ACCEPT", 0x1) { + t.Fatal("0x1 must not collide with 0x10") + } + if !ruleUsesConnmark("-A X -j CONNMARK --set-xmark 0x1/0xffffffff", 0x1) { + t.Fatal("exact set-xmark was not detected") + } +} + +func TestNativeNFTConflictReturnsActionableRule(t *testing.T) { + ruleset := map[string]any{"nftables": []any{ + map[string]any{"rule": map[string]any{"family": "ip", "table": "nat", "chain": "prerouting", "expr": []any{map[string]any{"dnat": map[string]any{"addr": "192.0.2.8"}}}}}, + }} + got := nativeDNATConflict(ruleset, "ipv4", "owned") + if !strings.Contains(got, "prerouting") || !strings.Contains(got, "dnat") { + t.Fatalf("native nft conflict is not actionable: %q", got) + } + ruleset["nftables"] = []any{map[string]any{"rule": map[string]any{"comment": "backpack:owned:dnat:1", "expr": []any{map[string]any{"dnat": map[string]any{"addr": "192.0.2.8"}}}}}} + if got := nativeDNATConflict(ruleset, "ipv4", "owned"); got != "" { + t.Fatalf("owned rule reported as native conflict: %s", got) + } +} + +func TestHooksActivateIngressLastAndRollbackBothFamilies(t *testing.T) { + old := nfRunner + recorder := &recordingRunner{out: map[string]string{}} + nfRunner = recorder + t.Cleanup(func() { nfRunner = old }) + + id := instanceid.Identity{InstanceID: "46b06f94-b61e-4569-b5d4-5472b97fdcff", Connmark: 77} + g, _ := makeGeneration(id, 3, []string{"ipv4", "ipv6"}) + tools := map[string]familyTools{ + "ipv4": {family: "ipv4", cmd: "iptables"}, + "ipv6": {family: "ipv6", cmd: "ip6tables"}, + } + if err := installHooks(context.Background(), id, g, tools); err != nil { + t.Fatal(err) + } + for _, family := range []string{"iptables", "ip6tables"} { + var familyCalls []string + for _, call := range recorder.calls { + if strings.HasPrefix(call, family+" ") { + familyCalls = append(familyCalls, call) + } + } + if len(familyCalls) != 3 || !strings.Contains(familyCalls[2], " PREROUTING ") { + t.Fatalf("%s ingress hook was not activated last: %#v", family, familyCalls) + } + } + + recorder.calls = nil + rollbackGeneration(context.Background(), id, g, tools) + joined := strings.Join(recorder.calls, "\n") + for _, chain := range []string{g.chains["ipv4"]["N"], g.chains["ipv4"]["F"], g.chains["ipv4"]["P"], g.chains["ipv6"]["N"], g.chains["ipv6"]["F"], g.chains["ipv6"]["P"]} { + if !strings.Contains(joined, " -X "+chain) { + t.Errorf("rollback did not remove %s", chain) + } + } +} + +func TestForwardStatePersistsSessionAndCumulative(t *testing.T) { + path := t.TempDir() + "/direct.toml" + id := instanceid.Identity{InstanceID: "77f40d73-bddd-4a84-87fb-004a0a5af309", Connmark: 9} + want := forwardState{InstanceID: id.InstanceID, Generation: 4, Last: map[string]kernelCount{"0004": {RXBytes: 11}}, Session: kernelCount{RXBytes: 20, TXPackets: 2}, Cumulative: kernelCount{RXBytes: 120, TXPackets: 12}} + if err := saveForwardState(path, want); err != nil { + t.Fatal(err) + } + got := loadForwardState(path, id) + if got.Session != want.Session || got.Cumulative != want.Cumulative || got.Last["0004"] != want.Last["0004"] { + t.Fatalf("state round-trip changed counters: %#v", got) + } +} + +func TestDetachedCollisionNeverRollsBackPreexistingChain(t *testing.T) { + old := nfRunner + id := instanceid.Identity{InstanceID: "b753e744-bd51-4714-b57e-57e78e0220a0", Connmark: 55} + g, _ := makeGeneration(id, 1, []string{"ipv4"}) + collision := g.chains["ipv4"]["N"] + recorder := &recordingRunner{out: map[string]string{}, fail: " -N " + collision} + nfRunner = recorder + t.Cleanup(func() { nfRunner = old }) + tools := map[string]familyTools{"ipv4": {family: "ipv4", cmd: "iptables"}} + if err := buildDetached(context.Background(), id, g, tools, nil); err == nil { + t.Fatal("injected chain collision was accepted") + } + joined := strings.Join(recorder.calls, "\n") + if strings.Contains(joined, " -F "+collision) || strings.Contains(joined, " -X "+collision) { + t.Fatalf("rollback touched the preexisting colliding chain:\n%s", joined) + } +} diff --git a/internal/engine/iptables_netns_linux_test.go b/internal/engine/iptables_netns_linux_test.go new file mode 100644 index 0000000..d0ee757 --- /dev/null +++ b/internal/engine/iptables_netns_linux_test.go @@ -0,0 +1,313 @@ +//go:build linux + +package engine + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/backpack/backpack/config" +) + +// TestDirectNetNSAcceptance is opt-in because it needs root, network +// namespaces and working iptables targets. The child roles run this same test +// binary inside the isolated namespaces created by the parent. +func TestDirectNetNSAcceptance(t *testing.T) { + switch os.Getenv("BP_NETNS_ROLE") { + case "target": + netNSTarget(t) + return + case "engine": + netNSEngine(t) + return + case "client", "client-fail": + netNSClient(t, os.Getenv("BP_NETNS_ROLE") == "client-fail") + return + } + if os.Getenv("BACKPACK_NETNS_TEST") != "1" { + t.Skip("set BACKPACK_NETNS_TEST=1 to run the root network-namespace acceptance test") + } + if os.Geteuid() != 0 { + t.Skip("network-namespace acceptance test requires root") + } + for _, binary := range []string{"ip", "iptables", "iptables-save", "iptables-restore"} { + if _, err := exec.LookPath(binary); err != nil { + t.Skipf("%s is unavailable", binary) + } + } + + suffix := fmt.Sprintf("%d", os.Getpid()) + clientNS, ingressNS, targetNS := "bpc"+suffix, "bpi"+suffix, "bpt"+suffix + for _, ns := range []string{clientNS, ingressNS, targetNS} { + if out, err := exec.Command("ip", "netns", "add", ns).CombinedOutput(); err != nil { + t.Fatalf("create namespace %s: %v: %s", ns, err, out) + } + } + t.Cleanup(func() { + for _, ns := range []string{clientNS, ingressNS, targetNS} { + _ = exec.Command("ip", "netns", "del", ns).Run() + } + }) + + commands := [][]string{ + {"link", "add", "bpc0", "type", "veth", "peer", "name", "bpi0"}, + {"link", "set", "bpc0", "netns", clientNS}, {"link", "set", "bpi0", "netns", ingressNS}, + {"link", "add", "bpi1", "type", "veth", "peer", "name", "bpt0"}, + {"link", "set", "bpi1", "netns", ingressNS}, {"link", "set", "bpt0", "netns", targetNS}, + } + for _, args := range commands { + netNSIP(t, args...) + } + for _, setup := range []struct { + ns string + args [][]string + }{ + {clientNS, [][]string{{"link", "set", "lo", "up"}, {"addr", "add", "10.210.1.2/24", "dev", "bpc0"}, {"-6", "addr", "add", "fd42:210:1::2/64", "dev", "bpc0", "nodad"}, {"link", "set", "bpc0", "up"}, {"route", "add", "default", "via", "10.210.1.1"}, {"-6", "route", "add", "default", "via", "fd42:210:1::1"}}}, + {ingressNS, [][]string{{"link", "set", "lo", "up"}, {"addr", "add", "10.210.1.1/24", "dev", "bpi0"}, {"-6", "addr", "add", "fd42:210:1::1/64", "dev", "bpi0", "nodad"}, {"addr", "add", "10.210.2.1/24", "dev", "bpi1"}, {"-6", "addr", "add", "fd42:210:2::1/64", "dev", "bpi1", "nodad"}, {"link", "set", "bpi0", "up"}, {"link", "set", "bpi1", "up"}}}, + {targetNS, [][]string{{"link", "set", "lo", "up"}, {"addr", "add", "10.210.2.2/24", "dev", "bpt0"}, {"-6", "addr", "add", "fd42:210:2::2/64", "dev", "bpt0", "nodad"}, {"link", "set", "bpt0", "up"}, {"route", "add", "default", "via", "10.210.2.1"}, {"-6", "route", "add", "default", "via", "fd42:210:2::1"}}}, + } { + for _, args := range setup.args { + netNSIP(t, append([]string{"-n", setup.ns}, args...)...) + } + } + + tmp := t.TempDir() + targetReady, engineReady := filepath.Join(tmp, "target.ready"), filepath.Join(tmp, "engine.ready") + configPath := filepath.Join(tmp, "direct.toml") + body := `engine = "iptables" +[forward] +[[forward.mappings]] +listen_address = "10.210.1.1" +listen_ports = "10000-10001" +target_address = "10.210.2.2" +target_ports = "20000-20001" +protocols = ["tcp", "udp"] +[[forward.mappings]] +listen_address = "10.210.1.1" +listen_ports = "10010" +target_address = "10.210.2.2" +target_ports = "20010" +protocols = ["tcp", "udp"] +[[forward.mappings]] +listen_address = "fd42:210:1::1" +listen_ports = "10000-10001" +target_address = "fd42:210:2::2" +target_ports = "20000-20001" +protocols = ["tcp", "udp"] +[[forward.mappings]] +listen_address = "fd42:210:1::1" +listen_ports = "10010" +target_address = "fd42:210:2::2" +target_ports = "20010" +protocols = ["tcp", "udp"] +` + if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + target := netNSChild(targetNS, exe, "target", "BP_NETNS_READY="+targetReady) + if err := target.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { terminateChild(target) }) + waitReady(t, targetReady) + + startEngine := func() *exec.Cmd { + _ = os.Remove(engineReady) + cmd := netNSChild(ingressNS, exe, "engine", "BP_NETNS_READY="+engineReady, "BP_NETNS_CONFIG="+configPath) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { terminateChild(cmd) }) + waitReady(t, engineReady) + return cmd + } + engineCmd := startEngine() + runNetNSClient(t, clientNS, exe, "client") + terminateChild(engineCmd) + runNetNSClient(t, clientNS, exe, "client-fail") + engineCmd = startEngine() + runNetNSClient(t, clientNS, exe, "client") +} + +func netNSIP(t *testing.T, args ...string) { + t.Helper() + if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil { + t.Fatalf("ip %s: %v: %s", strings.Join(args, " "), err, out) + } +} + +func netNSChild(ns, exe, role string, extra ...string) *exec.Cmd { + cmd := exec.Command("ip", "netns", "exec", ns, exe, "-test.run=^TestDirectNetNSAcceptance$", "-test.v") + cmd.Env = append(os.Environ(), append([]string{"BP_NETNS_ROLE=" + role}, extra...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + return cmd +} + +func waitReady(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", path) +} + +func terminateChild(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + select { + case <-done: + case <-time.After(8 * time.Second): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-done + } +} + +func netNSTarget(t *testing.T) { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + for _, address := range []string{"10.210.2.2", "fd42:210:2::2"} { + for _, port := range []int{20000, 20001, 20010} { + listen := net.JoinHostPort(address, fmt.Sprint(port)) + ln, err := net.Listen("tcp", listen) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _, _ = conn.Write([]byte(conn.RemoteAddr().String())) + _ = conn.Close() + } + }() + pc, err := net.ListenPacket("udp", listen) + if err != nil { + t.Fatal(err) + } + defer pc.Close() + go func() { + buf := make([]byte, 64) + for { + _, peer, err := pc.ReadFrom(buf) + if err != nil { + return + } + _, _ = pc.WriteTo([]byte(peer.String()), peer) + } + }() + } + } + _ = os.WriteFile(os.Getenv("BP_NETNS_READY"), []byte("ready"), 0o600) + <-ctx.Done() +} + +func netNSEngine(t *testing.T) { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer cancel() + cfg, err := config.LoadFile(os.Getenv("BP_NETNS_CONFIG")) + if err != nil { + t.Fatal(err) + } + provider, err := Resolve(cfg) + if err != nil { + t.Fatal(err) + } + errCh := make(chan error, 1) + request := Request{ConfigPath: os.Getenv("BP_NETNS_CONFIG"), Config: cfg} + go func() { errCh <- provider.Run(ctx, request) }() + deadline := time.Now().Add(12 * time.Second) + ready := false + for time.Now().Before(deadline) { + health, _ := provider.Health(context.Background(), request) + if health.Ready { + _ = os.WriteFile(os.Getenv("BP_NETNS_READY"), []byte("ready"), 0o600) + ready = true + break + } + select { + case err := <-errCh: + t.Fatalf("engine startup: %v", err) + default: + } + time.Sleep(100 * time.Millisecond) + } + if !ready { + cancel() + t.Fatalf("engine did not reach desired-state readiness") + } + select { + case err := <-errCh: + if err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + if err := <-errCh; err != nil { + t.Fatal(err) + } + } +} + +func netNSClient(t *testing.T, wantFailure bool) { + for _, tc := range []struct{ network, host, expected string }{ + {"tcp", "10.210.1.1", "10.210.2.1"}, {"udp", "10.210.1.1", "10.210.2.1"}, + {"tcp6", "fd42:210:1::1", "fd42:210:2::1"}, {"udp6", "fd42:210:1::1", "fd42:210:2::1"}, + } { + for _, port := range []int{10000, 10001, 10010} { + conn, err := net.DialTimeout(tc.network, net.JoinHostPort(tc.host, fmt.Sprint(port)), 800*time.Millisecond) + if err != nil { + if wantFailure { + continue + } + t.Fatalf("%s connect: %v", tc.network, err) + } + _ = conn.SetDeadline(time.Now().Add(800 * time.Millisecond)) + if strings.HasPrefix(tc.network, "udp") { + _, _ = conn.Write([]byte("probe")) + } + buf := make([]byte, 128) + n, readErr := conn.Read(buf) + _ = conn.Close() + if wantFailure { + if readErr == nil { + t.Fatalf("%s port %d still forwarded after stop", tc.network, port) + } + continue + } + if readErr != nil || !strings.Contains(string(buf[:n]), tc.expected) { + t.Fatalf("%s port %d did not preserve offset/MASQUERADE source: %q, %v", tc.network, port, buf[:n], readErr) + } + } + } +} + +func runNetNSClient(t *testing.T, ns, exe, role string) { + t.Helper() + cmd := netNSChild(ns, exe, role) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", role, err, out) + } +} diff --git a/internal/engine/iptables_other.go b/internal/engine/iptables_other.go new file mode 100644 index 0000000..454b648 --- /dev/null +++ b/internal/engine/iptables_other.go @@ -0,0 +1,29 @@ +//go:build !linux + +package engine + +import ( + "context" + "fmt" + + "github.com/backpack/backpack/config" +) + +type iptablesProvider struct{} + +func init() { Register(config.EngineIPTables, iptablesProvider{}) } +func (iptablesProvider) Metadata() Metadata { return Metadata{Name: "iptables", Mode: "direct"} } +func unsupported() error { + return fmt.Errorf("iptables direct forwarding is unsupported on this operating system; Linux is required") +} +func (iptablesProvider) Validate(context.Context, Request) error { return unsupported() } +func (iptablesProvider) Run(context.Context, Request) error { return unsupported() } +func (iptablesProvider) Health(context.Context, Request) (Health, error) { + return Health{Detail: unsupported().Error()}, nil +} +func (iptablesProvider) Counters(context.Context, Request) (Counters, error) { + return Counters{}, unsupported() +} +func (iptablesProvider) Cleanup(context.Context, Request) error { return unsupported() } +func cleanupOrphans(context.Context, string, bool) error { return nil } +func RemoveRuntimeArtifacts() error { return nil } diff --git a/internal/engine/iptables_state.go b/internal/engine/iptables_state.go new file mode 100644 index 0000000..18a8328 --- /dev/null +++ b/internal/engine/iptables_state.go @@ -0,0 +1,103 @@ +package engine + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/backpack/backpack/internal/instanceid" + "github.com/backpack/backpack/internal/metrics" +) + +type kernelCount struct { + RXBytes uint64 `json:"rx_bytes"` + TXBytes uint64 `json:"tx_bytes"` + RXPackets uint64 `json:"rx_packets"` + TXPackets uint64 `json:"tx_packets"` +} + +type forwardState struct { + InstanceID string `json:"instance_id"` + Generation uint64 `json:"generation"` + Families []string `json:"families"` + DesiredHash string `json:"desired_hash"` + RulesHash string `json:"rules_hash"` + Last map[string]kernelCount `json:"last_by_generation"` + Session kernelCount `json:"session"` + Cumulative kernelCount `json:"cumulative"` + StartedAt time.Time `json:"started_at"` +} + +func stateDir(path string) string { return filepath.Join(filepath.Dir(path), "forward-state") } +func statePath(path, id string) string { return filepath.Join(stateDir(path), id+".json") } + +func loadForwardState(path string, id instanceid.Identity) forwardState { + s := forwardState{InstanceID: id.InstanceID, Last: map[string]kernelCount{}} + b, err := os.ReadFile(statePath(path, id.InstanceID)) + if err == nil { + _ = json.Unmarshal(b, &s) + } + if s.InstanceID != id.InstanceID || s.Last == nil { + s = forwardState{InstanceID: id.InstanceID, Last: map[string]kernelCount{}} + } + return s +} + +func saveForwardState(path string, s forwardState) error { + if err := os.MkdirAll(stateDir(path), 0o700); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + p := statePath(path, s.InstanceID) + tmp := p + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, p); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func addDelta(total *kernelCount, previous, current kernelCount) { + // A recreated rule may reset to zero. Treat the current value as the new + // delta rather than underflowing or losing it. + delta := func(old, now uint64) uint64 { + if now >= old { + return now - old + } + return now + } + total.RXBytes += delta(previous.RXBytes, current.RXBytes) + total.TXBytes += delta(previous.TXBytes, current.TXBytes) + total.RXPackets += delta(previous.RXPackets, current.RXPackets) + total.TXPackets += delta(previous.TXPackets, current.TXPackets) +} + +func persistMetrics(r Request, s forwardState) error { + name := instanceid.Name(r.ConfigPath) + snap := metrics.Snapshot{ + Name: name, Engine: "iptables", Mode: "direct", Taken: time.Now(), + Transport: "", Role: "", BytesIn: s.Cumulative.RXBytes, BytesOut: s.Cumulative.TXBytes, + PacketsIn: s.Cumulative.RXPackets, PacketsOut: s.Cumulative.TXPackets, + } + if !s.StartedAt.IsZero() { + snap.Uptime = time.Since(s.StartedAt).Round(time.Second).String() + } + b, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return err + } + p := metrics.Path(filepath.Dir(r.ConfigPath), name) + tmp := p + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return fmt.Errorf("write direct metrics: %w", err) + } + return os.Rename(tmp, p) +} diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go new file mode 100644 index 0000000..66ed44d --- /dev/null +++ b/internal/engine/reverse.go @@ -0,0 +1,66 @@ +package engine + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/client" + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/server" +) + +type reverseProvider struct{} + +func init() { Register(config.EngineReverse, reverseProvider{}) } +func (reverseProvider) Metadata() Metadata { return Metadata{Name: "reverse", Mode: "reverse"} } +func (reverseProvider) Validate(_ context.Context, r Request) error { + if r.Config == nil { + return fmt.Errorf("nil reverse configuration") + } + return r.Config.ValidateStructure() +} + +func reverseName(path string) string { + base := filepath.Base(path) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func reverseMetrics(ctx context.Context, r Request, transport, role string) { + c := metrics.NewCollector(filepath.Dir(r.ConfigPath), reverseName(r.ConfigPath), transport, role, nil, nil) + done := make(chan struct{}) + go func() { <-ctx.Done(); close(done) }() + _ = c.Write() + go c.Run(done, 30*time.Second) +} + +func (reverseProvider) Run(ctx context.Context, r Request) error { + if err := (reverseProvider{}).Validate(ctx, r); err != nil { + return err + } + if r.Config.HasServer() { + reverseMetrics(ctx, r, string(r.Config.Server.Transport), "server") + s := server.NewServer(&r.Config.Server, ctx) + go s.Start() + <-ctx.Done() + s.Stop() + return nil + } + reverseMetrics(ctx, r, string(r.Config.Client.Transport), "client") + c := client.NewClient(&r.Config.Client, ctx) + go c.Start() + <-ctx.Done() + c.Stop() + return nil +} + +// Reverse health remains socket-aware in manage; the registry adapter reports +// only that this provider has no local rule-set to inspect. +func (reverseProvider) Health(context.Context, Request) (Health, error) { + return Health{Ready: true, Detail: "reverse health is connection-based"}, nil +} +func (reverseProvider) Counters(context.Context, Request) (Counters, error) { return Counters{}, nil } +func (reverseProvider) Cleanup(context.Context, Request) error { return nil } diff --git a/internal/instanceid/identity.go b/internal/instanceid/identity.go new file mode 100644 index 0000000..7963206 --- /dev/null +++ b/internal/instanceid/identity.go @@ -0,0 +1,130 @@ +package instanceid + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Identity is persistent ownership metadata shared by the runtime and the +// management plane. Connmark is stable because existing conntrack entries must +// keep working while a new rule generation replaces the old one. +type Identity struct { + InstanceID string `json:"instance_id"` + Connmark uint32 `json:"connmark"` +} + +func Name(configPath string) string { + base := filepath.Base(configPath) + return strings.TrimSuffix(base, filepath.Ext(base)) +} + +func Dir(configPath string) string { return filepath.Join(filepath.Dir(configPath), "instances") } +func Path(configPath string) string { return filepath.Join(Dir(configPath), Name(configPath)+".json") } + +// Deterministic derives a UUID-shaped stable identity from the canonical path. +// This gives legacy and hand-written configs an identity without rewriting the +// TOML or requiring an operator migration. +func Deterministic(configPath string) Identity { + abs, err := filepath.Abs(configPath) + if err != nil { + abs = filepath.Clean(configPath) + } + sum := sha256.Sum256([]byte("backpack-instance-v1:" + filepath.ToSlash(abs))) + b := append([]byte(nil), sum[:16]...) + b[6] = (b[6] & 0x0f) | 0x50 // UUID v5-shaped + b[8] = (b[8] & 0x3f) | 0x80 + id := fmt.Sprintf("%s-%s-%s-%s-%s", + hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), + hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16])) + mark := uint32(sum[16])<<24 | uint32(sum[17])<<16 | uint32(sum[18])<<8 | uint32(sum[19]) + mark &= 0x7fffffff + if mark == 0 { + mark = 1 + } + return Identity{InstanceID: id, Connmark: mark} +} + +func Load(configPath string) (Identity, error) { + b, err := os.ReadFile(Path(configPath)) + if err != nil { + return Identity{}, err + } + var id Identity + if err := json.Unmarshal(b, &id); err != nil { + return Identity{}, err + } + if id.InstanceID == "" || id.Connmark == 0 { + return Identity{}, fmt.Errorf("identity metadata is incomplete") + } + return id, nil +} + +// Resolve returns an existing identity or the deterministic legacy identity. +// Persistence happens only when requested by Run/management, never Validate. +func Resolve(configPath string, persist bool) (Identity, error) { + if id, err := Load(configPath); err == nil { + return id, nil + } + id := Deterministic(configPath) + if !persist { + return id, nil + } + if err := persistIdentity(configPath, id); err != nil { + return Identity{}, err + } + return id, nil +} + +func persistIdentity(configPath string, id Identity) error { + if err := os.MkdirAll(Dir(configPath), 0o700); err != nil { + return err + } + b, _ := json.MarshalIndent(id, "", " ") + tmp := Path(configPath) + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + if err := os.Rename(tmp, Path(configPath)); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +// Create allocates a random identity for a newly managed instance. Hand-written +// and legacy configs still use Resolve's deterministic path-derived fallback, +// so they need no migration. +func Create(configPath string) (Identity, error) { + if id, err := Load(configPath); err == nil { + return id, nil + } + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + return Identity{}, err + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + id := Identity{ + InstanceID: fmt.Sprintf("%s-%s-%s-%s-%s", hex.EncodeToString(b[0:4]), hex.EncodeToString(b[4:6]), hex.EncodeToString(b[6:8]), hex.EncodeToString(b[8:10]), hex.EncodeToString(b[10:16])), + Connmark: binary.BigEndian.Uint32(b[16:20]) & 0x7fffffff, + } + if id.Connmark == 0 { + id.Connmark = 1 + } + if err := persistIdentity(configPath, id); err != nil { + return Identity{}, err + } + return id, nil +} + +func Hash80(id string) string { + s := sha256.Sum256([]byte(id)) + return hex.EncodeToString(s[:10]) +} diff --git a/internal/instanceid/identity_test.go b/internal/instanceid/identity_test.go new file mode 100644 index 0000000..3cc395d --- /dev/null +++ b/internal/instanceid/identity_test.go @@ -0,0 +1,29 @@ +package instanceid + +import ( + "path/filepath" + "testing" +) + +func TestDeterministicIdentityIsStableAndNonZero(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.toml") + a, b := Deterministic(path), Deterministic(path) + if a != b || a.InstanceID == "" || a.Connmark == 0 { + t.Fatalf("unstable deterministic identity: %#v %#v", a, b) + } +} + +func TestCreatePersistsRandomManagedIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "direct.toml") + id, err := Create(path) + if err != nil { + t.Fatal(err) + } + if id.InstanceID == "" || id.Connmark == 0 || id == Deterministic(path) { + t.Fatalf("managed identity was not random and complete: %#v", id) + } + again, err := Create(path) + if err != nil || again != id { + t.Fatalf("managed identity was not persisted: %#v, %v", again, err) + } +} diff --git a/internal/manage/backup.go b/internal/manage/backup.go index a1b9a42..ee6a867 100644 --- a/internal/manage/backup.go +++ b/internal/manage/backup.go @@ -3,7 +3,9 @@ package manage import ( "archive/tar" "compress/gzip" + "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -12,7 +14,9 @@ import ( "strings" "time" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" "github.com/backpack/backpack/internal/schedule" ) @@ -150,6 +154,35 @@ func BackupToFile(dir string) (string, error) { return path, nil } +func configsInDir(dir string) (map[string]*config.Config, error) { + result := map[string]*config.Config{} + paths, err := filepath.Glob(filepath.Join(dir, "*.toml")) + if err != nil { + return nil, err + } + for _, path := range paths { + cfg, err := config.LoadFile(path) + if err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Base(path), err) + } + result[strings.TrimSuffix(filepath.Base(path), ".toml")] = cfg + } + return result, nil +} + +func validateRestoreSet(ctx context.Context, dir string, configs map[string]*config.Config) error { + for name, cfg := range configs { + provider, err := engine.Resolve(cfg) + if err == nil { + err = provider.Validate(ctx, engine.Request{ConfigPath: filepath.Join(dir, name+".toml"), Config: cfg}) + } + if err != nil { + return fmt.Errorf("restored instance %s is invalid or conflicts with this host: %w", name, err) + } + } + return nil +} + // Restore reads a backup archive produced by WriteBackup, extracts it into the // config directory (overwriting matching files, leaving others untouched), then // re-registers a systemd service for every tunnel it finds and starts them. It @@ -170,6 +203,22 @@ func Restore(r io.Reader) (RestoreResult, error) { if err := os.MkdirAll(app.ConfigDir, 0755); err != nil { return res, err } + parent := filepath.Dir(app.ConfigDir) + stage, err := os.MkdirTemp(parent, ".backpack-restore-") + if err != nil { + return res, err + } + stageLive := true + defer func() { + if stageLive { + _ = os.RemoveAll(stage) + } + }() + // Restore remains additive: begin with the current tree, then overlay the + // archive in staging. No live file is touched before all candidates pass. + if err := copyTree(app.ConfigDir, stage); err != nil { + return res, fmt.Errorf("prepare restore candidate: %w", err) + } sawConfig := false for { @@ -205,8 +254,8 @@ func Restore(r io.Reader) (RestoreResult, error) { if clean == filepath.Base(app.InstallPathFile) && fileExists(app.InstallPathFile) { continue } - target := filepath.Join(app.ConfigDir, clean) - if rel, err := filepath.Rel(app.ConfigDir, target); err != nil || strings.HasPrefix(rel, "..") { + target := filepath.Join(stage, clean) + if rel, err := filepath.Rel(stage, target); err != nil || strings.HasPrefix(rel, "..") { return res, fmt.Errorf("refusing unsafe path in archive: %q", hdr.Name) } @@ -246,40 +295,130 @@ func Restore(r io.Reader) (RestoreResult, error) { } } - if sawConfig { - // Re-register a systemd unit for every restored tunnel, then start them. - tunnels := List() - unitFailed := map[string]bool{} - for _, t := range tunnels { - res.Tunnels = append(res.Tunnels, t.Name) - if err := writeUnit(t.Name); err != nil { - unitFailed[t.Name] = true + if !sawConfig { + // Non-tunnel settings still use the same atomic directory swap below. + } + candidates, err := configsInDir(stage) + if err != nil { + return res, err + } + if err := validateRestoreSet(context.Background(), stage, candidates); err != nil { + return res, err + } + + oldTunnels := List() + oldActive := map[string]bool{} + oldNames := map[string]bool{} + for _, t := range oldTunnels { + oldNames[t.Name] = true + oldActive[t.Name] = IsActive(t.Service) + if oldActive[t.Name] { + if err := StopService(t.Service); err != nil { + return res, fmt.Errorf("quiesce %s before restore: %w", t.Name, err) } } + } + resumeOld := func() { + for _, t := range oldTunnels { + if oldActive[t.Name] { + _ = StartService(t.Service) + } + } + } + + rollbackDir, err := os.MkdirTemp(parent, ".backpack-rollback-") + if err != nil { + resumeOld() + return res, err + } + _ = os.Remove(rollbackDir) + if err = os.Rename(app.ConfigDir, rollbackDir); err != nil { + resumeOld() + return res, fmt.Errorf("create restore point: %w", err) + } + if err = os.Rename(stage, app.ConfigDir); err != nil { + _ = os.Rename(rollbackDir, app.ConfigDir) + resumeOld() + return res, fmt.Errorf("activate restore candidate: %w", err) + } + stageLive = false + + rollback := func(cause error) error { + for name := range candidates { + if oldNames[name] { + _ = StopService(app.ServiceName(name)) + } else { + _ = DisableService(app.ServiceName(name)) + removeUnit(name) + } + } + failedDir, _ := os.MkdirTemp(parent, ".backpack-failed-restore-") + if failedDir != "" { + _ = os.Remove(failedDir) + _ = os.Rename(app.ConfigDir, failedDir) + } + if e := os.Rename(rollbackDir, app.ConfigDir); e != nil { + return errors.Join(cause, fmt.Errorf("restore rollback failed: %w", e)) + } + for _, t := range oldTunnels { + _ = writeUnit(t.Name) + } _ = DaemonReload() - for _, t := range tunnels { - if unitFailed[t.Name] { - res.Failed++ - continue + for _, t := range oldTunnels { + if oldActive[t.Name] { + _ = StartService(t.Service) } - // Enabled first so it survives a reboot, then restarted. - // - // Starting is not enough: `systemctl start` does nothing to a - // service that is already running, so a tunnel that was up would - // carry on with the configuration it was started with and quietly - // ignore the one just restored. It would also keep writing its old - // traffic totals over the restored ones. - if err := StartService(app.ServiceName(t.Name)); err != nil { - res.Failed++ - continue + } + if failedDir != "" { + _ = os.RemoveAll(failedDir) + } + return cause + } + + var names []string + for name := range candidates { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + res.Tunnels = append(res.Tunnels, name) + if err := writeUnit(name); err != nil { + return res, rollback(fmt.Errorf("write restored unit %s: %w", name, err)) + } + } + if err := DaemonReload(); err != nil { + return res, rollback(fmt.Errorf("reload restored units: %w", err)) + } + for _, name := range names { + service := app.ServiceName(name) + if err := StartService(service); err != nil { + res.Failed++ + return res, rollback(fmt.Errorf("start restored instance %s: %w", name, err)) + } + if !WaitServiceActive(service, 12*time.Second) { + res.Failed++ + return res, rollback(fmt.Errorf("restored instance %s did not become active", name)) + } + if candidates[name].EffectiveEngine() == config.EngineIPTables { + provider, _ := engine.Resolve(candidates[name]) + deadline := time.Now().Add(12 * time.Second) + ready := false + for time.Now().Before(deadline) { + h, _ := provider.Health(context.Background(), engine.Request{ConfigPath: app.ConfigPath(name), Config: candidates[name]}) + if h.Ready { + ready = true + break + } + time.Sleep(500 * time.Millisecond) } - if err := RestartService(app.ServiceName(t.Name)); err != nil { + if !ready { res.Failed++ - continue + return res, rollback(fmt.Errorf("restored direct instance %s failed desired-state health", name)) } - res.Started++ } + res.Started++ } + _ = os.RemoveAll(rollbackDir) // Restore the auto-refresh schedule captured in the sidecar. if res.AutoRefreshHours > 0 { diff --git a/internal/manage/diagnose.go b/internal/manage/diagnose.go index 4c046c1..1a29b58 100644 --- a/internal/manage/diagnose.go +++ b/internal/manage/diagnose.go @@ -1,6 +1,7 @@ package manage import ( + "context" "fmt" "net" "os" @@ -11,7 +12,9 @@ import ( "sync" "time" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" ) // CheckLevel is how a diagnostic turned out. @@ -271,6 +274,9 @@ func tunnelChecks() []Check { // tunnelChecksFor is one tunnel's section of the report. func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check { + if t.Mode == "direct" { + return directChecksFor(t) + } var out []Check g := "Tunnel: " + t.Name h := tunnelHealthWith(t, pairs) @@ -380,6 +386,63 @@ func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check { return out } +func directChecksFor(t Tunnel) []Check { + g := "Direct: " + t.Name + path := app.ConfigPath(t.Name) + cfg, err := config.LoadFile(path) + if err != nil { + return []Check{{Group: g, Name: "Config", Level: CheckFail, Detail: err.Error(), Fix: "restore or correct the direct config"}} + } + p, err := engine.Resolve(cfg) + if err != nil { + return []Check{{Group: g, Name: "Engine", Level: CheckFail, Detail: err.Error()}} + } + h, err := p.Health(context.Background(), engine.Request{ConfigPath: path, Config: cfg}) + if err != nil { + return []Check{{Group: g, Name: "Desired state", Level: CheckFail, Detail: err.Error()}} + } + lvl := CheckOK + if !h.Ready { + lvl = CheckFail + } + detail := h.Detail + if h.Backend != "" { + detail += " (" + h.Backend + ")" + } + if len(h.Drift) > 0 { + detail += ": " + strings.Join(h.Drift, "; ") + } + out := []Check{{Group: g, Name: "Desired state", Level: lvl, Detail: detail, Fix: map[bool]string{true: "", false: "restart the instance; repeated drift may mean ufw/firewalld is reloading netfilter"}[h.Ready]}} + for _, m := range cfg.Forward.Mappings { + familyFlag := "-4" + if net.ParseIP(m.TargetAddress).To4() == nil { + familyFlag = "-6" + } + if b, e := exec.Command("ip", familyFlag, "route", "get", m.TargetAddress).CombinedOutput(); e != nil { + out = append(out, Check{Group: g, Name: "Route to " + m.TargetAddress, Level: CheckWarn, Detail: strings.TrimSpace(string(b)), Fix: "add a route to the direct target"}) + } else { + out = append(out, Check{Group: g, Name: "Route to " + m.TargetAddress, Level: CheckOK, Detail: strings.TrimSpace(string(b))}) + } + _, tr, e := m.Ranges() + if e != nil { + continue + } + for _, proto := range m.Protocols { + if strings.EqualFold(proto, "tcp") { + port := strconv.Itoa(int(tr.Start)) + if reachable(m.TargetAddress, port, 2*time.Second) { + out = append(out, Check{Group: g, Name: "TCP target", Level: CheckOK, Detail: net.JoinHostPort(m.TargetAddress, port) + " accepts connections"}) + } else { + out = append(out, Check{Group: g, Name: "TCP target", Level: CheckWarn, Detail: net.JoinHostPort(m.TargetAddress, port) + " did not accept the diagnostic probe", Fix: "this does not make the local engine unhealthy; verify the target service and firewall"}) + } + } else { + out = append(out, Check{Group: g, Name: "UDP target", Level: CheckInfo, Detail: net.JoinHostPort(m.TargetAddress, strconv.Itoa(int(tr.Start))) + " is unknown/unverifiable without application traffic"}) + } + } + } + return out +} + // certCheck validates a TLS certificate file and reports its expiry. func certCheck(group, path string) Check { if path == "" { diff --git a/internal/manage/direct.go b/internal/manage/direct.go new file mode 100644 index 0000000..1a64d84 --- /dev/null +++ b/internal/manage/direct.go @@ -0,0 +1,278 @@ +package manage + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" + "github.com/backpack/backpack/internal/instanceid" + "github.com/backpack/backpack/internal/tui" +) + +func renderDirect(mappings []config.ForwardMapping) string { + var b strings.Builder + b.WriteString("# Generated by backpack. Direct forwarding is owned by this instance.\n") + b.WriteString("engine = \"iptables\"\n\n[forward]\n") + for _, m := range mappings { + b.WriteString("\n[[forward.mappings]]\n") + fmt.Fprintf(&b, "listen_address = %q\nlisten_ports = %q\ntarget_address = %q\ntarget_ports = %q\n", m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts) + quoted := make([]string, len(m.Protocols)) + for i, p := range m.Protocols { + quoted[i] = fmt.Sprintf("%q", strings.ToLower(p)) + } + fmt.Fprintf(&b, "protocols = [%s]\n", strings.Join(quoted, ", ")) + } + return b.String() +} + +func directConfig(m []config.ForwardMapping) *config.Config { + return &config.Config{Engine: config.EngineIPTables, Forward: config.ForwardConfig{Mappings: m}} +} + +func validateDirectCandidate(name string, mappings []config.ForwardMapping) error { + cfg := directConfig(mappings) + if err := cfg.ValidateStructure(); err != nil { + return err + } + p, err := engine.Resolve(cfg) + if err != nil { + return err + } + return p.Validate(context.Background(), engine.Request{ConfigPath: app.ConfigPath(name), Config: cfg}) +} + +func saveDirect(name string, mappings []config.ForwardMapping) (string, error) { + cfg := directConfig(mappings) + if err := cfg.ValidateStructure(); err != nil { + return "", err + } + if err := os.MkdirAll(app.ConfigDir, 0o755); err != nil { + return "", err + } + path, service := app.ConfigPath(name), app.ServiceName(name) + identity, err := instanceid.Create(path) + if err != nil { + return "", fmt.Errorf("create direct instance identity: %w", err) + } + committed := false + defer func() { + if committed { + return + } + _ = DisableService(service) + if p, err := engine.Resolve(cfg); err == nil { + _ = p.Cleanup(context.Background(), engine.Request{ConfigPath: path, Config: cfg}) + } + removeUnit(name) + _ = os.Remove(path) + _ = os.Remove(instanceid.Path(path)) + _ = os.Remove(filepath.Join(app.ConfigDir, "forward-state", identity.InstanceID+".json")) + _ = os.Remove(filepath.Join(app.ConfigDir, name+".metrics.json")) + _ = DaemonReload() + }() + if err := validateDirectCandidate(name, mappings); err != nil { + return "", err + } + if err := app.WriteFileAtomic(path, []byte(renderDirect(mappings)), 0o600); err != nil { + return "", err + } + if err := writeUnit(name); err != nil { + return "", err + } + if err := DaemonReload(); err != nil { + return "", err + } + if err := StartService(service); err != nil { + return "", err + } + provider, _ := engine.Resolve(cfg) + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + h, _ := provider.Health(context.Background(), engine.Request{ConfigPath: path, Config: cfg}) + if h.Ready { + committed = true + return service, nil + } + time.Sleep(500 * time.Millisecond) + } + return "", fmt.Errorf("direct desired state did not become ready") +} + +func promptDirectMapping(existing *config.ForwardMapping) (config.ForwardMapping, bool) { + m := config.ForwardMapping{} + if existing != nil { + m = *existing + } + for { + raw := strings.TrimSpace(tui.PromptDefault("Target IP", m.TargetAddress)) + ip := net.ParseIP(raw) + if ip == nil { + tui.Error("Target must be an explicit IPv4 or IPv6 address.") + continue + } + m.TargetAddress = ip.String() + break + } + wildcard := "0.0.0.0" + if net.ParseIP(m.TargetAddress).To4() == nil { + wildcard = "::" + } + if m.ListenAddress == "" { + m.ListenAddress = wildcard + } + m.ListenAddress = strings.TrimSpace(tui.PromptDefault("Listen address (wildcard means every local address)", m.ListenAddress)) + m.ListenPorts = strings.TrimSpace(tui.PromptDefault("Listen port or range", m.ListenPorts)) + m.TargetPorts = strings.TrimSpace(tui.PromptDefault("Target port or equal-length range", m.TargetPorts)) + idx := tui.ChooseOpt("Protocols:", []tui.Option{{Title: "TCP + UDP", Desc: "install both protocol mappings"}, {Title: "TCP", Desc: "TCP only"}, {Title: "UDP", Desc: "UDP only"}}) + switch idx { + case 0: + m.Protocols = []string{"tcp", "udp"} + case 1: + m.Protocols = []string{"tcp"} + case 2: + m.Protocols = []string{"udp"} + default: + return config.ForwardMapping{}, false + } + if err := config.ValidateForward(config.ForwardConfig{Mappings: []config.ForwardMapping{m}}); err != nil { + tui.Error(err.Error()) + tui.PressEnter() + return config.ForwardMapping{}, false + } + return m, true +} + +func SetupDirect() { + tui.Clear() + tui.Title("Setup Direct Forward") + tui.Warn("Incoming TCP/UDP is DNATed directly to one fixed target with iptables.") + tui.Warn("MASQUERADE means the target sees this ingress server as the source.") + fmt.Println() + name := uniqueName(tui.PromptDefault("Instance name", "direct-forward")) + var mappings []config.ForwardMapping + for { + m, ok := promptDirectMapping(nil) + if !ok { + if len(mappings) == 0 { + return + } + break + } + mappings = append(mappings, m) + if !tui.Confirm("Add another mapping", false) { + break + } + } + if err := validateDirectCandidate(name, mappings); err != nil { + tui.Error("Validation failed: " + err.Error()) + tui.PressEnter() + return + } + fmt.Println() + tui.Info("Rules to install:") + for _, m := range mappings { + fmt.Printf(" %s %s:%s -> %s:%s\n", strings.ToUpper(strings.Join(m.Protocols, "+")), m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts) + } + if !tui.Confirm("Create and start this direct-forward instance", true) { + return + } + service, err := saveDirect(name, mappings) + if err != nil { + tui.Error("Failed: " + err.Error()) + } else { + tui.Success("Direct forward created: " + service) + } + tui.PressEnter() +} + +func applyDirect(name string, mappings []config.ForwardMapping) error { + if err := validateDirectCandidate(name, mappings); err != nil { + return err + } + path, service := app.ConfigPath(name), app.ServiceName(name) + previous, err := os.ReadFile(path) + if err != nil { + return err + } + wasActive := IsActive(service) + if err = app.WriteFileAtomic(path, []byte(renderDirect(mappings)), 0o600); err != nil { + return err + } + if err = RestartService(service); err != nil { + revertSpec(path, previous, service, wasActive) + return fmt.Errorf("direct instance failed to restart; reverted: %w", err) + } + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + cfg, e := config.LoadFile(path) + if e == nil { + p, _ := engine.Resolve(cfg) + h, _ := p.Health(context.Background(), engine.Request{ConfigPath: path, Config: cfg}) + if h.Ready { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + revertSpec(path, previous, service, wasActive) + return fmt.Errorf("direct desired state did not become ready; previous config was restored") +} + +func editDirectMenu(name string) { + for { + cfg, err := config.LoadFile(app.ConfigPath(name)) + if err != nil { + tui.Error(err.Error()) + tui.PressEnter() + return + } + maps := append([]config.ForwardMapping(nil), cfg.Forward.Mappings...) + opts := make([]tui.Option, 0, len(maps)+2) + for i, m := range maps { + opts = append(opts, tui.Option{Title: fmt.Sprintf("%d. %s:%s", i+1, m.ListenAddress, m.ListenPorts), Desc: fmt.Sprintf("%s -> %s:%s", strings.ToUpper(strings.Join(m.Protocols, "+")), m.TargetAddress, m.TargetPorts)}) + } + opts = append(opts, tui.Option{Title: "Add mapping", Desc: "append another TCP/UDP mapping"}, tui.Option{Title: "Remove mapping", Desc: "select one to delete"}) + idx := tui.ChooseOpt("Select a mapping to edit, or an action:", opts) + if idx < 0 { + return + } + switch { + case idx < len(maps): + m, ok := promptDirectMapping(&maps[idx]) + if !ok { + continue + } + maps[idx] = m + case idx == len(maps): + m, ok := promptDirectMapping(nil) + if !ok { + continue + } + maps = append(maps, m) + default: + if len(maps) <= 1 { + tui.Error("At least one mapping is required.") + tui.PressEnter() + continue + } + rm := tui.ChooseOpt("Remove which mapping?", opts[:len(maps)]) + if rm < 0 { + continue + } + maps = append(maps[:rm], maps[rm+1:]...) + } + if err := applyDirect(name, maps); err != nil { + tui.Error(err.Error()) + } else { + tui.Success("Direct mappings updated and verified.") + } + tui.PressEnter() + } +} diff --git a/internal/manage/health.go b/internal/manage/health.go index 76ed60b..11779ba 100644 --- a/internal/manage/health.go +++ b/internal/manage/health.go @@ -1,9 +1,13 @@ package manage import ( + "context" + "strings" "time" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" "github.com/backpack/backpack/internal/metrics" ) @@ -61,6 +65,30 @@ func tunnelHealthWith(t Tunnel, pairs [][2]string) Health { case !h.Active: h.State, h.Detail = "stopped", "service is not running" default: + if t.Engine == string(config.EngineIPTables) { + cfg, err := config.LoadFile(app.ConfigPath(t.Name)) + if err != nil { + h.State, h.Detail = "offline", "configuration is unreadable: "+err.Error() + return h + } + p, err := engine.Resolve(cfg) + if err != nil { + h.State, h.Detail = "offline", err.Error() + return h + } + eh, err := p.Health(context.Background(), engine.Request{ConfigPath: app.ConfigPath(t.Name), Config: cfg}) + if err != nil || !eh.Ready { + h.State = "offline" + if err != nil { + h.Detail = err.Error() + } else { + h.Detail = eh.Detail + ": " + strings.Join(eh.Drift, "; ") + } + return h + } + h.Connected, h.State, h.Detail = true, "online", eh.Detail + return h + } h.Connected = tunnelHealthy(t, pairs) // tunnelHealthy answers the watchdog's question — "is this worth // restarting?" — and for a datagram server it deliberately says yes diff --git a/internal/manage/menu.go b/internal/manage/menu.go index 6fafae0..879ea78 100644 --- a/internal/manage/menu.go +++ b/internal/manage/menu.go @@ -29,9 +29,13 @@ func ManageTunnels() { tui.Clear() opts := make([]tui.Option, len(tunnels)) for i, t := range tunnels { + desc := fmt.Sprintf("%s %s — %s", t.Role, t.Transport, plainState(t.Service)) + if t.Mode == "direct" { + desc = fmt.Sprintf("direct %s — %d mapping(s) — %s", t.Engine, len(t.Mappings), plainState(t.Service)) + } opts[i] = tui.Option{ Title: t.Name, - Desc: fmt.Sprintf("%s %s — %s", t.Role, t.Transport, plainState(t.Service)), + Desc: desc, } } @@ -55,11 +59,19 @@ func plainState(service string) string { func manageOne(t Tunnel) { for { tui.Clear() - tui.Title(fmt.Sprintf("Tunnel: %s", t.Name)) - fmt.Printf(" %s%s %s%s %s\n\n", tui.Gray, t.Role, t.Transport, tui.Reset, stateLabel(t.Service)) + tui.Title(fmt.Sprintf("Instance: %s", t.Name)) + label := strings.TrimSpace(t.Role + " " + t.Transport) + if t.Mode == "direct" { + label = "direct " + t.Engine + } + fmt.Printf(" %s%s%s %s\n\n", tui.Gray, label, tui.Reset, stateLabel(t.Service)) + editDesc := "change tunnel port & forwarded ports" + if t.Mode == "direct" { + editDesc = "add, edit, or remove direct mappings" + } idx := tui.ChooseOpt("Choose an action:", []tui.Option{ - {Title: "Edit", Desc: "change tunnel port & forwarded ports"}, + {Title: "Edit", Desc: editDesc}, {Title: "Start", Desc: "start the tunnel service"}, {Title: "Stop", Desc: "stop the tunnel service"}, {Title: "Restart", Desc: "restart the tunnel service"}, @@ -68,7 +80,11 @@ func manageOne(t Tunnel) { }) switch idx { case 0: - editPortsMenu(t.Name) + if t.Mode == "direct" { + editDirectMenu(t.Name) + } else { + editPortsMenu(t.Name) + } case 1: report(StartService(t.Service), "started") case 2: diff --git a/internal/manage/status.go b/internal/manage/status.go index a10e624..5a5f937 100644 --- a/internal/manage/status.go +++ b/internal/manage/status.go @@ -55,7 +55,7 @@ func StatusLive() { // printStatusTable prints a formatted table of tunnel states. func printStatusTable(tunnels []Tunnel) { - header := fmt.Sprintf("%-16s %-8s %-8s %-8s %s", "NAME", "ROLE", "TRANSP", "STATE", "PORTS / REMOTE") + header := fmt.Sprintf("%-16s %-8s %-10s %-8s %s", "NAME", "MODE", "ENGINE", "STATE", "PORTS / REMOTE") fmt.Println(tui.Bold + header + tui.Reset) fmt.Println(strings.Repeat("─", 72)) @@ -67,11 +67,18 @@ func printStatusTable(tunnels []Tunnel) { state := colorPad(tui.Color(color, plainState), plainState, 8) detail := t.Addr + if t.Mode == "direct" { + var maps []string + for _, m := range t.Mappings { + maps = append(maps, fmt.Sprintf("%s:%s->%s:%s", m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts)) + } + detail = strings.Join(maps, ",") + } if t.Role == "server" && len(t.Ports) > 0 { detail = strings.Join(t.Ports, ",") } - fmt.Printf("%-16s %-8s %-8s %s %s\n", - truncate(t.Name, 16), t.Role, t.Transport, state, detail) + fmt.Printf("%-16s %-8s %-10s %s %s\n", + truncate(t.Name, 16), t.Mode, t.Engine, state, detail) } } diff --git a/internal/manage/tunnel.go b/internal/manage/tunnel.go index 41f7e10..9f2009f 100644 --- a/internal/manage/tunnel.go +++ b/internal/manage/tunnel.go @@ -1,23 +1,29 @@ package manage import ( + "context" + "fmt" "os" "path/filepath" "sort" "strings" - "github.com/BurntSushi/toml" "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" + "github.com/backpack/backpack/internal/instanceid" ) // Tunnel is a discovered tunnel derived from a config file on disk. type Tunnel struct { Name string + Mode string // "reverse" or "direct"; derived from engine metadata + Engine string // effective engine, including the legacy reverse default Role string // "server" or "client" Transport string Addr string // bind_addr (server) or remote_addr (client) Ports []string // server only + Mappings []config.ForwardMapping Service string } @@ -26,13 +32,19 @@ func List() []Tunnel { var tunnels []Tunnel matches, _ := filepath.Glob(app.ConfigDir + "/*.toml") for _, path := range matches { - var cfg config.Config - if _, err := toml.DecodeFile(path, &cfg); err != nil { + cfg, err := config.LoadFile(path) + if err != nil { continue } name := strings.TrimSuffix(filepath.Base(path), ".toml") - t := Tunnel{Name: name, Service: app.ServiceName(name)} + meta, err := engine.MetadataFor(cfg) + if err != nil { + continue + } + t := Tunnel{Name: name, Service: app.ServiceName(name), Mode: meta.Mode, Engine: meta.Name} switch { + case cfg.EffectiveEngine() == config.EngineIPTables: + t.Mappings = append([]config.ForwardMapping(nil), cfg.Forward.Mappings...) case cfg.Server.BindAddr != "": t.Role = "server" t.Transport = string(cfg.Server.Transport) @@ -55,20 +67,47 @@ func List() []Tunnel { // than the summary List gives — preset, limits, certificate, fallbacks — read // it through this rather than parsing the TOML themselves. func LoadTunnelConfig(name string) (config.Config, error) { - var cfg config.Config - _, err := toml.DecodeFile(app.ConfigPath(name), &cfg) - return cfg, err + cfg, err := config.LoadFile(app.ConfigPath(name)) + if err != nil { + return config.Config{}, err + } + return *cfg, nil } // Delete removes a tunnel: stops/disables the service, deletes the unit, // config, any per-tunnel refresh script, and reloads systemd. func Delete(name string) error { service := app.ServiceName(name) + cfg, cfgErr := config.LoadFile(app.ConfigPath(name)) if IsActive(service) || IsEnabled(service) { _ = DisableService(service) } + if cfgErr == nil { + if p, err := engine.Resolve(cfg); err == nil { + if err = p.Cleanup(context.Background(), engine.Request{ConfigPath: app.ConfigPath(name), Config: cfg}); err != nil { + return fmt.Errorf("cleanup %s before delete: %w", name, err) + } + } + } else if _, identityErr := os.Stat(instanceid.Path(app.ConfigPath(name))); identityErr == nil { + // A direct config may be corrupt or already missing while its generation + // is still live. Persistent identity metadata is sufficient for the + // engine's ownership-safe cleanup path. + p, err := engine.Get(config.EngineIPTables) + if err == nil { + err = p.Cleanup(context.Background(), engine.Request{ConfigPath: app.ConfigPath(name)}) + } + if err != nil { + return fmt.Errorf("cleanup unreadable direct instance %s: %w", name, err) + } + } + id, _ := instanceid.Resolve(app.ConfigPath(name), false) removeUnit(name) os.Remove(app.ConfigPath(name)) + os.Remove(filepath.Join(app.ConfigDir, name+".metrics.json")) + os.Remove(instanceid.Path(app.ConfigPath(name))) + if id.InstanceID != "" { + os.Remove(filepath.Join(app.ConfigDir, "forward-state", id.InstanceID+".json")) + } deleteTunnelMeta(name) return DaemonReload() } diff --git a/internal/manage/watchdog.go b/internal/manage/watchdog.go index b70d707..674bcc5 100644 --- a/internal/manage/watchdog.go +++ b/internal/manage/watchdog.go @@ -7,7 +7,10 @@ import ( "strings" "time" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/alerthist" + "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" ) // Watchdog tuning. @@ -30,6 +33,7 @@ const ( func RunWatchdog(ctx context.Context) { fails := map[string]int{} lastRestart := map[string]time.Time{} + restartStreak := map[string]int{} seenHealthy := map[string]bool{} // only "was up, then dropped" counts as a drop ticker := time.NewTicker(wdInterval) @@ -46,20 +50,40 @@ func RunWatchdog(ctx context.Context) { fails[t.Name] = 0 // stopped on purpose (or systemd is restarting a crash) continue } - if tunnelHealthy(t, pairs) { + direct := t.Engine == string(config.EngineIPTables) + healthy := tunnelHealthy(t, pairs) + if direct { + healthy = directDesiredStateHealthy(ctx, t) + } + if healthy { fails[t.Name] = 0 + restartStreak[t.Name] = 0 seenHealthy[t.Name] = true continue } // Only treat as a "drop" if it had connected before — a tunnel // still waiting for its first connection isn't broken. - if !seenHealthy[t.Name] { + if !direct && !seenHealthy[t.Name] { continue } fails[t.Name]++ - if fails[t.Name] >= wdThreshold && time.Since(lastRestart[t.Name]) > wdCooldown { - RestartService(t.Service) + backoff := wdCooldown + if direct { + backoff *= time.Duration(1 << min(restartStreak[t.Name], 3)) + } + if fails[t.Name] >= wdThreshold && time.Since(lastRestart[t.Name]) > backoff { + if err := RestartService(t.Service); err != nil { + continue + } lastRestart[t.Name] = time.Now() + restartStreak[t.Name]++ + if direct { + message := "Direct desired-state drift triggered reconcile for " + t.Name + if restartStreak[t.Name] >= 3 { + message += "; repeated drift suggests ufw, firewalld, or another firewall manager is rewriting rules" + } + alerthist.RecordEvent(message) + } // On the record: "why did my tunnel reset overnight" should // be answerable from the panel's alert view. alerthist.RecordEvent("🔁 Watchdog restarted tunnel " + t.Name + @@ -71,6 +95,19 @@ func RunWatchdog(ctx context.Context) { } } +func directDesiredStateHealthy(ctx context.Context, t Tunnel) bool { + cfg, err := config.LoadFile(app.ConfigPath(t.Name)) + if err != nil { + return false + } + p, err := engine.Resolve(cfg) + if err != nil { + return false + } + h, err := p.Health(ctx, engine.Request{ConfigPath: app.ConfigPath(t.Name), Config: cfg}) + return err == nil && h.Ready +} + // tunnelHealthy reports whether a running tunnel currently has its connection up, // based on the established TCP sockets in `pairs` ([local, peer] address pairs). func tunnelHealthy(t Tunnel, pairs [][2]string) bool { diff --git a/internal/menu/menu.go b/internal/menu/menu.go index 66d76d0..037f523 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -3,6 +3,7 @@ package menu import ( + "context" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "time" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" "github.com/backpack/backpack/internal/localproxy" "github.com/backpack/backpack/internal/manage" "github.com/backpack/backpack/internal/optimize" @@ -63,20 +65,22 @@ func Run() { case "2": manage.SetupClient() case "3": - manageMenu() + manage.SetupDirect() case "4": - backupMenu() + manageMenu() case "5": - webPanelMenu() + backupMenu() case "6": - optimizeMenu() + webPanelMenu() case "7": - telegramMenu() + optimizeMenu() case "8": - updateMenu() + telegramMenu() case "9": + updateMenu() + case "10": uninstallMenu() - case "10", "0": + case "11", "0": tui.Info("Goodbye!") return default: @@ -94,7 +98,7 @@ func printUpdateBanner() { if !ok { return } - fmt.Printf(" %s⬆ %s is available%s %s— option 8 to update safely%s\n", + fmt.Printf(" %s⬆ %s is available%s %s— option 9 to update safely%s\n", tui.Bold+tui.Red, tag, tui.Reset, tui.Gray, tui.Reset) } @@ -103,18 +107,19 @@ func printMenu() { fmt.Println() menuItem(1, "Setup Server", "Iran side — exposes ports to users") menuItem(2, "Setup Client", "Kharej side — dials out to the Iran server") - menuItem(3, "Manage", "tunnels, ports, transport, status, health check") - menuItem(4, "Backup & Restore", "save or restore the full configuration") - menuItem(5, "Web Panel", "monitoring web UI — link, login code, port") - menuItem(6, "Optimize", "kernel & network tuning — BBR, buffers, limits") - menuItem(7, "Telegram Bot", "status reports, relayed through a tunnel") + menuItem(3, "Setup Direct", "iptables direct forward — IPv4/IPv6, TCP/UDP") + menuItem(4, "Manage", "instances, ports, transport, status, health check") + menuItem(5, "Backup & Restore", "save or restore the full configuration") + menuItem(6, "Web Panel", "monitoring web UI — link, login code, port") + menuItem(7, "Optimize", "kernel & network tuning — BBR, buffers, limits") + menuItem(8, "Telegram Bot", "status reports, relayed through a tunnel") updateDesc := "safe update with automatic rollback" if tag, ok := manage.UpdateAvailable(); ok { updateDesc = tag + " is out — safe update with automatic rollback" } - menuItem(8, "Update", updateDesc) - menuItem(9, "Uninstall", "remove everything") - menuItem(10, "Exit", "") + menuItem(9, "Update", updateDesc) + menuItem(10, "Uninstall", "remove everything") + menuItem(11, "Exit", "") fmt.Println() } @@ -997,7 +1002,7 @@ func restorePointMenu() { tui.PressEnter() } -// uninstallMenu is main-menu item 9. +// uninstallMenu is main-menu item 10. func uninstallMenu() { tui.Clear() tui.Title("Uninstall Backpack") @@ -1014,13 +1019,31 @@ func uninstallMenu() { repo = app.InstallDir } + var cleanupFailures []string for _, t := range manage.List() { - _ = manage.Delete(t.Name) + if err := manage.Delete(t.Name); err != nil { + cleanupFailures = append(cleanupFailures, t.Name+": "+err.Error()) + } + } + if err := engine.CleanupOrphans(context.Background(), app.ConfigDir, true); err != nil { + cleanupFailures = append(cleanupFailures, "orphan netfilter cleanup: "+err.Error()) + } + if len(cleanupFailures) > 0 { + tui.Error("Uninstall stopped because owned netfilter rules could not be safely cleaned:") + for _, failure := range cleanupFailures { + fmt.Println(" " + failure) + } + tui.Warn("Configs and the binary were kept so cleanup can be retried.") + tui.PressEnter() + return } _ = webui.Disable() _ = manage.DisableMonitorService() _ = schedule.SetAutoRefresh(0) _ = telegram.Disable() + if err := engine.RemoveRuntimeArtifacts(); err != nil { + tui.Warn("Could not remove the netfilter runtime lock: " + err.Error()) + } os.RemoveAll(app.ConfigDir) if err := os.Remove(app.BinPath); err != nil { tui.Warn("Could not remove binary at " + app.BinPath + " — remove it manually.") diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 454ec17..7e6bb6b 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -23,12 +23,16 @@ type Snapshot struct { Name string `json:"name"` Transport string `json:"transport"` Role string `json:"role"` + Engine string `json:"engine,omitempty"` + Mode string `json:"mode,omitempty"` Taken time.Time `json:"taken"` Uptime string `json:"uptime"` // Traffic over the tunnel itself, as the transport sees it. - BytesIn uint64 `json:"bytes_in"` - BytesOut uint64 `json:"bytes_out"` + BytesIn uint64 `json:"bytes_in"` + BytesOut uint64 `json:"bytes_out"` + PacketsIn uint64 `json:"packets_in,omitempty"` + PacketsOut uint64 `json:"packets_out,omitempty"` // Peer is the address of the connected far end, when the transport knows // it and the operating system does not. @@ -186,10 +190,10 @@ func (c *Collector) Snapshot() Snapshot { liveIn, liveOut := Traffic() s.BytesIn, s.BytesOut = c.baseIn+liveIn, c.baseOut+liveOut if c.bytesIn != nil { - s.BytesIn = c.bytesIn() + s.BytesIn = c.baseIn + c.bytesIn() } if c.bytesOut != nil { - s.BytesOut = c.bytesOut() + s.BytesOut = c.baseOut + c.bytesOut() } if live, target, configured, mbps := PoolState(); configured > 0 { s.Pool = &PoolStats{Live: live, Target: target, Configured: configured, Mbps: mbps} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go index 769e66a..9d810cf 100644 --- a/internal/telegram/telegram.go +++ b/internal/telegram/telegram.go @@ -157,13 +157,21 @@ func tunnelBlock(lang string, t manage.Tunnel, h manage.Health) string { if f := tunnelFlag(t); f != "" { fmt.Fprintf(&b, "%s ", f) } - fmt.Fprintf(&b, "%s [ %s ]", t.Name, strings.ToUpper(t.Transport)) + label := strings.ToUpper(t.Transport) + if t.Mode == "direct" { + label = "DIRECT/" + strings.ToUpper(t.Engine) + } + fmt.Fprintf(&b, "%s [ %s ]", t.Name, label) if p := manage.PresetLabel(t.Name); p != "" { fmt.Fprintf(&b, " [ %s ]", p) } b.WriteString("\n") - if t.Role == "server" { + if t.Mode == "direct" { + for _, m := range t.Mappings { + fmt.Fprintf(&b, "%s %s:%s -> %s:%s\n", strings.ToUpper(strings.Join(m.Protocols, "+")), m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts) + } + } else if t.Role == "server" { fmt.Fprintf(&b, tr(lang, "Tunnel Port")+" : %s\n", portOf(t.Addr)) if ports := manage.VisiblePorts(t.Ports, manage.TunnelToken(t.Name)); len(ports) > 0 { fmt.Fprintf(&b, tr(lang, "Forwarded Port")+" : %s\n", strings.Join(ports, ", ")) diff --git a/internal/webui/assets/dashboard.html b/internal/webui/assets/dashboard.html index 810520f..ee0fe6d 100644 --- a/internal/webui/assets/dashboard.html +++ b/internal/webui/assets/dashboard.html @@ -1857,9 +1857,9 @@

Logs

-
${t.role==='server'?'Tunnel Port':'Server'}
+
${t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server')}
Ping
-
${t.role==='server'?'Forwarded Ports':'Remote'}
+
${t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote')}
@@ -1885,7 +1885,7 @@

Logs

const fl=el.querySelector('.flag'), fe=cc?flagEmoji(cc):''; if(fl.textContent!==fe){ fl.textContent=fe; fl.title=cc?countryName(cc):''; } - setText(el.querySelector('.badge.tr'),(t.transport||'').toUpperCase()); + setText(el.querySelector('.badge.tr'),((t.mode==='direct'?t.engine:t.transport)||'').toUpperCase()); el.querySelector('.badge.bot').style.display=t.botRelay?'':'none'; const pe=el.querySelector('.ping'), pc='v ping '+pingClass(t.ping); @@ -1893,7 +1893,9 @@

Logs

setText(pe,pingText(t.ping)); // A server card shows the port clients dial; the full bind address is noise. - setText(el.querySelector('.addr'), t.role==='server' ? (t.tunnelPort||'—') : (t.addr||'—')); + setText(el.querySelector('.addrlabel'),t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server')); + setText(el.querySelector('.portslabel'),t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote')); + setText(el.querySelector('.addr'), t.mode==='direct' ? (t.engine||'iptables') : (t.role==='server' ? (t.tunnelPort||'—') : (t.addr||'—'))); setText(el.querySelector('.ports'),t.ports||t.addr||'—'); // Traffic comes from the tunnel's own metrics snapshot; a tunnel that has @@ -2029,10 +2031,20 @@

Logs

h+=dRow('Tunnel Uptime',t.uptime); h+=dRow('Traffic In',t.bytesIn); h+=dRow('Traffic Out',t.bytesOut); + if(t.packetsIn) h+=dRow('Packets In',t.packetsIn); + if(t.packetsOut) h+=dRow('Packets Out',t.packetsOut); h+=dRow('Preset',t.preset); + h+=dRow('Mode',t.mode); + h+=dRow('Engine',(t.engine||'').toUpperCase()); h+=dRow('Transport',(t.transport||'').toUpperCase()); h+='
'; + if(t.mode==='direct'&&t.mappings&&t.mappings.length){ + h+=dSec('Direct mappings')+'
'; + t.mappings.forEach(m=>{h+=dRow((m.protocols||[]).join('+').toUpperCase(),m.listenAddress+':'+m.listenPorts+' → '+m.targetAddress+':'+m.targetPorts,true);}); + h+='
'; + } + const lim=[]; if(t.maxConnections) lim.push(t.maxConnections+' connections'); if(t.bandwidthMbps) lim.push(t.bandwidthMbps+' Mbit/s'); diff --git a/internal/webui/direct_api_test.go b/internal/webui/direct_api_test.go new file mode 100644 index 0000000..c025aaf --- /dev/null +++ b/internal/webui/direct_api_test.go @@ -0,0 +1,35 @@ +package webui + +import ( + "encoding/json" + "testing" + + "github.com/backpack/backpack/config" +) + +func TestTunnelInfoAdditiveEngineShapeIsStable(t *testing.T) { + for _, info := range []TunnelInfo{ + {Name: "legacy", Mode: "reverse", Engine: "reverse", Role: "server", Transport: "tcp", Mappings: []config.ForwardMapping{}}, + {Name: "direct", Mode: "direct", Engine: "iptables", Role: "", Transport: "", Mappings: []config.ForwardMapping{}}, + } { + b, err := json.Marshal(info) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + for _, field := range []string{"mode", "engine", "role", "transport", "mappings", "packetsIn", "packetsOut"} { + if _, ok := got[field]; !ok { + t.Errorf("%s response omitted stable field %q: %s", info.Name, field, b) + } + } + if mappings, ok := got["mappings"].([]any); !ok || len(mappings) != 0 { + t.Errorf("%s mappings must be an empty JSON array, got %#v", info.Name, got["mappings"]) + } + if info.Mode == "direct" && (got["role"] != "" || got["transport"] != "") { + t.Errorf("direct reverse-only fields must be stable empty strings: %s", b) + } + } +} diff --git a/internal/webui/prometheus.go b/internal/webui/prometheus.go index b999303..6e09e87 100644 --- a/internal/webui/prometheus.go +++ b/internal/webui/prometheus.go @@ -35,21 +35,39 @@ func (s *server) handlePrometheus(w http.ResponseWriter, r *http.Request) { b.WriteString("# HELP backpack_tunnel_up 1 when the tunnel's peer is connected\n# TYPE backpack_tunnel_up gauge\n") for _, t := range tunnels { + if t.Mode != "reverse" { + continue + } up := health[t.Name].State == "online" fmt.Fprintf(&b, "backpack_tunnel_up{name=%q,transport=%q,role=%q} %d\n", t.Name, t.Transport, t.Role, int(boolVal(up))) } + b.WriteString("# HELP backpack_instance_up 1 when the instance's engine-specific desired state is healthy\n# TYPE backpack_instance_up gauge\n") + for _, t := range tunnels { + up := health[t.Name].State == "online" + fmt.Fprintf(&b, "backpack_instance_up{name=%q,mode=%q,engine=%q} %d\n", t.Name, t.Mode, t.Engine, int(boolVal(up))) + } counterHead(&b, "backpack_tunnel_bytes_in_total", "Bytes received over the tunnel") counterHead(&b, "backpack_tunnel_bytes_out_total", "Bytes sent over the tunnel") var kcpSnaps []metrics.Snapshot + counterHead(&b, "backpack_instance_rx_bytes_total", "Cumulative bytes received by an instance") + counterHead(&b, "backpack_instance_tx_bytes_total", "Cumulative bytes sent by an instance") + counterHead(&b, "backpack_instance_rx_packets_total", "Cumulative packets received by an instance") + counterHead(&b, "backpack_instance_tx_packets_total", "Cumulative packets sent by an instance") for _, t := range tunnels { snap, err := metrics.Read(app.ConfigDir, t.Name) if err != nil { continue } - fmt.Fprintf(&b, "backpack_tunnel_bytes_in_total{name=%q} %d\n", t.Name, snap.BytesIn) - fmt.Fprintf(&b, "backpack_tunnel_bytes_out_total{name=%q} %d\n", t.Name, snap.BytesOut) + if t.Mode == "reverse" { + fmt.Fprintf(&b, "backpack_tunnel_bytes_in_total{name=%q} %d\n", t.Name, snap.BytesIn) + fmt.Fprintf(&b, "backpack_tunnel_bytes_out_total{name=%q} %d\n", t.Name, snap.BytesOut) + } + fmt.Fprintf(&b, "backpack_instance_rx_bytes_total{name=%q,mode=%q,engine=%q} %d\n", t.Name, t.Mode, t.Engine, snap.BytesIn) + fmt.Fprintf(&b, "backpack_instance_tx_bytes_total{name=%q,mode=%q,engine=%q} %d\n", t.Name, t.Mode, t.Engine, snap.BytesOut) + fmt.Fprintf(&b, "backpack_instance_rx_packets_total{name=%q,mode=%q,engine=%q} %d\n", t.Name, t.Mode, t.Engine, snap.PacketsIn) + fmt.Fprintf(&b, "backpack_instance_tx_packets_total{name=%q,mode=%q,engine=%q} %d\n", t.Name, t.Mode, t.Engine, snap.PacketsOut) if snap.KCP != nil { snap.Name = t.Name kcpSnaps = append(kcpSnaps, snap) diff --git a/internal/webui/stats.go b/internal/webui/stats.go index 106598b..5b4c26b 100644 --- a/internal/webui/stats.go +++ b/internal/webui/stats.go @@ -1,6 +1,7 @@ package webui import ( + "fmt" "net" "os/exec" "strconv" @@ -104,16 +105,19 @@ type SystemStats struct { // TunnelInfo is one row for /api/tunnels. type TunnelInfo struct { - Name string `json:"name"` - Role string `json:"role"` - Transport string `json:"transport"` - Addr string `json:"addr"` - Ports string `json:"ports"` - State string `json:"state"` - Ping int `json:"ping"` // milliseconds, -1 = n/a - PeerLocation string `json:"peerLocation"` - PeerISP string `json:"peerISP"` - BotRelay bool `json:"botRelay"` // has a hidden port used for the Telegram relay + Name string `json:"name"` + Mode string `json:"mode"` + Engine string `json:"engine"` + Role string `json:"role"` + Transport string `json:"transport"` + Mappings []config.ForwardMapping `json:"mappings"` + Addr string `json:"addr"` + Ports string `json:"ports"` + State string `json:"state"` + Ping int `json:"ping"` // milliseconds, -1 = n/a + PeerLocation string `json:"peerLocation"` + PeerISP string `json:"peerISP"` + BotRelay bool `json:"botRelay"` // has a hidden port used for the Telegram relay // BotRelayPort is the loopback port that relay listens on. It is shown on // its own, under its own name, rather than as the raw mapping: the mapping // reads like something the operator set up and can therefore tidy away, @@ -129,9 +133,11 @@ type TunnelInfo struct { TunnelPort string `json:"tunnelPort"` // From the tunnel's metrics snapshot (empty when none has been written yet). - Uptime string `json:"uptime,omitempty"` - BytesIn string `json:"bytesIn,omitempty"` - BytesOut string `json:"bytesOut,omitempty"` + Uptime string `json:"uptime,omitempty"` + BytesIn string `json:"bytesIn,omitempty"` + BytesOut string `json:"bytesOut,omitempty"` + PacketsIn uint64 `json:"packetsIn"` + PacketsOut uint64 `json:"packetsOut"` // BytesTotal is the two added. The card shows all three on one line, and a // sum of two already-formatted strings is not something the browser can do. BytesTotal string `json:"bytesTotal,omitempty"` @@ -426,8 +432,11 @@ func GatherTunnels() []TunnelInfo { ports, relayPort, bot := splitBotRelay(t.Ports, "") info := TunnelInfo{ Name: t.Name, + Mode: t.Mode, + Engine: t.Engine, Role: t.Role, Transport: t.Transport, + Mappings: append([]config.ForwardMapping{}, t.Mappings...), Addr: t.Addr, Ports: ports, BotRelay: bot, @@ -440,7 +449,15 @@ func GatherTunnels() []TunnelInfo { Country: manage.TunnelCountry(t.Name), Ping: -1, } - if t.Role == "server" { + if t.Mode == "direct" { + info.State = health[t.Name].State + var rendered []string + for _, m := range t.Mappings { + rendered = append(rendered, fmt.Sprintf("%s:%s → %s:%s (%s)", m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts, strings.ToUpper(strings.Join(m.Protocols, "+")))) + } + info.Ports = strings.Join(rendered, ", ") + info.TunnelPort = "" + } else if t.Role == "server" { // Server (e.g. the Iran node): we can't ping our own bind_addr, // but we can detect the connected client(s) — the kharej peers // dialing in — and measure/geo-locate them. This gives the Iran @@ -747,6 +764,8 @@ func fillMetrics(info *TunnelInfo, snap metrics.Snapshot) { info.BytesIn = sysstat.HumanBytes(snap.BytesIn) info.BytesOut = sysstat.HumanBytes(snap.BytesOut) info.BytesTotal = sysstat.HumanBytes(snap.BytesIn + snap.BytesOut) + info.PacketsIn = snap.PacketsIn + info.PacketsOut = snap.PacketsOut info.Rates = rates.sample(info.Name, snap) info.Pool = snap.Pool if snap.KCP != nil { @@ -762,6 +781,9 @@ func fillConfig(info *TunnelInfo, t manage.Tunnel) { if err != nil { return } + if t.Mode == "direct" { + return + } if t.Role == "server" { sc := cfg.Server // Now that the token is known, split the ports again: one of the relay diff --git a/main.go b/main.go index 9bce897..a42c931 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "os" "os/signal" "syscall" - "time" "github.com/backpack/backpack/cmd" "github.com/backpack/backpack/internal/app" @@ -97,10 +96,16 @@ func runEngine(configPath string) { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - go cmd.Run(configPath, ctx) + done := make(chan struct{}) + go func() { + defer close(done) + cmd.Run(configPath, ctx) + }() <-sigChan cancel() - time.Sleep(1 * time.Second) + // Engine shutdown owns the ordered quiesce, final counter scrape and + // cleanup. Do not let the process exit until that sequence has completed. + <-done logger.Info("backpack engine stopped") } From 817416ee6729e88e44e227ee0e525103a1bfbbe6 Mon Sep 17 00:00:00 2001 From: etm Date: Fri, 7 Aug 2026 01:28:30 +0330 Subject: [PATCH 3/4] feat: add transport-aware direct connection mode --- .gitattributes | 1 + README.md | 30 +-- README_FA.md | 33 ++-- cmd/cmd.go | 2 +- cmd/defaults.go | 2 +- config/config.go | 34 +++- config/engine_test.go | 24 +++ docs/direct-forward.md | 124 +++++------- img/architecture.svg | 8 +- internal/client/client.go | 53 ++++- internal/client/transport/forward_common.go | 87 ++++++++ internal/client/transport/forward_limits.go | 58 ++++++ internal/client/transport/forward_tcp.go | 108 ++++++++++ internal/client/transport/forward_udp.go | 153 +++++++++++++++ internal/client/transport/kcp.go | 105 +++++++++- internal/client/transport/quic.go | 46 ++++- internal/client/transport/tcp.go | 78 +++++++- internal/client/transport/tcpmux.go | 87 +++++++- internal/client/transport/udp.go | 45 +++-- internal/client/transport/ws.go | 109 +++++++++-- internal/client/transport/wsmux.go | 74 ++++++- internal/e2e/forward_tcp_test.go | 90 +++++++++ internal/engine/engine.go | 3 + internal/engine/forward.go | 122 ++++++++++++ internal/engine/reverse.go | 31 ++- internal/engine/reverse_metrics_test.go | 28 +++ internal/forwardmap/forwardmap.go | 178 +++++++++++++++++ internal/forwardmap/forwardmap_test.go | 48 +++++ internal/manage/backup.go | 96 ++++++++- internal/manage/backup_restore_claims_test.go | 42 ++++ internal/manage/benchmarkmenu.go | 6 +- internal/manage/config.go | 110 ++++++++++- internal/manage/diagnose.go | 4 +- internal/manage/direct.go | 2 +- internal/manage/edit.go | 70 +++++-- internal/manage/forward_config_test.go | 105 ++++++++++ internal/manage/forward_conflict.go | 185 ++++++++++++++++++ internal/manage/forward_conflict_test.go | 46 +++++ internal/manage/health.go | 7 + internal/manage/menu.go | 116 ++++++++++- internal/manage/setup.go | 143 ++++++++++++-- internal/manage/setup_flow_test.go | 64 ++++++ internal/manage/status.go | 4 +- internal/manage/tunnel.go | 27 +++ internal/manage/validate.go | 9 + internal/manage/watchdog.go | 6 + internal/menu/menu.go | 41 ++-- internal/metrics/metrics.go | 28 ++- internal/metrics/persist_test.go | 26 +++ internal/server/server.go | 26 ++- internal/server/transport/forward.go | 80 ++++++++ internal/server/transport/kcp.go | 122 ++++++++++-- internal/server/transport/quic.go | 15 +- internal/server/transport/tcp.go | 35 +++- internal/server/transport/tcpmux.go | 31 ++- internal/server/transport/udp.go | 93 ++++++++- internal/server/transport/ws.go | 42 +++- internal/server/transport/wsmux.go | 30 ++- internal/telegram/telegram.go | 11 +- internal/utils/forward_udp.go | 34 ++++ internal/utils/forward_udp_test.go | 19 ++ internal/utils/network/resolver.go | 29 +-- internal/utils/network/resolver_test.go | 1 + internal/utils/signals.go | 14 ++ internal/webui/assets/dashboard.html | 26 ++- internal/webui/direct_api_test.go | 28 ++- internal/webui/handlers_monitoring.go | 2 +- internal/webui/stats.go | 11 +- 68 files changed, 3226 insertions(+), 321 deletions(-) create mode 100644 internal/client/transport/forward_common.go create mode 100644 internal/client/transport/forward_limits.go create mode 100644 internal/client/transport/forward_tcp.go create mode 100644 internal/client/transport/forward_udp.go create mode 100644 internal/e2e/forward_tcp_test.go create mode 100644 internal/engine/forward.go create mode 100644 internal/engine/reverse_metrics_test.go create mode 100644 internal/forwardmap/forwardmap.go create mode 100644 internal/forwardmap/forwardmap_test.go create mode 100644 internal/manage/backup_restore_claims_test.go create mode 100644 internal/manage/forward_config_test.go create mode 100644 internal/manage/forward_conflict.go create mode 100644 internal/manage/forward_conflict_test.go create mode 100644 internal/manage/setup_flow_test.go create mode 100644 internal/server/transport/forward.go create mode 100644 internal/utils/forward_udp.go create mode 100644 internal/utils/forward_udp_test.go diff --git a/.gitattributes b/.gitattributes index b02a463..aa84b7d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.go text eol=lf *.sh text eol=lf +*.html text eol=lf diff --git a/README.md b/README.md index 86fe2b2..fd46ebf 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,12 @@ dashboard — so you can run and manage everything with or without a terminal. ## Architecture -

Backpack architecture: end users reach a forwarded port on the Iran server, the engine carries it through one transport to the kharej client, which forwards it to the real service. The client dials the server.

+

Backpack architecture: end users reach a forwarded port on the Iran server, and the selected transport carries traffic to the real service on Kharej.

An end user connects to a **forwarded port** on the Iran server; the engine -carries it through **one transport** to the kharej client, which forwards it to -the **real service**. The tunnel is always dialed **by the client** -(kharej → Iran), so the far side needs no open inbound port. +carries it through **one selected transport** to Kharej, which forwards it to +the **real service**. In Direct mode Iran dials Kharej; in legacy Reverse mode +Kharej dials Iran. --- @@ -36,7 +36,7 @@ the **real service**. The tunnel is always dialed **by the client** - **Kernel direct forwarding** — dual-stack TCP/UDP DNAT with iptables, transactional rule generations, ownership-safe cleanup and persistent counters. -- **Multi-transport** — nine transports across TCP, UDP and WebSocket, so you +- **Multi-transport** — ten production transports across TCP, UDP and WebSocket, so you match the route instead of fighting it. - **Automatic rollback** — an update or edit that breaks a tunnel reverts itself, so you are never left with a dead tunnel. @@ -132,7 +132,7 @@ after it. | Server | Where | Menu option | Why | |--------|-------|-------------|-----| | **Iran server** | entry point | **Setup Server** | It exposes the ports; users connect to the **Iran IP** (fast, unfiltered for local users). | -| **Abroad (kharej)** | exit / origin | **Setup Client** | It dials the Iran server and forwards traffic to the real service (VPN panel, etc.). | +| **Abroad (kharej)** | exit / origin | **Setup Client** | It accepts Direct or dials Iran for Reverse, then forwards traffic to the real service (VPN panel, etc.). | ``` end users ──▶ Iran server (SERVER, exposes ports) ──tunnel──▶ Kharej (CLIENT, real service) @@ -147,10 +147,12 @@ after it. sudo backpack → 1. Setup Server ``` -Pick the transport family (TCP / UDP / WebSocket) and then the variant, the -tunnel port and the exposed ports, accept the suggested **64-character token** -(press Enter), and choose a performance preset — **Turbo** is the recommended -default. Copy the token; you'll need it on the client. +Pick the transport family and its concrete variant, then choose the connection +mode. **Direct** keeps that exact transport but makes Iran initiate the tunnel +toward Kharej; **Reverse** keeps the legacy direction where Kharej initiates +toward Iran. In both modes accept the suggested **64-character token** (press +Enter), copy it to the other side, and configure the same transport and tunnel +port there. **Turbo** is the recommended performance preset. ### 2) On the abroad (kharej) server — create the Client tunnel @@ -158,14 +160,16 @@ default. Copy the token; you'll need it on the client. sudo backpack → 2. Setup Client ``` -Enter the **Iran server IP**, the tunnel port and the **same token**. Done. +Choose the same transport and mode. For Direct, choose the local tunnel listen +port; for Reverse, enter the Iran server IP and tunnel port. Enter the **same +token** on both sides. Done. --- ## Features -**Transports** — TCP, TCP Mux, TCP + Stealth, UDP, UDP + KCP, WS, WS Mux, -WSS and WSS Mux, with connection pooling. +**Transports** — TCP, TCP Mux, TCP + Stealth, UDP, UDP + KCP, UDP + QUIC, WS, +WS Mux, WSS and WSS Mux, with connection pooling where applicable. - **TCP + Stealth** — a TCP tunnel wrapped in a Noise layer with **no fingerprint**; on the wire it looks like random bytes, so there is nothing for diff --git a/README_FA.md b/README_FA.md index 3fff216..d20a1fa 100644 --- a/README_FA.md +++ b/README_FA.md @@ -12,7 +12,7 @@ Total downloads across all releases

-**بک‌پک** یک هسته‌ی تونل معکوس (Reverse Tunnel) با کارایی بالاست که کاملاً با **Go** +**بک‌پک** یک هسته‌ی تونل Direct و Reverse با کارایی بالاست که کاملاً با **Go** نوشته شده و برای ست‌آپ سرور ایران ⇄ خارج طراحی شده. یک باینری واحد است با یک منوی تعاملی CLI **و** یک پنل وب امن — یعنی همه‌چیز را با ترمینال یا بدون ترمینال می‌توانی مدیریت کنی. @@ -25,18 +25,17 @@ ## معماری -

معماری بک‌پک: کاربر به پورت forward‌شده روی سرور ایران وصل می‌شود، انجین آن را از یک ترنسپورت به کلاینت خارج می‌برد و کلاینت به سرویس واقعی می‌رساند. کلاینت به سرور dial می‌کند.

+

معماری بک‌پک: کاربر به پورت forward‌شده روی سرور ایران وصل می‌شود و ترنسپورت انتخابی ترافیک را به سرویس واقعی در خارج می‌رساند.

-کاربر به یک **پورت forward‌شده** روی سرور ایران وصل می‌شود؛ انجین آن را از طریق -**یک ترنسپورت** به کلاینت خارج می‌برد و کلاینت آن را به **سرویس واقعی** می‌رساند. -خودِ تونل همیشه **توسط کلاینت** برقرار می‌شود (خارج → ایران)، پس سمت خارج نیازی به -پورت ورودی باز ندارد. +کاربر به یک **پورت forward‌شده** روی سرور ایران وصل می‌شود؛ انجین آن را با +**ترنسپورت انتخاب‌شده** به **سرویس واقعی** در خارج می‌رساند. در حالت Direct اتصال +تونل از ایران به خارج شروع می‌شود؛ در حالت قدیمی Reverse از خارج به ایران. --- ## چرا بک‌پک؟ -- **چند-ترنسپورت (Multi-transport):** نه ترنسپورت روی TCP، UDP و WebSocket — به‌جای +- **چند-ترنسپورت (Multi-transport):** ده ترنسپورت production روی TCP، UDP و WebSocket — به‌جای جنگیدن با مسیر، با آن هماهنگ می‌شوی. - **بازگشت خودکار (Automatic rollback):** آپدیت یا ویرایشی که تونل را خراب کند خودش برمی‌گردد عقب، پس هیچ‌وقت با تونل مرده تنها نمی‌مانی. @@ -144,7 +143,7 @@ sudo backpack | سرور | نقش | گزینه‌ی منو | چرا | |------|-----|------------|-----| | **سرور ایران** | ورودی | **Setup Server** | پورت‌ها را expose می‌کند؛ کاربر به **IP ایران** وصل می‌شود (سریع و بدون فیلتر). | -| **سرور خارج** | خروجی | **Setup Client** | به سرور ایران dial می‌کند و ترافیک را به سرویس واقعی می‌رساند. | +| **سرور خارج** | خروجی | **Setup Client** | در Direct اتصال ایران را می‌پذیرد و در Reverse به ایران dial می‌کند. | @@ -154,8 +153,8 @@ sudo backpack
-**همیشه اول سرور ایران (Server) را ست کن**، بعد سرور خارج (Client) را — کلاینت به -آدرس ایران و توکنی که سرور می‌سازد نیاز دارد. +اول سرور ایران (Server) را ست کن و mode، ترنسپورت، پورت تونل و توکن را یادداشت کن؛ +بعد همان مقادیر را روی سرور خارج (Client) وارد کن. ### ۱) روی سرور ایران — ساخت تونل Server @@ -167,9 +166,10 @@ sudo backpack → 1. Setup Server
-اول خانواده‌ی ترنسپورت (TCP / UDP / WebSocket) و بعد نوعش را انتخاب کن، پورت تونل -و پورت‌های expose را بده، **توکن ۶۴ کاراکتری** پیشنهادی را با Enter بپذیر، و یک -پریست انتخاب کن — **Turbo** پیشنهاد پیش‌فرض است. توکن را کپی کن (برای کلاینت لازم است). +اول خانواده و سپس نوع دقیق ترنسپورت را انتخاب کن. قبل از سؤال پورت‌ها، روش اتصال +را انتخاب می‌کنی: **Direct** یعنی ایران به خارج متصل شود؛ **Reverse** یعنی روش قدیمی +خارج به ایران. پورت تونل و پورت‌های expose را بده، **توکن ۶۴ کاراکتری** پیشنهادی را +با Enter بپذیر و یک پریست انتخاب کن — **Turbo** پیشنهاد پیش‌فرض است. ### ۲) روی سرور خارج — ساخت تونل Client @@ -181,14 +181,15 @@ sudo backpack → 2. Setup Client
-**IP سرور ایران**، پورت تونل، و **همان توکن** را وارد کن. تمام. +همان ترنسپورت و mode را انتخاب کن. در Direct پورت listen سرور خارج را بده؛ در +Reverse، IP و پورت سرور ایران را وارد کن. در هر دو حالت **همان توکن** را وارد کن. --- ## امکانات -**ترنسپورت‌ها** — TCP، TCP Mux، TCP + Stealth، UDP، UDP + KCP، WS، WS Mux، -WSS و WSS Mux، با Connection Pool. +**ترنسپورت‌ها** — TCP، TCP Mux، TCP + Stealth، UDP، UDP + KCP، UDP + QUIC، WS، +WS Mux، WSS و WSS Mux، با Connection Pool در موارد قابل استفاده. - **TCP + Stealth** — یک تونل TCP در لایه‌ی Noise با **بدون fingerprint**؛ روی سیم شبیه بایت تصادفی است، پس چیزی برای تطبیق DPI نیست. برای جایی که فیلترینگ سنگین diff --git a/cmd/cmd.go b/cmd/cmd.go index 14c1a3f..db5b380 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -73,7 +73,7 @@ func runEngine(cfg *config.Config, ctx context.Context, configPath string, apply if err != nil { return err } - if cfg.EffectiveEngine() == config.EngineReverse { + if cfg.EffectiveEngine() == config.EngineReverse || cfg.EffectiveEngine() == config.EngineForward { if cfg.HasServer() { // Apply temporary TCP optimizations at startup if applyTuning && !cfg.Server.SkipOptz { diff --git a/cmd/defaults.go b/cmd/defaults.go index 39b3cdd..364e64f 100644 --- a/cmd/defaults.go +++ b/cmd/defaults.go @@ -41,7 +41,7 @@ const ( // Default values ) func applyDefaults(cfg *config.Config) { - if cfg.EffectiveEngine() != config.EngineReverse { + if cfg.EffectiveEngine() != config.EngineReverse && cfg.EffectiveEngine() != config.EngineForward { return } // Token diff --git a/config/config.go b/config/config.go index 3d5f9cb..2aa97f8 100644 --- a/config/config.go +++ b/config/config.go @@ -14,7 +14,12 @@ import ( type EngineType string const ( - EngineReverse EngineType = "reverse" + EngineReverse EngineType = "reverse" + // EngineForward keeps Backpack's application-level tunnel and selected + // transport, but reverses who establishes it: the Iran edge dials the + // Kharej origin. It is intentionally distinct from EngineIPTables, which + // is a kernel-only DNAT engine and does not carry a Backpack transport. + EngineForward EngineType = "forward" EngineIPTables EngineType = "iptables" ) @@ -294,6 +299,10 @@ type ServerConfig struct { // ClientConfig represents the configuration for the client. type ClientConfig struct { RemoteAddr string `toml:"remote_addr"` + // Ports is used only by the forward engine. In that mode the dialling + // client is the Iran edge, so it also owns the public ingress listeners. + // Reverse client configs omit it and retain their historical meaning. + Ports []string `toml:"ports"` // FallbackAddrs are additional server addresses tried in order whenever the // primary cannot be reached (a filtered IP, a blocked port, a CDN edge). FallbackAddrs []string `toml:"fallback_addrs"` @@ -366,6 +375,12 @@ type ClientConfig struct { // plain `tcp` transport on Linux, and only when the tunnel has no bandwidth // limit — anything else quietly keeps the buffered path. ZeroCopy bool `toml:"zero_copy"` + // The following ingress controls are meaningful on the dialling side only + // for EngineForward. They mirror the long-standing reverse server knobs. + AcceptUDP bool `toml:"accept_udp"` + ProxyProtocol bool `toml:"proxy_protocol"` + MaxConnections int `toml:"max_connections"` + BandwidthMbps int `toml:"bandwidth_mbps"` Preset string `toml:"preset"` // LoadBalance spreads the pool's data connections over every configured @@ -445,6 +460,23 @@ func (c *Config) ValidateStructure() error { if hasForward || hasServer == hasClient { return fmt.Errorf("engine %q requires exactly one of [server] or [client]", EngineReverse) } + case EngineForward: + if hasForward || hasServer == hasClient { + return fmt.Errorf("engine %q requires exactly one of [server] or [client]", EngineForward) + } + // Operational roles are deliberately used here: [client] is the Iran + // dialler and therefore owns ingress ports; [server] is the Kharej + // listener and never exposes ports itself. + if hasClient { + if strings.TrimSpace(c.Client.RemoteAddr) == "" { + return fmt.Errorf("engine %q [client] requires remote_addr", EngineForward) + } + if len(c.Client.Ports) == 0 { + return fmt.Errorf("engine %q Iran [client] requires at least one ingress port mapping", EngineForward) + } + } else if strings.TrimSpace(c.Server.BindAddr) == "" { + return fmt.Errorf("engine %q Kharej [server] requires bind_addr", EngineForward) + } case EngineIPTables: if !hasForward || hasServer || hasClient { return fmt.Errorf("engine %q requires [forward] and no reverse section", EngineIPTables) diff --git a/config/engine_test.go b/config/engine_test.go index 61fba28..6ce1e86 100644 --- a/config/engine_test.go +++ b/config/engine_test.go @@ -60,6 +60,30 @@ func TestExplicitReverseAndLegacyClientRemainValid(t *testing.T) { } } +func TestApplicationForwardUsesOperationalClientAndServerSections(t *testing.T) { + edge := "engine='forward'\n[client]\nremote_addr='192.0.2.10:443'\ntransport='tcp'\nports=['8443=127.0.0.1:8443']\n" + origin := "engine='forward'\n[server]\nbind_addr=':443'\ntransport='tcp'\n" + for _, body := range []string{edge, origin} { + cfg, err := LoadFile(writeConfig(t, body)) + if err != nil { + t.Fatalf("valid application forward config rejected: %v\n%s", err, body) + } + if cfg.EffectiveEngine() != EngineForward || cfg.HasForward() { + t.Fatalf("application forward confused with iptables forwarding: %#v", cfg) + } + } + + for _, body := range []string{ + "engine='forward'\n[client]\nremote_addr='192.0.2.10:443'\ntransport='tcp'\n", + "engine='forward'\n[server]\ntransport='tcp'\n", + "engine='forward'\n[forward]\n", + } { + if _, err := LoadFile(writeConfig(t, body)); err == nil { + t.Fatalf("invalid application forward config accepted:\n%s", body) + } + } +} + func TestForwardValidation(t *testing.T) { body := "engine='iptables'\n[forward]\n[[forward.mappings]]\nlisten_address='0.0.0.0'\nlisten_ports='1000-1002'\ntarget_address='192.0.2.10'\ntarget_ports='2000-2002'\nprotocols=['tcp','udp']\n" if _, err := LoadFile(writeConfig(t, body)); err != nil { diff --git a/docs/direct-forward.md b/docs/direct-forward.md index f548cae..3e69360 100644 --- a/docs/direct-forward.md +++ b/docs/direct-forward.md @@ -1,82 +1,62 @@ -# Direct forwarding (iptables) +# Direct connection mode -Backpack can forward TCP and UDP traffic directly through the Linux kernel in -addition to its existing reverse-tunnel mode. Each direct-forward config is a -long-running systemd instance: it installs and watches its own rules, records -traffic counters, and removes only its owned rules when stopped. +Backpack supports two directions for its application tunnel. Both directions +use the transport selected by the user (TCP, TCP Mux, Stealth, UDP, KCP, QUIC, +WS, WSS, WS Mux or WSS Mux): -Create one from **Setup Direct Forward** in the CLI, or use this TOML form: +- **Direct:** the Iran server initiates the selected tunnel toward Kharej. The + public user ports remain on Iran and the backend service remains on Kharej. +- **Reverse:** the legacy behaviour; Kharej initiates the selected tunnel + toward Iran. -```toml -engine = "iptables" +Run `sudo backpack`, choose **Setup Server** on Iran or **Setup Client** on +Kharej, select the transport family and concrete transport, then select the +connection mode. The mode question appears after every concrete transport and +before the port questions. Both sides must use the same mode, transport, +tunnel port and token. -[forward] +Direct application configs use `engine = "forward"`. Iran is operationally the +dialling `[client]` and owns `ports`; Kharej is operationally the listening +`[server]`. The CLI hides that implementation detail and continues to call the +machines Server (Iran) and Client (Kharej). -[[forward.mappings]] -listen_address = "0.0.0.0" -listen_ports = "443" -target_address = "203.0.113.10" -target_ports = "8443" -protocols = ["tcp", "udp"] +```toml +# Iran +engine = "forward" + +[client] +remote_addr = "KHAREJ_IP:8443" +transport = "tcpmux" +token = "SAME_LONG_TOKEN" +ports = ["443=127.0.0.1:443"] ``` -`0.0.0.0` listens on every local IPv4 address and `::` does the same for IPv6. -A specific listen address must exist on a local interface when the instance -starts. The target must be an explicit, same-family unicast IP address; host -names, loopback, multicast, unspecified addresses and IPv4/IPv6 translation -are intentionally rejected. - -Ports can be single values or equal-length ranges. Range mapping preserves the -offset, for example `10000-10009` to `20000-20009`. A mapping is limited to -1,024 expanded ports and an instance to 4,096. Only `tcp` and `udp` are valid. - -## Behaviour and requirements - -- Linux and root privileges are required. The installer provides the iptables - suite. Both `iptables-nft` and `iptables-legacy` are supported, but the - command, save and restore tools must use the same backend. -- IPv4-only configs do not require IPv6 tools. IPv6 tools are checked only - when an IPv6 mapping is used. -- Backpack enables `net.ipv4.ip_forward` and, when needed, IPv6 forwarding on - every start. Stop and uninstall do not turn these shared host settings off. -- Direct forwarding applies only to ingress traffic. Backpack creates no - general `OUTPUT` rule, so locally generated host traffic is not redirected. -- DNAT connections receive an instance-specific connection mark. FORWARD and - MASQUERADE rules require that mark and the exact configured tuple. -- Rule changes are prepared in detached chains. Hooks are activated last and - failures roll back the new generation. Rules carry a structured ownership - comment, so cleanup never flushes a table or deletes an unrelated rule. - -Backpack refuses to start if a mapping overlaps another Backpack config, a -local TCP/UDP listener, or an existing DNAT rule. If an nftables, multiport or -ipset expression may overlap but cannot be interpreted safely, validation -fails closed and reports the rule that needs review. - -## Health and counters - -Direct health represents local desired state: service, forwarding sysctls, -backend, chains, hooks and the desired rule hash. Target reachability does not -make the service unhealthy. **Diagnose** reports routes and an optional TCP -probe separately; UDP reachability remains unknown because a connected UDP -socket is not proof of a reachable service. - -RX/TX packets and bytes are counted by dedicated FORWARD accounting rules. -Cumulative values are persisted under `/etc/backpack/forward-state` before a -generation is removed, so restart, reconcile and reboot do not reduce the -Prometheus totals. - -Legacy configs with no `engine` field remain reverse tunnels and need no -migration. `mode` is display-only metadata and must not be written to TOML. - -## Integration test - -On a disposable Linux CI runner with root and network namespaces enabled, run: +```toml +# Kharej +engine = "forward" -```bash -sudo BACKPACK_NETNS_TEST=1 go test ./internal/engine -run TestDirectNetNSAcceptance -v +[server] +bind_addr = "0.0.0.0:8443" +transport = "tcpmux" +token = "SAME_LONG_TOKEN" ``` -The test builds isolated client, ingress and target namespaces and covers IPv4 -and IPv6, TCP and UDP, single-port and range remapping, MASQUERADE source -visibility, stop cleanup and restart recovery. It is skipped during ordinary -unprivileged unit-test runs. +A bare mapping such as `443` exposes port 443 on Iran and connects it to +`127.0.0.1:443` on Kharej. Ranges preserve offsets, for example +`10000-10009=127.0.0.1:20000-20009`. IPv6 endpoints must use brackets. A pipe +separates multiple backends and Direct load-balances over available members: +`443=127.0.0.1:8443|127.0.0.1:9443`. An instance may expand at most 4096 +ingress ports, with at most 1024 ports from any one mapping. + +Legacy configs without `engine` remain Reverse and require no migration. +`mode` is display metadata and is never written to TOML. + +## Advanced iptables engine + +The separate `engine = "iptables"` provider remains available for operators +who intentionally want kernel DNAT/MASQUERADE without an application tunnel. +It is not the Direct/Reverse choice in the normal setup wizard and must be +configured explicitly with `[forward]`. It supports IPv4/IPv6 TCP/UDP mappings, +owned generation chains, rollback, conflict detection and persistent counters. +See the example config and engine tests under `internal/engine` for that +advanced deployment path. diff --git a/img/architecture.svg b/img/architecture.svg index eefd64a..86f0965 100644 --- a/img/architecture.svg +++ b/img/architecture.svg @@ -19,7 +19,7 @@ IRAN SERVER - role: SERVER · exposes ports + role: SERVER · public ingress Forwarded ports · :443 :8443 Backpack engine @@ -28,7 +28,7 @@ KHAREJ / ABROAD - role: CLIENT · dials out + role: CLIENT · real-service side Backpack engine forward to the real service @@ -43,8 +43,8 @@ encrypted tunnel · one transport - the client dials the server (kharej → Iran) + Direct: Iran → Kharej · Reverse: Kharej → Iran - Transports: TCP · TCP Mux · TCP + Stealth · UDP · UDP + KCP · WS · WS Mux · WSS · WSS Mux + Transports: TCP · TCP Mux · Stealth · UDP · KCP · QUIC · WS · WS Mux · WSS · WSS Mux diff --git a/internal/client/client.go b/internal/client/client.go index e737645..0800b0f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -20,10 +20,11 @@ import ( // Client encapsulates the client configuration and state type Client struct { - config *config.ClientConfig - ctx context.Context - cancel context.CancelFunc - logger *logrus.Logger + config *config.ClientConfig + forward bool + ctx context.Context + cancel context.CancelFunc + logger *logrus.Logger } func NewClient(cfg *config.ClientConfig, parentCtx context.Context) *Client { @@ -41,6 +42,14 @@ func NewClient(cfg *config.ClientConfig, parentCtx context.Context) *Client { } } +// NewForwardEdge builds the dialling Iran half. Unlike a reverse client it +// also owns the public ingress listeners declared in ClientConfig.Ports. +func NewForwardEdge(cfg *config.ClientConfig, parentCtx context.Context) *Client { + c := NewClient(cfg, parentCtx) + c.forward = true + return c +} + // Run starts the client and begins dialing the tunnel server func (c *Client) Start() { // Profiling endpoint, off unless explicitly enabled in the config. Bound to @@ -105,7 +114,13 @@ func (c *Client) Start() { Outbound: outbound, // Stealth is the TCP transport with a Noise record layer over every // tunnel connection; everything else about it is identical. - Stealth: c.config.Transport == config.STEALTH, + Stealth: c.config.Transport == config.STEALTH, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + AcceptUDP: c.config.AcceptUDP, + ProxyProtocol: c.config.ProxyProtocol, + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } tcpClient := transport.NewTCPClient(c.ctx, tcpConfig, c.logger) go tcpClient.Start() @@ -132,6 +147,11 @@ func (c *Client) Start() { SO_RCVBUF: c.config.SO_RCVBUF, SO_SNDBUF: c.config.SO_SNDBUF, Outbound: outbound, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + ProxyProtocol: c.config.ProxyProtocol, + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } tcpMuxClient := transport.NewMuxClient(c.ctx, tcpMuxConfig, c.logger) go tcpMuxClient.Start() @@ -177,6 +197,11 @@ func (c *Client) Start() { SpoofSrcPool: c.config.SpoofSrcPool, SpoofPeerIP: c.config.SpoofPeerIP, SpoofInterface: c.config.SpoofInterface, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + ProxyProtocol: c.config.ProxyProtocol, + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } kcpClient := transport.NewKcpClient(c.ctx, kcpConfig, c.logger) go kcpClient.Start() @@ -196,6 +221,11 @@ func (c *Client) Start() { AggressivePool: c.config.AggressivePool, SO_RCVBUF: c.config.SO_RCVBUF, SO_SNDBUF: c.config.SO_SNDBUF, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + ProxyProtocol: c.config.ProxyProtocol, + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } quicClient := transport.NewQuicClient(c.ctx, quicConfig, c.logger) go quicClient.Start() @@ -218,6 +248,10 @@ func (c *Client) Start() { AggressivePool: c.config.AggressivePool, EdgeIP: c.config.EdgeIP, Outbound: outbound, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } WsClient := transport.NewWSClient(c.ctx, WsConfig, c.logger) go WsClient.Start() @@ -244,6 +278,11 @@ func (c *Client) Start() { AggressivePool: c.config.AggressivePool, EdgeIP: c.config.EdgeIP, Outbound: outbound, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + ProxyProtocol: c.config.ProxyProtocol, + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } wsMuxClient := transport.NewWSMuxClient(c.ctx, wsMuxConfig, c.logger) go wsMuxClient.Start() @@ -262,6 +301,10 @@ func (c *Client) Start() { AggressivePool: c.config.AggressivePool, SO_RCVBUF: c.config.SO_RCVBUF, SO_SNDBUF: c.config.SO_SNDBUF, + Forward: c.forward, + Ports: append([]string(nil), c.config.Ports...), + MaxConnections: c.config.MaxConnections, + BandwidthMbps: c.config.BandwidthMbps, } udpClient := transport.NewUDPClient(c.ctx, udpConfig, c.logger) go udpClient.Start() diff --git a/internal/client/transport/forward_common.go b/internal/client/transport/forward_common.go new file mode 100644 index 0000000..1c40b3b --- /dev/null +++ b/internal/client/transport/forward_common.go @@ -0,0 +1,87 @@ +package transport + +import ( + "context" + "net" + "sync/atomic" + + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/utils/handlers" + "github.com/backpack/backpack/internal/web" + "github.com/sirupsen/logrus" +) + +type forwardStreamOpener func(target string) (net.Conn, error) + +// startForwardIngress is the Iran-side TCP ingress shared by all stream +// carriers. Carrier implementations provide only how a new logical stream is +// opened; mapping, limits, PROXY protocol and relay semantics stay identical. +func startForwardIngress(ctx context.Context, specs []string, maxConnections, bandwidthMbps int, proxyProtocol bool, logger *logrus.Logger, usage *web.Usage, sniffer bool, active *int32, open forwardStreamOpener, restart func()) { + mappings, err := expandForwardTCPMappings(specs) + if err != nil { + logger.Errorf("invalid forward ingress mappings: %v", err) + restart() + return + } + bandwidth := newForwardBandwidth(bandwidthMbps) + for _, mapping := range mappings { + mapping := mapping + go func() { + listener, err := net.Listen("tcp", mapping.listen) + if err != nil { + logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err) + restart() + return + } + defer listener.Close() + go func() { <-ctx.Done(); _ = listener.Close() }() + logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target) + for { + local, err := listener.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err) + continue + } + if !acquireForwardConnection(active, maxConnections) { + logger.Warnf("forward connection limit reached, refusing %s", local.RemoteAddr()) + local.Close() + continue + } + go func(local net.Conn) { + defer atomic.AddInt32(active, -1) + local = bandwidth.wrap(local) + stream, err := open(mapping.target) + if err != nil { + logger.Warnf("could not open forward channel for %s: %v", mapping.target, err) + local.Close() + return + } + port := 0 + if addr, ok := local.LocalAddr().(*net.TCPAddr); ok { + port = addr.Port + } + handlers.TCPConnectionHandler(ctx, proxyProtocol, local, metrics.CountedConn(stream), logger, usage, port, sniffer) + }(local) + } + }() + } +} + +func acquireForwardConnection(active *int32, limit int) bool { + if limit <= 0 { + atomic.AddInt32(active, 1) + return true + } + for { + current := atomic.LoadInt32(active) + if int(current) >= limit { + return false + } + if atomic.CompareAndSwapInt32(active, current, current+1) { + return true + } + } +} diff --git a/internal/client/transport/forward_limits.go b/internal/client/transport/forward_limits.go new file mode 100644 index 0000000..37d07f4 --- /dev/null +++ b/internal/client/transport/forward_limits.go @@ -0,0 +1,58 @@ +package transport + +import ( + "context" + "net" + + "golang.org/x/time/rate" +) + +// forwardBandwidth is shared by every ingress flow of one transport instance, +// so bandwidth_mbps is a tunnel-wide cap rather than a per-user multiplier. +type forwardBandwidth struct{ bucket *rate.Limiter } + +func newForwardBandwidth(mbps int) *forwardBandwidth { + if mbps <= 0 { + return nil + } + bytesPerSecond := float64(mbps) * 1_000_000 / 8 + return &forwardBandwidth{bucket: rate.NewLimiter(rate.Limit(bytesPerSecond), int(bytesPerSecond))} +} + +func (l *forwardBandwidth) wrap(conn net.Conn) net.Conn { + if l == nil { + return conn + } + return &forwardLimitedConn{Conn: conn, limit: l} +} + +func (l *forwardBandwidth) wait(n int) { + if l == nil || n <= 0 { + return + } + burst := l.bucket.Burst() + for n > 0 { + chunk := n + if chunk > burst { + chunk = burst + } + _ = l.bucket.WaitN(context.Background(), chunk) + n -= chunk + } +} + +type forwardLimitedConn struct { + net.Conn + limit *forwardBandwidth +} + +func (c *forwardLimitedConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + c.limit.wait(n) + return n, err +} + +func (c *forwardLimitedConn) Write(p []byte) (int, error) { + c.limit.wait(len(p)) + return c.Conn.Write(p) +} diff --git a/internal/client/transport/forward_tcp.go b/internal/client/transport/forward_tcp.go new file mode 100644 index 0000000..6428b1c --- /dev/null +++ b/internal/client/transport/forward_tcp.go @@ -0,0 +1,108 @@ +package transport + +import ( + "net" + "sync/atomic" + + "github.com/backpack/backpack/internal/forwardmap" + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/utils/handlers" +) + +type forwardTCPMapping struct { + listen string + target string +} + +// expandForwardTCPMappings normalises the long-standing Backpack port syntax +// for the dialling Iran edge. Ranges preserve their offset when both sides are +// ranges; a single target intentionally fans the whole listen range into that +// one backend, matching the historical server-side behaviour. +func expandForwardTCPMappings(specs []string) ([]forwardTCPMapping, error) { + expanded, err := forwardmap.Expand(specs) + if err != nil { + return nil, err + } + out := make([]forwardTCPMapping, len(expanded)) + for i, mapping := range expanded { + out[i] = forwardTCPMapping{listen: mapping.Listen, target: mapping.Target} + } + return out, nil +} + +func (c *TcpTransport) startForwardTCPIngress() { + mappings, err := expandForwardTCPMappings(c.config.Ports) + if err != nil { + c.logger.Errorf("invalid forward ingress mappings: %v", err) + go c.Restart() + return + } + for _, mapping := range mappings { + mapping := mapping + go c.runForwardTCPListener(mapping) + } +} + +func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) { + listener, err := net.Listen("tcp", mapping.listen) + if err != nil { + c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err) + go c.Restart() + return + } + defer listener.Close() + go func() { + <-c.state.Ctx().Done() + _ = listener.Close() + }() + c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target) + + for { + local, err := listener.Accept() + if err != nil { + if c.state.Ctx().Err() != nil { + return + } + c.logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err) + continue + } + if !c.acquireForwardSlot() { + c.logger.Warnf("forward connection limit reached, refusing %s", local.RemoteAddr()) + local.Close() + continue + } + go c.handleForwardTCPIngress(local, mapping.target) + } +} + +func (c *TcpTransport) acquireForwardSlot() bool { + if c.config.MaxConnections <= 0 { + atomic.AddInt32(&c.loadConnections, 1) + return true + } + for { + current := atomic.LoadInt32(&c.loadConnections) + if int(current) >= c.config.MaxConnections { + return false + } + if atomic.CompareAndSwapInt32(&c.loadConnections, current, current+1) { + return true + } + } +} + +func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) { + defer atomic.AddInt32(&c.loadConnections, -1) + local = c.forwardBandwidth.wrap(local) + tunnel, err := c.openForwardTCP(target) + if err != nil { + c.logger.Warnf("could not open forward channel for %s: %v", target, err) + local.Close() + return + } + port := 0 + if tcpAddr, ok := local.LocalAddr().(*net.TCPAddr); ok { + port = tcpAddr.Port + } + handlers.TCPConnectionHandler(c.state.Ctx(), c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, c.state.Usage(), port, c.config.Sniffer) +} diff --git a/internal/client/transport/forward_udp.go b/internal/client/transport/forward_udp.go new file mode 100644 index 0000000..d52c306 --- /dev/null +++ b/internal/client/transport/forward_udp.go @@ -0,0 +1,153 @@ +package transport + +import ( + "net" + "sync" + "sync/atomic" + "time" + + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/utils" +) + +type clientForwardUDPFlow struct { + client *net.UDPAddr + payload chan []byte +} + +func (c *UdpTransport) startForwardUDPIngress() { + mappings, err := expandForwardTCPMappings(c.config.Ports) + if err != nil { + c.logger.Errorf("invalid forward UDP mappings: %v", err) + go c.Restart() + return + } + for _, mapping := range mappings { + mapping := mapping + go c.runForwardUDPListener(mapping) + } +} + +func (c *UdpTransport) runForwardUDPListener(mapping forwardTCPMapping) { + addr, err := net.ResolveUDPAddr("udp", mapping.listen) + if err != nil { + c.logger.Errorf("invalid UDP ingress %s: %v", mapping.listen, err) + return + } + listener, err := net.ListenUDP("udp", addr) + if err != nil { + c.logger.Errorf("failed to listen on UDP ingress %s: %v", mapping.listen, err) + go c.Restart() + return + } + c.applyBuffers(listener) + defer listener.Close() + go func() { <-c.state.Ctx().Done(); listener.Close() }() + c.logger.Infof("forward UDP ingress listening on %s -> Kharej %s", listener.LocalAddr(), mapping.target) + + flows := map[string]*clientForwardUDPFlow{} + var mu sync.Mutex + buf := make([]byte, 64*1024) + for { + n, clientAddr, err := listener.ReadFromUDP(buf) + if err != nil { + if c.state.Ctx().Err() != nil { + return + } + continue + } + key := clientAddr.String() + mu.Lock() + flow := flows[key] + if flow == nil { + if !acquireForwardConnection(&c.forwardActive, c.config.MaxConnections) { + mu.Unlock() + continue + } + flow = &clientForwardUDPFlow{client: clientAddr, payload: make(chan []byte, 1024)} + flows[key] = flow + go c.runForwardUDPFlow(listener, mapping.target, key, flow, &mu, flows) + } + select { + case flow.payload <- append([]byte(nil), buf[:n]...): + default: + c.logger.Warnf("forward UDP flow %s queue is full; dropping packet", key) + } + mu.Unlock() + } +} + +func (c *UdpTransport) runForwardUDPFlow(listener *net.UDPConn, target, key string, flow *clientForwardUDPFlow, mu *sync.Mutex, flows map[string]*clientForwardUDPFlow) { + defer func() { + atomic.AddInt32(&c.forwardActive, -1) + mu.Lock() + if flows[key] == flow { + delete(flows, key) + close(flow.payload) + } + mu.Unlock() + }() + remote, err := net.ResolveUDPAddr("udp", c.config.Endpoints.Next()) + if err != nil { + return + } + tunnel, err := net.DialUDP("udp", nil, remote) + if err != nil { + return + } + c.applyBuffers(tunnel) + defer tunnel.Close() + announcement, err := utils.EncodeForwardUDP(c.config.Token, target) + if err != nil { + return + } + if _, err := tunnel.Write(announcement); err != nil { + return + } + _ = tunnel.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + ack := []byte{0} + if _, err := tunnel.Read(ack); err != nil || ack[0] != utils.SG_ForwardOK { + return + } + _ = tunnel.SetReadDeadline(time.Time{}) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-c.state.Ctx().Done(): + return + case payload, ok := <-flow.payload: + if !ok { + return + } + c.forwardBandwidth.wait(len(payload)) + _ = tunnel.SetWriteDeadline(time.Now().Add(60 * time.Second)) + if _, err := tunnel.Write(payload); err != nil { + return + } + metrics.AddBytes(0, uint64(len(payload))) + } + } + }() + + buf := make([]byte, 64*1024) + for { + _ = tunnel.SetReadDeadline(time.Now().Add(60 * time.Second)) + n, err := tunnel.Read(buf) + if err != nil { + return + } + c.forwardBandwidth.wait(n) + if _, err := listener.WriteToUDP(buf[:n], flow.client); err != nil { + return + } + metrics.AddBytes(uint64(n), 0) + select { + case <-done: + return + default: + } + } +} diff --git a/internal/client/transport/kcp.go b/internal/client/transport/kcp.go index 2c58647..8fdd6d7 100644 --- a/internal/client/transport/kcp.go +++ b/internal/client/transport/kcp.go @@ -34,6 +34,8 @@ type KcpTransport struct { poolConnections int32 loadConnections int32 controlFlow chan struct{} + forwardSessions chan *smux.Session + forwardActive int32 } type KcpConfig struct { @@ -83,6 +85,11 @@ type KcpConfig struct { SpoofSrcPool []string SpoofPeerIP string SpoofInterface string + Forward bool + Ports []string + ProxyProtocol bool + MaxConnections int + BandwidthMbps int } // transportLabel is what the panel and logs call this transport — XDI over ICMP @@ -97,6 +104,10 @@ func (c *KcpTransport) transportLabel() string { return "KCP" } +func (c *KcpTransport) rawForward() bool { + return c.config.Forward && (c.config.UseICMP || c.config.UseSpoof) +} + func (c *KcpConfig) settings() network.KCPSettings { s := network.KCPSettings{ MTU: c.MTU, @@ -147,6 +158,7 @@ func NewKcpClient(parentCtx context.Context, config *KcpConfig, logger *logrus.L poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), + forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)), } // Seed the first generation through the same path a restart uses, so // there is only one way this state is ever published. @@ -180,6 +192,7 @@ func (c *KcpTransport) Restart() { if c.state.Cancel() != nil { c.state.Cancel()() } + metrics.ClearPeer() c.state.CloseConn() @@ -211,6 +224,16 @@ func (c *KcpTransport) Restart() { // connection that is gone until the new run's first tick replaced them. metrics.ClearPool() drain(c.controlFlow) + for { + select { + case session := <-c.forwardSessions: + _ = session.Close() + default: + goto sessionsDrained + } + } +sessionsDrained: + atomic.StoreInt32(&c.forwardActive, 0) c.logger.SetLevel(level) @@ -301,6 +324,19 @@ func (c *KcpTransport) channelDialer() { bo.Wait(c.state.Ctx()) continue } + if c.rawForward() { + // Raw ICMP/spoof carriers cannot safely open several PacketConns + // with the same tunnel identity: the kernel may deliver a reply to + // the wrong socket. Reuse the authenticated control KCP session as + // the one long-lived SMUX carrier for every Direct stream. + c.state.SetConn(tunnelConn) + metrics.ReportPeer(tunnelConn.RemoteAddr().String()) + c.config.TunnelStatus = "Connected (" + c.transportLabel() + ")" + atomic.AddInt32(&c.poolConnections, 1) + go c.handleSession(tunnelConn) + go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardKCPStream, func() { go c.Restart() }) + return + } c.state.SetConn(tunnelConn) c.logger.Info("control channel established successfully") @@ -309,6 +345,9 @@ func (c *KcpTransport) channelDialer() { go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardKCPStream, func() { go c.Restart() }) + } return } @@ -487,7 +526,11 @@ func (c *KcpTransport) tunnelDialer() { // materialises a session once it receives a packet from this socket. So // every pool connection announces itself with the token, which both wakes // the listener and authenticates the session before any data flows. - if err := utils.SendBinaryTransportString(tunnelConn, c.config.Token, utils.SG_TCP); err != nil { + signal := utils.SG_TCP + if c.config.Forward { + signal = utils.SG_ForwardTCP + } + if err := utils.SendBinaryTransportString(tunnelConn, c.config.Token, signal); err != nil { c.logger.Errorf("failed to announce tunnel connection: %v", err) tunnelConn.Close() return @@ -501,6 +544,12 @@ func (c *KcpTransport) tunnelDialer() { func (c *KcpTransport) handleSession(tunnelConn net.Conn) { defer func() { atomic.AddInt32(&c.poolConnections, -1) + if c.rawForward() { + metrics.ClearPeer() + if c.state.Ctx().Err() == nil { + go c.Restart() + } + } }() // SMUX server @@ -510,6 +559,17 @@ func (c *KcpTransport) handleSession(tunnelConn net.Conn) { tunnelConn.Close() return } + if c.config.Forward { + select { + case c.forwardSessions <- session: + case <-c.state.Ctx().Done(): + session.Close() + return + } + <-c.state.Ctx().Done() + session.Close() + return + } for { select { @@ -535,6 +595,49 @@ func (c *KcpTransport) handleSession(tunnelConn net.Conn) { } } +func (c *KcpTransport) openForwardKCPStream(target string) (net.Conn, error) { + timer := time.NewTimer(c.config.DialTimeOut) + defer timer.Stop() + for { + select { + case <-c.state.Ctx().Done(): + return nil, c.state.Ctx().Err() + case <-timer.C: + return nil, fmt.Errorf("no forward KCP session became ready") + case session := <-c.forwardSessions: + stream, err := session.OpenStream() + if err != nil { + session.Close() + if c.rawForward() { + go c.Restart() + } else { + go c.tunnelDialer() + } + continue + } + select { + case c.forwardSessions <- session: + default: + } + if err := utils.SendBinaryString(stream, target); err != nil { + stream.Close() + return nil, err + } + _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + status, err := utils.ReceiveBinaryByte(stream) + _ = stream.SetReadDeadline(time.Time{}) + if err != nil || status != utils.SG_ForwardOK { + stream.Close() + if err != nil { + return nil, err + } + return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target) + } + return stream, nil + } + } +} + func (c *KcpTransport) localDialer(stream *smux.Stream, remoteAddr string) { port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr) if err != nil { diff --git a/internal/client/transport/quic.go b/internal/client/transport/quic.go index 93b4e2d..5490a49 100644 --- a/internal/client/transport/quic.go +++ b/internal/client/transport/quic.go @@ -38,8 +38,9 @@ type QuicTransport struct { // connMu guards quicConn, the connection this run opens its streams on. It is // replaced on every restart, so a data stream is always opened on the current // connection rather than a torn-down one. - connMu sync.Mutex - quicConn *quic.Conn + connMu sync.Mutex + quicConn *quic.Conn + forwardActive int32 } type QuicConfig struct { @@ -59,6 +60,11 @@ type QuicConfig struct { AggressivePool bool SO_RCVBUF int SO_SNDBUF int + Forward bool + Ports []string + ProxyProtocol bool + MaxConnections int + BandwidthMbps int } func (c *QuicConfig) settings() network.QUICSettings { @@ -158,6 +164,7 @@ func (c *QuicTransport) Restart() { c.config.TunnelStatus = "" atomic.StoreInt32(&c.poolConnections, 0) atomic.StoreInt32(&c.loadConnections, 0) + atomic.StoreInt32(&c.forwardActive, 0) metrics.ClearPool() drain(c.controlFlow) @@ -247,14 +254,47 @@ func (c *QuicTransport) channelDialer() { c.config.TunnelStatus = "Connected (QUIC)" - go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardQUICStream, func() { go c.Restart() }) + } else { + go c.poolMaintainer() + } return } } } +func (c *QuicTransport) openForwardQUICStream(target string) (net.Conn, error) { + qc := c.getQUICConn() + if qc == nil { + return nil, fmt.Errorf("forward QUIC connection is not ready") + } + stream, err := qc.OpenStreamSync(c.state.Ctx()) + if err != nil { + return nil, err + } + data := network.NewQUICStreamConn(stream, qc) + fail := func(err error) (net.Conn, error) { data.Close(); return nil, err } + if err := utils.SendBinaryTransportString(data, c.config.Token, utils.SG_ForwardTCP); err != nil { + return fail(err) + } + if err := utils.SendBinaryString(data, target); err != nil { + return fail(err) + } + _ = data.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + status, err := utils.ReceiveBinaryByte(data) + _ = data.SetReadDeadline(time.Time{}) + if err != nil { + return fail(err) + } + if status != utils.SG_ForwardOK { + return fail(fmt.Errorf("Kharej backend %q is unavailable or invalid", target)) + } + return data, nil +} + func (c *QuicTransport) poolMaintainer() { for i := 0; i < c.config.ConnPoolSize; i++ { // initial pool filling go c.tunnelDialer() diff --git a/internal/client/transport/tcp.go b/internal/client/transport/tcp.go index 2371060..5c98faf 100644 --- a/internal/client/transport/tcp.go +++ b/internal/client/transport/tcp.go @@ -35,7 +35,8 @@ type TcpTransport struct { // handshake, so later attempts skip straight to the old one instead of // spending a connection discovering it again. It is not reset on restart: // an upgraded server means an upgraded binary, which means a new process. - legacyServer atomic.Bool + legacyServer atomic.Bool + forwardBandwidth *forwardBandwidth } type TcpConfig struct { RemoteAddr string @@ -64,6 +65,15 @@ type TcpConfig struct { // Stealth wraps every tunnel-carrying connection in the Noise record layer, // so the stream has no fingerprint for deep packet inspection to match. Stealth bool + // Forward turns this dialler into the Iran edge: it keeps the ordinary + // authenticated control channel, owns the public ingress listeners, and + // opens one authenticated data connection per accepted user connection. + Forward bool + Ports []string + AcceptUDP bool + ProxyProtocol bool + MaxConnections int + BandwidthMbps int } // wrapStealth upgrades a freshly dialled tunnel connection to the Noise record @@ -83,12 +93,13 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L // Initialize the TcpTransport struct client := &TcpTransport{ - config: config, - parentctx: parentCtx, - logger: logger, - poolConnections: 0, - loadConnections: 0, - controlFlow: make(chan struct{}, 100), + config: config, + parentctx: parentCtx, + logger: logger, + poolConnections: 0, + loadConnections: 0, + controlFlow: make(chan struct{}, 100), + forwardBandwidth: newForwardBandwidth(config.BandwidthMbps), } // Seed the first generation through the same path a restart uses, so @@ -248,8 +259,18 @@ func (c *TcpTransport) channelDialer() { c.logger.Info("control channel established successfully") c.config.TunnelStatus = "Connected (TCP)" - go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + if nonce == "" { + c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old") + tunnelTCPConn.Close() + bo.Wait(c.state.Ctx()) + continue + } + go c.startForwardTCPIngress() + return + } + go c.poolMaintainer() return @@ -263,6 +284,47 @@ func (c *TcpTransport) channelDialer() { } } +// openForwardTCP creates the data connection proactively from the Iran edge. +// It returns only after the Kharej origin has successfully dialled the target, +// so an unavailable backend becomes a prompt close instead of a black hole. +func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) { + nonce := c.poolNonce.Get() + if nonce == "" || c.state.Conn() == nil { + return nil, fmt.Errorf("forward control channel is not ready") + } + raw, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS) + if err != nil { + return nil, fmt.Errorf("dial forward origin: %w", err) + } + conn, err := c.wrapStealth(raw) + if err != nil { + raw.Close() + return nil, fmt.Errorf("forward stealth handshake: %w", err) + } + fail := func(e error) (net.Conn, error) { + conn.Close() + return nil, e + } + if err := utils.SendBinaryTransportString(conn, nonce, utils.SG_ForwardTCP); err != nil { + return fail(fmt.Errorf("announce forward data connection: %w", err)) + } + if err := utils.SendBinaryString(conn, target); err != nil { + return fail(fmt.Errorf("send forward target: %w", err)) + } + if err := conn.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)); err != nil { + return fail(err) + } + status, err := utils.ReceiveBinaryByte(conn) + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + return fail(fmt.Errorf("receive forward-open result: %w", err)) + } + if status != utils.SG_ForwardOK { + return fail(fmt.Errorf("Kharej backend %q is unavailable or invalid", target)) + } + return conn, nil +} + func (c *TcpTransport) poolMaintainer() { for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling go c.tunnelDialer() diff --git a/internal/client/transport/tcpmux.go b/internal/client/transport/tcpmux.go index 56ae5e4..3101e79 100644 --- a/internal/client/transport/tcpmux.go +++ b/internal/client/transport/tcpmux.go @@ -41,7 +41,9 @@ type TcpMuxTransport struct { // handshake, so later attempts skip straight to the old one instead of // spending a connection discovering it again. It is not reset on restart: // an upgraded server means an upgraded binary, which means a new process. - legacyServer atomic.Bool + legacyServer atomic.Bool + forwardSessions chan *smux.Session + forwardActive int32 } type TcpMuxConfig struct { @@ -71,7 +73,12 @@ type TcpMuxConfig struct { // this machine: through a proxy, from a chosen source address or // interface, under a routing mark. Nil dials directly. None of it is ever // applied to the dial to the local backend — see network/outbound.go. - Outbound *network.Outbound + Outbound *network.Outbound + Forward bool + Ports []string + ProxyProtocol bool + MaxConnections int + BandwidthMbps int } func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logrus.Logger) *TcpMuxTransport { @@ -93,6 +100,7 @@ func NewMuxClient(parentCtx context.Context, config *TcpMuxConfig, logger *logru poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), + forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)), } // Seed the first generation through the same path a restart uses, so @@ -163,6 +171,16 @@ func (c *TcpMuxTransport) Restart() { // connection that is gone until the new run's first tick replaced them. metrics.ClearPool() drain(c.controlFlow) + for { + select { + case session := <-c.forwardSessions: + _ = session.Close() + default: + goto sessionsDrained + } + } +sessionsDrained: + atomic.StoreInt32(&c.forwardActive, 0) // set the log level again c.logger.SetLevel(level) @@ -244,9 +262,18 @@ func (c *TcpMuxTransport) channelDialer() { c.logger.Infof("control channel established successfully (mux version %d)", c.muxVersion.Load()) c.config.TunnelStatus = "Connected (TCPMux)" + if c.config.Forward && nonce == "" { + c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old") + tunnelConn.Close() + bo.Wait(c.state.Ctx()) + continue + } go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardMuxStream, func() { go c.Restart() }) + } return } else { @@ -415,7 +442,11 @@ func (c *TcpMuxTransport) tunnelDialer() { // Say what this connection is, so the server admits it on the nonce rather // than on the address it happened to dial out from. - if err := announcePoolConn(tunnelConn, c.poolNonce.Get()); err != nil { + signal := utils.SG_Pool + if c.config.Forward { + signal = utils.SG_ForwardTCP + } + if err := utils.SendBinaryTransportString(tunnelConn, c.poolNonce.Get(), signal); err != nil { c.logger.Debugf("tunnel dialer: failed to announce the pool connection: %v", err) tunnelConn.Close() return @@ -438,6 +469,17 @@ func (c *TcpMuxTransport) handleSession(tunnelConn net.Conn) { c.logger.Errorf("failed to create mux session: %v", err) return } + if c.config.Forward { + select { + case c.forwardSessions <- session: + case <-c.state.Ctx().Done(): + session.Close() + return + } + <-c.state.Ctx().Done() + session.Close() + return + } for { select { @@ -463,6 +505,45 @@ func (c *TcpMuxTransport) handleSession(tunnelConn net.Conn) { } } +func (c *TcpMuxTransport) openForwardMuxStream(target string) (net.Conn, error) { + deadline := time.NewTimer(c.config.DialTimeOut) + defer deadline.Stop() + for { + select { + case <-c.state.Ctx().Done(): + return nil, c.state.Ctx().Err() + case <-deadline.C: + return nil, fmt.Errorf("no forward mux session became ready") + case session := <-c.forwardSessions: + stream, err := session.OpenStream() + if err != nil { + session.Close() + go c.tunnelDialer() + continue + } + select { + case c.forwardSessions <- session: + default: + } + if err := utils.SendBinaryString(stream, target); err != nil { + stream.Close() + return nil, err + } + _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + status, err := utils.ReceiveBinaryByte(stream) + _ = stream.SetReadDeadline(time.Time{}) + if err != nil || status != utils.SG_ForwardOK { + stream.Close() + if err != nil { + return nil, err + } + return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target) + } + return stream, nil + } + } +} + func (c *TcpMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) { // Extract the port from the received address port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr) diff --git a/internal/client/transport/udp.go b/internal/client/transport/udp.go index 42cf282..4812ba0 100644 --- a/internal/client/transport/udp.go +++ b/internal/client/transport/udp.go @@ -15,14 +15,16 @@ import ( ) type UdpTransport struct { - config *UdpConfig - parentctx context.Context - state clientState - logger *logrus.Logger - restartMutex sync.Mutex - poolConnections int32 - loadConnections int32 - controlFlow chan struct{} + config *UdpConfig + parentctx context.Context + state clientState + logger *logrus.Logger + restartMutex sync.Mutex + poolConnections int32 + loadConnections int32 + controlFlow chan struct{} + forwardActive int32 + forwardBandwidth *forwardBandwidth } type UdpConfig struct { RemoteAddr string @@ -42,8 +44,12 @@ type UdpConfig struct { // to the server and to the local backend. The kernel default is small enough // that a datagram flood overruns it and drops packets before they are read; // the preset's several MB is what carries a speed test without stalling. - SO_RCVBUF int - SO_SNDBUF int + SO_RCVBUF int + SO_SNDBUF int + Forward bool + Ports []string + MaxConnections int + BandwidthMbps int } func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.Logger) *UdpTransport { @@ -52,12 +58,13 @@ func NewUDPClient(parentCtx context.Context, config *UdpConfig, logger *logrus.L // Initialize the TcpTransport struct client := &UdpTransport{ - config: config, - parentctx: parentCtx, - logger: logger, - poolConnections: 0, - loadConnections: 0, - controlFlow: make(chan struct{}, 100), + config: config, + parentctx: parentCtx, + logger: logger, + poolConnections: 0, + loadConnections: 0, + controlFlow: make(chan struct{}, 100), + forwardBandwidth: newForwardBandwidth(config.BandwidthMbps), } // Seed the first generation through the same path a restart uses, so @@ -191,8 +198,12 @@ func (c *UdpTransport) channelDialer() { c.config.TunnelStatus = "Connected (UDP)" - go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go c.startForwardUDPIngress() + } else { + go c.poolMaintainer() + } return diff --git a/internal/client/transport/ws.go b/internal/client/transport/ws.go index 0699b1c..f69889c 100644 --- a/internal/client/transport/ws.go +++ b/internal/client/transport/ws.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "net" "strings" "sync" "sync/atomic" @@ -20,14 +21,16 @@ import ( ) type WsTransport struct { - config *WsConfig - parentctx context.Context - state clientState - logger *logrus.Logger - restartMutex sync.Mutex - poolConnections int32 - loadConnections int32 - controlFlow chan struct{} + config *WsConfig + parentctx context.Context + state clientState + logger *logrus.Logger + restartMutex sync.Mutex + poolConnections int32 + loadConnections int32 + controlFlow chan struct{} + forwardActive int32 + forwardBandwidth *forwardBandwidth } type WsConfig struct { RemoteAddr string @@ -52,7 +55,11 @@ type WsConfig struct { // this machine: through a proxy, from a chosen source address or // interface, under a routing mark. Nil dials directly. None of it is ever // applied to the dial to the local backend — see network/outbound.go. - Outbound *network.Outbound + Outbound *network.Outbound + Forward bool + Ports []string + MaxConnections int + BandwidthMbps int } func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Logger) *WsTransport { @@ -61,12 +68,13 @@ func NewWSClient(parentCtx context.Context, config *WsConfig, logger *logrus.Log // Initialize the TcpTransport struct client := &WsTransport{ - config: config, - parentctx: parentCtx, - logger: logger, - poolConnections: 0, - loadConnections: 0, - controlFlow: make(chan struct{}, 100), + config: config, + parentctx: parentCtx, + logger: logger, + poolConnections: 0, + loadConnections: 0, + controlFlow: make(chan struct{}, 100), + forwardBandwidth: newForwardBandwidth(config.BandwidthMbps), } // Seed the first generation through the same path a restart uses, so @@ -129,6 +137,7 @@ func (c *WsTransport) Restart() { c.config.TunnelStatus = "" atomic.StoreInt32(&c.poolConnections, 0) atomic.StoreInt32(&c.loadConnections, 0) + atomic.StoreInt32(&c.forwardActive, 0) drain(c.controlFlow) // set the log level again @@ -166,14 +175,82 @@ func (c *WsTransport) channelDialer() { c.config.TunnelStatus = fmt.Sprintf("Connected (%s)", c.config.Mode) - go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go c.startForwardWSIngress() + } else { + go c.poolMaintainer() + } return } } } +func (c *WsTransport) startForwardWSIngress() { + mappings, err := expandForwardTCPMappings(c.config.Ports) + if err != nil { + c.logger.Errorf("invalid forward ingress mappings: %v", err) + go c.Restart() + return + } + for _, mapping := range mappings { + mapping := mapping + go func() { + listener, err := net.Listen("tcp", mapping.listen) + if err != nil { + c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err) + go c.Restart() + return + } + defer listener.Close() + go func() { <-c.state.Ctx().Done(); _ = listener.Close() }() + c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target) + for { + local, err := listener.Accept() + if err != nil { + if c.state.Ctx().Err() != nil { + return + } + continue + } + if !acquireForwardConnection(&c.forwardActive, c.config.MaxConnections) { + local.Close() + continue + } + go c.handleForwardWSIngress(local, mapping.target) + } + }() + } +} + +func (c *WsTransport) handleForwardWSIngress(local net.Conn, target string) { + defer atomic.AddInt32(&c.forwardActive, -1) + local = c.forwardBandwidth.wrap(local) + wsConn, err := network.WebSocketDialer(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.EdgeIP, "/tunnel", c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, c.config.Token, c.config.Mode, c.config.SimpleAuth, 3, 1024*1024, 1024*1024) + if err != nil { + local.Close() + return + } + fail := func() { wsConn.Close(); local.Close() } + if err := wsConn.WriteMessage(websocket.TextMessage, []byte(target)); err != nil { + fail() + return + } + _ = wsConn.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + _, ack, err := wsConn.ReadMessage() + _ = wsConn.SetReadDeadline(time.Time{}) + if err != nil || len(ack) != 1 || ack[0] != utils.SG_ForwardOK { + fail() + return + } + port := 0 + if addr, ok := local.LocalAddr().(*net.TCPAddr); ok { + port = addr.Port + } + handlers.WSConnectionHandler(c.state.Ctx(), wsConn, local, c.logger, c.state.Usage(), port, c.config.Sniffer) +} + func (c *WsTransport) poolMaintainer() { for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling go c.tunnelDialer() diff --git a/internal/client/transport/wsmux.go b/internal/client/transport/wsmux.go index 0d970e3..2a4eb2a 100644 --- a/internal/client/transport/wsmux.go +++ b/internal/client/transport/wsmux.go @@ -3,6 +3,7 @@ package transport import ( "context" "fmt" + "net" "strings" "sync" "sync/atomic" @@ -30,6 +31,8 @@ type WsMuxTransport struct { poolConnections int32 loadConnections int32 controlFlow chan struct{} + forwardSessions chan *smux.Session + forwardActive int32 } type WsMuxConfig struct { RemoteAddr string @@ -58,7 +61,12 @@ type WsMuxConfig struct { // this machine: through a proxy, from a chosen source address or // interface, under a routing mark. Nil dials directly. None of it is ever // applied to the dial to the local backend — see network/outbound.go. - Outbound *network.Outbound + Outbound *network.Outbound + Forward bool + Ports []string + ProxyProtocol bool + MaxConnections int + BandwidthMbps int } func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logrus.Logger) *WsMuxTransport { @@ -81,6 +89,7 @@ func NewWSMuxClient(parentCtx context.Context, config *WsMuxConfig, logger *logr poolConnections: 0, loadConnections: 0, controlFlow: make(chan struct{}, 100), + forwardSessions: make(chan *smux.Session, max(1, config.ConnPoolSize)), } // Seed the first generation through the same path a restart uses, so @@ -147,6 +156,16 @@ func (c *WsMuxTransport) Restart() { // connection that is gone until the new run's first tick replaced them. metrics.ClearPool() drain(c.controlFlow) + for { + select { + case session := <-c.forwardSessions: + _ = session.Close() + default: + goto sessionsDrained + } + } +sessionsDrained: + atomic.StoreInt32(&c.forwardActive, 0) // set the log level again c.logger.SetLevel(level) @@ -186,6 +205,9 @@ func (c *WsMuxTransport) channelDialer() { go c.poolMaintainer() go c.channelHandler() + if c.config.Forward { + go startForwardIngress(c.state.Ctx(), c.config.Ports, c.config.MaxConnections, c.config.BandwidthMbps, c.config.ProxyProtocol, c.logger, c.state.Usage(), c.config.Sniffer, &c.forwardActive, c.openForwardWSMuxStream, func() { go c.Restart() }) + } return } @@ -368,6 +390,17 @@ func (c *WsMuxTransport) handleSession(tunnelConn *websocket.Conn) { c.logger.Errorf("failed to create mux session: %v", err) return } + if c.config.Forward { + select { + case c.forwardSessions <- session: + case <-c.state.Ctx().Done(): + session.Close() + return + } + <-c.state.Ctx().Done() + session.Close() + return + } for { select { @@ -393,6 +426,45 @@ func (c *WsMuxTransport) handleSession(tunnelConn *websocket.Conn) { } } +func (c *WsMuxTransport) openForwardWSMuxStream(target string) (net.Conn, error) { + timer := time.NewTimer(c.config.DialTimeOut) + defer timer.Stop() + for { + select { + case <-c.state.Ctx().Done(): + return nil, c.state.Ctx().Err() + case <-timer.C: + return nil, fmt.Errorf("no forward websocket mux session became ready") + case session := <-c.forwardSessions: + stream, err := session.OpenStream() + if err != nil { + session.Close() + go c.tunnelDialer() + continue + } + select { + case c.forwardSessions <- session: + default: + } + if err := utils.SendBinaryString(stream, target); err != nil { + stream.Close() + return nil, err + } + _ = stream.SetReadDeadline(time.Now().Add(c.config.DialTimeOut)) + status, err := utils.ReceiveBinaryByte(stream) + _ = stream.SetReadDeadline(time.Time{}) + if err != nil || status != utils.SG_ForwardOK { + stream.Close() + if err != nil { + return nil, err + } + return nil, fmt.Errorf("Kharej backend %q is unavailable or invalid", target) + } + return stream, nil + } + } +} + func (c *WsMuxTransport) localDialer(stream *smux.Stream, remoteAddr string) { // Extract the port from the received address port, resolvedAddr, err := network.ResolveRemoteAddr(remoteAddr) diff --git a/internal/e2e/forward_tcp_test.go b/internal/e2e/forward_tcp_test.go new file mode 100644 index 0000000..65632ae --- /dev/null +++ b/internal/e2e/forward_tcp_test.go @@ -0,0 +1,90 @@ +package e2e + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/backpack/backpack/internal/client" + "github.com/backpack/backpack/internal/server" +) + +// TestForwardTCP proves the corrected direct semantics end to end: the Iran +// process is the dialler and owns the user-facing port, while the listening +// Kharej process owns and dials the backend. +func TestForwardStreamTransports(t *testing.T) { + for _, transport := range []string{"tcp", "stealth", "tcpmux", "kcp", "quic", "ws", "wss", "wsmux", "wssmux"} { + t.Run(transport, func(t *testing.T) { testForwardStreamTransport(t, transport) }) + } +} + +func testForwardStreamTransport(t *testing.T, transport string) { + backend := startEchoBackend(t) + tunnelPort := freePort(t) + entryPort := freePort(t) + token := "forward-e2e-token-0123456789abcdef" + + origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token) + if transport == "wss" || transport == "wssmux" { + origin.TLSCertFile, origin.TLSKeyFile = testCert(t) + } + origin.Ports = nil // Kharej must not expose the user's ingress port. + edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)} + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + srv := server.NewForwardOrigin(origin, ctx) + wg.Add(1) + go func() { defer wg.Done(); srv.Start() }() + time.Sleep(300 * time.Millisecond) + cli := client.NewForwardEdge(edge, ctx) + wg.Add(1) + go func() { defer wg.Done(); cli.Start() }() + + tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg} + t.Cleanup(tun.Stop) + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatalf("forward %s tunnel never carried traffic: %v", transport, err) + } + if err := tun.roundTrip(randomPayload(t, 512*1024)); err != nil { + t.Fatalf("forward %s payload failed: %v", transport, err) + } +} + +func TestForwardUDP(t *testing.T) { + backendAddr := startUDPEchoBackend(t) + tunnelPort, entryPort := freePort(t), freePort(t) + token := "forward-udp-token-0123456789abcdef" + origin := baseServerConfig("udp", tunnelPort, 1, backendAddr, token) + origin.Ports = nil + edge := baseClientConfig("udp", fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backendAddr)} + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + t.Cleanup(func() { cancel(); wg.Wait() }) + srv := server.NewForwardOrigin(origin, ctx) + wg.Add(1) + go func() { defer wg.Done(); srv.Start() }() + time.Sleep(300 * time.Millisecond) + cli := client.NewForwardEdge(edge, ctx) + wg.Add(1) + go func() { defer wg.Done(); cli.Start() }() + + entry := fmt.Sprintf("127.0.0.1:%d", entryPort) + payload := []byte("forward-udp-datagram") + deadline := time.Now().Add(tunnelReadyTimeout) + var lastErr error + for time.Now().Before(deadline) { + if err := udpRoundTrip(entry, payload); err == nil { + return + } else { + lastErr = err + } + time.Sleep(250 * time.Millisecond) + } + t.Fatalf("forward UDP tunnel never carried a datagram: %v", lastErr) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 0677814..c83fdde 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -19,6 +19,9 @@ type Metadata struct { type Request struct { ConfigPath string Config *config.Config + // Replacing means validation is evaluating a candidate for an instance + // whose current process may still own its existing listen sockets. + Replacing bool } type Health struct { diff --git a/internal/engine/forward.go b/internal/engine/forward.go new file mode 100644 index 0000000..588c871 --- /dev/null +++ b/internal/engine/forward.go @@ -0,0 +1,122 @@ +package engine + +import ( + "context" + "fmt" + "net" + "path/filepath" + "strings" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/client" + "github.com/backpack/backpack/internal/forwardmap" + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/server" +) + +// forwardProvider is Backpack's application-level direct mode: the Iran edge +// dials Kharej using a normal Backpack transport, while user-facing ports stay +// on Iran and backends stay on Kharej. It is not the iptables DNAT provider. +type forwardProvider struct{} + +func init() { Register(config.EngineForward, forwardProvider{}) } + +func (forwardProvider) Metadata() Metadata { return Metadata{Name: "forward", Mode: "direct"} } + +func (forwardProvider) Validate(_ context.Context, r Request) error { + if r.Config == nil { + return fmt.Errorf("nil forward tunnel configuration") + } + if err := r.Config.ValidateStructure(); err != nil { + return err + } + var transport config.TransportType + if r.Config.HasClient() { + transport = r.Config.Client.Transport + mappings, err := forwardmap.Expand(r.Config.Client.Ports) + if err != nil { + return fmt.Errorf("invalid forward ingress mappings: %w", err) + } + if !r.Replacing { + network := "tcp" + if transport == config.UDP { + network = "udp" + } + for _, mapping := range mappings { + if err := probeForwardListen(network, mapping.Listen); err != nil { + return fmt.Errorf("forward ingress %s/%s is unavailable: %w", network, mapping.Listen, err) + } + } + } + } else { + transport = r.Config.Server.Transport + if !r.Replacing && transport != config.XDI && transport != config.SPOOF { + network := "tcp" + if transport == config.UDP || transport == config.KCP || transport == config.QUIC { + network = "udp" + } + if err := probeForwardListen(network, r.Config.Server.BindAddr); err != nil { + return fmt.Errorf("forward tunnel listener %s/%s is unavailable: %w", network, r.Config.Server.BindAddr, err) + } + } + } + // Keep incomplete carriers impossible to start while their direction-aware + // stream adapters are being added. This guard is widened only together with + // an end-to-end test for that carrier. + switch transport { + case config.TCP, config.STEALTH, config.TCPMUX, config.KCP, config.XDI, config.SPOOF, config.QUIC, config.WS, config.WSS, config.WSMUX, config.WSSMUX, config.UDP: + return nil + default: + return fmt.Errorf("forward direction for transport %q is not implemented in this build", transport) + } +} + +func probeForwardListen(network, addr string) error { + if network == "udp" { + pc, err := net.ListenPacket("udp", addr) + if err != nil { + return err + } + return pc.Close() + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + return ln.Close() +} + +func (p forwardProvider) Run(ctx context.Context, r Request) error { + if err := p.Validate(ctx, r); err != nil { + return err + } + if r.Config.HasClient() { + waitMetrics := reverseMetrics(ctx, r, string(r.Config.Client.Transport), "iran-edge") + c := client.NewForwardEdge(&r.Config.Client, ctx) + go c.Start() + <-ctx.Done() + c.Stop() + waitMetrics() + return nil + } + waitMetrics := reverseMetrics(ctx, r, string(r.Config.Server.Transport), "kharej-origin") + s := server.NewForwardOrigin(&r.Config.Server, ctx) + go s.Start() + <-ctx.Done() + s.Stop() + waitMetrics() + return nil +} + +func (forwardProvider) Health(context.Context, Request) (Health, error) { + return Health{Ready: true, Detail: "forward transport process is running"}, nil +} +func (forwardProvider) Counters(_ context.Context, r Request) (Counters, error) { + name := strings.TrimSuffix(filepath.Base(r.ConfigPath), filepath.Ext(r.ConfigPath)) + snap, err := metrics.Read(filepath.Dir(r.ConfigPath), name) + if err != nil { + return Counters{}, err + } + return Counters{RXBytes: snap.BytesIn, TXBytes: snap.BytesOut, RXPackets: snap.PacketsIn, TXPackets: snap.PacketsOut}, nil +} +func (forwardProvider) Cleanup(context.Context, Request) error { return nil } diff --git a/internal/engine/reverse.go b/internal/engine/reverse.go index 66ed44d..d1be278 100644 --- a/internal/engine/reverse.go +++ b/internal/engine/reverse.go @@ -29,12 +29,19 @@ func reverseName(path string) string { return strings.TrimSuffix(base, filepath.Ext(base)) } -func reverseMetrics(ctx context.Context, r Request, transport, role string) { +// reverseMetrics starts the process-wide collector and returns a waiter for its +// final atomic snapshot. Providers must call the waiter after ctx is cancelled; +// otherwise systemd can let the process exit while the final rename is still +// pending, losing the last interval of cumulative counters. +func reverseMetrics(ctx context.Context, r Request, transport, role string) func() { c := metrics.NewCollector(filepath.Dir(r.ConfigPath), reverseName(r.ConfigPath), transport, role, nil, nil) - done := make(chan struct{}) - go func() { <-ctx.Done(); close(done) }() _ = c.Write() - go c.Run(done, 30*time.Second) + finished := make(chan struct{}) + go func() { + defer close(finished) + c.Run(ctx.Done(), 30*time.Second) + }() + return func() { <-finished } } func (reverseProvider) Run(ctx context.Context, r Request) error { @@ -42,18 +49,20 @@ func (reverseProvider) Run(ctx context.Context, r Request) error { return err } if r.Config.HasServer() { - reverseMetrics(ctx, r, string(r.Config.Server.Transport), "server") + waitMetrics := reverseMetrics(ctx, r, string(r.Config.Server.Transport), "server") s := server.NewServer(&r.Config.Server, ctx) go s.Start() <-ctx.Done() s.Stop() + waitMetrics() return nil } - reverseMetrics(ctx, r, string(r.Config.Client.Transport), "client") + waitMetrics := reverseMetrics(ctx, r, string(r.Config.Client.Transport), "client") c := client.NewClient(&r.Config.Client, ctx) go c.Start() <-ctx.Done() c.Stop() + waitMetrics() return nil } @@ -62,5 +71,11 @@ func (reverseProvider) Run(ctx context.Context, r Request) error { func (reverseProvider) Health(context.Context, Request) (Health, error) { return Health{Ready: true, Detail: "reverse health is connection-based"}, nil } -func (reverseProvider) Counters(context.Context, Request) (Counters, error) { return Counters{}, nil } -func (reverseProvider) Cleanup(context.Context, Request) error { return nil } +func (reverseProvider) Counters(_ context.Context, r Request) (Counters, error) { + snap, err := metrics.Read(filepath.Dir(r.ConfigPath), reverseName(r.ConfigPath)) + if err != nil { + return Counters{}, err + } + return Counters{RXBytes: snap.BytesIn, TXBytes: snap.BytesOut, RXPackets: snap.PacketsIn, TXPackets: snap.PacketsOut}, nil +} +func (reverseProvider) Cleanup(context.Context, Request) error { return nil } diff --git a/internal/engine/reverse_metrics_test.go b/internal/engine/reverse_metrics_test.go new file mode 100644 index 0000000..cac60ef --- /dev/null +++ b/internal/engine/reverse_metrics_test.go @@ -0,0 +1,28 @@ +package engine + +import ( + "context" + "path/filepath" + "testing" + + "github.com/backpack/backpack/internal/metrics" +) + +func TestMetricsShutdownWaitsForFinalSnapshot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sigterm.toml") + ctx, cancel := context.WithCancel(context.Background()) + wait := reverseMetrics(ctx, Request{ConfigPath: path}, "tcp", "client") + + metrics.AddBytes(17, 29) + cancel() + wait() + + snapshot, err := metrics.Read(dir, "sigterm") + if err != nil { + t.Fatalf("read final metrics snapshot: %v", err) + } + if snapshot.BytesIn != 17 || snapshot.BytesOut != 29 { + t.Fatalf("final counters = %d/%d, want 17/29", snapshot.BytesIn, snapshot.BytesOut) + } +} diff --git a/internal/forwardmap/forwardmap.go b/internal/forwardmap/forwardmap.go new file mode 100644 index 0000000..21e1431 --- /dev/null +++ b/internal/forwardmap/forwardmap.go @@ -0,0 +1,178 @@ +package forwardmap + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +const ( + MaxPortsPerMapping = 1024 + MaxExpandedPorts = 4096 +) + +// Mapping is one concrete Iran listen socket and its Kharej backend target. +type Mapping struct { + Listen string + Target string +} + +type endpointRange struct { + host string + lo, hi int + explicitHost bool +} + +func parseEndpointRange(raw string) (endpointRange, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return endpointRange{}, fmt.Errorf("empty endpoint") + } + host, portText := "", raw + if strings.Contains(raw, ":") { + var err error + host, portText, err = net.SplitHostPort(raw) + if err != nil { + return endpointRange{}, fmt.Errorf("invalid host:port %q (IPv6 addresses must be bracketed): %w", raw, err) + } + } + parts := strings.Split(portText, "-") + if len(parts) < 1 || len(parts) > 2 { + return endpointRange{}, fmt.Errorf("invalid port range %q", portText) + } + parsePort := func(v string) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n < 1 || n > 65535 { + return 0, fmt.Errorf("invalid port %q", v) + } + return n, nil + } + lo, err := parsePort(parts[0]) + if err != nil { + return endpointRange{}, err + } + hi := lo + if len(parts) == 2 { + hi, err = parsePort(parts[1]) + if err != nil { + return endpointRange{}, err + } + if hi < lo { + return endpointRange{}, fmt.Errorf("port range %q ends before it starts", portText) + } + } + return endpointRange{host: host, lo: lo, hi: hi, explicitHost: strings.Contains(raw, ":")}, nil +} + +func (e endpointRange) len() int { return e.hi - e.lo + 1 } + +func endpoint(e endpointRange, port int, listen bool) string { + if e.explicitHost { + return net.JoinHostPort(e.host, strconv.Itoa(port)) + } + if listen { + return ":" + strconv.Itoa(port) + } + return strconv.Itoa(port) +} + +// Expand parses Backpack's existing mapping syntax. Ranges preserve offsets +// when the target is also a range. A single target intentionally fans a listen +// range into one backend, matching the historical reverse behaviour. +func Expand(specs []string) ([]Mapping, error) { + var out []Mapping + var listens []string + for _, raw := range specs { + left, right, hasTarget := strings.Cut(strings.TrimSpace(raw), "=") + listenRange, err := parseEndpointRange(left) + if err != nil { + return nil, fmt.Errorf("mapping %q listen: %w", raw, err) + } + if listenRange.len() > MaxPortsPerMapping { + return nil, fmt.Errorf("mapping %q expands beyond the %d-port mapping limit", raw, MaxPortsPerMapping) + } + if len(out)+listenRange.len() > MaxExpandedPorts { + return nil, fmt.Errorf("mappings expand beyond the %d-port instance limit", MaxExpandedPorts) + } + + var targets []endpointRange + if !hasTarget { + targets = []endpointRange{{lo: listenRange.lo, hi: listenRange.hi}} + } else { + for _, targetRaw := range strings.Split(right, "|") { + target, err := parseEndpointRange(targetRaw) + if err != nil { + return nil, fmt.Errorf("mapping %q target: %w", raw, err) + } + if target.len() != 1 && target.len() != listenRange.len() { + return nil, fmt.Errorf("mapping %q listen and target ranges must have equal length", raw) + } + targets = append(targets, target) + } + } + if len(targets) == 0 { + return nil, fmt.Errorf("mapping %q has no target", raw) + } + for offset := 0; offset < listenRange.len(); offset++ { + targetParts := make([]string, 0, len(targets)) + for _, target := range targets { + port := target.lo + if target.len() > 1 { + port += offset + } + targetParts = append(targetParts, endpoint(target, port, false)) + } + listen := endpoint(listenRange, listenRange.lo+offset, true) + for _, old := range listens { + if listenOverlap(listen, old) { + return nil, fmt.Errorf("listen endpoint %s overlaps %s", listen, old) + } + } + listens = append(listens, listen) + out = append(out, Mapping{ + Listen: listen, + Target: strings.Join(targetParts, "|"), + }) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("at least one ingress port mapping is required") + } + return out, nil +} + +func listenOverlap(a, b string) bool { + ah, ap, aerr := net.SplitHostPort(a) + bh, bp, berr := net.SplitHostPort(b) + if aerr != nil || berr != nil || ap != bp { + return false + } + normalize := func(h string) string { return strings.Trim(strings.TrimSpace(h), "[]") } + wild := func(h string) bool { + switch normalize(h) { + case "", "0.0.0.0", "::", "*": + return true + } + return false + } + family := func(h string) int { + h = normalize(h) + if h == "" || h == "*" { + return 0 // an unspecified Go listen address may claim both families + } + ip := net.ParseIP(h) + if ip == nil { + return 0 // unresolved names are conservatively treated as either family + } + if ip.To4() != nil { + return 4 + } + return 6 + } + af, bf := family(ah), family(bh) + if af != 0 && bf != 0 && af != bf { + return false + } + return wild(ah) || wild(bh) || strings.EqualFold(ah, bh) +} diff --git a/internal/forwardmap/forwardmap_test.go b/internal/forwardmap/forwardmap_test.go new file mode 100644 index 0000000..6bd76fc --- /dev/null +++ b/internal/forwardmap/forwardmap_test.go @@ -0,0 +1,48 @@ +package forwardmap + +import "testing" + +func TestExpandOffsetRangesAndIPv6(t *testing.T) { + got, err := Expand([]string{"[::1]:1000-1002=[2001:db8::5]:2000-2002"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0].Listen != "[::1]:1000" || got[2].Target != "[2001:db8::5]:2002" { + t.Fatalf("unexpected expansion: %#v", got) + } +} + +func TestExpandMultipleBackends(t *testing.T) { + got, err := Expand([]string{"100-101=127.0.0.1:200-201|[::1]:300-301"}) + if err != nil { + t.Fatal(err) + } + if got[1].Target != "127.0.0.1:201|[::1]:301" { + t.Fatalf("unexpected target: %q", got[1].Target) + } +} + +func TestExpandRejectsUnequalRangeAndLimit(t *testing.T) { + if _, err := Expand([]string{"100-102=200-201"}); err == nil { + t.Fatal("unequal ranges must fail") + } + if _, err := Expand([]string{"1-4097"}); err == nil { + t.Fatal("oversized expansion must fail") + } +} + +func TestExpandRejectsPerMappingExpansionLimit(t *testing.T) { + if _, err := Expand([]string{"1000-2024=3000-4024"}); err == nil { + t.Fatal("a mapping expanding beyond 1024 ports must be rejected") + } +} + +func TestExpandKeepsExplicitIPv4AndIPv6FamiliesIndependent(t *testing.T) { + mappings, err := Expand([]string{"0.0.0.0:443=8443", "[::]:443=8443"}) + if err != nil { + t.Fatalf("explicit IPv4 and IPv6 listeners should not overlap: %v", err) + } + if len(mappings) != 2 { + t.Fatalf("got %d mappings, want 2", len(mappings)) + } +} diff --git a/internal/manage/backup.go b/internal/manage/backup.go index ee6a867..f449a81 100644 --- a/internal/manage/backup.go +++ b/internal/manage/backup.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "net" "os" "path/filepath" "sort" @@ -170,11 +171,11 @@ func configsInDir(dir string) (map[string]*config.Config, error) { return result, nil } -func validateRestoreSet(ctx context.Context, dir string, configs map[string]*config.Config) error { +func validateRestoreSet(ctx context.Context, dir string, configs map[string]*config.Config, replacing bool) error { for name, cfg := range configs { provider, err := engine.Resolve(cfg) if err == nil { - err = provider.Validate(ctx, engine.Request{ConfigPath: filepath.Join(dir, name+".toml"), Config: cfg}) + err = provider.Validate(ctx, engine.Request{ConfigPath: filepath.Join(dir, name+".toml"), Config: cfg, Replacing: replacing}) } if err != nil { return fmt.Errorf("restored instance %s is invalid or conflicts with this host: %w", name, err) @@ -183,6 +184,76 @@ func validateRestoreSet(ctx context.Context, dir string, configs map[string]*con return nil } +func restoreTunnel(name string, cfg *config.Config) Tunnel { + t := Tunnel{Name: name, Engine: string(cfg.EffectiveEngine())} + switch { + case cfg.EffectiveEngine() == config.EngineIPTables: + t.Mappings = append([]config.ForwardMapping(nil), cfg.Forward.Mappings...) + case cfg.HasServer(): + t.Role, t.Transport, t.Addr = "server", string(cfg.Server.Transport), cfg.Server.BindAddr + t.Ports = append([]string(nil), cfg.Server.Ports...) + case cfg.HasClient(): + t.Role, t.Transport, t.Addr = "client", string(cfg.Client.Transport), cfg.Client.RemoteAddr + if cfg.EffectiveEngine() == config.EngineForward { + t.Ports = append([]string(nil), cfg.Client.Ports...) + } + } + return t +} + +// validateRestoreClaims compares the whole staged set with itself. Binding +// each candidate and immediately closing it cannot discover that two staged +// instances both intend to claim the same socket, so that comparison must be +// explicit before any live service is stopped. +func validateRestoreClaims(configs map[string]*config.Config) error { + type ownedClaim struct { + name string + listenClaim + } + var claims []ownedClaim + for name, cfg := range configs { + for _, claim := range tunnelClaims(restoreTunnel(name, cfg)) { + claims = append(claims, ownedClaim{name: name, listenClaim: claim}) + } + } + for i := range claims { + for j := 0; j < i; j++ { + if claimsOverlap(claims[i].listenClaim, claims[j].listenClaim) { + return fmt.Errorf("restored instances %s and %s both claim %s %s", claims[i].name, claims[j].name, claims[i].network, claims[i].addr) + } + } + } + return nil +} + +// probeRestoreClaims runs only after old Backpack services have been +// quiesced, so a bind failure now identifies an external listener rather than +// the instance that the candidate is about to replace. +func probeRestoreClaims(configs map[string]*config.Config) error { + for name, cfg := range configs { + t := restoreTunnel(name, cfg) + if t.KernelDirect() { + continue // the iptables provider performs netfilter conflict analysis + } + for _, claim := range tunnelClaims(t) { + if claim.network == "udp" { + pc, err := net.ListenPacket("udp", claim.addr) + if err != nil { + return fmt.Errorf("restored instance %s cannot claim UDP %s: %w", name, claim.addr, err) + } + _ = pc.Close() + continue + } + ln, err := net.Listen("tcp", claim.addr) + if err != nil { + return fmt.Errorf("restored instance %s cannot claim TCP %s: %w", name, claim.addr, err) + } + _ = ln.Close() + } + } + return nil +} + // Restore reads a backup archive produced by WriteBackup, extracts it into the // config directory (overwriting matching files, leaving others untouched), then // re-registers a systemd service for every tunnel it finds and starts them. It @@ -302,7 +373,10 @@ func Restore(r io.Reader) (RestoreResult, error) { if err != nil { return res, err } - if err := validateRestoreSet(context.Background(), stage, candidates); err != nil { + if err := validateRestoreSet(context.Background(), stage, candidates, true); err != nil { + return res, err + } + if err := validateRestoreClaims(candidates); err != nil { return res, err } @@ -325,6 +399,14 @@ func Restore(r io.Reader) (RestoreResult, error) { } } } + if err := validateRestoreSet(context.Background(), stage, candidates, false); err != nil { + resumeOld() + return res, err + } + if err := probeRestoreClaims(candidates); err != nil { + resumeOld() + return res, err + } rollbackDir, err := os.MkdirTemp(parent, ".backpack-rollback-") if err != nil { @@ -391,9 +473,13 @@ func Restore(r io.Reader) (RestoreResult, error) { } for _, name := range names { service := app.ServiceName(name) - if err := StartService(service); err != nil { + // A restored name may already have a running process from before the + // atomic directory swap. Restart is required so it actually reads the + // restored config and cannot overwrite restored cumulative metrics with + // its old in-memory snapshot. systemd restart also starts an inactive unit. + if err := RestartService(service); err != nil { res.Failed++ - return res, rollback(fmt.Errorf("start restored instance %s: %w", name, err)) + return res, rollback(fmt.Errorf("restart restored instance %s: %w", name, err)) } if !WaitServiceActive(service, 12*time.Second) { res.Failed++ diff --git a/internal/manage/backup_restore_claims_test.go b/internal/manage/backup_restore_claims_test.go new file mode 100644 index 0000000..1df945b --- /dev/null +++ b/internal/manage/backup_restore_claims_test.go @@ -0,0 +1,42 @@ +package manage + +import ( + "net" + "testing" + + "github.com/backpack/backpack/config" +) + +func TestRestoreSetRejectsCandidateSocketConflict(t *testing.T) { + configs := map[string]*config.Config{ + "one": {Server: config.ServerConfig{BindAddr: "0.0.0.0:24443", Transport: config.TCP}}, + "two": {Server: config.ServerConfig{BindAddr: "127.0.0.1:24443", Transport: config.TCP}}, + } + if err := validateRestoreClaims(configs); err == nil { + t.Fatal("overlapping candidate listeners must be rejected before restore") + } +} + +func TestRestoreSetKeepsExplicitFamiliesIndependent(t *testing.T) { + configs := map[string]*config.Config{ + "v4": {Server: config.ServerConfig{BindAddr: "0.0.0.0:24443", Transport: config.TCP}}, + "v6": {Server: config.ServerConfig{BindAddr: "[::]:24443", Transport: config.TCP}}, + } + if err := validateRestoreClaims(configs); err != nil { + t.Fatalf("explicit IPv4 and IPv6 candidates should be independent: %v", err) + } +} + +func TestRestoreProbeRejectsExternalListener(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + configs := map[string]*config.Config{ + "candidate": {Server: config.ServerConfig{BindAddr: listener.Addr().String(), Transport: config.TCP}}, + } + if err := probeRestoreClaims(configs); err == nil { + t.Fatal("an external listener must block restore activation") + } +} diff --git a/internal/manage/benchmarkmenu.go b/internal/manage/benchmarkmenu.go index e403fb5..e9025a3 100644 --- a/internal/manage/benchmarkmenu.go +++ b/internal/manage/benchmarkmenu.go @@ -19,9 +19,9 @@ func LinkTest() { clients := clientTunnels() if len(clients) == 0 { - tui.Info("No client tunnels found on this server.") - tui.Warn("Run this on the abroad (kharej) side — it is the side that dials") - tui.Warn("out, so it is the side that can measure the link.") + tui.Info("No dialling tunnels found on this server.") + tui.Warn("Run this on the side that initiates the tunnel: Iran for Direct,") + tui.Warn("or Kharej for Reverse.") tui.PressEnter() return } diff --git a/internal/manage/config.go b/internal/manage/config.go index 2117e90..4a0dfd2 100644 --- a/internal/manage/config.go +++ b/internal/manage/config.go @@ -1,11 +1,14 @@ package manage import ( + "context" "fmt" "os" "strings" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/engine" ) // TunnelSpec is the full description of a tunnel used to render a TOML config. @@ -13,6 +16,10 @@ type TunnelSpec struct { Name string Role string // "server" (Iran/edge that exposes ports) or "client" (kharej/origin) Transport string // tcp, tcpmux, udp, kcp, ws, wss, wsmux, wssmux + // Engine is empty for legacy/reverse output. Forward retains the geographic + // UI role above while swapping the operational TOML section: Iran is the + // dialling [client], Kharej is the listening [server]. + Engine config.EngineType // Preset is the performance profile every tuning field was filled from: // balance, turbo or aggressive. Empty means the values were set by hand or @@ -125,6 +132,48 @@ type TunnelSpec struct { ZeroCopy bool } +func (s TunnelSpec) operationalServer() bool { + server := s.Role == "server" + if s.Engine == config.EngineForward { + return !server + } + return server +} + +// Validate renders and decodes the exact candidate that would be installed, +// then runs the selected engine's side-effect-free validation. +func (s TunnelSpec) Validate() error { + if err := validateForwardConflicts(s); err != nil { + return err + } + if err := os.MkdirAll(app.ConfigDir, 0755); err != nil { + return err + } + f, err := os.CreateTemp(app.ConfigDir, ".candidate-*") + if err != nil { + return err + } + path := f.Name() + defer os.Remove(path) + if _, err = f.WriteString(s.Render()); err != nil { + f.Close() + return err + } + if err = f.Close(); err != nil { + return err + } + cfg, err := config.LoadFile(path) + if err != nil { + return err + } + provider, err := engine.Resolve(cfg) + if err != nil { + return err + } + _, statErr := os.Stat(app.ConfigPath(s.Name)) + return provider.Validate(context.Background(), engine.Request{ConfigPath: app.ConfigPath(s.Name), Config: cfg, Replacing: statErr == nil}) +} + // writeTuning emits the throughput/latency knobs shared by server and client. func (s TunnelSpec) writeTuning(p func(string, ...any)) { if s.MSS > 0 { @@ -221,13 +270,15 @@ func isDatagram(t string) bool { return t == "udp" || t == "kcp" || t == "xdi" || t == "quic" || t == "spoof" } +func isRawDatagram(t string) bool { return t == "xdi" || t == "spoof" } + // supportsProxyProtocol reports whether a transport can prepend the PROXY // protocol header. The plain websocket and raw UDP transports cannot: one has // no place to put it in its framing, the other carries datagrams with no // connection to describe. func supportsProxyProtocol(t string) bool { switch t { - case "tcp", "tcpmux", "kcp", "wsmux", "wssmux", "stealth", "quic", "spoof": + case "tcp", "tcpmux", "kcp", "wsmux", "wssmux", "stealth", "quic", "xdi", "spoof": return true } return false @@ -260,8 +311,12 @@ func (s TunnelSpec) Render() string { b.WriteString("# Generated by backpack — do not edit while the service is running.\n") p("# name = \"%s\"\n\n", s.Name) + if s.Engine != "" && s.Engine != config.EngineReverse { + p("engine = %q\n\n", s.Engine) + } - if s.Role == "server" { + serverSection := s.operationalServer() + if serverSection { b.WriteString("[server]\n") p("bind_addr = %q\n", s.BindAddr) p("transport = %q\n", s.Transport) @@ -325,11 +380,13 @@ func (s TunnelSpec) Render() string { if s.WebPort > 0 { p("web_port = %d\n", s.WebPort) } - b.WriteString("ports = [\n") - for _, port := range s.Ports { - p(" %q,\n", port) + if s.Engine != config.EngineForward { + b.WriteString("ports = [\n") + for _, port := range s.Ports { + p(" %q,\n", port) + } + b.WriteString("]\n") } - b.WriteString("]\n") return b.String() } @@ -385,6 +442,20 @@ func (s TunnelSpec) Render() string { if s.ZeroCopy { p("zero_copy = true\n") } + if s.Engine == config.EngineForward { + if s.Transport == "tcp" { + p("accept_udp = %t\n", s.AcceptUDP) + } + if supportsProxyProtocol(s.Transport) { + p("proxy_protocol = %t\n", s.ProxyProtocol) + } + if s.MaxConnections > 0 { + p("max_connections = %d\n", s.MaxConnections) + } + if s.BandwidthMbps > 0 { + p("bandwidth_mbps = %d\n", s.BandwidthMbps) + } + } if isMux(s.Transport) { p("mux_session = %d\n", s.MuxCon) p("mux_version = %d\n", s.MuxVersion) @@ -396,6 +467,13 @@ func (s TunnelSpec) Render() string { if s.WebPort > 0 { p("web_port = %d\n", s.WebPort) } + if s.Engine == config.EngineForward { + b.WriteString("ports = [\n") + for _, port := range s.Ports { + p(" %q,\n", port) + } + b.WriteString("]\n") + } return b.String() } @@ -405,17 +483,35 @@ func (s TunnelSpec) Save() (string, error) { if err := os.MkdirAll(app.ConfigDir, 0755); err != nil { return "", err } - if err := os.WriteFile(app.ConfigPath(s.Name), []byte(s.Render()), 0644); err != nil { + if err := s.Validate(); err != nil { + return "", fmt.Errorf("invalid tunnel configuration: %w", err) + } + path := app.ConfigPath(s.Name) + previous, previousErr := os.ReadFile(path) + hadPrevious := previousErr == nil + rollback := func() { + if hadPrevious { + _ = app.WriteFileAtomic(path, previous, 0644) + } else { + _ = os.Remove(path) + removeUnit(s.Name) + } + _ = DaemonReload() + } + if err := app.WriteFileAtomic(path, []byte(s.Render()), 0644); err != nil { return "", err } if err := writeUnit(s.Name); err != nil { + rollback() return "", err } if err := DaemonReload(); err != nil { + rollback() return "", err } service := app.ServiceName(s.Name) if err := StartService(service); err != nil { + rollback() return service, err } return service, nil diff --git a/internal/manage/diagnose.go b/internal/manage/diagnose.go index 1a29b58..c6421f6 100644 --- a/internal/manage/diagnose.go +++ b/internal/manage/diagnose.go @@ -274,7 +274,7 @@ func tunnelChecks() []Check { // tunnelChecksFor is one tunnel's section of the report. func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check { - if t.Mode == "direct" { + if t.KernelDirect() { return directChecksFor(t) } var out []Check @@ -285,7 +285,7 @@ func tunnelChecksFor(t Tunnel, pairs [][2]string) []Check { switch h.State { case "online": out = append(out, Check{Group: g, Name: "State", Level: CheckOK, - Detail: fmt.Sprintf("online (%s %s)", t.Role, t.Transport)}) + Detail: fmt.Sprintf("online (%s %s)", t.DisplayRole(), t.Transport)}) case "offline": fix := "check the other side is running and reachable" if t.Role == "client" { diff --git a/internal/manage/direct.go b/internal/manage/direct.go index 1a64d84..155da1e 100644 --- a/internal/manage/direct.go +++ b/internal/manage/direct.go @@ -151,7 +151,7 @@ func promptDirectMapping(existing *config.ForwardMapping) (config.ForwardMapping func SetupDirect() { tui.Clear() - tui.Title("Setup Direct Forward") + tui.Title("Advanced Kernel Direct Forward") tui.Warn("Incoming TCP/UDP is DNATed directly to one fixed target with iptables.") tui.Warn("MASQUERADE means the target sees this ingress server as the source.") fmt.Println() diff --git a/internal/manage/edit.go b/internal/manage/edit.go index ddd1b6a..10a1ae2 100644 --- a/internal/manage/edit.go +++ b/internal/manage/edit.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/BurntSushi/toml" "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" ) @@ -16,16 +15,25 @@ import ( // loadServerSpec reconstructs a server tunnel's spec from its config file so it // can be modified and re-saved without losing settings. func loadServerSpec(name string) (TunnelSpec, error) { - var cfg config.Config - if _, err := toml.DecodeFile(app.ConfigPath(name), &cfg); err != nil { + cfg, err := config.LoadFile(app.ConfigPath(name)) + if err != nil { return TunnelSpec{}, err } + return serverSpecFromConfig(name, cfg) +} + +func serverSpecFromConfig(name string, cfg *config.Config) (TunnelSpec, error) { sc := cfg.Server if sc.BindAddr == "" { return TunnelSpec{}, fmt.Errorf("%q is not a server tunnel", name) } + role := "server" + if cfg.EffectiveEngine() == config.EngineForward { + role = "client" // geographic Kharej role; [server] is operational + } return TunnelSpec{ - Role: "server", + Role: role, + Engine: cfg.Engine, Name: name, Transport: string(sc.Transport), BindAddr: sc.BindAddr, @@ -82,21 +90,31 @@ func loadServerSpec(name string) (TunnelSpec, error) { // loadClientSpec reconstructs a client tunnel's spec from its config file so it // can be modified and re-saved without losing settings. func loadClientSpec(name string) (TunnelSpec, error) { - var cfg config.Config - if _, err := toml.DecodeFile(app.ConfigPath(name), &cfg); err != nil { + cfg, err := config.LoadFile(app.ConfigPath(name)) + if err != nil { return TunnelSpec{}, err } + return clientSpecFromConfig(name, cfg) +} + +func clientSpecFromConfig(name string, cfg *config.Config) (TunnelSpec, error) { cc := cfg.Client if cc.RemoteAddr == "" { return TunnelSpec{}, fmt.Errorf("%q is not a client tunnel", name) } + role := "client" + if cfg.EffectiveEngine() == config.EngineForward { + role = "server" // geographic Iran role; [client] is operational + } return TunnelSpec{ - Role: "client", + Role: role, + Engine: cfg.Engine, Name: name, Transport: string(cc.Transport), RemoteAddr: cc.RemoteAddr, FallbackAddrs: cc.FallbackAddrs, Token: cc.Token, + Ports: append([]string(nil), cc.Ports...), ConnectionPool: cc.ConnectionPool, AggressivePool: cc.AggressivePool, KeepAlive: cc.Keepalive, @@ -113,6 +131,10 @@ func loadClientSpec(name string) (TunnelSpec, error) { Interface: cc.Interface, SOMark: cc.SOMark, ZeroCopy: cc.ZeroCopy, + AcceptUDP: cc.AcceptUDP, + ProxyProtocol: cc.ProxyProtocol, + MaxConnections: cc.MaxConnections, + BandwidthMbps: cc.BandwidthMbps, MuxCon: cc.MuxSession, MuxVersion: cc.MuxVersion, MuxFrameSize: cc.MaxFrameSize, @@ -221,7 +243,7 @@ func EditTunnel(name, host, tunnelPort string, ports []string) error { if tunnelPort != "" && !validPort(tunnelPort) { return fmt.Errorf("invalid tunnel port %q", tunnelPort) } - if s.Role == "server" { + if s.operationalServer() { if host != "" { return fmt.Errorf("the address can only be changed on client tunnels") } @@ -259,6 +281,11 @@ func EditTunnel(name, host, tunnelPort string, ports []string) error { if err := validatePortSpecs(clean); err != nil { return err } + if s.Engine == config.EngineForward { + if err := validateForwardPortSpecs(clean); err != nil { + return err + } + } // Keep the hidden Telegram/SOCKS relay mapping the user never sees. for _, p := range s.Ports { if isBotRelayPort(p, s.Token) { @@ -287,7 +314,7 @@ func SetFallbackAddrs(name string, addrs []string) error { if err != nil { return err } - if s.Role != "client" { + if s.operationalServer() { return fmt.Errorf("fallback addresses apply to client tunnels only") } @@ -364,7 +391,7 @@ func ChangeTransport(name, transport string) error { s.MuxStreamBuffer = 65536 } // TLS transports need a certificate on the server side. - if s.Role == "server" && needsTLS(transport) && (s.TLSCert == "" || !fileExists(s.TLSCert)) { + if s.operationalServer() && needsTLS(transport) && (s.TLSCert == "" || !fileExists(s.TLSCert)) { cert, key, err := EnsureSelfSignedCert(s.Name, "") if err != nil { return fmt.Errorf("could not generate a TLS certificate: %w", err) @@ -395,7 +422,7 @@ func SetLoadBalance(name string, on bool) error { if err != nil { return err } - if s.Role != "client" { + if s.operationalServer() { return fmt.Errorf("load balancing is a client-side setting") } if on && len(s.FallbackAddrs) == 0 { @@ -474,12 +501,21 @@ func applySpec(s TunnelSpec) error { } wasActive := IsActive(service) - if _, err := s.Save(); err != nil { - // Save failed — put the original file back untouched. - _ = os.WriteFile(path, prev, 0644) + if err := s.Validate(); err != nil { + return fmt.Errorf("invalid candidate configuration: %w", err) + } + if err := app.WriteFileAtomic(path, []byte(s.Render()), 0644); err != nil { + return fmt.Errorf("could not atomically install candidate config: %w", err) + } + if err := writeUnit(s.Name); err != nil { + _ = app.WriteFileAtomic(path, prev, 0644) return err } - // Save alone won't reload an already-running unit — restart explicitly. + if err := DaemonReload(); err != nil { + _ = app.WriteFileAtomic(path, prev, 0644) + return err + } + // Restart explicitly so an existing process cannot keep the old config. if err := RestartService(service); err != nil { revertSpec(path, prev, service, wasActive) return fmt.Errorf("the tunnel failed to restart with the new settings — reverted: %w", err) @@ -501,7 +537,7 @@ func applySpec(s TunnelSpec) error { // revertSpec restores a previous config file and brings the service back to the // state it was in before the edit. func revertSpec(path string, prev []byte, service string, wasActive bool) { - _ = os.WriteFile(path, prev, 0644) + _ = app.WriteFileAtomic(path, prev, 0644) if wasActive { _ = RestartService(service) } else { @@ -542,7 +578,7 @@ func SetCertificate(name, domain, email string) error { if err != nil { return err } - if s.Role != "server" { + if !s.operationalServer() { return fmt.Errorf("the certificate is a server-side setting — the client does not present one") } if !needsTLS(s.Transport) { diff --git a/internal/manage/forward_config_test.go b/internal/manage/forward_config_test.go new file mode 100644 index 0000000..d72a9e4 --- /dev/null +++ b/internal/manage/forward_config_test.go @@ -0,0 +1,105 @@ +package manage + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/backpack/backpack/config" +) + +func loadRenderedForward(t *testing.T, s TunnelSpec) *config.Config { + t.Helper() + path := filepath.Join(t.TempDir(), "forward.toml") + if err := os.WriteFile(path, []byte(s.Render()), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadFile(path) + if err != nil { + t.Fatalf("rendered forward config is invalid: %v\n%s", err, s.Render()) + } + return cfg +} + +func TestForwardIranRoleRendersDiallingClientWithIngress(t *testing.T) { + s := TunnelSpec{ + Name: "iran", Role: "server", Engine: config.EngineForward, + Transport: "tcp", RemoteAddr: "192.0.2.10:443", Token: "secret", + Ports: []string{"8443=127.0.0.1:8443"}, KeepAlive: 75, + } + cfg := loadRenderedForward(t, s) + if !cfg.HasClient() || cfg.HasServer() || len(cfg.Client.Ports) != 1 { + t.Fatalf("Iran forward role rendered with wrong operational section: %#v", cfg) + } + if strings.Contains(s.Render(), "[forward]") || !strings.Contains(s.Render(), "engine = \"forward\"") { + t.Fatalf("application forward was confused with iptables config:\n%s", s.Render()) + } +} + +func TestForwardKharejRoleRendersListeningServerWithoutIngress(t *testing.T) { + s := TunnelSpec{ + Name: "kharej", Role: "client", Engine: config.EngineForward, + Transport: "tcp", BindAddr: "0.0.0.0:443", Token: "secret", + ChannelSize: 2048, KeepAlive: 75, Heartbeat: 40, + } + cfg := loadRenderedForward(t, s) + if !cfg.HasServer() || cfg.HasClient() || len(cfg.Server.Ports) != 0 { + t.Fatalf("Kharej forward role rendered with wrong operational section: %#v", cfg) + } +} + +func TestForwardConfigReloadPreservesGeographicRolesAndIngress(t *testing.T) { + iranRendered := TunnelSpec{ + Name: "iran", Role: "server", Engine: config.EngineForward, + Transport: "tcpmux", RemoteAddr: "192.0.2.10:443", Token: "secret", + Ports: []string{"8443=127.0.0.1:9443"}, MaxConnections: 50, BandwidthMbps: 25, + }.Render() + iranPath := filepath.Join(t.TempDir(), "iran.toml") + if err := os.WriteFile(iranPath, []byte(iranRendered), 0o600); err != nil { + t.Fatal(err) + } + iranCfg, err := config.LoadFile(iranPath) + if err != nil { + t.Fatal(err) + } + iran, err := clientSpecFromConfig("iran", iranCfg) + if err != nil { + t.Fatal(err) + } + if iran.Role != "server" || iran.Engine != config.EngineForward || len(iran.Ports) != 1 || iran.MaxConnections != 50 || iran.BandwidthMbps != 25 { + t.Fatalf("Iran Direct spec lost settings on reload: %#v", iran) + } + if !strings.Contains(iran.Render(), "[client]") || strings.Contains(iran.Render(), "[server]") { + t.Fatalf("editing Iran Direct would flip its operational role:\n%s", iran.Render()) + } + + kharejRendered := TunnelSpec{ + Name: "kharej", Role: "client", Engine: config.EngineForward, + Transport: "wss", BindAddr: "0.0.0.0:443", Token: "secret", + TLSCert: "/tmp/cert", TLSKey: "/tmp/key", + }.Render() + kharejPath := filepath.Join(t.TempDir(), "kharej.toml") + if err := os.WriteFile(kharejPath, []byte(kharejRendered), 0o600); err != nil { + t.Fatal(err) + } + kharejCfg, err := config.LoadFile(kharejPath) + if err != nil { + t.Fatal(err) + } + kharej, err := serverSpecFromConfig("kharej", kharejCfg) + if err != nil { + t.Fatal(err) + } + if kharej.Role != "client" || !kharej.operationalServer() || kharej.TLSCert != "/tmp/cert" { + t.Fatalf("Kharej Direct spec lost its operational server identity: %#v", kharej) + } +} + +func TestForwardTunnelDisplayRoleStaysGeographic(t *testing.T) { + iran := Tunnel{Engine: string(config.EngineForward), Role: "client"} + kharej := Tunnel{Engine: string(config.EngineForward), Role: "server"} + if iran.DisplayRole() != "server" || kharej.DisplayRole() != "client" { + t.Fatalf("display roles changed sides: Iran=%q Kharej=%q", iran.DisplayRole(), kharej.DisplayRole()) + } +} diff --git a/internal/manage/forward_conflict.go b/internal/manage/forward_conflict.go new file mode 100644 index 0000000..5636db9 --- /dev/null +++ b/internal/manage/forward_conflict.go @@ -0,0 +1,185 @@ +package manage + +import ( + "fmt" + "net" + "os" + "strings" + + "github.com/backpack/backpack/config" + "github.com/backpack/backpack/internal/app" + "github.com/backpack/backpack/internal/forwardmap" +) + +type listenClaim struct { + network string // tcp or udp + addr string + purpose string +} + +func transportNetwork(transport string) string { + if isDatagram(transport) { + return "udp" + } + return "tcp" +} + +func appForwardClaims(s TunnelSpec) ([]listenClaim, error) { + var claims []listenClaim + if s.Role == "server" { // geographic Iran edge owns user ingress + mappings, err := forwardmap.Expand(s.Ports) + if err != nil { + return nil, err + } + network := "tcp" + if s.Transport == "udp" { + network = "udp" + } + for _, mapping := range mappings { + claims = append(claims, listenClaim{network: network, addr: mapping.Listen, purpose: "Direct ingress"}) + } + } + if s.operationalServer() && !isRawDatagram(s.Transport) { + claims = append(claims, listenClaim{network: transportNetwork(s.Transport), addr: s.BindAddr, purpose: "tunnel listener"}) + } + return claims, nil +} + +func tunnelClaims(t Tunnel) []listenClaim { + var claims []listenClaim + if t.Role == "server" && !isRawDatagram(t.Transport) && strings.TrimSpace(t.Addr) != "" { + claims = append(claims, listenClaim{network: transportNetwork(t.Transport), addr: t.Addr, purpose: t.Name + " tunnel listener"}) + } + if len(t.Ports) > 0 { + if mappings, err := forwardmap.Expand(t.Ports); err == nil { + network := "tcp" + if t.Transport == "udp" { + network = "udp" + } + for _, mapping := range mappings { + claims = append(claims, listenClaim{network: network, addr: mapping.Listen, purpose: t.Name + " exposed port"}) + } + } + } + for _, mapping := range t.Mappings { // advanced iptables engine + lr, _, err := mapping.Ranges() + if err != nil { + continue + } + for _, proto := range mapping.Protocols { + for p := int(lr.Start); p <= int(lr.End); p++ { + claims = append(claims, listenClaim{network: strings.ToLower(proto), addr: net.JoinHostPort(mapping.ListenAddress, fmt.Sprint(p)), purpose: t.Name + " iptables mapping"}) + } + } + } + return claims +} + +func splitClaim(addr string) (host, port string, ok bool) { + host, port, err := net.SplitHostPort(addr) + return host, port, err == nil +} + +func wildcardHost(host string) bool { + switch strings.Trim(strings.TrimSpace(host), "[]") { + case "", "0.0.0.0", "::", "*": + return true + } + return false +} + +func claimFamily(host string) int { + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" || host == "*" { + return 0 + } + ip := net.ParseIP(host) + if ip == nil { + return 0 + } + if ip.To4() != nil { + return 4 + } + return 6 +} + +func claimsOverlap(a, b listenClaim) bool { + if a.network != b.network { + return false + } + ah, ap, aok := splitClaim(a.addr) + bh, bp, bok := splitClaim(b.addr) + if !aok || !bok || ap != bp { + return false + } + af, bf := claimFamily(ah), claimFamily(bh) + if af != 0 && bf != 0 && af != bf { + return false + } + return wildcardHost(ah) || wildcardHost(bh) || strings.EqualFold(ah, bh) +} + +// validateForwardConflicts rejects mistakes before config replacement or +// optimization. Existing local sockets are probed for new instances; edits +// rely on config claims so the instance being replaced is not mistaken for a +// second owner of its own ports. +func validateForwardConflicts(s TunnelSpec) error { + if s.Engine != config.EngineForward { + return nil + } + claims, err := appForwardClaims(s) + if err != nil { + return err + } + for i := range claims { + for j := 0; j < i; j++ { + if claimsOverlap(claims[i], claims[j]) { + return fmt.Errorf("%s %s overlaps %s %s", claims[i].network, claims[i].addr, claims[j].purpose, claims[j].addr) + } + } + } + for _, other := range List() { + if other.Name == s.Name { + continue + } + for _, existing := range tunnelClaims(other) { + for _, candidate := range claims { + if claimsOverlap(candidate, existing) { + return fmt.Errorf("%s %s conflicts with %s (%s)", candidate.network, candidate.addr, existing.purpose, existing.addr) + } + } + } + } + var oldClaims []listenClaim + if _, err := os.Stat(app.ConfigPath(s.Name)); err == nil { + if current, ok := Find(s.Name); ok { + oldClaims = tunnelClaims(current) + } + } + for _, claim := range claims { + ownedByCurrent := false + for _, old := range oldClaims { + if claimsOverlap(claim, old) { + ownedByCurrent = true + break + } + } + if ownedByCurrent { + continue + } + if claim.network == "udp" { + pc, err := net.ListenPacket("udp", claim.addr) + if err != nil { + return fmt.Errorf("%s %s is already in use: %w", claim.purpose, claim.addr, err) + } + pc.Close() + } else { + ln, err := net.Listen("tcp", claim.addr) + if err != nil { + return fmt.Errorf("%s %s is already in use: %w", claim.purpose, claim.addr, err) + } + ln.Close() + } + } + return nil +} diff --git a/internal/manage/forward_conflict_test.go b/internal/manage/forward_conflict_test.go new file mode 100644 index 0000000..4375ffc --- /dev/null +++ b/internal/manage/forward_conflict_test.go @@ -0,0 +1,46 @@ +package manage + +import ( + "fmt" + "net" + "testing" + "time" + + "github.com/backpack/backpack/config" +) + +func TestForwardConflictRejectsDuplicateWildcardAndSpecific(t *testing.T) { + s := TunnelSpec{ + Name: "conflict-unit", Role: "server", Engine: config.EngineForward, + Transport: "tcp", RemoteAddr: "192.0.2.1:443", + Ports: []string{"0.0.0.0:18080=18080", "127.0.0.1:18080=18081"}, + } + if err := validateForwardConflicts(s); err == nil { + t.Fatal("wildcard and specific listeners on the same port must overlap") + } +} + +func TestForwardConflictRejectsExistingLocalListener(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + s := TunnelSpec{ + Name: fmt.Sprintf("conflict-%d", time.Now().UnixNano()), + Role: "server", Engine: config.EngineForward, Transport: "tcp", + RemoteAddr: "192.0.2.1:443", Ports: []string{fmt.Sprintf("127.0.0.1:%d=18081", port)}, + } + if err := validateForwardConflicts(s); err == nil { + t.Fatal("an unrelated local listener must be rejected before config write") + } +} + +func TestForwardConflictTreatsExplicitFamiliesIndependently(t *testing.T) { + a := listenClaim{network: "tcp", addr: "0.0.0.0:443"} + b := listenClaim{network: "tcp", addr: "[::]:443"} + if claimsOverlap(a, b) { + t.Fatal("explicit IPv4 and IPv6 listeners must be checked independently") + } +} diff --git a/internal/manage/health.go b/internal/manage/health.go index 11779ba..caf7a66 100644 --- a/internal/manage/health.go +++ b/internal/manage/health.go @@ -105,6 +105,13 @@ func tunnelHealthWith(t Tunnel, pairs [][2]string) Health { h.Connected = connected } } + if t.AppForward() && isRawDatagram(t.Transport) && t.Role == "client" { + if connected, known := datagramServerPeer(app.ConfigDir, t.Name); known { + h.Connected = connected + } else { + h.Connected = false + } + } if h.Connected { h.State = "online" h.Detail = "peer connected" diff --git a/internal/manage/menu.go b/internal/manage/menu.go index 879ea78..bf54bb2 100644 --- a/internal/manage/menu.go +++ b/internal/manage/menu.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" "github.com/backpack/backpack/internal/tui" ) @@ -29,9 +30,11 @@ func ManageTunnels() { tui.Clear() opts := make([]tui.Option, len(tunnels)) for i, t := range tunnels { - desc := fmt.Sprintf("%s %s — %s", t.Role, t.Transport, plainState(t.Service)) - if t.Mode == "direct" { + desc := fmt.Sprintf("%s %s — %s", t.DisplayRole(), t.Transport, plainState(t.Service)) + if t.KernelDirect() { desc = fmt.Sprintf("direct %s — %d mapping(s) — %s", t.Engine, len(t.Mappings), plainState(t.Service)) + } else if t.AppForward() { + desc = fmt.Sprintf("direct %s over %s — %s", t.DisplayRole(), t.Transport, plainState(t.Service)) } opts[i] = tui.Option{ Title: t.Name, @@ -60,14 +63,16 @@ func manageOne(t Tunnel) { for { tui.Clear() tui.Title(fmt.Sprintf("Instance: %s", t.Name)) - label := strings.TrimSpace(t.Role + " " + t.Transport) - if t.Mode == "direct" { + label := strings.TrimSpace(t.DisplayRole() + " " + t.Transport) + if t.KernelDirect() { label = "direct " + t.Engine + } else if t.AppForward() { + label = "direct " + label } fmt.Printf(" %s%s%s %s\n\n", tui.Gray, label, tui.Reset, stateLabel(t.Service)) editDesc := "change tunnel port & forwarded ports" - if t.Mode == "direct" { + if t.KernelDirect() { editDesc = "add, edit, or remove direct mappings" } idx := tui.ChooseOpt("Choose an action:", []tui.Option{ @@ -80,7 +85,7 @@ func manageOne(t Tunnel) { }) switch idx { case 0: - if t.Mode == "direct" { + if t.KernelDirect() { editDirectMenu(t.Name) } else { editPortsMenu(t.Name) @@ -134,6 +139,12 @@ func editPortsMenu(name string) { tui.Clear() tui.Title("Edit — " + name) fmt.Println() + if spec.Engine == config.EngineForward { + if editForwardSpec(name, spec) { + continue + } + return + } if spec.Role == "server" { tui.Info("Tunnel (control) port : " + addrPort(spec.BindAddr)) @@ -218,10 +229,95 @@ func editPortsMenu(name string) { } } +// editForwardSpec renders Direct using the geographic roles shown by setup, +// while applying settings to the operationally reversed TOML sections. +// It returns true after an action so the caller redraws fresh values. +func editForwardSpec(name string, spec TunnelSpec) bool { + if spec.Role == "server" { // Iran edge: operational [client] + tui.Info("Kharej address : " + spec.RemoteAddr) + tui.Info("Exposed ports : " + strings.Join(VisiblePorts(spec.Ports, spec.Token), ", ")) + tui.Info("Transport : " + transportLabel(spec.Transport)) + tui.Info("Performance preset : " + presetLabel(spec.Preset)) + tui.Info("Limits : " + limitsSummary(spec)) + fmt.Println() + opts := []tui.Option{ + {Title: "Change tunnel port", Desc: "the port Iran dials on Kharej"}, + {Title: "Change Kharej address", Desc: "IP or domain of the Direct origin"}, + {Title: "Change exposed ports", Desc: "public Iran listen ports and Kharej targets"}, + {Title: "Change transport", Desc: "switch carrier; change the other side too"}, + {Title: "Change performance preset", Desc: "Balance, Turbo or Aggressive"}, + {Title: "Limits", Desc: "cap ingress connections and bandwidth"}, + {Title: "Backup Kharej addresses", Desc: "fail over if the primary path is blocked"}, + {Title: "Load balancing", Desc: "spread over all Kharej addresses"}, + } + if supportsProxyProtocol(spec.Transport) { + opts = append(opts, tui.Option{Title: "Real client IP", Desc: "send PROXY protocol to the Kharej backend"}) + } + switch tui.ChooseOpt("Choose:", opts) { + case 0: + changeTunnelPort(name, spec) + case 1: + changeClientHost(name, spec) + case 2: + changeForwardedPorts(name, spec) + case 3: + changeTunnelTransport(name, spec) + case 4: + changeTunnelPreset(name, spec) + case 5: + editLimits(name, spec) + case 6: + changeFallbackAddrs(name, spec) + case 7: + toggleLoadBalance(name, spec) + case 8: + if supportsProxyProtocol(spec.Transport) { + toggleProxyProtocol(name, spec) + } + default: + return false + } + return true + } + + // Kharej origin: operational [server]. It owns the TLS certificate and + // tunnel listener, but never exposes the public user ports. + tui.Info("Tunnel listen address : " + spec.BindAddr) + tui.Info("Transport : " + transportLabel(spec.Transport)) + tui.Info("Performance preset : " + presetLabel(spec.Preset)) + if needsTLS(spec.Transport) { + tui.Info("Certificate : " + certSummary(spec)) + } + fmt.Println() + opts := []tui.Option{ + {Title: "Change tunnel port", Desc: "the Direct port Iran dials"}, + {Title: "Change transport", Desc: "switch carrier; change the other side too"}, + {Title: "Change performance preset", Desc: "Balance, Turbo or Aggressive"}, + } + if needsTLS(spec.Transport) { + opts = append(opts, tui.Option{Title: "Certificate", Desc: "self-signed or Let's Encrypt"}) + } + switch tui.ChooseOpt("Choose:", opts) { + case 0: + changeTunnelPort(name, spec) + case 1: + changeTunnelTransport(name, spec) + case 2: + changeTunnelPreset(name, spec) + case 3: + if needsTLS(spec.Transport) { + editCertificate(name, spec) + } + default: + return false + } + return true +} + // changeTunnelPort prompts for and applies a new tunnel (control) port. func changeTunnelPort(name string, spec TunnelSpec) { cur := addrPort(spec.BindAddr) - if spec.Role == "client" { + if !spec.operationalServer() { cur = addrPort(spec.RemoteAddr) } fmt.Println() @@ -236,7 +332,7 @@ func changeTunnelPort(name string, spec TunnelSpec) { } // Check the protocol the transport actually binds: a UDP-based tunnel is // unaffected by whatever holds the same TCP port, and vice versa. - if spec.Role == "server" && TunnelPortInUse(spec.Transport, port) { + if spec.operationalServer() && TunnelPortInUse(spec.Transport, port) { tui.Error(fmt.Sprintf("Port %s is already in use on this machine.", port)) tui.PressEnter() return @@ -247,7 +343,7 @@ func changeTunnelPort(name string, spec TunnelSpec) { return } tui.Success(fmt.Sprintf("Tunnel port changed to %s and the tunnel was restarted.", port)) - if spec.Role == "server" { + if spec.operationalServer() { tui.Warn("Update the CLIENT side to the same port, or it will not reconnect.") } tui.PressEnter() @@ -299,7 +395,7 @@ func changeTunnelTransport(name string, spec TunnelSpec) { tui.PressEnter() return } - if spec.Role == "server" && needsTLS(newTransport) { + if spec.operationalServer() && needsTLS(newTransport) { tui.Info("A self-signed TLS certificate will be generated automatically if needed.") } if !tui.Confirm(fmt.Sprintf("Switch %q to %s now", name, transportLabel(newTransport)), true) { diff --git a/internal/manage/setup.go b/internal/manage/setup.go index 5bcdb15..ba8455e 100644 --- a/internal/manage/setup.go +++ b/internal/manage/setup.go @@ -5,6 +5,7 @@ import ( "net" "strings" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/app" "github.com/backpack/backpack/internal/optimize" "github.com/backpack/backpack/internal/tui" @@ -84,6 +85,38 @@ func chooseTransport() string { } } +// chooseConnectionMode is deliberately shown after the concrete transport +// choice. Both modes use that exact transport; only the side that initiates +// the tunnel changes. +func chooseConnectionMode(transport string) string { + idx := tui.ChooseOpt("Connection mode:", []tui.Option{ + { + Title: "Direct", + Desc: "Iran initiates the selected " + strings.ToUpper(transport) + " tunnel toward Kharej", + }, + { + Title: "Reverse", + Desc: "Kharej initiates the selected " + strings.ToUpper(transport) + " tunnel toward Iran (legacy)", + }, + }) + switch idx { + case 0: + return "direct" + case 1: + return "reverse" + default: + return "" + } +} + +func forwardTransportReady(transport string) bool { + switch transport { + case "tcp", "stealth", "tcpmux", "kcp", "xdi", "spoof", "quic", "ws", "wss", "wsmux", "wssmux", "udp": + return true + } + return false +} + // choosePreset asks for the performance profile. Turbo is preselected because // it reproduces exactly what earlier versions called "Best Performance". func choosePreset() string { @@ -113,7 +146,7 @@ func applyManualTuning(s *TunnelSpec) { } else { s.LogFormat = "" } - if s.Role == "server" { + if s.operationalServer() { s.ChannelSize = tui.PromptInt("Channel size", s.ChannelSize) if s.Transport == "tcp" { s.AcceptUDP = tui.Confirm("Accept UDP traffic over the TCP transport (accept_udp)", s.AcceptUDP) @@ -319,7 +352,7 @@ func askSpoof(s *TunnelSpec) { // The server cannot learn the client's real address from the forged packets, // so it must be told it. The client already knows the server's real address // from the tunnel address it dialled. - if s.Role == "server" { + if s.operationalServer() { tui.Info("The client forges its source, so the server cannot see where to send") tui.Info("replies. Enter the client's REAL public IPv4 address.") for { @@ -431,15 +464,30 @@ func uniqueName(name string) string { func SetupServer() { tui.Clear() tui.Title("Setup Server") - tui.Warn("Iran side — reverse tunnel that exposes ports on this machine.") + tui.Warn("Iran side — exposes ports with either direct forwarding or a reverse tunnel.") fmt.Println() transport := chooseTransport() if transport == "" { return } - + mode := chooseConnectionMode(transport) + if mode == "" { + return + } s := TunnelSpec{Role: "server", Transport: transport} + if mode == "direct" { + s.Engine = config.EngineForward + // Forward adapters are enabled only after their carrier-specific E2E + // test exists. This avoids writing a service that can start but cannot + // carry the user's traffic while the remaining adapters are developed. + if !forwardTransportReady(transport) { + tui.Warn("Direct direction for " + strings.ToUpper(transport) + " is still being integrated and is not enabled in this build.") + tui.Warn("No configuration was written. Reverse remains available and unchanged.") + tui.PressEnter() + return + } + } port := tui.Prompt("Tunnel (control) port: ") if !validPort(port) { @@ -447,13 +495,23 @@ func SetupServer() { tui.PressEnter() return } - // Binding the IPv6 wildcard accepts IPv4 as well on a normal dual-stack - // host, so this is "IPv6 too" rather than "IPv6 instead". - bind := "0.0.0.0" - if tui.Confirm("Listen on IPv6 as well", false) { - bind = "::" + if mode == "direct" { + remoteHost := strings.TrimSpace(tui.Prompt("Kharej server address (IP or domain): ")) + if remoteHost == "" { + tui.Error("Kharej server address is required.") + tui.PressEnter() + return + } + s.RemoteAddr = net.JoinHostPort(strings.Trim(remoteHost, "[]"), port) + } else { + // Binding the IPv6 wildcard accepts IPv4 as well on a normal dual-stack + // host, so this is "IPv6 too" rather than "IPv6 instead". + bind := "0.0.0.0" + if tui.Confirm("Listen on IPv6 as well", false) { + bind = "::" + } + s.BindAddr = net.JoinHostPort(bind, port) } - s.BindAddr = net.JoinHostPort(bind, port) defaultName := "server-" + port s.Name = uniqueName(tui.PromptDefault("Tunnel name", defaultName)) @@ -487,9 +545,16 @@ func SetupServer() { tui.PressEnter() return } + if mode == "direct" { + if err := validateForwardPortSpecs(s.Ports); err != nil { + tui.Error(err.Error()) + tui.PressEnter() + return + } + } showForwardTargets(s.Ports) - if needsTLS(transport) && !setupServerTLS(&s) { + if mode == "reverse" && needsTLS(transport) && !setupServerTLS(&s) { return } askSimpleAuth(&s, transport) @@ -510,13 +575,21 @@ func SetupServer() { func SetupClient() { tui.Clear() tui.Title("Setup Client") - tui.Warn("Kharej side — reverse tunnel that dials out to the Iran server.") + tui.Warn("Kharej side — listens for Direct, or dials Iran for Reverse.") fmt.Println() transport := chooseTransport() if transport == "" { return } + mode := chooseConnectionMode(transport) + if mode == "" { + return + } + if mode == "direct" { + setupForwardOrigin(transport) + return + } s := TunnelSpec{Role: "client", Transport: transport} @@ -636,9 +709,55 @@ func SetupClient() { finishSetup(s) } +// setupForwardOrigin configures the Kharej listener for Direct mode. The +// geographic name remains "Client" in the UI, but engine=forward makes its +// operational TOML role [server]: it accepts the selected transport and dials +// the local backend named by each Iran-side ingress connection. +func setupForwardOrigin(transport string) { + if !forwardTransportReady(transport) { + tui.Warn("Direct direction for " + strings.ToUpper(transport) + " is still being integrated and is not enabled in this build.") + tui.Warn("No configuration was written. Reverse remains available and unchanged.") + tui.PressEnter() + return + } + + s := TunnelSpec{Role: "client", Transport: transport, Engine: config.EngineForward} + port := tui.Prompt("Tunnel (control) port to listen on: ") + if !validPort(port) { + tui.Error("Invalid port.") + tui.PressEnter() + return + } + bind := "0.0.0.0" + if tui.Confirm("Listen on IPv6 as well", false) { + bind = "::" + } + s.BindAddr = net.JoinHostPort(bind, port) + s.Name = uniqueName(tui.PromptDefault("Tunnel name", "client-"+port)) + tui.Info("Enter the SAME token configured on the Iran server.") + s.Token = tui.PromptDefault("Security token", "backpack") + + if needsTLS(transport) && !setupServerTLS(&s) { + return + } + askSimpleAuth(&s, transport) + askSpoof(&s) + ApplyPreset(&s, choosePreset()) + if tui.Confirm("Fine-tune the advanced settings by hand", false) { + applyManualTuning(&s) + } + finishSetup(s) +} + // finishSetup persists the tunnel, applies system-level tuning, and reports // the result. func finishSetup(s TunnelSpec) { + if err := s.Validate(); err != nil { + tui.Error("Configuration is invalid: " + err.Error()) + tui.Warn("Nothing was installed or changed on the system.") + tui.PressEnter() + return + } tui.Info("Applying system network optimizations...") optimize.ApplyQuiet() diff --git a/internal/manage/setup_flow_test.go b/internal/manage/setup_flow_test.go new file mode 100644 index 0000000..a357116 --- /dev/null +++ b/internal/manage/setup_flow_test.go @@ -0,0 +1,64 @@ +package manage + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +const setupFlowHelperEnv = "BACKPACK_SETUP_FLOW_HELPER" + +// The helper runs in a child process so tui's package-level stdin reader is +// attached to the pipe from process start, exactly like a real terminal run. +func TestSetupFlowHelper(t *testing.T) { + if os.Getenv(setupFlowHelperEnv) != "1" { + return + } + SetupServer() +} + +func runSetupFlow(t *testing.T, input string) string { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestSetupFlowHelper$") + cmd.Env = append(os.Environ(), setupFlowHelperEnv+"=1") + cmd.Stdin = strings.NewReader(input) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("setup helper failed: %v\n%s", err, out) + } + return string(out) +} + +func TestEveryConcreteTransportShowsConnectionMode(t *testing.T) { + for _, tc := range []struct { + name string + family, child int + }{ + {"tcp", 1, 1}, {"tcpmux", 1, 2}, {"stealth", 1, 3}, + {"udp", 2, 1}, {"kcp", 2, 2}, {"quic", 2, 3}, + {"ws", 3, 1}, {"wsmux", 3, 2}, {"wss", 3, 3}, {"wssmux", 3, 4}, + {"xdi", 4, 1}, {"spoof", 4, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + out := runSetupFlow(t, strings.Join([]string{ + string(rune('0' + tc.family)), string(rune('0' + tc.child)), "0", "", + }, "\n")) + if !strings.Contains(out, "Connection mode:") { + t.Fatalf("mode prompt missing after %s selection:\n%s", tc.name, out) + } + }) + } +} + +func TestDirectModePromptPrecedesPortQuestions(t *testing.T) { + out := runSetupFlow(t, "1\n1\n1\nnot-a-port\n\n") + modeAt := strings.Index(out, "Connection mode:") + portAt := strings.Index(out, "Tunnel (control) port:") + if modeAt < 0 || portAt < 0 || modeAt >= portAt { + t.Fatalf("Direct/Reverse must be asked after transport and before ports:\n%s", out) + } + if strings.Contains(out, "Kharej server address") { + t.Fatal("an invalid tunnel port must stop the novice flow before later questions") + } +} diff --git a/internal/manage/status.go b/internal/manage/status.go index 5a5f937..802363e 100644 --- a/internal/manage/status.go +++ b/internal/manage/status.go @@ -67,14 +67,14 @@ func printStatusTable(tunnels []Tunnel) { state := colorPad(tui.Color(color, plainState), plainState, 8) detail := t.Addr - if t.Mode == "direct" { + if t.KernelDirect() { var maps []string for _, m := range t.Mappings { maps = append(maps, fmt.Sprintf("%s:%s->%s:%s", m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts)) } detail = strings.Join(maps, ",") } - if t.Role == "server" && len(t.Ports) > 0 { + if (t.Role == "server" || (t.AppForward() && t.Role == "client")) && len(t.Ports) > 0 { detail = strings.Join(t.Ports, ",") } fmt.Printf("%-16s %-8s %-10s %s %s\n", diff --git a/internal/manage/tunnel.go b/internal/manage/tunnel.go index 9f2009f..4f5d394 100644 --- a/internal/manage/tunnel.go +++ b/internal/manage/tunnel.go @@ -27,6 +27,30 @@ type Tunnel struct { Service string } +// KernelDirect reports the standalone netfilter engine. Application-forward +// instances also have mode=direct, but still have a role, transport and peer; +// treating every direct mode as iptables breaks their management/UI paths. +func (t Tunnel) KernelDirect() bool { return t.Engine == string(config.EngineIPTables) } + +// AppForward reports the application tunnel whose dial direction is reversed. +func (t Tunnel) AppForward() bool { return t.Engine == string(config.EngineForward) } + +// DisplayRole preserves the geographic Server/Client language used by setup: +// Iran is Server and Kharej is Client. EngineForward deliberately swaps the +// operational TOML sections, but exposing that implementation detail in the +// panel and management menu makes the same machine appear to change roles. +func (t Tunnel) DisplayRole() string { + if t.AppForward() { + switch t.Role { + case "client": + return "server" + case "server": + return "client" + } + } + return t.Role +} + // List scans the config directory and returns all tunnels, sorted by name. func List() []Tunnel { var tunnels []Tunnel @@ -54,6 +78,9 @@ func List() []Tunnel { t.Role = "client" t.Transport = string(cfg.Client.Transport) t.Addr = cfg.Client.RemoteAddr + if cfg.EffectiveEngine() == config.EngineForward { + t.Ports = append([]string(nil), cfg.Client.Ports...) + } default: continue } diff --git a/internal/manage/validate.go b/internal/manage/validate.go index 9e030a0..e637714 100644 --- a/internal/manage/validate.go +++ b/internal/manage/validate.go @@ -6,6 +6,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/backpack/backpack/internal/forwardmap" ) // validPort reports whether s is a valid TCP/UDP port number. @@ -85,3 +87,10 @@ func validatePortSpecs(ports []string) error { } return nil } + +func validateForwardPortSpecs(ports []string) error { + if _, err := forwardmap.Expand(ports); err != nil { + return fmt.Errorf("invalid Direct mapping: %w", err) + } + return nil +} diff --git a/internal/manage/watchdog.go b/internal/manage/watchdog.go index 674bcc5..10d1c1b 100644 --- a/internal/manage/watchdog.go +++ b/internal/manage/watchdog.go @@ -111,6 +111,12 @@ func directDesiredStateHealthy(ctx context.Context, t Tunnel) bool { // tunnelHealthy reports whether a running tunnel currently has its connection up, // based on the established TCP sockets in `pairs` ([local, peer] address pairs). func tunnelHealthy(t Tunnel, pairs [][2]string) bool { + if t.AppForward() && isRawDatagram(t.Transport) && t.Role == "client" { + if connected, known := datagramServerPeer(app.ConfigDir, t.Name); known { + return connected + } + return true // not observable yet: avoid a startup restart loop + } // UDP-based transports (udp, kcp) hold no TCP sockets at all, so the TCP // table says nothing about them. // diff --git a/internal/menu/menu.go b/internal/menu/menu.go index 037f523..8ae156b 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -65,22 +65,20 @@ func Run() { case "2": manage.SetupClient() case "3": - manage.SetupDirect() - case "4": manageMenu() - case "5": + case "4": backupMenu() - case "6": + case "5": webPanelMenu() - case "7": + case "6": optimizeMenu() - case "8": + case "7": telegramMenu() - case "9": + case "8": updateMenu() - case "10": + case "9": uninstallMenu() - case "11", "0": + case "10", "11", "0": tui.Info("Goodbye!") return default: @@ -98,28 +96,27 @@ func printUpdateBanner() { if !ok { return } - fmt.Printf(" %s⬆ %s is available%s %s— option 9 to update safely%s\n", + fmt.Printf(" %s⬆ %s is available%s %s— option 8 to update safely%s\n", tui.Bold+tui.Red, tag, tui.Reset, tui.Gray, tui.Reset) } // printMenu renders the main menu: red numbers, white titles, gray descriptions. func printMenu() { fmt.Println() - menuItem(1, "Setup Server", "Iran side — exposes ports to users") - menuItem(2, "Setup Client", "Kharej side — dials out to the Iran server") - menuItem(3, "Setup Direct", "iptables direct forward — IPv4/IPv6, TCP/UDP") - menuItem(4, "Manage", "instances, ports, transport, status, health check") - menuItem(5, "Backup & Restore", "save or restore the full configuration") - menuItem(6, "Web Panel", "monitoring web UI — link, login code, port") - menuItem(7, "Optimize", "kernel & network tuning — BBR, buffers, limits") - menuItem(8, "Telegram Bot", "status reports, relayed through a tunnel") + menuItem(1, "Setup Server", "Iran side — exposes ports; Direct dials Kharej") + menuItem(2, "Setup Client", "Kharej side — Direct accepts, Reverse dials Iran") + menuItem(3, "Manage", "instances, ports, transport, status, health check") + menuItem(4, "Backup & Restore", "save or restore the full configuration") + menuItem(5, "Web Panel", "monitoring web UI — link, login code, port") + menuItem(6, "Optimize", "kernel & network tuning — BBR, buffers, limits") + menuItem(7, "Telegram Bot", "status reports, relayed through a tunnel") updateDesc := "safe update with automatic rollback" if tag, ok := manage.UpdateAvailable(); ok { updateDesc = tag + " is out — safe update with automatic rollback" } - menuItem(9, "Update", updateDesc) - menuItem(10, "Uninstall", "remove everything") - menuItem(11, "Exit", "") + menuItem(8, "Update", updateDesc) + menuItem(9, "Uninstall", "remove everything") + menuItem(10, "Exit", "") fmt.Println() } @@ -1002,7 +999,7 @@ func restorePointMenu() { tui.PressEnter() } -// uninstallMenu is main-menu item 10. +// uninstallMenu is main-menu item 9. func uninstallMenu() { tui.Clear() tui.Title("Uninstall Backpack") diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 7e6bb6b..8bfb0fc 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -133,7 +133,10 @@ func Path(dir, name string) string { type Collector struct { // baseIn/baseOut are the totals this tunnel had already accumulated before // this process started, read from the last written snapshot. - baseIn, baseOut uint64 + baseIn, baseOut uint64 + basePacketsIn, basePacketsOut uint64 + startIn, startOut uint64 + startPacketsIn, startPacketsOut uint64 dir string name string @@ -159,6 +162,8 @@ func NewCollector(dir, name, transport, role string, bytesIn, bytesOut func() ui bytesIn: bytesIn, bytesOut: bytesOut, } + c.startIn, c.startOut = Traffic() + c.startPacketsIn, c.startPacketsOut = TrafficPackets() // Carry on from whatever this tunnel had already moved. // // The live counters only know about this process, so without a baseline the @@ -172,6 +177,7 @@ func NewCollector(dir, name, transport, role string, bytesIn, bytesOut func() ui // again on the new machine. if prev, err := Read(dir, name); err == nil { c.baseIn, c.baseOut = prev.BytesIn, prev.BytesOut + c.basePacketsIn, c.basePacketsOut = prev.PacketsIn, prev.PacketsOut } return c } @@ -188,7 +194,10 @@ func (c *Collector) Snapshot() Snapshot { } // The persisted baseline plus what this process has carried. liveIn, liveOut := Traffic() - s.BytesIn, s.BytesOut = c.baseIn+liveIn, c.baseOut+liveOut + s.BytesIn, s.BytesOut = c.baseIn+(liveIn-c.startIn), c.baseOut+(liveOut-c.startOut) + livePacketsIn, livePacketsOut := TrafficPackets() + s.PacketsIn = c.basePacketsIn + (livePacketsIn - c.startPacketsIn) + s.PacketsOut = c.basePacketsOut + (livePacketsOut - c.startPacketsOut) if c.bytesIn != nil { s.BytesIn = c.baseIn + c.bytesIn() } @@ -276,8 +285,10 @@ func Read(dir, name string) (Snapshot, error) { // whichever side of the tunnel this process is. One tunnel runs per process, so // package-level totals describe exactly this tunnel. var ( - bytesIn atomic.Uint64 - bytesOut atomic.Uint64 + bytesIn atomic.Uint64 + bytesOut atomic.Uint64 + packetsIn atomic.Uint64 + packetsOut atomic.Uint64 ) // CountedConn wraps a tunnel connection so its traffic is recorded. @@ -293,6 +304,7 @@ func (c *countedConn) Read(b []byte) (int, error) { n, err := c.Conn.Read(b) if n > 0 { bytesIn.Add(uint64(n)) + packetsIn.Add(1) } return n, err } @@ -301,6 +313,7 @@ func (c *countedConn) Write(b []byte) (int, error) { n, err := c.Conn.Write(b) if n > 0 { bytesOut.Add(uint64(n)) + packetsOut.Add(1) } return n, err } @@ -326,11 +339,18 @@ func Uncount(c net.Conn) (net.Conn, bool) { func AddBytes(in, out uint64) { if in > 0 { bytesIn.Add(in) + packetsIn.Add(1) } if out > 0 { bytesOut.Add(out) + packetsOut.Add(1) } } // Traffic returns the bytes carried over the tunnel so far. func Traffic() (in, out uint64) { return bytesIn.Load(), bytesOut.Load() } + +// TrafficPackets returns transport-level read/write units. For datagrams and +// websocket messages these are real messages; for streams they are successful +// relay reads/writes (kernel packet counts are not exposed to the process). +func TrafficPackets() (in, out uint64) { return packetsIn.Load(), packetsOut.Load() } diff --git a/internal/metrics/persist_test.go b/internal/metrics/persist_test.go index 129d79d..4529f98 100644 --- a/internal/metrics/persist_test.go +++ b/internal/metrics/persist_test.go @@ -15,12 +15,38 @@ func resetCounters(t *testing.T) { t.Helper() bytesIn.Store(0) bytesOut.Store(0) + packetsIn.Store(0) + packetsOut.Store(0) t.Cleanup(func() { bytesIn.Store(0) bytesOut.Store(0) + packetsIn.Store(0) + packetsOut.Store(0) }) } +func TestCollectorReloadInSameProcessDoesNotDoubleCount(t *testing.T) { + resetCounters(t) + dir := t.TempDir() + first := NewCollector(dir, "t", "tcp", "server", nil, nil) + AddBytes(1000, 500) + if err := first.Write(); err != nil { + t.Fatal(err) + } + + // Config reloads happen inside the same process, so the package counters do + // not reset. The new collector must begin at their current offset. + second := NewCollector(dir, "t", "tcp", "server", nil, nil) + if got := second.Snapshot(); got.BytesIn != 1000 || got.BytesOut != 500 { + t.Fatalf("same-process reload doubled totals: %#v", got) + } + AddBytes(200, 100) + got := second.Snapshot() + if got.BytesIn != 1200 || got.BytesOut != 600 || got.PacketsIn != 2 || got.PacketsOut != 2 { + t.Fatalf("reload totals did not continue monotonically: %#v", got) + } +} + func TestTrafficResumesAfterRestart(t *testing.T) { resetCounters(t) dir := t.TempDir() diff --git a/internal/server/server.go b/internal/server/server.go index 2bc9fc0..597b2b2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,10 +22,11 @@ import ( const acmeCacheDir = "/etc/backpack/acme" type Server struct { - config *config.ServerConfig - ctx context.Context - cancel context.CancelFunc - logger *logrus.Logger + config *config.ServerConfig + forward bool + ctx context.Context + cancel context.CancelFunc + logger *logrus.Logger } func NewServer(cfg *config.ServerConfig, parentCtx context.Context) *Server { @@ -43,6 +44,16 @@ func NewServer(cfg *config.ServerConfig, parentCtx context.Context) *Server { } } +// NewForwardOrigin builds the listening Kharej half of an application-level +// forward tunnel. It uses the same transport listener and authentication as a +// reverse server, but accepted data channels are dialled into local backends +// instead of being paired with public ingress sockets. +func NewForwardOrigin(cfg *config.ServerConfig, parentCtx context.Context) *Server { + s := NewServer(cfg, parentCtx) + s.forward = true + return s +} + func (s *Server) Start() { // Profiling endpoint, off unless explicitly enabled in the config. // @@ -82,6 +93,7 @@ func (s *Server) Start() { // Stealth is the TCP transport with a Noise record layer over every // tunnel connection; everything else about it is identical. Stealth: s.config.Transport == config.STEALTH, + Forward: s.forward, } tcpServer := transport.NewTCPServer(s.ctx, tcpConfig, s.logger) @@ -129,6 +141,7 @@ func (s *Server) Start() { SpoofSrcPool: s.config.SpoofSrcPool, SpoofPeerIP: s.config.SpoofPeerIP, SpoofInterface: s.config.SpoofInterface, + Forward: s.forward, } kcpServer := transport.NewKcpServer(s.ctx, kcpConfig, s.logger) @@ -150,6 +163,7 @@ func (s *Server) Start() { ProxyProtocol: s.config.ProxyProtocol, MaxConnections: s.config.MaxConnections, BandwidthMbps: s.config.BandwidthMbps, + Forward: s.forward, } quicServer := transport.NewQuicServer(s.ctx, quicConfig, s.logger) @@ -178,6 +192,7 @@ func (s *Server) Start() { ProxyProtocol: s.config.ProxyProtocol, MaxConnections: s.config.MaxConnections, BandwidthMbps: s.config.BandwidthMbps, + Forward: s.forward, } tcpMuxServer := transport.NewTcpMuxServer(s.ctx, tcpMuxConfig, s.logger) @@ -205,6 +220,7 @@ func (s *Server) Start() { MaxConnections: s.config.MaxConnections, BandwidthMbps: s.config.BandwidthMbps, + Forward: s.forward, } wsServer := transport.NewWSServer(s.ctx, wsConfig, s.logger) @@ -237,6 +253,7 @@ func (s *Server) Start() { ProxyProtocol: s.config.ProxyProtocol, MaxConnections: s.config.MaxConnections, BandwidthMbps: s.config.BandwidthMbps, + Forward: s.forward, } wsMuxServer := transport.NewWSMuxServer(s.ctx, wsMuxConfig, s.logger) @@ -254,6 +271,7 @@ func (s *Server) Start() { SnifferLog: s.config.SnifferLog, SO_RCVBUF: s.config.SO_RCVBUF, SO_SNDBUF: s.config.SO_SNDBUF, + Forward: s.forward, } udpServer := transport.NewUDPServer(s.ctx, udpConfig, s.logger) diff --git a/internal/server/transport/forward.go b/internal/server/transport/forward.go new file mode 100644 index 0000000..dc62595 --- /dev/null +++ b/internal/server/transport/forward.go @@ -0,0 +1,80 @@ +package transport + +import ( + "context" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/backpack/backpack/internal/metrics" + "github.com/backpack/backpack/internal/utils" + "github.com/backpack/backpack/internal/utils/handlers" + "github.com/backpack/backpack/internal/utils/network" + "github.com/backpack/backpack/internal/web" + "github.com/sirupsen/logrus" +) + +var forwardBackendCursor sync.Map // map[canonical backend list]*atomic.Uint64 + +// dialForwardTCPBackend load-balances across the same pipe-separated backend +// syntax supported by reverse tunnels. A failed member is skipped immediately; +// the next user connection starts at the next member, so healthy backends share +// load without a dead member black-holing the ingress. +func dialForwardTCPBackend(ctx context.Context, target string, keepAlive time.Duration) (net.Conn, int, string, error) { + firstPort, resolved, err := network.ResolveRemoteAddr(target) + if err != nil { + return nil, 0, "", err + } + parts := strings.Split(resolved, "|") + cursorAny, _ := forwardBackendCursor.LoadOrStore(resolved, &atomic.Uint64{}) + start := int(cursorAny.(*atomic.Uint64).Add(1)-1) % len(parts) + var lastErr error + for i := 0; i < len(parts); i++ { + candidate := strings.TrimSpace(parts[(start+i)%len(parts)]) + port, _, err := network.ResolveRemoteAddr(candidate) + if err != nil { + lastErr = err + continue + } + dialer := net.Dialer{Timeout: 10 * time.Second, KeepAlive: keepAlive} + backend, err := dialer.DialContext(ctx, "tcp", candidate) + if err == nil { + return backend, port, candidate, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("no backend candidates") + } + return nil, firstPort, "", lastErr +} + +// handleForwardStream is shared by every stream-oriented carrier. The carrier +// has already authenticated the data connection/session; this function reads +// the requested Kharej backend, dials it, acknowledges readiness, and relays. +func handleForwardStream(ctx context.Context, stream net.Conn, keepAlive time.Duration, logger *logrus.Logger, usage *web.Usage, sniffer bool) { + target, err := utils.ReceiveBinaryString(stream) + if err != nil { + logger.Warnf("invalid forward target from %s: %v", stream.RemoteAddr(), err) + _ = utils.SendBinaryByte(stream, utils.SG_ForwardError) + stream.Close() + return + } + backend, port, resolved, err := dialForwardTCPBackend(ctx, target, keepAlive) + if err != nil { + logger.Warnf("invalid forward backend %q: %v", target, err) + _ = utils.SendBinaryByte(stream, utils.SG_ForwardError) + stream.Close() + return + } + if err := utils.SendBinaryByte(stream, utils.SG_ForwardOK); err != nil { + backend.Close() + stream.Close() + return + } + logger.Debugf("forward data channel connected to backend %s", resolved) + handlers.TCPConnectionHandler(ctx, false, metrics.CountedConn(stream), backend, logger, usage, port, sniffer) +} diff --git a/internal/server/transport/kcp.go b/internal/server/transport/kcp.go index f297c1c..9dc3482 100644 --- a/internal/server/transport/kcp.go +++ b/internal/server/transport/kcp.go @@ -33,6 +33,7 @@ type kcpGen struct { localChannel chan LocalTCPConn reqNewConnChan chan struct{} usageMonitor *web.Usage + forwardReady chan struct{} } // KcpTransport is the server side of the KCP transport: a reliable, @@ -44,23 +45,26 @@ type kcpGen struct { // or a path where the return route is asymmetric. Forward error correction // repairs losses without waiting a full round trip for a retransmit. type KcpTransport struct { - config *KcpConfig - smuxConfig *smux.Config - kcpSettings network.KCPSettings - parentctx context.Context - ctx context.Context - cancel context.CancelFunc - logger *logrus.Logger - tunnelChannel chan *smux.Session - handshakeChannel chan net.Conn - localChannel chan LocalTCPConn - reqNewConnChan chan struct{} - controlChannel netControl - usageMonitor *web.Usage - restartMutex sync.Mutex - streamCounter int32 - sessionCounter int32 - limits *limiter + config *KcpConfig + smuxConfig *smux.Config + kcpSettings network.KCPSettings + parentctx context.Context + ctx context.Context + cancel context.CancelFunc + logger *logrus.Logger + tunnelChannel chan *smux.Session + handshakeChannel chan net.Conn + localChannel chan LocalTCPConn + reqNewConnChan chan struct{} + controlChannel netControl + usageMonitor *web.Usage + restartMutex sync.Mutex + streamCounter int32 + sessionCounter int32 + limits *limiter + forwardReady chan struct{} + forwardMu sync.Mutex + forwardRawSession *smux.Session } type KcpConfig struct { @@ -110,6 +114,7 @@ type KcpConfig struct { SpoofSrcPool []string SpoofPeerIP string SpoofInterface string + Forward bool } // transportLabel is what the panel and logs call this transport — XDI when it @@ -125,6 +130,10 @@ func (s *KcpTransport) transportLabel() string { return "KCP" } +func (s *KcpTransport) rawForward() bool { + return s.config.Forward && (s.config.UseICMP || s.config.UseSpoof) +} + func (c *KcpConfig) settings() network.KCPSettings { s := network.KCPSettings{ MTU: c.MTU, @@ -180,6 +189,7 @@ func NewKcpServer(parentCtx context.Context, config *KcpConfig, logger *logrus.L reqNewConnChan: make(chan struct{}, config.ChannelSize), usageMonitor: web.NewDataStore(fmt.Sprintf(":%v", config.WebPort), ctx, config.SnifferLog, config.Sniffer, &config.TunnelStatus, logger), limits: newLimiter(Limits{MaxConnections: config.MaxConnections, BandwidthMbps: config.BandwidthMbps}), + forwardReady: make(chan struct{}, 1), } } @@ -195,6 +205,7 @@ func (s *KcpTransport) Start() { localChannel: s.localChannel, reqNewConnChan: s.reqNewConnChan, usageMonitor: s.usageMonitor, + forwardReady: s.forwardReady, } if s.config.WebPort > 0 { @@ -203,11 +214,23 @@ func (s *KcpTransport) Start() { s.config.TunnelStatus = "Disconnected (" + s.transportLabel() + ")" go s.tunnelListener(g) + if s.rawForward() { + select { + case <-g.forwardReady: + s.config.TunnelStatus = "Connected (" + s.transportLabel() + ")" + case <-g.ctx.Done(): + } + return + } s.channelHandshake(g) if s.controlChannel.IsSet() { s.config.TunnelStatus = "Connected (" + s.transportLabel() + ")" + go s.channelHandler(g) + if s.config.Forward { + return + } numCPU := runtime.NumCPU() if numCPU > 4 { @@ -215,8 +238,6 @@ func (s *KcpTransport) Start() { } go s.parsePortMappings(g) - go s.channelHandler(g) - s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) for i := 0; i < numCPU; i++ { @@ -244,6 +265,12 @@ func (s *KcpTransport) Restart() { if s.controlChannel.IsSet() { s.controlChannel.Close() } + s.forwardMu.Lock() + if s.forwardRawSession != nil { + _ = s.forwardRawSession.Close() + s.forwardRawSession = nil + } + s.forwardMu.Unlock() time.Sleep(2 * time.Second) @@ -268,6 +295,7 @@ func (s *KcpTransport) Restart() { s.tunnelChannel = make(chan *smux.Session, s.config.ChannelSize) s.localChannel = make(chan LocalTCPConn, s.config.ChannelSize) s.reqNewConnChan = make(chan struct{}, s.config.ChannelSize) + s.forwardReady = make(chan struct{}, 1) s.handshakeChannel = make(chan net.Conn) s.controlChannel.Clear() // The peer is gone until a new control channel arrives; a stale address @@ -452,6 +480,20 @@ func (s *KcpTransport) acceptSession(g *kcpGen, session *kcp.UDPSession) { } // The control channel carries small, latency-critical signals. session.SetACKNoDelay(true) + if s.rawForward() { + muxSession, err := smux.Client(session, s.smuxConfig) + if err != nil { + session.Close() + return + } + metrics.ReportPeer(session.RemoteAddr().String()) + select { + case g.forwardReady <- struct{}{}: + default: + } + s.adoptRawForwardSession(g, muxSession) + return + } // A control claim while one is already established means the client // restarted on its own and re-dialed, while this run never noticed @@ -498,12 +540,54 @@ func (s *KcpTransport) acceptSession(g *kcpGen, session *kcp.UDPSession) { muxSession.Close() } + case utils.SG_ForwardTCP: + if !s.config.Forward || !s.controlChannel.IsSet() { + s.logger.Warnf("invalid forward KCP session from %s", session.RemoteAddr()) + session.Close() + return + } + muxSession, err := smux.Client(session, s.smuxConfig) + if err != nil { + session.Close() + return + } + go s.handleForwardKCPSession(g, muxSession) + default: s.logger.Warnf("unexpected announcement signal %v from %s", signal, session.RemoteAddr()) session.Close() } } +func (s *KcpTransport) handleForwardKCPSession(g *kcpGen, session *smux.Session) { + defer session.Close() + for { + stream, err := session.AcceptStream() + if err != nil { + return + } + go handleForwardStream(g.ctx, stream, 75*time.Second, s.logger, g.usageMonitor, s.config.Sniffer) + } +} + +func (s *KcpTransport) adoptRawForwardSession(g *kcpGen, session *smux.Session) { + s.forwardMu.Lock() + old := s.forwardRawSession + s.forwardRawSession = session + s.forwardMu.Unlock() + if old != nil && old != session { + _ = old.Close() + } + go func() { + s.handleForwardKCPSession(g, session) + s.forwardMu.Lock() + if s.forwardRawSession == session { + s.forwardRawSession = nil + } + s.forwardMu.Unlock() + }() +} + func (s *KcpTransport) parsePortMappings(g *kcpGen) { for _, portMapping := range s.config.Ports { parts := strings.Split(portMapping, "=") diff --git a/internal/server/transport/quic.go b/internal/server/transport/quic.go index 46cd16f..22f40f4 100644 --- a/internal/server/transport/quic.go +++ b/internal/server/transport/quic.go @@ -71,6 +71,7 @@ type QuicConfig struct { MaxConnections int // BandwidthMbps caps total tunnel throughput (0 = unlimited). BandwidthMbps int + Forward bool } func (c *QuicConfig) settings() network.QUICSettings { @@ -143,6 +144,10 @@ func (s *QuicTransport) Start() { } s.config.TunnelStatus = "Connected (QUIC)" + go s.channelHandler(g) + if s.config.Forward { + return + } numCPU := runtime.NumCPU() if numCPU > 4 { @@ -150,8 +155,6 @@ func (s *QuicTransport) Start() { } go s.parsePortMappings(g) - go s.channelHandler(g) - s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) for i := 0; i < numCPU; i++ { go s.handleLoop(g) @@ -326,6 +329,14 @@ func (s *QuicTransport) acceptStream(g *quicGen, conn *quic.Conn, stream *quic.S stream.Close() } + case utils.SG_ForwardTCP: + if !s.config.Forward || !s.controlChannel.IsSet() { + s.logger.Warnf("invalid forward QUIC stream from %s", conn.RemoteAddr()) + stream.Close() + return + } + go handleForwardStream(g.ctx, wrapped, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer) + default: s.logger.Warnf("unexpected announcement signal %v from %s", signal, conn.RemoteAddr()) stream.Close() diff --git a/internal/server/transport/tcp.go b/internal/server/transport/tcp.go index acc9ba4..54d5c22 100644 --- a/internal/server/transport/tcp.go +++ b/internal/server/transport/tcp.go @@ -78,6 +78,10 @@ type TcpConfig struct { // Stealth wraps every accepted tunnel connection in the Noise record layer, // so the stream has no fingerprint for deep packet inspection to match. Stealth bool + // Forward makes this listener the Kharej origin of a forward tunnel. The + // control channel is unchanged; data connections are opened by the Iran + // peer and carry their backend target immediately. + Forward bool } func NewTCPServer(parentCtx context.Context, config *TcpConfig, logger *logrus.Logger) *TcpTransport { @@ -132,6 +136,14 @@ func (s *TcpTransport) Start() { if s.controlChannel.IsSet() { s.config.TunnelStatus = "Connected (TCP)" + go s.channelHandler(g) + + if s.config.Forward { + // The forward origin owns no public ingress ports and never asks the + // dialler for pool connections. Each Iran-side ingress opens and + // authenticates its own data connection when needed. + return + } numCPU := runtime.NumCPU() if numCPU > 4 { @@ -139,8 +151,6 @@ func (s *TcpTransport) Start() { } go s.parsePortMappings(g) - go s.channelHandler(g) - s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) for i := 0; i < numCPU; i++ { @@ -444,12 +454,33 @@ func (s *TcpTransport) admitTunnelConn(g *tcpGen, raw net.Conn) { } s.deliverTunnelConn(g, conn) + case ann.signal == utils.SG_ForwardTCP: + if !s.config.Forward { + s.logger.Warnf("forward data connection sent to a reverse instance from %s", conn.RemoteAddr()) + conn.Close() + return + } + if !s.controlChannel.IsSet() || !s.poolNonce.Verify(ann.payload) { + s.logger.Warnf("forward data connection from %s presented an invalid run nonce", conn.RemoteAddr()) + conn.Close() + return + } + go s.handleForwardTCP(g, conn) + default: s.logger.Warnf("unexpected announcement %d from %s, discarding", ann.signal, conn.RemoteAddr()) conn.Close() } } +// handleForwardTCP completes the forward-open handshake, dials the backend on +// this Kharej machine, and only then acknowledges the Iran-side ingress. The +// target is bounded by the binary framing and is resolved with the same rules +// as reverse mode (a bare port means 127.0.0.1:). +func (s *TcpTransport) handleForwardTCP(g *tcpGen, tunnelConn net.Conn) { + handleForwardStream(g.ctx, tunnelConn, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer) +} + // admitControlChannel verifies a peer claiming the control channel, answers it, // and offers it as the candidate for channelHandshake to publish. func (s *TcpTransport) admitControlChannel(g *tcpGen, conn net.Conn, ann announcement) { diff --git a/internal/server/transport/tcpmux.go b/internal/server/transport/tcpmux.go index 34de84c..43e6d8a 100644 --- a/internal/server/transport/tcpmux.go +++ b/internal/server/transport/tcpmux.go @@ -87,6 +87,7 @@ type TcpMuxConfig struct { MaxConnections int // BandwidthMbps caps total tunnel throughput (0 = unlimited). BandwidthMbps int + Forward bool } // setMuxVersion records the version this run agreed on. A legacy client cannot @@ -169,6 +170,10 @@ func (s *TcpMuxTransport) Start() { if s.controlChannel.IsSet() { s.config.TunnelStatus = "Connected (TCPMux)" + go s.channelHandler(g) + if s.config.Forward { + return + } numCPU := runtime.NumCPU() if numCPU > 4 { @@ -176,8 +181,6 @@ func (s *TcpMuxTransport) Start() { } go s.parsePortMappings(g) - go s.channelHandler(g) - s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) for i := 0; i < numCPU; i++ { @@ -462,12 +465,36 @@ func (s *TcpMuxTransport) admitTunnelConn(g *tcpMuxGen, conn net.Conn) { } s.deliverTunnelConn(g, conn) + case ann.signal == utils.SG_ForwardTCP: + if !s.config.Forward || !s.controlChannel.IsSet() || !s.poolNonce.Verify(ann.payload) { + s.logger.Warnf("invalid forward mux session from %s", conn.RemoteAddr()) + conn.Close() + return + } + go s.handleForwardMuxConn(g, conn) + default: s.logger.Warnf("unexpected announcement %d from %s, discarding", ann.signal, conn.RemoteAddr()) conn.Close() } } +func (s *TcpMuxTransport) handleForwardMuxConn(g *tcpMuxGen, conn net.Conn) { + session, err := smux.Client(conn, s.smuxCfg()) + if err != nil { + conn.Close() + return + } + defer session.Close() + for { + stream, err := session.AcceptStream() + if err != nil { + return + } + go handleForwardStream(g.ctx, stream, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer) + } +} + // admitControlChannel verifies a peer claiming the control channel, answers it, // and offers it as the candidate for channelHandshake to publish. func (s *TcpMuxTransport) admitControlChannel(g *tcpMuxGen, conn net.Conn, ann announcement) { diff --git a/internal/server/transport/udp.go b/internal/server/transport/udp.go index 3f791e8..f7c4489 100644 --- a/internal/server/transport/udp.go +++ b/internal/server/transport/udp.go @@ -10,6 +10,7 @@ import ( "time" "github.com/backpack/backpack/internal/utils" + "github.com/backpack/backpack/internal/utils/network" "github.com/backpack/backpack/internal/web" "github.com/sirupsen/logrus" ) @@ -58,6 +59,7 @@ type UdpConfig struct { // keeps the tunnel carrying traffic under load instead of stalling. SO_RCVBUF int SO_SNDBUF int + Forward bool } func NewUDPServer(parentCtx context.Context, config *UdpConfig, logger *logrus.Logger) *UdpTransport { @@ -222,7 +224,9 @@ func (s *UdpTransport) channelHandshake(g *udpGen) { established = true go s.tunnelListener(g) - go s.parsePortMappings(g) + if !s.config.Forward { + go s.parsePortMappings(g) + } go s.channelHandler(g) } } @@ -422,7 +426,15 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) { s.activeMu.Unlock() - if string(buf[:n]) != s.config.Token { // For new connections, validate the token + forwardTarget := "" + if s.config.Forward { + token, target, err := utils.DecodeForwardUDP(buf[:n]) + if err != nil || !tokenMatches(token, s.config.Token) { + s.logger.Errorf("invalid forward UDP announcement from %s", addr.String()) + continue + } + forwardTarget = target + } else if string(buf[:n]) != s.config.Token { // For new reverse connections, validate the token s.logger.Errorf("invalid token received from %s", addr.String()) continue } @@ -445,7 +457,12 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) { s.activeConnections[key] = &tunnelConn s.activeMu.Unlock() - // Send the new tunnel connection to the tunnel channel + if s.config.Forward { + go s.handleForwardUDP(g, &tunnelConn, forwardTarget) + continue + } + + // Send the new reverse tunnel connection to the tunnel channel select { case g.tunnelChannel <- &tunnelConn: go s.keepAlive(g, &tunnelConn) @@ -460,6 +477,76 @@ func (s *UdpTransport) acceptTunnelConn(g *udpGen, listener *net.UDPConn) { } } +func (s *UdpTransport) handleForwardUDP(g *udpGen, tunnel *TunnelUDPConn, target string) { + defer func() { + s.activeMu.Lock() + key := tunnel.addr.String() + if s.activeConnections[key] == tunnel { + delete(s.activeConnections, key) + close(tunnel.payload) + } + s.activeMu.Unlock() + }() + _, resolved, err := network.ResolveRemoteAddr(target) + if err != nil { + _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr) + return + } + // UDP has no connection probe that proves an application is healthy. Match + // reverse mode's documented behaviour and use the first configured backend. + resolved = strings.TrimSpace(strings.Split(resolved, "|")[0]) + backendAddr, err := net.ResolveUDPAddr("udp", resolved) + if err != nil { + _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr) + return + } + backend, err := net.DialUDP("udp", nil, backendAddr) + if err != nil { + _, _ = tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardError}, tunnel.addr) + return + } + s.applyBuffers(backend) + defer backend.Close() + if _, err := tunnel.listener.WriteToUDP([]byte{utils.SG_ForwardOK}, tunnel.addr); err != nil { + return + } + + done := make(chan struct{}) + go func() { + defer close(done) + for { + select { + case <-g.ctx.Done(): + return + case payload, ok := <-tunnel.payload: + if !ok { + return + } + _ = backend.SetWriteDeadline(time.Now().Add(60 * time.Second)) + if _, err := backend.Write(payload); err != nil { + return + } + } + } + }() + buf := make([]byte, 64*1024) + for { + _ = backend.SetReadDeadline(time.Now().Add(60 * time.Second)) + n, err := backend.Read(buf) + if err != nil { + return + } + if _, err := tunnel.listener.WriteToUDP(buf[:n], tunnel.addr); err != nil { + return + } + select { + case <-done: + return + default: + } + } +} + func (s *UdpTransport) parsePortMappings(g *udpGen) { for _, portMapping := range s.config.Ports { parts := strings.Split(portMapping, "=") diff --git a/internal/server/transport/ws.go b/internal/server/transport/ws.go index ec403e4..3edfa4b 100644 --- a/internal/server/transport/ws.go +++ b/internal/server/transport/ws.go @@ -72,6 +72,7 @@ type WsConfig struct { MaxConnections int // BandwidthMbps caps total tunnel throughput (0 = unlimited). BandwidthMbps int + Forward bool } func NewWSServer(parentCtx context.Context, config *WsConfig, logger *logrus.Logger) *WsTransport { @@ -299,17 +300,29 @@ func (s *WsTransport) tunnelListener(g *wsGen) { } go s.channelHandler(g) - go s.parsePortMappings(g) + if !s.config.Forward { + go s.parsePortMappings(g) + } s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) - for i := 0; i < numCPU; i++ { - go s.handleLoop(g) + if !s.config.Forward { + for i := 0; i < numCPU; i++ { + go s.handleLoop(g) + } } s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode) } else if strings.HasPrefix(r.URL.Path, "/tunnel") { + if s.config.Forward { + if !s.controlChannel.IsSet() { + conn.Close() + return + } + go s.handleForwardWS(g, conn) + return + } wsConn := TunnelChannel{ conn: conn, ping: make(chan struct{}), @@ -375,6 +388,29 @@ func (s *WsTransport) tunnelListener(g *wsGen) { } +func (s *WsTransport) handleForwardWS(g *wsGen, conn *websocket.Conn) { + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + _, payload, err := conn.ReadMessage() + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + conn.Close() + return + } + target := string(payload) + backend, port, _, err := dialForwardTCPBackend(g.ctx, target, s.config.KeepAlive) + if err != nil { + _ = conn.WriteMessage(websocket.BinaryMessage, []byte{utils.SG_ForwardError}) + conn.Close() + return + } + if err := conn.WriteMessage(websocket.BinaryMessage, []byte{utils.SG_ForwardOK}); err != nil { + backend.Close() + conn.Close() + return + } + handlers.WSConnectionHandler(g.ctx, conn, backend, s.logger, g.usageMonitor, port, s.config.Sniffer) +} + func (s *WsTransport) parsePortMappings(g *wsGen) { for _, portMapping := range s.config.Ports { parts := strings.Split(portMapping, "=") diff --git a/internal/server/transport/wsmux.go b/internal/server/transport/wsmux.go index df96226..c4ed889 100644 --- a/internal/server/transport/wsmux.go +++ b/internal/server/transport/wsmux.go @@ -83,6 +83,7 @@ type WsMuxConfig struct { MaxConnections int // BandwidthMbps caps total tunnel throughput (0 = unlimited). BandwidthMbps int + Forward bool } func NewWSMuxServer(parentCtx context.Context, config *WsMuxConfig, logger *logrus.Logger) *WsMuxTransport { @@ -327,12 +328,16 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) { } go s.channelHandler(g) - go s.parsePortMappings(g) + if !s.config.Forward { + go s.parsePortMappings(g) + } s.logger.Infof("starting %d handle loops on each CPU thread", numCPU) - for i := 0; i < numCPU; i++ { - go s.handleLoop(g) + if !s.config.Forward { + for i := 0; i < numCPU; i++ { + go s.handleLoop(g) + } } s.config.TunnelStatus = fmt.Sprintf("Connected (%s)", s.config.Mode) @@ -344,6 +349,14 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) { conn.Close() return } + if s.config.Forward { + if !s.controlChannel.IsSet() { + session.Close() + return + } + go s.handleForwardWSMuxSession(g, session) + return + } select { case g.tunnelChannel <- session: // ok default: @@ -400,6 +413,17 @@ func (s *WsMuxTransport) tunnelListener(g *wsMuxGen) { } } +func (s *WsMuxTransport) handleForwardWSMuxSession(g *wsMuxGen, session *smux.Session) { + defer session.Close() + for { + stream, err := session.AcceptStream() + if err != nil { + return + } + go handleForwardStream(g.ctx, stream, s.config.KeepAlive, s.logger, g.usageMonitor, s.config.Sniffer) + } +} + func (s *WsMuxTransport) parsePortMappings(g *wsMuxGen) { for _, portMapping := range s.config.Ports { parts := strings.Split(portMapping, "=") diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go index 9d810cf..f89ed39 100644 --- a/internal/telegram/telegram.go +++ b/internal/telegram/telegram.go @@ -158,8 +158,10 @@ func tunnelBlock(lang string, t manage.Tunnel, h manage.Health) string { fmt.Fprintf(&b, "%s ", f) } label := strings.ToUpper(t.Transport) - if t.Mode == "direct" { + if t.KernelDirect() { label = "DIRECT/" + strings.ToUpper(t.Engine) + } else if t.AppForward() { + label = "DIRECT/" + strings.ToUpper(t.Transport) } fmt.Fprintf(&b, "%s [ %s ]", t.Name, label) if p := manage.PresetLabel(t.Name); p != "" { @@ -167,10 +169,15 @@ func tunnelBlock(lang string, t manage.Tunnel, h manage.Health) string { } b.WriteString("\n") - if t.Mode == "direct" { + if t.KernelDirect() { for _, m := range t.Mappings { fmt.Fprintf(&b, "%s %s:%s -> %s:%s\n", strings.ToUpper(strings.Join(m.Protocols, "+")), m.ListenAddress, m.ListenPorts, m.TargetAddress, m.TargetPorts) } + } else if t.AppForward() && t.Role == "client" { + fmt.Fprintf(&b, "Kharej : %s\n", t.Addr) + if ports := manage.VisiblePorts(t.Ports, manage.TunnelToken(t.Name)); len(ports) > 0 { + fmt.Fprintf(&b, tr(lang, "Forwarded Port")+" : %s\n", strings.Join(ports, ", ")) + } } else if t.Role == "server" { fmt.Fprintf(&b, tr(lang, "Tunnel Port")+" : %s\n", portOf(t.Addr)) if ports := manage.VisiblePorts(t.Ports, manage.TunnelToken(t.Name)); len(ports) > 0 { diff --git a/internal/utils/forward_udp.go b/internal/utils/forward_udp.go new file mode 100644 index 0000000..bf5d16a --- /dev/null +++ b/internal/utils/forward_udp.go @@ -0,0 +1,34 @@ +package utils + +import ( + "encoding/binary" + "fmt" +) + +// EncodeForwardUDP builds the first datagram of a direct UDP flow. Lengths are +// explicit because both the token and target are user-controlled strings and +// no separator byte is safe. The packet stays well below the UDP maximum. +func EncodeForwardUDP(token, target string) ([]byte, error) { + if len(token) > 65535 || len(target) > 65535 || len(token)+len(target)+5 > 65507 { + return nil, fmt.Errorf("forward UDP announcement is too large") + } + b := make([]byte, 5+len(token)+len(target)) + b[0] = SG_ForwardUDP + binary.BigEndian.PutUint16(b[1:3], uint16(len(token))) + binary.BigEndian.PutUint16(b[3:5], uint16(len(target))) + copy(b[5:], token) + copy(b[5+len(token):], target) + return b, nil +} + +func DecodeForwardUDP(b []byte) (token, target string, err error) { + if len(b) < 5 || b[0] != SG_ForwardUDP { + return "", "", fmt.Errorf("not a forward UDP announcement") + } + tokenLen := int(binary.BigEndian.Uint16(b[1:3])) + targetLen := int(binary.BigEndian.Uint16(b[3:5])) + if tokenLen == 0 || targetLen == 0 || 5+tokenLen+targetLen != len(b) { + return "", "", fmt.Errorf("malformed forward UDP announcement") + } + return string(b[5 : 5+tokenLen]), string(b[5+tokenLen:]), nil +} diff --git a/internal/utils/forward_udp_test.go b/internal/utils/forward_udp_test.go new file mode 100644 index 0000000..7606ddd --- /dev/null +++ b/internal/utils/forward_udp_test.go @@ -0,0 +1,19 @@ +package utils + +import "testing" + +func TestForwardUDPRoundTripAndMalformedPackets(t *testing.T) { + p, err := EncodeForwardUDP("tok:en\x00", "[2001:db8::1]:443") + if err != nil { + t.Fatal(err) + } + token, target, err := DecodeForwardUDP(p) + if err != nil || token != "tok:en\x00" || target != "[2001:db8::1]:443" { + t.Fatalf("decoded %q %q err=%v", token, target, err) + } + for _, bad := range [][]byte{nil, {SG_ForwardUDP}, {SG_ForwardUDP, 0, 1, 0, 1, 'x'}} { + if _, _, err := DecodeForwardUDP(bad); err == nil { + t.Fatalf("accepted malformed packet %v", bad) + } + } +} diff --git a/internal/utils/network/resolver.go b/internal/utils/network/resolver.go index 4a23d17..45ea41c 100644 --- a/internal/utils/network/resolver.go +++ b/internal/utils/network/resolver.go @@ -2,6 +2,7 @@ package network import ( "fmt" + "net" "strconv" "strings" ) @@ -33,26 +34,32 @@ func ResolveRemoteAddr(remoteAddr string) (int, string, error) { return firstPort, strings.Join(resolved, "|"), nil } - // Split the address into host and port - parts := strings.Split(remoteAddr, ":") - var port int - var err error - - // Handle cases where only the port is sent or host:port format - if len(parts) < 2 { - port, err = strconv.Atoi(parts[0]) + remoteAddr = strings.TrimSpace(remoteAddr) + // A bare number means the historical loopback shorthand. Everything else + // must be a valid host:port; net.SplitHostPort is required here because a + // strings.Split on ':' corrupts bracketed IPv6 addresses. + if !strings.Contains(remoteAddr, ":") { + port, err := strconv.Atoi(remoteAddr) if err != nil { return 0, "", fmt.Errorf("invalid port format: %v", err) } + if port < 1 || port > 65535 { + return 0, "", fmt.Errorf("invalid port %d", port) + } // Default to localhost if only the port is provided return port, fmt.Sprintf("127.0.0.1:%d", port), nil } - - // If both host and port are provided - port, err = strconv.Atoi(parts[1]) + host, portText, err := net.SplitHostPort(remoteAddr) + if err != nil || strings.TrimSpace(host) == "" { + return 0, "", fmt.Errorf("invalid remote address %q: %w", remoteAddr, err) + } + port, err := strconv.Atoi(portText) if err != nil { return 0, "", fmt.Errorf("invalid port format: %v", err) } + if port < 1 || port > 65535 { + return 0, "", fmt.Errorf("invalid port %d", port) + } // Return the full resolved address return port, remoteAddr, nil diff --git a/internal/utils/network/resolver_test.go b/internal/utils/network/resolver_test.go index f3f42ab..ad69685 100644 --- a/internal/utils/network/resolver_test.go +++ b/internal/utils/network/resolver_test.go @@ -14,6 +14,7 @@ func TestResolveRemoteAddrSingle(t *testing.T) { {"443", 443, "127.0.0.1:443"}, {"127.0.0.1:2096", 2096, "127.0.0.1:2096"}, {"10.0.0.5:8443", 8443, "10.0.0.5:8443"}, + {"[2001:db8::10]:8443", 8443, "[2001:db8::10]:8443"}, } { port, addr, err := ResolveRemoteAddr(c.in) if err != nil { diff --git a/internal/utils/signals.go b/internal/utils/signals.go index a2ece62..e5a4078 100644 --- a/internal/utils/signals.go +++ b/internal/utils/signals.go @@ -18,4 +18,18 @@ const ( // SG_Pool announces a pool connection, carrying the nonce the server handed // out when the control channel was established. SG_Pool + + // SG_ForwardTCP announces a data connection opened by the dialling Iran + // edge. The payload is the current control-channel nonce; the next framed + // string is the backend target on the Kharej origin. Older binaries reject + // the unknown signal without changing legacy reverse behaviour. + SG_ForwardTCP + // SG_ForwardUDP is the datagram equivalent. It is reserved separately so a + // receiver can never interpret UDP framing as a TCP byte stream. + SG_ForwardUDP + // The origin answers a forward-open only after its backend dial succeeds. + // This keeps an accepted Iran-side user socket from hanging against a dead + // or invalid backend. + SG_ForwardOK + SG_ForwardError ) diff --git a/internal/webui/assets/dashboard.html b/internal/webui/assets/dashboard.html index ee0fe6d..242f928 100644 --- a/internal/webui/assets/dashboard.html +++ b/internal/webui/assets/dashboard.html @@ -1420,6 +1420,11 @@

Logs

"Disk %":"دیسک ٪", "Tunnel Port":"پورت تونل", "Server":"سرور", + "Engine":"موتور", + "Mappings":"نگاشت‌ها", + "Kharej Server":"سرور خارج", + "Direction":"جهت اتصال", + "Iran → Kharej":"ایران → خارج", "Ping":"پینگ", "Forwarded Ports":"پورت‌های فورواردشده", "Remote":"مقصد", @@ -1845,6 +1850,7 @@

Logs

let cardMap={}, lastTunnels=[]; function buildCard(t){ const n=esc(t.name); + const kernelDirect=t.engine==='iptables', appDirect=t.engine==='forward'; const el=document.createElement('div'); el.className='tun'; el.dataset.name=t.name; @@ -1857,9 +1863,9 @@

Logs

-
${t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server')}
+
${kernelDirect?'Engine':(appDirect?(t.role==='server'?'Kharej Server':'Tunnel Port'):(t.role==='server'?'Tunnel Port':'Server'))}
Ping
-
${t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote')}
+
${kernelDirect?'Mappings':(appDirect?(t.role==='server'?'Forwarded Ports':'Direction'):(t.role==='server'?'Forwarded Ports':'Remote'))}
@@ -1871,6 +1877,7 @@

Logs

return el; } function updateCard(el,t){ + const kernelDirect=t.engine==='iptables', appDirect=t.engine==='forward'; const st=t.state||'stopped'; if(el.classList.contains(st)===false){ el.className='tun '+st; } // The dot is the whole status display now, so it carries the label as a @@ -1885,7 +1892,7 @@

Logs

const fl=el.querySelector('.flag'), fe=cc?flagEmoji(cc):''; if(fl.textContent!==fe){ fl.textContent=fe; fl.title=cc?countryName(cc):''; } - setText(el.querySelector('.badge.tr'),((t.mode==='direct'?t.engine:t.transport)||'').toUpperCase()); + setText(el.querySelector('.badge.tr'),(kernelDirect?(t.engine||'iptables'):(appDirect?'DIRECT/'+(t.transport||''):t.transport||'')).toUpperCase()); el.querySelector('.badge.bot').style.display=t.botRelay?'':'none'; const pe=el.querySelector('.ping'), pc='v ping '+pingClass(t.ping); @@ -1893,10 +1900,12 @@

Logs

setText(pe,pingText(t.ping)); // A server card shows the port clients dial; the full bind address is noise. - setText(el.querySelector('.addrlabel'),t.mode==='direct'?'Engine':(t.role==='server'?'Tunnel Port':'Server')); - setText(el.querySelector('.portslabel'),t.mode==='direct'?'Mappings':(t.role==='server'?'Forwarded Ports':'Remote')); - setText(el.querySelector('.addr'), t.mode==='direct' ? (t.engine||'iptables') : (t.role==='server' ? (t.tunnelPort||'—') : (t.addr||'—'))); - setText(el.querySelector('.ports'),t.ports||t.addr||'—'); + const addrLabel=kernelDirect?'Engine':(appDirect?(t.role==='server'?'Kharej Server':'Tunnel Port'):(t.role==='server'?'Tunnel Port':'Server')); + const portsLabel=kernelDirect?'Mappings':(appDirect?(t.role==='server'?'Forwarded Ports':'Direction'):(t.role==='server'?'Forwarded Ports':'Remote')); + setText(el.querySelector('.addrlabel'),T(addrLabel)); + setText(el.querySelector('.portslabel'),T(portsLabel)); + setText(el.querySelector('.addr'),kernelDirect?(t.engine||'iptables'):(appDirect?(t.role==='server'?(t.addr||'—'):(t.tunnelPort||'—')):(t.role==='server'?(t.tunnelPort||'—'):(t.addr||'—')))); + setText(el.querySelector('.ports'),appDirect&&t.role!=='server'?T('Iran → Kharej'):(t.ports||t.addr||'—')); // Traffic comes from the tunnel's own metrics snapshot; a tunnel that has // never run has none, and the row stays hidden rather than showing a @@ -2175,7 +2184,8 @@

Logs

function renderLinkTestBox(t){ const box=$('ltwrap'); // Measured from the side that dials out; a TCP probe can't see a UDP port. - if(t.role!=='client'||['udp','kcp'].includes(t.transport)){ box.innerHTML=''; return; } + const dialler=t.engine==='forward'?t.role==='server':t.role==='client'; + if(!dialler||['udp','kcp','quic','xdi','spoof'].includes(t.transport)){ box.innerHTML=''; return; } box.innerHTML=dSec('Link Test')+ '
Latency, jitter and loss to '+esc(t.addr)+ ', with a transport recommendation.'+ diff --git a/internal/webui/direct_api_test.go b/internal/webui/direct_api_test.go index c025aaf..b7d79b4 100644 --- a/internal/webui/direct_api_test.go +++ b/internal/webui/direct_api_test.go @@ -2,6 +2,7 @@ package webui import ( "encoding/json" + "strings" "testing" "github.com/backpack/backpack/config" @@ -10,7 +11,8 @@ import ( func TestTunnelInfoAdditiveEngineShapeIsStable(t *testing.T) { for _, info := range []TunnelInfo{ {Name: "legacy", Mode: "reverse", Engine: "reverse", Role: "server", Transport: "tcp", Mappings: []config.ForwardMapping{}}, - {Name: "direct", Mode: "direct", Engine: "iptables", Role: "", Transport: "", Mappings: []config.ForwardMapping{}}, + {Name: "kernel-direct", Mode: "direct", Engine: "iptables", Role: "", Transport: "", Mappings: []config.ForwardMapping{}}, + {Name: "app-direct", Mode: "direct", Engine: "forward", Role: "server", Transport: "tcpmux", Mappings: []config.ForwardMapping{}}, } { b, err := json.Marshal(info) if err != nil { @@ -28,8 +30,28 @@ func TestTunnelInfoAdditiveEngineShapeIsStable(t *testing.T) { if mappings, ok := got["mappings"].([]any); !ok || len(mappings) != 0 { t.Errorf("%s mappings must be an empty JSON array, got %#v", info.Name, got["mappings"]) } - if info.Mode == "direct" && (got["role"] != "" || got["transport"] != "") { - t.Errorf("direct reverse-only fields must be stable empty strings: %s", b) + if info.Engine == "iptables" && (got["role"] != "" || got["transport"] != "") { + t.Errorf("kernel-direct reverse-only fields must be stable empty strings: %s", b) } + if info.Engine == "forward" && (got["role"] == "" || got["transport"] == "") { + t.Errorf("application Direct must retain its selected transport: %s", b) + } + } +} + +func TestDashboardSeparatesApplicationDirectFromKernelDirect(t *testing.T) { + body := string(dashboardHTML) + for _, marker := range []string{ + "kernelDirect=t.engine==='iptables'", + "appDirect=t.engine==='forward'", + "appDirect?'DIRECT/'+(t.transport||'')", + "appDirect&&t.role!=='server'?T('Iran → Kharej')", + } { + if !strings.Contains(body, marker) { + t.Fatalf("dashboard is missing Direct-mode distinction %q", marker) + } + } + if strings.Contains(body, "t.mode==='direct'?'Engine'") { + t.Fatal("dashboard still renders every Direct instance as the iptables engine") } } diff --git a/internal/webui/handlers_monitoring.go b/internal/webui/handlers_monitoring.go index 326679c..2861735 100644 --- a/internal/webui/handlers_monitoring.go +++ b/internal/webui/handlers_monitoring.go @@ -102,7 +102,7 @@ func (s *server) handleLinkTest(w http.ResponseWriter, r *http.Request) { // has no address to probe, and probing a datagram tunnel's port over // TCP would report a working tunnel as dead. if t.Role != "client" { - http.Error(w, "the link test runs on the client (kharej) side — it is the side that dials out", http.StatusBadRequest) + http.Error(w, "the link test runs on the side that dials the tunnel (Iran for Direct, Kharej for Reverse)", http.StatusBadRequest) return } if manage.IsDatagram(t.Transport) { diff --git a/internal/webui/stats.go b/internal/webui/stats.go index 5b4c26b..b56c0bf 100644 --- a/internal/webui/stats.go +++ b/internal/webui/stats.go @@ -434,7 +434,7 @@ func GatherTunnels() []TunnelInfo { Name: t.Name, Mode: t.Mode, Engine: t.Engine, - Role: t.Role, + Role: t.DisplayRole(), Transport: t.Transport, Mappings: append([]config.ForwardMapping{}, t.Mappings...), Addr: t.Addr, @@ -449,7 +449,7 @@ func GatherTunnels() []TunnelInfo { Country: manage.TunnelCountry(t.Name), Ping: -1, } - if t.Mode == "direct" { + if t.KernelDirect() { info.State = health[t.Name].State var rendered []string for _, m := range t.Mappings { @@ -781,7 +781,7 @@ func fillConfig(info *TunnelInfo, t manage.Tunnel) { if err != nil { return } - if t.Mode == "direct" { + if t.KernelDirect() { return } if t.Role == "server" { @@ -814,6 +814,11 @@ func fillConfig(info *TunnelInfo, t manage.Tunnel) { info.Preset = manage.PresetValueLabel(cc.Preset) info.LoadBalance = cc.LoadBalance info.FallbackAddrs = cc.FallbackAddrs + if t.AppForward() { + info.MaxConnections = cc.MaxConnections + info.BandwidthMbps = cc.BandwidthMbps + info.ProxyProtocol = cc.ProxyProtocol + } } } From 7389db7b7d5622765c62bfcc2455915ac0451c27 Mon Sep 17 00:00:00 2001 From: etm Date: Fri, 7 Aug 2026 03:00:34 +0330 Subject: [PATCH 4/4] fix: harden direct TCP reconnects under load --- .../client/transport/forward_pool_test.go | 104 +++++++++++ internal/client/transport/forward_tcp.go | 104 +++++++++-- internal/client/transport/state.go | 23 +++ .../client/transport/state_generation_test.go | 30 ++++ internal/client/transport/tcp.go | 133 +++++++++----- internal/e2e/forward_tcp_test.go | 168 ++++++++++++++++++ internal/utils/handlers/tcp_handler.go | 15 ++ internal/utils/handlers/tcp_handler_test.go | 20 +++ 8 files changed, 538 insertions(+), 59 deletions(-) create mode 100644 internal/client/transport/forward_pool_test.go create mode 100644 internal/client/transport/state_generation_test.go diff --git a/internal/client/transport/forward_pool_test.go b/internal/client/transport/forward_pool_test.go new file mode 100644 index 0000000..5822d25 --- /dev/null +++ b/internal/client/transport/forward_pool_test.go @@ -0,0 +1,104 @@ +package transport + +import ( + "context" + "net" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestForwardTCPPoolBoundsConcurrentDialsAndRefills(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const workers = 3 + gate := make(chan struct{}, workers+1) + var calls atomic.Int32 + var peersMu sync.Mutex + var peers []net.Conn + dial := func(ctx context.Context) (net.Conn, error) { + calls.Add(1) + select { + case <-gate: + case <-ctx.Done(): + return nil, ctx.Err() + } + client, peer := net.Pipe() + peersMu.Lock() + peers = append(peers, peer) + peersMu.Unlock() + return client, nil + } + + pool := newForwardTCPPool(ctx, workers, dial) + pool.Start() + t.Cleanup(func() { + pool.Close() + peersMu.Lock() + defer peersMu.Unlock() + for _, peer := range peers { + peer.Close() + } + }) + + waitAtomic(t, &calls, workers) + time.Sleep(50 * time.Millisecond) + if got := calls.Load(); got != workers { + t.Fatalf("pool launched %d concurrent dials, want exactly %d", got, workers) + } + + for range workers { + gate <- struct{}{} + } + conn, err := pool.Get(ctx, time.Second) + if err != nil { + t.Fatalf("get pre-warmed connection: %v", err) + } + conn.Close() + + // Consuming one slot makes exactly that worker refill it. + waitAtomic(t, &calls, workers+1) + time.Sleep(50 * time.Millisecond) + if got := calls.Load(); got != workers+1 { + t.Fatalf("one checkout triggered %d total dials, want %d", got, workers+1) + } +} + +func TestForwardTCPPoolCancellationUnblocksWaiter(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + pool := newForwardTCPPool(ctx, 1, func(ctx context.Context) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() + }) + pool.Start() + + done := make(chan error, 1) + go func() { + _, err := pool.Get(ctx, time.Minute) + done <- err + }() + cancel() + + select { + case err := <-done: + if err == nil { + t.Fatal("Get returned nil after generation cancellation") + } + case <-time.After(time.Second): + t.Fatal("Get remained blocked after generation cancellation") + } +} + +func waitAtomic(t *testing.T, value *atomic.Int32, want int32) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if value.Load() >= want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("counter reached %d, want at least %d", value.Load(), want) +} diff --git a/internal/client/transport/forward_tcp.go b/internal/client/transport/forward_tcp.go index 6428b1c..2f6f6d7 100644 --- a/internal/client/transport/forward_tcp.go +++ b/internal/client/transport/forward_tcp.go @@ -1,12 +1,17 @@ package transport import ( + "context" + "fmt" "net" + "sync" "sync/atomic" + "time" "github.com/backpack/backpack/internal/forwardmap" "github.com/backpack/backpack/internal/metrics" "github.com/backpack/backpack/internal/utils/handlers" + "github.com/backpack/backpack/internal/web" ) type forwardTCPMapping struct { @@ -30,29 +35,106 @@ func expandForwardTCPMappings(specs []string) ([]forwardTCPMapping, error) { return out, nil } -func (c *TcpTransport) startForwardTCPIngress() { +type forwardTCPPool struct { + ctx context.Context + cancel context.CancelFunc + size int + dial func(context.Context) (net.Conn, error) + ready chan net.Conn + closed chan struct{} + once sync.Once +} + +func newForwardTCPPool(parent context.Context, size int, dial func(context.Context) (net.Conn, error)) *forwardTCPPool { + ctx, cancel := context.WithCancel(parent) + return &forwardTCPPool{ctx: ctx, cancel: cancel, size: max(1, size), dial: dial, ready: make(chan net.Conn), closed: make(chan struct{})} +} + +func (p *forwardTCPPool) Start() { + for i := 0; i < p.size; i++ { + go p.worker() + } +} + +func (p *forwardTCPPool) worker() { + bo := newBackoff(100 * time.Millisecond) + for p.ctx.Err() == nil { + conn, err := p.dial(p.ctx) + if err != nil { + if !bo.Wait(p.ctx) { + return + } + continue + } + // A successful connection means the outage is over. A later failure is + // a new outage and should again recover quickly instead of inheriting a + // 30-second backoff from an old one. + bo = newBackoff(100 * time.Millisecond) + select { + case p.ready <- conn: + // The connection was consumed. Refill this worker's one slot. + case <-p.ctx.Done(): + conn.Close() + return + } + } +} + +func (p *forwardTCPPool) Get(ctx context.Context, timeout time.Duration) (net.Conn, error) { + if err := p.ctx.Err(); err != nil { + return nil, err + } + if timeout <= 0 { + timeout = 15 * time.Second + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case conn := <-p.ready: + if err := p.ctx.Err(); err != nil { + conn.Close() + return nil, err + } + return conn, nil + case <-ctx.Done(): + return nil, ctx.Err() + case <-p.closed: + return nil, fmt.Errorf("forward connection pool is closed") + case <-timer.C: + return nil, fmt.Errorf("timed out waiting for a forward data connection") + } +} + +func (p *forwardTCPPool) Close() { + p.once.Do(func() { + p.cancel() + close(p.closed) + }) +} + +func (c *TcpTransport) startForwardTCPIngress(ctx context.Context, usage *web.Usage, pool *forwardTCPPool) { mappings, err := expandForwardTCPMappings(c.config.Ports) if err != nil { c.logger.Errorf("invalid forward ingress mappings: %v", err) - go c.Restart() + go c.restartGeneration(ctx) return } for _, mapping := range mappings { mapping := mapping - go c.runForwardTCPListener(mapping) + go c.runForwardTCPListener(ctx, usage, pool, mapping) } } -func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) { +func (c *TcpTransport) runForwardTCPListener(ctx context.Context, usage *web.Usage, pool *forwardTCPPool, mapping forwardTCPMapping) { listener, err := net.Listen("tcp", mapping.listen) if err != nil { c.logger.Errorf("failed to listen on forward ingress %s: %v", mapping.listen, err) - go c.Restart() + go c.restartGeneration(ctx) return } defer listener.Close() go func() { - <-c.state.Ctx().Done() + <-ctx.Done() _ = listener.Close() }() c.logger.Infof("forward ingress listening on %s -> Kharej %s", listener.Addr(), mapping.target) @@ -60,7 +142,7 @@ func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) { for { local, err := listener.Accept() if err != nil { - if c.state.Ctx().Err() != nil { + if ctx.Err() != nil { return } c.logger.Warnf("forward ingress accept on %s failed: %v", mapping.listen, err) @@ -71,7 +153,7 @@ func (c *TcpTransport) runForwardTCPListener(mapping forwardTCPMapping) { local.Close() continue } - go c.handleForwardTCPIngress(local, mapping.target) + go c.handleForwardTCPIngress(ctx, usage, pool, local, mapping.target) } } @@ -91,10 +173,10 @@ func (c *TcpTransport) acquireForwardSlot() bool { } } -func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) { +func (c *TcpTransport) handleForwardTCPIngress(ctx context.Context, usage *web.Usage, pool *forwardTCPPool, local net.Conn, target string) { defer atomic.AddInt32(&c.loadConnections, -1) local = c.forwardBandwidth.wrap(local) - tunnel, err := c.openForwardTCP(target) + tunnel, err := c.openForwardTCP(ctx, pool, target) if err != nil { c.logger.Warnf("could not open forward channel for %s: %v", target, err) local.Close() @@ -104,5 +186,5 @@ func (c *TcpTransport) handleForwardTCPIngress(local net.Conn, target string) { if tcpAddr, ok := local.LocalAddr().(*net.TCPAddr); ok { port = tcpAddr.Port } - handlers.TCPConnectionHandler(c.state.Ctx(), c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, c.state.Usage(), port, c.config.Sniffer) + handlers.TCPConnectionHandler(ctx, c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, usage, port, c.config.Sniffer) } diff --git a/internal/client/transport/state.go b/internal/client/transport/state.go index d6a19bd..a48e0d0 100644 --- a/internal/client/transport/state.go +++ b/internal/client/transport/state.go @@ -71,6 +71,29 @@ func (s *clientState) SetConn(c net.Conn) { s.conn = c } +// SetConnFor publishes a control connection only when ctx still identifies +// the current generation. A dial can finish after Restart has already +// installed the next generation; publishing that late result would otherwise +// let an old goroutine replace the new run's control channel. +func (s *clientState) SetConnFor(ctx context.Context, c net.Conn) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.ctx != ctx { + return false + } + s.conn = c + return true +} + +// IsCurrent reports whether ctx belongs to the currently published +// generation. It is used before a failing old worker requests a restart, so a +// late error cannot tear down a healthy replacement generation. +func (s *clientState) IsCurrent(ctx context.Context) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ctx == ctx +} + func (s *clientState) WSConn() *websocket.Conn { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/client/transport/state_generation_test.go b/internal/client/transport/state_generation_test.go new file mode 100644 index 0000000..d0a3faf --- /dev/null +++ b/internal/client/transport/state_generation_test.go @@ -0,0 +1,30 @@ +package transport + +import ( + "context" + "net" + "testing" +) + +func TestClientStateRejectsRetiredGenerationConnection(t *testing.T) { + oldCtx, oldCancel := context.WithCancel(context.Background()) + var state clientState + state.Reset(oldCtx, oldCancel, nil) + + newCtx, newCancel := context.WithCancel(context.Background()) + defer newCancel() + state.Reset(newCtx, newCancel, nil) + + late, peer := net.Pipe() + defer late.Close() + defer peer.Close() + if state.SetConnFor(oldCtx, late) { + t.Fatal("retired generation published a late control connection") + } + if state.Conn() != nil { + t.Fatal("late retired connection replaced the current control connection") + } + if state.IsCurrent(oldCtx) || !state.IsCurrent(newCtx) { + t.Fatal("generation identity check returned the wrong result") + } +} diff --git a/internal/client/transport/tcp.go b/internal/client/transport/tcp.go index 5c98faf..0fd7403 100644 --- a/internal/client/transport/tcp.go +++ b/internal/client/transport/tcp.go @@ -67,7 +67,8 @@ type TcpConfig struct { Stealth bool // Forward turns this dialler into the Iran edge: it keeps the ordinary // authenticated control channel, owns the public ingress listeners, and - // opens one authenticated data connection per accepted user connection. + // checks out one authenticated, pre-warmed data connection per accepted + // user connection. Forward bool Ports []string AcceptUDP bool @@ -109,20 +110,33 @@ func NewTCPClient(parentCtx context.Context, config *TcpConfig, logger *logrus.L } func (c *TcpTransport) Start() { + ctx := c.state.Ctx() + usage := c.state.Usage() if c.config.WebPort > 0 { - go c.state.Usage().Monitor() + go usage.Monitor() } c.config.TunnelStatus = "Disconnected (TCP)" - go c.channelDialer() + go c.channelDialer(ctx, usage) } func (c *TcpTransport) Restart() { + c.restartGeneration(nil) +} + +// restartGeneration replaces only the generation that actually failed. An +// error from a goroutine belonging to a previous run is expected while that +// run drains and must never cancel the healthy run that replaced it. +func (c *TcpTransport) restartGeneration(failed context.Context) { if !c.restartMutex.TryLock() { c.logger.Warn("client is already restarting") return } defer c.restartMutex.Unlock() + if failed != nil && !c.state.IsCurrent(failed) { + c.logger.Debug("ignoring restart request from a retired client generation") + return + } c.logger.Info("restarting client...") @@ -175,7 +189,7 @@ func (c *TcpTransport) Restart() { go c.Start() } -func (c *TcpTransport) channelDialer() { +func (c *TcpTransport) channelDialer(ctx context.Context, usage *web.Usage) { c.logger.Info("attempting to establish a new control channel connection...") // One backoff for this reconnect loop: retries start at the configured @@ -184,11 +198,11 @@ func (c *TcpTransport) channelDialer() { for { select { - case <-c.state.Ctx().Done(): + case <-ctx.Done(): return default: //set default behaviour of control channel to nodelay, also using default buffer parameters - rawConn, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Current(), c.config.DialTimeOut, c.config.KeepAlive, true, 3, 0, 0, 0) + rawConn, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Current(), c.config.DialTimeOut, c.config.KeepAlive, true, 3, 0, 0, 0) if err != nil { c.logger.Errorf("channel dialer: %v", err) // The current endpoint did not answer — move to the next one so a @@ -196,7 +210,7 @@ func (c *TcpTransport) channelDialer() { if next := c.config.Endpoints.Rotate(); c.config.Endpoints.Len() > 1 { c.logger.Infof("trying next server endpoint: %s", next) } - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } @@ -207,7 +221,7 @@ func (c *TcpTransport) channelDialer() { if err != nil { c.logger.Errorf("channel dialer: stealth handshake failed: %v", err) rawConn.Close() - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } @@ -219,7 +233,7 @@ func (c *TcpTransport) channelDialer() { if err != nil { c.logger.Errorf("failed to send security token: %v", err) tunnelTCPConn.Close() - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } @@ -227,7 +241,7 @@ func (c *TcpTransport) channelDialer() { if err := tunnelTCPConn.SetReadDeadline(time.Now().Add(controlAckTimeout)); err != nil { c.logger.Errorf("failed to set read deadline: %v", err) tunnelTCPConn.Close() - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } @@ -241,7 +255,7 @@ func (c *TcpTransport) channelDialer() { noteLegacyServer(c.logger, &c.legacyServer, signal) } tunnelTCPConn.Close() // Close connection on error or timeout - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } // Resetting the deadline (removes any existing deadline) @@ -255,44 +269,51 @@ func (c *TcpTransport) channelDialer() { // Before the control channel is published, so the pool // connections poolMaintainer starts below already have it. c.poolNonce.Set(nonce) - c.state.SetConn(tunnelTCPConn) + if !c.state.SetConnFor(ctx, tunnelTCPConn) { + tunnelTCPConn.Close() + return + } c.logger.Info("control channel established successfully") c.config.TunnelStatus = "Connected (TCP)" - go c.channelHandler() + go c.channelHandler(ctx, usage, tunnelTCPConn, nonce) if c.config.Forward { if nonce == "" { c.logger.Error("forward mode requires the authenticated v2 control handshake; peer is too old") tunnelTCPConn.Close() - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } - go c.startForwardTCPIngress() + generation := ctx + pool := newForwardTCPPool(generation, max(1, c.config.ConnPoolSize), func(poolCtx context.Context) (net.Conn, error) { + return c.newForwardDataConn(poolCtx, generation, nonce) + }) + pool.Start() + go c.startForwardTCPIngress(ctx, usage, pool) return } - go c.poolMaintainer() + go c.poolMaintainer(ctx, usage, nonce) return } else { c.logger.Errorf("invalid token received (does not match the server's token). Retrying...") tunnelTCPConn.Close() // Close connection if the token is invalid - bo.Wait(c.state.Ctx()) + bo.Wait(ctx) continue } } } } -// openForwardTCP creates the data connection proactively from the Iran edge. -// It returns only after the Kharej origin has successfully dialled the target, -// so an unavailable backend becomes a prompt close instead of a black hole. -func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) { - nonce := c.poolNonce.Get() - if nonce == "" || c.state.Conn() == nil { +// newForwardDataConn creates and authenticates one pre-warmed connection from +// the Iran edge. The target is deliberately sent only when a user checks it +// out, so one bounded pool can serve every configured mapping. +func (c *TcpTransport) newForwardDataConn(ctx, generation context.Context, nonce string) (net.Conn, error) { + if nonce == "" || !c.state.IsCurrent(generation) { return nil, fmt.Errorf("forward control channel is not ready") } - raw, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS) + raw, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 1, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS) if err != nil { return nil, fmt.Errorf("dial forward origin: %w", err) } @@ -308,6 +329,22 @@ func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) { if err := utils.SendBinaryTransportString(conn, nonce, utils.SG_ForwardTCP); err != nil { return fail(fmt.Errorf("announce forward data connection: %w", err)) } + return conn, nil +} + +// openForwardTCP checks out one authenticated, pre-warmed data connection and +// completes the target handshake. The bounded worker pool absorbs connection +// bursts without launching one expensive dial/Noise handshake per user at the +// same instant. +func (c *TcpTransport) openForwardTCP(ctx context.Context, pool *forwardTCPPool, target string) (net.Conn, error) { + conn, err := pool.Get(ctx, c.config.DialTimeOut) + if err != nil { + return nil, err + } + fail := func(e error) (net.Conn, error) { + conn.Close() + return nil, e + } if err := utils.SendBinaryString(conn, target); err != nil { return fail(fmt.Errorf("send forward target: %w", err)) } @@ -325,9 +362,9 @@ func (c *TcpTransport) openForwardTCP(target string) (net.Conn, error) { return conn, nil } -func (c *TcpTransport) poolMaintainer() { +func (c *TcpTransport) poolMaintainer(ctx context.Context, usage *web.Usage, nonce string) { for i := 0; i < c.config.ConnPoolSize; i++ { //initial pool filling - go c.tunnelDialer() + go c.tunnelDialer(ctx, usage, nonce) } // factors @@ -356,7 +393,7 @@ func (c *TcpTransport) poolMaintainer() { for { select { - case <-c.state.Ctx().Done(): + case <-ctx.Done(): return case <-tickerPool.C: @@ -390,7 +427,7 @@ func (c *TcpTransport) poolMaintainer() { newPoolSize++ // Add a new connection to the pool - go c.tunnelDialer() + go c.tunnelDialer(ctx, usage, nonce) } else if float64(loadConnections+x) < float64(poolConnectionsAvg)*y && newPoolSize > c.config.ConnPoolSize { c.logger.Debugf("decreasing pool size: %d -> %d, avg pool conn: %d, avg load conn: %d", newPoolSize, newPoolSize-1, poolConnectionsAvg, loadConnections) newPoolSize-- @@ -403,21 +440,21 @@ func (c *TcpTransport) poolMaintainer() { } -func (c *TcpTransport) channelHandler() { +func (c *TcpTransport) channelHandler(ctx context.Context, usage *web.Usage, control net.Conn, nonce string) { msgChan := make(chan byte, 1000) // Goroutine to handle the blocking ReceiveBinaryString go func() { for { select { - case <-c.state.Ctx().Done(): + case <-ctx.Done(): return default: - msg, err := utils.ReceiveBinaryByte(c.state.Conn()) + msg, err := utils.ReceiveBinaryByte(control) if err != nil { - if c.state.Cancel() != nil { + if ctx.Err() == nil { c.logger.Error("failed to read from control channel. ", err) - go c.Restart() + go c.restartGeneration(ctx) } return } @@ -429,8 +466,8 @@ func (c *TcpTransport) channelHandler() { // Main loop to listen for context cancellation or received messages for { select { - case <-c.state.Ctx().Done(): - _ = utils.SendBinaryByte(c.state.Conn(), utils.SG_Closed) + case <-ctx.Done(): + _ = utils.SendBinaryByte(control, utils.SG_Closed) return case msg := <-msgChan: @@ -443,7 +480,7 @@ func (c *TcpTransport) channelHandler() { default: c.logger.Debug("channel signal received, initiating tunnel dialer") - go c.tunnelDialer() + go c.tunnelDialer(ctx, usage, nonce) } case utils.SG_HB: @@ -451,20 +488,20 @@ func (c *TcpTransport) channelHandler() { case utils.SG_Closed: c.logger.Warn("control channel has been closed by the server") - go c.Restart() + go c.restartGeneration(ctx) return case utils.SG_RTT: - err := utils.SendBinaryByte(c.state.Conn(), utils.SG_RTT) + err := utils.SendBinaryByte(control, utils.SG_RTT) if err != nil { c.logger.Error("failed to send RTT signal, restarting client: ", err) - go c.Restart() + go c.restartGeneration(ctx) return } default: c.logger.Errorf("unexpected response from channel: %v.", msg) - go c.Restart() + go c.restartGeneration(ctx) return } } @@ -472,14 +509,14 @@ func (c *TcpTransport) channelHandler() { } // Dialing to the tunnel server, chained functions, without retry -func (c *TcpTransport) tunnelDialer() { +func (c *TcpTransport) tunnelDialer(ctx context.Context, usage *web.Usage, nonce string) { c.logger.Debugf("initiating new connection to tunnel server at %s", c.config.RemoteAddr) // Dial to the tunnel server // Next() rather than Current(): with load balancing enabled the pool // spreads its connections over every configured endpoint, so one // congested route only slows its own share of the traffic. - rawConn, err := network.TcpDialerVia(c.state.Ctx(), c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS) + rawConn, err := network.TcpDialerVia(ctx, c.config.Outbound, c.config.Endpoints.Next(), c.config.DialTimeOut, c.config.KeepAlive, c.config.Nodelay, 3, c.config.SO_RCVBUF, c.config.SO_SNDBUF, c.config.MSS) if err != nil { c.logger.Error("tunnel server dialer: ", err) @@ -497,7 +534,7 @@ func (c *TcpTransport) tunnelDialer() { // Say what this connection is, so the server admits it on the nonce rather // than on the address it happened to dial out from. - if err := announcePoolConn(tcpConn, c.poolNonce.Get()); err != nil { + if err := announcePoolConn(tcpConn, nonce); err != nil { c.logger.Debugf("tunnel dialer: failed to announce the pool connection: %v", err) tcpConn.Close() return @@ -529,10 +566,10 @@ func (c *TcpTransport) tunnelDialer() { switch transport { case utils.SG_TCP: // Dial local server using the received address - c.localDialer(tcpConn, resolvedAddr, port) + c.localDialer(ctx, usage, tcpConn, resolvedAddr, port) case utils.SG_UDP: - UDPDialer(tcpConn, resolvedAddr, c.logger, c.state.Usage(), port, c.config.Sniffer) + UDPDialer(tcpConn, resolvedAddr, c.logger, usage, port, c.config.Sniffer) default: c.logger.Error("undefined transport. close the connection.") @@ -540,7 +577,7 @@ func (c *TcpTransport) tunnelDialer() { } } -func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port int) { +func (c *TcpTransport) localDialer(ctx context.Context, usage *web.Usage, tcpConn net.Conn, resolvedAddr string, port int) { // Pick a healthy backend when several are configured; a single backend is // returned unchanged, so ordinary tunnels are untouched. resolvedAddr = backends.pick(resolvedAddr) @@ -556,7 +593,7 @@ func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port i recvBuf = c.config.SO_RCVBUF } - localConnection, err := network.TcpDialer(c.state.Ctx(), resolvedAddr, c.config.DialTimeOut, c.config.KeepAlive, true, 1, recvBuf, sendBuf, c.config.MSS) + localConnection, err := network.TcpDialer(ctx, resolvedAddr, c.config.DialTimeOut, c.config.KeepAlive, true, 1, recvBuf, sendBuf, c.config.MSS) if err != nil { localDial.Report(c.logger, resolvedAddr, err) tcpConn.Close() @@ -565,5 +602,5 @@ func (c *TcpTransport) localDialer(tcpConn net.Conn, resolvedAddr string, port i c.logger.Debugf("connected to local address %s successfully", resolvedAddr) - handlers.TCPConnectionHandler(c.state.Ctx(), false, metrics.CountedConn(tcpConn), localConnection, c.logger, c.state.Usage(), port, c.config.Sniffer) + handlers.TCPConnectionHandler(ctx, false, metrics.CountedConn(tcpConn), localConnection, c.logger, usage, port, c.config.Sniffer) } diff --git a/internal/e2e/forward_tcp_test.go b/internal/e2e/forward_tcp_test.go index 65632ae..1bfab59 100644 --- a/internal/e2e/forward_tcp_test.go +++ b/internal/e2e/forward_tcp_test.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "sync" + "sync/atomic" "testing" "time" + "github.com/backpack/backpack/config" "github.com/backpack/backpack/internal/client" "github.com/backpack/backpack/internal/server" ) @@ -20,6 +22,172 @@ func TestForwardStreamTransports(t *testing.T) { } } +// A browser, game gateway or proxy can open hundreds of connections at once. +// Direct TCP/Stealth must queue the expensive outer handshakes behind the +// configured pool instead of turning that burst into an outbound dial storm. +func TestForwardTCPBurst(t *testing.T) { + for _, transport := range []string{"tcp", "stealth"} { + t.Run(transport, func(t *testing.T) { + backend := startEchoBackend(t) + tunnelPort, entryPort := freePort(t), freePort(t) + token := "forward-burst-token-0123456789abcdef" + origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token) + origin.Ports = nil + edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)} + edge.ConnectionPool = 4 + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + srv := server.NewForwardOrigin(origin, ctx) + wg.Add(1) + go func() { defer wg.Done(); srv.Start() }() + time.Sleep(300 * time.Millisecond) + cli := client.NewForwardEdge(edge, ctx) + wg.Add(1) + go func() { defer wg.Done(); cli.Start() }() + tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg} + t.Cleanup(tun.Stop) + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatalf("forward tunnel never became ready: %v", err) + } + + payload := randomPayload(t, 128*1024) + const users = 64 + var failures atomic.Int32 + var burst sync.WaitGroup + burst.Add(users) + for range users { + go func() { + defer burst.Done() + if err := tun.roundTrip(payload); err != nil { + failures.Add(1) + } + }() + } + burst.Wait() + if got := failures.Load(); got != 0 { + t.Fatalf("%d of %d simultaneous forward users failed", got, users) + } + }) + } +} + +func TestLargePayloadAcrossTCPDirections(t *testing.T) { + for _, transport := range []string{"tcp", "stealth"} { + t.Run("direct/"+transport, func(t *testing.T) { + backend := startEchoBackend(t) + tunnelPort, entryPort := freePort(t), freePort(t) + token := "forward-large-token-0123456789abcdef" + origin := baseServerConfig(transport, tunnelPort, 1, backend.addr, token) + origin.Ports = nil + edge := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + edge.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)} + tun := runForwardPair(t, origin, edge, entryPort, tunnelPort) + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatal(err) + } + if err := tun.roundTrip(randomPayload(t, 16*1024*1024)); err != nil { + t.Fatalf("large direct payload: %v", err) + } + }) + + t.Run("reverse/"+transport, func(t *testing.T) { + backend := startEchoBackend(t) + tunnelPort, entryPort := freePort(t), freePort(t) + token := "reverse-large-token-0123456789abcdef" + srvCfg := baseServerConfig(transport, tunnelPort, entryPort, backend.addr, token) + cliCfg := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + tun := runPair(t, srvCfg, cliCfg, entryPort, tunnelPort) + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatal(err) + } + if err := tun.roundTrip(randomPayload(t, 16*1024*1024)); err != nil { + t.Fatalf("large reverse payload: %v", err) + } + }) + } +} + +// Reconnecting the control channel creates a new client generation. The old +// generation must release the public ingress listener before the replacement +// binds it; this is the regression test for the production "address already +// in use" restart loop. +func TestForwardTCPRecoversAfterOriginRestart(t *testing.T) { + for _, transport := range []string{"tcp", "stealth"} { + t.Run(transport, func(t *testing.T) { + backend := startEchoBackend(t) + tunnelPort, entryPort := freePort(t), freePort(t) + token := "forward-restart-token-0123456789abcdef" + originCfg := baseServerConfig(transport, tunnelPort, 1, backend.addr, token) + originCfg.Ports = nil + edgeCfg := baseClientConfig(transport, fmt.Sprintf("127.0.0.1:%d", tunnelPort), token, nil) + edgeCfg.Ports = []string{fmt.Sprintf("%d=%s", entryPort, backend.addr)} + + edgeCtx, stopEdge := context.WithCancel(context.Background()) + edge := client.NewForwardEdge(edgeCfg, edgeCtx) + + startOrigin := func() (context.CancelFunc, <-chan struct{}) { + originCtx, stopOrigin := context.WithCancel(context.Background()) + done := make(chan struct{}) + origin := server.NewForwardOrigin(originCfg, originCtx) + go func() { + defer close(done) + origin.Start() + }() + return stopOrigin, done + } + + stopOrigin, originDone := startOrigin() + time.Sleep(300 * time.Millisecond) + edgeDone := make(chan struct{}) + go func() { defer close(edgeDone); edge.Start() }() + tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort)} + t.Cleanup(func() { + stopEdge() + stopOrigin() + for name, done := range map[string]<-chan struct{}{"origin": originDone, "edge": edgeDone} { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Errorf("%s did not stop during cleanup", name) + } + } + }) + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatalf("initial forward generation never became ready: %v", err) + } + + stopOrigin() + select { + case <-originDone: + case <-time.After(5 * time.Second): + t.Fatal("old origin did not stop") + } + stopOrigin, originDone = startOrigin() + if err := tun.waitReady(tunnelReadyTimeout); err != nil { + t.Fatalf("forward tunnel did not recover after origin restart: %v", err) + } + }) + } +} + +func runForwardPair(t *testing.T, originCfg *config.ServerConfig, edgeCfg *config.ClientConfig, entryPort, tunnelPort int) *tunnel { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + origin := server.NewForwardOrigin(originCfg, ctx) + wg.Add(1) + go func() { defer wg.Done(); origin.Start() }() + time.Sleep(300 * time.Millisecond) + edge := client.NewForwardEdge(edgeCfg, ctx) + wg.Add(1) + go func() { defer wg.Done(); edge.Start() }() + tun := &tunnel{Entry: fmt.Sprintf("127.0.0.1:%d", entryPort), TunnelPort: tunnelPort, cancel: cancel, wg: &wg} + t.Cleanup(tun.Stop) + return tun +} + func testForwardStreamTransport(t *testing.T, transport string) { backend := startEchoBackend(t) tunnelPort := freePort(t) diff --git a/internal/utils/handlers/tcp_handler.go b/internal/utils/handlers/tcp_handler.go index 2dc95fa..0af8b3a 100644 --- a/internal/utils/handlers/tcp_handler.go +++ b/internal/utils/handlers/tcp_handler.go @@ -13,6 +13,21 @@ import ( func TCPConnectionHandler(ctx context.Context, proxyProtocol bool, from net.Conn, to net.Conn, logger *logrus.Logger, usage *web.Usage, remotePort int, sniffer bool) { done := make(chan struct{}) + stopWatch := make(chan struct{}) + defer close(stopWatch) + + // Relay reads are intentionally blocking, so merely selecting on ctx after + // one direction finishes cannot stop an idle connection. Close both sockets + // as soon as the generation ends; that wakes both copy loops and guarantees + // reconnect/reload does not retain old file descriptors indefinitely. + go func() { + select { + case <-ctx.Done(): + from.Close() + to.Close() + case <-stopWatch: + } + }() // Write Proxy Protocol V2 Header if proxyProtocol { diff --git a/internal/utils/handlers/tcp_handler_test.go b/internal/utils/handlers/tcp_handler_test.go index 3fb06a5..221a4cd 100644 --- a/internal/utils/handlers/tcp_handler_test.go +++ b/internal/utils/handlers/tcp_handler_test.go @@ -99,6 +99,26 @@ func TestRelayCarriesBothDirections(t *testing.T) { } } +func TestRelayCancellationClosesIdleConnections(t *testing.T) { + client, from := tcpPair(t) + to, backend := tcpPair(t) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + TCPConnectionHandler(ctx, false, from, to, quietLogger(), &web.Usage{}, 8080, false) + }() + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("idle relay did not stop when its generation was cancelled") + } + client.Close() + backend.Close() +} + // When either side goes away the handler closes both connections, so a forwarded // connection can never be left half open holding a socket open forever. func TestRelayClosesBothEnds(t *testing.T) {