//go:build windows
// +build windows

package main

// Copypasta from the example files:
// https://github.com/golang/sys/blob/master/windows/svc/example

import (
	"fmt"
	"os"
	"syscall"
	"time"
	"unsafe"

	"github.com/pkg/errors"
	"github.com/urfave/cli/v2"
	"golang.org/x/sys/windows"
	"golang.org/x/sys/windows/svc"
	"golang.org/x/sys/windows/svc/eventlog"
	"golang.org/x/sys/windows/svc/mgr"

	"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
	"github.com/cloudflare/cloudflared/logger"
)

const (
	windowsServiceName        = "Cloudflared"
	windowsServiceDescription = "Cloudflared agent"
	windowsServiceUrl         = "https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/run-tunnel/as-a-service/windows/"

	recoverActionDelay      = time.Second * 20
	failureCountResetPeriod = time.Hour * 24

	// not defined in golang.org/x/sys/windows package
	// https://msdn.microsoft.com/en-us/library/windows/desktop/ms681988(v=vs.85).aspx
	serviceConfigFailureActionsFlag = 4

	// ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
	// https://docs.microsoft.com/en-us/windows/desktop/debug/system-error-codes--1000-1299-
	serviceControllerConnectionFailure = 1063

	LogFieldWindowsServiceName = "windowsServiceName"
)

func runApp(app *cli.App, graceShutdownC chan struct{}) {
	app.Commands = append(app.Commands, &cli.Command{
		Name:  "service",
		Usage: "Manages the cloudflared Windows service",
		Subcommands: []*cli.Command{
			{
				Name:   "install",
				Usage:  "Install cloudflared as a Windows service",
				Action: cliutil.ConfiguredAction(installWindowsService),
			},
			{
				Name:   "uninstall",
				Usage:  "Uninstall the cloudflared service",
				Action: cliutil.ConfiguredAction(uninstallWindowsService),
			},
		},
	})

	// `IsAnInteractiveSession()` isn't exactly equivalent to "should the
	// process run as a normal EXE?" There are legitimate non-service cases,
	// like running cloudflared in a GCP startup script, for which
	// `IsAnInteractiveSession()` returns false. For more context, see:
	//     https://github.com/judwhite/go-svc/issues/6
	// It seems that the "correct way" to check "is this a normal EXE?" is:
	//     1. attempt to connect to the Service Control Manager
	//     2. get ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
	// This involves actually trying to start the service.

	log := logger.Create(nil)

	isIntSess, err := svc.IsAnInteractiveSession()
	if err != nil {
		log.Fatal().Err(err).Msg("failed to determine if we are running in an interactive session")
	}
	if isIntSess {
		app.Run(os.Args)
		return
	}

	// Run executes service name by calling windowsService which is a Handler
	// interface that implements Execute method.
	// It will set service status to stop after Execute returns
	err = svc.Run(windowsServiceName, &windowsService{app: app, graceShutdownC: graceShutdownC})
	if err != nil {
		if errno, ok := err.(syscall.Errno); ok && int(errno) == serviceControllerConnectionFailure {
			// Hack: assume this is a false negative from the IsAnInteractiveSession() check above.
			// Run the app in "interactive" mode anyway.
			app.Run(os.Args)
			return
		}
		log.Fatal().Err(err).Msgf("%s service failed", windowsServiceName)
	}
}

type windowsService struct {
	app            *cli.App
	graceShutdownC chan struct{}
}

// Execute is called by the service manager when service starts, the state
// of the service will be set to Stopped when this function returns.
func (s *windowsService) Execute(serviceArgs []string, r <-chan svc.ChangeRequest, statusChan chan<- svc.Status) (ssec bool, errno uint32) {
	log := logger.Create(nil)
	elog, err := eventlog.Open(windowsServiceName)
	if err != nil {
		log.Err(err).Msgf("Cannot open event log for %s", windowsServiceName)
		return
	}
	defer elog.Close()

	elog.Info(1, fmt.Sprintf("%s service starting", windowsServiceName))
	defer func() {
		elog.Info(1, fmt.Sprintf("%s service stopped", windowsServiceName))
	}()

	// the arguments passed here are only meaningful if they were manually
	// specified by the user, e.g. using the Services console or `sc start`.
	// https://docs.microsoft.com/en-us/windows/desktop/services/service-entry-point
	// https://stackoverflow.com/a/6235139
	var args []string
	if len(serviceArgs) > 1 {
		args = serviceArgs
	} else {
		// fall back to the arguments from ImagePath (or, as sc calls it, binPath)
		args = os.Args
	}
	elog.Info(1, fmt.Sprintf("%s service arguments: %v", windowsServiceName, args))

	statusChan <- svc.Status{State: svc.StartPending}
	errC := make(chan error)
	go func() {
		errC <- s.app.Run(args)
	}()
	statusChan <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}

	for {
		select {
		case c := <-r:
			switch c.Cmd {
			case svc.Interrogate:
				statusChan <- c.CurrentStatus
			case svc.Stop, svc.Shutdown:
				if s.graceShutdownC != nil {
					// start graceful shutdown
					elog.Info(1, "cloudflared starting graceful shutdown")
					close(s.graceShutdownC)
					s.graceShutdownC = nil
					statusChan <- svc.Status{State: svc.StopPending}
					continue
				}
				// repeated attempts at graceful shutdown forces immediate stop
				elog.Info(1, "cloudflared terminating immediately")
				statusChan <- svc.Status{State: svc.StopPending}
				return false, 0
			default:
				elog.Error(1, fmt.Sprintf("unexpected control request #%d", c))
			}
		case err := <-errC:
			if err != nil {
				elog.Error(1, fmt.Sprintf("cloudflared terminated with error %v", err))
				ssec = true
				errno = 1
			} else {
				elog.Info(1, "cloudflared terminated without error")
				errno = 0
			}
			return
		}
	}
}

