rmmagent/agent/agent.go

605 lines
15 KiB
Go
Raw Normal View History

2022-03-19 18:55:43 +00:00
/*
2023-09-01 21:33:43 +00:00
Copyright 2022 AmidaWare Inc.
2022-03-19 18:55:43 +00:00
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 (
"bytes"
"context"
"crypto/tls"
2022-03-19 18:55:43 +00:00
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
2023-05-22 22:04:03 +00:00
"syscall"
2022-03-19 18:55:43 +00:00
"time"
rmm "github.com/amidaware/rmmagent/shared"
ps "github.com/elastic/go-sysinfo"
gocmd "github.com/go-cmd/cmd"
"github.com/go-resty/resty/v2"
"github.com/kardianos/service"
nats "github.com/nats-io/nats.go"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/sirupsen/logrus"
trmm "github.com/wh1te909/trmm-shared"
)
// Agent struct
type Agent struct {
2023-04-29 22:30:23 +00:00
Hostname string
Arch string
AgentID string
BaseURL string
ApiURL string
Token string
AgentPK int
Cert string
ProgramDir string
EXE string
SystemDrive string
WinTmpDir string
WinRunAsUserTmpDir string
MeshInstaller string
MeshSystemEXE string
MeshSVC string
PyBin 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
2022-03-19 18:55:43 +00:00
}
const (
2022-10-25 18:34:44 +00:00
progFilesName = "TacticalAgent"
winExeName = "tacticalrmm.exe"
winSvcName = "tacticalrmm"
meshSvcName = "mesh agent"
etcConfig = "/etc/tacticalagent"
nixAgentDir = "/opt/tacticalagent"
nixMeshDir = "/opt/tacticalmesh"
nixAgentBin = nixAgentDir + "/tacticalagent"
nixMeshAgentBin = nixMeshDir + "/meshagent"
macPlistPath = "/Library/LaunchDaemons/tacticalagent.plist"
macPlistName = "tacticalagent"
defaultMacMeshSvcDir = "/usr/local/mesh_services"
2022-03-19 18:55:43 +00:00
)
2023-04-29 22:30:23 +00:00
var defaultWinTmpDir = filepath.Join(os.Getenv("PROGRAMDATA"), "TacticalRMM")
var winMeshDir = filepath.Join(os.Getenv("PROGRAMFILES"), "Mesh Agent")
2022-03-19 18:55:43 +00:00
var natsCheckin = []string{"agent-hello", "agent-agentinfo", "agent-disks", "agent-winsvc", "agent-publicip", "agent-wmi"}
var limitNatsData = []string{"agent-winsvc", "agent-wmi"}
2022-03-19 18:55:43 +00:00
func New(logger *logrus.Logger, version string) *Agent {
host, _ := ps.Host()
info := host.Info()
pd := filepath.Join(os.Getenv("ProgramFiles"), progFilesName)
exe := filepath.Join(pd, winExeName)
sd := os.Getenv("SystemDrive")
2023-04-29 22:30:23 +00:00
winTempDir := defaultWinTmpDir
winRunAsUserTmpDir := defaultWinTmpDir
2022-03-19 18:55:43 +00:00
2023-08-14 20:25:42 +00:00
hostname, err := os.Hostname()
if err != nil {
hostname = info.Hostname
}
2022-03-19 18:55:43 +00:00
var pybin string
switch runtime.GOARCH {
case "amd64":
pybin = filepath.Join(pd, "py38-x64", "python.exe")
case "386":
pybin = filepath.Join(pd, "py38-x32", "python.exe")
}
ac := NewAgentConfig()
headers := make(map[string]string)
if len(ac.Token) > 0 {
headers["Content-Type"] = "application/json"
headers["Authorization"] = fmt.Sprintf("Token %s", ac.Token)
}
insecure := ac.Insecure == "true"
2022-03-19 18:55:43 +00:00
restyC := resty.New()
restyC.SetBaseURL(ac.BaseURL)
restyC.SetCloseConnection(true)
restyC.SetHeaders(headers)
restyC.SetTimeout(15 * time.Second)
restyC.SetDebug(logger.IsLevelEnabled(logrus.DebugLevel))
if insecure {
insecureConf := &tls.Config{
InsecureSkipVerify: true,
}
restyC.SetTLSClientConfig(insecureConf)
}
2022-03-19 18:55:43 +00:00
if len(ac.Proxy) > 0 {
restyC.SetProxy(ac.Proxy)
}
if len(ac.Cert) > 0 {
restyC.SetRootCertificate(ac.Cert)
}
2023-04-29 22:30:23 +00:00
if len(ac.WinTmpDir) > 0 {
winTempDir = ac.WinTmpDir
}
if len(ac.WinRunAsUserTmpDir) > 0 {
winRunAsUserTmpDir = ac.WinRunAsUserTmpDir
}
2022-06-17 05:14:44 +00:00
var MeshSysExe string
2022-09-23 23:05:17 +00:00
switch runtime.GOOS {
case "windows":
if len(ac.CustomMeshDir) > 0 {
MeshSysExe = filepath.Join(ac.CustomMeshDir, "MeshAgent.exe")
} else {
MeshSysExe = filepath.Join(os.Getenv("ProgramFiles"), "Mesh Agent", "MeshAgent.exe")
}
case "darwin":
2022-10-25 18:34:44 +00:00
if trmm.FileExists(nixMeshAgentBin) {
MeshSysExe = nixMeshAgentBin
} else {
MeshSysExe = "/usr/local/mesh_services/meshagent/meshagent"
}
2022-09-23 23:05:17 +00:00
default:
2022-10-25 18:34:44 +00:00
MeshSysExe = nixMeshAgentBin
2022-08-23 20:55:25 +00:00
}
2022-03-19 18:55:43 +00:00
svcConf := &service.Config{
Executable: exe,
Name: winSvcName,
DisplayName: "TacticalRMM Agent Service",
Arguments: []string{"-m", "svc"},
Description: "TacticalRMM Agent Service",
Option: service.KeyValue{
"StartType": "automatic",
"OnFailure": "restart",
"OnFailureDelayDuration": "12s",
2022-03-19 18:55:43 +00:00
"OnFailureResetPeriod": 10,
},
}
var natsProxyPath, natsProxyPort string
if ac.NatsProxyPath == "" {
natsProxyPath = "natsws"
}
if ac.NatsProxyPort == "" {
natsProxyPort = "443"
}
// check if using nats standard tcp, otherwise use nats websockets by default
var natsServer string
2022-11-29 19:38:17 +00:00
var natsWsCompression bool
if ac.NatsStandardPort != "" {
natsServer = fmt.Sprintf("tls://%s:%s", ac.APIURL, ac.NatsStandardPort)
} else {
natsServer = fmt.Sprintf("wss://%s:%s", ac.APIURL, natsProxyPort)
2022-11-29 19:38:17 +00:00
natsWsCompression = true
}
var natsPingInterval int
if ac.NatsPingInterval == 0 {
natsPingInterval = randRange(35, 45)
} else {
natsPingInterval = ac.NatsPingInterval
}
2022-03-19 18:55:43 +00:00
return &Agent{
2023-08-14 20:25:42 +00:00
Hostname: hostname,
2023-04-29 22:30:23 +00:00
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,
MeshInstaller: "meshagent.exe",
MeshSystemEXE: MeshSysExe,
MeshSVC: meshSvcName,
PyBin: pybin,
Headers: headers,
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,
2022-03-19 18:55:43 +00:00
}
}
type CmdStatus struct {
Status gocmd.Status
Stdout string
Stderr string
}
type CmdOptions struct {
Shell string
Command string
Args []string
Timeout time.Duration
IsScript bool
IsExecutable bool
Detached bool
2022-11-29 07:18:22 +00:00
EnvVars []string
2022-03-19 18:55:43 +00:00
}
func (a *Agent) NewCMDOpts() *CmdOptions {
return &CmdOptions{
Shell: "/bin/bash",
2023-08-01 05:09:48 +00:00
Timeout: 60,
2022-03-19 18:55:43 +00:00
}
}
func (a *Agent) CmdV2(c *CmdOptions) CmdStatus {
ctx, cancel := context.WithTimeout(context.Background(), c.Timeout*time.Second)
defer cancel()
// Disable output buffering, enable streaming
cmdOptions := gocmd.Options{
Buffered: false,
Streaming: true,
}
// have a child process that is in a different process group so that
// parent terminating doesn't kill child
if c.Detached {
2022-09-11 01:34:04 +00:00
cmdOptions.BeforeExec = append(cmdOptions.BeforeExec, func(cmd *exec.Cmd) {
cmd.SysProcAttr = SetDetached()
})
}
2022-11-29 07:18:22 +00:00
if len(c.EnvVars) > 0 {
2022-09-11 01:34:04 +00:00
cmdOptions.BeforeExec = append(cmdOptions.BeforeExec, func(cmd *exec.Cmd) {
cmd.Env = os.Environ()
2022-11-29 07:18:22 +00:00
cmd.Env = append(cmd.Env, c.EnvVars...)
2022-09-11 01:34:04 +00:00
})
2022-03-19 18:55:43 +00:00
}
var envCmd *gocmd.Cmd
if c.IsScript {
envCmd = gocmd.NewCmdOptions(cmdOptions, c.Shell, c.Args...) // call script directly
} else if c.IsExecutable {
envCmd = gocmd.NewCmdOptions(cmdOptions, c.Shell, c.Command) // c.Shell: bin + c.Command: args as one string
} else {
envCmd = gocmd.NewCmdOptions(cmdOptions, c.Shell, "-c", c.Command) // /bin/bash -c 'ls -l /var/log/...'
}
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
// Print STDOUT and STDERR lines streaming from Cmd
doneChan := make(chan struct{})
go func() {
defer close(doneChan)
// Done when both channels have been closed
// https://dave.cheney.net/2013/04/30/curious-channels
for envCmd.Stdout != nil || envCmd.Stderr != nil {
select {
case line, open := <-envCmd.Stdout:
if !open {
envCmd.Stdout = nil
continue
}
fmt.Fprintln(&stdoutBuf, line)
a.Logger.Debugln(line)
case line, open := <-envCmd.Stderr:
if !open {
envCmd.Stderr = nil
continue
}
fmt.Fprintln(&stderrBuf, line)
a.Logger.Debugln(line)
}
}
}()
2023-08-01 05:09:48 +00:00
statusChan := make(chan gocmd.Status, 1)
2023-05-22 22:04:03 +00:00
// workaround for https://github.com/golang/go/issues/22315
2022-03-19 18:55:43 +00:00
go func() {
2023-08-01 05:09:48 +00:00
for i := 0; i < 5; i++ {
finalStatus := <-envCmd.Start()
if errors.Is(finalStatus.Error, syscall.ETXTBSY) {
a.Logger.Errorln("CmdV2 syscall.ETXTBSY, retrying...")
time.Sleep(500 * time.Millisecond)
continue
}
statusChan <- finalStatus
2022-03-19 18:55:43 +00:00
return
}
}()
2023-08-01 05:09:48 +00:00
var finalStatus gocmd.Status
select {
case <-ctx.Done():
a.Logger.Debugf("Command timed out after %d seconds\n", c.Timeout)
pid := envCmd.Status().PID
a.Logger.Debugln("Killing process with PID", pid)
KillProc(int32(pid))
finalStatus.Exit = 98
ret := CmdStatus{
Status: finalStatus,
Stdout: CleanString(stdoutBuf.String()),
Stderr: fmt.Sprintf("%s\nTimed out after %d seconds", CleanString(stderrBuf.String()), c.Timeout),
}
a.Logger.Debugf("%+v\n", ret)
return ret
case finalStatus = <-statusChan:
// done
}
2022-03-19 18:55:43 +00:00
// Wait for goroutine to print everything
<-doneChan
2023-08-01 05:09:48 +00:00
2022-03-19 18:55:43 +00:00
ret := CmdStatus{
2023-08-01 05:09:48 +00:00
Status: finalStatus,
2022-03-19 18:55:43 +00:00
Stdout: CleanString(stdoutBuf.String()),
Stderr: CleanString(stderrBuf.String()),
}
a.Logger.Debugf("%+v\n", ret)
return ret
}
func (a *Agent) GetCPULoadAvg() int {
fallback := false
pyCode := `
import psutil
try:
print(int(round(psutil.cpu_percent(interval=10))), end='')
except:
print("pyerror", end='')
`
pypercent, err := a.RunPythonCode(pyCode, 13, []string{})
if err != nil || pypercent == "pyerror" {
fallback = true
}
i, err := strconv.Atoi(pypercent)
if err != nil {
fallback = true
}
if fallback {
percent, err := cpu.Percent(10*time.Second, false)
if err != nil {
a.Logger.Debugln("Go CPU Check:", err)
return 0
}
return int(math.Round(percent[0]))
}
return i
}
// ForceKillMesh kills all mesh agent related processes
func (a *Agent) ForceKillMesh() {
pids := make([]int, 0)
procs, err := ps.Processes()
if err != nil {
return
}
for _, process := range procs {
p, err := process.Info()
if err != nil {
continue
}
if strings.Contains(strings.ToLower(p.Name), "meshagent") {
pids = append(pids, p.PID)
}
}
for _, pid := range pids {
a.Logger.Debugln("Killing mesh process with pid %d", pid)
if err := KillProc(int32(pid)); err != nil {
a.Logger.Debugln(err)
}
}
}
func (a *Agent) SyncMeshNodeID() {
id, err := a.getMeshNodeID()
if err != nil {
a.Logger.Errorln("SyncMeshNodeID() getMeshNodeID()", err)
return
}
payload := rmm.MeshNodeID{
Func: "syncmesh",
Agentid: a.AgentID,
NodeID: StripAll(id),
}
_, err = a.rClient.R().SetBody(payload).Post("/api/v3/syncmesh/")
if err != nil {
a.Logger.Debugln("SyncMesh:", err)
}
}
func (a *Agent) setupNatsOptions() []nats.Option {
2022-11-29 19:38:17 +00:00
reconnectWait := randRange(2, 8)
2022-03-19 18:55:43 +00:00
opts := make([]nats.Option, 0)
2022-11-25 07:48:27 +00:00
opts = append(opts, nats.Name(a.AgentID))
2022-03-19 18:55:43 +00:00
opts = append(opts, nats.UserInfo(a.AgentID, a.Token))
2022-11-29 19:38:17 +00:00
opts = append(opts, nats.ReconnectWait(time.Duration(reconnectWait)*time.Second))
2022-03-19 18:55:43 +00:00
opts = append(opts, nats.RetryOnFailedConnect(true))
2022-12-21 00:08:18 +00:00
opts = append(opts, nats.IgnoreAuthErrorAbort())
2022-11-29 19:38:17 +00:00
opts = append(opts, nats.PingInterval(time.Duration(a.NatsPingInterval)*time.Second))
opts = append(opts, nats.Compression(a.NatsWSCompression))
2022-03-19 18:55:43 +00:00
opts = append(opts, nats.MaxReconnects(-1))
opts = append(opts, nats.ReconnectBufSize(-1))
2022-06-28 06:31:56 +00:00
opts = append(opts, nats.ProxyPath(a.NatsProxyPath))
2022-11-29 19:38:17 +00:00
opts = append(opts, nats.ReconnectJitter(500*time.Millisecond, 4*time.Second))
2022-11-25 07:48:27 +00:00
opts = append(opts, nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
2022-12-01 01:13:55 +00:00
a.Logger.Debugln("NATS disconnected:", err)
a.Logger.Debugf("%+v\n", nc.Statistics)
2022-11-25 07:48:27 +00:00
}))
opts = append(opts, nats.ReconnectHandler(func(nc *nats.Conn) {
2022-12-01 01:13:55 +00:00
a.Logger.Debugln("NATS reconnected")
a.Logger.Debugf("%+v\n", nc.Statistics)
2022-11-25 07:48:27 +00:00
}))
opts = append(opts, nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) {
2022-11-29 07:19:58 +00:00
a.Logger.Errorln("NATS error:", err)
a.Logger.Errorf("%+v\n", sub)
2022-11-25 07:48:27 +00:00
}))
if a.Insecure {
insecureConf := &tls.Config{
InsecureSkipVerify: true,
}
opts = append(opts, nats.Secure(insecureConf))
}
2022-03-19 18:55:43 +00:00
return opts
}
func (a *Agent) GetUninstallExe() string {
cderr := os.Chdir(a.ProgramDir)
if cderr == nil {
files, err := filepath.Glob("unins*.exe")
if err == nil {
for _, f := range files {
if strings.Contains(f, "001") {
return f
}
}
}
}
return "unins000.exe"
}
func (a *Agent) CleanupAgentUpdates() {
// TODO remove a.ProgramDir, updates are now in winTempDir
2023-04-29 22:30:23 +00:00
dirs := [3]string{a.WinTmpDir, os.Getenv("TMP"), a.ProgramDir}
for _, dir := range dirs {
err := os.Chdir(dir)
if err != nil {
a.Logger.Debugln("CleanupAgentUpdates()", dir, err)
continue
2022-03-19 18:55:43 +00:00
}
// TODO winagent-v* is deprecated
2022-11-07 06:20:49 +00:00
globs := [3]string{"tacticalagent-v*", "is-*.tmp", "winagent-v*"}
for _, glob := range globs {
files, err := filepath.Glob(glob)
if err == nil {
for _, f := range files {
a.Logger.Debugln("CleanupAgentUpdates() Removing file:", f)
os.Remove(f)
}
}
2022-06-26 16:02:48 +00:00
}
}
err := os.Chdir(os.Getenv("TMP"))
2022-03-19 18:55:43 +00:00
if err == nil {
dirs, err := filepath.Glob("tacticalrmm*")
if err == nil {
for _, f := range dirs {
os.RemoveAll(f)
}
2022-03-19 18:55:43 +00:00
}
}
}
func (a *Agent) RunPythonCode(code string, timeout int, args []string) (string, error) {
content := []byte(code)
2023-05-22 20:16:51 +00:00
tmpfn, _ := os.CreateTemp(a.WinTmpDir, "*.py")
2022-03-19 18:55:43 +00:00
if _, err := tmpfn.Write(content); err != nil {
a.Logger.Debugln(err)
return "", err
}
2022-08-09 19:41:07 +00:00
defer os.Remove(tmpfn.Name())
2022-03-19 18:55:43 +00:00
if err := tmpfn.Close(); err != nil {
a.Logger.Debugln(err)
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
var outb, errb bytes.Buffer
cmdArgs := []string{tmpfn.Name()}
if len(args) > 0 {
cmdArgs = append(cmdArgs, args...)
}
a.Logger.Debugln(cmdArgs)
cmd := exec.CommandContext(ctx, a.PyBin, cmdArgs...)
cmd.Stdout = &outb
cmd.Stderr = &errb
cmdErr := cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
a.Logger.Debugln("RunPythonCode:", ctx.Err())
return "", ctx.Err()
}
if cmdErr != nil {
a.Logger.Debugln("RunPythonCode:", cmdErr)
return "", cmdErr
}
if errb.String() != "" {
a.Logger.Debugln(errb.String())
return errb.String(), errors.New("RunPythonCode stderr")
}
return outb.String(), nil
}
func createWinTempDir() error {
2023-04-29 22:30:23 +00:00
if !trmm.FileExists(defaultWinTmpDir) {
err := os.Mkdir(defaultWinTmpDir, 0775)
2022-03-19 18:55:43 +00:00
if err != nil {
return err
2022-03-19 18:55:43 +00:00
}
}
return nil
2022-03-19 18:55:43 +00:00
}