diff --git a/docs/LICENSE_OF_DEPENDENCIES.md b/docs/LICENSE_OF_DEPENDENCIES.md index a3dec1465..a57c776a5 100644 --- a/docs/LICENSE_OF_DEPENDENCIES.md +++ b/docs/LICENSE_OF_DEPENDENCIES.md @@ -377,6 +377,7 @@ following works: - k8s.io/klog [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) - k8s.io/kube-openapi [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) - k8s.io/utils [Apache License 2.0](https://github.com/kubernetes/client-go/blob/master/LICENSE) +- layeh.com/radius [Mozilla Public License 2.0](https://github.com/layeh/radius/blob/master/LICENSE) - modernc.org/libc [BSD 3-Clause "New" or "Revised" License](https://gitlab.com/cznic/libc/-/blob/master/LICENSE) - modernc.org/mathutil [BSD 3-Clause "New" or "Revised" License](https://gitlab.com/cznic/mathutil/-/blob/master/LICENSE) - modernc.org/memory [BSD 3-Clause "New" or "Revised" License](https://gitlab.com/cznic/memory/-/blob/master/LICENSE) diff --git a/go.mod b/go.mod index 2579cc071..77db0b560 100644 --- a/go.mod +++ b/go.mod @@ -196,6 +196,7 @@ require ( k8s.io/api v0.25.3 k8s.io/apimachinery v0.25.6 k8s.io/client-go v0.25.0 + layeh.com/radius v0.0.0-20221205141417-e7fbddd11d68 modernc.org/sqlite v1.19.2 ) diff --git a/go.sum b/go.sum index 0b79624e6..b0b26fa89 100644 --- a/go.sum +++ b/go.sum @@ -2480,6 +2480,7 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -3362,6 +3363,8 @@ k8s.io/kube-openapi v0.0.0-20220803164354-a70c9af30aea/go.mod h1:C/N6wCaBHeBHkHU k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed h1:jAne/RjBTyawwAy0utX5eqigAwz/lQhTmy+Hr/Cpue4= k8s.io/utils v0.0.0-20220728103510-ee6ede2d64ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +layeh.com/radius v0.0.0-20221205141417-e7fbddd11d68 h1:2NDro2Jzkrqfngy/sA5GVnChs7fx8EzcQKFi/lI2cfg= +layeh.com/radius v0.0.0-20221205141417-e7fbddd11d68/go.mod h1:pFWM9De99EY9TPVyHIyA56QmoRViVck/x41WFkUlc9A= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= diff --git a/plugins/inputs/all/radius.go b/plugins/inputs/all/radius.go new file mode 100644 index 000000000..2d77af178 --- /dev/null +++ b/plugins/inputs/all/radius.go @@ -0,0 +1,5 @@ +//go:build !custom || inputs || inputs.radius + +package all + +import _ "github.com/influxdata/telegraf/plugins/inputs/radius" // register plugin diff --git a/plugins/inputs/radius/README.md b/plugins/inputs/radius/README.md new file mode 100644 index 000000000..29a51ab77 --- /dev/null +++ b/plugins/inputs/radius/README.md @@ -0,0 +1,44 @@ +# Radius Input Plugin + +The Radius plugin collects radius authentication response times. + +## 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 +[[inputs.radius]] + ## An array of Server IPs and ports to gather from. If none specified, defaults to localhost. + servers = ["127.0.0.1:1812","hostname.domain.com:1812"] + + ## Credentials for radius authentication. + username = "myuser" + password = "mypassword" + secret = "mysecret" + + ## Maximum time to receive response. + # response_timeout = "5s" +``` + +## Metrics + +- radius + - tags: + - response_code + - source + - source_port + - fields: + - responsetime_ms (int64) + +## Example Output + +```shell +radius,response_code=Access-Accept,source=hostname.com,source_port=1812 responsetime_ms=311i 1677526200000000000 +``` diff --git a/plugins/inputs/radius/radius.go b/plugins/inputs/radius/radius.go new file mode 100644 index 000000000..7db2f6ff0 --- /dev/null +++ b/plugins/inputs/radius/radius.go @@ -0,0 +1,137 @@ +//go:generate ../../../tools/readme_config_includer/generator +package radius + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net" + "sync" + "time" + + "layeh.com/radius" + "layeh.com/radius/rfc2865" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/plugins/inputs" +) + +type Radius struct { + Servers []string `toml:"servers"` + Username config.Secret `toml:"username"` + Password config.Secret `toml:"password"` + Secret config.Secret `toml:"secret"` + ResponseTimeout config.Duration `toml:"response_timeout"` + Log telegraf.Logger `toml:"-"` + client radius.Client +} + +//go:embed sample.conf +var sampleConfig string + +func (r *Radius) SampleConfig() string { + return sampleConfig +} + +func (r *Radius) Init() error { + if len(r.Servers) == 0 { + r.Servers = []string{"127.0.0.1:1812"} + } + + r.client = radius.Client{ + Retry: 0, + } + + return nil +} + +func (r *Radius) Gather(acc telegraf.Accumulator) error { + var wg sync.WaitGroup + + for _, server := range r.Servers { + wg.Add(1) + go func(server string) { + defer wg.Done() + acc.AddError(r.pollServer(acc, server)) + }(server) + } + + wg.Wait() + return nil +} + +func (r *Radius) pollServer(acc telegraf.Accumulator, server string) error { + // Create the fields for this metric + host, port, err := net.SplitHostPort(server) + if err != nil { + return fmt.Errorf("splitting host and port failed: %w", err) + } + tags := map[string]string{"source": host, "source_port": port} + fields := make(map[string]interface{}) + + secret, err := r.Secret.Get() + if err != nil { + return fmt.Errorf("getting secret failed: %w", err) + } + defer config.ReleaseSecret(secret) + + username, err := r.Username.Get() + if err != nil { + return fmt.Errorf("getting username failed: %w", err) + } + defer config.ReleaseSecret(username) + + password, err := r.Password.Get() + if err != nil { + return fmt.Errorf("getting password failed: %w", err) + } + defer config.ReleaseSecret(password) + + // Create the radius packet with PAP authentication + packet := radius.New(radius.CodeAccessRequest, secret) + err = rfc2865.UserName_Set(packet, username) + if err != nil { + return fmt.Errorf("setting username for radius auth failed: %w", err) + } + err = rfc2865.UserPassword_Set(packet, password) + if err != nil { + return fmt.Errorf("setting password for radius auth failed: %w", err) + } + + // Do the radius request + ctx := context.Background() + if r.ResponseTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(r.ResponseTimeout)) + defer cancel() + } + + startTime := time.Now() + response, err := r.client.Exchange(ctx, packet, server) + duration := time.Since(startTime) + + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + fields["responsetime_ms"] = time.Duration(r.ResponseTimeout).Milliseconds() + tags["response_code"] = "timeout" + } else if response.Code != radius.CodeAccessAccept { + fields["responsetime_ms"] = time.Duration(r.ResponseTimeout).Milliseconds() + tags["response_code"] = response.Code.String() + } else { + fields["responsetime_ms"] = duration.Milliseconds() + tags["response_code"] = response.Code.String() + } + + acc.AddFields("radius", fields, tags) + return nil +} + +func init() { + inputs.Add("radius", func() telegraf.Input { + return &Radius{ResponseTimeout: config.Duration(time.Second * 5)} + }) +} diff --git a/plugins/inputs/radius/radius_test.go b/plugins/inputs/radius/radius_test.go new file mode 100644 index 000000000..fd5ba1619 --- /dev/null +++ b/plugins/inputs/radius/radius_test.go @@ -0,0 +1,194 @@ +package radius + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/testutil" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go/wait" + "layeh.com/radius" + "layeh.com/radius/rfc2865" +) + +func TestRadiusLocal(t *testing.T) { + handler := func(w radius.ResponseWriter, r *radius.Request) { + username := rfc2865.UserName_GetString(r.Packet) + password := rfc2865.UserPassword_GetString(r.Packet) + + var code radius.Code + if username == "testusername" && password == "testpassword" { + code = radius.CodeAccessAccept + } else { + code = radius.CodeAccessReject + } + if err := w.Write(r.Response(code)); err != nil { + require.NoError(t, err, "failed writing radius server response") + } + } + + server := radius.PacketServer{ + Handler: radius.HandlerFunc(handler), + SecretSource: radius.StaticSecretSource([]byte(`testsecret`)), + Addr: ":1813", + } + + go func() { + if err := server.ListenAndServe(); err != nil { + if !errors.Is(err, radius.ErrServerShutdown) { + require.NoError(t, err, "local radius server failed") + } + } + }() + + plugin := &Radius{ + Servers: []string{"localhost:1813"}, + Username: config.NewSecret([]byte(`testusername`)), + Password: config.NewSecret([]byte(`testpassword`)), + Secret: config.NewSecret([]byte(`testsecret`)), + Log: testutil.Logger{}, + } + var acc testutil.Accumulator + + require.NoError(t, plugin.Init()) + require.NoError(t, plugin.Gather(&acc)) + require.Len(t, acc.Errors, 0) + if !acc.HasMeasurement("radius") { + t.Errorf("acc.HasMeasurement: expected radius") + } + require.Equal(t, true, acc.HasTag("radius", "source")) + require.Equal(t, true, acc.HasTag("radius", "source_port")) + require.Equal(t, true, acc.HasTag("radius", "response_code")) + require.Equal(t, "localhost", acc.TagValue("radius", "source")) + require.Equal(t, "1813", acc.TagValue("radius", "source_port")) + require.Equal(t, radius.CodeAccessAccept.String(), acc.TagValue("radius", "response_code")) + require.Equal(t, true, acc.HasInt64Field("radius", "responsetime_ms")) + + if err := server.Shutdown(context.Background()); err != nil { + require.NoError(t, err, "failed to properly shutdown local radius server") + } +} + +func TestRadiusIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + testdata, err := filepath.Abs("testdata/raddb/clients.conf") + require.NoError(t, err, "determining absolute path of test-data clients.conf failed") + testdataa, err := filepath.Abs("testdata/raddb/mods-config/files/authorize") + require.NoError(t, err, "determining absolute path of test-data authorize failed") + testdataaa, err := filepath.Abs("testdata/raddb/radiusd.conf") + require.NoError(t, err, "determining absolute path of test-data radiusd.conf failed") + + container := testutil.Container{ + Image: "freeradius/freeradius-server", + ExposedPorts: []string{"1812/udp"}, + BindMounts: map[string]string{ + "/etc/raddb/clients.conf": testdata, + "/etc/raddb/mods-config/files/authorize": testdataa, + "/etc/raddb/radiusd.conf": testdataaa, + }, + WaitingFor: wait.ForAll( + wait.ForLog("Ready to process requests"), + ), + } + err = container.Start() + require.NoError(t, err, "failed to start container") + defer container.Terminate() + + port := container.Ports["1812"] + + // Define the testset + var testset = []struct { + name string + testingTimeout config.Duration + expectedSource string + expectedSourcePort string + serverToTest string + expectSuccess bool + usedPassword string + }{ + { + name: "timeout_5s", + testingTimeout: config.Duration(time.Second * 5), + expectedSource: container.Address, + expectedSourcePort: port, + serverToTest: container.Address + ":" + port, + expectSuccess: true, + usedPassword: "testpassword", + }, + { + name: "timeout_0s", + testingTimeout: config.Duration(0), + expectedSource: container.Address, + expectedSourcePort: port, + serverToTest: container.Address + ":" + port, + expectSuccess: true, + usedPassword: "testpassword", + }, + { + name: "wrong_pw", + testingTimeout: config.Duration(time.Second * 5), + expectedSource: container.Address, + expectedSourcePort: port, + serverToTest: container.Address + ":" + port, + expectSuccess: false, + usedPassword: "wrongpass", + }, + { + name: "unreachable", + testingTimeout: config.Duration(5), + expectedSource: "unreachable.unreachable.com", + expectedSourcePort: "7777", + serverToTest: "unreachable.unreachable.com:7777", + expectSuccess: false, + usedPassword: "testpassword", + }, + } + + for _, tt := range testset { + t.Run(tt.name, func(t *testing.T) { + // Setup the plugin-under-test + plugin := &Radius{ + ResponseTimeout: tt.testingTimeout, + Servers: []string{tt.serverToTest}, + Username: config.NewSecret([]byte(`testusername`)), + Password: config.NewSecret([]byte(tt.usedPassword)), + Secret: config.NewSecret([]byte(`testsecret`)), + Log: testutil.Logger{}, + } + var acc testutil.Accumulator + + // Startup the plugin + require.NoError(t, plugin.Init()) + + // Gather + require.NoError(t, plugin.Gather(&acc)) + require.Len(t, acc.Errors, 0) + + if !acc.HasMeasurement("radius") { + t.Errorf("acc.HasMeasurement: expected radius") + } + require.Equal(t, true, acc.HasTag("radius", "source")) + require.Equal(t, true, acc.HasTag("radius", "source_port")) + require.Equal(t, true, acc.HasTag("radius", "response_code")) + require.Equal(t, tt.expectedSource, acc.TagValue("radius", "source")) + require.Equal(t, tt.expectedSourcePort, acc.TagValue("radius", "source_port")) + require.Equal(t, true, acc.HasInt64Field("radius", "responsetime_ms"), true) + if tt.expectSuccess { + require.Equal(t, radius.CodeAccessAccept.String(), acc.TagValue("radius", "response_code")) + } else { + require.NotEqual(t, radius.CodeAccessAccept.String(), acc.TagValue("radius", "response_code")) + } + + if tt.name == "unreachable" { + require.Equal(t, time.Duration(tt.testingTimeout).Milliseconds(), acc.Metrics[0].Fields["responsetime_ms"]) + } + }) + } +} diff --git a/plugins/inputs/radius/sample.conf b/plugins/inputs/radius/sample.conf new file mode 100644 index 000000000..2a8212602 --- /dev/null +++ b/plugins/inputs/radius/sample.conf @@ -0,0 +1,11 @@ +[[inputs.radius]] + ## An array of Server IPs and ports to gather from. If none specified, defaults to localhost. + servers = ["127.0.0.1:1812","hostname.domain.com:1812"] + + ## Credentials for radius authentication. + username = "myuser" + password = "mypassword" + secret = "mysecret" + + ## Maximum time to receive response. + # response_timeout = "5s" diff --git a/plugins/inputs/radius/testdata/raddb/clients.conf b/plugins/inputs/radius/testdata/raddb/clients.conf new file mode 100644 index 000000000..323eb9312 --- /dev/null +++ b/plugins/inputs/radius/testdata/raddb/clients.conf @@ -0,0 +1,4 @@ +client localtest { + ipaddr = 0.0.0.0/0 + secret = testsecret +} diff --git a/plugins/inputs/radius/testdata/raddb/mods-config/files/authorize b/plugins/inputs/radius/testdata/raddb/mods-config/files/authorize new file mode 100644 index 000000000..384abb308 --- /dev/null +++ b/plugins/inputs/radius/testdata/raddb/mods-config/files/authorize @@ -0,0 +1 @@ +testusername Cleartext-Password := "testpassword" diff --git a/plugins/inputs/radius/testdata/raddb/radiusd.conf b/plugins/inputs/radius/testdata/raddb/radiusd.conf new file mode 100644 index 000000000..481dfb0ac --- /dev/null +++ b/plugins/inputs/radius/testdata/raddb/radiusd.conf @@ -0,0 +1,118 @@ +prefix = /usr +exec_prefix = /usr +sysconfdir = /etc +localstatedir = /var +sbindir = ${exec_prefix}/sbin +logdir = /var/log/freeradius +raddbdir = /etc/freeradius +radacctdir = ${logdir}/radacct + +name = freeradius + +confdir = ${raddbdir} +modconfdir = ${confdir}/mods-config +certdir = ${confdir}/certs +cadir = ${confdir}/certs +run_dir = ${localstatedir}/run/${name} + +db_dir = ${raddbdir} + +libdir = /usr/lib/freeradius + +pidfile = ${run_dir}/${name}.pid + + +max_request_time = 30 + +cleanup_delay = 5 + +max_requests = 16384 + +hostname_lookups = no + + +log { + destination = stdout + + colourise = yes + + file = ${logdir}/radius.log + + syslog_facility = daemon + + stripped_names = no + + auth = yes + + + + auth_badpass = yes + auth_goodpass = yes + + + msg_denied = "You are already logged in - access denied" + +} + +checkrad = ${sbindir}/checkrad + +ENV { + + +} + +security { + + user = freerad + group = freerad + + allow_core_dumps = no + + max_attributes = 200 + + reject_delay = 1 + + status_server = yes + + +} + +proxy_requests = yes +$INCLUDE proxy.conf + + + +$INCLUDE clients.conf + + +thread pool { + start_servers = 5 + + max_servers = 32 + + min_spare_servers = 3 + max_spare_servers = 10 + + + max_requests_per_server = 0 + + + auto_limit_acct = no +} + + +modules { + + + $INCLUDE mods-enabled/ +} + +instantiate { + +} + +policy { + $INCLUDE policy.d/ +} + +$INCLUDE sites-enabled/ diff --git a/testutil/container.go b/testutil/container.go index b3791dda9..f125d4575 100644 --- a/testutil/container.go +++ b/testutil/container.go @@ -103,15 +103,16 @@ func (c *Container) LookupMappedPorts() error { port = strings.Split(port, ":")[1] } + p, err := c.container.MappedPort(c.ctx, nat.Port(port)) + if err != nil { + return fmt.Errorf("failed to find %q: %w", port, err) + } + // strip off the transport: 80/tcp -> 80 if strings.Contains(port, "/") { port = strings.Split(port, "/")[0] } - p, err := c.container.MappedPort(c.ctx, nat.Port(port)) - if err != nil { - return fmt.Errorf("failed to find %q: %w", port, err) - } fmt.Printf("mapped container port %q to host port %q\n", port, p.Port()) c.Ports[port] = p.Port() }