func installWindowsService(c *cli.Context) error {
	zeroLogger := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog)

	zeroLogger.Info().Msg("Installing cloudflared Windows service")
	exepath, err := os.Executable()
	if err != nil {
		return errors.Wrap(err, "Cannot find path name that start the process")
	}
	m, err := mgr.Connect()
	if err != nil {
		return errors.Wrap(err, "Cannot establish a connection to the service control manager")
	}
	defer m.Disconnect()
	s, err := m.OpenService(windowsServiceName)
	log := zeroLogger.With().Str(LogFieldWindowsServiceName, windowsServiceName).Logger()
	if err == nil {
		s.Close()
		return fmt.Errorf(serviceAlreadyExistsWarn(windowsServiceName))
	}
	extraArgs, err := getServiceExtraArgsFromCliArgs(c, &log)
	if err != nil {
		errMsg := "Unable to determine extra arguments for windows service"
		log.Err(err).Msg(errMsg)
		return errors.Wrap(err, errMsg)
	}

	config := mgr.Config{StartType: mgr.StartAutomatic, DisplayName: windowsServiceDescription}
	s, err = m.CreateService(windowsServiceName, exepath, config, extraArgs...)
	if err != nil {
		return errors.Wrap(err, "Cannot install service")
	}
	defer s.Close()
	log.Info().Msg("cloudflared agent service is installed")
	err = eventlog.InstallAsEventCreate(windowsServiceName, eventlog.Error|eventlog.Warning|eventlog.Info)
	if err != nil {
		s.Delete()
		return errors.Wrap(err, "Cannot install event logger")
	}

	err = configRecoveryOption(s.Handle)
	if err != nil {
		log.Err(err).Msg("Cannot set service recovery actions")
		log.Info().Msgf("See %s to manually configure service recovery actions", windowsServiceUrl)
	}

	err = s.Start()
	if err == nil {
		log.Info().Msg("Agent service for cloudflared installed successfully")
	}
	return err
}

func uninstallWindowsService(c *cli.Context) error {
	log := logger.CreateLoggerFromContext(c, logger.EnableTerminalLog).
		With().
		Str(LogFieldWindowsServiceName, windowsServiceName).Logger()

	log.Info().Msg("Uninstalling cloudflared agent service")
	m, err := mgr.Connect()
	if err != nil {
		return errors.Wrap(err, "Cannot establish a connection to the service control manager")
	}
	defer m.Disconnect()
	s, err := m.OpenService(windowsServiceName)
	if err != nil {
		return fmt.Errorf("Agent service %s is not installed, so it could not be uninstalled", windowsServiceName)
	}
	defer s.Close()

	if status, err := s.Query(); err == nil && status.State == svc.Running {
		log.Info().Msg("Stopping cloudflared agent service")
		if _, err := s.Control(svc.Stop); err != nil {
			log.Info().Err(err).Msg("Failed to stop cloudflared agent service, you may need to stop it manually to complete uninstall.")
		}
	}

	err = s.Delete()
	if err != nil {
		return errors.Wrap(err, "Cannot delete agent service")
	}
	log.Info().Msg("Agent service for cloudflared was uninstalled successfully")
	err = eventlog.Remove(windowsServiceName)
	if err != nil {
		return errors.Wrap(err, "Cannot remove event logger")
	}
	return nil
}

// defined in https://msdn.microsoft.com/en-us/library/windows/desktop/ms685126(v=vs.85).aspx
type scAction int

// https://msdn.microsoft.com/en-us/library/windows/desktop/ms685126(v=vs.85).aspx
const (
	scActionNone scAction = iota
	scActionRestart
	scActionReboot
	scActionRunCommand
)

// defined in https://msdn.microsoft.com/en-us/library/windows/desktop/ms685939(v=vs.85).aspx
type serviceFailureActions struct {
	// time to wait to reset the failure count to zero if there are no failures in seconds
	resetPeriod uint32
	rebootMsg   *uint16
	command     *uint16
	// If failure count is greater than actionCount, the service controller repeats
	// the last action in actions
	actionCount uint32
	actions     uintptr
}

// https://msdn.microsoft.com/en-us/library/windows/desktop/ms685937(v=vs.85).aspx
// Not supported in Windows Server 2003 and Windows XP
type serviceFailureActionsFlag struct {
	// enableActionsForStopsWithErr is of type BOOL, which is declared as
	// typedef int BOOL in C
	enableActionsForStopsWithErr int
}

type recoveryAction struct {
	recoveryType uint32
	// The time to wait before performing the specified action, in milliseconds
	delay uint32
}

// until https://github.com/golang/go/issues/23239 is release, we will need to
// configure through ChangeServiceConfig2
func configRecoveryOption(handle windows.Handle) error {
	actions := []recoveryAction{
		{recoveryType: uint32(scActionRestart), delay: uint32(recoverActionDelay / time.Millisecond)},
	}
	serviceRecoveryActions := serviceFailureActions{
		resetPeriod: uint32(failureCountResetPeriod / time.Second),
		actionCount: uint32(len(actions)),
		actions:     uintptr(unsafe.Pointer(&actions[0])),
	}
	if err := windows.ChangeServiceConfig2(handle, windows.SERVICE_CONFIG_FAILURE_ACTIONS, (*byte)(unsafe.Pointer(&serviceRecoveryActions))); err != nil {
		return err
	}
	serviceFailureActionsFlag := serviceFailureActionsFlag{enableActionsForStopsWithErr: 1}
	return windows.ChangeServiceConfig2(handle, serviceConfigFailureActionsFlag, (*byte)(unsafe.Pointer(&serviceFailureActionsFlag)))
}
