diff --git a/cmd/serve.go b/cmd/serve.go index 4d2803d5..dfbdc0a9 100644 --- a/cmd/serve.go +++ b/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 -} diff --git a/cmd/serve_unix.go b/cmd/serve_unix.go new file mode 100644 index 00000000..2c612338 --- /dev/null +++ b/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 +} diff --git a/cmd/serve_windows.go b/cmd/serve_windows.go new file mode 100644 index 00000000..c5e93c89 --- /dev/null +++ b/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 +} diff --git a/docs/config.md b/docs/config.md index 8a125146..584f4064 100644 --- a/docs/config.md +++ b/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). diff --git a/docs/install.md b/docs/install.md index dc50e222..a437499e 100644 --- a/docs/install.md +++ b/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. diff --git a/go.mod b/go.mod index ad61782e..1f7f6b4c 100644 --- a/go.mod +++ b/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 diff --git a/server/config.go b/server/config.go index c4c76bd1..e52556f2 100644 --- a/server/config.go +++ b/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 diff --git a/server/config_unix.go b/server/config_unix.go new file mode 100644 index 00000000..5b9812b2 --- /dev/null +++ b/server/config_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package server + +func init() { + DefaultConfigFile = "/etc/ntfy/server.yml" + DefaultTemplateDir = "/etc/ntfy/templates" +} diff --git a/server/config_windows.go b/server/config_windows.go new file mode 100644 index 00000000..fc883c38 --- /dev/null +++ b/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") +}