作为一个深度mac用户,突然项目需要做一个windows服务,就很痛苦。用过golang的都知道,回不到 .net了,那就想办法用golang实现吧。

程序结构

  1. windows服务部分(service目录)
  2. 执行部分(app目录)

首先编写服务部分

service/main.go

入口程序,主要用于注册、卸载服务。

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"strconv"

	"github.com/kardianos/service"
)

var serviceConfig = &service.Config{
	Name:        "EtaxHelper",
	DisplayName: "Etax大树企服税务自动化工具",
	Description: "这里填写对项目的描述"
var logger service.Logger // 系统日志,在windows日志管理器查看日志

func main() {

	// 构建服务对象
	prog := &Program{}
	s, err := service.New(prog, serviceConfig)
	if err != nil {
		log.Fatal(err)
	}

	// 用于记录系统日志
	var errlog error
	logger, errlog = s.Logger(nil)
	if errlog != nil {
		log.Fatal(err)
	}

	if len(os.Args) < 2 {
		err = s.Run()
		if err != nil {
			logger.Error(err)
		}
		return
	}

	cmd := os.Args[1]

	if cmd == "install" {
		err = s.Install()
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println("安装成功")
		s.Start()
	}
	if cmd == "uninstall" {
		s.Stop()
		err = s.Uninstall()
		if err != nil {
			log.Fatal(err)
		}
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println("卸载成功")
	}

	// install, uninstall, start, stop 的另一种实现方式
	// err = service.Control(s, os.Args[1])
	// if err != nil {
	// 	log.Fatal(err)
	// }
}

type Program struct{}

func (p *Program) Start(s service.Service) error {
	log.Println("开始服务")
	go p.run()
	return nil
}

func (p *Program) Stop(s service.Service) error {
	log.Println("停止服务")
	cupath, _ := getCurrentPath()
	// log.Println("path:", fmt.Sprintf("%sEtaxHelper.exe", p), fmt.Sprintf("%sEtaxHelper.exe run", p), p)
	lockFile := fmt.Sprintf("%slock.pid", cupath)
	lock, err := os.Open(lockFile)
	defer lock.Close()
	if err == nil {
		filePid, err := ioutil.ReadAll(lock)

		if err == nil {
			pidStr := fmt.Sprintf("%s", filePid)

			pid, _ := strconv.Atoi(pidStr)
			x, err := os.FindProcess(pid)
			if err == nil {
				fmt.Printf("[ERROR] 工具已启动[%s].", pidStr)
				if err := x.Kill(); err != nil {
					logger.Error("err kill", err)
				} else {
					logger.Info("killed pid", pid)
				}
			} else {
				logger.Warning("err FindProcess", err)
			}
		} else {
			logger.Warning("not read pid file", err)
		}
	} else {
		logger.Warning("not open pid file", err)
	}
	return nil
}

func (p *Program) run() {
    // service/run.go
	runit()
}

service/run.go

运行程序,主要用于调用同目录下的EtaxHelper.exe这个程序。

package main

import (
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
)

func getCurrentPath() (string, error) {
	file, err := exec.LookPath(os.Args[0])
	if err != nil {
		return "", err
	}
	path, err := filepath.Abs(file)
	if err != nil {
		return "", err
	}
	i := strings.LastIndex(path, "/")
	if i < 0 {
		i = strings.LastIndex(path, "\\")
	}
	if i < 0 {
		return "", errors.New(`error: Can't find "/" or "\".`)
	}
	return string(path[0 : i+1]), nil
}
func runit() {

	p, _ := getCurrentPath()
	// 这里循环主要是避免用户还没登录的时候,无法运行。每5秒尝试一次启动app,StartProcessAsCurrentUser第三个参数确定是否以管理员身份运行。
	go func() {
		for {
			if err := StartProcessAsCurrentUser(fmt.Sprintf("%sEtaxHelper.exe", p), fmt.Sprintf("%sEtaxHelper.exe run", p), p, true); err == nil {
				break
			}
			time.Sleep(5 * time.Second)
		}
	}()
}

service/service.go

服务运行模块,这部分很重要,用于以当前用户来运行exe,这部分可以在任务管理器里看到exe的运行用户。(service以SYSTEM权限运行,app以登录用户权限运行)。这部分代码来自Github https://gist.github.com/LiamHaworth/1ac37f7fb6018293fc43f86993db24fc。

package main

import (
	"fmt"
	"unsafe"

	"golang.org/x/sys/windows"
)

var (
	modwtsapi32                      *windows.LazyDLL  = windows.NewLazySystemDLL("wtsapi32.dll")
	modkernel32                      *windows.LazyDLL  = windows.NewLazySystemDLL("kernel32.dll")
	modadvapi32                      *windows.LazyDLL  = windows.NewLazySystemDLL("advapi32.dll")
	moduserenv                       *windows.LazyDLL  = windows.NewLazySystemDLL("userenv.dll")
	procWTSEnumerateSessionsW        *windows.LazyProc = modwtsapi32.NewProc("WTSEnumerateSessionsW")
	procWTSGetActiveConsoleSessionId *windows.LazyProc = modkernel32.NewProc("WTSGetActiveConsoleSessionId")
	procWTSQueryUserToken            *windows.LazyProc = modwtsapi32.NewProc("WTSQueryUserToken")
	procDuplicateTokenEx             *windows.LazyProc = modadvapi32.NewProc("DuplicateTokenEx")
	procCreateEnvironmentBlock       *windows.LazyProc = moduserenv.NewProc("CreateEnvironmentBlock")
	procCreateProcessAsUser          *windows.LazyProc = modadvapi32.NewProc("CreateProcessAsUserW")
	procGetTokenInformation          *windows.LazyProc = modadvapi32.NewProc("GetTokenInformation")
)

type WTS_CONNECTSTATE_CLASS int
type SECURITY_IMPERSONATION_LEVEL int
type TOKEN_TYPE int
type SW int
type WTS_SESSION_INFO struct {
	SessionID      windows.Handle
	WinStationName *uint16
	State          WTS_CONNECTSTATE_CLASS
}
type TOKEN_LINKED_TOKEN struct {
	LinkedToken windows.Token
}

const (
	WTS_CURRENT_SERVER_HANDLE uintptr = 0
)
const (
	WTSActive WTS_CONNECTSTATE_CLASS = iota
	WTSConnected
	WTSConnectQuery
	WTSShadow
	WTSDisconnected
	WTSIdle
	WTSListen
	WTSReset
	WTSDown
	WTSInit
)
const (
	SecurityAnonymous SECURITY_IMPERSONATION_LEVEL = iota
	SecurityIdentification
	SecurityImpersonation
	SecurityDelegation
)
const (
	TokenPrimary TOKEN_TYPE = iota + 1
	TokenImpersonazion
)
const (
	SW_HIDE            SW = 0
	SW_SHOWNORMAL         = 1
	SW_NORMAL             = 1
	SW_SHOWMINIMIZED      = 2
	SW_SHOWMAXIMIZED      = 3
	SW_MAXIMIZE           = 3
	SW_SHOWNOACTIVATE     = 4
	SW_SHOW               = 5
	SW_MINIMIZE           = 6
	SW_SHOWMINNOACTIVE    = 7
	SW_SHOWNA             = 8
	SW_RESTORE            = 9
	SW_SHOWDEFAULT        = 10
	SW_MAX                = 1
)
const (
	CREATE_UNICODE_ENVIRONMENT uint16 = 0x00000400
	CREATE_NO_WINDOW                  = 0x08000000
	CREATE_NEW_CONSOLE                = 0x00000010
)

//获得当前系统活动的SessionID
func GetCurrentUserSessionId() (windows.Handle, error) {
	sessionList, err := WTSEnumerateSessions()
	if err != nil {
		return 0xFFFFFFFF, fmt.Errorf("get current user session token: %s", err)
	}
	for i := range sessionList {
		if sessionList[i].State == WTSActive {
			return sessionList[i].SessionID, nil
		}
	}
	if sessionId, _, err := procWTSGetActiveConsoleSessionId.Call(); sessionId == 0xFFFFFFFF {
		return 0xFFFFFFFF, fmt.Errorf("get current user session token: call native WTSGetActiveConsoleSessionId: %s", err)
	} else {
		return windows.Handle(sessionId), nil
	}
}

// WTSEnumerateSession will call the native
// version for Windows and parse the result
// to a Golang friendly version
func WTSEnumerateSessions() ([]*WTS_SESSION_INFO, error) {
	var (
		sessionInformation windows.Handle      = windows.Handle(0)
		sessionCount       int                 = 0
		sessionList        []*WTS_SESSION_INFO = make([]*WTS_SESSION_INFO, 0)
	)
	if returnCode, _, err := procWTSEnumerateSessionsW.Call(WTS_CURRENT_SERVER_HANDLE, 0, 1, uintptr(unsafe.Pointer(&sessionInformation)), uintptr(unsafe.Pointer(&sessionCount))); returnCode == 0 {
		return nil, fmt.Errorf("call native WTSEnumerateSessionsW: %s", err)
	}
	structSize := unsafe.Sizeof(WTS_SESSION_INFO{})
	current := uintptr(sessionInformation)
	for i := 0; i < sessionCount; i++ {
		sessionList = append(sessionList, (*WTS_SESSION_INFO)(unsafe.Pointer(current)))
		current += structSize
	}
	return sessionList, nil
}

// DuplicateUserTokenFromSessionID will attempt
// to duplicate the user token for the user logged
// into the provided session ID
func DuplicateUserTokenFromSessionID(sessionId windows.Handle, runas bool) (windows.Token, error) {
	var (
		impersonationToken windows.Handle = 0
		userToken          windows.Token  = 0
	)

	if returnCode, _, err := procWTSQueryUserToken.Call(uintptr(sessionId), uintptr(unsafe.Pointer(&impersonationToken))); returnCode == 0 {
		return 0xFFFFFFFF, fmt.Errorf("call native WTSQueryUserToken: %s", err)
	}

	if returnCode, _, err := procDuplicateTokenEx.Call(uintptr(impersonationToken), 0, 0, uintptr(SecurityImpersonation), uintptr(TokenPrimary), uintptr(unsafe.Pointer(&userToken))); returnCode == 0 {
		return 0xFFFFFFFF, fmt.Errorf("call native DuplicateTokenEx: %s", err)
	}
	if runas {
		var admin TOKEN_LINKED_TOKEN
		var dt uintptr = 0
		if returnCode, _, _ := procGetTokenInformation.Call(uintptr(impersonationToken), 19, uintptr(unsafe.Pointer(&admin)), uintptr(unsafe.Sizeof(admin)), uintptr(unsafe.Pointer(&dt))); returnCode != 0 {
			userToken = admin.LinkedToken
		}
	}
	if err := windows.CloseHandle(impersonationToken); err != nil {
		return 0xFFFFFFFF, fmt.Errorf("close windows handle used for token duplication: %s", err)
	}
	return userToken, nil
}

//StartProcessAsCurrentUser(程序路径, 启动参数, 工作目录 string, 是否以管理员身份运行) error
//需要注意的是,若使用cmdLine传入启动参数,则需要加上传入文件路径,否则可能会有不可预期的错误。
//例:
//
//StartProcessAsCurrentUser(`C:\test\test.exe`,`C:\test\test.exe hello world`,`C:\test`,true)
func StartProcessAsCurrentUser(appPath, cmdLine, workDir string, runas bool) error {
	var (
		sessionId windows.Handle
		userToken windows.Token
		envInfo   windows.Handle

		startupInfo windows.StartupInfo
		processInfo windows.ProcessInformation

		commandLine uintptr = 0
		workingDir  uintptr = 0

		err error
	)

	if sessionId, err = GetCurrentUserSessionId(); err != nil {
		return err
	}

	if userToken, err = DuplicateUserTokenFromSessionID(sessionId, runas); err != nil {
		return fmt.Errorf("get duplicate user token for current user session: %s", err)
	}

	if returnCode, _, err := procCreateEnvironmentBlock.Call(uintptr(unsafe.Pointer(&envInfo)), uintptr(userToken), 0); returnCode == 0 {
		return fmt.Errorf("create environment details for process: %s", err)
	}

	creationFlags := CREATE_UNICODE_ENVIRONMENT | CREATE_NEW_CONSOLE
	startupInfo.ShowWindow = SW_SHOW
	startupInfo.Desktop = windows.StringToUTF16Ptr("winsta0\\default")

	if len(cmdLine) > 0 {
		commandLine = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(cmdLine)))
	}
	if len(workDir) > 0 {
		workingDir = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(workDir)))
	}
	if returnCode, _, err := procCreateProcessAsUser.Call(
		uintptr(userToken), uintptr(unsafe.Pointer(windows.StringToUTF16Ptr(appPath))), commandLine, 0, 0, 0,
		uintptr(creationFlags), uintptr(envInfo), workingDir, uintptr(unsafe.Pointer(&startupInfo)), uintptr(unsafe.Pointer(&processInfo)),
	); returnCode == 0 {
		return fmt.Errorf("create process as user: %s", err)
	}
	return nil
}

