-اول خانوادهی ترنسپورت (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 92c8d28..db5b380 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 || cfg.EffectiveEngine() == config.EngineForward {
+ 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 8b15204..ae2faca 100644
--- a/cmd/defaults.go
+++ b/cmd/defaults.go
@@ -42,6 +42,9 @@ const ( // Default values
)
func applyDefaults(cfg *config.Config) {
+ if cfg.EffectiveEngine() != config.EngineReverse && cfg.EffectiveEngine() != config.EngineForward {
+ return
+ }
// Token
if cfg.Server.Token == "" {
cfg.Server.Token = defaultToken
diff --git a/config/config.go b/config/config.go
index fb7a339..678bd07 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,5 +1,95 @@
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"
+ // 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"
+)
+
+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
@@ -220,6 +310,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"`
@@ -292,6 +386,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
@@ -309,6 +409,172 @@ 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 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)
+ }
+ 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..6ce1e86
--- /dev/null
+++ b/config/engine_test.go
@@ -0,0 +1,146 @@
+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 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 {
+ 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..3e69360
--- /dev/null
+++ b/docs/direct-forward.md
@@ -0,0 +1,62 @@
+# Direct connection mode
+
+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):
+
+- **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.
+
+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.
+
+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).
+
+```toml
+# Iran
+engine = "forward"
+
+[client]
+remote_addr = "KHAREJ_IP:8443"
+transport = "tcpmux"
+token = "SAME_LONG_TOKEN"
+ports = ["443=127.0.0.1:443"]
+```
+
+```toml
+# Kharej
+engine = "forward"
+
+[server]
+bind_addr = "0.0.0.0:8443"
+transport = "tcpmux"
+token = "SAME_LONG_TOKEN"
+```
+
+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/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/client/client.go b/internal/client/client.go
index d1ceccf..8a55a90 100644
--- a/internal/client/client.go
+++ b/internal/client/client.go
@@ -21,10 +21,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 {
@@ -42,6 +43,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
@@ -106,7 +115,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()
@@ -133,6 +148,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()
@@ -209,6 +229,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()
@@ -228,6 +253,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()
@@ -250,6 +280,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()
@@ -276,6 +310,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()
@@ -294,6 +333,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_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
new file mode 100644
index 0000000..2f6f6d7
--- /dev/null
+++ b/internal/client/transport/forward_tcp.go
@@ -0,0 +1,190 @@
+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 {
+ 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
+}
+
+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.restartGeneration(ctx)
+ return
+ }
+ for _, mapping := range mappings {
+ mapping := mapping
+ go c.runForwardTCPListener(ctx, usage, pool, mapping)
+ }
+}
+
+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.restartGeneration(ctx)
+ return
+ }
+ defer listener.Close()
+ go func() {
+ <-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 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(ctx, usage, pool, 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(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(ctx, pool, 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(ctx, c.config.ProxyProtocol, local, metrics.CountedConn(tunnel), c.logger, 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/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 2371060..0fd7403 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,16 @@ 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
+ // checks out one authenticated, pre-warmed 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 +94,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
@@ -98,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...")
@@ -164,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
@@ -173,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
@@ -185,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
}
@@ -196,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
}
@@ -208,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
}
@@ -216,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
}
@@ -230,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)
@@ -244,28 +269,102 @@ 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.poolMaintainer()
- 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(ctx)
+ continue
+ }
+ 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(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
}
}
}
}
-func (c *TcpTransport) poolMaintainer() {
+// 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(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)
+ }
+ 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))
+ }
+ 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))
+ }
+ 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(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
@@ -294,7 +393,7 @@ func (c *TcpTransport) poolMaintainer() {
for {
select {
- case <-c.state.Ctx().Done():
+ case <-ctx.Done():
return
case <-tickerPool.C:
@@ -328,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--
@@ -341,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
}
@@ -367,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:
@@ -381,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:
@@ -389,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
}
}
@@ -410,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)
@@ -435,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
@@ -467,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.")
@@ -478,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)
@@ -494,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()
@@ -503,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/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..1bfab59
--- /dev/null
+++ b/internal/e2e/forward_tcp_test.go
@@ -0,0 +1,258 @@
+package e2e
+
+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"
+)
+
+// 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) })
+ }
+}
+
+// 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)
+ 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
new file mode 100644
index 0000000..c83fdde
--- /dev/null
+++ b/internal/engine/engine.go
@@ -0,0 +1,104 @@
+// 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
+ // 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 {
+ 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/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/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..d1be278
--- /dev/null
+++ b/internal/engine/reverse.go
@@ -0,0 +1,81 @@
+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))
+}
+
+// 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)
+ _ = c.Write()
+ 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 {
+ if err := (reverseProvider{}).Validate(ctx, r); err != nil {
+ return err
+ }
+ if r.Config.HasServer() {
+ 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
+ }
+ 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
+}
+
+// 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, 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/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..f449a81 100644
--- a/internal/manage/backup.go
+++ b/internal/manage/backup.go
@@ -3,16 +3,21 @@ package manage
import (
"archive/tar"
"compress/gzip"
+ "context"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "net"
"os"
"path/filepath"
"sort"
"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 +155,105 @@ 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, 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, Replacing: replacing})
+ }
+ if err != nil {
+ return fmt.Errorf("restored instance %s is invalid or conflicts with this host: %w", name, err)
+ }
+ }
+ 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
@@ -170,6 +274,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 +325,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 +366,145 @@ 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, true); err != nil {
+ return res, err
+ }
+ if err := validateRestoreClaims(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)
+ }
+ }
+ }
+ 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 {
+ 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)
+ // 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("restart 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/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 58d985b..ad50db3 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
@@ -127,6 +134,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 {
@@ -231,13 +280,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
@@ -270,8 +321,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)
@@ -335,11 +390,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()
}
@@ -395,6 +452,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)
@@ -406,6 +477,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()
}
@@ -415,17 +493,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 4c046c1..c6421f6 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.KernelDirect() {
+ return directChecksFor(t)
+ }
var out []Check
g := "Tunnel: " + t.Name
h := tunnelHealthWith(t, pairs)
@@ -279,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" {
@@ -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..155da1e
--- /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("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()
+ 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/edit.go b/internal/manage/edit.go
index d364bf2..078151b 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,
@@ -84,21 +92,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,
@@ -115,6 +133,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,
@@ -225,7 +247,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")
}
@@ -263,6 +285,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) {
@@ -291,7 +318,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")
}
@@ -368,7 +395,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)
@@ -399,7 +426,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 {
@@ -478,12 +505,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)
@@ -505,7 +541,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 {
@@ -546,7 +582,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 76ed60b..caf7a66 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
@@ -77,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 6fafae0..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,15 @@ func ManageTunnels() {
tui.Clear()
opts := make([]tui.Option, len(tunnels))
for i, t := range tunnels {
+ 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,
- Desc: fmt.Sprintf("%s %s — %s", t.Role, t.Transport, plainState(t.Service)),
+ Desc: desc,
}
}
@@ -55,11 +62,21 @@ 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.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.KernelDirect() {
+ 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 +85,11 @@ func manageOne(t Tunnel) {
})
switch idx {
case 0:
- editPortsMenu(t.Name)
+ if t.KernelDirect() {
+ editDirectMenu(t.Name)
+ } else {
+ editPortsMenu(t.Name)
+ }
case 1:
report(StartService(t.Service), "started")
case 2:
@@ -118,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))
@@ -202,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()
@@ -220,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
@@ -231,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()
@@ -283,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 e163436..d96300a 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 {
@@ -449,15 +482,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) {
@@ -465,13 +513,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))
@@ -505,9 +563,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)
@@ -528,13 +593,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}
@@ -654,9 +727,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 a10e624..802363e 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.Role == "server" && len(t.Ports) > 0 {
+ 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" || (t.AppForward() && t.Role == "client")) && 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..4f5d394 100644
--- a/internal/manage/tunnel.go
+++ b/internal/manage/tunnel.go
@@ -1,38 +1,74 @@
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
}
+// 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
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)
@@ -42,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
}
@@ -55,20 +94,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/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 b70d707..10d1c1b 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,9 +95,28 @@ 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 {
+ 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 66d76d0..8ae156b 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"
@@ -76,7 +78,7 @@ func Run() {
updateMenu()
case "9":
uninstallMenu()
- case "10", "0":
+ case "10", "11", "0":
tui.Info("Goodbye!")
return
default:
@@ -101,9 +103,9 @@ func printUpdateBanner() {
// 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, "Manage", "tunnels, ports, transport, status, health check")
+ 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")
@@ -1014,13 +1016,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..8bfb0fc 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.
@@ -129,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
@@ -155,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
@@ -168,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
}
@@ -184,12 +194,15 @@ 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.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}
@@ -272,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.
@@ -289,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
}
@@ -297,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
}
@@ -322,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 e994459..90dcc10 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)
@@ -152,6 +164,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)
@@ -173,6 +186,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)
@@ -201,6 +215,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)
@@ -228,6 +243,7 @@ func (s *Server) Start() {
MaxConnections: s.config.MaxConnections,
BandwidthMbps: s.config.BandwidthMbps,
+ Forward: s.forward,
}
wsServer := transport.NewWSServer(s.ctx, wsConfig, s.logger)
@@ -260,6 +276,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)
@@ -277,6 +294,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 e8ba7dd..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, "=")
@@ -572,12 +656,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..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()
@@ -459,12 +470,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..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) {
@@ -577,12 +608,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..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) {
@@ -601,12 +628,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..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, "=")
@@ -536,12 +623,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..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, "=")
@@ -451,12 +487,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..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, "=")
@@ -476,12 +500,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)
diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go
index 769e66a..f89ed39 100644
--- a/internal/telegram/telegram.go
+++ b/internal/telegram/telegram.go
@@ -157,13 +157,28 @@ 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.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 != "" {
fmt.Fprintf(&b, " [ %s ]", p)
}
b.WriteString("\n")
- if t.Role == "server" {
+ 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 {
fmt.Fprintf(&b, tr(lang, "Forwarded Port")+" : %s\n", strings.Join(ports, ", "))
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/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) {
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 810520f..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
${ICON.bot}
';
+ 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');
@@ -2163,7 +2184,8 @@
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')+
'