Default local listeners to 127.0.0.1
CI / check-and-test (pull_request) Successful in 11s

Client TCP/UDP and the built-in DNS server bind loopback unless bind_address or a per-rule host (tcp://22:0.0.0.0:2222) is set. README and generated server examples no longer document :9000/:8080 as if they were localhost-only.

Closes #3
This commit is contained in:
s1d3sw1ped_bot
2026-09-01 04:10:14 +00:00
parent 283781fd47
commit c20398642d
5 changed files with 393 additions and 45 deletions
+106 -11
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"os"
"strconv"
"strings"
@@ -19,6 +20,7 @@ type Config struct {
InstanceID string `yaml:"instance_id"`
ListenAddress string `yaml:"listen_address"`
RemoteAddress string `yaml:"remote_address"`
BindAddress string `yaml:"bind_address"` // local TCP/UDP/DNS bind host; default 127.0.0.1
Ports []PortRule `yaml:"ports"`
EncryptionKey string `yaml:"encryption_key"`
KeepAlive bool `yaml:"keep_alive"`
@@ -31,10 +33,43 @@ type Config struct {
// PortRule defines a port forwarding rule
type PortRule struct {
LocalPort int `yaml:"local_port"`
RemotePort int `yaml:"remote_port"`
Protocol string `yaml:"protocol"` // "tcp" or "udp"
TargetHost string `yaml:"target_host,omitempty"` // Target host for server-side forwarding (defaults to localhost)
LocalPort int `yaml:"local_port"`
RemotePort int `yaml:"remote_port"`
Protocol string `yaml:"protocol"` // "tcp" or "udp"
TargetHost string `yaml:"target_host,omitempty"` // Target host for server-side forwarding (defaults to localhost)
BindAddress string `yaml:"-"` // Client local listen host; empty means use Config.BindAddress
}
// DefaultBindAddress is the loopback host used when bind_address is omitted.
// Binding 0.0.0.0 (all interfaces) requires an explicit override.
const DefaultBindAddress = "127.0.0.1"
// NormalizeBindAddress returns host, or 127.0.0.1 when host is empty.
func NormalizeBindAddress(host string) string {
if host == "" {
return DefaultBindAddress
}
return host
}
// LocalListenAddr returns host:port for a local listener. Empty host becomes 127.0.0.1.
func LocalListenAddr(host string, port int) string {
return net.JoinHostPort(NormalizeBindAddress(host), strconv.Itoa(port))
}
// ClientListenAddr is the address the client binds for a port rule.
// Per-rule BindAddress wins over Config.BindAddress; both default to 127.0.0.1.
func (c *Config) ClientListenAddr(rule PortRule) string {
host := rule.BindAddress
if host == "" && c != nil {
host = c.BindAddress
}
return LocalListenAddr(host, rule.LocalPort)
}
// ListenAddr is the address the built-in DNS server binds. Default 127.0.0.1.
func (d DNSServerConfig) ListenAddr() string {
return LocalListenAddr(d.BindAddress, d.ListenPort)
}
// UnmarshalYAML implements custom YAML unmarshaling for PortRule
@@ -133,21 +168,70 @@ func (p *PortRule) UnmarshalYAML(value *yaml.Node) error {
p.TargetHost = "" // Client doesn't specify target host
return nil
}
} else if len(addressParts) >= 3 {
// Client format with bind host: protocol://targetport:bindhost:localport
// e.g. tcp://22:127.0.0.1:2222 or tcp://22:0.0.0.0:2222
firstColon := strings.Index(addressPart, ":")
lastColon := strings.LastIndex(addressPart, ":")
if firstColon < 0 || lastColon <= firstColon {
return fmt.Errorf("invalid address format: %s (expected 'targetport:bindhost:localport')", addressPart)
}
targetPortStr := addressPart[:firstColon]
bindHost := addressPart[firstColon+1 : lastColon]
localPortStr := addressPart[lastColon+1:]
bindHost = strings.TrimPrefix(bindHost, "[")
bindHost = strings.TrimSuffix(bindHost, "]")
if bindHost == "" {
return fmt.Errorf("bind host is required in format 'targetport:bindhost:localport'")
}
if len(bindHost) > 253 {
return fmt.Errorf("bind host too long")
}
for _, c := range bindHost {
if c < 32 || c > 126 {
return fmt.Errorf("invalid character in bind host")
}
}
targetPort, err := strconv.Atoi(targetPortStr)
if err != nil {
return fmt.Errorf("invalid target port: %s", targetPortStr)
}
localPort, err := strconv.Atoi(localPortStr)
if err != nil {
return fmt.Errorf("invalid local port: %s", localPortStr)
}
if targetPort < 1 || targetPort > 65535 {
return fmt.Errorf("invalid target port: %d (must be 1-65535)", targetPort)
}
if localPort < 1 || localPort > 65535 {
return fmt.Errorf("invalid local port: %d (must be 1-65535)", localPort)
}
p.LocalPort = localPort
p.RemotePort = targetPort
p.Protocol = protocol
p.TargetHost = ""
p.BindAddress = bindHost
return nil
} else {
return fmt.Errorf("invalid address format: %s (expected 'target:port' for server or 'targetport:localport' for client)", addressPart)
return fmt.Errorf("invalid address format: %s (expected 'target:port' for server, 'targetport:localport' for client, or 'targetport:bindhost:localport' for client with bind)", addressPart)
}
}
// MarshalYAML implements custom YAML marshaling for PortRule
func (p PortRule) MarshalYAML() (interface{}, error) {
// Use new format: protocol://target:targetport (server) or protocol://targetport:localport (client)
// Server: protocol://target:targetport
// Client: protocol://targetport:localport
// Client with explicit bind: protocol://targetport:bindhost:localport
if p.TargetHost == "" {
// Client format: protocol://targetport:localport
if p.BindAddress != "" {
return fmt.Sprintf("%s://%d:%s:%d", p.Protocol, p.RemotePort, p.BindAddress, p.LocalPort), nil
}
return fmt.Sprintf("%s://%d:%d", p.Protocol, p.RemotePort, p.LocalPort), nil
} else {
// Server format: protocol://target:targetport
return fmt.Sprintf("%s://%s:%d", p.Protocol, p.TargetHost, p.RemotePort), nil
}
return fmt.Sprintf("%s://%s:%d", p.Protocol, p.TargetHost, p.RemotePort), nil
}
// RateLimitConfig defines rate limiting configuration
@@ -162,6 +246,7 @@ type RateLimitConfig struct {
type DNSServerConfig struct {
Enabled bool `yaml:"enabled"`
ListenPort int `yaml:"listen_port"`
BindAddress string `yaml:"bind_address"`
BackupServer string `yaml:"backup_server"`
CustomRecords []DNSRecord `yaml:"custom_records"`
}
@@ -217,9 +302,15 @@ func LoadConfig(filename string) (*Config, error) {
if config.RateLimit.WindowSize == 0 {
config.RateLimit.WindowSize = 1 * time.Second
}
if config.BindAddress == "" {
config.BindAddress = DefaultBindAddress
}
if config.DNSServer.ListenPort == 0 {
config.DNSServer.ListenPort = 5353
}
if config.DNSServer.BindAddress == "" {
config.DNSServer.BindAddress = DefaultBindAddress
}
if config.DNSServer.BackupServer == "" {
config.DNSServer.BackupServer = "8.8.8.8:53"
}
@@ -457,8 +548,9 @@ func GenerateExampleConfig(filename string) error {
// Generate server configuration
config = Config{
InstanceID: "teleport-server-01",
ListenAddress: ":8080",
ListenAddress: "127.0.0.1:8080",
RemoteAddress: "",
BindAddress: DefaultBindAddress,
Ports: []PortRule{
{LocalPort: 80, RemotePort: 80, Protocol: "tcp", TargetHost: "localhost"},
},
@@ -475,6 +567,7 @@ func GenerateExampleConfig(filename string) error {
},
DNSServer: DNSServerConfig{
ListenPort: 5353,
BindAddress: DefaultBindAddress,
BackupServer: "8.8.8.8:53",
CustomRecords: []DNSRecord{},
},
@@ -485,6 +578,7 @@ func GenerateExampleConfig(filename string) error {
InstanceID: "teleport-client-01",
ListenAddress: "",
RemoteAddress: "localhost:8080",
BindAddress: DefaultBindAddress,
Ports: []PortRule{
{LocalPort: 8080, RemotePort: 80, Protocol: "tcp", TargetHost: ""},
},
@@ -501,6 +595,7 @@ func GenerateExampleConfig(filename string) error {
},
DNSServer: DNSServerConfig{
ListenPort: 5353,
BindAddress: DefaultBindAddress,
BackupServer: "8.8.8.8:53",
CustomRecords: []DNSRecord{
{Name: "app.local", Type: "A", Value: "127.0.0.1", TTL: 300},
+224
View File
@@ -1,6 +1,7 @@
package config
import (
"net"
"os"
"path/filepath"
"strings"
@@ -135,6 +136,14 @@ encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
if config.DNSServer.BackupServer != "8.8.8.8:53" {
t.Errorf("Expected default backup server '8.8.8.8:53', got '%s'", config.DNSServer.BackupServer)
}
if config.BindAddress != DefaultBindAddress {
t.Errorf("Expected default BindAddress %q, got %q", DefaultBindAddress, config.BindAddress)
}
if config.DNSServer.BindAddress != DefaultBindAddress {
t.Errorf("Expected default DNS BindAddress %q, got %q", DefaultBindAddress, config.DNSServer.BindAddress)
}
}
func TestDetectMode(t *testing.T) {
@@ -361,3 +370,218 @@ encryption_key: test-key
t.Errorf("Expected error message to mention 'protocol://', got: %v", err)
}
}
func TestDefaultLocalListenAddrIsLoopback(t *testing.T) {
if got := LocalListenAddr("", 9000); got != "127.0.0.1:9000" {
t.Fatalf("empty host: got %q want 127.0.0.1:9000", got)
}
if got := LocalListenAddr("127.0.0.1", 2222); got != "127.0.0.1:2222" {
t.Fatalf("loopback host: got %q", got)
}
cfg := &Config{}
tcpRule := PortRule{LocalPort: 8080, RemotePort: 80, Protocol: "tcp"}
udpRule := PortRule{LocalPort: 5353, RemotePort: 53, Protocol: "udp"}
if got := cfg.ClientListenAddr(tcpRule); got != "127.0.0.1:8080" {
t.Fatalf("default TCP listen: got %q want 127.0.0.1:8080", got)
}
if got := cfg.ClientListenAddr(udpRule); got != "127.0.0.1:5353" {
t.Fatalf("default UDP listen: got %q want 127.0.0.1:5353", got)
}
dnsCfg := DNSServerConfig{ListenPort: 5353}
if got := dnsCfg.ListenAddr(); got != "127.0.0.1:5353" {
t.Fatalf("default DNS listen: got %q want 127.0.0.1:5353", got)
}
}
func TestExplicitBindAddressOverride(t *testing.T) {
if got := LocalListenAddr("0.0.0.0", 9000); got != "0.0.0.0:9000" {
t.Fatalf("0.0.0.0 override: got %q", got)
}
cfg := &Config{BindAddress: "0.0.0.0"}
rule := PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp"}
if got := cfg.ClientListenAddr(rule); got != "0.0.0.0:2222" {
t.Fatalf("global bind_address 0.0.0.0: got %q", got)
}
rule.BindAddress = "10.0.0.5"
if got := cfg.ClientListenAddr(rule); got != "10.0.0.5:2222" {
t.Fatalf("per-rule host should win: got %q", got)
}
dnsCfg := DNSServerConfig{ListenPort: 5353, BindAddress: "0.0.0.0"}
if got := dnsCfg.ListenAddr(); got != "0.0.0.0:5353" {
t.Fatalf("DNS bind_address 0.0.0.0: got %q", got)
}
}
func TestLocalListenAddrBinds(t *testing.T) {
ln, err := net.Listen("tcp", LocalListenAddr("", 0))
if err != nil {
t.Fatalf("listen default: %v", err)
}
defer ln.Close()
ip := ln.Addr().(*net.TCPAddr).IP
if !ip.IsLoopback() {
t.Fatalf("default TCP bind is not loopback: %v", ip)
}
udpAddr, err := net.ResolveUDPAddr("udp", LocalListenAddr("", 0))
if err != nil {
t.Fatalf("resolve default udp: %v", err)
}
uc, err := net.ListenUDP("udp", udpAddr)
if err != nil {
t.Fatalf("listen default udp: %v", err)
}
defer uc.Close()
if !uc.LocalAddr().(*net.UDPAddr).IP.IsLoopback() {
t.Fatalf("default UDP bind is not loopback: %v", uc.LocalAddr())
}
all, err := net.Listen("tcp", LocalListenAddr("0.0.0.0", 0))
if err != nil {
t.Fatalf("listen 0.0.0.0: %v", err)
}
defer all.Close()
if !all.Addr().(*net.TCPAddr).IP.IsUnspecified() {
t.Fatalf("explicit 0.0.0.0 bind is not unspecified: %v", all.Addr())
}
}
func TestPortRuleBindHostFormat(t *testing.T) {
tests := []struct {
name string
config string
expected PortRule
}{
{
name: "client with loopback bind",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://22:127.0.0.1:2222
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp", BindAddress: "127.0.0.1"},
},
{
name: "client with all-interfaces bind",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://22:0.0.0.0:2222
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 2222, RemotePort: 22, Protocol: "tcp", BindAddress: "0.0.0.0"},
},
{
name: "client two-part still defaults bind via config",
config: `
instance_id: test
remote_address: localhost:8080
ports:
- tcp://80:8080
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
`,
expected: PortRule{LocalPort: 8080, RemotePort: 80, Protocol: "tcp", BindAddress: ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "test-config.yaml")
if err := os.WriteFile(configFile, []byte(tt.config), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := LoadConfig(configFile)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.Ports) != 1 {
t.Fatalf("expected 1 port, got %d", len(cfg.Ports))
}
port := cfg.Ports[0]
if port.LocalPort != tt.expected.LocalPort || port.RemotePort != tt.expected.RemotePort ||
port.Protocol != tt.expected.Protocol || port.BindAddress != tt.expected.BindAddress {
t.Errorf("got %+v want %+v", port, tt.expected)
}
})
}
}
func TestLoadConfigGlobalBindAddress(t *testing.T) {
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "cfg.yaml")
content := `
instance_id: test
remote_address: localhost:8080
bind_address: 0.0.0.0
ports:
- tcp://80:8080
encryption_key: a0e3dd20a761b118ca234160dd8b87230a001e332a97c9cfe3b8b9c99efaae03
dns_server:
enabled: true
listen_port: 5353
bind_address: 0.0.0.0
backup_server: 8.8.8.8:53
`
if err := os.WriteFile(configFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(configFile)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.BindAddress != "0.0.0.0" {
t.Fatalf("BindAddress got %q", cfg.BindAddress)
}
if cfg.DNSServer.BindAddress != "0.0.0.0" {
t.Fatalf("DNS BindAddress got %q", cfg.DNSServer.BindAddress)
}
if got := cfg.ClientListenAddr(cfg.Ports[0]); got != "0.0.0.0:8080" {
t.Fatalf("client listen got %q", got)
}
if got := cfg.DNSServer.ListenAddr(); got != "0.0.0.0:5353" {
t.Fatalf("dns listen got %q", got)
}
}
func TestGenerateExampleConfigUsesLoopback(t *testing.T) {
tempDir := t.TempDir()
serverFile := filepath.Join(tempDir, "server.yaml")
if err := GenerateExampleConfig(serverFile); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(serverFile)
if err != nil {
t.Fatal(err)
}
body := string(data)
if strings.Contains(body, "listen_address: :8080") || strings.Contains(body, "listen_address: :9000") {
t.Fatalf("generated server config still documents all-interfaces listen: %s", body)
}
if !strings.Contains(body, "127.0.0.1:8080") {
t.Fatalf("generated server config missing loopback listen_address: %s", body)
}
clientFile := filepath.Join(tempDir, "client.yaml")
if err := GenerateExampleConfig(clientFile); err != nil {
t.Fatal(err)
}
cfg, err := LoadConfig(clientFile)
if err != nil {
t.Fatalf("reload generated client: %v", err)
}
if cfg.BindAddress != DefaultBindAddress {
t.Fatalf("generated client bind_address %q", cfg.BindAddress)
}
if got := cfg.ClientListenAddr(cfg.Ports[0]); !strings.HasPrefix(got, "127.0.0.1:") {
t.Fatalf("generated client local listen %q", got)
}
}