Allow several comma-separated addresses in listen-http

A load balancer connects to each upstream address from one source IP, so one address caps it at ~63k connections to a node. listen-http now takes a comma-separated list (whitespace trimmed, empty and duplicate entries rejected, "-" still unsets it); Config.ListenHTTP becomes a slice, Run starts one HTTP server per address on the same handler, and stop closes all of them.
This commit is contained in:
binwiederhier committed 2026-10-23 04:12:25 +00:00
1 parent 2012d3e09a
commit 6f94317377
10 files changed
+170 -20

No files matched your search

+26 -5
View File
@@ -35,7 +35,7 @@ var flagsServe = append(
append([]cli.Flag{}, flagsDefault...),
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address; several comma-separated addresses are allowed"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
@@ -155,7 +155,7 @@ func execServe(c *cli.Context) error {
// Read all the options
config := c.String("config")
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
listenHTTP := c.String("listen-http")
listenHTTPRaw := c.String("listen-http")
listenHTTPS := c.String("listen-https")
listenUnix := c.String("listen-unix")
listenUnixMode := c.Int("listen-unix-mode")
@@ -467,9 +467,9 @@ func execServe(c *cli.Context) error {
return err
}
// Special case: Unset default
if listenHTTP == "-" {
listenHTTP = ""
listenHTTP, err := parseListenHTTP(listenHTTPRaw)
if err != nil {
return err
}
// Resolve hosts
@@ -798,3 +798,24 @@ func wildcardAddr(addr string) bool {
}
return host == "" || host == "0.0.0.0" || host == "::"
}
// parseListenHTTP parses the comma-separated listen-http addresses ("-" unsets the default)
func parseListenHTTP(raw string) ([]string, error) {
addrs := make([]string, 0)
if raw == "" || raw == "-" {
return addrs, nil
}
seen := make(map[string]struct{})
for _, addr := range strings.Split(raw, ",") {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil, fmt.Errorf("invalid listen-http: %s, empty address", raw)
}
if _, ok := seen[addr]; ok {
return nil, fmt.Errorf("invalid listen-http: %s, duplicate address %s", raw, addr)
}
seen[addr] = struct{}{}
addrs = append(addrs, addr)
}
return addrs, nil
}
+73
View File
@@ -522,6 +522,79 @@ func TestCLI_Serve_WebSocket(t *testing.T) {
require.Equal(t, "mytopic", m.Topic)
}
func TestParseListenHTTP_Success(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "single address",
input: ":80",
expected: []string{":80"},
},
{
name: "several addresses",
input: "10.0.0.5:2586,10.0.0.5:2588,10.0.0.5:2589",
expected: []string{"10.0.0.5:2586", "10.0.0.5:2588", "10.0.0.5:2589"},
},
{
name: "whitespace around commas",
input: " 10.0.0.5:2586 , 10.0.0.5:2588 ",
expected: []string{"10.0.0.5:2586", "10.0.0.5:2588"},
},
{
name: "unset default",
input: "-",
expected: []string{},
},
{
name: "empty",
input: "",
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseListenHTTP(tt.input)
require.Nil(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseListenHTTP_Errors(t *testing.T) {
tests := []struct {
name string
input string
error string
}{
{
name: "empty entry",
input: "10.0.0.5:2586,,10.0.0.5:2588",
error: "invalid listen-http: 10.0.0.5:2586,,10.0.0.5:2588, empty address",
},
{
name: "trailing comma",
input: "10.0.0.5:2586,",
error: "invalid listen-http: 10.0.0.5:2586,, empty address",
},
{
name: "duplicate address",
input: "10.0.0.5:2586,10.0.0.5:2586",
error: "invalid listen-http: 10.0.0.5:2586,10.0.0.5:2586, duplicate address 10.0.0.5:2586",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseListenHTTP(tt.input)
require.Error(t, err)
require.Nil(t, result)
assert.Equal(t, tt.error, err.Error())
})
}
}
func TestIP_Host_Parsing(t *testing.T) {
cases := map[string]string{
"1.1.1.1": "1.1.1.1/32",
+6 -2
View File
@@ -24,6 +24,10 @@ get a list of [command line options](#command-line-options).
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
`listen-http` may list several comma-separated addresses, e.g. `10.0.0.5:2586,10.0.0.5:2588`. This is useful behind a
load balancer: it connects to each address from one source IP, so a single address caps it at ~63k connections (one per
source port), and every extra address adds as many again.
Here are a few working sample configs using a `/etc/ntfy/server.yml` file:
=== "server.yml (HTTP-only, with cache + attachments)"
@@ -2468,7 +2472,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| Config option | Env variable | Format | Default | Description |
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server; several comma-separated addresses are allowed |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 |
@@ -2588,7 +2592,7 @@ OPTIONS:
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--config value, -c value config file (default: "/etc/ntfy/server.yml") [$NTFY_CONFIG_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address; several comma-separated addresses are allowed (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
+6
View File
@@ -2242,6 +2242,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.34.0 (UNRELEASED)
**Features:**
* Allow several comma-separated addresses in `listen-http`, so a load balancer can hold more than ~63k connections to one ntfy node
### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:**
+2 -2
View File
@@ -126,7 +126,7 @@ var (
type Config struct {
File string // Config file, only used for testing
BaseURL string
ListenHTTP string
ListenHTTP []string // Several addresses let a load balancer open more than ~63k connections to this node
ListenHTTPS string
ListenUnix string
ListenUnixMode fs.FileMode
@@ -249,7 +249,7 @@ func NewConfig() *Config {
return &Config{
File: DefaultConfigFile, // Only used for testing
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTP: []string{DefaultListenHTTP},
ListenHTTPS: "",
ListenUnix: "",
ListenUnixMode: 0,
+1 -1
View File
@@ -9,7 +9,7 @@ import (
func TestConfig_New(t *testing.T) {
c := server.NewConfig()
assert.Equal(t, ":80", c.ListenHTTP)
assert.Equal(t, []string{server.DefaultListenHTTP}, c.ListenHTTP)
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
}
+9 -8
View File
@@ -53,7 +53,7 @@ type Server struct {
config *Config
db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite
cluster cluster.Cluster // Fans messages out to peer cluster nodes (nop when not clustered)
httpServer *http.Server
httpServers []*http.Server
httpsServer *http.Server
httpMetricsServer *http.Server
httpProfileServer *http.Server
@@ -409,8 +409,8 @@ func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachme
// a manager go routine to print stats and prune messages.
func (s *Server) Run() error {
var listenStr string
if s.config.ListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http]", s.config.ListenHTTP)
for _, addr := range s.config.ListenHTTP {
listenStr += fmt.Sprintf(" %s[http]", addr)
}
if s.config.ListenHTTPS != "" {
listenStr += fmt.Sprintf(" %s[https]", s.config.ListenHTTPS)
@@ -444,10 +444,11 @@ func (s *Server) Run() error {
return nil // Stopped while we waited for the lock, i.e. the stores are closed by now
}
s.closeChan = make(chan bool)
if s.config.ListenHTTP != "" {
s.httpServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}
for _, addr := range s.config.ListenHTTP {
httpServer := &http.Server{Addr: addr, Handler: mux}
s.httpServers = append(s.httpServers, httpServer)
go func() {
errChan <- s.httpServer.ListenAndServe()
errChan <- httpServer.ListenAndServe()
}()
}
if s.config.ListenHTTPS != "" {
@@ -551,8 +552,8 @@ func (s *Server) stopBounded() {
func (s *Server) stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.httpServer != nil {
s.httpServer.Close()
for _, httpServer := range s.httpServers {
httpServer.Close()
}
if s.httpsServer != nil {
s.httpsServer.Close()
+3
View File
@@ -19,6 +19,9 @@
# To listen on all interfaces, you may omit the IP address, e.g. ":443".
# To disable HTTP, set "listen-http" to "-".
#
# "listen-http" may list several comma-separated addresses, e.g. "10.0.0.5:2586,10.0.0.5:2588".
# Behind a load balancer, each address allows ~63k more connections from it (one per source port).
#
# listen-http: ":80"
# listen-https:
+43 -1
View File
@@ -5772,7 +5772,7 @@ func TestServer_RunDoesNotStartListenersAfterStop(t *testing.T) {
addr := ln.Addr().String()
require.Nil(t, ln.Close())
conf := newTestConfig(t, "")
conf.ListenHTTP = addr
conf.ListenHTTP = []string{addr}
s := newTestServer(t, conf)
s.Stop()
@@ -5787,6 +5787,48 @@ func TestServer_RunDoesNotStartListenersAfterStop(t *testing.T) {
require.Error(t, err, "listener was started after Stop")
}
func TestServer_RunListensOnSeveralAddresses(t *testing.T) {
// A load balancer reaches each upstream address from one source IP, so every extra address
// gives it another ~63k ports to this node
addrs := []string{freeTestAddr(t), freeTestAddr(t)}
conf := newTestConfig(t, "")
conf.ListenHTTP = addrs
s := newTestServer(t, conf)
done := make(chan error, 1)
go func() { done <- s.Run() }()
for _, addr := range addrs {
require.Eventually(t, func() bool {
resp, err := http.Get("http://" + addr + "/v1/health")
if err != nil {
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 10*time.Second, 50*time.Millisecond, "no answer on %s", addr)
}
// Stop closes every listener
s.Stop()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after Stop")
}
for _, addr := range addrs {
_, err := net.DialTimeout("tcp", addr, time.Second)
require.Error(t, err, "listener %s still open after Stop", addr)
}
}
// freeTestAddr returns a local address that was free a moment ago
func freeTestAddr(t *testing.T) string {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.Nil(t, err)
addr := ln.Addr().String()
require.Nil(t, ln.Close())
return addr
}
func TestServer_StopIsBoundedWhenAStoreBlocks(t *testing.T) {
// Stop waits for every store to close, and those waits have no deadline of their own: the
// attachment sync loop queries the database, so a wedged database can park it indefinitely.
+1 -1
View File
@@ -17,7 +17,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
port := findAvailablePort(t)
conf.ListenHTTP = fmt.Sprintf(":%d", port)
conf.ListenHTTP = []string{fmt.Sprintf(":%d", port)}
conf.AttachmentCacheDir = t.TempDir()
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
s, err := server.New(conf)