Windows server support

This commit is contained in:
binwiederhier
2026-01-17 14:43:43 -05:00
parent 9f3883eaf0
commit 6d5cc6aeac
9 changed files with 304 additions and 46 deletions

View File

@@ -10,10 +10,8 @@ import (
"net" "net"
"net/netip" "net/netip"
"net/url" "net/url"
"os" "runtime"
"os/signal"
"strings" "strings"
"syscall"
"text/template" "text/template"
"time" "time"
@@ -350,6 +348,8 @@ func execServe(c *cli.Context) error {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 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 // Backwards compatibility
@@ -503,6 +503,14 @@ func execServe(c *cli.Context) error {
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version 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 // Set up hot-reloading of config
go sigHandlerConfigReload(config) go sigHandlerConfigReload(config)
@@ -517,22 +525,6 @@ func execServe(c *cli.Context) error {
return nil 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) { func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32 // Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
prefix, err := netip.ParsePrefix(host) prefix, err := netip.ParsePrefix(host)
@@ -664,24 +656,3 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
return tokens, nil 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
cmd/serve_unix.go Normal file
View File

@@ -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
cmd/serve_windows.go Normal file
View File

@@ -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
}

View File

@@ -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) ## Firebase (FCM)
!!! info !!! info
Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app). Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).

View File

@@ -228,20 +228,39 @@ brew install ntfy
``` ```
## Windows ## 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), 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%`. 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: Also available in [Scoop's](https://scoop.sh) Main repository:
`scoop install ntfy` `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 !!! info
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a 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. [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 ## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should 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. be pretty straight forward to use.

2
go.mod
View File

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

View File

@@ -12,8 +12,6 @@ import (
// Defines default config settings (excluding limits, see below) // Defines default config settings (excluding limits, see below)
const ( const (
DefaultListenHTTP = ":80" DefaultListenHTTP = ":80"
DefaultConfigFile = "/etc/ntfy/server.yml"
DefaultTemplateDir = "/etc/ntfy/templates"
DefaultCacheDuration = 12 * time.Hour DefaultCacheDuration = 12 * time.Hour
DefaultCacheBatchTimeout = time.Duration(0) DefaultCacheBatchTimeout = time.Duration(0)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) 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 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 // Defines default Web Push settings
const ( const (
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour

8
server/config_unix.go Normal file
View File

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

17
server/config_windows.go Normal file
View File

@@ -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")
}