diff --git a/.gitignore b/.gitignore index 363cf4aa..5836a4d8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ *.bmp build/Output tacticalagent-v* + +# Allow WinPTY agent binary (runtime dependency) +!agent/winpty_bins/**/winpty-agent.exe diff --git a/agent/agent.go b/agent/agent.go index d5941322..884a4032 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -27,11 +27,13 @@ import ( "runtime" "strconv" "strings" + "sync" "syscall" "time" rmm "github.com/amidaware/rmmagent/shared" + "github.com/creack/pty" ps "github.com/elastic/go-sysinfo" gocmd "github.com/go-cmd/cmd" "github.com/go-resty/resty/v2" @@ -45,47 +47,50 @@ import ( // Agent struct type Agent struct { - Hostname string - Arch string - AgentID string - BaseURL string - ApiURL string - Token string - AgentPK int - Cert string - ProgramDir string - EXE string - SystemDrive string - WinTmpDir string - UnixTmpDir string - WinRunAsUserTmpDir string - MeshInstaller string - MeshSystemEXE string - MeshSVC string - PyBin string - PyVer string - PyBaseDir string - PyDir string - NuBin string - DenoBin string - AgentHeader string - Headers map[string]string - Logger *logrus.Logger - Version string - Debug bool - rClient *resty.Client - Proxy string - LogTo string - LogFile *os.File - Platform string - GoArch string - ServiceConfig *service.Config - NatsServer string - NatsProxyPath string - NatsProxyPort string - NatsPingInterval int - NatsWSCompression bool - Insecure bool + Hostname string + Arch string + AgentID string + BaseURL string + ApiURL string + Token string + AgentPK int + Cert string + ProgramDir string + EXE string + SystemDrive string + WinTmpDir string + UnixTmpDir string + WinRunAsUserTmpDir string + MeshInstaller string + MeshSystemEXE string + MeshSVC string + PyBin string + PyVer string + PyBaseDir string + PyDir string + NuBin string + DenoBin string + AgentHeader string + Headers map[string]string + Logger *logrus.Logger + Version string + Debug bool + rClient *resty.Client + Proxy string + LogTo string + LogFile *os.File + Platform string + GoArch string + ServiceConfig *service.Config + NatsServer string + NatsProxyPath string + NatsProxyPort string + NatsPingInterval int + NatsWSCompression bool + Insecure bool + TerminalSessions map[string]*TerminalSession + TerminalSessionsMu sync.Mutex + PendingTerminalResizes map[string]PendingTerminalResize } const ( @@ -258,44 +263,46 @@ func New(logger *logrus.Logger, version string) *Agent { } return &Agent{ - Hostname: hostname, - BaseURL: ac.BaseURL, - AgentID: ac.AgentID, - ApiURL: ac.APIURL, - Token: ac.Token, - AgentPK: ac.PK, - Cert: ac.Cert, - ProgramDir: pd, - EXE: exe, - SystemDrive: sd, - WinTmpDir: winTempDir, - WinRunAsUserTmpDir: winRunAsUserTmpDir, - UnixTmpDir: ac.UnixTmpDir, - MeshInstaller: "meshagent.exe", - MeshSystemEXE: MeshSysExe, - MeshSVC: meshSvcName, - PyBin: pybin, - PyVer: pyver, - PyBaseDir: pyBaseDir, - PyDir: pydir, - NuBin: nuBin, - DenoBin: denoBin, - Headers: headers, - AgentHeader: agentHeader, - Logger: logger, - Version: version, - Debug: logger.IsLevelEnabled(logrus.DebugLevel), - rClient: restyC, - Proxy: ac.Proxy, - Platform: runtime.GOOS, - GoArch: runtime.GOARCH, - ServiceConfig: svcConf, - NatsServer: natsServer, - NatsProxyPath: natsProxyPath, - NatsProxyPort: natsProxyPort, - NatsPingInterval: natsPingInterval, - NatsWSCompression: natsWsCompression, - Insecure: insecure, + Hostname: hostname, + BaseURL: ac.BaseURL, + AgentID: ac.AgentID, + ApiURL: ac.APIURL, + Token: ac.Token, + AgentPK: ac.PK, + Cert: ac.Cert, + ProgramDir: pd, + EXE: exe, + SystemDrive: sd, + WinTmpDir: winTempDir, + WinRunAsUserTmpDir: winRunAsUserTmpDir, + UnixTmpDir: ac.UnixTmpDir, + MeshInstaller: "meshagent.exe", + MeshSystemEXE: MeshSysExe, + MeshSVC: meshSvcName, + PyBin: pybin, + PyVer: pyver, + PyBaseDir: pyBaseDir, + PyDir: pydir, + NuBin: nuBin, + DenoBin: denoBin, + Headers: headers, + AgentHeader: agentHeader, + Logger: logger, + Version: version, + Debug: logger.IsLevelEnabled(logrus.DebugLevel), + rClient: restyC, + Proxy: ac.Proxy, + Platform: runtime.GOOS, + GoArch: runtime.GOARCH, + ServiceConfig: svcConf, + NatsServer: natsServer, + NatsProxyPath: natsProxyPath, + NatsProxyPort: natsProxyPort, + NatsPingInterval: natsPingInterval, + NatsWSCompression: natsWsCompression, + Insecure: insecure, + TerminalSessions: make(map[string]*TerminalSession), + PendingTerminalResizes: make(map[string]PendingTerminalResize), } } @@ -315,7 +322,7 @@ type CmdOptions struct { Detached bool EnvVars []string Stream bool - Nc *nats.Conn // nats connection + Nc *nats.Conn CmdID string } @@ -830,3 +837,276 @@ func (a *Agent) RunTask(id int) error { } return nil } + +type TerminalSession struct { + ID string + Cmd *exec.Cmd + Ptmx *os.File +} + +type PendingTerminalResize struct { + Rows int + Cols int +} + +func (a *Agent) storePendingTerminalResize(sessionID string, rows, cols int) { + if sessionID == "" || rows <= 0 || cols <= 0 { + return + } + + a.TerminalSessionsMu.Lock() + defer a.TerminalSessionsMu.Unlock() + + if a.PendingTerminalResizes == nil { + a.PendingTerminalResizes = make(map[string]PendingTerminalResize) + } + + a.PendingTerminalResizes[sessionID] = PendingTerminalResize{ + Rows: rows, + Cols: cols, + } +} + +func (a *Agent) popPendingTerminalResize(sessionID string) (int, int, bool) { + a.TerminalSessionsMu.Lock() + defer a.TerminalSessionsMu.Unlock() + + if a.PendingTerminalResizes == nil { + return 0, 0, false + } + + r, ok := a.PendingTerminalResizes[sessionID] + if !ok { + return 0, 0, false + } + + delete(a.PendingTerminalResizes, sessionID) + return r.Rows, r.Cols, true +} + +func (a *Agent) applyPendingTerminalResize(sessionID string) { + rows, cols, ok := a.popPendingTerminalResize(sessionID) + if !ok { + return + } + + if err := a.ResizeTerminalSession(sessionID, rows, cols); err != nil { + a.Logger.Debugf( + "applyPendingTerminalResize failed: session=%s rows=%d cols=%d err=%v", + sessionID, rows, cols, err, + ) + } +} + +func (a *Agent) StartTerminalSession(sessionID, shell string, nc *nats.Conn) error { + a.Logger.Debugf("StartTerminalSession: session=%s shell=%s", sessionID, shell) + + cmd := exec.Command(shell) + env := os.Environ() + env = append(env, "TERM=xterm-256color") // need this or stuff like htop doesn't work + env = append(env, "COLORTERM=truecolor") + cmd.Env = env + + if home, err := os.UserHomeDir(); err == nil && home != "" { + cmd.Dir = home + } + + ptmx, err := pty.Start(cmd) + if err != nil { + return fmt.Errorf("failed to start PTY: %w", err) + } + + a.TerminalSessionsMu.Lock() + if _, exists := a.TerminalSessions[sessionID]; exists { + a.TerminalSessionsMu.Unlock() + _ = ptmx.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return fmt.Errorf("session already exists: %s", sessionID) + } + + a.TerminalSessions[sessionID] = &TerminalSession{ + ID: sessionID, + Cmd: cmd, + Ptmx: ptmx, + } + a.TerminalSessionsMu.Unlock() + + a.applyPendingTerminalResize(sessionID) + a.Logger.Debugf("Registered terminal session %s", sessionID) + + go a.StreamTerminalOutput(sessionID, ptmx, nc) + go func() { + waitErr := cmd.Wait() + a.Logger.Debugf("Terminal session %s exited: %v", sessionID, waitErr) + + a.StopTerminalSession(sessionID) + exitCode := 0 + if waitErr != nil { + if exitErr, ok := waitErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + a.SendTerminalDone(sessionID, exitCode, nc) + }() + + return nil +} + +func (a *Agent) StreamTerminalOutput(sessionID string, ptmx *os.File, nc *nats.Conn) { + topic := a.AgentID + ".terminal." + sessionID + // Reuse msgpack handle (avoid allocating a new one per chunk) + var mh codec.MsgpackHandle + buf := make([]byte, 2048) + for { + n, err := ptmx.Read(buf) + if err != nil { + a.Logger.Debugf("PTY closed for session %s: %v", sessionID, err) + return + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, &mh) + if err := enc.Encode(buf[:n]); err != nil { + a.Logger.Debugf("msgpack encode failed for session %s: %v", sessionID, err) + return + } + + if err := nc.Publish(topic, resp); err != nil { + a.Logger.Debugf("nats publish failed for session %s: %v", sessionID, err) + return + } + } +} + +func (a *Agent) StopTerminalSession(sessionID string) { + a.TerminalSessionsMu.Lock() + defer a.TerminalSessionsMu.Unlock() + + sess, ok := a.TerminalSessions[sessionID] + if ok { + if sess.Ptmx != nil { + _ = sess.Ptmx.Close() + } + delete(a.TerminalSessions, sessionID) + } + + if a.PendingTerminalResizes != nil { + delete(a.PendingTerminalResizes, sessionID) + } +} + +func (a *Agent) SendTerminalDone(sessionID string, exitCode int, nc *nats.Conn) { + topic := a.AgentID + ".terminal." + sessionID + + payload := map[string]interface{}{ + "done": true, + "exit_code": exitCode, + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, new(codec.MsgpackHandle)) + _ = enc.Encode(payload) + + _ = nc.Publish(topic, resp) +} + +func (a *Agent) FeedTerminalInput(sessionID string, input string) error { + a.Logger.Debugf("Input received for session %s: %.20s", sessionID, input) + + a.TerminalSessionsMu.Lock() + sess, ok := a.TerminalSessions[sessionID] + a.TerminalSessionsMu.Unlock() + + if !ok { + return fmt.Errorf("session not found: %s", sessionID) + } + + if sess.Ptmx == nil { + return fmt.Errorf("PTY not initialized for session: %s", sessionID) + } + + _, err := sess.Ptmx.Write([]byte(input)) + return err +} + +func (a *Agent) ResizeTerminalSession(sessionID string, rows, cols int) error { + a.Logger.Debugf("Resizing terminal session %s to %dx%d", sessionID, rows, cols) + + if rows <= 0 || cols <= 0 { + return nil + } + + a.TerminalSessionsMu.Lock() + sess, ok := a.TerminalSessions[sessionID] + a.TerminalSessionsMu.Unlock() + + if !ok { + a.storePendingTerminalResize(sessionID, rows, cols) + return nil + } + + if sess.Ptmx == nil { + a.storePendingTerminalResize(sessionID, rows, cols) + return nil + } + + size := &pty.Winsize{ + Rows: uint16(rows), + Cols: uint16(cols), + } + + return pty.Setsize(sess.Ptmx, size) +} + +func (a *Agent) KillTerminalSession(sessionID string) error { + a.Logger.Debugf("Killing terminal session %s", sessionID) + + var ok bool + + a.TerminalSessionsMu.Lock() + sess, ok := a.TerminalSessions[sessionID] + if ok { + if sess.Cmd != nil && sess.Cmd.Process != nil { + _ = sess.Cmd.Process.Kill() + } + + if sess.Ptmx != nil { + _ = sess.Ptmx.Close() + } + + delete(a.TerminalSessions, sessionID) + } + + if a.PendingTerminalResizes != nil { + delete(a.PendingTerminalResizes, sessionID) + } + a.TerminalSessionsMu.Unlock() + + if !ok { + a.Logger.Debugf("KillTerminalSession: session already cleaned up: %s", sessionID) + return nil + } + + a.Logger.Debugf("Terminal session %s force-killed", sessionID) + return nil +} + +func (a *Agent) SendTerminalError(agentID, sessionID, message string, nc *nats.Conn) { + topic := agentID + ".terminal." + sessionID + + payload := map[string]interface{}{ + "output": "[ERROR] " + message + "\r\n", + "session_id": sessionID, + "done": true, + "exit_code": 1, + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, new(codec.MsgpackHandle)) + _ = enc.Encode(payload) + _ = nc.Publish(topic, resp) +} diff --git a/agent/agent_unix.go b/agent/agent_unix.go index f622869d..ef239bb0 100644 --- a/agent/agent_unix.go +++ b/agent/agent_unix.go @@ -32,10 +32,11 @@ import ( "github.com/go-resty/resty/v2" "github.com/jaypipes/ghw" "github.com/kardianos/service" + nats "github.com/nats-io/nats.go" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" psHost "github.com/shirou/gopsutil/v3/host" - nats "github.com/nats-io/nats.go" + "github.com/sirupsen/logrus" "github.com/spf13/viper" trmm "github.com/wh1te909/trmm-shared" "golang.org/x/text/cases" @@ -991,6 +992,26 @@ func ModifyRegistryValue(path string, name string, valType string, data interfac return nil, errors.New("modifying registry values is only supported on Windows") } +func StartTerminalSessionWindows(agentID string, programDir string, sessionID string, shell string, runAsUser bool, nc *nats.Conn, logger *logrus.Logger) error { + return errors.New("failed to start terminal session on windows") +} + +func SendTerminalError(agentID, sessionID, message string, nc *nats.Conn) { + // no-op on non-windows builds +} + +func ResizeTerminalSessionWindows(sessionID string, rows, cols int) error { + return errors.New("failed to resize terminal session on windows") +} + +func KillTerminalSessionWindows(sessionID string) error { + return errors.New("failed to kill terminal session on windows") +} + +func FeedTerminalInputWindows(sessionID string, input string) error { + return errors.New("failed to feed input terminal session on windows") +} + func CMD(exe string, args []string, timeout int, detached bool) (output [2]string, e error) { return [2]string{"", ""}, nil } diff --git a/agent/agent_windows.go b/agent/agent_windows.go index 2283dae2..e67fb1cc 100644 --- a/agent/agent_windows.go +++ b/agent/agent_windows.go @@ -224,7 +224,7 @@ func (a *Agent) RunScript(code string, shell string, args []string, timeout int, usingEnvVars := len(envVars) > 0 cmd := exec.Command(exe, cmdArgs...) if runasuser { - token, err = wintoken.GetInteractiveToken(wintoken.TokenImpersonation) + token, err = getUserToken() if err == nil { defer token.Close() cmd.SysProcAttr = &syscall.SysProcAttr{Token: syscall.Token(token.Token()), HideWindow: true} @@ -374,7 +374,7 @@ func CMDShell(shell string, cmdArgs []string, command string, timeout int, detac } if runasuser { - token, err := wintoken.GetInteractiveToken(wintoken.TokenImpersonation) + token, err := getUserToken() if err != nil { return [2]string{"", CleanString(err.Error())}, err } diff --git a/agent/rpc.go b/agent/rpc.go index 1572a002..9a772848 100644 --- a/agent/rpc.go +++ b/agent/rpc.go @@ -817,6 +817,103 @@ func (a *Agent) RunRPC() { nc.Close() os.Exit(0) }(payload) + + case "terminal_start": + go func(p *NatsMsg) { + sessionID := p.Data["session_id"] + shell := p.Data["shell"] + if sessionID == "" { + a.Logger.Errorln("terminal_start: missing session_id") + return + } + switch runtime.GOOS { + case "windows": + if shell == "" { + shell = "cmd" + } + if err := StartTerminalSessionWindows(a.AgentID, a.ProgramDir, sessionID, shell, p.RunAsUser, nc, a.Logger); err != nil { + a.Logger.Errorln("terminal_start: StartTerminalSessionWindows:", err) + SendTerminalError(a.AgentID, sessionID, err.Error(), nc) + } + default: + if shell == "" { + shell = "/bin/bash" + } + if err := a.StartTerminalSession(sessionID, shell, nc); err != nil { + a.Logger.Errorln("terminal_start: StartTerminalSession:", err) + a.SendTerminalError(a.AgentID, sessionID, err.Error(), nc) + } + } + }(payload) + + case "terminal_input": + go func(p *NatsMsg) { + sessionID := p.Data["session_id"] + if sessionID == "" { + a.Logger.Errorln("terminal_input: missing session_id") + return + } + data := p.Data["data"] + if data == "" { + return + } + switch runtime.GOOS { + case "windows": + if err := FeedTerminalInputWindows(sessionID, data); err != nil { + a.Logger.Errorln("terminal_input: FeedTerminalInputWindows:", err) + } + default: + if err := a.FeedTerminalInput(sessionID, data); err != nil { + a.Logger.Errorln("terminal_input: FeedTerminalInput:", err) + } + } + }(payload) + + case "terminal_resize": + go func(p *NatsMsg) { + sessionID := p.Data["session_id"] + if sessionID == "" { + a.Logger.Errorln("terminal_resize: missing session_id") + return + } + rowsStr := p.Data["rows"] + colsStr := p.Data["cols"] + rows, err1 := strconv.Atoi(rowsStr) + cols, err2 := strconv.Atoi(colsStr) + if err1 != nil || err2 != nil || rows <= 0 || cols <= 0 { + a.Logger.Debugf("terminal_resize: failed to validate values: os=%s session=%s rows=%q cols=%q", runtime.GOOS, sessionID, rowsStr, colsStr) + return + } + switch runtime.GOOS { + case "windows": + if err := ResizeTerminalSessionWindows(sessionID, rows, cols); err != nil { + a.Logger.Errorln("terminal_resize: ResizeTerminalSessionWindows:", err) + } + default: + if err := a.ResizeTerminalSession(sessionID, rows, cols); err != nil { + a.Logger.Errorln("terminal_resize: ResizeTerminalSession:", err) + } + } + }(payload) + + case "terminal_kill": + go func(p *NatsMsg) { + sessionID := p.Data["session_id"] + if sessionID == "" { + a.Logger.Errorln("terminal_kill: missing session_id") + return + } + switch runtime.GOOS { + case "windows": + if err := KillTerminalSessionWindows(sessionID); err != nil { + a.Logger.Errorln("terminal_kill: KillTerminalSessionWindows:", err) + } + default: + if err := a.KillTerminalSession(sessionID); err != nil { + a.Logger.Errorln("terminal_kill: KillTerminalSession:", err) + } + } + }(payload) } }) nc.Flush() diff --git a/agent/syscall_windows.go b/agent/syscall_windows.go index 2d8b8c8e..ad6c38d8 100644 --- a/agent/syscall_windows.go +++ b/agent/syscall_windows.go @@ -32,6 +32,7 @@ var ( procReadEventLogW = modadvapi32.NewProc("ReadEventLogW") procCreateEnvironmentBlock = userenv.NewProc("CreateEnvironmentBlock") procDestroyEnvironmentBlock = userenv.NewProc("DestroyEnvironmentBlock") + procCreateProcessAsUserW = modadvapi32.NewProc("CreateProcessAsUserW") ) // https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-eventlogrecord diff --git a/agent/terminal_windows.go b/agent/terminal_windows.go new file mode 100644 index 00000000..74a5bb26 --- /dev/null +++ b/agent/terminal_windows.go @@ -0,0 +1,780 @@ +//go:build windows + +/* +Copyright 2025 AmidaWare Inc. + +Licensed under the Tactical RMM License Version 1.0 (the “License”). +You may only use the Licensed Software in accordance with the License. +A copy of the License is available at: + +https://license.tacticalrmm.com + +*/ + +package agent + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/fourcorelabs/wintoken" + winpty "github.com/iamacarpet/go-winpty" + "github.com/nats-io/nats.go" + "github.com/sirupsen/logrus" + "github.com/ugorji/go/codec" + "golang.org/x/sys/windows" +) + +type winTerminalSession struct { + id string + backend string // "conpty" or "winpty" + job windows.Handle + // conpty + hPC windows.Handle + proc windows.Handle + inW *os.File + outR *os.File + // winpty + wp *winpty.WinPTY + wpIn *os.File + wpOut *os.File + closeOnce sync.Once +} + +var ( + winTermMu sync.Mutex + winTerms = map[string]*winTerminalSession{} + pendingWinResizes = map[string]pendingWinResize{} +) + +type pendingWinResize struct { + rows int + cols int +} + +func storePendingResizeWindows(sessionID string, rows, cols int) { + if rows <= 0 || cols <= 0 { + return + } + + winTermMu.Lock() + defer winTermMu.Unlock() + pendingWinResizes[sessionID] = pendingWinResize{ + rows: rows, + cols: cols, + } +} + +func popPendingResizeWindows(sessionID string) (int, int, bool) { + winTermMu.Lock() + defer winTermMu.Unlock() + + r, ok := pendingWinResizes[sessionID] + if !ok { + return 0, 0, false + } + + delete(pendingWinResizes, sessionID) + return r.rows, r.cols, true +} + +func applyPendingResizeWindows(sessionID string, logger *logrus.Logger) { + rows, cols, ok := popPendingResizeWindows(sessionID) + if !ok { + return + } + if err := ResizeTerminalSessionWindows(sessionID, rows, cols); err != nil { + logger.Debugf("applyPendingResizeWindows failed: session=%s rows=%d cols=%d err=%v", sessionID, rows, cols, err) + } +} + +func resolveWindowsHomeDir() string { + if v := strings.TrimSpace(os.Getenv("HOME")); v != "" { + return v + } + + if v := strings.TrimSpace(os.Getenv("USERPROFILE")); v != "" { + return v + } + + hd := strings.TrimSpace(os.Getenv("HOMEDRIVE")) + hp := strings.TrimSpace(os.Getenv("HOMEPATH")) + if hd != "" && hp != "" { + return hd + hp + } + return "" +} + +func resolveWindowsShellExe(shell string) (string, error) { + s := strings.TrimSpace(shell) + sl := strings.ToLower(s) + switch sl { + case "": + return getCMDExe(), nil + case "cmd", "cmd.exe": + return getCMDExe(), nil + case "powershell", "powershell.exe": + return getPowershellExe(), nil + default: + s = filepath.Clean(s) + if !isAbsoluteWindowsExePath(s) { + return "", fmt.Errorf("invalid custom shell path: must be an absolute .exe path") + } + + if _, err := os.Stat(s); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("custom shell not found: %s", s) + } + return "", fmt.Errorf("custom shell is not accessible: %s", s) + } + return s, nil + } +} + +func isAbsoluteWindowsExePath(path string) bool { + p := strings.TrimSpace(path) + if p == "" { + return false + } + + if strings.ContainsAny(p, "\"\n\r") { + return false + } + + p = filepath.Clean(p) + if !filepath.IsAbs(p) { + return false + } + + if !strings.EqualFold(filepath.Ext(p), ".exe") { + return false + } + return true +} + +func CreateProcessAsUser( + token syscall.Token, + applicationName *uint16, + commandLine *uint16, + processAttributes *windows.SecurityAttributes, + threadAttributes *windows.SecurityAttributes, + inheritHandles bool, + creationFlags uint32, + environment *uint16, + currentDirectory *uint16, + startupInfo *windows.StartupInfo, + processInformation *windows.ProcessInformation, +) error { + var inherit uintptr + if inheritHandles { + inherit = 1 + } + + ret, _, err := procCreateProcessAsUserW.Call( + uintptr(token), + uintptr(unsafe.Pointer(applicationName)), + uintptr(unsafe.Pointer(commandLine)), + uintptr(unsafe.Pointer(processAttributes)), + uintptr(unsafe.Pointer(threadAttributes)), + inherit, + uintptr(creationFlags), + uintptr(unsafe.Pointer(environment)), + uintptr(unsafe.Pointer(currentDirectory)), + uintptr(unsafe.Pointer(startupInfo)), + uintptr(unsafe.Pointer(processInformation)), + ) + if ret == 0 { + if err != nil && err != syscall.Errno(0) { + return err + } + return syscall.EINVAL + } + return nil +} + +func getUserToken() (*wintoken.Token, error) { + token, err := wintoken.GetInteractiveToken(wintoken.TokenLinked) + if err == nil { + return token, nil + } + return wintoken.GetInteractiveToken(wintoken.TokenPrimary) +} + +func startTerminalSessionConPTY(agentID string, sessionID string, shell string, runAsUser bool, nc *nats.Conn, logger *logrus.Logger) error { + if sessionID == "" { + return fmt.Errorf("missing session_id") + } + + winTermMu.Lock() + if _, exists := winTerms[sessionID]; exists { + winTermMu.Unlock() + return fmt.Errorf("session already exists: %s", sessionID) + } + winTermMu.Unlock() + + exe, err := resolveWindowsShellExe(shell) + if err != nil { + return err + } + + inR, inW, err := createInheritablePipe() + if err != nil { + return fmt.Errorf("create input pipe: %w", err) + } + + outR, outW, err := createInheritablePipe() + if err != nil { + _ = windows.CloseHandle(inR) + _ = windows.CloseHandle(inW) + return fmt.Errorf("create output pipe: %w", err) + } + + cleanupHandles := func() { + _ = windows.CloseHandle(inR) + _ = windows.CloseHandle(inW) + _ = windows.CloseHandle(outR) + _ = windows.CloseHandle(outW) + } + + hPC, err := createPseudoConsole(120, 30, inR, outW) + if err != nil { + cleanupHandles() + return fmt.Errorf("create pseudoconsole: %w", err) + } + + _ = windows.CloseHandle(inR) + _ = windows.CloseHandle(outW) + inWFile := os.NewFile(uintptr(inW), "conpty-in") + outRFile := os.NewFile(uintptr(outR), "conpty-out") + + sess := &winTerminalSession{ + id: sessionID, + backend: "conpty", + hPC: hPC, + proc: 0, + inW: inWFile, + outR: outRFile, + } + + winTermMu.Lock() + if _, exists := winTerms[sessionID]; exists { + winTermMu.Unlock() + _ = inWFile.Close() + _ = outRFile.Close() + closePseudoConsole(hPC) + return fmt.Errorf("session already exists: %s", sessionID) + } + + winTerms[sessionID] = sess + winTermMu.Unlock() + applyPendingResizeWindows(sessionID, logger) + + cleanupRegistered := func() { + _ = StopTerminalSessionWindows(sessionID) + } + + siEx, attr, err := buildStartupInfoEx(hPC) + if err != nil { + cleanupRegistered() + return fmt.Errorf("build startupinfoex: %w", err) + } + defer deleteProcThreadAttrList(attr) + + home := resolveWindowsHomeDir() + if home != "" { + if st, err := os.Stat(home); err != nil || !st.IsDir() { + home = "" + } + } + + var cwd *uint16 + if home != "" { + cwd = windows.StringToUTF16Ptr(home) + } + + var ( + token *wintoken.Token + envBlock *uint16 + launchAsUser = false + ) + + if runAsUser { + token, err = getUserToken() + if err != nil { + logger.Debugf("terminal user token unavailable for session=%s: %v. Falling back to SYSTEM.", sessionID, err) + } else { + launchAsUser = true + defer token.Close() + + envBlock, err = CreateEnvironmentBlock(syscall.Token(token.Token())) + if err != nil { + logger.Debugf("terminal user environment unavailable for session=%s: %v. Falling back to SYSTEM.", sessionID, err) + launchAsUser = false + } else { + defer DestroyEnvironmentBlock(envBlock) + + // get users homedir + var size uint32 + _ = windows.GetUserProfileDirectory(windows.Token(token.Token()), nil, &size) + if size > 0 { + buf := make([]uint16, size) + if err := windows.GetUserProfileDirectory(windows.Token(token.Token()), &buf[0], &size); err == nil { + userHome := windows.UTF16ToString(buf) + cwd = windows.StringToUTF16Ptr(userHome) + } + } + } + } + } + + if runAsUser && !launchAsUser { + SendTerminalInfo( + agentID, + sessionID, + "Run as user was not available. Falling back to SYSTEM.", + nc, + ) + } + + cmdline := windows.StringToUTF16Ptr(quoteIfNeeded(exe)) + var pi windows.ProcessInformation + + if launchAsUser { + err = CreateProcessAsUser( + syscall.Token(token.Token()), + nil, + cmdline, + nil, + nil, + true, + EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT, + envBlock, + cwd, + &siEx.StartupInfo, + &pi, + ) + } else { + err = windows.CreateProcess( + nil, + cmdline, + nil, + nil, + true, + EXTENDED_STARTUPINFO_PRESENT, + nil, + cwd, + &siEx.StartupInfo, + &pi, + ) + } + if err != nil { + cleanupRegistered() + return fmt.Errorf("CreateProcess: %w", err) + } + + _ = windows.CloseHandle(pi.Thread) + sess.proc = pi.Process + job, jobErr := createTerminalJobObject() + if jobErr != nil { + logger.Errorf("terminal job object creation failed: session=%s err=%v", sessionID, jobErr) + _ = windows.TerminateProcess(pi.Process, 1) + cleanupRegistered() + return fmt.Errorf("failed to create terminal cleanup guard: %w", jobErr) + } + + if assignErr := windows.AssignProcessToJobObject(job, pi.Process); assignErr != nil { + logger.Errorf("terminal job assignment failed: session=%s err=%v", sessionID, assignErr) + _ = windows.TerminateProcess(pi.Process, 1) + _ = windows.CloseHandle(job) + cleanupRegistered() + return fmt.Errorf("failed to assign terminal cleanup guard: %w", assignErr) + } + sess.job = job + + go streamTerminalOutputWindows(agentID, sessionID, outRFile, nc, logger) + go func() { + _, _ = windows.WaitForSingleObject(sess.proc, windows.INFINITE) + + var code uint32 + _ = windows.GetExitCodeProcess(sess.proc, &code) + + removed := StopTerminalSessionWindows(sessionID) + if removed { + sendTerminalDoneWindows(agentID, sessionID, int(code), nc) + } + }() + + return nil +} + +// StopTerminalSessionWindows func returns true when session is removed. +func StopTerminalSessionWindows(sessionID string) bool { + winTermMu.Lock() + sess, ok := winTerms[sessionID] + if ok { + delete(winTerms, sessionID) + delete(pendingWinResizes, sessionID) + } + winTermMu.Unlock() + + if !ok { + return false + } + cleanupWinSession(sess) + return true +} + +func KillTerminalSessionWindows(sessionID string) error { + winTermMu.Lock() + sess, ok := winTerms[sessionID] + if ok { + delete(winTerms, sessionID) + delete(pendingWinResizes, sessionID) + } + winTermMu.Unlock() + + if !ok { + return nil + } + + if sess.job != 0 { + _ = windows.TerminateJobObject(sess.job, 1) + } else if sess.proc != 0 { + _ = windows.TerminateProcess(sess.proc, 1) + } + cleanupWinSession(sess) + return nil +} + +func FeedTerminalInputWindows(sessionID string, input string) error { + winTermMu.Lock() + sess, ok := winTerms[sessionID] + winTermMu.Unlock() + + if !ok { + return fmt.Errorf("session not found: %s", sessionID) + } + + if sess.backend == "winpty" { + if sess.wpIn == nil { + return fmt.Errorf("stdin not initialized for winpty session: %s", sessionID) + } + _, err := sess.wpIn.Write([]byte(input)) + return err + } + + if sess.inW == nil { + return fmt.Errorf("stdin pipe not initialized for session: %s", sessionID) + } + _, err := sess.inW.Write([]byte(input)) + return err +} + +func ResizeTerminalSessionWindows(sessionID string, rows, cols int) error { + if rows <= 0 || cols <= 0 { + return nil + } + + winTermMu.Lock() + sess, ok := winTerms[sessionID] + winTermMu.Unlock() + + if !ok { + storePendingResizeWindows(sessionID, rows, cols) + return nil + } + + if sess.backend == "winpty" { + if sess.wp == nil { + storePendingResizeWindows(sessionID, rows, cols) + return nil + } + sess.wp.SetSize(uint32(cols), uint32(rows)) + return nil + } + + if sess.hPC == 0 { + storePendingResizeWindows(sessionID, rows, cols) + return nil + } + return resizePseudoConsole(sess.hPC, int16(cols), int16(rows)) +} + +func streamTerminalOutputWindows(agentID, sessionID string, out *os.File, nc *nats.Conn, logger *logrus.Logger) { + topic := agentID + ".terminal." + sessionID + + var mh codec.MsgpackHandle + buf := make([]byte, 2048) + for { + n, err := out.Read(buf) + if err != nil { + if err != io.EOF && strings.Contains(err.Error(), "file already closed") { + // normal during cleanup/kill + return + } + if err != io.EOF { + logger.Debugf("terminal stream read error: session=%s err=%v", sessionID, err) + } + return + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, &mh) + if err := enc.Encode(buf[:n]); err != nil { + logger.Errorf("terminal msgpack encode failed: session=%s err=%v", sessionID, err) + return + } + if err := nc.Publish(topic, resp); err != nil { + logger.Errorf("terminal NATS publish failed: session=%s err=%v", sessionID, err) + return + } + } +} + +func sendTerminalDoneWindows(agentID, sessionID string, exitCode int, nc *nats.Conn) { + topic := agentID + ".terminal." + sessionID + + payload := map[string]interface{}{ + "done": true, + "exit_code": exitCode, + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, new(codec.MsgpackHandle)) + _ = enc.Encode(payload) + _ = nc.Publish(topic, resp) +} + +type jobObjectBasicLimitInfo struct { + PerProcessUserTimeLimit int64 + PerJobUserTimeLimit int64 + LimitFlags uint32 + MinimumWorkingSetSize uintptr + MaximumWorkingSetSize uintptr + ActiveProcessLimit uint32 + Affinity uintptr + PriorityClass uint32 + SchedulingClass uint32 +} + +type ioCounters struct { + ReadOperationCount uint64 + WriteOperationCount uint64 + OtherOperationCount uint64 + ReadTransferCount uint64 + WriteTransferCount uint64 + OtherTransferCount uint64 +} + +type jobObjectExtendedLimitInfo struct { + BasicLimitInformation jobObjectBasicLimitInfo + IoInfo ioCounters + ProcessMemoryLimit uintptr + JobMemoryLimit uintptr + PeakProcessMemoryUsed uintptr + PeakJobMemoryUsed uintptr +} + +const ( + jobObjectExtendedLimitInformation = 9 + jobObjectLimitKillOnJobClose = 0x00002000 +) + +// createTerminalJobObject func is used for cleaning up all child processes when the terminal session ends. +func createTerminalJobObject() (windows.Handle, error) { + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + return 0, fmt.Errorf("CreateJobObject: %w", err) + } + + info := jobObjectExtendedLimitInfo{} + info.BasicLimitInformation.LimitFlags = jobObjectLimitKillOnJobClose + + if _, err := windows.SetInformationJobObject( + job, + jobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ); err != nil { + _ = windows.CloseHandle(job) + return 0, fmt.Errorf("SetInformationJobObject: %w", err) + } + + return job, nil +} + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole") + procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole") + procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole") + + procInitializeProcThreadAttr = kernel32.NewProc("InitializeProcThreadAttributeList") + procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute") + procDeleteProcThreadAttribute = kernel32.NewProc("DeleteProcThreadAttributeList") +) + +const ( + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x00020016 + EXTENDED_STARTUPINFO_PRESENT = 0x00080000 +) + +type coord struct { + X int16 + Y int16 +} + +type startupInfoEx struct { + windows.StartupInfo + lpAttributeList *byte +} + +func cleanupWinSession(sess *winTerminalSession) { + if sess == nil { + return + } + + sess.closeOnce.Do(func() { + if sess.job != 0 { + _ = windows.TerminateJobObject(sess.job, 1) + _ = windows.CloseHandle(sess.job) + sess.job = 0 + } + // conpty + if sess.inW != nil { + _ = sess.inW.Close() + sess.inW = nil + } + if sess.outR != nil { + _ = sess.outR.Close() + sess.outR = nil + } + if sess.hPC != 0 { + closePseudoConsole(sess.hPC) + sess.hPC = 0 + } + if sess.proc != 0 { + _ = windows.CloseHandle(sess.proc) + sess.proc = 0 + } + // winpty + if sess.wp != nil { + sess.wp.Close() + sess.wp = nil + } + if sess.wpIn != nil { + _ = sess.wpIn.Close() + sess.wpIn = nil + } + if sess.wpOut != nil { + _ = sess.wpOut.Close() + sess.wpOut = nil + } + }) +} + +func quoteIfNeeded(s string) string { + if strings.ContainsAny(s, " \t") && !(strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`)) { + return `"` + s + `"` + } + return s +} + +func createInheritablePipe() (r windows.Handle, w windows.Handle, err error) { + sa := &windows.SecurityAttributes{ + Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), + InheritHandle: 1, + } + if err = windows.CreatePipe(&r, &w, sa, 0); err != nil { + return 0, 0, err + } + return r, w, nil +} + +func createPseudoConsole(cols, rows int16, inR, outW windows.Handle) (windows.Handle, error) { + var hPC windows.Handle + c := coord{X: cols, Y: rows} + coordPacked := *(*uint32)(unsafe.Pointer(&c)) + r1, _, e1 := procCreatePseudoConsole.Call( + uintptr(coordPacked), + uintptr(inR), + uintptr(outW), + 0, + uintptr(unsafe.Pointer(&hPC)), + ) + + if r1 != 0 { + return 0, error(e1) + } + return hPC, nil +} + +func resizePseudoConsole(hPC windows.Handle, cols, rows int16) error { + c := coord{X: cols, Y: rows} + coordPacked := *(*uint32)(unsafe.Pointer(&c)) + r1, _, e1 := procResizePseudoConsole.Call( + uintptr(hPC), + uintptr(coordPacked), + ) + + if r1 != 0 { + return error(e1) + } + return nil +} + +func closePseudoConsole(hPC windows.Handle) { + _, _, _ = procClosePseudoConsole.Call(uintptr(hPC)) +} + +func buildStartupInfoEx(hPC windows.Handle) (*startupInfoEx, *byte, error) { + var size uintptr + _, _, _ = procInitializeProcThreadAttr.Call(0, 1, 0, uintptr(unsafe.Pointer(&size))) + + buf := make([]byte, size) + attr := &buf[0] + + r1, _, e1 := procInitializeProcThreadAttr.Call( + uintptr(unsafe.Pointer(attr)), + 1, + 0, + uintptr(unsafe.Pointer(&size)), + ) + + if r1 == 0 { + return nil, nil, error(e1) + } + + r1, _, e1 = procUpdateProcThreadAttribute.Call( + uintptr(unsafe.Pointer(attr)), + 0, + uintptr(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE), + uintptr(hPC), + unsafe.Sizeof(hPC), + 0, + 0, + ) + + if r1 == 0 { + deleteProcThreadAttrList(attr) + return nil, nil, error(e1) + } + + var si startupInfoEx + si.Cb = uint32(unsafe.Sizeof(si)) + si.lpAttributeList = attr + return &si, attr, nil +} + +func deleteProcThreadAttrList(attr *byte) { + if attr == nil { + return + } + _, _, _ = procDeleteProcThreadAttribute.Call(uintptr(unsafe.Pointer(attr))) +} diff --git a/agent/terminal_windows_selector.go b/agent/terminal_windows_selector.go new file mode 100644 index 00000000..e76bc0e2 --- /dev/null +++ b/agent/terminal_windows_selector.go @@ -0,0 +1,69 @@ +//go:build windows + +/* +Copyright 2025 AmidaWare Inc. + +Licensed under the Tactical RMM License Version 1.0 (the “License”). +You may only use the Licensed Software in accordance with the License. +A copy of the License is available at: + +https://license.tacticalrmm.com + +*/ + +package agent + +import ( + "fmt" + + nats "github.com/nats-io/nats.go" + "github.com/sirupsen/logrus" + "github.com/ugorji/go/codec" +) + +func conptySupported() bool { + return procCreatePseudoConsole.Find() == nil +} + +func StartTerminalSessionWindows(agentID, programDir, sessionID, shell string, runAsUser bool, nc *nats.Conn, logger *logrus.Logger) error { + if sessionID == "" { + return fmt.Errorf("missing session_id") + } + + if conptySupported() { + return startTerminalSessionConPTY(agentID, sessionID, shell, runAsUser, nc, logger) + } + return startTerminalSessionWinPTY(agentID, programDir, sessionID, shell, runAsUser, nc, logger) +} + +func SendTerminalError(agentID, sessionID, message string, nc *nats.Conn) { + topic := agentID + ".terminal." + sessionID + + payload := map[string]interface{}{ + "output": "[ERROR] " + message + "\r\n", + "session_id": sessionID, + "done": true, + "exit_code": 1, + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, new(codec.MsgpackHandle)) + _ = enc.Encode(payload) + _ = nc.Publish(topic, resp) +} + +func SendTerminalInfo(agentID, sessionID, message string, nc *nats.Conn) { + topic := agentID + ".terminal." + sessionID + + payload := map[string]interface{}{ + "output": "[INFO] " + message + "\r\n", + "session_id": sessionID, + "done": false, + "exit_code": 0, + } + + var resp []byte + enc := codec.NewEncoderBytes(&resp, new(codec.MsgpackHandle)) + _ = enc.Encode(payload) + _ = nc.Publish(topic, resp) +} diff --git a/agent/terminal_windows_winpty.go b/agent/terminal_windows_winpty.go new file mode 100644 index 00000000..17dbb3c9 --- /dev/null +++ b/agent/terminal_windows_winpty.go @@ -0,0 +1,166 @@ +//go:build windows + +/* +Copyright 2025 AmidaWare Inc. + +Licensed under the Tactical RMM License Version 1.0 (the “License”). +You may only use the Licensed Software in accordance with the License. +A copy of the License is available at: + +https://license.tacticalrmm.com + +*/ + +package agent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + winpty "github.com/iamacarpet/go-winpty" + "github.com/nats-io/nats.go" + "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +func startTerminalSessionWinPTY(agentID, programDir, sessionID, shell string, runAsUser bool, nc *nats.Conn, logger *logrus.Logger) error { + winTermMu.Lock() + if _, exists := winTerms[sessionID]; exists { + winTermMu.Unlock() + return fmt.Errorf("session already exists: %s", sessionID) + } + winTermMu.Unlock() + + if runAsUser { + SendTerminalInfo( + agentID, + sessionID, + "Run as user is not supported on legacy Windows terminals. Continuing with SYSTEM.", + nc, + ) + } + + prefix, err := EnsureWinPTY(programDir, false) + if err != nil { + return fmt.Errorf("EnsureWinPTY() %v", err) + } + + dll := filepath.Join(prefix, "winpty.dll") + agentExe := filepath.Join(prefix, "winpty-agent.exe") + if _, err := os.Stat(dll); err != nil { + return fmt.Errorf("winpty missing winpty.dll at %s: %w", dll, err) + } + + if _, err := os.Stat(agentExe); err != nil { + return fmt.Errorf("winpty missing winpty-agent.exe at %s: %w", agentExe, err) + } + + cmdline := winptyCommandLine(shell) + home := resolveWindowsHomeDir() + if home != "" { + if st, err := os.Stat(home); err != nil || !st.IsDir() { + home = "" + } + } + + opts := winpty.Options{ + DLLPrefix: prefix, + AppName: "", + Command: cmdline, + Dir: home, + Env: os.Environ(), + Flags: 0, + InitialCols: 120, + InitialRows: 30, + } + + logger.Debugf( + "winpty opts: cmd=%q dir=%q prefix=%q flags=%d", + opts.Command, + opts.Dir, + opts.DLLPrefix, + opts.Flags, + ) + + wp, err := winpty.OpenWithOptions(opts) + if err != nil { + return fmt.Errorf( + "winpty open failed (cmd=%q dir=%q prefix=%q): %+v", + opts.Command, opts.Dir, opts.DLLPrefix, err, + ) + } + + cleanup := true + defer func() { + if cleanup { + wp.Close() + } + }() + + sess := &winTerminalSession{ + id: sessionID, + backend: "winpty", + wp: wp, + wpIn: wp.StdIn, + wpOut: wp.StdOut, + } + + if ph := wp.GetProcHandle(); ph != 0 { + sess.proc = windows.Handle(ph) + } + + // Register only after WinPTY successfully opened + winTermMu.Lock() + winTerms[sessionID] = sess + winTermMu.Unlock() + + applyPendingResizeWindows(sessionID, logger) + cleanup = false + go streamTerminalOutputWindows(agentID, sessionID, sess.wpOut, nc, logger) + + go func() { + if sess.proc != 0 { + _, _ = windows.WaitForSingleObject(sess.proc, windows.INFINITE) + + var code uint32 + _ = windows.GetExitCodeProcess(sess.proc, &code) + + if StopTerminalSessionWindows(sessionID) { + sendTerminalDoneWindows(agentID, sessionID, int(code), nc) + } + return + } + + _ = StopTerminalSessionWindows(sessionID) + }() + + return nil +} + +func winptyCommandLine(shell string) string { + s := strings.ToLower(strings.TrimSpace(shell)) + + switch s { + case "powershell", "powershell.exe": + ps := getPowershellExe() + return quoteIfNeeded(ps) + + case "cmd", "cmd.exe", "": + cmd := getCMDExe() + return quoteIfNeeded(cmd) + + default: + cmd := getCMDExe() + return quoteIfNeeded(cmd) + } +} + +// winptyPrefixDir returns the directory where winpty.dll and winpty-agent.exe are located. ( local go run main.go workaround ) +// func winptyPrefixDir() string { +// if v := strings.TrimSpace(`C:\Users\Administrator\rmmagent`); v != "" { +// return v +// } +// return "." +// } diff --git a/agent/winpty_bins/386/winpty-agent.exe b/agent/winpty_bins/386/winpty-agent.exe new file mode 100644 index 00000000..221e24f4 Binary files /dev/null and b/agent/winpty_bins/386/winpty-agent.exe differ diff --git a/agent/winpty_bins/386/winpty.dll b/agent/winpty_bins/386/winpty.dll new file mode 100644 index 00000000..782f40f5 Binary files /dev/null and b/agent/winpty_bins/386/winpty.dll differ diff --git a/agent/winpty_bins/amd64/winpty-agent.exe b/agent/winpty_bins/amd64/winpty-agent.exe new file mode 100644 index 00000000..c0eef1a3 Binary files /dev/null and b/agent/winpty_bins/amd64/winpty-agent.exe differ diff --git a/agent/winpty_bins/amd64/winpty.dll b/agent/winpty_bins/amd64/winpty.dll new file mode 100644 index 00000000..0a5178c6 Binary files /dev/null and b/agent/winpty_bins/amd64/winpty.dll differ diff --git a/agent/winpty_embed_windows.go b/agent/winpty_embed_windows.go new file mode 100644 index 00000000..f60ed393 --- /dev/null +++ b/agent/winpty_embed_windows.go @@ -0,0 +1,19 @@ +//go:build windows + +/* +Copyright 2025 AmidaWare Inc. + +Licensed under the Tactical RMM License Version 1.0 (the “License”). +You may only use the Licensed Software in accordance with the License. +A copy of the License is available at: + +https://license.tacticalrmm.com + +*/ + +package agent + +import "embed" + +//go:embed winpty_bins/**/* +var winptyFS embed.FS diff --git a/agent/winpty_install_windows.go b/agent/winpty_install_windows.go new file mode 100644 index 00000000..7d59eb09 --- /dev/null +++ b/agent/winpty_install_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +/* +Copyright 2025 AmidaWare Inc. + +Licensed under the Tactical RMM License Version 1.0 (the “License”). +You may only use the Licensed Software in accordance with the License. +A copy of the License is available at: + +https://license.tacticalrmm.com + +*/ + +package agent + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +func EnsureWinPTY(programDir string, force bool) (string, error) { + arch := runtime.GOARCH + if arch != "amd64" && arch != "386" { + return "", errors.New("EnsureWinPTY(): unsupported arch: " + arch) + } + + if stat, err := os.Stat(programDir); err != nil || !stat.IsDir() { + return "", fmt.Errorf("expected install directory not found: %s", programDir) + } + + dstDLL := filepath.Join(programDir, "winpty.dll") + dstAgent := filepath.Join(programDir, "winpty-agent.exe") + + if !force && fileExists(dstDLL) && fileExists(dstAgent) { + return programDir, nil + } + + dllEmbedPath := fmt.Sprintf("winpty_bins/%s/winpty.dll", arch) + agentEmbedPath := fmt.Sprintf("winpty_bins/%s/winpty-agent.exe", arch) + + dllBytes, err := winptyFS.ReadFile(dllEmbedPath) + if err != nil { + return "", fmt.Errorf("failed to read embedded DLL: %w", err) + } + + agentBytes, err := winptyFS.ReadFile(agentEmbedPath) + if err != nil { + return "", fmt.Errorf("failed to read embedded EXE: %w", err) + } + + if !force && + sameContent(dstDLL, dllBytes) && + sameContent(dstAgent, agentBytes) { + return programDir, nil + } + + if err := writeAtomic(dstDLL, dllBytes, 0o644); err != nil { + return "", fmt.Errorf("failed to write DLL: %w", err) + } + + if err := writeAtomic(dstAgent, agentBytes, 0o755); err != nil { + return "", fmt.Errorf("failed to write agent: %w", err) + } + + return programDir, nil +} + +func fileExists(p string) bool { + fi, err := os.Stat(p) + return err == nil && !fi.IsDir() +} + +func sameContent(path string, want []byte) bool { + have, err := os.ReadFile(path) + if err != nil { + return false + } + return sha256.Sum256(have) == sha256.Sum256(want) +} + +func writeAtomic(dst string, content []byte, perm os.FileMode) error { + tmp := dst + ".tmp" + if err := os.WriteFile(tmp, content, perm); err != nil { + return err + } + return os.Rename(tmp, dst) +} diff --git a/go.mod b/go.mod index 41e66181..f3f375ed 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,9 @@ require ( ) require ( + github.com/creack/pty v1.1.24 github.com/fourcorelabs/wintoken v1.0.0 + github.com/iamacarpet/go-winpty v1.0.4 github.com/jaypipes/ghw v0.12.0 github.com/kardianos/service v1.2.2 github.com/spf13/viper v1.19.0 diff --git a/go.sum b/go.sum index eb5ded3f..b15c6e23 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/amidaware/taskmaster v0.0.0-20220111015025-c9cd178bbbf2 h1:1K03qwtvgd github.com/amidaware/taskmaster v0.0.0-20220111015025-c9cd178bbbf2/go.mod h1:5UVBogOiPFWC2F6fKT/Kb9qD4NCsp2y+dCj+pvXnDQE= github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU= github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -84,6 +86,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM= github.com/iamacarpet/go-win64api v0.0.0-20230324134531-ef6dbdd6db97 h1:VjwKCN2PMLlMKM2k9AW8QQsfmEH43ldlX+JGeWW9cEE= github.com/iamacarpet/go-win64api v0.0.0-20230324134531-ef6dbdd6db97/go.mod h1:B7zFQPAznj+ujXel5X+LUoK3LgY6VboCdVYHZNn7gpg= +github.com/iamacarpet/go-winpty v1.0.4 h1:r42xaLLRZcUqjX6vHZeHos2haACfWkooOJTnFdogBfI= +github.com/iamacarpet/go-winpty v1.0.4/go.mod h1:50yLtqN2hFb5sYO5Qm2LegB166oAEw/PZYRn+FPWj0Q= github.com/jaypipes/ghw v0.12.0 h1:xU2/MDJfWmBhJnujHY9qwXQLs3DBsf0/Xa9vECY0Tho= github.com/jaypipes/ghw v0.12.0/go.mod h1:jeJGbkRB2lL3/gxYzNYzEDETV1ZJ56OKr+CSeSEym+g= github.com/jaypipes/pcidb v1.0.0 h1:vtZIfkiCUE42oYbJS0TAq9XSfSmcsgo9IdxSm9qzYU8= @@ -153,11 +157,9 @@ github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXE github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=