Files
Paul Lorenz 7e43a0a731 Add agent IPC capabilities and v2 log-level channel commands
- adds the agent-local LogLevel enum and a capability registry
  (AgentLoggingSlogLevels, the names table, mask / string-list / bit-from-string
  helpers, RegisterAppCapabilities) that freezes on Listen and panics on late
  registration
- adds AppInfoV2 (framed op 0x16) returning identity plus the two capability
  lists, with a client-side reader that falls back cleanly to the legacy
  AppInfo when an old server closes on the unknown op
- carries the agent capability bitmask in the channel hello, with
  CapabilitiesFromHeaders to decode it
- adds three v2 channel commands (SetLogLevelV2, SetChannelLogLevelV2,
  ClearChannelLogLevelV2) in the agent-reserved 30000+ content-type band,
  carrying parameters as channel string headers
- routes both the v2 channel handlers and the legacy framed handlers through a
  registered LogLevelCallbacks, falling back to today's logrus / pfxlog
  behavior when no callback is registered
- HandleChannelConnection auto-binds the v2 handlers on every agent channel
  when callbacks are registered
- the v2 handlers emit the same pfxlog audit Info lines as the framed handlers,
  so server-side audit output is identical across both paths
2026-06-12 16:04:27 -04:00

534 lines
15 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package agent provides hooks programs can register to retrieve
// diagnostics data by using the Ziti CLI.
package agent
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/michaelquigley/pfxlog"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"io"
"net"
"os"
"os/signal"
"path"
"runtime"
"runtime/debug"
"runtime/pprof"
"runtime/trace"
"strconv"
"strings"
"sync"
"time"
)
var (
mu sync.Mutex
tmpfile string
listener net.Listener
units = []string{" bytes", "KB", "MB", "GB", "TB", "PB"}
Magic = []byte{0x1, 0xB, 0xA, 0xD, 0xE, 0xC, 0xA, 0xF, 0xE, 0xF, 0x0, 0x0, 0xD}
SockPrefix = "gops-agent"
)
// Options allows configuring the started agent.
type Options struct {
// Addr is the host:port the agent will be listening at.
// Optional.
Addr string
// AppId is a way to identify the host application
AppId string
// Type is the host application type
AppType string
// AppAlias is an alternate way to identify the host application
AppAlias string
// AppVersion is the application version
AppVersion string
// ConfigDir is the directory to store the configuration file,
// PID of the gops process, filename, port as well as content.
// Optional.
ConfigDir string
// ShutdownCleanup automatically cleans up resources if the
// running process receives an interrupt. Otherwise, users
// can call Close before shutting down.
// Optional.
ShutdownCleanup *bool
// Custom Operations
CustomOps map[byte]func(conn net.Conn) error
}
// Listen starts the gops agent on a host process. Once agent started, users
// can use the advanced gops features. The agent will listen to Interrupt
// signals and exit the process, if you need to perform further work on the
// Interrupt signal use the options parameter to configure the agent
// accordingly.
//
// Note: The agent exposes an endpoint via a TCP connection that can be used by
// any program on the system. Review your security requirements before starting
// the agent.
func Listen(opts Options) error {
mu.Lock()
defer mu.Unlock()
if listener != nil {
return errors.Errorf("gops: agent already listening at: %v", listener.Addr())
}
if opts.ShutdownCleanup == nil || *opts.ShutdownCleanup {
gracefulShutdown()
}
addr := opts.Addr
network := "tcp"
if addr == "" {
network = "unix"
tmpfile = path.Join(os.TempDir(), fmt.Sprintf("%v.%v.sock", SockPrefix, os.Getpid()))
if err := os.Remove(tmpfile); err != nil && !os.IsNotExist(err) {
return err
}
addr = tmpfile
} else {
parts := strings.SplitN(addr, ":", 2)
if len(parts) == 2 {
network = parts[0]
addr = parts[1]
}
if network == "unix" {
tmpfile = addr
}
}
var err error
if listener, err = net.Listen(network, addr); err != nil {
return err
}
if network == "unix" {
if err := os.Chmod(addr, 0700); err != nil {
_ = listener.Close()
return err
}
}
if opts.ConfigDir != "" {
if err := os.MkdirAll(opts.ConfigDir, os.ModePerm); err != nil {
return err
}
if tcpAddr, ok := listener.Addr().(*net.TCPAddr); ok {
port := tcpAddr.Port
tmpfile = fmt.Sprintf("%s/%d", opts.ConfigDir, os.Getpid())
if err = os.WriteFile(tmpfile, []byte(strconv.Itoa(port)), os.ModePerm); err != nil {
return err
}
}
}
h := &handler{
options: opts,
listener: listener,
}
// Freeze the capability set; no further capability or handler registration
// is accepted once we start accepting connections.
freezeCapabilities()
go h.listen()
return nil
}
// handler owns the listener for the lifetime of the listen goroutine. The
// handler holds its own reference rather than reading the package-global
// listener, so cleanup paths that nil out the global don't race with the
// goroutine's Accept loop or its deferred Close.
type handler struct {
options Options
listener net.Listener
}
func (self *handler) listen() {
logger := pfxlog.Logger()
defer func() {
if err := self.listener.Close(); err != nil {
logger.WithError(err).Error("error closing gops listener")
}
}()
for {
if conn, err := self.listener.Accept(); err != nil {
logger.WithError(err).Error("error accepting gops connection, closing gops listener")
break
} else {
self.handleConnection(conn)
}
}
}
func (self *handler) handleConnection(conn net.Conn) {
async := false
defer func() {
if !async {
_ = conn.Close()
}
}()
logger := pfxlog.Logger()
if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
logger.WithError(err).Error("unable to set read deadline on gops connection")
return
}
buf := make([]byte, len(Magic)+1)
if _, err := io.ReadFull(conn, buf); err != nil {
logger.WithError(err).Error("unable to read gops request")
return
}
if !bytes.Equal(Magic, buf[:len(Magic)]) {
logger.Error("invalid magic number on gops connection", hex.Dump(buf[:len(Magic)]))
return
}
var err error
if async, err = self.handle(conn, buf[len(buf)-1]); err != nil {
logger.WithError(err).Error("error while processing gops request")
_, _ = conn.Write([]byte(err.Error()))
}
}
func gracefulShutdown() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
go func() {
// cleanup the socket on shutdown.
<-c
Close()
}()
}
// Close closes the agent, removing temporary files and closing the TCP listener.
// If no agent is listening, Close does nothing.
func Close() {
mu.Lock()
defer mu.Unlock()
if listener != nil {
_ = listener.Close()
}
if tmpfile != "" {
_ = os.Remove(tmpfile)
}
}
func formatBytes(val uint64) string {
var i int
var target uint64
for i = range units {
target = 1 << uint(10*(i+1))
if val < target {
break
}
}
if i > 0 {
return fmt.Sprintf("%0.2f%s (%d bytes)", float64(val)/(float64(target)/1024), units[i], val)
}
return fmt.Sprintf("%d bytes", val)
}
func (self *handler) handle(conn net.Conn, op byte) (bool, error) {
switch op {
case StackTrace:
return false, pprof.Lookup("goroutine").WriteTo(conn, 2)
case GC:
runtime.GC()
_, err := conn.Write([]byte("ok"))
return false, err
case MemStats:
var s runtime.MemStats
runtime.ReadMemStats(&s)
_, _ = fmt.Fprintf(conn, "alloc: %v\n", formatBytes(s.Alloc))
_, _ = fmt.Fprintf(conn, "total-alloc: %v\n", formatBytes(s.TotalAlloc))
_, _ = fmt.Fprintf(conn, "sys: %v\n", formatBytes(s.Sys))
_, _ = fmt.Fprintf(conn, "lookups: %v\n", s.Lookups)
_, _ = fmt.Fprintf(conn, "mallocs: %v\n", s.Mallocs)
_, _ = fmt.Fprintf(conn, "frees: %v\n", s.Frees)
_, _ = fmt.Fprintf(conn, "heap-alloc: %v\n", formatBytes(s.HeapAlloc))
_, _ = fmt.Fprintf(conn, "heap-sys: %v\n", formatBytes(s.HeapSys))
_, _ = fmt.Fprintf(conn, "heap-idle: %v\n", formatBytes(s.HeapIdle))
_, _ = fmt.Fprintf(conn, "heap-in-use: %v\n", formatBytes(s.HeapInuse))
_, _ = fmt.Fprintf(conn, "heap-released: %v\n", formatBytes(s.HeapReleased))
_, _ = fmt.Fprintf(conn, "heap-objects: %v\n", s.HeapObjects)
_, _ = fmt.Fprintf(conn, "stack-in-use: %v\n", formatBytes(s.StackInuse))
_, _ = fmt.Fprintf(conn, "stack-sys: %v\n", formatBytes(s.StackSys))
_, _ = fmt.Fprintf(conn, "stack-mspan-inuse: %v\n", formatBytes(s.MSpanInuse))
_, _ = fmt.Fprintf(conn, "stack-mspan-sys: %v\n", formatBytes(s.MSpanSys))
_, _ = fmt.Fprintf(conn, "stack-mcache-inuse: %v\n", formatBytes(s.MCacheInuse))
_, _ = fmt.Fprintf(conn, "stack-mcache-sys: %v\n", formatBytes(s.MCacheSys))
_, _ = fmt.Fprintf(conn, "other-sys: %v\n", formatBytes(s.OtherSys))
_, _ = fmt.Fprintf(conn, "gc-sys: %v\n", formatBytes(s.GCSys))
_, _ = fmt.Fprintf(conn, "next-gc: when heap-alloc >= %v\n", formatBytes(s.NextGC))
lastGC := "-"
if s.LastGC != 0 {
lastGC = fmt.Sprint(time.Unix(0, int64(s.LastGC)))
}
_, _ = fmt.Fprintf(conn, "last-gc: %v\n", lastGC)
_, _ = fmt.Fprintf(conn, "gc-pause-total: %v\n", time.Duration(s.PauseTotalNs))
_, _ = fmt.Fprintf(conn, "gc-pause: %v\n", s.PauseNs[(s.NumGC+255)%256])
_, _ = fmt.Fprintf(conn, "num-gc: %v\n", s.NumGC)
_, _ = fmt.Fprintf(conn, "enable-gc: %v\n", s.EnableGC)
_, _ = fmt.Fprintf(conn, "debug-gc: %v\n", s.DebugGC)
case Version:
_, _ = fmt.Fprintf(conn, "%v\n", runtime.Version())
case HeapProfile:
_ = pprof.WriteHeapProfile(conn)
case CPUProfile:
if err := pprof.StartCPUProfile(conn); err != nil {
return false, err
}
time.Sleep(30 * time.Second)
pprof.StopCPUProfile()
case Stats:
_, _ = fmt.Fprintf(conn, "goroutines: %v\n", runtime.NumGoroutine())
_, _ = fmt.Fprintf(conn, "OS threads: %v\n", pprof.Lookup("threadcreate").Count())
_, _ = fmt.Fprintf(conn, "GOMAXPROCS: %v\n", runtime.GOMAXPROCS(0))
_, _ = fmt.Fprintf(conn, "num CPU: %v\n", runtime.NumCPU())
case BinaryDump:
executable, err := os.Executable()
if err != nil {
return false, err
}
f, err := os.Open(executable)
if err != nil {
return false, err
}
defer func() { _ = f.Close() }()
_, err = bufio.NewReader(f).WriteTo(conn)
return false, err
case Trace:
if err := trace.Start(conn); err != nil {
return false, err
}
time.Sleep(5 * time.Second)
trace.Stop()
case SetGCPercent:
perc, err := binary.ReadVarint(bufio.NewReader(conn))
if err != nil {
return false, err
}
_, _ = fmt.Fprintf(conn, "New GC percent set to %v. Previous value was %v.\n", perc, debug.SetGCPercent(int(perc)))
case AppInfo:
result := map[string]string{}
if self.options.AppType != "" {
result["type"] = self.options.AppType
}
if self.options.AppId != "" {
result["id"] = self.options.AppId
}
if self.options.AppAlias != "" {
result["alias"] = self.options.AppAlias
}
if self.options.AppVersion != "" {
result["version"] = self.options.AppVersion
}
marshalled, err := json.Marshal(result)
if err != nil {
return false, err
}
_, err = conn.Write(marshalled)
return false, err
case AppInfoV2:
result := AppInfoV2Response{
Type: self.options.AppType,
Id: self.options.AppId,
Alias: self.options.AppAlias,
Version: self.options.AppVersion,
AgentCapabilities: GetAgentCapabilityStringList(),
AppCapabilities: getAppCapabilities(),
}
marshalled, err := json.Marshal(result)
if err != nil {
return false, err
}
_, err = conn.Write(marshalled)
return false, err
case SetLogLevel:
param, err := bufio.NewReader(conn).ReadByte()
if err != nil {
return false, err
}
if param < byte(logrus.PanicLevel) || param > byte(logrus.TraceLevel) {
return false, errors.Errorf("invalid log level %v", param)
}
// The framed command carries a logrus.Level byte; the agent LogLevel
// enum is defined in the same order, so the byte maps directly.
if cbs := getLogLevelCallbacks(); cbs != nil {
level := LogLevel(param)
cbs.SetLogLevel(level)
pfxlog.Logger().Infof("log level set to %v", level)
_, _ = fmt.Fprintf(conn, "log level set to %v\n", level)
} else {
oldLevel := logrus.GetLevel()
newLevel := logrus.Level(param)
logrus.SetLevel(newLevel)
pfxlog.Logger().Infof("log level set to from %v to %v", oldLevel, newLevel)
_, _ = fmt.Fprintf(conn, "log level set from %v to %v\n", oldLevel, newLevel)
}
case SetChannelLogLevel:
reader := bufio.NewReader(conn)
channel, err := self.readVarString(reader, 1024)
if err != nil {
return false, err
}
level, err := reader.ReadByte()
if err != nil {
return false, err
}
if level < byte(logrus.PanicLevel) || level > byte(logrus.TraceLevel) {
return false, errors.Errorf("invalid log level %v", level)
}
if cbs := getLogLevelCallbacks(); cbs != nil {
lvl := LogLevel(level)
cbs.SetChannelLogLevel(channel, lvl)
pfxlog.Logger().Infof("log level for channel %v set to %v", channel, lvl)
_, _ = fmt.Fprintf(conn, "log level for channel %v set to %v\n", channel, lvl)
} else {
var prevLevel string
newLevel := logrus.Level(level)
pfxlog.GlobalConfig(func(options *pfxlog.Options) *pfxlog.Options {
if val, found := options.ChannelLogLevelOverrides[channel]; found {
prevLevel = val.String()
} else {
prevLevel = "default"
}
options.SetChannelLogLevel(channel, newLevel)
return options
})
pfxlog.Logger().Infof("log level for channel %v set from %v to %v", channel, prevLevel, newLevel)
_, _ = fmt.Fprintf(conn, "log level for channel %v set from %v to %v\n", channel, prevLevel, newLevel)
}
case ClearChannelLogLevel:
reader := bufio.NewReader(conn)
channel, err := self.readVarString(reader, 1024)
if err != nil {
return false, err
}
if cbs := getLogLevelCallbacks(); cbs != nil {
cbs.ClearChannelLogLevel(channel)
pfxlog.Logger().Infof("log level for channel %v cleared", channel)
_, _ = fmt.Fprintf(conn, "log level for channel %v cleared\n", channel)
} else {
var prevLevel string
pfxlog.GlobalConfig(func(options *pfxlog.Options) *pfxlog.Options {
if val, found := options.ChannelLogLevelOverrides[channel]; found {
prevLevel = val.String()
} else {
prevLevel = "default"
}
options.ClearChannelLogLevel(channel)
return options
})
pfxlog.Logger().Infof("log level for channel %v cleared, was %v", channel, prevLevel)
_, _ = fmt.Fprintf(conn, "log level for channel %v cleared, was %v\n", channel, prevLevel)
}
case HeapDump:
reader := bufio.NewReader(conn)
outputFileName, err := self.readVarString(reader, 1024)
if err != nil {
return false, err
}
f, err := os.Create(outputFileName)
if err != nil {
return false, err
}
defer func() {
if err = f.Close(); err != nil {
pfxlog.Logger().WithError(err).Errorf("failed to close file %s", outputFileName)
}
}()
debug.WriteHeapDump(f.Fd())
pfxlog.Logger().Infof("heap dump save to %s", outputFileName)
default:
if self.options.CustomOps != nil {
if customHandler, found := self.options.CustomOps[op]; found {
if err := customHandler(conn); err != nil {
return false, err
}
return op == CustomOpAsync, nil
}
}
}
return false, nil
}
func (self *handler) readVarString(reader *bufio.Reader, maxLen int64) (string, error) {
strLen, err := binary.ReadVarint(reader)
if err != nil {
return "", err
}
if strLen > maxLen {
return "", errors.Errorf("string exceeds max length of %v", maxLen)
}
buf := make([]byte, strLen)
_, err = io.ReadFull(reader, buf)
if err != nil {
return "", err
}
return string(buf), nil
}