至此service部分就基本完成了,赋予管理员权限(可选)。

另外,避免用户安装的时候未赋予管理员权限,可以通过修改NAC的方式,用于修改程序图标和权限的manifest。

service/nac.manifest

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
        <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
            <security>
                <requestedPrivileges>
                    <requestedExecutionLevel level="requireAdministrator"/>
                </requestedPrivileges>
            </security>
    </trustInfo>
</assembly>

首先需要安装rsrc工具

要使rsrc生效,需要在windows下编译。

go get github.com/akavel/rsrc

针对无需ico图标,仅需要管理员权限的情况

rsrc -manifest nac.manifest -o nac.syso

针对需要ico图标的情况

rsrc -manifest nac.manifest -o nac.syso -ico x.ico

最后一步编译

mac下编译(不支持添加上述的图标manifest和UAC强制管理员权限)

需要安装mingw

brew install mingw-w64

编译64位

env CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc go build -ldflags="-w -s" -o EtaxService.exe service/*.go

编译32位(32位其实兼容性更好)

env CGO_ENABLED=1 GOOS=windows GOARCH=386 CC=i686-w64-mingw32-gcc go build -ldflags="-w -s" -o EtaxService.exe service/*.go

WINDOW下编译64位(windos下编译会自动检测到目录下的nac.syso文件,并打包manifest中的图标和UAC管理员权限配置)

service/build.bat

set CGO_ENABLED=1
set GOARCH=amd64
set GOOS=windows


go build -ldflags="-w -s" -o EtaxService.exe
pause
echo press any key continue

WINDOW下编译32位

service/build.bat

set CGO_ENABLED=1
set GOARCH=386
set GOOS=windows


go build -ldflags="-w -s" -o EtaxService.exe
pause
echo press any key continue

然后就是程序部分了

这部分没必要多做赘述,只是在编译的时候需要选择是否以无界面的方式后台运行。如果需要图标就重复上面windows添加图标的方法。

app/main.go

package main
func main() {
    // 这里就自由发挥了
}

无界面编译app

go build -ldflags="-w -s -H windowsgui"

常规编译 (-ldflags="-w -s" 用于去除调试信息)

go build -ldflags="-w -s"