mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +00:00
571 lines
17 KiB
Go
571 lines
17 KiB
Go
//go:build windows
|
|
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"strings"
|
|
"syscall"
|
|
"unsafe"
|
|
|
|
"github.com/gliderlabs/ssh"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
ExitCodeSuccess = 0
|
|
ExitCodeLogonFail = 10
|
|
ExitCodeCreateProcessFail = 11
|
|
ExitCodeWorkingDirFail = 12
|
|
ExitCodeShellExecFail = 13
|
|
ExitCodeValidationFail = 14
|
|
)
|
|
|
|
type WindowsExecutorConfig struct {
|
|
Username string
|
|
Domain string
|
|
WorkingDir string
|
|
Shell string
|
|
Command string
|
|
Args []string
|
|
Interactive bool
|
|
Pty bool
|
|
PtyWidth int
|
|
PtyHeight int
|
|
}
|
|
|
|
type PrivilegeDropper struct{}
|
|
|
|
func NewPrivilegeDropper() *PrivilegeDropper {
|
|
return &PrivilegeDropper{}
|
|
}
|
|
|
|
var (
|
|
advapi32 = windows.NewLazyDLL("advapi32.dll")
|
|
procAllocateLocallyUniqueId = advapi32.NewProc("AllocateLocallyUniqueId")
|
|
)
|
|
|
|
const (
|
|
logon32LogonNetwork = 3 // Network logon - no password required for authenticated users
|
|
|
|
// Common error messages
|
|
commandFlag = "-Command"
|
|
closeTokenErrorMsg = "close token error: %v" // #nosec G101 -- This is an error message template, not credentials
|
|
convertUsernameError = "convert username to UTF16: %w"
|
|
convertDomainError = "convert domain to UTF16: %w"
|
|
)
|
|
|
|
// CreateWindowsExecutorCommand creates a Windows command with privilege dropping.
|
|
// The caller must close the returned token handle after starting the process.
|
|
func (pd *PrivilegeDropper) CreateWindowsExecutorCommand(ctx context.Context, config WindowsExecutorConfig) (*exec.Cmd, windows.Token, error) {
|
|
if config.Username == "" {
|
|
return nil, 0, errors.New("username cannot be empty")
|
|
}
|
|
if config.Shell == "" {
|
|
return nil, 0, errors.New("shell cannot be empty")
|
|
}
|
|
|
|
shell := config.Shell
|
|
|
|
var shellArgs []string
|
|
if config.Command != "" {
|
|
shellArgs = []string{shell, commandFlag, config.Command}
|
|
} else {
|
|
shellArgs = []string{shell}
|
|
}
|
|
|
|
log.Tracef("creating Windows direct shell command: %s %v", shellArgs[0], shellArgs)
|
|
|
|
cmd, token, err := pd.CreateWindowsProcessAsUser(
|
|
ctx, shellArgs[0], shellArgs, config.Username, config.Domain, config.WorkingDir)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("create Windows process as user: %w", err)
|
|
}
|
|
|
|
return cmd, token, nil
|
|
}
|
|
|
|
const (
|
|
// StatusSuccess represents successful LSA operation
|
|
StatusSuccess = 0
|
|
|
|
// KerbS4ULogonType message type for domain users with Kerberos
|
|
KerbS4ULogonType = 12
|
|
// Msv10s4ulogontype message type for local users with MSV1_0
|
|
Msv10s4ulogontype = 12
|
|
|
|
// MicrosoftKerberosNameA is the authentication package name for Kerberos
|
|
MicrosoftKerberosNameA = "Kerberos"
|
|
// Msv10packagename is the authentication package name for MSV1_0
|
|
Msv10packagename = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
|
|
|
|
NameSamCompatible = 2
|
|
NameUserPrincipal = 8
|
|
NameCanonical = 7
|
|
|
|
maxUPNLen = 1024
|
|
)
|
|
|
|
// kerbS4ULogon structure for S4U authentication (domain users)
|
|
type kerbS4ULogon struct {
|
|
MessageType uint32
|
|
Flags uint32
|
|
ClientUpn unicodeString
|
|
ClientRealm unicodeString
|
|
}
|
|
|
|
// msv10s4ulogon structure for S4U authentication (local users)
|
|
type msv10s4ulogon struct {
|
|
MessageType uint32
|
|
Flags uint32
|
|
UserPrincipalName unicodeString
|
|
DomainName unicodeString
|
|
}
|
|
|
|
// unicodeString structure
|
|
type unicodeString struct {
|
|
Length uint16
|
|
MaximumLength uint16
|
|
Buffer *uint16
|
|
}
|
|
|
|
// lsaString structure
|
|
type lsaString struct {
|
|
Length uint16
|
|
MaximumLength uint16
|
|
Buffer *byte
|
|
}
|
|
|
|
// tokenSource structure
|
|
type tokenSource struct {
|
|
SourceName [8]byte
|
|
SourceIdentifier windows.LUID
|
|
}
|
|
|
|
// quotaLimits structure
|
|
type quotaLimits struct {
|
|
PagedPoolLimit uint32
|
|
NonPagedPoolLimit uint32
|
|
MinimumWorkingSetSize uint32
|
|
MaximumWorkingSetSize uint32
|
|
PagefileLimit uint32
|
|
TimeLimit int64
|
|
}
|
|
|
|
var (
|
|
secur32 = windows.NewLazyDLL("secur32.dll")
|
|
procLsaRegisterLogonProcess = secur32.NewProc("LsaRegisterLogonProcess")
|
|
procLsaLookupAuthenticationPackage = secur32.NewProc("LsaLookupAuthenticationPackage")
|
|
procLsaLogonUser = secur32.NewProc("LsaLogonUser")
|
|
procLsaFreeReturnBuffer = secur32.NewProc("LsaFreeReturnBuffer")
|
|
procLsaDeregisterLogonProcess = secur32.NewProc("LsaDeregisterLogonProcess")
|
|
procTranslateNameW = secur32.NewProc("TranslateNameW")
|
|
)
|
|
|
|
// newLsaString creates an LsaString from a Go string
|
|
func newLsaString(s string) lsaString {
|
|
b := append([]byte(s), 0)
|
|
return lsaString{
|
|
Length: uint16(len(s)),
|
|
MaximumLength: uint16(len(b)),
|
|
Buffer: &b[0],
|
|
}
|
|
}
|
|
|
|
// generateS4UUserToken creates a Windows token using S4U authentication
|
|
// This is the exact approach OpenSSH for Windows uses for public key authentication
|
|
func generateS4UUserToken(username, domain string) (windows.Handle, error) {
|
|
userCpn := buildUserCpn(username, domain)
|
|
|
|
pd := NewPrivilegeDropper()
|
|
isDomainUser := !pd.isLocalUser(domain)
|
|
|
|
lsaHandle, err := initializeLsaConnection()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer cleanupLsaConnection(lsaHandle)
|
|
|
|
authPackageId, err := lookupAuthenticationPackage(lsaHandle, isDomainUser)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
logonInfo, logonInfoSize, err := prepareS4ULogonStructure(username, domain, isDomainUser)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return performS4ULogon(lsaHandle, authPackageId, logonInfo, logonInfoSize, userCpn, isDomainUser)
|
|
}
|
|
|
|
// buildUserCpn constructs the user principal name
|
|
func buildUserCpn(username, domain string) string {
|
|
if domain != "" && domain != "." {
|
|
return fmt.Sprintf(`%s\%s`, domain, username)
|
|
}
|
|
return username
|
|
}
|
|
|
|
// initializeLsaConnection establishes connection to LSA
|
|
func initializeLsaConnection() (windows.Handle, error) {
|
|
|
|
processName := newLsaString("NetBird")
|
|
var mode uint32
|
|
var lsaHandle windows.Handle
|
|
ret, _, _ := procLsaRegisterLogonProcess.Call(
|
|
uintptr(unsafe.Pointer(&processName)),
|
|
uintptr(unsafe.Pointer(&lsaHandle)),
|
|
uintptr(unsafe.Pointer(&mode)),
|
|
)
|
|
if ret != StatusSuccess {
|
|
return 0, fmt.Errorf("LsaRegisterLogonProcess: 0x%x", ret)
|
|
}
|
|
|
|
return lsaHandle, nil
|
|
}
|
|
|
|
// cleanupLsaConnection closes the LSA connection
|
|
func cleanupLsaConnection(lsaHandle windows.Handle) {
|
|
if ret, _, _ := procLsaDeregisterLogonProcess.Call(uintptr(lsaHandle)); ret != StatusSuccess {
|
|
log.Debugf("LsaDeregisterLogonProcess failed: 0x%x", ret)
|
|
}
|
|
}
|
|
|
|
// lookupAuthenticationPackage finds the correct authentication package
|
|
func lookupAuthenticationPackage(lsaHandle windows.Handle, isDomainUser bool) (uint32, error) {
|
|
var authPackageName lsaString
|
|
if isDomainUser {
|
|
authPackageName = newLsaString(MicrosoftKerberosNameA)
|
|
} else {
|
|
authPackageName = newLsaString(Msv10packagename)
|
|
}
|
|
|
|
var authPackageId uint32
|
|
ret, _, _ := procLsaLookupAuthenticationPackage.Call(
|
|
uintptr(lsaHandle),
|
|
uintptr(unsafe.Pointer(&authPackageName)),
|
|
uintptr(unsafe.Pointer(&authPackageId)),
|
|
)
|
|
if ret != StatusSuccess {
|
|
return 0, fmt.Errorf("LsaLookupAuthenticationPackage: 0x%x", ret)
|
|
}
|
|
|
|
return authPackageId, nil
|
|
}
|
|
|
|
// lookupPrincipalName converts DOMAIN\username to username@domain.fqdn (UPN format)
|
|
func lookupPrincipalName(username, domain string) (string, error) {
|
|
samAccountName := fmt.Sprintf(`%s\%s`, domain, username)
|
|
samAccountNameUtf16, err := windows.UTF16PtrFromString(samAccountName)
|
|
if err != nil {
|
|
return "", fmt.Errorf("convert SAM account name to UTF-16: %w", err)
|
|
}
|
|
|
|
upnBuf := make([]uint16, maxUPNLen+1)
|
|
upnSize := uint32(len(upnBuf))
|
|
|
|
ret, _, _ := procTranslateNameW.Call(
|
|
uintptr(unsafe.Pointer(samAccountNameUtf16)),
|
|
uintptr(NameSamCompatible),
|
|
uintptr(NameUserPrincipal),
|
|
uintptr(unsafe.Pointer(&upnBuf[0])),
|
|
uintptr(unsafe.Pointer(&upnSize)),
|
|
)
|
|
|
|
if ret != 0 {
|
|
upn := windows.UTF16ToString(upnBuf[:upnSize])
|
|
log.Debugf("Translated %s to explicit UPN: %s", samAccountName, upn)
|
|
return upn, nil
|
|
}
|
|
|
|
upnSize = uint32(len(upnBuf))
|
|
ret, _, _ = procTranslateNameW.Call(
|
|
uintptr(unsafe.Pointer(samAccountNameUtf16)),
|
|
uintptr(NameSamCompatible),
|
|
uintptr(NameCanonical),
|
|
uintptr(unsafe.Pointer(&upnBuf[0])),
|
|
uintptr(unsafe.Pointer(&upnSize)),
|
|
)
|
|
|
|
if ret != 0 {
|
|
canonical := windows.UTF16ToString(upnBuf[:upnSize])
|
|
slashIdx := strings.IndexByte(canonical, '/')
|
|
if slashIdx > 0 {
|
|
fqdn := canonical[:slashIdx]
|
|
upn := fmt.Sprintf("%s@%s", username, fqdn)
|
|
log.Debugf("Translated %s to implicit UPN: %s (from canonical: %s)", samAccountName, upn, canonical)
|
|
return upn, nil
|
|
}
|
|
}
|
|
|
|
log.Debugf("Could not translate %s to UPN, using SAM format", samAccountName)
|
|
return samAccountName, nil
|
|
}
|
|
|
|
// prepareS4ULogonStructure creates the appropriate S4U logon structure
|
|
func prepareS4ULogonStructure(username, domain string, isDomainUser bool) (unsafe.Pointer, uintptr, error) {
|
|
if isDomainUser {
|
|
return prepareDomainS4ULogon(username, domain)
|
|
}
|
|
return prepareLocalS4ULogon(username)
|
|
}
|
|
|
|
// prepareDomainS4ULogon creates S4U logon structure for domain users
|
|
func prepareDomainS4ULogon(username, domain string) (unsafe.Pointer, uintptr, error) {
|
|
upn, err := lookupPrincipalName(username, domain)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("lookup principal name: %w", err)
|
|
}
|
|
|
|
log.Debugf("using KerbS4ULogon for domain user with UPN: %s", upn)
|
|
|
|
upnUtf16, err := windows.UTF16FromString(upn)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf(convertUsernameError, err)
|
|
}
|
|
|
|
structSize := unsafe.Sizeof(kerbS4ULogon{})
|
|
upnByteSize := len(upnUtf16) * 2
|
|
logonInfoSize := structSize + uintptr(upnByteSize)
|
|
|
|
buffer := make([]byte, logonInfoSize)
|
|
logonInfo := unsafe.Pointer(&buffer[0])
|
|
|
|
s4uLogon := (*kerbS4ULogon)(logonInfo)
|
|
s4uLogon.MessageType = KerbS4ULogonType
|
|
s4uLogon.Flags = 0
|
|
|
|
upnOffset := structSize
|
|
upnBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + upnOffset))
|
|
copy((*[1025]uint16)(unsafe.Pointer(upnBuffer))[:len(upnUtf16)], upnUtf16)
|
|
|
|
s4uLogon.ClientUpn = unicodeString{
|
|
Length: uint16((len(upnUtf16) - 1) * 2),
|
|
MaximumLength: uint16(len(upnUtf16) * 2),
|
|
Buffer: upnBuffer,
|
|
}
|
|
s4uLogon.ClientRealm = unicodeString{}
|
|
|
|
return logonInfo, logonInfoSize, nil
|
|
}
|
|
|
|
// prepareLocalS4ULogon creates S4U logon structure for local users
|
|
func prepareLocalS4ULogon(username string) (unsafe.Pointer, uintptr, error) {
|
|
log.Debugf("using Msv1_0S4ULogon for local user: %s", username)
|
|
|
|
usernameUtf16, err := windows.UTF16FromString(username)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf(convertUsernameError, err)
|
|
}
|
|
|
|
domainUtf16, err := windows.UTF16FromString(".")
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf(convertDomainError, err)
|
|
}
|
|
|
|
structSize := unsafe.Sizeof(msv10s4ulogon{})
|
|
usernameByteSize := len(usernameUtf16) * 2
|
|
domainByteSize := len(domainUtf16) * 2
|
|
logonInfoSize := structSize + uintptr(usernameByteSize) + uintptr(domainByteSize)
|
|
|
|
buffer := make([]byte, logonInfoSize)
|
|
logonInfo := unsafe.Pointer(&buffer[0])
|
|
|
|
s4uLogon := (*msv10s4ulogon)(logonInfo)
|
|
s4uLogon.MessageType = Msv10s4ulogontype
|
|
s4uLogon.Flags = 0x0
|
|
|
|
usernameOffset := structSize
|
|
usernameBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + usernameOffset))
|
|
copy((*[256]uint16)(unsafe.Pointer(usernameBuffer))[:len(usernameUtf16)], usernameUtf16)
|
|
|
|
s4uLogon.UserPrincipalName = unicodeString{
|
|
Length: uint16((len(usernameUtf16) - 1) * 2),
|
|
MaximumLength: uint16(len(usernameUtf16) * 2),
|
|
Buffer: usernameBuffer,
|
|
}
|
|
|
|
domainOffset := usernameOffset + uintptr(usernameByteSize)
|
|
domainBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + domainOffset))
|
|
copy((*[16]uint16)(unsafe.Pointer(domainBuffer))[:len(domainUtf16)], domainUtf16)
|
|
|
|
s4uLogon.DomainName = unicodeString{
|
|
Length: uint16((len(domainUtf16) - 1) * 2),
|
|
MaximumLength: uint16(len(domainUtf16) * 2),
|
|
Buffer: domainBuffer,
|
|
}
|
|
|
|
return logonInfo, logonInfoSize, nil
|
|
}
|
|
|
|
// performS4ULogon executes the S4U logon operation
|
|
func performS4ULogon(lsaHandle windows.Handle, authPackageId uint32, logonInfo unsafe.Pointer, logonInfoSize uintptr, userCpn string, isDomainUser bool) (windows.Handle, error) {
|
|
var tokenSource tokenSource
|
|
copy(tokenSource.SourceName[:], "netbird")
|
|
if ret, _, _ := procAllocateLocallyUniqueId.Call(uintptr(unsafe.Pointer(&tokenSource.SourceIdentifier))); ret == 0 {
|
|
log.Debugf("AllocateLocallyUniqueId failed")
|
|
}
|
|
|
|
originName := newLsaString("netbird")
|
|
|
|
var profile uintptr
|
|
var profileSize uint32
|
|
var logonId windows.LUID
|
|
var token windows.Handle
|
|
var quotas quotaLimits
|
|
var subStatus int32
|
|
|
|
ret, _, _ := procLsaLogonUser.Call(
|
|
uintptr(lsaHandle),
|
|
uintptr(unsafe.Pointer(&originName)),
|
|
logon32LogonNetwork,
|
|
uintptr(authPackageId),
|
|
uintptr(logonInfo),
|
|
logonInfoSize,
|
|
0,
|
|
uintptr(unsafe.Pointer(&tokenSource)),
|
|
uintptr(unsafe.Pointer(&profile)),
|
|
uintptr(unsafe.Pointer(&profileSize)),
|
|
uintptr(unsafe.Pointer(&logonId)),
|
|
uintptr(unsafe.Pointer(&token)),
|
|
uintptr(unsafe.Pointer("as)),
|
|
uintptr(unsafe.Pointer(&subStatus)),
|
|
)
|
|
|
|
if profile != 0 {
|
|
if ret, _, _ := procLsaFreeReturnBuffer.Call(profile); ret != StatusSuccess {
|
|
log.Debugf("LsaFreeReturnBuffer failed: 0x%x", ret)
|
|
}
|
|
}
|
|
|
|
if ret != StatusSuccess {
|
|
return 0, fmt.Errorf("LsaLogonUser S4U for %s: NTSTATUS=0x%x, SubStatus=0x%x", userCpn, ret, subStatus)
|
|
}
|
|
|
|
log.Debugf("created S4U %s token for user %s",
|
|
map[bool]string{true: "domain", false: "local"}[isDomainUser], userCpn)
|
|
return token, nil
|
|
}
|
|
|
|
// createToken implements NetBird trust-based authentication using S4U
|
|
func (pd *PrivilegeDropper) createToken(username, domain string) (windows.Handle, error) {
|
|
fullUsername := buildUserCpn(username, domain)
|
|
|
|
if err := userExists(fullUsername, username, domain); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
isLocalUser := pd.isLocalUser(domain)
|
|
|
|
if isLocalUser {
|
|
return pd.authenticateLocalUser(username, fullUsername)
|
|
}
|
|
return pd.authenticateDomainUser(username, domain, fullUsername)
|
|
}
|
|
|
|
// userExists checks if the target useVerifier exists on the system
|
|
func userExists(fullUsername, username, domain string) error {
|
|
if _, err := lookupUser(fullUsername); err != nil {
|
|
log.Debugf("User %s not found: %v", fullUsername, err)
|
|
if domain != "" && domain != "." {
|
|
_, err = lookupUser(username)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("target user %s not found: %w", fullUsername, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isLocalUser determines if this is a local user vs domain user
|
|
func (pd *PrivilegeDropper) isLocalUser(domain string) bool {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
hostname = "localhost"
|
|
}
|
|
|
|
return domain == "" || domain == "." ||
|
|
strings.EqualFold(domain, hostname)
|
|
}
|
|
|
|
// authenticateLocalUser handles authentication for local users
|
|
func (pd *PrivilegeDropper) authenticateLocalUser(username, fullUsername string) (windows.Handle, error) {
|
|
log.Debugf("using S4U authentication for local user %s", fullUsername)
|
|
token, err := generateS4UUserToken(username, ".")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("S4U authentication for local user %s: %w", fullUsername, err)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// authenticateDomainUser handles authentication for domain users
|
|
func (pd *PrivilegeDropper) authenticateDomainUser(username, domain, fullUsername string) (windows.Handle, error) {
|
|
log.Debugf("using S4U authentication for domain user %s", fullUsername)
|
|
token, err := generateS4UUserToken(username, domain)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("S4U authentication for domain user %s: %w", fullUsername, err)
|
|
}
|
|
log.Debugf("Successfully created S4U token for domain user %s", fullUsername)
|
|
return token, nil
|
|
}
|
|
|
|
// CreateWindowsProcessAsUser creates a process as user with safe argument passing (for SFTP and executables).
|
|
// The caller must close the returned token handle after starting the process.
|
|
func (pd *PrivilegeDropper) CreateWindowsProcessAsUser(ctx context.Context, executablePath string, args []string, username, domain, workingDir string) (*exec.Cmd, windows.Token, error) {
|
|
token, err := pd.createToken(username, domain)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("user authentication: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if err := windows.CloseHandle(token); err != nil {
|
|
log.Debugf("close impersonation token: %v", err)
|
|
}
|
|
}()
|
|
|
|
cmd, primaryToken, err := pd.createProcessWithToken(ctx, windows.Token(token), executablePath, args, workingDir)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return cmd, primaryToken, nil
|
|
}
|
|
|
|
// createProcessWithToken creates process with the specified token and executable path.
|
|
// The caller must close the returned token handle after starting the process.
|
|
func (pd *PrivilegeDropper) createProcessWithToken(ctx context.Context, sourceToken windows.Token, executablePath string, args []string, workingDir string) (*exec.Cmd, windows.Token, error) {
|
|
cmd := exec.CommandContext(ctx, executablePath, args[1:]...)
|
|
cmd.Dir = workingDir
|
|
|
|
var primaryToken windows.Token
|
|
err := windows.DuplicateTokenEx(
|
|
sourceToken,
|
|
windows.TOKEN_ALL_ACCESS,
|
|
nil,
|
|
windows.SecurityIdentification,
|
|
windows.TokenPrimary,
|
|
&primaryToken,
|
|
)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("duplicate token to primary token: %w", err)
|
|
}
|
|
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
Token: syscall.Token(primaryToken),
|
|
}
|
|
|
|
return cmd, primaryToken, nil
|
|
}
|
|
|
|
// createSuCommand creates a command using su -l -c for privilege switching (Windows stub)
|
|
func (s *Server) createSuCommand(ssh.Session, *user.User, bool) (*exec.Cmd, error) {
|
|
return nil, fmt.Errorf("su command not available on Windows")
|
|
}
|