Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.2.5 2026-08-24

### Changed

- Entrypoints bind IPv4 and IPv6 loopback addresses by default, preventing LAN exposure while supporting both localhost stacks.
- Entrypoint `listen` accepts one address or a list; legacy wildcard addresses now resolve to both loopback addresses.
- **Breaking:** Entrypoints accept loopback IP addresses only. Replace LAN addresses with `127.0.0.1:port` and `[::1]:port`.

## 0.2.4 2026-08-20

### Fixed
Expand Down
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,34 +230,46 @@ dns:
upstream: "8.8.8.8:53"

# Entrypoints define the ports devproxy listens on
# Entrypoints are restricted to IPv4 and IPv6 loopback addresses
# Reserved names: "http" and "https" are handled specially
entrypoints:
# HTTP entrypoint (redirects to HTTPS by default)
http:
listen: ":80"
listen:
- "127.0.0.1:80"
- "[::1]:80"

# HTTPS entrypoint (TLS termination with auto-generated certs)
https:
listen: ":443"
listen:
- "127.0.0.1:443"
- "[::1]:443"

# TCP entrypoints for databases and other services
# The name is used in container labels: devproxy.entrypoint=postgres
# Note: Only postgres and mongo are included by default
postgres:
listen: ":15432" # Port devproxy listens on
listen:
- "127.0.0.1:15432"
- "[::1]:15432"
target_port: 5432 # Default backend port (optional)

mongo:
listen: ":27017"
listen:
- "127.0.0.1:27017"
- "[::1]:27017"
target_port: 27017

# Additional entrypoints can be added as needed:
# Additional entrypoints can be added as needed. A single string remains valid;
# legacy wildcard strings such as ":13306" are restricted to both loopback addresses:
# mysql:
# listen: ":13306"
# listen: "127.0.0.1:13306"
# target_port: 3306
#
# redis:
# listen: ":16379"
# listen:
# - "127.0.0.1:16379"
# - "[::1]:16379"
# target_port: 6379

# Docker integration settings
Expand Down Expand Up @@ -287,11 +299,11 @@ If no configuration file exists, devproxy creates one with these defaults:
| `dns.listen` | `:15353` |
| `dns.domains` | `["localhost"]` |
| `dns.upstream` | `8.8.8.8:53` |
| `entrypoints.http.listen` | `:80` |
| `entrypoints.https.listen` | `:443` |
| `entrypoints.postgres.listen` | `:15432` |
| `entrypoints.http.listen` | `["127.0.0.1:80", "[::1]:80"]` |
| `entrypoints.https.listen` | `["127.0.0.1:443", "[::1]:443"]` |
| `entrypoints.postgres.listen` | `["127.0.0.1:15432", "[::1]:15432"]` |
| `entrypoints.postgres.target_port` | `5432` |
| `entrypoints.mongo.listen` | `:27017` |
| `entrypoints.mongo.listen` | `["127.0.0.1:27017", "[::1]:27017"]` |
| `entrypoints.mongo.target_port` | `27017` |
| `docker.enabled` | `true` |
| `docker.socket` | `unix:///var/run/docker.sock` |
Expand Down
171 changes: 93 additions & 78 deletions cmd/devproxy/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"syscall"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -63,6 +64,37 @@ func init() {
rootCmd.AddCommand(runCmd, initConfigCmd)
}

func listenAll(addresses []string) ([]net.Listener, error) {
return listenAllWith(addresses, net.Listen)
}

func listenAllWith(addresses []string, listen func(string, string) (net.Listener, error)) ([]net.Listener, error) {
listeners := make([]net.Listener, 0, len(addresses))
var unavailableErr error
for _, address := range addresses {
listener, err := listen("tcp", address)
if err != nil {
if errors.Is(err, syscall.EAFNOSUPPORT) || errors.Is(err, syscall.EADDRNOTAVAIL) {
unavailableErr = fmt.Errorf("failed to listen on %s: %w", address, err)
continue
}
closeListeners(listeners)
return nil, fmt.Errorf("failed to listen on %s: %w", address, err)
Comment thread
scflode marked this conversation as resolved.
}
listeners = append(listeners, listener)
}
if len(listeners) == 0 && unavailableErr != nil {
return nil, unavailableErr
}
return listeners, nil
}

func closeListeners(listeners []net.Listener) {
for _, listener := range listeners {
_ = listener.Close()
}
}

