diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index f5b211cdf..ea11f5046 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -268,6 +268,7 @@ following works: - github.com/netsampler/goflow2 [BSD 3-Clause "New" or "Revised" License](https://github.com/netsampler/goflow2/blob/main/LICENSE) - github.com/newrelic/newrelic-telemetry-sdk-go [Apache License 2.0](https://github.com/newrelic/newrelic-telemetry-sdk-go/blob/master/LICENSE.md) - github.com/nsqio/go-nsq [MIT License](https://github.com/nsqio/go-nsq/blob/master/LICENSE) +- github.com/nwaples/tacplus [BSD 2-Clause "Simplified" License](https://github.com/nwaples/tacplus/blob/master/LICENSE) - github.com/olivere/elastic [MIT License](https://github.com/olivere/elastic/blob/release-branch.v7/LICENSE) - github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil [Apache License 2.0](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/LICENSE) - github.com/openconfig/gnmi [Apache License 2.0](https://github.com/openconfig/gnmi/blob/master/LICENSE) diff --git a/go.mod b/go.mod index b7e0aeb85..e8e95126f 100644 --- a/go.mod +++ b/go.mod @@ -138,6 +138,7 @@ require ( github.com/netsampler/goflow2 v1.3.3 github.com/newrelic/newrelic-telemetry-sdk-go v0.8.1 github.com/nsqio/go-nsq v1.1.0 + github.com/nwaples/tacplus v0.0.3 github.com/olivere/elastic v6.2.37+incompatible github.com/openconfig/gnmi v0.10.0 github.com/opensearch-project/opensearch-go/v2 v2.3.0 diff --git a/go.sum b/go.sum index e5e4880e6..f8289ab82 100644 --- a/go.sum +++ b/go.sum @@ -1182,6 +1182,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE= github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY= github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= +github.com/nwaples/tacplus v0.0.3 h1:i3v/BUWWrbKq00BzFDrgcPksUF4HwAWvS8Zk63ezYXg= +github.com/nwaples/tacplus v0.0.3/go.mod h1:y5ZA9N5V2JbmwO766S+ET9zuu5FtL1OtdfBCYrbTIgw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= diff --git a/plugins/inputs/all/tacacs.go b/plugins/inputs/all/tacacs.go new file mode 100644 index 000000000..fd898ca4e --- /dev/null +++ b/plugins/inputs/all/tacacs.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.tacacs + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/tacacs" // register plugin diff --git a/plugins/inputs/tacacs/README.md b/plugins/inputs/tacacs/README.md new file mode 100644 index 000000000..c8276584c --- /dev/null +++ b/plugins/inputs/tacacs/README.md @@ -0,0 +1,75 @@ +# Tacacs Input Plugin + +The Tacacs plugin collects successful tacacs authentication response times +from tacacs servers such as Aruba ClearPass, FreeRADIUS or tac_plus (TACACS+). +It is primarily meant to monitor how long it takes for the server to fully +handle an auth request, including all potential dependent calls (for example +to AD servers, or other sources of truth for auth the tacacs server uses). + +## Global configuration options + +In addition to the plugin-specific configuration settings, plugins support +additional global and plugin configuration settings. These settings are used to +modify metrics, tags, and field or create aliases and configure ordering, etc. +See the [CONFIGURATION.md][CONFIGURATION.md] for more details. + +[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins + +## Configuration + +```toml @sample.conf +# Tacacs plugin collects successful tacacs authentication response times. +[[inputs.tacacs]] + ## An array of Server IPs (or hostnames) and ports to gather from. If none specified, defaults to localhost. + # servers = ["127.0.0.1:49"] + + ## Request source server IP, normally the server running telegraf. + # request_ip = "127.0.0.1" + + ## Credentials for tacacs authentication. + username = "myuser" + password = "mypassword" + secret = "mysecret" + + ## Maximum time to receive response. + # response_timeout = "5s" +``` + +## Metrics + +- tacacs + - tags: + - source + - fields: + - response_status (string, [see below](#field-response_status))) + - responsetime_ms (int64 [see below](#field-responsetime_ms))) + +### field `response_status` + +The field "response_status" is either a translated raw code returned +by the tacacs server, or filled by telegraf in case of a timeout. + +| Field Value | Raw Code | From | responsetime_ms +| -------------------- | ------------ | ------------- | --------------- +| AuthenStatusPass | 1 (0x1) | tacacs server | real value +| AuthenStatusFail | 2 (0x2) | tacacs server | real value +| AuthenStatusGetData | 3 (0x3) | tacacs server | real value +| AuthenStatusGetUser | 4 (0x4) | tacacs server | real value +| AuthenStatusGetPass | 5 (0x5) | tacacs server | real value +| AuthenStatusRestart | 6 (0x6) | tacacs server | real value +| AuthenStatusError | 7 (0x7) | tacacs server | real value +| AuthenStatusFollow | 33 (0x21) | tacacs server | real value +| Timeout | Timeout | telegraf | eq. to response_timeout + +### field `responsetime_ms` + +The field responsetime_ms is response time of the tacacs server +in miliseconds of the furthest achieved stage of auth. +In case of timeout, its filled by telegraf to be the value of +the configured response_timeout. + +## Example Output + +```text +tacacs,source=127.0.0.1:49 responsetime_ms=311i,response_status="AuthenStatusPass" 1677526200000000000 +``` diff --git a/plugins/inputs/tacacs/sample.conf b/plugins/inputs/tacacs/sample.conf new file mode 100644 index 000000000..bb378b6c8 --- /dev/null +++ b/plugins/inputs/tacacs/sample.conf @@ -0,0 +1,15 @@ +# Tacacs plugin collects successful tacacs authentication response times. +[[inputs.tacacs]] + ## An array of Server IPs (or hostnames) and ports to gather from. If none specified, defaults to localhost. + # servers = ["127.0.0.1:49"] + + ## Request source server IP, normally the server running telegraf. + # request_ip = "127.0.0.1" + + ## Credentials for tacacs authentication. + username = "myuser" + password = "mypassword" + secret = "mysecret" + + ## Maximum time to receive response. + # response_timeout = "5s" diff --git a/plugins/inputs/tacacs/tacacs.go b/plugins/inputs/tacacs/tacacs.go new file mode 100644 index 000000000..be6a13ab5 --- /dev/null +++ b/plugins/inputs/tacacs/tacacs.go @@ -0,0 +1,209 @@ +//go:generate ../../../tools/readme_config_includer/generator +package tacacs + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net" + "os" + "strconv" + "sync" + "time" + + "github.com/nwaples/tacplus" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/plugins/inputs" +) + +type Tacacs struct { + Servers []string `toml:"servers"` + Username config.Secret `toml:"username"` + Password config.Secret `toml:"password"` + Secret config.Secret `toml:"secret"` + RequestAddr string `toml:"request_ip"` + ResponseTimeout config.Duration `toml:"response_timeout"` + Log telegraf.Logger `toml:"-"` + clients []tacplus.Client + authStart tacplus.AuthenStart +} + +//go:embed sample.conf +var sampleConfig string + +func (t *Tacacs) SampleConfig() string { + return sampleConfig +} + +func (t *Tacacs) Init() error { + if len(t.Servers) == 0 { + t.Servers = []string{"127.0.0.1:49"} + } + + if t.Username.Empty() || t.Password.Empty() || t.Secret.Empty() { + return errors.New("empty credentials were provided (username, password or secret)") + } + + if t.RequestAddr == "" { + t.RequestAddr = "127.0.0.1" + } + if net.ParseIP(t.RequestAddr) == nil { + return fmt.Errorf("invalid ip address provided for request_ip: %s", t.RequestAddr) + } + + t.clients = make([]tacplus.Client, 0, len(t.Servers)) + for _, server := range t.Servers { + t.clients = append(t.clients, tacplus.Client{ + Addr: server, + ConnConfig: tacplus.ConnConfig{}, + }) + } + + t.authStart = tacplus.AuthenStart{ + Action: tacplus.AuthenActionLogin, + AuthenType: tacplus.AuthenTypeASCII, + AuthenService: tacplus.AuthenServiceLogin, + PrivLvl: 1, + Port: "heartbeat", + RemAddr: t.RequestAddr, + } + + return nil +} + +func (t *Tacacs) AuthenReplyToString(code uint8) string { + switch code { + case tacplus.AuthenStatusPass: + return `AuthenStatusPass` + case tacplus.AuthenStatusFail: + return `AuthenStatusFail` + case tacplus.AuthenStatusGetData: + return `AuthenStatusGetData` + case tacplus.AuthenStatusGetUser: + return `AuthenStatusGetUser` + case tacplus.AuthenStatusGetPass: + return `AuthenStatusGetPass` + case tacplus.AuthenStatusRestart: + return `AuthenStatusRestart` + case tacplus.AuthenStatusError: + return `AuthenStatusError` + case tacplus.AuthenStatusFollow: + return `AuthenStatusFollow` + } + return "AuthenStatusUnknown(" + strconv.FormatUint(uint64(code), 10) + ")" +} + +func (t *Tacacs) Gather(acc telegraf.Accumulator) error { + var wg sync.WaitGroup + + for idx := range t.clients { + wg.Add(1) + go func(client *tacplus.Client) { + defer wg.Done() + acc.AddError(t.pollServer(acc, client)) + }(&t.clients[idx]) + } + + wg.Wait() + return nil +} + +func (t *Tacacs) pollServer(acc telegraf.Accumulator, client *tacplus.Client) error { + // Create the fields for this metric + tags := map[string]string{"source": client.Addr} + fields := make(map[string]interface{}) + + secret, err := t.Secret.Get() + if err != nil { + return fmt.Errorf("getting secret failed: %w", err) + } + defer config.ReleaseSecret(secret) + + client.ConnConfig.Secret = secret + + username, err := t.Username.Get() + if err != nil { + return fmt.Errorf("getting username failed: %w", err) + } + defer config.ReleaseSecret(username) + + password, err := t.Password.Get() + if err != nil { + return fmt.Errorf("getting password failed: %w", err) + } + defer config.ReleaseSecret(password) + + ctx := context.Background() + if t.ResponseTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(t.ResponseTimeout)) + defer cancel() + } + + startTime := time.Now() + reply, session, err := client.SendAuthenStart(ctx, &t.authStart) + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, os.ErrDeadlineExceeded) { + return fmt.Errorf("error on new tacacs authentication start request to %s : %w", client.Addr, err) + } + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = "Timeout" + acc.AddFields("tacacs", fields, tags) + return nil + } + defer session.Close() + if reply.Status != tacplus.AuthenStatusGetUser { + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = t.AuthenReplyToString(reply.Status) + acc.AddFields("tacacs", fields, tags) + return nil + } + + reply, err = session.Continue(ctx, string(username)) + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, os.ErrDeadlineExceeded) { + return fmt.Errorf("error on tacacs authentication continue username request to %s : %w", client.Addr, err) + } + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = "Timeout" + acc.AddFields("tacacs", fields, tags) + return nil + } + if reply.Status != tacplus.AuthenStatusGetPass { + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = t.AuthenReplyToString(reply.Status) + acc.AddFields("tacacs", fields, tags) + return nil + } + + reply, err = session.Continue(ctx, string(password)) + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, os.ErrDeadlineExceeded) { + return fmt.Errorf("error on tacacs authentication continue password request to %s : %w", client.Addr, err) + } + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = "Timeout" + acc.AddFields("tacacs", fields, tags) + return nil + } + if reply.Status != tacplus.AuthenStatusPass { + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = t.AuthenReplyToString(reply.Status) + acc.AddFields("tacacs", fields, tags) + return nil + } + + fields["responsetime_ms"] = time.Since(startTime).Milliseconds() + fields["response_status"] = t.AuthenReplyToString(reply.Status) + acc.AddFields("tacacs", fields, tags) + return nil +} + +func init() { + inputs.Add("tacacs", func() telegraf.Input { + return &Tacacs{ResponseTimeout: config.Duration(time.Second * 5)} + }) +} diff --git a/plugins/inputs/tacacs/tacacs_test.go b/plugins/inputs/tacacs/tacacs_test.go new file mode 100644 index 000000000..321cbbd30 --- /dev/null +++ b/plugins/inputs/tacacs/tacacs_test.go @@ -0,0 +1,315 @@ +package tacacs + +import ( + "context" + "net" + "testing" + "time" + + "github.com/nwaples/tacplus" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/testutil" +) + +type testRequestHandler map[string]struct { + password string + args []string +} + +func (t testRequestHandler) HandleAuthenStart(_ context.Context, a *tacplus.AuthenStart, s *tacplus.ServerSession) *tacplus.AuthenReply { + user := a.User + for user == "" { + c, err := s.GetUser(context.Background(), "Username:") + if err != nil || c.Abort { + return nil + } + user = c.Message + } + pass := "" + for pass == "" { + c, err := s.GetPass(context.Background(), "Password:") + if err != nil || c.Abort { + return nil + } + pass = c.Message + } + if u, ok := t[user]; ok && u.password == pass { + return &tacplus.AuthenReply{Status: tacplus.AuthenStatusPass} + } + return &tacplus.AuthenReply{Status: tacplus.AuthenStatusFail} +} + +func (t testRequestHandler) HandleAuthorRequest(_ context.Context, a *tacplus.AuthorRequest, _ *tacplus.ServerSession) *tacplus.AuthorResponse { + if u, ok := t[a.User]; ok { + return &tacplus.AuthorResponse{Status: tacplus.AuthorStatusPassAdd, Arg: u.args} + } + return &tacplus.AuthorResponse{Status: tacplus.AuthorStatusFail} +} + +func (t testRequestHandler) HandleAcctRequest(_ context.Context, _ *tacplus.AcctRequest, _ *tacplus.ServerSession) *tacplus.AcctReply { + return &tacplus.AcctReply{Status: tacplus.AcctStatusSuccess} +} + +func TestTacacsInit(t *testing.T) { + var testset = []struct { + name string + testingTimeout config.Duration + serversToTest []string + usedUsername config.Secret + usedPassword config.Secret + usedSecret config.Secret + requestAddr string + errContains string + }{ + { + name: "empty_creds", + testingTimeout: config.Duration(time.Second * 5), + serversToTest: []string{"foo.bar:80"}, + usedUsername: config.NewSecret([]byte(``)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + errContains: "empty credentials were provided (username, password or secret)", + }, + { + name: "wrong_reqaddress", + testingTimeout: config.Duration(time.Second * 5), + serversToTest: []string{"foo.bar:80"}, + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + requestAddr: "257.257.257.257", + errContains: "invalid ip address provided for request_ip", + }, + { + name: "no_reqaddress_no_servers", + testingTimeout: config.Duration(time.Second * 5), + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + }, + } + + for _, tt := range testset { + t.Run(tt.name, func(t *testing.T) { + plugin := &Tacacs{ + ResponseTimeout: tt.testingTimeout, + Servers: tt.serversToTest, + Username: tt.usedUsername, + Password: tt.usedPassword, + Secret: tt.usedSecret, + RequestAddr: tt.requestAddr, + Log: testutil.Logger{}, + } + + err := plugin.Init() + + if tt.errContains == "" { + require.NoError(t, err) + if tt.requestAddr == "" { + require.Equal(t, "127.0.0.1", plugin.RequestAddr) + } + if len(tt.serversToTest) == 0 { + require.Equal(t, []string{"127.0.0.1:49"}, plugin.Servers) + } + } else { + require.ErrorContains(t, err, tt.errContains) + } + }) + } +} + +func TestTacacsLocal(t *testing.T) { + testHandler := tacplus.ServerConnHandler{ + Handler: &testRequestHandler{ + "testusername": { + password: "testpassword", + }, + }, + ConnConfig: tacplus.ConnConfig{ + Secret: []byte(`testsecret`), + Mux: true, + }, + } + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err, "local net listen failed to start listening") + + srvLocal := l.Addr().String() + + srv := &tacplus.Server{ + ServeConn: func(nc net.Conn) { + testHandler.Serve(nc) + }, + } + + go func() { + err = srv.Serve(l) + require.NoError(t, err, "local srv.Serve failed to start serving on "+srvLocal) + }() + + var testset = []struct { + name string + testingTimeout config.Duration + serverToTest []string + usedUsername config.Secret + usedPassword config.Secret + usedSecret config.Secret + requestAddr string + errContains string + reqRespStatus string + }{ + { + name: "success_timeout_0s", + testingTimeout: config.Duration(0), + serverToTest: []string{srvLocal}, + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + requestAddr: "127.0.0.1", + reqRespStatus: "AuthenStatusPass", + }, + { + name: "wrongpw", + testingTimeout: config.Duration(time.Second * 5), + serverToTest: []string{srvLocal}, + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`WRONGPASSWORD`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + requestAddr: "127.0.0.1", + reqRespStatus: "AuthenStatusFail", + }, + { + name: "wrongsecret", + testingTimeout: config.Duration(time.Second * 5), + serverToTest: []string{srvLocal}, + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`WRONGSECRET`)), + requestAddr: "127.0.0.1", + errContains: "error on new tacacs authentication start request to " + srvLocal + " : bad secret or packet", + }, + { + name: "unreachable", + testingTimeout: config.Duration(time.Nanosecond * 1000), + serverToTest: []string{"unreachable.test:49"}, + usedUsername: config.NewSecret([]byte(`testusername`)), + usedPassword: config.NewSecret([]byte(`testpassword`)), + usedSecret: config.NewSecret([]byte(`testsecret`)), + requestAddr: "127.0.0.1", + errContains: "error on new tacacs authentication start request to unreachable.test:49 : dial tcp", + }, + } + + for _, tt := range testset { + t.Run(tt.name, func(t *testing.T) { + plugin := &Tacacs{ + ResponseTimeout: tt.testingTimeout, + Servers: tt.serverToTest, + Username: tt.usedUsername, + Password: tt.usedPassword, + Secret: tt.usedSecret, + RequestAddr: tt.requestAddr, + Log: testutil.Logger{}, + } + + var acc testutil.Accumulator + + require.NoError(t, plugin.Init()) + require.NoError(t, plugin.Gather(&acc)) + + if tt.errContains == "" { + require.Len(t, acc.Errors, 0) + require.Equal(t, true, acc.HasMeasurement("tacacs")) + require.Equal(t, true, acc.HasTag("tacacs", "source")) + require.Equal(t, srvLocal, acc.TagValue("tacacs", "source")) + require.Equal(t, true, acc.HasInt64Field("tacacs", "responsetime_ms")) + require.Equal(t, true, acc.HasStringField("tacacs", "response_status")) + require.Equal(t, tt.reqRespStatus, acc.Metrics[0].Fields["response_status"]) + } else { + require.Len(t, acc.Errors, 1) + require.ErrorContains(t, acc.FirstError(), tt.errContains) + require.Equal(t, false, acc.HasTag("tacacs", "source")) + require.Equal(t, false, acc.HasInt64Field("tacacs", "responsetime_ms")) + require.Equal(t, false, acc.HasStringField("tacacs", "response_status")) + } + }) + } +} + +func TestTacacsIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + container := testutil.Container{ + Image: "dchidell/docker-tacacs", + ExposedPorts: []string{"49/tcp"}, + WaitingFor: wait.ForAll( + wait.ForLog("Starting server..."), + ), + } + err := container.Start() + require.NoError(t, err, "failed to start container") + defer container.Terminate() + + port := container.Ports["49"] + + // Define the testset + var testset = []struct { + name string + testingTimeout config.Duration + usedPassword string + reqRespStatus string + }{ + { + name: "timeout_3s", + testingTimeout: config.Duration(time.Second * 3), + usedPassword: "cisco", + reqRespStatus: "AuthenStatusPass", + }, + { + name: "timeout_0s", + testingTimeout: config.Duration(0), + usedPassword: "cisco", + reqRespStatus: "AuthenStatusPass", + }, + { + name: "wrong_pw", + testingTimeout: config.Duration(time.Second * 5), + usedPassword: "wrongpass", + reqRespStatus: "AuthenStatusFail", + }, + } + + for _, tt := range testset { + t.Run(tt.name, func(t *testing.T) { + // Setup the plugin-under-test + plugin := &Tacacs{ + ResponseTimeout: tt.testingTimeout, + Servers: []string{container.Address + ":" + port}, + Username: config.NewSecret([]byte(`iosadmin`)), + Password: config.NewSecret([]byte(tt.usedPassword)), + Secret: config.NewSecret([]byte(`ciscotacacskey`)), + RequestAddr: "127.0.0.1", + Log: testutil.Logger{}, + } + var acc testutil.Accumulator + + // Startup the plugin & Gather + require.NoError(t, plugin.Init()) + require.NoError(t, plugin.Gather(&acc)) + + require.NoError(t, acc.FirstError()) + + require.Equal(t, true, acc.HasMeasurement("tacacs")) + require.Equal(t, true, acc.HasStringField("tacacs", "response_status")) + require.Equal(t, true, acc.HasInt64Field("tacacs", "responsetime_ms")) + require.Equal(t, true, acc.HasTag("tacacs", "source")) + + require.Equal(t, tt.reqRespStatus, acc.Metrics[0].Fields["response_status"]) + require.Equal(t, container.Address+":"+port, acc.TagValue("tacacs", "source")) + }) + } +}