mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-11-10 14:44:04 +08:00
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:
1 parent
2012d3e09a
commit
6f94317377
10 files changed
+170
-20
No files matched your search
+26
-5
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user