Browse Source

add Windows Service support

Nicola Murino 5 years ago
parent
commit
f3f38f5f09
12 changed files with 739 additions and 167 deletions
  1. 7 0
      README.md
  2. 52 0
      cmd/install_windows.go
  3. 132 1
      cmd/root.go
  4. 19 165
      cmd/serve.go
  5. 16 0
      cmd/service_windows.go
  6. 42 0
      cmd/start_windows.go
  7. 32 0
      cmd/status_windows.go
  8. 32 0
      cmd/stop_windows.go
  9. 32 0
      cmd/uninstall_windows.go
  10. 1 1
      go.mod
  11. 103 0
      service/service.go
  12. 271 0
      service/service_windows.go

+ 7 - 0
README.md

@@ -76,6 +76,13 @@ Alternately you can use distro packages:
 
 For macOS a `launchd` sample [service](https://github.com/drakkan/sftpgo/tree/master/init/com.github.drakkan.sftpgo.plist "launchd plist") can be found inside the source tree. The `launchd` plist assumes that `sftpgo` has `/usr/local/opt/sftpgo` as base directory.
 
+On Windows you can install and run `SFTPGo` as Windows Service, take a look at the CLI usage to learn how:
+
+```bash
+sftpgo.exe service --help
+sftpgo.exe service install --help
+```
+
 ## Configuration
 
 The `sftpgo` executable can be used this way:

+ 52 - 0
cmd/install_windows.go

@@ -0,0 +1,52 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/service"
+	"github.com/spf13/cobra"
+)
+
+var (
+	installCmd = &cobra.Command{
+		Use:   "install",
+		Short: "Install SFTPGo as Windows Service",
+		Long: `To install the SFTPGo Windows Service with the default values for the command line flags simply use:
+
+sftpgo service install
+
+Please take a look at the usage below to customize the startup options`,
+		Run: func(cmd *cobra.Command, args []string) {
+			s := service.Service{
+				ConfigDir:     configDir,
+				ConfigFile:    configFile,
+				LogFilePath:   logFilePath,
+				LogMaxSize:    logMaxSize,
+				LogMaxBackups: logMaxBackups,
+				LogMaxAge:     logMaxAge,
+				LogCompress:   logCompress,
+				LogVerbose:    logVerbose,
+				Shutdown:      make(chan bool),
+			}
+			winService := service.WindowsService{
+				Service: s,
+			}
+			serviceArgs := []string{"service", "start"}
+			customFlags := getCustomServeFlags()
+			if len(customFlags) > 0 {
+				serviceArgs = append(serviceArgs, customFlags...)
+			}
+			err := winService.Install(serviceArgs...)
+			if err != nil {
+				fmt.Printf("Error installing service: %v\r\n", err)
+			} else {
+				fmt.Printf("Service installed!\r\n")
+			}
+		},
+	}
+)
+
+func init() {
+	serviceCmd.AddCommand(installCmd)
+	addServeFlags(installCmd)
+}

+ 132 - 1
cmd/root.go

@@ -3,16 +3,52 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"strconv"
 
