Repository: caddyserver/caddy (language: go)

Issue:
`net.TCPConn.SetKeepAlivePeriod` on HTTP(S) listener
`transport.http.keepalive_interval`  `defines how to communicate with the backend. Default is http`.
Something similar is needed for client (my mobile device) -> Caddy, because by default Go uses a 15-second interval, which is unnecessary traffic for a battery-powered device that could be optimized away.

Nginx and Apache both use the Linux default value, 2 hours.

I can attempt a PR soon.

Reference: https://caddy.community/t/tcp-keepalives-from-client-to-caddy-not-to-upstream/16466


Discussion / hints:
Also from the forum post

> It sets both Idle time and Retry interval. The Ping amount remains at the system default. So when I set it to, say, 5 * time.Second, it waits for 5 seconds, pings and waits for another 5. And 8 more pings. I require more flexibility.

> Doing that requires system calls. Which we can do… but it’s a little more complicated.

I don't need that. In fact, I doubt many applications would, because most modern applications have their own keepalive system (such as websocket ping/pongs) built on top. Disabling/increasing the TCP keepalive time is what is important to prevent any more traffic than the application layer needs.

Fix patch:
diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go
index 7a7525241e5..767403f8e3d 100644
--- a/caddyconfig/httpcaddyfile/serveroptions.go
+++ b/caddyconfig/httpcaddyfile/serveroptions.go
@@ -38,6 +38,7 @@ type serverOptions struct {
 	ReadHeaderTimeout    caddy.Duration
 	WriteTimeout         caddy.Duration
 	IdleTimeout          caddy.Duration
+	KeepAliveInterval    caddy.Duration
 	MaxHeaderBytes       int
 	AllowH2C             bool
 	ExperimentalHTTP3    bool
@@ -123,6 +124,15 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (interface{}, error
 						return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
 					}
 				}
+			case "keepalive_interval":
+				if !d.NextArg() {
+					return nil, d.ArgErr()
+				}
+				dur, err := caddy.ParseDuration(d.Val())
+				if err != nil {
+					return nil, d.Errf("parsing keepalive interval duration: %v", err)
+				}
+				serverOpts.KeepAliveInterval = caddy.Duration(dur)
 
 			case "max_header_size":
 				var sizeStr string