func loadConfig(originalUser *privilege.Info) (*config.Config, error) {
if originalUser == nil {
return config.Load()
Expand Down Expand Up @@ -109,51 +141,46 @@ func runDaemon() error {
httpCfg, _ := cfg.GetEntrypoint("http")
httpsCfg, _ := cfg.GetEntrypoint("https")

// Bind HTTP port
httpListener, err := net.Listen("tcp", httpCfg.Listen)
// Bind HTTP ports
httpListeners, err := listenAll(httpCfg.Listen)
if err != nil {
return fmt.Errorf("failed to bind HTTP port %s: %w", httpCfg.Listen, err)
return fmt.Errorf("failed to bind HTTP ports: %w", err)
}
defer closeListeners(httpListeners)

// Bind HTTPS port
httpsListener, err := net.Listen("tcp", httpsCfg.Listen)
// Bind HTTPS ports
httpsListeners, err := listenAll(httpsCfg.Listen)
if err != nil {
httpListener.Close()
return fmt.Errorf("failed to bind HTTPS port %s: %w", httpsCfg.Listen, err)
return fmt.Errorf("failed to bind HTTPS ports: %w", err)
}
defer closeListeners(httpsListeners)

// Bind DNS port if enabled
var dnsListener net.PacketConn
if cfg.DNS.Enabled {
dnsListener, err = net.ListenPacket("udp", cfg.DNS.Listen)
if err != nil {
httpListener.Close()
httpsListener.Close()
return fmt.Errorf("failed to bind DNS port %s: %w", cfg.DNS.Listen, err)
}
defer dnsListener.Close()
}

// Bind TCP entrypoint ports
tcpListeners := make(map[string]net.Listener)
tcpListeners := make(map[string][]net.Listener)
var allTCPListeners []net.Listener
for name, epCfg := range cfg.Entrypoints {
if name == "http" || name == "https" || epCfg.TargetPort <= 0 {
continue
}
listener, err := net.Listen("tcp", epCfg.Listen)
listeners, err := listenAll(epCfg.Listen)
if err != nil {
// Clean up already-bound listeners
httpListener.Close()
httpsListener.Close()
if dnsListener != nil {
dnsListener.Close()
}
for _, l := range tcpListeners {
l.Close()
}
return fmt.Errorf("failed to bind TCP entrypoint %s on %s: %w", name, epCfg.Listen, err)
closeListeners(allTCPListeners)
return fmt.Errorf("failed to bind TCP entrypoint %s: %w", name, err)
}
tcpListeners[name] = listener
tcpListeners[name] = listeners
allTCPListeners = append(allTCPListeners, listeners...)
}
defer closeListeners(allTCPListeners)

// =========================================================================
// DROP PRIVILEGES - Run as original user after binding privileged ports
Expand All @@ -175,15 +202,6 @@ func runDaemon() error {
}

if err := privilege.Drop(originalUser); err != nil {
// Clean up listeners before returning
httpListener.Close()
httpsListener.Close()
if dnsListener != nil {
dnsListener.Close()
}
for _, l := range tcpListeners {
l.Close()
}
return fmt.Errorf("failed to drop privileges: %w", err)
}
// Log after dropping privileges (logging not set up yet)
Expand Down Expand Up @@ -296,22 +314,28 @@ func runDaemon() error {
// =========================================================================
// Extract HTTPS port for redirect
httpsPort := 443
if _, portStr, err := net.SplitHostPort(httpsCfg.Listen); err == nil {
if _, portStr, err := net.SplitHostPort(httpsCfg.Listen[0]); err == nil {
Comment thread
scflode marked this conversation as resolved.
if p, err := net.LookupPort("tcp", portStr); err == nil {
httpsPort = p
}
}

httpServer := proxy.NewHTTPServerWithListener(httpListener, httpsPort)
if err := httpServer.Start(); err != nil {
return fmt.Errorf("failed to start HTTP server: %w", err)
httpServers := make([]*proxy.HTTPServer, 0, len(httpListeners))
for _, listener := range httpListeners {
httpServer := proxy.NewHTTPServerWithListener(listener, httpsPort)
if err := httpServer.Start(); err != nil {
return fmt.Errorf("failed to start HTTP server: %w", err)
}
httpServers = append(httpServers, httpServer)
}
shutdown.OnShutdown(func() {
if err := httpServer.Stop(); err != nil {
logging.Error("failed to stop HTTP server", "error", err)
for _, server := range httpServers {
if err := server.Stop(); err != nil {
logging.Error("failed to stop HTTP server", "error", err)
}
}
})
logging.Info("HTTP server started", "address", httpCfg.Listen)
logging.Info("HTTP servers started", "addresses", httpCfg.Listen)

// =========================================================================
// Start HTTPS Server (using pre-bound listener)
Expand All @@ -324,16 +348,22 @@ func runDaemon() error {
httpsHandler := proxy.NewAccessLogger(proxyHandler, slog.Default(), func() bool {
return (*cfgPtr).Logging.AccessLog
})
httpsServer := proxy.NewHTTPSServerWithListener(httpsListener, certManager, httpsHandler)
if err := httpsServer.Start(); err != nil {
return fmt.Errorf("failed to start HTTPS server: %w", err)
httpsServers := make([]*proxy.HTTPSServer, 0, len(httpsListeners))
for _, listener := range httpsListeners {
httpsServer := proxy.NewHTTPSServerWithListener(listener, certManager, httpsHandler)
if err := httpsServer.Start(); err != nil {
return fmt.Errorf("failed to start HTTPS server: %w", err)
}
httpsServers = append(httpsServers, httpsServer)
}
shutdown.OnShutdown(func() {
if err := httpsServer.Stop(); err != nil {
logging.Error("failed to stop HTTPS server", "error", err)
for _, server := range httpsServers {
if err := server.Stop(); err != nil {
logging.Error("failed to stop HTTPS server", "error", err)
}
}
})
logging.Info("HTTPS server started", "address", httpsCfg.Listen)
logging.Info("HTTPS servers started", "addresses", httpsCfg.Listen)

// =========================================================================
// Start TCP Entrypoints (using pre-bound listeners)
Expand All @@ -350,28 +380,26 @@ func runDaemon() error {
continue
}

listener, ok := tcpListeners[name]
if !ok {
continue
}
listeners := tcpListeners[name]
for _, listener := range listeners {
tcpCfg := proxy.TCPEntrypointConfig{
Name: name,
Listen: listener.Addr().String(),
TargetPort: epCfg.TargetPort,
Registry: registry,
CertManager: certManager,
Logger: logger,
}

tcpCfg := proxy.TCPEntrypointConfig{
Name: name,
Listen: epCfg.Listen,
TargetPort: epCfg.TargetPort,
Registry: registry,
CertManager: certManager,
Logger: logger,
}
tcpEntry := proxy.NewTCPEntrypointWithListener(tcpCfg, listener)
if err := tcpEntry.Start(ctx); err != nil {
logging.Error("failed to start TCP entrypoint", "name", name, "error", err)
continue
}

tcpEntry := proxy.NewTCPEntrypointWithListener(tcpCfg, listener)
if err := tcpEntry.Start(ctx); err != nil {
logging.Error("failed to start TCP entrypoint", "name", name, "error", err)
continue
tcpEntrypoints = append(tcpEntrypoints, tcpEntry)
}

tcpEntrypoints = append(tcpEntrypoints, tcpEntry)
logging.Info("TCP entrypoint started", "name", name, "address", epCfg.Listen, "target_port", epCfg.TargetPort)
logging.Info("TCP entrypoint started", "name", name, "addresses", epCfg.Listen, "target_port", epCfg.TargetPort)
}

// Register TCP entrypoint cleanup
Expand Down Expand Up @@ -481,7 +509,7 @@ func applyConfigChanges(oldCfg, newCfg *config.Config, dnsServer *dns.Server) {

// Update DNS settings (domains and upstream only - listen address requires restart)
if dnsServer != nil {
domainsChanged := !equalStringSlices(oldCfg.DNS.Domains, newCfg.DNS.Domains)
domainsChanged := !slices.Equal(oldCfg.DNS.Domains, newCfg.DNS.Domains)
upstreamChanged := oldCfg.DNS.Upstream != newCfg.DNS.Upstream

if domainsChanged || upstreamChanged {
Expand All @@ -498,23 +526,10 @@ func applyConfigChanges(oldCfg, newCfg *config.Config, dnsServer *dns.Server) {
// Check for entrypoint changes
for name, oldEp := range oldCfg.Entrypoints {
if newEp, exists := newCfg.Entrypoints[name]; exists {
if oldEp.Listen != newEp.Listen {
if !slices.Equal(oldEp.Listen, newEp.Listen) {
logging.Warn("entrypoint listen address changed - restart required to apply",
"entrypoint", name, "old", oldEp.Listen, "new", newEp.Listen)
}
}
}
}

// equalStringSlices compares two string slices for equality.
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
}
Loading
Loading