+	"github.com/drakkan/sftpgo/config"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
 )
 
 const (
-	logSender = "cmd"
+	logSender           = "cmd"
+	configDirFlag       = "config-dir"
+	configDirKey        = "config_dir"
+	configFileFlag      = "config-file"
+	configFileKey       = "config_file"
+	logFilePathFlag     = "log-file-path"
+	logFilePathKey      = "log_file_path"
+	logMaxSizeFlag      = "log-max-size"
+	logMaxSizeKey       = "log_max_size"
+	logMaxBackupFlag    = "log-max-backups"
+	logMaxBackupKey     = "log_max_backups"
+	logMaxAgeFlag       = "log-max-age"
+	logMaxAgeKey        = "log_max_age"
+	logCompressFlag     = "log-compress"
+	logCompressKey      = "log_compress"
+	logVerboseFlag      = "log-verbose"
+	logVerboseKey       = "log_verbose"
+	defaultConfigDir    = "."
+	defaultConfigName   = config.DefaultConfigName
+	defaultLogFile      = "sftpgo.log"
+	defaultLogMaxSize   = 10
+	defaultLogMaxBackup = 5
+	defaultLogMaxAge    = 28
+	defaultLogCompress  = false
+	defaultLogVerbose   = true
 )
 
 var (
+	configDir     string
+	configFile    string
+	logFilePath   string
+	logMaxSize    int
+	logMaxBackups int
+	logMaxAge     int
+	logCompress   bool
+	logVerbose    bool
+
 	rootCmd = &cobra.Command{
 		Use:   "sftpgo",
 		Short: "Full featured and highly configurable SFTP server",
@@ -35,3 +71,98 @@ func Execute() {
 		os.Exit(1)
 	}
 }
+
+func addServeFlags(cmd *cobra.Command) {
+	viper.SetDefault(configDirKey, defaultConfigDir)
+	viper.BindEnv(configDirKey, "SFTPGO_CONFIG_DIR")
+	cmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey),
+		"Location for SFTPGo config dir. This directory should contain the \"sftpgo\" configuration file or the configured "+
+			"config-file and it is used as the base for files with a relative path (eg. the private keys for the SFTP server, "+
+			"the SQLite database if you use SQLite as data provider). This flag can be set using SFTPGO_CONFIG_DIR env var too.")
+	viper.BindPFlag(configDirKey, cmd.Flags().Lookup(configDirFlag))
+
+	viper.SetDefault(configFileKey, defaultConfigName)
+	viper.BindEnv(configFileKey, "SFTPGO_CONFIG_FILE")
+	cmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey),
+		"Name for SFTPGo configuration file. It must be the name of a file stored in config-dir not the absolute path to the "+
+			"configuration file. The specified file name must have no extension we automatically load JSON, YAML, TOML, HCL and "+
+			"Java properties. Therefore if you set \"sftpgo\" then \"sftpgo.json\", \"sftpgo.yaml\" and so on are searched. "+
+			"This flag can be set using SFTPGO_CONFIG_FILE env var too.")
+	viper.BindPFlag(configFileKey, cmd.Flags().Lookup(configFileFlag))
+
+	viper.SetDefault(logFilePathKey, defaultLogFile)
+	viper.BindEnv(logFilePathKey, "SFTPGO_LOG_FILE_PATH")
+	cmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey),
+		"Location for the log file. Leave empty to write logs to the standard output. This flag can be set using SFTPGO_LOG_FILE_PATH "+
+			"env var too.")
+	viper.BindPFlag(logFilePathKey, cmd.Flags().Lookup(logFilePathFlag))
+
+	viper.SetDefault(logMaxSizeKey, defaultLogMaxSize)
+	viper.BindEnv(logMaxSizeKey, "SFTPGO_LOG_MAX_SIZE")
+	cmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey),
+		"Maximum size in megabytes of the log file before it gets rotated. This flag can be set using SFTPGO_LOG_MAX_SIZE "+
+			"env var too. It is unused if log-file-path is empty.")
+	viper.BindPFlag(logMaxSizeKey, cmd.Flags().Lookup(logMaxSizeFlag))
+
+	viper.SetDefault(logMaxBackupKey, defaultLogMaxBackup)
+	viper.BindEnv(logMaxBackupKey, "SFTPGO_LOG_MAX_BACKUPS")
+	cmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey),
+		"Maximum number of old log files to retain. This flag can be set using SFTPGO_LOG_MAX_BACKUPS env var too. "+
+			"It is unused if log-file-path is empty.")
+	viper.BindPFlag(logMaxBackupKey, cmd.Flags().Lookup(logMaxBackupFlag))
+
+	viper.SetDefault(logMaxAgeKey, defaultLogMaxAge)
+	viper.BindEnv(logMaxAgeKey, "SFTPGO_LOG_MAX_AGE")
+	cmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey),
+		"Maximum number of days to retain old log files. This flag can be set using SFTPGO_LOG_MAX_AGE env var too. "+
+			"It is unused if log-file-path is empty.")
+	viper.BindPFlag(logMaxAgeKey, cmd.Flags().Lookup(logMaxAgeFlag))
+
+	viper.SetDefault(logCompressKey, defaultLogCompress)
+	viper.BindEnv(logCompressKey, "SFTPGO_LOG_COMPRESS")
+	cmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey), "Determine if the rotated "+
+		"log files should be compressed using gzip. This flag can be set using SFTPGO_LOG_COMPRESS env var too. "+
+		"It is unused if log-file-path is empty.")
+	viper.BindPFlag(logCompressKey, cmd.Flags().Lookup(logCompressFlag))
+
+	viper.SetDefault(logVerboseKey, defaultLogVerbose)
+	viper.BindEnv(logVerboseKey, "SFTPGO_LOG_VERBOSE")
+	cmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey), "Enable verbose logs. "+
+		"This flag can be set using SFTPGO_LOG_VERBOSE env var too.")
+	viper.BindPFlag(logVerboseKey, cmd.Flags().Lookup(logVerboseFlag))
+}
+
+func getCustomServeFlags() []string {
+	result := []string{}
+	if configDir != defaultConfigDir {
+		result = append(result, "--"+configDirFlag)
+		result = append(result, configDir)
+	}
+	if configFile != defaultConfigName {
+		result = append(result, "--"+configFileFlag)
+		result = append(result, configFile)
+	}
+	if logFilePath != defaultLogFile {
+		result = append(result, "--"+logFilePathFlag)
+		result = append(result, logFilePath)
+	}
+	if logMaxSize != defaultLogMaxSize {
+		result = append(result, "--"+logMaxSizeFlag)
+		result = append(result, strconv.Itoa(logMaxSize))
+	}
+	if logMaxBackups != defaultLogMaxBackup {
+		result = append(result, "--"+logMaxBackupFlag)
+		result = append(result, strconv.Itoa(logMaxBackups))
+	}
+	if logMaxAge != defaultLogMaxAge {
+		result = append(result, "--"+logMaxAgeFlag)
+		result = append(result, strconv.Itoa(logMaxAge))
+	}
+	if logVerbose != defaultLogVerbose {
+		result = append(result, "--"+logVerboseFlag+"=false")
+	}
+	if logCompress != defaultLogCompress {
+		result = append(result, "--"+logCompressFlag+"=true")
+	}
+	return result
+}

