diff --git a/.goreleaser.yml b/.goreleaser.yml index f0cf08f6..3c4e9c76 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -48,13 +48,15 @@ builds: - id: ntfy_windows_amd64 binary: ntfy env: - - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [ noserver ] # don't include server files + - CGO_ENABLED=1 # required for go-sqlite3 + - CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64 + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" + - "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" goos: [ windows ] - goarch: [ amd64 ] - - id: ntfy_darwin_all + goarch: [amd64 ] + - + id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 @@ -201,4 +203,4 @@ docker_manifests: - *amd64_image - *arm64v8_image - *armv7_image - - *armv6_image \ No newline at end of file + - *armv6_image diff --git a/Makefile b/Makefile index df131c7a..ed16cabc 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ help: @echo "Build server & client (without GoReleaser):" @echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)" @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" + @echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo @echo "Build dev Docker:" @@ -106,6 +107,7 @@ build-deps-ubuntu: curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ + gcc-mingw-w64-x86-64 \ python3 \ python3-venv \ jq @@ -201,6 +203,16 @@ cli-darwin-server: cli-deps-static-sites -ldflags \ "-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" +cli-windows-server: cli-deps-static-sites + # This is a target to build the CLI (including the server) for Windows. + # Use this for Windows development, if you really don't want to install GoReleaser ... + mkdir -p dist/ntfy_windows_server server/docs + CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \ + -o dist/ntfy_windows_server/ntfy.exe \ + -tags sqlite_omit_load_extension,osusergo,netgo \ + -ldflags \ + "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)" + cli-client: cli-deps-static-sites # This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows. # Use this for development, if you really don't want to install GoReleaser ... @@ -213,7 +225,7 @@ cli-client: cli-deps-static-sites cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc -cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 +cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows cli-deps-static-sites: mkdir -p server/docs server/site @@ -228,6 +240,9 @@ cli-deps-gcc-armv6-armv7: cli-deps-gcc-arm64: which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; } +cli-deps-gcc-windows: + which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; } + cli-deps-update: go get -u go install honnef.co/go/tools/cmd/staticcheck@latest diff --git a/client/config.go b/client/config.go index 870c835b..444460d6 100644 --- a/client/config.go +++ b/client/config.go @@ -11,6 +11,9 @@ const ( DefaultBaseURL = "https://ntfy.sh" ) +// DefaultConfigFile is the default path to the client config file (set in config_*.go) +var DefaultConfigFile string + // Config is the config struct for a Client type Config struct { DefaultHost string `yaml:"default-host"` diff --git a/client/config_darwin.go b/client/config_darwin.go new file mode 100644 index 00000000..c2488849 --- /dev/null +++ b/client/config_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_unix.go b/client/config_unix.go new file mode 100644 index 00000000..273340e1 --- /dev/null +++ b/client/config_unix.go @@ -0,0 +1,18 @@ +//go:build linux || dragonfly || freebsd || netbsd || openbsd + +package client + +import ( + "os" + "os/user" + "path/filepath" +) + +func init() { + u, err := user.Current() + if err == nil && u.Uid == "0" { + DefaultConfigFile = "/etc/ntfy/client.yml" + } else if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/client/config_windows.go b/client/config_windows.go new file mode 100644 index 00000000..2ee55328 --- /dev/null +++ b/client/config_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package client + +import ( + "os" + "path/filepath" +) + +func init() { + if configDir, err := os.UserConfigDir(); err == nil { + DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml") + } +} diff --git a/cmd/serve.go b/cmd/serve.go index 4d2803d5..5acf048b 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) @@ -663,25 +655,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..f12bb85b --- /dev/null +++ b/cmd/serve_unix.go @@ -0,0 +1,55 @@ +//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 +} + +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..e917a079 --- /dev/null +++ b/cmd/serve_windows.go @@ -0,0 +1,100 @@ +//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") +} + +// runAsWindowsService runs the ntfy server as a Windows service +func runAsWindowsService(conf *server.Config) error { + return svc.Run(serviceName, &windowsService{conf: conf}) +} + +// windowsService implements the svc.Handler interface +type windowsService struct { + conf *server.Config + server *server.Server + mu sync.Mutex +} + +// Execute is the main entry point for the Windows service +func (s *windowsService) 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) + } else 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/cmd/subscribe.go b/cmd/subscribe.go index 5ebf9627..84450927 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -3,28 +3,21 @@ package cmd import ( "errors" "fmt" + "os" + "os/exec" + "sort" + "strings" + "github.com/urfave/cli/v2" "heckel.io/ntfy/v2/client" "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" - "os" - "os/exec" - "os/user" - "path/filepath" - "sort" - "strings" ) func init() { commands = append(commands, cmdSubscribe) } -const ( - clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml" - clientUserConfigFileUnixRelative = "ntfy/client.yml" - clientUserConfigFileWindowsRelative = "ntfy\\client.yml" -) - var flagsSubscribe = append( append([]cli.Flag{}, flagsDefault...), &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"}, @@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) { if filename != "" { return client.LoadConfig(filename) } - configFile, err := defaultClientConfigFile() - if err != nil { - log.Warn("Could not determine default client config file: %s", err.Error()) - } else { - if s, _ := os.Stat(configFile); s != nil { - return client.LoadConfig(configFile) + if client.DefaultConfigFile != "" { + if s, _ := os.Stat(client.DefaultConfigFile); s != nil { + return client.LoadConfig(client.DefaultConfigFile) } - log.Debug("Config file %s not found", configFile) + log.Debug("Config file %s not found", client.DefaultConfigFile) } log.Debug("Loading default config") return client.NewConfig(), nil } -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileUnix() (string, error) { - u, err := user.Current() - if err != nil { - return "", fmt.Errorf("could not determine current user: %w", err) - } - configFile := clientRootConfigFileUnixAbsolute - if u.Uid != "0" { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil - } - return configFile, nil -} - -//lint:ignore U1000 Conditionally used in different builds -func defaultClientConfigFileWindows() (string, error) { - homeDir, err := os.UserConfigDir() - if err != nil { - return "", fmt.Errorf("could not determine user config dir: %w", err) - } - return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil -} - func logMessagePrefix(m *client.Message) string { return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID) } diff --git a/cmd/subscribe_darwin.go b/cmd/subscribe_darwin.go index 487f0641..00335540 100644 --- a/cmd/subscribe_darwin.go +++ b/cmd/subscribe_darwin.go @@ -1,3 +1,5 @@ +//go:build darwin + package cmd const ( @@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_unix.go b/cmd/subscribe_unix.go index 3f5f526f..4c9c6039 100644 --- a/cmd/subscribe_unix.go +++ b/cmd/subscribe_unix.go @@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.` var ( scriptLauncher = []string{"sh", "-c"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileUnix() -} diff --git a/cmd/subscribe_windows.go b/cmd/subscribe_windows.go index 22c07d81..ea5f09f0 100644 --- a/cmd/subscribe_windows.go +++ b/cmd/subscribe_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package cmd const ( @@ -9,7 +11,3 @@ const ( var ( scriptLauncher = []string{"cmd.exe", "/Q", "/C"} ) - -func defaultClientConfigFile() (string, error) { - return defaultClientConfigFileWindows() -} diff --git a/docs/install.md b/docs/install.md index dc50e222..0711fdb2 100644 --- a/docs/install.md +++ b/docs/install.md @@ -228,19 +228,29 @@ brew install ntfy ``` ## Windows -The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_windows_amd64.zip), +The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. +To install, you can either + +* [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%`. +* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy` -The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). +Once installed, you can run the ntfy CLI commands like so: -Also available in [Scoop's](https://scoop.sh) Main repository: +``` +ntfy.exe -h +``` -`scoop install ntfy` +The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`) +for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually. -!!! 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. +To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an +elevated command prompt (adjust the path to `ntfy.exe` accordingly): + +``` +sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto +sc start ntfy +``` ## Docker The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should diff --git a/docs/releases.md b/docs/releases.md index 4c950b3b..e3e9ab50 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1605,7 +1605,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536), [ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation) -* Support for a [custom Twilio call format](config.md#phone-calls) ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) +* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation) +* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911)) ### ntfy Android app v1.22.x (UNRELEASED) 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..804c0980 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") +}