Replace built-in proxies with tun2socks transparent proxying

Remove the built-in HTTP/SOCKS5 proxy servers and domain allowlist/denylist
system. Instead, use tun2socks with a TUN device inside the network namespace
to transparently route all TCP/UDP traffic through an external SOCKS5 proxy.

This enables truly transparent proxying where any binary (Go, static, etc.)
has its traffic routed through the proxy without needing to respect
HTTP_PROXY/ALL_PROXY environment variables. The external proxy handles its
own filtering.

Key changes:
- NetworkConfig: remove AllowedDomains/DeniedDomains/proxy ports, add ProxyURL
- Delete internal/proxy/, internal/templates/, internal/importer/
- Embed tun2socks binary (downloaded at build time via Makefile)
- Replace LinuxBridge with ProxyBridge (single Unix socket to external proxy)
- Inner script sets up TUN device + tun2socks inside network namespace
- Falls back to env-var proxying when TUN is unavailable
- macOS: best-effort env-var proxying to external SOCKS5 proxy
- CLI: remove --template/import, add --proxy flag
- Feature detection: add ip/tun/tun2socks status to --linux-features
This commit is contained in:
2026-02-09 20:41:12 -06:00
parent da5f61e390
commit 9cb65151ee
38 changed files with 588 additions and 4155 deletions

View File

@@ -1,331 +0,0 @@
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
package proxy
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/Use-Tusk/fence/internal/config"
)
// FilterFunc determines if a connection to host:port should be allowed.
type FilterFunc func(host string, port int) bool
// HTTPProxy is an HTTP/HTTPS proxy server with domain filtering.
type HTTPProxy struct {
server *http.Server
listener net.Listener
filter FilterFunc
debug bool
monitor bool
mu sync.RWMutex
running bool
}
// NewHTTPProxy creates a new HTTP proxy with the given filter.
// If monitor is true, only blocked requests are logged.
// If debug is true, all requests and filter rules are logged.
func NewHTTPProxy(filter FilterFunc, debug, monitor bool) *HTTPProxy {
return &HTTPProxy{
filter: filter,
debug: debug,
monitor: monitor,
}
}
// Start starts the HTTP proxy on a random available port.
func (p *HTTPProxy) Start() (int, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("failed to listen: %w", err)
}
p.listener = listener
p.server = &http.Server{
Handler: http.HandlerFunc(p.handleRequest),
ReadHeaderTimeout: 10 * time.Second,
}
p.mu.Lock()
p.running = true
p.mu.Unlock()
go func() {
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
p.logDebug("HTTP proxy server error: %v", err)
}
}()
addr := listener.Addr().(*net.TCPAddr)
p.logDebug("HTTP proxy listening on localhost:%d", addr.Port)
return addr.Port, nil
}
// Stop stops the HTTP proxy.
func (p *HTTPProxy) Stop() error {
p.mu.Lock()
p.running = false
p.mu.Unlock()
if p.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return p.server.Shutdown(ctx)
}
return nil
}
// Port returns the port the proxy is listening on.
func (p *HTTPProxy) Port() int {
if p.listener == nil {
return 0
}
return p.listener.Addr().(*net.TCPAddr).Port
}
func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
} else {
p.handleHTTP(w, r)
}
}
// handleConnect handles HTTPS CONNECT requests (tunnel).
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
start := time.Now()
host, portStr, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
portStr = "443"
}
port := 443
if portStr != "" {
if p, err := strconv.Atoi(portStr); err == nil {
port = p
}
}
// Check if allowed
if !p.filter(host, port) {
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 403, "BLOCKED", time.Since(start))
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
return
}
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
// Connect to target
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
if err != nil {
p.logDebug("CONNECT dial failed: %s:%d: %v", host, port, err)
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer func() { _ = targetConn.Close() }()
// Hijack the connection
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
return
}
defer func() { _ = clientConn.Close() }()
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return
}
// Pipe data bidirectionally
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(targetConn, clientConn)
}()
go func() {
defer wg.Done()
_, _ = io.Copy(clientConn, targetConn)
}()
wg.Wait()
}
// handleHTTP handles regular HTTP proxy requests.
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
targetURL, err := url.Parse(r.RequestURI)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
host := targetURL.Hostname()
port := 80
if targetURL.Port() != "" {
if p, err := strconv.Atoi(targetURL.Port()); err == nil {
port = p
}
} else if targetURL.Scheme == "https" {
port = 443
}
if !p.filter(host, port) {
p.logRequest(r.Method, r.RequestURI, host, 403, "BLOCKED", time.Since(start))
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
return
}
// Create new request and copy headers
proxyReq, err := http.NewRequest(r.Method, r.RequestURI, r.Body)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
for key, values := range r.Header {
for _, value := range values {
proxyReq.Header.Add(key, value)
}
}
proxyReq.Host = targetURL.Host
// Remove hop-by-hop headers
proxyReq.Header.Del("Proxy-Connection")
proxyReq.Header.Del("Proxy-Authorization")
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(proxyReq)
if err != nil {
p.logRequest(r.Method, r.RequestURI, host, 502, "ERROR", time.Since(start))
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
// Copy response headers
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
p.logRequest(r.Method, r.RequestURI, host, resp.StatusCode, "ALLOWED", time.Since(start))
}
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
if p.debug {
fmt.Fprintf(os.Stderr, "[fence:http] "+format+"\n", args...)
}
}
// logRequest logs a detailed request entry.
// In monitor mode (-m), only blocked/error requests are logged.
// In debug mode (-d), all requests are logged.
func (p *HTTPProxy) logRequest(method, url, host string, status int, action string, duration time.Duration) {
isBlocked := action == "BLOCKED" || action == "ERROR"
if p.monitor && !p.debug && !isBlocked {
return
}
if !p.debug && !p.monitor {
return
}
timestamp := time.Now().Format("15:04:05")
statusIcon := "✓"
switch action {
case "BLOCKED":
statusIcon = "✗"
case "ERROR":
statusIcon = "!"
}
fmt.Fprintf(os.Stderr, "[fence:http] %s %s %-7s %d %s %s (%v)\n", timestamp, statusIcon, method, status, host, truncateURL(url, 60), duration.Round(time.Millisecond))
}
// truncateURL shortens a URL for display.
func truncateURL(url string, maxLen int) string {
if len(url) <= maxLen {
return url
}
return url[:maxLen-3] + "..."
}
// CreateDomainFilter creates a filter function from a config.
// When debug is true, logs filter rule matches to stderr.
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
return func(host string, port int) bool {
if cfg == nil {
// No config = deny all
if debug {
fmt.Fprintf(os.Stderr, "[fence:filter] No config, denying: %s:%d\n", host, port)
}
return false
}
// Check denied domains first
for _, denied := range cfg.Network.DeniedDomains {
if config.MatchesDomain(host, denied) {
if debug {
fmt.Fprintf(os.Stderr, "[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
}
return false
}
}
// Check allowed domains
for _, allowed := range cfg.Network.AllowedDomains {
if config.MatchesDomain(host, allowed) {
if debug {
fmt.Fprintf(os.Stderr, "[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
}
return true
}
}
if debug {
fmt.Fprintf(os.Stderr, "[fence:filter] No matching rule, denying: %s:%d\n", host, port)
}
return false
}
}
// GetHostFromRequest extracts the hostname from a request.
func GetHostFromRequest(r *http.Request) string {
host := r.Host
if h := r.URL.Hostname(); h != "" {
host = h
}
// Strip port
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
return host
}

View File

@@ -1,308 +0,0 @@
package proxy
import (
"net/http"
"net/url"
"testing"
"github.com/Use-Tusk/fence/internal/config"
)
func TestTruncateURL(t *testing.T) {
tests := []struct {
name string
url string
maxLen int
want string
}{
{"short url", "https://example.com", 50, "https://example.com"},
{"exact length", "https://example.com", 19, "https://example.com"},
{"needs truncation", "https://example.com/very/long/path/to/resource", 30, "https://example.com/very/lo..."},
{"empty url", "", 50, ""},
{"very short max", "https://example.com", 10, "https:/..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := truncateURL(tt.url, tt.maxLen)
if got != tt.want {
t.Errorf("truncateURL(%q, %d) = %q, want %q", tt.url, tt.maxLen, got, tt.want)
}
})
}
}
func TestGetHostFromRequest(t *testing.T) {
tests := []struct {
name string
host string
urlStr string
wantHost string
}{
{
name: "host header only",
host: "example.com",
urlStr: "/path",
wantHost: "example.com",
},
{
name: "host header with port",
host: "example.com:8080",
urlStr: "/path",
wantHost: "example.com",
},
{
name: "full URL overrides host",
host: "other.com",
urlStr: "http://example.com/path",
wantHost: "example.com",
},
{
name: "url with port",
host: "other.com",
urlStr: "http://example.com:9000/path",
wantHost: "example.com",
},
{
name: "ipv6 host",
host: "[::1]:8080",
urlStr: "/path",
wantHost: "[::1]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsedURL, _ := url.Parse(tt.urlStr)
req := &http.Request{
Host: tt.host,
URL: parsedURL,
}
got := GetHostFromRequest(req)
if got != tt.wantHost {
t.Errorf("GetHostFromRequest() = %q, want %q", got, tt.wantHost)
}
})
}
}
func TestCreateDomainFilter(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
host string
port int
allowed bool
}{
{
name: "nil config denies all",
cfg: nil,
host: "example.com",
port: 443,
allowed: false,
},
{
name: "allowed domain",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
},
host: "example.com",
port: 443,
allowed: true,
},
{
name: "denied domain takes precedence",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
DeniedDomains: []string{"example.com"},
},
},
host: "example.com",
port: 443,
allowed: false,
},
{
name: "wildcard allowed",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
},
},
host: "api.example.com",
port: 443,
allowed: true,
},
{
name: "wildcard denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
DeniedDomains: []string{"*.blocked.example.com"},
},
},
host: "api.blocked.example.com",
port: 443,
allowed: false,
},
{
name: "unmatched domain denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com"},
},
},
host: "other.com",
port: 443,
allowed: false,
},
{
name: "empty allowed list denies all",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
},
},
host: "example.com",
port: 443,
allowed: false,
},
{
name: "star wildcard allows all",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
},
},
host: "any-domain.example.com",
port: 443,
allowed: true,
},
{
name: "star wildcard with deny list",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
DeniedDomains: []string{"blocked.com"},
},
},
host: "blocked.com",
port: 443,
allowed: false,
},
{
name: "star wildcard allows non-denied",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
DeniedDomains: []string{"blocked.com"},
},
},
host: "allowed.com",
port: 443,
allowed: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter := CreateDomainFilter(tt.cfg, false)
got := filter(tt.host, tt.port)
if got != tt.allowed {
t.Errorf("CreateDomainFilter() filter(%q, %d) = %v, want %v", tt.host, tt.port, got, tt.allowed)
}
})
}
}
func TestCreateDomainFilterCaseInsensitive(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"Example.COM"},
},
}
filter := CreateDomainFilter(cfg, false)
tests := []struct {
host string
allowed bool
}{
{"example.com", true},
{"EXAMPLE.COM", true},
{"Example.Com", true},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
got := filter(tt.host, 443)
if got != tt.allowed {
t.Errorf("filter(%q) = %v, want %v", tt.host, got, tt.allowed)
}
})
}
}
func TestNewHTTPProxy(t *testing.T) {
filter := func(host string, port int) bool { return true }
tests := []struct {
name string
debug bool
monitor bool
}{
{"default", false, false},
{"debug mode", true, false},
{"monitor mode", false, true},
{"both modes", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy := NewHTTPProxy(filter, tt.debug, tt.monitor)
if proxy == nil {
t.Fatal("NewHTTPProxy() returned nil")
}
if proxy.debug != tt.debug {
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
}
if proxy.monitor != tt.monitor {
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
}
})
}
}
func TestHTTPProxyStartStop(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewHTTPProxy(filter, false, false)
port, err := proxy.Start()
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if port <= 0 {
t.Errorf("Start() returned invalid port: %d", port)
}
if proxy.Port() != port {
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
}
if err := proxy.Stop(); err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestHTTPProxyPortBeforeStart(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewHTTPProxy(filter, false, false)
if proxy.Port() != 0 {
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
}
}

View File

@@ -1,106 +0,0 @@
package proxy
import (
"context"
"fmt"
"net"
"os"
"time"
"github.com/things-go/go-socks5"
)
// SOCKSProxy is a SOCKS5 proxy server with domain filtering.
type SOCKSProxy struct {
server *socks5.Server
listener net.Listener
filter FilterFunc
debug bool
monitor bool
port int
}
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
// If monitor is true, only blocked connections are logged.
// If debug is true, all connections are logged.
func NewSOCKSProxy(filter FilterFunc, debug, monitor bool) *SOCKSProxy {
return &SOCKSProxy{
filter: filter,
debug: debug,
monitor: monitor,
}
}
// fenceRuleSet implements socks5.RuleSet for domain filtering.
type fenceRuleSet struct {
filter FilterFunc
debug bool
monitor bool
}
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
host := req.DestAddr.FQDN
if host == "" {
host = req.DestAddr.IP.String()
}
port := req.DestAddr.Port
allowed := r.filter(host, port)
shouldLog := r.debug || (r.monitor && !allowed)
if shouldLog {
timestamp := time.Now().Format("15:04:05")
if allowed {
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✓ CONNECT %s:%d ALLOWED\n", timestamp, host, port)
} else {
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✗ CONNECT %s:%d BLOCKED\n", timestamp, host, port)
}
}
return ctx, allowed
}
// Start starts the SOCKS5 proxy on a random available port.
func (p *SOCKSProxy) Start() (int, error) {
// Create listener first to get a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("failed to listen: %w", err)
}
p.listener = listener
p.port = listener.Addr().(*net.TCPAddr).Port
server := socks5.NewServer(
socks5.WithRule(&fenceRuleSet{
filter: p.filter,
debug: p.debug,
monitor: p.monitor,
}),
)
p.server = server
go func() {
if err := p.server.Serve(p.listener); err != nil {
if p.debug {
fmt.Fprintf(os.Stderr, "[fence:socks] Server error: %v\n", err)
}
}
}()
if p.debug {
fmt.Fprintf(os.Stderr, "[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
}
return p.port, nil
}
// Stop stops the SOCKS5 proxy.
func (p *SOCKSProxy) Stop() error {
if p.listener != nil {
return p.listener.Close()
}
return nil
}
// Port returns the port the proxy is listening on.
func (p *SOCKSProxy) Port() int {
return p.port
}