+ 19 - 165
cmd/serve.go

@@ -1,185 +1,39 @@
 package cmd
 
 import (
-	"fmt"
-	"net/http"
-	"os"
-	"time"
-
-	"github.com/drakkan/sftpgo/api"
-	"github.com/drakkan/sftpgo/config"
-	"github.com/drakkan/sftpgo/dataprovider"
-	"github.com/drakkan/sftpgo/logger"
-	"github.com/drakkan/sftpgo/sftpd"
-	"github.com/rs/zerolog"
+	"github.com/drakkan/sftpgo/service"
 	"github.com/spf13/cobra"
-	"github.com/spf13/viper"
-)
-
-const (
-	configDirFlag    = "config-dir"
-	configDirKey     = "config_dir"
-	configFileFlag   = "config-file"
-	configFileKey    = "config_file"
-	logFilePathFlag  = "log-file-path"
-	logFilePathKey   = "log_file_path"
-	logMaxSizeFlag   = "log-max-size"
-	logMaxSizeKey    = "log_max_size"
-	logMaxBackupFlag = "log-max-backups"
-	logMaxBackupKey  = "log_max_backups"
-	logMaxAgeFlag    = "log-max-age"
-	logMaxAgeKey     = "log_max_age"
-	logCompressFlag  = "log-compress"
-	logCompressKey   = "log_compress"
-	logVerboseFlag   = "log-verbose"
-	logVerboseKey    = "log_verbose"
 )
 
 var (
-	configDir     string
-	configFile    string
-	logFilePath   string
-	logMaxSize    int
-	logMaxBackups int
-	logMaxAge     int
-	logCompress   bool
-	logVerbose    bool
-	testVar       string
-	serveCmd      = &cobra.Command{
+	serveCmd = &cobra.Command{
 		Use:   "serve",
 		Short: "Start the SFTP Server",
-		Long: `To start the SFTP Server with the default values for the command line flags simply use:
+		Long: `To start the SFTPGo with the default values for the command line flags simply use:
 
 sftpgo serve
-		
+
 Please take a look at the usage below to customize the startup options`,
 		Run: func(cmd *cobra.Command, args []string) {
-			startServe()
+			service := service.Service{
+				ConfigDir:     configDir,
+				ConfigFile:    configFile,
+				LogFilePath:   logFilePath,
+				LogMaxSize:    logMaxSize,
+				LogMaxBackups: logMaxBackups,
+				LogMaxAge:     logMaxAge,
+				LogCompress:   logCompress,
+				LogVerbose:    logVerbose,
+				Shutdown:      make(chan bool),
+			}
+			if err := service.Start(); err == nil {
+				service.Wait()
+			}
 		},
 	}
 )
 
 func init() {
 	rootCmd.AddCommand(serveCmd)
-
-	viper.SetDefault(configDirKey, ".")
-	viper.BindEnv(configDirKey, "SFTPGO_CONFIG_DIR")
-	serveCmd.Flags().StringVarP(&configDir, configDirFlag, "c", viper.GetString(configDirKey),
-		"Location for SFTPGo config dir. This directory should contain the \"sftpgo\" configuration file or the configured "+
-			"config-file and it is used as the base for files with a relative path (eg. the private keys for the SFTP server, "+
-			"the SQLite database if you use SQLite as data provider). This flag can be set using SFTPGO_CONFIG_DIR env var too.")
-	viper.BindPFlag(configDirKey, serveCmd.Flags().Lookup(configDirFlag))
-
-	viper.SetDefault(configFileKey, config.DefaultConfigName)
-	viper.BindEnv(configFileKey, "SFTPGO_CONFIG_FILE")
-	serveCmd.Flags().StringVarP(&configFile, configFileFlag, "f", viper.GetString(configFileKey),
-		"Name for SFTPGo configuration file. It must be the name of a file stored in config-dir not the absolute path to the "+
-			"configuration file. The specified file name must have no extension we automatically load JSON, YAML, TOML, HCL and "+
-			"Java properties. Therefore if you set \"sftpgo\" then \"sftpgo.json\", \"sftpgo.yaml\" and so on are searched. "+
-			"This flag can be set using SFTPGO_CONFIG_FILE env var too.")
-	viper.BindPFlag(configFileKey, serveCmd.Flags().Lookup(configFileFlag))
-
-	viper.SetDefault(logFilePathKey, "sftpgo.log")
-	viper.BindEnv(logFilePathKey, "SFTPGO_LOG_FILE_PATH")
-	serveCmd.Flags().StringVarP(&logFilePath, logFilePathFlag, "l", viper.GetString(logFilePathKey),
-		"Location for the log file. Leave empty to write logs to the standard output. This flag can be set using SFTPGO_LOG_FILE_PATH "+
-			"env var too.")
-	viper.BindPFlag(logFilePathKey, serveCmd.Flags().Lookup(logFilePathFlag))
-
-	viper.SetDefault(logMaxSizeKey, 10)
-	viper.BindEnv(logMaxSizeKey, "SFTPGO_LOG_MAX_SIZE")
-	serveCmd.Flags().IntVarP(&logMaxSize, logMaxSizeFlag, "s", viper.GetInt(logMaxSizeKey),
-		"Maximum size in megabytes of the log file before it gets rotated. This flag can be set using SFTPGO_LOG_MAX_SIZE "+
-			"env var too. It is unused if log-file-path is empty.")
-	viper.BindPFlag(logMaxSizeKey, serveCmd.Flags().Lookup(logMaxSizeFlag))
-
-	viper.SetDefault(logMaxBackupKey, 5)
-	viper.BindEnv(logMaxBackupKey, "SFTPGO_LOG_MAX_BACKUPS")
-	serveCmd.Flags().IntVarP(&logMaxBackups, "log-max-backups", "b", viper.GetInt(logMaxBackupKey),
-		"Maximum number of old log files to retain. This flag can be set using SFTPGO_LOG_MAX_BACKUPS env var too. "+
-			"It is unused if log-file-path is empty.")
-	viper.BindPFlag(logMaxBackupKey, serveCmd.Flags().Lookup(logMaxBackupFlag))
-
-	viper.SetDefault(logMaxAgeKey, 28)
-	viper.BindEnv(logMaxAgeKey, "SFTPGO_LOG_MAX_AGE")
-	serveCmd.Flags().IntVarP(&logMaxAge, "log-max-age", "a", viper.GetInt(logMaxAgeKey),
-		"Maximum number of days to retain old log files. This flag can be set using SFTPGO_LOG_MAX_AGE env var too. "+
-			"It is unused if log-file-path is empty.")
-	viper.BindPFlag(logMaxAgeKey, serveCmd.Flags().Lookup(logMaxAgeFlag))
-
-	viper.SetDefault(logCompressKey, false)
-	viper.BindEnv(logCompressKey, "SFTPGO_LOG_COMPRESS")
-	serveCmd.Flags().BoolVarP(&logCompress, logCompressFlag, "z", viper.GetBool(logCompressKey), "Determine if the rotated "+
-		"log files should be compressed using gzip. This flag can be set using SFTPGO_LOG_COMPRESS env var too. "+
-		"It is unused if log-file-path is empty.")
-	viper.BindPFlag(logCompressKey, serveCmd.Flags().Lookup(logCompressFlag))
-
-	viper.SetDefault(logVerboseKey, true)
-	viper.BindEnv(logVerboseKey, "SFTPGO_LOG_VERBOSE")
-	serveCmd.Flags().BoolVarP(&logVerbose, logVerboseFlag, "v", viper.GetBool(logVerboseKey), "Enable verbose logs. "+
-		"This flag can be set using SFTPGO_LOG_VERBOSE env var too.")
-	viper.BindPFlag(logVerboseKey, serveCmd.Flags().Lookup(logVerboseFlag))
-}
-
-func startServe() {
-	logLevel := zerolog.DebugLevel
-	if !logVerbose {
-		logLevel = zerolog.InfoLevel
-	}
-	logger.InitLogger(logFilePath, logMaxSize, logMaxBackups, logMaxAge, logCompress, logLevel)
-	logger.Info(logSender, "", "starting SFTPGo, config dir: %v, config file: %v, log max size: %v log max backups: %v "+
-		"log max age: %v log verbose: %v, log compress: %v", configDir, configFile, logMaxSize, logMaxBackups, logMaxAge,
-		logVerbose, logCompress)
-	config.LoadConfig(configDir, configFile)
-	providerConf := config.GetProviderConf()
-
-	err := dataprovider.Initialize(providerConf, configDir)
-	if err != nil {
-		logger.Error(logSender, "", "error initializing data provider: %v", err)
-		logger.ErrorToConsole("error initializing data provider: %v", err)
-		os.Exit(1)
-	}
-
-	dataProvider := dataprovider.GetProvider()
-	sftpdConf := config.GetSFTPDConfig()
-	httpdConf := config.GetHTTPDConfig()
-
-	sftpd.SetDataProvider(dataProvider)
-
-	shutdown := make(chan bool)
-
-	go func() {
-		logger.Debug(logSender, "", "initializing SFTP server with config %+v", sftpdConf)
-		if err := sftpdConf.Initialize(configDir); err != nil {
-			logger.Error(logSender, "", "could not start SFTP server: %v", err)
-			logger.ErrorToConsole("could not start SFTP server: %v", err)
-		}
-		shutdown <- true
-	}()
-
-	if httpdConf.BindPort > 0 {
-		router := api.GetHTTPRouter()
-		api.SetDataProvider(dataProvider)
-
-		go func() {
-			logger.Debug(logSender, "", "initializing HTTP server with config %+v", httpdConf)
-			s := &http.Server{
-				Addr:           fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort),
-				Handler:        router,
-				ReadTimeout:    300 * time.Second,
-				WriteTimeout:   300 * time.Second,
-				MaxHeaderBytes: 1 << 20, // 1MB
-			}
-			if err := s.ListenAndServe(); err != nil {
-				logger.Error(logSender, "", "could not start HTTP server: %v", err)
-				logger.ErrorToConsole("could not start HTTP server: %v", err)
-			}
-			shutdown <- true
-		}()
-	} else {
-		logger.Debug(logSender, "", "HTTP server not started, disabled in config file")
-		logger.DebugToConsole("HTTP server not started, disabled in config file")
-	}
-
-	<-shutdown
+	addServeFlags(serveCmd)
 }

+ 16 - 0
cmd/service_windows.go

@@ -0,0 +1,16 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var (
+	serviceCmd = &cobra.Command{
+		Use:   "service",
+		Short: "Install, Uninstall, Start, Stop and retrieve status for SFTPGo Windows Service",
+	}
+)
+
+func init() {
+	rootCmd.AddCommand(serviceCmd)
+}

+ 42 - 0
cmd/start_windows.go

@@ -0,0 +1,42 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/service"
+	"github.com/spf13/cobra"
+)
+
+var (
+	startCmd = &cobra.Command{
+		Use:   "start",
+		Short: "Start SFTPGo Windows Service",
+		Run: func(cmd *cobra.Command, args []string) {
+			s := service.Service{
+				ConfigDir:     configDir,
+				ConfigFile:    configFile,
+				LogFilePath:   logFilePath,
+				LogMaxSize:    logMaxSize,
+				LogMaxBackups: logMaxBackups,
+				LogMaxAge:     logMaxAge,
+				LogCompress:   logCompress,
+				LogVerbose:    logVerbose,
+				Shutdown:      make(chan bool),
+			}
+			winService := service.WindowsService{
+				Service: s,
+			}
+			err := winService.RunService()
+			if err != nil {
+				fmt.Printf("Error starting service: %v\r\n", err)
+			} else {
+				fmt.Printf("Service started!\r\n")
+			}
+		},
+	}
+)
+
+func init() {
+	serviceCmd.AddCommand(startCmd)
+	addServeFlags(startCmd)
+}

+ 32 - 0
cmd/status_windows.go

@@ -0,0 +1,32 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/service"
+	"github.com/spf13/cobra"
+)
+
+var (
+	statusCmd = &cobra.Command{
+		Use:   "status",
+		Short: "Retrieve the status for the SFTPGo Windows Service",
+		Run: func(cmd *cobra.Command, args []string) {
+			s := service.WindowsService{
+				Service: service.Service{
+					Shutdown: make(chan bool),
+				},
+			}
+			status, err := s.Status()
+			if err != nil {
+				fmt.Printf("Error querying service status: %v\r\n", err)
+			} else {
+				fmt.Printf("Service status: %#v\r\n", status.String())
+			}
+		},
+	}
+)
+
+func init() {
+	serviceCmd.AddCommand(statusCmd)
+}

+ 32 - 0
cmd/stop_windows.go

@@ -0,0 +1,32 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/service"
+	"github.com/spf13/cobra"
+)
+
+var (
+	stopCmd = &cobra.Command{
+		Use:   "stop",
+		Short: "Stop SFTPGo Windows Service",
+		Run: func(cmd *cobra.Command, args []string) {
+			s := service.WindowsService{
+				Service: service.Service{
+					Shutdown: make(chan bool),
+				},
+			}
+			err := s.Stop()
+			if err != nil {
+				fmt.Printf("Error stopping service: %v\r\n", err)
+			} else {
+				fmt.Printf("Service stopped!\r\n")
+			}
+		},
+	}
+)
+
+func init() {
+	serviceCmd.AddCommand(stopCmd)
+}

+ 32 - 0
cmd/uninstall_windows.go

@@ -0,0 +1,32 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/drakkan/sftpgo/service"
+	"github.com/spf13/cobra"
+)
+
+var (
+	uninstallCmd = &cobra.Command{
+		Use:   "uninstall",
+		Short: "Uninstall SFTPGo Windows Service",
+		Run: func(cmd *cobra.Command, args []string) {
+			s := service.WindowsService{
+				Service: service.Service{
+					Shutdown: make(chan bool),
+				},
+			}
+			err := s.Uninstall()
+			if err != nil {
+				fmt.Printf("Error removing service: %v\r\n", err)
+			} else {
+				fmt.Printf("Service uninstalled\r\n")
+			}
+		},
+	}
+)
+
+func init() {
+	serviceCmd.AddCommand(uninstallCmd)
+}

+ 1 - 1
go.mod

@@ -26,7 +26,7 @@ require (
 	go.etcd.io/bbolt v1.3.3
 	golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7
 	golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 // indirect
-	golang.org/x/sys v0.0.0-20190913121621-c3b328c6e5a7 // indirect
+	golang.org/x/sys v0.0.0-20190913121621-c3b328c6e5a7
 	google.golang.org/appengine v1.6.2 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )

+ 103 - 0
service/service.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/drakkan/sftpgo/api"
+	"github.com/drakkan/sftpgo/config"
+	"github.com/drakkan/sftpgo/dataprovider"
+	"github.com/drakkan/sftpgo/logger"
+	"github.com/drakkan/sftpgo/sftpd"
+	"github.com/rs/zerolog"
+)
+
+const (
+	logSender = "service"
+)
+
+// Service defines the SFTPGo service
+type Service struct {
+	ConfigDir     string
+	ConfigFile    string
+	LogFilePath   string
+	LogMaxSize    int
+	LogMaxBackups int
+	LogMaxAge     int
+	LogCompress   bool
+	LogVerbose    bool
+	Shutdown      chan bool
+}
+
+// Start initializes the service
+func (s *Service) Start() error {
+	logLevel := zerolog.DebugLevel
+	if !s.LogVerbose {
+		logLevel = zerolog.InfoLevel
+	}
+	logger.InitLogger(s.LogFilePath, s.LogMaxSize, s.LogMaxBackups, s.LogMaxAge, s.LogCompress, logLevel)
+	logger.Info(logSender, "", "starting SFTPGo, config dir: %v, config file: %v, log max size: %v log max backups: %v "+
+		"log max age: %v log verbose: %v, log compress: %v", s.ConfigDir, s.ConfigFile, s.LogMaxSize, s.LogMaxBackups, s.LogMaxAge,
+		s.LogVerbose, s.LogCompress)
+	config.LoadConfig(s.ConfigDir, s.ConfigFile)
+	providerConf := config.GetProviderConf()
+
+	err := dataprovider.Initialize(providerConf, s.ConfigDir)
+	if err != nil {
+		logger.Error(logSender, "", "error initializing data provider: %v", err)
+		logger.ErrorToConsole("error initializing data provider: %v", err)
+		return err
+	}
+
+	dataProvider := dataprovider.GetProvider()
+	sftpdConf := config.GetSFTPDConfig()
+	httpdConf := config.GetHTTPDConfig()
+
+	sftpd.SetDataProvider(dataProvider)
+
+	go func() {
+		logger.Debug(logSender, "", "initializing SFTP server with config %+v", sftpdConf)
+		if err := sftpdConf.Initialize(s.ConfigDir); err != nil {
+			logger.Error(logSender, "", "could not start SFTP server: %v", err)
+			logger.ErrorToConsole("could not start SFTP server: %v", err)
+		}
+		s.Shutdown <- true
+	}()
+
+	if httpdConf.BindPort > 0 {
+		router := api.GetHTTPRouter()
+		api.SetDataProvider(dataProvider)
+
+		go func() {
+			logger.Debug(logSender, "", "initializing HTTP server with config %+v", httpdConf)
+			httpServer := &http.Server{
+				Addr:           fmt.Sprintf("%s:%d", httpdConf.BindAddress, httpdConf.BindPort),
+				Handler:        router,
+				ReadTimeout:    300 * time.Second,
+				WriteTimeout:   300 * time.Second,
+				MaxHeaderBytes: 1 << 20, // 1MB
+			}
+			if err := httpServer.ListenAndServe(); err != nil {
+				logger.Error(logSender, "", "could not start HTTP server: %v", err)
+				logger.ErrorToConsole("could not start HTTP server: %v", err)
+			}
+			s.Shutdown <- true
+		}()
+	} else {
+		logger.Debug(logSender, "", "HTTP server not started, disabled in config file")
+		logger.DebugToConsole("HTTP server not started, disabled in config file")
+	}
+	return nil
+}
+
+// Wait blocks until the service exits
+func (s *Service) Wait() {
+	<-s.Shutdown
+}
+
+// Stop terminates the service and unblocks the Wait method
+func (s *Service) Stop() {
+	close(s.Shutdown)
+	logger.Debug(logSender, "", "Service stopped")
+}

+ 271 - 0
service/service_windows.go

@@ -0,0 +1,271 @@
+package service
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/drakkan/sftpgo/logger"
+
+	"golang.org/x/sys/windows/svc"
+	"golang.org/x/sys/windows/svc/eventlog"
+	"golang.org/x/sys/windows/svc/mgr"
+)
+
+const (
+	serviceName = "SFTPGo"
+	serviceDesc = "Full featured and highly configurable SFTP server"
+)
+
+// Status defines service status
+type Status uint8
+
+// Supported values for service status
+const (
+	StatusUnknown Status = iota
+	StatusRunning
+	StatusStopped
+	StatusPaused
+	StatusStartPending
+	StatusPausePending
+	StatusContinuePending
+	StatusStopPending
+)
+
+type WindowsService struct {
+	Service       Service
+	isInteractive bool
+}
+
+func (s Status) String() string {
+	switch s {
+	case StatusRunning:
+		return "running"
+	case StatusStopped:
+		return "stopped"
+	case StatusStartPending:
+		return "start pending"
+	case StatusPausePending:
+		return "pause pending"
+	case StatusPaused:
+		return "paused"
+	case StatusContinuePending:
+		return "continue pending"
+	case StatusStopPending:
+		return "stop pending"
+	default:
+		return "unknown"
+	}
+}
+
+func (s *WindowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
+	const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
+	changes <- svc.Status{State: svc.StartPending}
+	if err := s.Service.Start(); err != nil {
+		return true, 1
+	}
+	changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
+loop:
+	for {
+		c := <-r
+		switch c.Cmd {
+		case svc.Interrogate:
+			logger.Debug(logSender, "", "Received service interrogate request, current status: %v", c.CurrentStatus)
+			changes <- c.CurrentStatus
+		case svc.Stop, svc.Shutdown:
+			logger.Debug(logSender, "", "Received service stop request")
+			changes <- svc.Status{State: svc.StopPending}
+			s.Service.Stop()
+			break loop
+		default:
+			continue loop
+		}
+	}
+
+	return false, 0
+}
+
+func (s *WindowsService) RunService() error {
+	exepath, err := s.getExecPath()
+	if err != nil {
+		return err
+	}
+
+	isIntSess, err := svc.IsAnInteractiveSession()
+	if err != nil {
+		return err
+	}
+
+	s.isInteractive = isIntSess
+	dir := filepath.Dir(exepath)
+	if err = os.Chdir(dir); err != nil {
+		return err
+	}
+	if s.isInteractive {
+		return s.Start()
+	}
+	return svc.Run(serviceName, s)
+}
+
+func (s *WindowsService) Start() error {
+	m, err := mgr.Connect()
+	if err != nil {
+		return err
+	}
+	defer m.Disconnect()
+	service, err := m.OpenService(serviceName)
+	if err != nil {
+		return fmt.Errorf("could not access service: %v", err)
+	}
+	defer service.Close()
+	err = service.Start()
+	if err != nil {
+		return fmt.Errorf("could not start service: %v", err)
+	}
+	return nil
+}
+
+func (s *WindowsService) Install(args ...string) error {
+	exepath, err := s.getExecPath()
+	if err != nil {
+		return err
+	}
+	m, err := mgr.Connect()
+	if err != nil {
+		return err
+	}
+	defer m.Disconnect()
+	service, err := m.OpenService(serviceName)
+	if err == nil {
+		service.Close()
+		return fmt.Errorf("service %s already exists", serviceName)
+	}
+	config := mgr.Config{
+		DisplayName: serviceName,
+		Description: serviceDesc,
+		StartType:   mgr.StartAutomatic}
+	service, err = m.CreateService(serviceName, exepath, config, args...)
+	if err != nil {
+		return err
+	}
+	defer service.Close()
+	err = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info)
+	if err != nil {
+		if !strings.Contains(err.Error(), "exists") {
+			service.Delete()
+			return fmt.Errorf("SetupEventLogSource() failed: %s", err)
+		}
+	}
+	recoveryActions := []mgr.RecoveryAction{
+		mgr.RecoveryAction{
+			Type:  mgr.ServiceRestart,
+			Delay: 0,
+		},
+		mgr.RecoveryAction{
+			Type:  mgr.ServiceRestart,
+			Delay: 60 * time.Second,
+		},
+		mgr.RecoveryAction{
+			Type:  mgr.ServiceRestart,
+			Delay: 90 * time.Second,
+		},
+	}
+	err = service.SetRecoveryActions(recoveryActions, uint32(86400))
+	if err != nil {
+		service.Delete()
+		return fmt.Errorf("unable to set recovery actions: %v", err)
+	}
+	return nil
+}
+
+func (s *WindowsService) Uninstall() error {
+	m, err := mgr.Connect()
+	if err != nil {
+		return err
+	}
+	defer m.Disconnect()
+	service, err := m.OpenService(serviceName)
+	if err != nil {
+		return fmt.Errorf("service %s is not installed", serviceName)
+	}
+	defer service.Close()
+	err = service.Delete()
+	if err != nil {
+		return err
+	}
+	err = eventlog.Remove(serviceName)
+	if err != nil {
+		return fmt.Errorf("RemoveEventLogSource() failed: %s", err)
+	}
+	return nil
+}
+
+func (s *WindowsService) Stop() error {
+	m, err := mgr.Connect()
+	if err != nil {
+		return err
+	}
+	defer m.Disconnect()
+	service, err := m.OpenService(serviceName)
+	if err != nil {
+		return fmt.Errorf("could not access service: %v", err)
+	}
+	defer service.Close()
+	status, err := service.Control(svc.Stop)
+	if err != nil {
+		return fmt.Errorf("could not send control=%d: %v", svc.Stop, err)
+	}
+	timeout := time.Now().Add(10 * time.Second)
+	for status.State != svc.Stopped {
+		if timeout.Before(time.Now()) {
+			return fmt.Errorf("timeout waiting for service to go to state=%d", svc.Stopped)
+		}
+		time.Sleep(300 * time.Millisecond)
+		status, err = service.Query()
+		if err != nil {
+			return fmt.Errorf("could not retrieve service status: %v", err)
+		}
+	}
+	return nil
+}
+
+func (s *WindowsService) Status() (Status, error) {
+	m, err := mgr.Connect()
+	if err != nil {
+		return StatusUnknown, err
+	}
+	defer m.Disconnect()
+	service, err := m.OpenService(serviceName)
+	if err != nil {
+		return StatusUnknown, fmt.Errorf("could not access service: %v", err)
+	}
+	defer service.Close()
+	status, err := service.Query()
+	if err != nil {
+		return StatusUnknown, fmt.Errorf("could not query service status: %v", err)
+	}
+	switch status.State {
+	case svc.StartPending:
+		return StatusStartPending, nil
+	case svc.Running:
+		return StatusRunning, nil
+	case svc.PausePending:
+		return StatusPausePending, nil
+	case svc.Paused:
+		return StatusPaused, nil
+	case svc.ContinuePending:
+		return StatusContinuePending, nil
+	case svc.StopPending:
+		return StatusStopPending, nil
+	case svc.Stopped:
+		return StatusStopped, nil
+	default:
+		return StatusUnknown, fmt.Errorf("unknown status %v", status)
+	}
+}
+
+func (s *WindowsService) getExecPath() (string, error) {
+	return os.Executable()
+}