@@ -228,6 +238,7 @@ func applyServerOptions(
 		server.ReadHeaderTimeout = opts.ReadHeaderTimeout
 		server.WriteTimeout = opts.WriteTimeout
 		server.IdleTimeout = opts.IdleTimeout
+		server.KeepAliveInterval = opts.KeepAliveInterval
 		server.MaxHeaderBytes = opts.MaxHeaderBytes
 		server.AllowH2C = opts.AllowH2C
 		server.ExperimentalHTTP3 = opts.ExperimentalHTTP3
diff --git a/listen.go b/listen.go
index f2cbdb431c6..c58e70ab579 100644
--- a/listen.go
+++ b/listen.go
@@ -9,6 +9,8 @@ import (
 	"sync"
 	"sync/atomic"
 	"time"
+
+	"go.uber.org/zap"
 )
 
 // Listen is like net.Listen, except Caddy's listeners can overlap
@@ -20,10 +22,16 @@ import (
 // the socket have been finished. Always be sure to close listeners
 // when you are done with them, just like normal listeners.
 func Listen(network, addr string) (net.Listener, error) {
-	lnKey := network + "/" + addr
+	// a 0 timeout means Go uses its default
+	return ListenTimeout(network, addr, 0)
+}
+
+func ListenTimeout(network, addr string, keepalivePeriod time.Duration) (net.Listener, error) {
+	lnKey := network + "/" + addr + "/"
 
 	sharedLn, _, err := listenerPool.LoadOrNew(lnKey, func() (Destructor, error) {
 		ln, err := net.Listen(network, addr)
+
 		if err != nil {
 			// https://github.com/caddyserver/caddy/pull/4534
 			if isUnixNetwork(network) && isListenBindAddressAlreadyInUseError(err) {
@@ -37,7 +45,7 @@ func Listen(network, addr string) (net.Listener, error) {
 		return nil, err
 	}
 
-	return &fakeCloseListener{sharedListener: sharedLn.(*sharedListener)}, nil
+	return &fakeCloseListener{sharedListener: sharedLn.(*sharedListener), keepalivePeriod: keepalivePeriod}, nil
 }
 
 // fakeCloseListener is a private wrapper over a listener that
@@ -50,7 +58,13 @@ func Listen(network, addr string) (net.Listener, error) {
 // is reused. This type is atomic and values must not be copied.
 type fakeCloseListener struct {
 	closed          int32 // accessed atomically; belongs to this struct only
-	*sharedListener       // embedded, so we also become a net.Listener
+	keepalivePeriod time.Duration
+	*sharedListener // embedded, so we also become a net.Listener
+}
+
+type canSetKeepAlive interface {
+	SetKeepAlivePeriod(d time.Duration) error
+	SetKeepAlive(bool) error
 }
 
 func (fcl *fakeCloseListener) Accept() (net.Conn, error) {
@@ -62,6 +76,18 @@ func (fcl *fakeCloseListener) Accept() (net.Conn, error) {
 	// call underlying accept
 	conn, err := fcl.sharedListener.Accept()
 	if err == nil {
+		// if 0, do nothing, Go's default is already set
+		// and if the connection allows setting KeepAlive, set it
+		if tconn, ok := conn.(canSetKeepAlive); ok && fcl.keepalivePeriod != 0 {
+			if fcl.keepalivePeriod > 0 {
+				err = tconn.SetKeepAlivePeriod(fcl.keepalivePeriod)
+			} else { // negative
+				err = tconn.SetKeepAlive(false)
+			}
+			if err != nil {
+				Log().With(zap.String("server", fcl.sharedListener.key)).Warn("unable to set keepalive for new connection:", zap.Error(err))
+			}
+		}
 		return conn, nil
 	}
 
diff --git a/listen_linux.go b/listen_linux.go
index a24ceb6bbd7..30c9c0f027b 100644
--- a/listen_linux.go
+++ b/listen_linux.go
@@ -4,13 +4,19 @@ import (
 	"context"
 	"net"
 	"syscall"
+	"time"
 
 	"go.uber.org/zap"
 	"golang.org/x/sys/unix"
 )
 
 func Listen(network, addr string) (net.Listener, error) {
-	config := &net.ListenConfig{Control: reusePort}
+	// 0 timeout means Go picks the default
+	return ListenTimeout(network, addr, 0)
+}
+
+func ListenTimeout(network, addr string, keepalivePeriod time.Duration) (net.Listener, error) {
+	config := &net.ListenConfig{Control: reusePort, KeepAlive: keepalivePeriod}
 	return config.Listen(context.Background(), network, addr)
 }
 
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index d886b888ac9..6dadea27107 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -324,7 +324,7 @@ func (app *App) Start() error {
 			for portOffset := uint(0); portOffset < listenAddr.PortRangeSize(); portOffset++ {
 				// create the listener for this socket
 				hostport := listenAddr.JoinHostPort(portOffset)
-				ln, err := caddy.Listen(listenAddr.Network, hostport)
+				ln, err := caddy.ListenTimeout(listenAddr.Network, hostport, time.Duration(srv.KeepAliveInterval))
 				if err != nil {
 					return fmt.Errorf("%s: listening on %s: %v", listenAddr.Network, hostport, err)
 				}
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index a4a976f7ef7..bf784a530de 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -65,6 +65,11 @@ type Server struct {
 	// 5m is applied to help avoid resource exhaustion.
 	IdleTimeout caddy.Duration `json:"idle_timeout,omitempty"`
 
+	// KeepAliveInterval is the interval at which TCP keepalive packets
+	// are sent to keep the connection alive at the TCP layer when no other
+	// data is being transmitted. The default is 15s.
+	KeepAliveInterval caddy.Duration `json:"keepalive_interval,omitempty"`
+
 	// MaxHeaderBytes is the maximum size to parse from a client's
 	// HTTP request headers.
 	MaxHeaderBytes int `json:"max_header_bytes,omitempty"`


Test patch: