binwiederhier 3 недель назад
Родитель
Сommit
6d5cc6aeac
9 измененных файлов с 304 добавлено и 46 удалено
  1. 11 40
      cmd/serve.go
  2. 57 0
      cmd/serve_unix.go
  3. 104 0
      cmd/serve_windows.go
  4. 78 0
      docs/config.md
  5. 22 3
      docs/install.md
  6. 1 1
      go.mod
  7. 6 2
      server/config.go
  8. 8 0
      server/config_unix.go
  9. 17 0
      server/config_windows.go

+ 11 - 40
cmd/serve.go

@@ -10,10 +10,8 @@ import (
 	"net"
 	"net/netip"
 	"net/url"
-	"os"
-	"os/signal"
+	"runtime"
 	"strings"
-	"syscall"
 	"text/template"
 	"time"
 
@@ -350,6 +348,8 @@ func execServe(c *cli.Context) error {
 		return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
 	} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
 		return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
+	} else if runtime.GOOS == "windows" && listenUnix != "" {
+		return errors.New("listen-unix is not supported on Windows")
 	}
 
 	// Backwards compatibility
@@ -503,6 +503,14 @@ func execServe(c *cli.Context) error {
 	conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
 	conf.Version = c.App.Version
 
+	// Check if we should run as a Windows service
+	if ranAsService, err := maybeRunAsService(conf); err != nil {
+		log.Fatal("%s", err.Error())
+	} else if ranAsService {
+		log.Info("Exiting.")
+		return nil
+	}
+
 	// Set up hot-reloading of config
 	go sigHandlerConfigReload(config)
 
@@ -517,22 +525,6 @@ func execServe(c *cli.Context) error {
 	return nil
 }
 
-func sigHandlerConfigReload(config string) {
-	sigs := make(chan os.Signal, 1)
-	signal.Notify(sigs, syscall.SIGHUP)
-	for range sigs {
-		log.Info("Partially hot reloading configuration ...")
-		inputSource, err := newYamlSourceFromFile(config, flagsServe)
-		if err != nil {
-			log.Warn("Hot reload failed: %s", err.Error())
-			continue
-		}
-		if err := reloadLogLevel(inputSource); err != nil {
-			log.Warn("Reloading log level failed: %s", err.Error())
-		}
-	}
-}
-
 func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
 	// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
 	prefix, err := netip.ParsePrefix(host)
@@ -664,24 +656,3 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
 	return tokens, nil
 }
 
-func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
-	newLevelStr, err := inputSource.String("log-level")
-	if err != nil {
-		return fmt.Errorf("cannot load log level: %s", err.Error())
-	}
-	overrides, err := inputSource.StringSlice("log-level-overrides")
-	if err != nil {
-		return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
-	}
-	log.ResetLevelOverrides()
-	if err := applyLogLevelOverrides(overrides); err != nil {
-		return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
-	}
-	log.SetLevel(log.ToLevel(newLevelStr))
-	if len(overrides) > 0 {
-		log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
-	} else {
-		log.Info("Log level is %v", strings.ToUpper(newLevelStr))
-	}
-	return nil
-}

+ 57 - 0
cmd/serve_unix.go

@@ -0,0 +1,57 @@
+//go:build linux || dragonfly || freebsd || netbsd || openbsd
+
+package cmd
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/urfave/cli/v2/altsrc"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/server"
+)
+
+func sigHandlerConfigReload(config string) {
+	sigs := make(chan os.Signal, 1)
+	signal.Notify(sigs, syscall.SIGHUP)
+	for range sigs {
+		log.Info("Partially hot reloading configuration ...")
+		inputSource, err := newYamlSourceFromFile(config, flagsServe)
+		if err != nil {
+			log.Warn("Hot reload failed: %s", err.Error())
+			continue
+		}
+		if err := reloadLogLevel(inputSource); err != nil {
+			log.Warn("Reloading log level failed: %s", err.Error())
+		}
+	}
+}
+
+func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
+	newLevelStr, err := inputSource.String("log-level")
+	if err != nil {
+		return err
+	}
+	overrides, err := inputSource.StringSlice("log-level-overrides")
+	if err != nil {
+		return err
+	}
+	log.ResetLevelOverrides()
+	if err := applyLogLevelOverrides(overrides); err != nil {
+		return err
+	}
+	log.SetLevel(log.ToLevel(newLevelStr))
+	if len(overrides) > 0 {
+		log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
+	} else {
+		log.Info("Log level is %v", newLevelStr)
+	}
+	return nil
+}
+
+// maybeRunAsService is a no-op on Unix systems.
+// Windows service mode is not available on Unix.
+func maybeRunAsService(conf *server.Config) (bool, error) {
+	return false, nil
+}

+ 104 - 0
cmd/serve_windows.go

@@ -0,0 +1,104 @@
+//go:build windows && !noserver
+
+package cmd
+
+import (
+	"fmt"
+	"sync"
+
+	"golang.org/x/sys/windows/svc"
+	"heckel.io/ntfy/v2/log"
+	"heckel.io/ntfy/v2/server"
+)
+
+const serviceName = "ntfy"
+
+// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
+// Windows users can restart the service to reload configuration.
+func sigHandlerConfigReload(config string) {
+	log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
+	// On Windows, we simply don't set up any signal handler for config reload.
+	// Users must restart the service/process to reload configuration.
+}
+
+// runAsWindowsService runs the ntfy server as a Windows service
+func runAsWindowsService(conf *server.Config) error {
+	return svc.Run(serviceName, &ntfyService{conf: conf})
+}
+
+// ntfyService implements the svc.Handler interface
+type ntfyService struct {
+	conf   *server.Config
+	server *server.Server
+	mu     sync.Mutex
+}
+
+// Execute is the main entry point for the Windows service
+func (s *ntfyService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
+	const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
+
+	status <- svc.Status{State: svc.StartPending}
+
+	// Create and start the server
+	var err error
+	s.mu.Lock()
+	s.server, err = server.New(s.conf)
+	s.mu.Unlock()
+	if err != nil {
+		log.Error("Failed to create server: %s", err.Error())
+		return true, 1
+	}
+
+	// Start server in a goroutine
+	serverErrChan := make(chan error, 1)
+	go func() {
+		serverErrChan <- s.server.Run()
+	}()
+
+	status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
+	log.Info("Windows service started")
+
+	for {
+		select {
+		case err := <-serverErrChan:
+			if err != nil {
+				log.Error("Server error: %s", err.Error())
+				return true, 1
+			}
+			return false, 0
+		case req := <-requests:
+			switch req.Cmd {
+			case svc.Interrogate:
+				status <- req.CurrentStatus
+			case svc.Stop, svc.Shutdown:
+				log.Info("Windows service stopping...")
+				status <- svc.Status{State: svc.StopPending}
+				s.mu.Lock()
+				if s.server != nil {
+					s.server.Stop()
+				}
+				s.mu.Unlock()
+				return false, 0
+			default:
+				log.Warn("Unexpected service control request: %d", req.Cmd)
+			}
+		}
+	}
+}
+
+// maybeRunAsService checks if the process is running as a Windows service,
+// and if so, runs the server as a service. Returns true if it ran as a service.
+func maybeRunAsService(conf *server.Config) (bool, error) {
+	isService, err := svc.IsWindowsService()
+	if err != nil {
+		return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
+	}
+	if !isService {
+		return false, nil
+	}
+	log.Info("Running as Windows service")
+	if err := runAsWindowsService(conf); err != nil {
+		return true, fmt.Errorf("failed to run as Windows service: %w", err)
+	}
+	return true, nil
+}

+ 78 - 0
docs/config.md

