diff --git a/cmd/serve.go b/cmd/serve.go index a4e42619..2320f18e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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 +} diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 696fb77b..b3518e83 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -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", diff --git a/docs/config.md b/docs/config.md index 83d272f4..6698fb96 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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] diff --git a/docs/releases.md b/docs/releases.md index 70466b47..5275a0aa 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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:** diff --git a/server/config.go b/server/config.go index 686046f7..086b8b69 100644 --- a/server/config.go +++ b/server/config.go @@ -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, diff --git a/server/config_test.go b/server/config_test.go index e789d55d..c70bab18 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -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) } diff --git a/server/server.go b/server/server.go index f71c3651..401218da 100644 --- a/server/server.go +++ b/server/server.go @@ -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() diff --git a/server/server.yml b/server/server.yml index b9bfafb4..24c5b55f 100644 --- a/server/server.yml +++ b/server/server.yml @@ -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: diff --git a/server/server_test.go b/server/server_test.go index 0585132c..42ee8730 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -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. diff --git a/test/server.go b/test/server.go index 21e3af78..53e28fb5 100644 --- a/test/server.go +++ b/test/server.go @@ -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)