View File

@@ -1,130 +0,0 @@
package proxy
import (
"context"
"net"
"testing"
"github.com/things-go/go-socks5"
"github.com/things-go/go-socks5/statute"
)
func TestFenceRuleSetAllow(t *testing.T) {
tests := []struct {
name string
fqdn string
ip net.IP
port int
allowed bool
}{
{
name: "allow by FQDN",
fqdn: "allowed.com",
port: 443,
allowed: true,
},
{
name: "deny by FQDN",
fqdn: "blocked.com",
port: 443,
allowed: false,
},
{
name: "fallback to IP when FQDN empty",
fqdn: "",
ip: net.ParseIP("1.2.3.4"),
port: 80,
allowed: false,
},
{
name: "allow with IP fallback",
fqdn: "",
ip: net.ParseIP("127.0.0.1"),
port: 8080,
allowed: true,
},
}
filter := func(host string, port int) bool {
return host == "allowed.com" || host == "127.0.0.1"
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rs := &fenceRuleSet{filter: filter, debug: false, monitor: false}
req := &socks5.Request{
DestAddr: &statute.AddrSpec{
FQDN: tt.fqdn,
IP: tt.ip,
Port: tt.port,
},
}
_, allowed := rs.Allow(context.Background(), req)
if allowed != tt.allowed {
t.Errorf("Allow() = %v, want %v", allowed, tt.allowed)
}
})
}
}
func TestNewSOCKSProxy(t *testing.T) {
filter := func(host string, port int) bool { return true }
tests := []struct {
name string
debug bool
monitor bool
}{
{"default", false, false},
{"debug mode", true, false},
{"monitor mode", false, true},
{"both modes", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy := NewSOCKSProxy(filter, tt.debug, tt.monitor)
if proxy == nil {
t.Fatal("NewSOCKSProxy() returned nil")
}
if proxy.debug != tt.debug {
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
}
if proxy.monitor != tt.monitor {
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
}
})
}
}
func TestSOCKSProxyStartStop(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewSOCKSProxy(filter, false, false)
port, err := proxy.Start()
if err != nil {
t.Fatalf("Start() error = %v", err)
}
if port <= 0 {
t.Errorf("Start() returned invalid port: %d", port)
}
if proxy.Port() != port {
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
}
if err := proxy.Stop(); err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestSOCKSProxyPortBeforeStart(t *testing.T) {
filter := func(host string, port int) bool { return true }
proxy := NewSOCKSProxy(filter, false, false)
if proxy.Port() != 0 {
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
}
}