@@ -1060,6 +1060,84 @@ or the root domain:
     }
     ```
 
+## Windows service
+ntfy can run as a Windows service, allowing it to start automatically on boot and run in the background.
+ntfy automatically detects when it is running as a Windows service and adjusts its behavior accordingly.
+
+### Installing the service
+To install ntfy as a Windows service, open an **Administrator** command prompt or PowerShell and run:
+
+```cmd
+sc create ntfy binPath= "C:\path\to\ntfy.exe serve" start= auto
+```
+
+!!! note
+    Make sure to replace `C:\path\to\ntfy.exe` with the actual path to your ntfy executable.
+    The spaces after `binPath=` and `start=` are required.
+
+You can also specify a config file:
+
+```cmd
+sc create ntfy binPath= "C:\path\to\ntfy.exe serve --config C:\ProgramData\ntfy\server.yml" start= auto
+```
+
+### Starting and stopping
+To start the service:
+
+```cmd
+sc start ntfy
+```
+
+To stop the service:
+
+```cmd
+sc stop ntfy
+```
+
+To check the service status:
+
+```cmd
+sc query ntfy
+```
+
+### Configuring the service
+The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (typically `C:\ProgramData\ntfy\server.yml`).
+Create this directory and file manually if needed.
+
+Example minimal config:
+
+```yaml
+base-url: "https://ntfy.example.com"
+listen-http: ":80"
+cache-file: "C:\\ProgramData\\ntfy\\cache.db"
+auth-file: "C:\\ProgramData\\ntfy\\auth.db"
+```
+
+!!! warning
+    Use double backslashes (`\\`) for paths in YAML files on Windows, or use forward slashes (`/`).
+
+### Viewing logs
+By default, ntfy logs to stderr. When running as a Windows service, you can configure logging to a file:
+
+```yaml
+log-file: "C:\\ProgramData\\ntfy\\ntfy.log"
+log-level: info
+```
+
+### Removing the service
+To remove the ntfy service:
+
+```cmd
+sc stop ntfy
+sc delete ntfy
+```
+
+### Limitations on Windows
+When running on Windows, the following features are not available:
+
+- **Unix socket listening**: The `listen-unix` option is not supported on Windows
+- **Config hot-reload**: The SIGHUP signal for hot-reloading configuration is not available on Windows; restart the service to apply config changes
+
 ## Firebase (FCM)
 !!! info
     Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).

+ 22 - 3
docs/install.md

@@ -228,20 +228,39 @@ brew install ntfy
 ```
 
 ## Windows
-The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
+The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
+
 To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
 extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 
 
-The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
-
 Also available in [Scoop's](https://scoop.sh) Main repository:
 
 `scoop install ntfy`
 
+### Running the server
+To run the ntfy server directly:
+```
+ntfy serve
+```
+
+The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`).
+You may need to create the directory and config file manually.
+
+For information on running ntfy as a Windows service, see the [Windows service](config.md#windows-service) section in the configuration documentation.
+
+### Client configuration
+The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
+
 !!! info
     There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
     [GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
 
+!!! note
+    Some features are not available on Windows:
+    
+    - Unix socket listening (`listen-unix`) is not supported
+    - Config hot-reload via SIGHUP is not available; restart the service to apply config changes
+
 ## Docker
 The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should 
 be pretty straight forward to use.

+ 1 - 1
go.mod

@@ -35,6 +35,7 @@ require (
 	github.com/microcosm-cc/bluemonday v1.0.27
 	github.com/prometheus/client_golang v1.23.2
 	github.com/stripe/stripe-go/v74 v74.30.0
+	golang.org/x/sys v0.40.0
 	golang.org/x/text v0.33.0
 )
 
@@ -93,7 +94,6 @@ require (
 	go.opentelemetry.io/otel/trace v1.39.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.3 // indirect
 	golang.org/x/net v0.49.0 // indirect
-	golang.org/x/sys v0.40.0 // indirect
 	google.golang.org/appengine/v2 v2.0.6 // indirect
 	google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect

+ 6 - 2
server/config.go

@@ -12,8 +12,6 @@ import (
 // Defines default config settings (excluding limits, see below)
 const (
 	DefaultListenHTTP                           = ":80"
-	DefaultConfigFile                           = "/etc/ntfy/server.yml"
-	DefaultTemplateDir                          = "/etc/ntfy/templates"
 	DefaultCacheDuration                        = 12 * time.Hour
 	DefaultCacheBatchTimeout                    = time.Duration(0)
 	DefaultKeepaliveInterval                    = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
@@ -27,6 +25,12 @@ const (
 	DefaultStripePriceCacheDuration             = 3 * time.Hour    // Time to keep Stripe prices cached in memory before a refresh is needed
 )
 
+// Platform-specific default paths (set in config_unix.go or config_windows.go)
+var (
+	DefaultConfigFile string
+	DefaultTemplateDir string
+)
+
 // Defines default Web Push settings
 const (
 	DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour

+ 8 - 0
server/config_unix.go

@@ -0,0 +1,8 @@
+//go:build !windows
+
+package server
+
+func init() {
+	DefaultConfigFile = "/etc/ntfy/server.yml"
+	DefaultTemplateDir = "/etc/ntfy/templates"
+}

+ 17 - 0
server/config_windows.go

@@ -0,0 +1,17 @@
+//go:build windows
+
+package server
+
+import (
+	"os"
+	"path/filepath"
+)
+
+func init() {
+	programData := os.Getenv("ProgramData")
+	if programData == "" {
+		programData = `C:\ProgramData`
+	}
+	DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
+	DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
+}