Repository: prometheus/snmp_exporter (go)

Issue:
Support RFC 3430
While it's not common, some devices do support [RFC 3430](https://tools.ietf.org/html/rfc3430), SNMP over TCP. The upstream gosnmp library supports this, so it should be an easy thing to add.

Pull request description:
Add support for parsing SNMP transport from target
This PR implements support for parsing the SNMP transport to use from the `target` query parameter.

* Accepted syntax for `target`: `[transport://]host[:port]`
* New dedicated function: configures a `gosnmp.GoSNMP` instance from a parsed `target` string.
* If `transport://` is not provided, `Transport` is left unconfigured and GoSNMP defaults to `udp`.
* Setting `Target` and `Port` in GoSNMP is left as it was in the original implementation.
* There is no transport validation during parsing, this is delegated to GoSNMP during connect.
* Added unit test and cases for the new dedicated parsing function.
* Added better documentation for the `target` parameter in README that includes transport support.

I tested connectivity as well. The snmp exporter can now connect via TCP to an SNMP server.

Closes #555 


Patch:
diff --git a/README.md b/README.md
index 9abd0e7..e15d0f2 100644
--- a/README.md
+++ b/README.md
@@ -72,12 +72,22 @@ Start `snmp_exporter` as a daemon or from CLI:
 ./snmp_exporter
 ```
 
-Visit [http://localhost:9116/snmp?target=192.0.0.8] where `192.0.0.8` is the IP or
-FQDN of the SNMP device to get metrics from. Note that this will use the default auth (`public_v2`) and
-default module (`if_mib`). The auth and module must be defined in the `snmp.yml`.
+Visit <http://localhost:9116/snmp?target=192.0.0.8> where `192.0.0.8` is the IP or
+FQDN of the SNMP device to get metrics from. Note that this will use the default transport (`udp`),
+default port (`161`), default auth (`public_v2`) and default module (`if_mib`). The auth and module
+must be defined in the `snmp.yml` file.
 
 For example, if you have an auth named `my_secure_v3` for walking `ddwrt`, the URL would look like
-[http://localhost:9116/snmp?auth=my_secure_v3&module=ddwrt&target=192.0.0.8].
+<http://localhost:9116/snmp?auth=my_secure_v3&module=ddwrt&target=192.0.0.8>.
+
+To configure a different transport and/or port, use the syntax `[transport://]host[:port]`.
+
+For example, to scrape a device using `tcp` on port `1161`, the URL would look like
+<http://localhost:9116/snmp?auth=my_secure_v3&module=ddwrt&target=tcp%3A%2F%2F192.0.0.8%3A1161>.
+
+Note that [URL encoding](https://en.wikipedia.org/wiki/URL_encoding) should be used for `target` due
+to the `:` and `/` characters. Prometheus encodes query parameters automatically and manual encoding
+is not necessary within the Prometheus configuration file.
 
 ## Configuration
 
@@ -85,7 +95,7 @@ The default configuration file name is `snmp.yml` and should not be edited
 by hand. If you need to change it, see
 [Generating configuration](#generating-configuration).
 
-The default `snmp.yml` covers a variety of common hardware walking them
+The default `snmp.yml` file covers a variety of common hardware walking them
 using SNMP v2 GETBULK.
 
 ## Prometheus Configuration
@@ -100,6 +110,7 @@ scrape_configs:
       - targets:
         - 192.168.1.2  # SNMP device.
         - switch.local # SNMP device.
+        - tcp://192.168.1.3:1161  # SNMP device using TCP transport and custom port.
     metrics_path: /snmp
     params:
       auth: [public_v2]
@@ -156,4 +167,4 @@ easier for others, please consider contributing back your configurations to
 us.
 `snmp.yml` config should be accompanied by generator config.
 For your dashboard, alerts, and recording rules, please consider
-contributing them to https://github.com/prometheus/snmp_exporter/tree/main/snmp-mixin
+contributing them to <https://github.com/prometheus/snmp_exporter/tree/main/snmp-mixin>.
diff --git a/collector/collector.go b/collector/collector.go
index 1879bf3..1b62288 100644
--- a/collector/collector.go
+++ b/collector/collector.go
@@ -117,15 +117,9 @@ func ScrapeTarget(ctx context.Context, target string, auth *config.Auth, module
 		results.retries++
 	}
 
-	snmp.Target = target
-	snmp.Port = 161
-	if host, port, err := net.SplitHostPort(target); err == nil {
-		snmp.Target = host
-		p, err := strconv.Atoi(port)
-		if err != nil {
-			return results, fmt.Errorf("error converting port number to int for target %s: %s", target, err)
-		}
-		snmp.Port = uint16(p)
+	// Configure target.
+	if err := configureTarget(&snmp, target); err != nil {
+		return results, err
 	}
 
 	// Configure auth.
@@ -242,6 +236,24 @@ func ScrapeTarget(ctx context.Context, target string, auth *config.Auth, module
 	return results, nil
 }
 
+func configureTarget(g *gosnmp.GoSNMP, target string) error {
+	if s := strings.SplitN(target, "://", 2); len(s) == 2 {
+		g.Transport = s[0]
+		target = s[1]
+	}
+	g.Target = target
+	g.Port = 161
+	if host, port, err := net.SplitHostPort(target); err == nil {
+		g.Target = host
+		p, err := strconv.Atoi(port)
+		if err != nil {
+			return fmt.Errorf("error converting port number to int for target %q: %w", target, err)
+		}
+		g.Port = uint16(p)
+	}
+	return nil
+}
+
 func filterAllowedIndices(logger log.Logger, filter config.DynamicFilter, pdus []gosnmp.SnmpPDU, allowedList []string, metrics internalMetrics) []string {
 	level.Debug(logger).Log("msg", "Evaluating rule for oid", "oid", filter.Oid)
 	for _, pdu := range pdus {


Test patch:
diff --git a/collector/collector_test.go b/collector/collector_test.go
index a1a009d..818a870 100644
--- a/collector/collector_test.go
+++ b/collector/collector_test.go
@@ -1033,6 +1033,173 @@ func TestIndexesToLabels(t *testing.T) {
 	}
 }
 
+func TestConfigureTarget(t *testing.T) {
+	cases := []struct {
+		target     string
+		gTransport string
+		gTarget    string
+		gPort      uint16
+		shouldErr  bool
+	}{
+		{
+			target:     "localhost",
+			gTransport: "",
+			gTarget:    "localhost",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "localhost:1161",
+			gTransport: "",
+			gTarget:    "localhost",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{
+			target:     "udp://localhost",
+			gTransport: "udp",
+			gTarget:    "localhost",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "udp://localhost:1161",
+			gTransport: "udp",
+			gTarget:    "localhost",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{
+			target:     "tcp://localhost",
+			gTransport: "tcp",
+			gTarget:    "localhost",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "tcp://localhost:1161",
+			gTransport: "tcp",
+			gTarget:    "localhost",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{
+			target:     "[::1]",
+			gTransport: "",
+			gTarget:    "[::1]",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "[::1]:1161",
+			gTransport: "",
+			gTarget:    "::1",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{
+			target:     "udp://[::1]",
+			gTransport: "udp",
+			gTarget:    "[::1]",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "udp://[::1]:1161",
+			gTransport: "udp",
+			gTarget:    "::1",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{
+			target:     "tcp://[::1]",
+			gTransport: "tcp",
+			gTarget:    "[::1]",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "tcp://[::1]:1161",
+			gTransport: "tcp",
+			gTarget:    "::1",
+			gPort:      1161,
+			shouldErr:  false,
+		},
+		{ // this case is valid during parse but invalid during connect
+			target:     "tcp://udp://localhost:1161",
+			gTransport: "tcp",
+			gTarget:    "udp://localhost:1161",
+			gPort:      161,
+			shouldErr:  false,
+		},
+		{
+			target:     "localhost:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+		{
+			target:     "udp://localhost:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+		{
+			target:     "tcp://localhost:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+		{
+			target:     "[::1]:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+		{
+			target:     "udp://[::1]:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+		{
+			target:     "tcp://[::1]:badport",
+			gTransport: "",
+			gTarget:    "",
+			gPort:      0,
+			shouldErr:  true,
+		},
+	}
+
+	for _, c := range cases {
+		var g gosnmp.GoSNMP
+		err := configureTarget(&g, c.target)
+		if c.shouldErr {
+			if err == nil {
+				t.Fatalf("Was expecting error, but none returned for %q", c.target)
+			}
+			continue
+		}
+		if err != nil {
+			t.Fatalf("Error configuring target %q: %v", c.target, err)
+		}
+		if g.Transport != c.gTransport {
+			t.Fatalf("Bad SNMP transport for %q, got=%q, expected=%q", c.target, g.Transport, c.gTransport)
+		}
+		if g.Target != c.gTarget {
+			t.Fatalf("Bad SNMP target for %q, got=%q, expected=%q", c.target, g.Target, c.gTarget)
+		}
+		if g.Port != c.gPort {
+			t.Fatalf("Bad SNMP port for %q, got=%d, expected=%d", c.target, g.Port, c.gPort)
+		}
+	}
+}
+
 func TestFilterAllowedIndices(t *testing.T) {
 
 	pdus := []gosnmp.SnmpPDU{