Add PAN-OS go.d collector (#22389)
Co-authored-by: ilyam8 <ilya@netdata.cloud>
Costa Tsaousis committed
May 27, 2026 at 13:28 UTC
59af74d164ca4cad3d24df7ebfaee395b97025ca
38 files changed
+7265
src/collectors/COLLECTORS.md
+1
@@ -291,6 +291,7 @@ Need a dedicated integration? [Submit a feature request](https://github.com/netd
291
| [OpenVPN](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/openvpn/integrations/openvpn.md) | This collector monitors OpenVPN servers. |
292
| [OpenVPN status log](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/openvpn_status_log/integrations/openvpn_status_log.md) | This collector monitors OpenVPN server. |
293
| [Optical modules](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/ethtool/integrations/optical_modules.md) | This collector monitors optical transceiver modules' diagnostic parameters (temperature, voltage, laser bias current, transmit/receive power levels) from network interfaces equipped with modules that support Digital Diagnostic Monitoring (DDM). |
294
+| [Palo Alto Networks PAN-OS](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/panos/integrations/palo_alto_networks_pan-os.md) | This collector monitors Palo Alto Networks PAN-OS firewalls. |
295
| [Pi-hole](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/pihole/integrations/pi-hole.md) | This collector monitors Pi-hole instances using [Pi-hole API 6.0](https://ftl.pi-hole.net/master/docs/). |
296
| [PowerDNS Authoritative Server](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/powerdns/integrations/powerdns_authoritative_server.md) | This collector monitors PowerDNS Authoritative Server instances. |
297
| [PowerDNS Recursor](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/powerdns_recursor/integrations/powerdns_recursor.md) | This collector monitors PowerDNS Recursor instances. |
src/go/go.mod
+1
@@ -9,6 +9,7 @@ replace github.com/gosnmp/gosnmp => github.com/ilyam8/gosnmp v0.0.0-202509122027
9
require (
10
github.com/DATA-DOG/go-sqlmock v1.5.2
11
github.com/Masterminds/sprig/v3 v3.3.0
12
+ github.com/PaloAltoNetworks/pango v0.10.2
13
github.com/Wing924/ltsv v0.4.0
14
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
15
github.com/axiomhq/hyperloglog v0.2.6
src/go/go.sum
+2
@@ -42,6 +42,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
42
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
43
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
44
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
45
+github.com/PaloAltoNetworks/pango v0.10.2 h1:Tjn6vIzzAq6Dd7N0mDuiP8w8pz8k5W9zz/TTSUQCsQY=
46
+github.com/PaloAltoNetworks/pango v0.10.2/go.mod h1:GztcRnVLur7G+VFG7Z5ZKNFgScLtsycwPMp1qVebE5g=
47
github.com/Wing924/ltsv v0.4.0 h1:MMTcnzNVS26KLermAJDebO7/n5Qt42UpvC6vVcLjmJQ=
48
github.com/Wing924/ltsv v0.4.0/go.mod h1:BgO75Q6CyIcRVvHN2acl63ZzoH/Tkj7R3rjYO9/hleQ=
49
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
src/go/plugin/go.d/README.md
+1
@@ -102,6 +102,7 @@ All capabilities are set automatically during Netdata installation using the [of
102
| [openldap](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/openldap) | OpenLDAP |
103
| [openvpn](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/openvpn) | OpenVPN |
104
| [openvpn_status_log](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/openvpn_status_log) | OpenVPN |
105
+| [panos](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/panos) | Palo Alto Networks PAN-OS |
106
| [pgbouncer](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/pgbouncer) | PgBouncer |
107
| [oracledb](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/oracledb) | Oracle DB |
108
| [phpdaemon](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/phpdaemon) | phpDaemon |
src/go/plugin/go.d/collector/init.go
+1
@@ -80,6 +80,7 @@ import (
80
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/openvpn"
81
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/openvpn_status_log"
82
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/oracledb"
83
+ _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/panos"
84
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/pgbouncer"
85
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/phpdaemon"
86
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/phpfpm"
src/go/plugin/go.d/collector/panos/README.md
new
+1
@@ -0,0 +1 @@
1
+integrations/palo_alto_networks_pan-os.md
\ No newline at end of file
src/go/plugin/go.d/collector/panos/apiclient.go
new
+287
@@ -0,0 +1,287 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "maps"
10
+ "math"
11
+ "net/http"
12
+ "net/url"
13
+ "regexp"
14
+ "strconv"
15
+ "strings"
16
+
17
+ "github.com/PaloAltoNetworks/pango"
18
+
19
+ "github.com/netdata/netdata/go/plugins/pkg/web"
20
+)
21
+
22
+type panosAPIClient interface {
23
+ op(ctx context.Context, cmd string) ([]byte, error)
24
+ systemInfo() map[string]string
25
+ closeIdleConnections()
26
+}
27
+
28
+type pangoOperator interface {
29
+ Initialize() error
30
+ Op(req any, vsys string, extras, ans any) ([]byte, error)
31
+ RetrieveApiKey() error
32
+ SystemInfo() map[string]string
33
+}
34
+
35
+type pangoAPIClient struct {
36
+ client pangoOperator
37
+ transport *http.Transport
38
+
39
+ vsys string
40
+ canRefresh bool
41
+ initialized bool
42
+}
43
+
44
+func newPangoAPIClient(cfg Config) (panosAPIClient, error) {
45
+ apiURL, err := parseAPIURL(cfg.URL)
46
+ if err != nil {
47
+ return nil, err
48
+ }
49
+
50
+ transport, err := newPangoTransport(cfg.ClientConfig)
51
+ if err != nil {
52
+ return nil, err
53
+ }
54
+
55
+ fw := &pango.Firewall{
56
+ Client: pango.Client{
57
+ Hostname: apiURL.hostname,
58
+ Protocol: apiURL.protocol,
59
+ Port: apiURL.port,
60
+ Timeout: timeoutSeconds(cfg.ClientConfig),
61
+ Username: cfg.Username,
62
+ Password: cfg.Password,
63
+ ApiKey: cfg.APIKey,
64
+ Headers: cfg.Headers,
65
+ VerifyCertificate: !cfg.TLSConfig.InsecureSkipVerify,
66
+ Transport: transport,
67
+ Logging: pango.LogQuiet,
68
+ },
69
+ }
70
+
71
+ return &pangoAPIClient{
72
+ client: &pangoFirewallOperator{fw: fw},
73
+ transport: transport,
74
+ vsys: cfg.Vsys,
75
+ canRefresh: cfg.Username != "" &&
76
+ cfg.Password != "",
77
+ }, nil
78
+}
79
+
80
+func (c *pangoAPIClient) op(ctx context.Context, cmd string) ([]byte, error) {
81
+ if err := contextError(ctx); err != nil {
82
+ return nil, err
83
+ }
84
+ if err := c.ensureInitialized(ctx); err != nil {
85
+ return nil, err
86
+ }
87
+
88
+ if err := contextError(ctx); err != nil {
89
+ return nil, err
90
+ }
91
+ body, err := c.client.Op(cmd, c.vsys, nil, nil)
92
+ if err == nil || !c.canRefresh || !isUnauthorizedError(err) {
93
+ return body, sanitizePANOSAPIError(err)
94
+ }
95
+
96
+ if err := contextError(ctx); err != nil {
97
+ return nil, err
98
+ }
99
+ if refreshErr := c.client.RetrieveApiKey(); refreshErr != nil {
100
+ c.initialized = false
101
+ return nil, fmt.Errorf("refresh PAN-OS API key after unauthorized response: %w", sanitizePANOSAPIError(refreshErr))
102
+ }
103
+
104
+ if err := contextError(ctx); err != nil {
105
+ return nil, err
106
+ }
107
+ body, err = c.client.Op(cmd, c.vsys, nil, nil)
108
+ return body, sanitizePANOSAPIError(err)
109
+}
110
+
111
+func (c *pangoAPIClient) ensureInitialized(ctx context.Context) error {
112
+ if c.initialized {
113
+ return nil
114
+ }
115
+
116
+ if err := contextError(ctx); err != nil {
117
+ return err
118
+ }
119
+ err := c.client.Initialize()
120
+ if err == nil {
121
+ c.initialized = true
122
+ return nil
123
+ }
124
+ if !c.canRefresh || !isUnauthorizedError(err) {
125
+ return sanitizePANOSAPIError(err)
126
+ }
127
+
128
+ if err := contextError(ctx); err != nil {
129
+ return err
130
+ }
131
+ if refreshErr := c.client.RetrieveApiKey(); refreshErr != nil {
132
+ return fmt.Errorf("refresh PAN-OS API key after unauthorized initialization: %w", sanitizePANOSAPIError(refreshErr))
133
+ }
134
+
135
+ if err := contextError(ctx); err != nil {
136
+ return err
137
+ }
138
+ if err := c.client.Initialize(); err != nil {
139
+ return fmt.Errorf("re-initialize PAN-OS API client after key refresh: %w", sanitizePANOSAPIError(err))
140
+ }
141
+
142
+ c.initialized = true
143
+ return nil
144
+}
145
+
146
+func (c *pangoAPIClient) systemInfo() map[string]string {
147
+ info := c.client.SystemInfo()
148
+ if len(info) == 0 {
149
+ return nil
150
+ }
151
+ cp := make(map[string]string, len(info))
152
+ maps.Copy(cp, info)
153
+ return cp
154
+}
155
+
156
+func (c *pangoAPIClient) closeIdleConnections() {
157
+ if c.transport != nil {
158
+ c.transport.CloseIdleConnections()
159
+ }
160
+}
161
+
162
+type pangoFirewallOperator struct {
163
+ fw *pango.Firewall
164
+}
165
+
166
+func (p *pangoFirewallOperator) Initialize() error {
167
+ return p.fw.Initialize()
168
+}
169
+
170
+func (p *pangoFirewallOperator) Op(req any, vsys string, extras, ans any) ([]byte, error) {
171
+ return p.fw.Op(req, vsys, extras, ans)
172
+}
173
+
174
+func (p *pangoFirewallOperator) RetrieveApiKey() error {
175
+ return p.fw.RetrieveApiKey()
176
+}
177
+
178
+func (p *pangoFirewallOperator) SystemInfo() map[string]string {
179
+ return p.fw.SystemInfo
180
+}
181
+
182
+func isUnauthorizedError(err error) bool {
183
+ if err == nil {
184
+ return false
185
+ }
186
+ msg := strings.ToLower(err.Error())
187
+ return strings.Contains(msg, "unauthorized") ||
188
+ unauthorizedCodeRE.MatchString(msg) ||
189
+ strings.Contains(msg, "forbidden") ||
190
+ strings.Contains(msg, "session timed out")
191
+}
192
+
193
+type panosAPIURL struct {
194
+ protocol string
195
+ hostname string
196
+ port uint
197
+}
198
+
199
+func parseAPIURL(rawURL string) (panosAPIURL, error) {
200
+ u, err := url.Parse(rawURL)
201
+ if err != nil {
202
+ return panosAPIURL{}, fmt.Errorf("parse url: %w", err)
203
+ }
204
+ if u.Scheme != "http" && u.Scheme != "https" {
205
+ return panosAPIURL{}, fmt.Errorf("config: url scheme must be http or https")
206
+ }
207
+ if u.User != nil {
208
+ return panosAPIURL{}, errors.New("config: url must not include embedded credentials")
209
+ }
210
+ if u.Hostname() == "" {
211
+ return panosAPIURL{}, errors.New("config: url hostname not configured")
212
+ }
213
+ if u.Path != "" && u.Path != "/" && u.Path != "/api" {
214
+ return panosAPIURL{}, fmt.Errorf("config: url path must be empty, /, or /api")
215
+ }
216
+ if u.RawQuery != "" || u.Fragment != "" {
217
+ return panosAPIURL{}, errors.New("config: url must not include query or fragment")
218
+ }
219
+
220
+ var port uint
221
+ if rawPort := u.Port(); rawPort != "" {
222
+ v, err := strconv.ParseUint(rawPort, 10, 16)
223
+ if err != nil {
224
+ return panosAPIURL{}, fmt.Errorf("parse url port: %w", err)
225
+ }
226
+ if v == 0 {
227
+ return panosAPIURL{}, errors.New("config: url port must be greater than 0")
228
+ }
229
+ port = uint(v)
230
+ } else if hasExplicitPort(u.Host) {
231
+ return panosAPIURL{}, errors.New("config: url port must be numeric")
232
+ }
233
+
234
+ hostname := u.Hostname()
235
+ if strings.Contains(hostname, ":") {
236
+ hostname = "[" + hostname + "]"
237
+ }
238
+
239
+ return panosAPIURL{
240
+ protocol: u.Scheme,
241
+ hostname: hostname,
242
+ port: port,
243
+ }, nil
244
+}
245
+
246
+func hasExplicitPort(host string) bool {
247
+ if strings.HasPrefix(host, "[") {
248
+ return strings.Contains(host, "]:")
249
+ }
250
+ return strings.Count(host, ":") == 1
251
+}
252
+
253
+func newPangoTransport(cfg web.ClientConfig) (*http.Transport, error) {
254
+ client, err := web.NewHTTPClient(cfg)
255
+ if err != nil {
256
+ return nil, err
257
+ }
258
+
259
+ transport, ok := client.Transport.(*http.Transport)
260
+ if !ok {
261
+ return nil, errors.New("PAN-OS SDK requires an HTTP/1.x transport")
262
+ }
263
+ transport.MaxConnsPerHost = 2
264
+ transport.MaxIdleConnsPerHost = 2
265
+ return transport, nil
266
+}
267
+
268
+func timeoutSeconds(cfg web.ClientConfig) int {
269
+ d := cfg.Timeout.Duration()
270
+ if d <= 0 {
271
+ return 10
272
+ }
273
+ return max(1, int(math.Ceil(d.Seconds())))
274
+}
275
+
276
+var (
277
+ unauthorizedCodeRE = regexp.MustCompile(`(?i)\bcode:?\s*(?:16|22|403)\b`)
278
+ secretParamRE = regexp.MustCompile(`(?i)\b((?:api_key|apikey|password|pass|username|user|key)=)[^&\s]+`)
279
+)
280
+
281
+func sanitizePANOSAPIError(err error) error {
282
+ if err == nil {
283
+ return nil
284
+ }
285
+ msg := secretParamRE.ReplaceAllString(err.Error(), "${1}<redacted>")
286
+ return errors.New(msg)
287
+}
src/go/plugin/go.d/collector/panos/bgp.go
new
+816
@@ -0,0 +1,816 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "encoding/xml"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "net"
12
+ "net/netip"
13
+ "regexp"
14
+ "strconv"
15
+ "strings"
16
+ "time"
17
+)
18
+
19
+const legacyBGPPeerCommand = "<show><routing><protocol><bgp><peer></peer></bgp></protocol></routing></show>"
20
+
21
+const (
22
+ noBGPReprobeInterval = 5 * time.Minute
23
+ maxBGPPeerEntryDepth = 8
24
+
25
+ logKeyBGPEmptyQuery = "panos:bgp:empty_query"
26
+ logKeyBGPQueryFailed = "panos:bgp:query_failed"
27
+ logKeyBGPProbeErrors = "panos:bgp:probe_errors"
28
+ logKeyBGPNoPeers = "panos:bgp:no_peers"
29
+ logKeyBGPLegacy = "panos:bgp:legacy"
30
+ logKeyBGPAdvanced = "panos:bgp:advanced"
31
+)
32
+
33
+var advancedBGPPeerCommands = []string{
34
+ "<show><advanced-routing><bgp><peer><details></details></peer></bgp></advanced-routing></show>",
35
+ "<show><advanced-routing><bgp><peer><status></status></peer></bgp></advanced-routing></show>",
36
+ "<show><advanced-routing><bgp><peer></peer></bgp></advanced-routing></show>",
37
+}
38
+
39
+type routingEngine string
40
+
41
+const (
42
+ routingEngineUnknown routingEngine = ""
43
+ routingEngineLegacy routingEngine = "legacy"
44
+ routingEngineAdvanced routingEngine = "advanced"
45
+ routingEngineNone routingEngine = "none"
46
+)
47
+
48
+type bgpPeer struct {
49
+ VR string
50
+ PeerAddress string
51
+ LocalAddress string
52
+ RemoteAS string
53
+ PeerGroup string
54
+ State string
55
+ Uptime int64
56
+ MessagesIn int64
57
+ MessagesOut int64
58
+ UpdatesIn int64
59
+ UpdatesOut int64
60
+ Flaps int64
61
+ Established int64
62
+ PrefixCounters []bgpPrefixCounter
63
+}
64
+
65
+type bgpPrefixCounter struct {
66
+ AFI string
67
+ SAFI string
68
+ IncomingTotal int64
69
+ IncomingAccepted int64
70
+ IncomingRejected int64
71
+ OutgoingAdvertised int64
72
+}
73
+
74
+type panosResponseMessage struct {
75
+ Text string `xml:",chardata"`
76
+ Lines []string `xml:"line"`
77
+}
78
+
79
+type panosBGPPeerEntry struct {
80
+ PeerAttr string `xml:"peer,attr"`
81
+ NameAttr string `xml:"name,attr"`
82
+ PeerNameAttr string `xml:"peer-name,attr"`
83
+ PeerAddressAttr string `xml:"peer-address,attr"`
84
+ VRAttr string `xml:"vr,attr"`
85
+ VirtualRouterAttr string `xml:"virtual-router,attr"`
86
+ LogicalRouterAttr string `xml:"logical-router,attr"`
87
+ PeerGroupAttr string `xml:"peer-group,attr"`
88
+ RemoteASAttr string `xml:"remote-as,attr"`
89
+
90
+ Peer string `xml:"peer"`
91
+ PeerName string `xml:"peer-name"`
92
+ PeerAddress string `xml:"peer-address"`
93
+ VR string `xml:"vr"`
94
+ VirtualRouter string `xml:"virtual-router"`
95
+ LogicalRouter string `xml:"logical-router"`
96
+ PeerGroup string `xml:"peer-group"`
97
+ LocalAddress string `xml:"local-address"`
98
+ RemoteAS string `xml:"remote-as"`
99
+ PeerAS string `xml:"peer-as"`
100
+ PeerASNumber string `xml:"peer-as-number"`
101
+ Status string `xml:"status"`
102
+ State string `xml:"state"`
103
+ BGPState string `xml:"bgp-state"`
104
+ PeerState string `xml:"peer-state"`
105
+ SessionState string `xml:"sess-state"`
106
+ StatusDuration string `xml:"status-duration"`
107
+ Uptime string `xml:"uptime"`
108
+ UptimeSeconds string `xml:"uptime-seconds"`
109
+ MsgTotalIn string `xml:"msg-total-in"`
110
+ MsgTotalOut string `xml:"msg-total-out"`
111
+ MsgUpdateIn string `xml:"msg-update-in"`
112
+ MsgUpdateOut string `xml:"msg-update-out"`
113
+ StatusFlapCounts string `xml:"status-flap-counts"`
114
+ FlapCount string `xml:"flap-count"`
115
+ EstablishedCount string `xml:"established-counts"`
116
+
117
+ PrefixCounter struct {
118
+ Entries []panosBGPPrefixEntry `xml:"entry"`
119
+ } `xml:"prefix-counter"`
120
+ Entries []panosBGPPeerEntry `xml:"entry"`
121
+}
122
+
123
+type panosBGPPrefixEntry struct {
124
+ AFISAFIAttr string `xml:"afi-safi,attr"`
125
+ NameAttr string `xml:"name,attr"`
126
+ AFIAttr string `xml:"afi,attr"`
127
+ SAFIAttr string `xml:"safi,attr"`
128
+
129
+ AFI string `xml:"afi"`
130
+ SAFI string `xml:"safi"`
131
+ IncomingTotal string `xml:"incoming-total"`
132
+ IncomingAccepted string `xml:"incoming-accepted"`
133
+ IncomingRejected string `xml:"incoming-rejected"`
134
+ OutgoingAdvertised string `xml:"outgoing-advertised"`
135
+}
136
+
137
+func (c *Collector) collectBGPPeers(ctx context.Context) ([]bgpPeer, error) {
138
+ if c.apiClient == nil {
139
+ return nil, errors.New("PAN-OS API client not initialized")
140
+ }
141
+ defer c.logSystemInfo()
142
+ if err := contextError(ctx); err != nil {
143
+ return nil, err
144
+ }
145
+
146
+ if c.routingEngine == routingEngineNone && c.now().Sub(c.noBGPProbedAt) < noBGPReprobeInterval {
147
+ c.Debugf("PAN-OS BGP peers not found on previous probe; skipping routing-engine probe until %s", c.noBGPProbedAt.Add(noBGPReprobeInterval).Format(time.RFC3339))
148
+ return nil, nil
149
+ }
150
+
151
+ if c.bgpCommand != "" {
152
+ peers, err := c.queryBGPPeers(ctx, c.bgpCommand)
153
+ if len(peers) > 0 {
154
+ return peers, err
155
+ }
156
+ if err == nil {
157
+ staleCommand := c.bgpCommand
158
+ c.Limit(logKeyBGPEmptyQuery, 1, recurringLogEvery).
159
+ Warningf("PAN-OS BGP query returned no peers (routing_engine=%s, command=%s), probing routing engine again", c.routingEngine, bgpCommandName(staleCommand))
160
+ c.routingEngine = routingEngineUnknown
161
+ c.bgpCommand = ""
162
+ return c.probeAndCollectBGPPeersExcept(ctx, staleCommand, true)
163
+ }
164
+ c.Limit(logKeyBGPQueryFailed, 1, recurringLogEvery).
165
+ Warningf("PAN-OS BGP query failed (routing_engine=%s, command=%s), probing routing engine again: %v", c.routingEngine, bgpCommandName(c.bgpCommand), err)
166
+ c.routingEngine = routingEngineUnknown
167
+ c.bgpCommand = ""
168
+ }
169
+
170
+ return c.probeAndCollectBGPPeers(ctx)
171
+}
172
+
173
+func (c *Collector) probeAndCollectBGPPeers(ctx context.Context) ([]bgpPeer, error) {
174
+ return c.probeAndCollectBGPPeersExcept(ctx, "", false)
175
+}
176
+
177
+func (c *Collector) probeAndCollectBGPPeersExcept(ctx context.Context, skipCommand string, emptySuccess bool) ([]bgpPeer, error) {
178
+ var errs []error
179
+
180
+ if legacyBGPPeerCommand != skipCommand {
181
+ if err := contextError(ctx); err != nil {
182
+ return nil, err
183
+ }
184
+ peers, err := c.queryBGPPeers(ctx, legacyBGPPeerCommand)
185
+ if len(peers) > 0 {
186
+ c.routingEngine = routingEngineLegacy
187
+ c.bgpCommand = legacyBGPPeerCommand
188
+ c.Limit(logKeyBGPLegacy, 1, recurringLogEvery).
189
+ Infof("detected PAN-OS legacy routing engine for BGP collection (command=%s)", bgpCommandName(c.bgpCommand))
190
+ return peers, err
191
+ }
192
+ if err == nil {
193
+ emptySuccess = true
194
+ } else {
195
+ errs = append(errs, fmt.Errorf("%s: %w", bgpCommandName(legacyBGPPeerCommand), err))
196
+ }
197
+ }
198
+
199
+ for _, cmd := range c.advancedBGPCommands {
200
+ if cmd == skipCommand {
201
+ continue
202
+ }
203
+ if err := contextError(ctx); err != nil {
204
+ return nil, err
205
+ }
206
+ peers, err := c.queryBGPPeers(ctx, cmd)
207
+ if len(peers) > 0 {
208
+ c.routingEngine = routingEngineAdvanced
209
+ c.bgpCommand = cmd
210
+ c.Limit(logKeyBGPAdvanced, 1, recurringLogEvery).
211
+ Infof("detected PAN-OS Advanced Routing Engine for BGP collection (command=%s)", bgpCommandName(c.bgpCommand))
212
+ return peers, err
213
+ }
214
+ if err == nil {
215
+ emptySuccess = true
216
+ } else {
217
+ errs = append(errs, fmt.Errorf("%s: %w", bgpCommandName(cmd), err))
218
+ }
219
+ }
220
+
221
+ if emptySuccess {
222
+ if len(errs) > 0 {
223
+ err := errors.Join(errs...)
224
+ c.Limit(logKeyBGPProbeErrors, 1, recurringLogEvery).
225
+ Warningf("PAN-OS BGP probes returned no peers, but at least one probe failed; will retry all BGP probes next cycle: %v", err)
226
+ return nil, err
227
+ }
228
+ c.routingEngine = routingEngineNone
229
+ c.noBGPProbedAt = c.now()
230
+ c.Limit(logKeyBGPNoPeers, 1, recurringLogEvery).
231
+ Info("connected to PAN-OS XML API, but no BGP peers were found by legacy or Advanced Routing Engine probes")
232
+ return nil, nil
233
+ }
234
+ if len(errs) > 0 {
235
+ return nil, errors.Join(errs...)
236
+ }
237
+ return nil, nil
238
+}
239
+
240
+func (c *Collector) queryBGPPeers(ctx context.Context, cmd string) ([]bgpPeer, error) {
241
+ body, err := c.apiClient.op(ctx, cmd)
242
+ if err != nil {
243
+ return nil, fmt.Errorf("%s API call: %w", bgpCommandName(cmd), err)
244
+ }
245
+ peers, err := parseBGPPeers(body)
246
+ if err != nil {
247
+ return peers, fmt.Errorf("%s response: %w", bgpCommandName(cmd), err)
248
+ }
249
+ return peers, nil
250
+}
251
+
252
+func parseBGPPeers(body []byte) ([]bgpPeer, error) {
253
+ innerXML, err := decodePANOSResultInner(body, "PAN-OS BGP response")
254
+ if err != nil {
255
+ return nil, err
256
+ }
257
+ if strings.TrimSpace(innerXML) == "" {
258
+ return nil, nil
259
+ }
260
+
261
+ entries, err := decodeBGPPeerEntries(innerXML)
262
+ if err != nil {
263
+ return nil, err
264
+ }
265
+
266
+ var peers []bgpPeer
267
+ var errs []error
268
+ seen := make(map[string]bool)
269
+ for _, entry := range entries {
270
+ peer, ok, err := entry.toBGPPeer()
271
+ if err != nil {
272
+ errs = append(errs, fmt.Errorf("BGP peer entry %s: %w", bgpPeerEntryName(entry), err))
273
+ }
274
+ if !ok {
275
+ continue
276
+ }
277
+ key := peer.VR + "\x00" + peer.PeerAddress
278
+ if seen[key] {
279
+ continue
280
+ }
281
+ seen[key] = true
282
+ peers = append(peers, peer)
283
+ }
284
+
285
+ return peers, errors.Join(errs...)
286
+}
287
+
288
+func bgpPeerEntryName(entry panosBGPPeerEntry) string {
289
+ return firstNonEmpty(normalizeAddress(entry.peerAddress()), normalizeAddress(entry.peerName()), "unknown")
290
+}
291
+
292
+func decodeBGPPeerEntries(innerXML string) ([]panosBGPPeerEntry, error) {
293
+ decoder := xml.NewDecoder(strings.NewReader(innerXML))
294
+
295
+ var entries []panosBGPPeerEntry
296
+ for {
297
+ tok, err := decoder.Token()
298
+ if err != nil {
299
+ if errors.Is(err, io.EOF) {
300
+ break
301
+ }
302
+ return nil, fmt.Errorf("parse PAN-OS BGP result: %w", err)
303
+ }
304
+
305
+ start, ok := tok.(xml.StartElement)
306
+ if !ok || start.Name.Local != "entry" {
307
+ continue
308
+ }
309
+
310
+ var entry panosBGPPeerEntry
311
+ if err := decoder.DecodeElement(&entry, &start); err != nil {
312
+ return nil, fmt.Errorf("parse PAN-OS BGP peer entry: %w", err)
313
+ }
314
+ entries = appendFlattenedBGPPeerEntries(entries, entry, inheritedBGPPeerFields{}, 0)
315
+ }
316
+
317
+ return entries, nil
318
+}
319
+
320
+type inheritedBGPPeerFields struct {
321
+ vr string
322
+ peerGroup string
323
+ localAddr string
324
+ remoteAS string
325
+ peerAddress string
326
+}
327
+
328
+func appendFlattenedBGPPeerEntries(entries []panosBGPPeerEntry, entry panosBGPPeerEntry, parent inheritedBGPPeerFields, depth int) []panosBGPPeerEntry {
329
+ if depth > maxBGPPeerEntryDepth {
330
+ return entries
331
+ }
332
+ entry.inherit(parent)
333
+ entries = append(entries, entry)
334
+
335
+ vr := entry.vr()
336
+ if vr == "" && len(entry.Entries) > 0 {
337
+ vr = entry.NameAttr
338
+ }
339
+ next := inheritedBGPPeerFields{
340
+ vr: vr,
341
+ peerGroup: entry.peerGroup(),
342
+ localAddr: entry.localAddress(),
343
+ remoteAS: entry.remoteAS(),
344
+ peerAddress: entry.peerAddress(),
345
+ }
346
+ for _, child := range entry.Entries {
347
+ entries = appendFlattenedBGPPeerEntries(entries, child, next, depth+1)
348
+ }
349
+ return entries
350
+}
351
+
352
+func (e *panosBGPPeerEntry) inherit(parent inheritedBGPPeerFields) {
353
+ if e.vr() == "" {
354
+ e.VR = parent.vr
355
+ }
356
+ if e.peerGroup() == "" {
357
+ e.PeerGroup = parent.peerGroup
358
+ }
359
+ if e.localAddress() == "" {
360
+ e.LocalAddress = parent.localAddr
361
+ }
362
+ if e.remoteAS() == "" {
363
+ e.RemoteAS = parent.remoteAS
364
+ }
365
+ if e.peerAddress() == "" {
366
+ e.PeerAddress = parent.peerAddress
367
+ }
368
+}
369
+
370
+func (e panosBGPPeerEntry) toBGPPeer() (bgpPeer, bool, error) {
371
+ peerAddr := normalizeAddress(e.peerAddress())
372
+ if peerAddr == "" && e.hasPeerData() {
373
+ peerAddr = normalizeAddress(e.peerName())
374
+ }
375
+ if peerAddr == "" {
376
+ return bgpPeer{}, false, nil
377
+ }
378
+
379
+ uptime, err := parseRequiredPANOSDurationField("BGP peer "+peerAddr+" uptime", firstNonEmpty(e.StatusDuration, e.UptimeSeconds, e.Uptime))
380
+ if err != nil {
381
+ return bgpPeer{}, false, err
382
+ }
383
+ messagesIn, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" msg-total-in", e.MsgTotalIn)
384
+ if err != nil {
385
+ return bgpPeer{}, false, err
386
+ }
387
+ messagesOut, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" msg-total-out", e.MsgTotalOut)
388
+ if err != nil {
389
+ return bgpPeer{}, false, err
390
+ }
391
+ updatesIn, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" msg-update-in", e.MsgUpdateIn)
392
+ if err != nil {
393
+ return bgpPeer{}, false, err
394
+ }
395
+ updatesOut, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" msg-update-out", e.MsgUpdateOut)
396
+ if err != nil {
397
+ return bgpPeer{}, false, err
398
+ }
399
+ flaps, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" flap-count", firstNonEmpty(e.StatusFlapCounts, e.FlapCount))
400
+ if err != nil {
401
+ return bgpPeer{}, false, err
402
+ }
403
+ established, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" established-counts", e.EstablishedCount)
404
+ if err != nil {
405
+ return bgpPeer{}, false, err
406
+ }
407
+ prefixCounters, err := e.prefixCounters(peerAddr)
408
+ peer := bgpPeer{
409
+ VR: firstNonEmpty(e.vr(), "default"),
410
+ PeerAddress: peerAddr,
411
+ LocalAddress: normalizeAddress(e.localAddress()),
412
+ RemoteAS: e.remoteAS(),
413
+ PeerGroup: e.peerGroup(),
414
+ State: normalizeBGPState(firstNonEmpty(e.Status, e.State, e.BGPState, e.PeerState, e.SessionState)),
415
+ Uptime: uptime,
416
+ MessagesIn: messagesIn,
417
+ MessagesOut: messagesOut,
418
+ UpdatesIn: updatesIn,
419
+ UpdatesOut: updatesOut,
420
+ Flaps: flaps,
421
+ Established: established,
422
+ PrefixCounters: prefixCounters,
423
+ }
424
+
425
+ return peer, true, err
426
+}
427
+
428
+func (e panosBGPPeerEntry) hasPeerData() bool {
429
+ return firstNonEmpty(
430
+ e.Status,
431
+ e.State,
432
+ e.BGPState,
433
+ e.PeerState,
434
+ e.SessionState,
435
+ e.StatusDuration,
436
+ e.Uptime,
437
+ e.UptimeSeconds,
438
+ e.MsgTotalIn,
439
+ e.MsgTotalOut,
440
+ e.MsgUpdateIn,
441
+ e.MsgUpdateOut,
442
+ e.StatusFlapCounts,
443
+ e.FlapCount,
444
+ e.EstablishedCount,
445
+ ) != "" || len(e.PrefixCounter.Entries) > 0
446
+}
447
+
448
+func (e panosBGPPeerEntry) vr() string {
449
+ return firstNonEmpty(e.VR, e.VRAttr, e.VirtualRouter, e.VirtualRouterAttr, e.LogicalRouter, e.LogicalRouterAttr)
450
+}
451
+
452
+func (e panosBGPPeerEntry) peerName() string {
453
+ return firstNonEmpty(e.PeerAddress, e.PeerAddressAttr, e.Peer, e.PeerAttr, e.PeerName, e.PeerNameAttr, e.NameAttr)
454
+}
455
+
456
+func (e panosBGPPeerEntry) peerAddress() string {
457
+ return firstNonEmpty(e.PeerAddress, e.PeerAddressAttr, e.Peer, e.PeerAttr)
458
+}
459
+
460
+func (e panosBGPPeerEntry) localAddress() string {
461
+ return e.LocalAddress
462
+}
463
+
464
+func (e panosBGPPeerEntry) remoteAS() string {
465
+ return firstNonEmpty(e.RemoteAS, e.RemoteASAttr, e.PeerAS, e.PeerASNumber)
466
+}
467
+
468
+func (e panosBGPPeerEntry) peerGroup() string {
469
+ return firstNonEmpty(e.PeerGroup, e.PeerGroupAttr)
470
+}
471
+
472
+func (e panosBGPPeerEntry) prefixCounters(peerAddr string) ([]bgpPrefixCounter, error) {
473
+ counters := make([]bgpPrefixCounter, 0, len(e.PrefixCounter.Entries))
474
+ var errs []error
475
+ for _, entry := range e.PrefixCounter.Entries {
476
+ counter, err := entry.toBGPPrefixCounter(peerAddr)
477
+ if err != nil {
478
+ errs = append(errs, err)
479
+ continue
480
+ }
481
+ if counter.AFI == "" {
482
+ counter.AFI = "unknown"
483
+ }
484
+ if counter.SAFI == "" {
485
+ counter.SAFI = "unknown"
486
+ }
487
+ counters = append(counters, counter)
488
+ }
489
+ return counters, errors.Join(errs...)
490
+}
491
+
492
+func (e panosBGPPrefixEntry) toBGPPrefixCounter(peerAddr string) (bgpPrefixCounter, error) {
493
+ afi, safi := normalizeAFISAFI(firstNonEmpty(e.AFISAFIAttr, e.NameAttr))
494
+ if afi == "" {
495
+ afi = normalizeAFI(firstNonEmpty(e.AFI, e.AFIAttr))
496
+ }
497
+ if safi == "" {
498
+ safi = normalizeSAFI(firstNonEmpty(e.SAFI, e.SAFIAttr))
499
+ }
500
+ family := "unknown"
501
+ if afi != "" && safi != "" {
502
+ family = afi + "-" + safi
503
+ } else if afi != "" {
504
+ family = afi
505
+ } else if safi != "" {
506
+ family = safi
507
+ }
508
+
509
+ incomingTotal, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" "+family+" incoming-total", e.IncomingTotal)
510
+ if err != nil {
511
+ return bgpPrefixCounter{}, err
512
+ }
513
+ incomingAccepted, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" "+family+" incoming-accepted", e.IncomingAccepted)
514
+ if err != nil {
515
+ return bgpPrefixCounter{}, err
516
+ }
517
+ incomingRejected, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" "+family+" incoming-rejected", e.IncomingRejected)
518
+ if err != nil {
519
+ return bgpPrefixCounter{}, err
520
+ }
521
+ outgoingAdvertised, err := parseRequiredPANOSIntField("BGP peer "+peerAddr+" "+family+" outgoing-advertised", e.OutgoingAdvertised)
522
+ if err != nil {
523
+ return bgpPrefixCounter{}, err
524
+ }
525
+
526
+ return bgpPrefixCounter{
527
+ AFI: afi,
528
+ SAFI: safi,
529
+ IncomingTotal: incomingTotal,
530
+ IncomingAccepted: incomingAccepted,
531
+ IncomingRejected: incomingRejected,
532
+ OutgoingAdvertised: outgoingAdvertised,
533
+ }, nil
534
+}
535
+
536
+func normalizeBGPState(state string) string {
537
+ v := strings.ToLower(strings.TrimSpace(state))
538
+ if v == "" {
539
+ return ""
540
+ }
541
+ v = stateNameReplacer.Replace(v)
542
+
543
+ switch {
544
+ case strings.Contains(v, "established"):
545
+ return "established"
546
+ case strings.Contains(v, "openconfirm"):
547
+ return "openconfirm"
548
+ case strings.Contains(v, "opensent"):
549
+ return "opensent"
550
+ case strings.Contains(v, "active"):
551
+ return "active"
552
+ case strings.Contains(v, "connect"):
553
+ return "connect"
554
+ case strings.Contains(v, "idle"):
555
+ return "idle"
556
+ default:
557
+ return "unknown"
558
+ }
559
+}
560
+
561
+func normalizeAFISAFI(v string) (string, string) {
562
+ v = strings.ToLower(strings.TrimSpace(v))
563
+ v = strings.TrimPrefix(v, "bgpafi")
564
+ v = strings.TrimPrefix(v, "afi-")
565
+ v = strings.TrimPrefix(v, "afi_")
566
+ v = strings.ReplaceAll(v, "_", "-")
567
+
568
+ parts := strings.Split(v, "-")
569
+ if len(parts) < 2 {
570
+ return normalizeAFI(v), ""
571
+ }
572
+ return normalizeAFI(parts[0]), normalizeSAFI(strings.Join(parts[1:], "-"))
573
+}
574
+
575
+func normalizeAFI(v string) string {
576
+ v = strings.ToLower(strings.TrimSpace(v))
577
+ v = strings.TrimPrefix(v, "bgpafi")
578
+ switch v {
579
+ case "ipv4", "ip":
580
+ return "ipv4"
581
+ case "ipv6":
582
+ return "ipv6"
583
+ default:
584
+ return strings.ReplaceAll(v, " ", "_")
585
+ }
586
+}
587
+
588
+func normalizeSAFI(v string) string {
589
+ v = strings.ToLower(strings.TrimSpace(v))
590
+ v = strings.ReplaceAll(v, " ", "_")
591
+ return strings.ReplaceAll(v, "-", "_")
592
+}
593
+
594
+func normalizeAddress(addr string) string {
595
+ addr = strings.TrimSpace(addr)
596
+ if addr == "" {
597
+ return ""
598
+ }
599
+
600
+ if host, _, err := net.SplitHostPort(addr); err == nil {
601
+ return strings.Trim(host, "[]")
602
+ }
603
+ if _, err := netip.ParseAddr(addr); err == nil {
604
+ return addr
605
+ }
606
+
607
+ if strings.Count(addr, ":") == 1 {
608
+ host, port, ok := strings.Cut(addr, ":")
609
+ if ok && isDigits(port) {
610
+ return host
611
+ }
612
+ }
613
+
614
+ return strings.Trim(addr, "[]")
615
+}
616
+
617
+var digitsOnly = regexp.MustCompile(`^\d+$`)
618
+
619
+func isDigits(v string) bool {
620
+ return digitsOnly.MatchString(v)
621
+}
622
+
623
+func parsePANOSIntField(field, v string) (int64, error) {
624
+ raw := strings.TrimSpace(v)
625
+ v = strings.ReplaceAll(raw, ",", "")
626
+ if v == "" {
627
+ return 0, nil
628
+ }
629
+ n, err := strconv.ParseInt(v, 10, 64)
630
+ if err != nil {
631
+ return 0, fmt.Errorf("%s: invalid integer %q", field, raw)
632
+ }
633
+ return n, nil
634
+}
635
+
636
+func parseRequiredPANOSIntField(field, v string) (int64, error) {
637
+ if strings.TrimSpace(v) == "" {
638
+ return 0, fmt.Errorf("%s: missing integer", field)
639
+ }
640
+ return parsePANOSIntField(field, v)
641
+}
642
+
643
+func parsePANOSDurationField(field, v string) (int64, error) {
644
+ v = strings.TrimSpace(strings.ToLower(v))
645
+ if v == "" {
646
+ return 0, nil
647
+ }
648
+ if isDigits(v) {
649
+ return parsePANOSIntField(field, v)
650
+ }
651
+
652
+ var total int64
653
+ matched := false
654
+ for _, match := range durationPartRe.FindAllStringSubmatch(v, -1) {
655
+ matched = true
656
+ n, err := parsePANOSIntField(field, match[1])
657
+ if err != nil {
658
+ return 0, err
659
+ }
660
+ switch match[2] {
661
+ case "day", "days", "d":
662
+ total += n * int64((24 * time.Hour).Seconds())
663
+ case "hour", "hours", "h":
664
+ total += n * int64(time.Hour.Seconds())
665
+ case "minute", "minutes", "min", "mins", "m":
666
+ total += n * int64(time.Minute.Seconds())
667
+ case "second", "seconds", "sec", "secs", "s":
668
+ total += n
669
+ }
670
+ }
671
+
672
+ timeMatch := durationClockRe.FindStringSubmatch(v)
673
+ if len(timeMatch) == 4 {
674
+ matched = true
675
+ hour, err := parsePANOSIntField(field, timeMatch[1])
676
+ if err != nil {
677
+ return 0, err
678
+ }
679
+ minute, err := parsePANOSIntField(field, timeMatch[2])
680
+ if err != nil {
681
+ return 0, err
682
+ }
683
+ second, err := parsePANOSIntField(field, timeMatch[3])
684
+ if err != nil {
685
+ return 0, err
686
+ }
687
+ if minute >= 60 || second >= 60 {
688
+ return 0, fmt.Errorf("%s: invalid duration %q", field, v)
689
+ }
690
+ total += hour*int64(time.Hour.Seconds()) +
691
+ minute*int64(time.Minute.Seconds()) +
692
+ second
693
+ }
694
+
695
+ remainder := durationPartRe.ReplaceAllString(v, "")
696
+ remainder = durationClockRe.ReplaceAllString(remainder, "")
697
+ remainder = strings.TrimSpace(strings.Trim(remainder, ","))
698
+ if !matched || remainder != "" {
699
+ return 0, fmt.Errorf("%s: invalid duration %q", field, v)
700
+ }
701
+
702
+ return total, nil
703
+}
704
+
705
+func parseRequiredPANOSDurationField(field, v string) (int64, error) {
706
+ if strings.TrimSpace(v) == "" {
707
+ return 0, fmt.Errorf("%s: missing duration", field)
708
+ }
709
+ return parsePANOSDurationField(field, v)
710
+}
711
+
712
+func (m panosResponseMessage) String() string {
713
+ var lines []string
714
+ if text := strings.TrimSpace(m.Text); text != "" {
715
+ lines = append(lines, text)
716
+ }
717
+ for _, line := range m.Lines {
718
+ if line = strings.TrimSpace(line); line != "" {
719
+ lines = append(lines, line)
720
+ }
721
+ }
722
+ return strings.Join(lines, "; ")
723
+}
724
+
725
+type panosResponseError struct {
726
+ code string
727
+ message string
728
+}
729
+
730
+func (e panosResponseError) Error() string {
731
+ code := strings.TrimSpace(e.code)
732
+ codeName := panosResponseCodeName(code)
733
+ if e.message == "" {
734
+ if code == "" {
735
+ return "PAN-OS XML API response error"
736
+ }
737
+ if codeName != "" {
738
+ return fmt.Sprintf("PAN-OS XML API response error code %s (%s)", code, codeName)
739
+ }
740
+ return fmt.Sprintf("PAN-OS XML API response error code %s", code)
741
+ }
742
+ if code == "" {
743
+ return fmt.Sprintf("PAN-OS XML API response error: %s", e.message)
744
+ }
745
+ if codeName != "" && !strings.Contains(strings.ToLower(e.message), strings.ToLower(codeName)) {
746
+ return fmt.Sprintf("PAN-OS XML API response error code %s (%s): %s", code, codeName, e.message)
747
+ }
748
+ return fmt.Sprintf("PAN-OS XML API response error code %s: %s", code, e.message)
749
+}
750
+
751
+func panosResponseCodeName(code string) string {
752
+ switch strings.TrimSpace(code) {
753
+ case "1":
754
+ return "Unknown command"
755
+ case "2", "3", "4", "5", "11":
756
+ return "Internal error"
757
+ case "6":
758
+ return "Bad XPath"
759
+ case "7":
760
+ return "Object not found"
761
+ case "8":
762
+ return "Object not unique"
763
+ case "10":
764
+ return "Reference count not zero"
765
+ case "12":
766
+ return "Invalid object"
767
+ case "14":
768
+ return "Operation not possible"
769
+ case "15":
770
+ return "Operation denied"
771
+ case "16":
772
+ return "Unauthorized"
773
+ case "17":
774
+ return "Invalid command"
775
+ case "18":
776
+ return "Malformed command"
777
+ case "22":
778
+ return "Session timed out"
779
+ case "400":
780
+ return "Bad request"
781
+ case "403":
782
+ return "Forbidden"
783
+ default:
784
+ return ""
785
+ }
786
+}
787
+
788
+func bgpCommandName(cmd string) string {
789
+ switch cmd {
790
+ case legacyBGPPeerCommand:
791
+ return "legacy routing BGP peer query"
792
+ case advancedBGPPeerCommands[0]:
793
+ return "advanced routing BGP peer details query"
794
+ case advancedBGPPeerCommands[1]:
795
+ return "advanced routing BGP peer status query"
796
+ case advancedBGPPeerCommands[2]:
797
+ return "advanced routing BGP peer query"
798
+ default:
799
+ return "PAN-OS BGP query"
800
+ }
801
+}
802
+
803
+var (
804
+ stateNameReplacer = strings.NewReplacer(" ", "", "-", "", "_", "")
805
+ durationPartRe = regexp.MustCompile(`\b(\d+)\s*(days?|d|hours?|h|minutes?|mins?|min|m|seconds?|secs?|sec|s)\b`)
806
+ durationClockRe = regexp.MustCompile(`\b(\d{1,2}):(\d{2}):(\d{2})\b`)
807
+)
808
+
809
+func firstNonEmpty(values ...string) string {
810
+ for _, value := range values {
811
+ if strings.TrimSpace(value) != "" {
812
+ return strings.TrimSpace(value)
813
+ }
814
+ }
815
+ return ""
816
+}
src/go/plugin/go.d/collector/panos/charts.yaml
new
+373
@@ -0,0 +1,373 @@
1
+version: v1
2
+context_namespace: panos
3
+groups:
4
+ - family: system
5
+ metrics:
6
+ - system_uptime
7
+ - system_device_certificate_status
8
+ - system_operational_mode
9
+ charts:
10
+ - id: system_uptime
11
+ title: System Uptime
12
+ context: system.uptime
13
+ units: seconds
14
+ label_promotion: [hostname, model, serial, sw_version]
15
+ dimensions:
16
+ - selector: system_uptime
17
+ name: uptime
18
+ - id: system_device_certificate_status
19
+ title: Device Certificate Status
20
+ context: system.device_certificate_status
21
+ units: status
22
+ label_promotion: [hostname, model, serial, sw_version]
23
+ dimensions:
24
+ - selector: system_device_certificate_status
25
+ - id: system_operational_mode
26
+ title: Operational Mode
27
+ context: system.operational_mode
28
+ units: mode
29
+ label_promotion: [hostname, model, serial, sw_version]
30
+ dimensions:
31
+ - selector: system_operational_mode
32
+
33
+ - family: ha
34
+ metrics:
35
+ - ha_status
36
+ - ha_local_state
37
+ - ha_peer_state
38
+ - ha_peer_connection_status
39
+ - ha_state_sync_status
40
+ - ha_link_status
41
+ charts:
42
+ - id: ha_status
43
+ title: HA Status
44
+ context: ha.status
45
+ units: status
46
+ dimensions:
47
+ - selector: ha_status
48
+ - id: ha_local_state
49
+ title: Local HA State
50
+ context: ha.local.state
51
+ units: state
52
+ dimensions:
53
+ - selector: ha_local_state
54
+ - id: ha_peer_state
55
+ title: Peer HA State
56
+ context: ha.peer.state
57
+ units: state
58
+ dimensions:
59
+ - selector: ha_peer_state
60
+ - id: ha_peer_connection_status
61
+ title: HA Peer Connection Status
62
+ context: ha.peer.connection_status
63
+ units: status
64
+ dimensions:
65
+ - selector: ha_peer_connection_status
66
+ - id: ha_state_sync_status
67
+ title: HA State Synchronization
68
+ context: ha.state_sync_status
69
+ units: status
70
+ dimensions:
71
+ - selector: ha_state_sync_status
72
+ - id: ha_link_status
73
+ title: HA Link Status
74
+ context: ha.link_status
75
+ units: status
76
+ instances:
77
+ by_labels: [link]
78
+ dimensions:
79
+ - selector: ha_link_status
80
+ - family: environment temperature
81
+ metrics:
82
+ - environment_temperature
83
+ charts:
84
+ - id: environment_temperature
85
+ title: Environment Temperature
86
+ context: environment.temperature
87
+ units: Celsius
88
+ instances:
89
+ by_labels: [sensor_type, slot, sensor]
90
+ dimensions:
91
+ - selector: environment_temperature
92
+ name: temperature
93
+ options:
94
+ divisor: 1000
95
+
96
+ - family: environment fans
97
+ metrics:
98
+ - environment_fan_speed
99
+ charts:
100
+ - id: environment_fan_speed
101
+ title: Environment Fan Speed
102
+ context: environment.fan_speed
103
+ units: RPM
104
+ instances:
105
+ by_labels: [sensor_type, slot, sensor]
106
+ dimensions:
107
+ - selector: environment_fan_speed
108
+ name: speed
109
+
110
+ - family: environment voltage
111
+ metrics:
112
+ - environment_voltage
113
+ charts:
114
+ - id: environment_voltage
115
+ title: Environment Voltage
116
+ context: environment.voltage
117
+ units: Volts
118
+ instances:
119
+ by_labels: [sensor_type, slot, sensor]
120
+ dimensions:
121
+ - selector: environment_voltage
122
+ name: voltage
123
+ options:
124
+ divisor: 1000
125
+
126
+ - family: environment sensors
127
+ metrics:
128
+ - environment_sensor_alarm_status
129
+ - environment_power_supply_presence_status
130
+ - environment_power_supply_alarm_status
131
+ charts:
132
+ - id: environment_sensor_alarm_status
133
+ title: Environment Sensor Alarm
134
+ context: environment.sensor_alarm_status
135
+ units: status
136
+ instances:
137
+ by_labels: [sensor_type, slot, sensor]
138
+ dimensions:
139
+ - selector: environment_sensor_alarm_status
140
+ - id: environment_power_supply_presence_status
141
+ title: Power Supply Presence
142
+ context: environment.power_supply_presence_status
143
+ units: status
144
+ instances:
145
+ by_labels: [sensor_type, slot, sensor]
146
+ dimensions:
147
+ - selector: environment_power_supply_presence_status
148
+ - id: environment_power_supply_alarm_status
149
+ title: Power Supply Alarm
150
+ context: environment.power_supply_alarm_status
151
+ units: status
152
+ instances:
153
+ by_labels: [sensor_type, slot, sensor]
154
+ dimensions:
155
+ - selector: environment_power_supply_alarm_status
156
+
157
+ - family: licenses
158
+ metrics:
159
+ - license_count_total
160
+ - license_count_expired
161
+ - license_status
162
+ - license_time_until_expiration
163
+ charts:
164
+ - id: license_count
165
+ title: Licenses
166
+ context: license.count
167
+ units: licenses
168
+ algorithm: absolute
169
+ dimensions:
170
+ - selector: license_count_total
171
+ name: total
172
+ - selector: license_count_expired
173
+ name: expired
174
+ - id: license_status
175
+ title: License Status
176
+ context: license.status
177
+ units: status
178
+ label_promotion: [description]
179
+ instances:
180
+ by_labels: [feature]
181
+ dimensions:
182
+ - selector: license_status
183
+ - id: license_time_until_expiration
184
+ title: License Time Until Expiration
185
+ context: license.time_until_expiration
186
+ units: days
187
+ label_promotion: [description]
188
+ instances:
189
+ by_labels: [feature]
190
+ dimensions:
191
+ - selector: license_time_until_expiration
192
+ name: time_until_expiration
193
+
194
+ - family: ipsec
195
+ metrics:
196
+ - ipsec_tunnels_active
197
+ - ipsec_tunnel_sa_lifetime
198
+ charts:
199
+ - id: ipsec_tunnels
200
+ title: IPsec Tunnels
201
+ context: ipsec.tunnels
202
+ units: tunnels
203
+ dimensions:
204
+ - selector: ipsec_tunnels_active
205
+ name: active
206
+ - id: ipsec_tunnel_sa_lifetime
207
+ title: IPsec Tunnel SA Remaining Lifetime
208
+ context: ipsec.tunnel.sa_lifetime
209
+ units: seconds
210
+ label_promotion: [protocol, encryption]
211
+ instances:
212
+ by_labels: [tunnel, gateway, remote, tunnel_id]
213
+ dimensions:
214
+ - selector: ipsec_tunnel_sa_lifetime
215
+ name: remaining_lifetime
216
+
217
+ - family: bgp peers
218
+ metrics:
219
+ - bgp_peer_state
220
+ - bgp_peer_uptime
221
+ - bgp_peer_messages_in
222
+ - bgp_peer_messages_out
223
+ - bgp_peer_updates_in
224
+ - bgp_peer_updates_out
225
+ - bgp_peer_flaps
226
+ - bgp_peer_established_transitions
227
+ charts:
228
+ - id: bgp_peer_state
229
+ title: BGP Peer State
230
+ context: bgp.peer.state
231
+ units: state
232
+ label_promotion: [local_address, remote_as, peer_group]
233
+ instances:
234
+ by_labels: [vr, peer_address]
235
+ dimensions:
236
+ - selector: bgp_peer_state
237
+ - id: bgp_peer_uptime
238
+ title: BGP Peer Uptime
239
+ context: bgp.peer.uptime
240
+ units: seconds
241
+ label_promotion: [local_address, remote_as, peer_group]
242
+ instances:
243
+ by_labels: [vr, peer_address]
244
+ dimensions:
245
+ - selector: bgp_peer_uptime
246
+ name: uptime
247
+ - id: bgp_peer_messages
248
+ title: BGP Peer Messages
249
+ context: bgp.peer.messages
250
+ units: messages/s
251
+ algorithm: incremental
252
+ label_promotion: [local_address, remote_as, peer_group]
253
+ instances:
254
+ by_labels: [vr, peer_address]
255
+ dimensions:
256
+ - selector: bgp_peer_messages_in
257
+ name: in
258
+ - selector: bgp_peer_messages_out
259
+ name: out
260
+ - id: bgp_peer_updates
261
+ title: BGP Peer Updates
262
+ context: bgp.peer.updates
263
+ units: messages/s
264
+ algorithm: incremental
265
+ label_promotion: [local_address, remote_as, peer_group]
266
+ instances:
267
+ by_labels: [vr, peer_address]
268
+ dimensions:
269
+ - selector: bgp_peer_updates_in
270
+ name: in
271
+ - selector: bgp_peer_updates_out
272
+ name: out
273
+ - id: bgp_peer_flaps
274
+ title: BGP Peer Flaps
275
+ context: bgp.peer.flaps
276
+ units: flaps/s
277
+ algorithm: incremental
278
+ label_promotion: [local_address, remote_as, peer_group]
279
+ instances:
280
+ by_labels: [vr, peer_address]
281
+ dimensions:
282
+ - selector: bgp_peer_flaps
283
+ name: flaps
284
+ - id: bgp_peer_established_transitions
285
+ title: BGP Peer Established Transitions
286
+ context: bgp.peer.established_transitions
287
+ units: transitions/s
288
+ algorithm: incremental
289
+ label_promotion: [local_address, remote_as, peer_group]
290
+ instances:
291
+ by_labels: [vr, peer_address]
292
+ dimensions:
293
+ - selector: bgp_peer_established_transitions
294
+ name: established
295
+
296
+ - family: bgp prefixes
297
+ metrics:
298
+ - bgp_peer_prefixes_received_total
299
+ - bgp_peer_prefixes_received_accepted
300
+ - bgp_peer_prefixes_received_rejected
301
+ - bgp_peer_prefixes_advertised
302
+ charts:
303
+ - id: bgp_peer_prefixes_received
304
+ title: BGP Peer Received Prefixes
305
+ context: bgp.peer.prefixes_received
306
+ units: prefixes
307
+ algorithm: absolute
308
+ label_promotion: [local_address, remote_as, peer_group]
309
+ instances:
310
+ by_labels: [vr, peer_address, afi, safi]
311
+ dimensions:
312
+ - selector: bgp_peer_prefixes_received_total
313
+ name: total
314
+ - selector: bgp_peer_prefixes_received_accepted
315
+ name: accepted
316
+ - selector: bgp_peer_prefixes_received_rejected
317
+ name: rejected
318
+ - id: bgp_peer_prefixes_advertised
319
+ title: BGP Peer Advertised Prefixes
320
+ context: bgp.peer.prefixes_advertised
321
+ units: prefixes
322
+ label_promotion: [local_address, remote_as, peer_group]
323
+ instances:
324
+ by_labels: [vr, peer_address, afi, safi]
325
+ dimensions:
326
+ - selector: bgp_peer_prefixes_advertised
327
+ name: advertised
328
+
329
+ - family: bgp virtual routers
330
+ metrics:
331
+ - bgp_vr_peers_by_state_idle
332
+ - bgp_vr_peers_by_state_connect
333
+ - bgp_vr_peers_by_state_active
334
+ - bgp_vr_peers_by_state_opensent
335
+ - bgp_vr_peers_by_state_openconfirm
336
+ - bgp_vr_peers_by_state_established
337
+ - bgp_vr_peers_by_state_unknown
338
+ - bgp_vr_peers_total_configured
339
+ - bgp_vr_peers_total_established
340
+ charts:
341
+ - id: bgp_vr_peers_by_state
342
+ title: BGP Peers by State
343
+ context: bgp.vr.peers_by_state
344
+ units: peers
345
+ type: stacked
346
+ instances:
347
+ by_labels: [vr]
348
+ dimensions:
349
+ - selector: bgp_vr_peers_by_state_idle
350
+ name: idle
351
+ - selector: bgp_vr_peers_by_state_connect
352
+ name: connect
353
+ - selector: bgp_vr_peers_by_state_active
354
+ name: active
355
+ - selector: bgp_vr_peers_by_state_opensent
356
+ name: opensent
357
+ - selector: bgp_vr_peers_by_state_openconfirm
358
+ name: openconfirm
359
+ - selector: bgp_vr_peers_by_state_established
360
+ name: established
361
+ - selector: bgp_vr_peers_by_state_unknown
362
+ name: unknown
363
+ - id: bgp_vr_peers_total
364
+ title: BGP Peers Total
365
+ context: bgp.vr.peers_total
366
+ units: peers
367
+ instances:
368
+ by_labels: [vr]
369
+ dimensions:
370
+ - selector: bgp_vr_peers_total_configured
371
+ name: configured
372
+ - selector: bgp_vr_peers_total_established
373
+ name: established
src/go/plugin/go.d/collector/panos/collect.go
new
+177
@@ -0,0 +1,177 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "sort"
9
+)
10
+
11
+func (c *Collector) collect(ctx context.Context) (bool, error) {
12
+ if c.apiClient == nil {
13
+ return false, errors.New("PAN-OS API client not initialized")
14
+ }
15
+ defer c.logSystemInfo()
16
+
17
+ var result collectResult
18
+
19
+ if result.addContextError(ctx) {
20
+ return result.hasMetrics, errors.Join(result.errs...)
21
+ }
22
+ result.add(c.collectSystemMetrics(ctx))
23
+ if result.addContextError(ctx) {
24
+ return result.hasMetrics, errors.Join(result.errs...)
25
+ }
26
+ result.add(c.collectHAMetrics(ctx))
27
+ if result.addContextError(ctx) {
28
+ return result.hasMetrics, errors.Join(result.errs...)
29
+ }
30
+ result.add(c.collectEnvironmentMetrics(ctx))
31
+ if result.addContextError(ctx) {
32
+ return result.hasMetrics, errors.Join(result.errs...)
33
+ }
34
+ result.add(c.collectLicenseMetrics(ctx))
35
+ if result.addContextError(ctx) {
36
+ return result.hasMetrics, errors.Join(result.errs...)
37
+ }
38
+ result.add(c.collectIPSecMetrics(ctx))
39
+ if result.addContextError(ctx) {
40
+ return result.hasMetrics, errors.Join(result.errs...)
41
+ }
42
+
43
+ peers, err := c.collectBGPPeers(ctx)
44
+ result.add(false, err)
45
+ if len(peers) > 0 {
46
+ monitoredPeers := orderedBGPPeers(peers)
47
+ result.add(c.collectPeerMetrics(monitoredPeers), nil)
48
+ result.add(c.collectVRMetrics(peers), nil)
49
+ }
50
+
51
+ return result.hasMetrics, errors.Join(result.errs...)
52
+}
53
+
54
+func contextError(ctx context.Context) error {
55
+ if ctx == nil {
56
+ return nil
57
+ }
58
+ return ctx.Err()
59
+}
60
+
61
+type collectResult struct {
62
+ hasMetrics bool
63
+ errs []error
64
+}
65
+
66
+func (r *collectResult) add(hasMetrics bool, err error) {
67
+ if hasMetrics {
68
+ r.hasMetrics = true
69
+ }
70
+ if err != nil {
71
+ r.errs = append(r.errs, err)
72
+ }
73
+}
74
+
75
+func (r *collectResult) addContextError(ctx context.Context) bool {
76
+ if err := contextError(ctx); err != nil {
77
+ r.add(false, err)
78
+ return true
79
+ }
80
+ return false
81
+}
82
+
83
+func orderedBGPPeers(peers []bgpPeer) []bgpPeer {
84
+ items := append([]bgpPeer(nil), peers...)
85
+ sort.SliceStable(items, func(i, j int) bool {
86
+ return bgpPeerOrderKey(items[i]) < bgpPeerOrderKey(items[j])
87
+ })
88
+
89
+ for i := range items {
90
+ counters := append([]bgpPrefixCounter(nil), items[i].PrefixCounters...)
91
+ sort.SliceStable(counters, func(j, k int) bool {
92
+ return bgpPrefixCounterOrderKey(counters[j]) < bgpPrefixCounterOrderKey(counters[k])
93
+ })
94
+ items[i].PrefixCounters = counters
95
+ }
96
+
97
+ return items
98
+}
99
+
100
+func bgpPeerOrderKey(peer bgpPeer) string {
101
+ return firstNonEmpty(peer.VR, "default") + "/" + firstNonEmpty(peer.PeerAddress, "unknown")
102
+}
103
+
104
+func bgpPrefixCounterOrderKey(counter bgpPrefixCounter) string {
105
+ return firstNonEmpty(counter.AFI, "unknown") + "/" + firstNonEmpty(counter.SAFI, "unknown")
106
+}
107
+
108
+func (c *Collector) collectPeerMetrics(peers []bgpPeer) bool {
109
+ if len(peers) == 0 {
110
+ return false
111
+ }
112
+
113
+ for _, peer := range peers {
114
+ labels := peerLabelValues(peer)
115
+ observeStateSetVec(c.metrics.bgp.peerState, peer.State, labels...)
116
+ c.metrics.bgp.peerUptime.WithLabelValues(labels...).Observe(float64(peer.Uptime))
117
+ c.metrics.bgp.peerMessagesIn.WithLabelValues(labels...).ObserveTotal(float64(peer.MessagesIn))
118
+ c.metrics.bgp.peerMessagesOut.WithLabelValues(labels...).ObserveTotal(float64(peer.MessagesOut))
119
+ c.metrics.bgp.peerUpdatesIn.WithLabelValues(labels...).ObserveTotal(float64(peer.UpdatesIn))
120
+ c.metrics.bgp.peerUpdatesOut.WithLabelValues(labels...).ObserveTotal(float64(peer.UpdatesOut))
121
+ c.metrics.bgp.peerFlaps.WithLabelValues(labels...).ObserveTotal(float64(peer.Flaps))
122
+ c.metrics.bgp.peerEstablishedTransitions.WithLabelValues(labels...).ObserveTotal(float64(peer.Established))
123
+
124
+ for _, counter := range peer.PrefixCounters {
125
+ prefixLabels := prefixLabelValues(peer, counter)
126
+ c.metrics.bgp.peerPrefixesReceivedTotal.WithLabelValues(prefixLabels...).Observe(float64(counter.IncomingTotal))
127
+ c.metrics.bgp.peerPrefixesReceivedAccepted.WithLabelValues(prefixLabels...).Observe(float64(counter.IncomingAccepted))
128
+ c.metrics.bgp.peerPrefixesReceivedRejected.WithLabelValues(prefixLabels...).Observe(float64(counter.IncomingRejected))
129
+ c.metrics.bgp.peerPrefixesAdvertised.WithLabelValues(prefixLabels...).Observe(float64(counter.OutgoingAdvertised))
130
+ }
131
+ }
132
+
133
+ return true
134
+}
135
+
136
+func (c *Collector) collectVRMetrics(peers []bgpPeer) bool {
137
+ type vrStats struct {
138
+ stateCounts map[string]int64
139
+ total int64
140
+ established int64
141
+ }
142
+
143
+ stats := make(map[string]*vrStats)
144
+ for _, peer := range peers {
145
+ st := stats[peer.VR]
146
+ if st == nil {
147
+ st = &vrStats{stateCounts: make(map[string]int64)}
148
+ stats[peer.VR] = st
149
+ }
150
+
151
+ st.total++
152
+ if peer.State != "" {
153
+ st.stateCounts[peer.State]++
154
+ }
155
+ if peer.State == "established" {
156
+ st.established++
157
+ }
158
+ }
159
+
160
+ vrs := make([]string, 0, len(stats))
161
+ for vr := range stats {
162
+ vrs = append(vrs, vr)
163
+ }
164
+ sort.Strings(vrs)
165
+
166
+ for _, vr := range vrs {
167
+ st := stats[vr]
168
+ labels := []string{vr}
169
+ for _, state := range bgpStates {
170
+ c.metrics.bgp.vrPeersByState[state].WithLabelValues(labels...).Observe(float64(st.stateCounts[state]))
171
+ }
172
+ c.metrics.bgp.vrPeersConfigured.WithLabelValues(labels...).Observe(float64(st.total))
173
+ c.metrics.bgp.vrPeersEstablished.WithLabelValues(labels...).Observe(float64(st.established))
174
+ }
175
+
176
+ return len(vrs) > 0
177
+}
src/go/plugin/go.d/collector/panos/collect_environment.go
new
+253
@@ -0,0 +1,253 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "encoding/xml"
8
+ "errors"
9
+ "fmt"
10
+ "io"
11
+ "strings"
12
+)
13
+
14
+type environmentResult struct {
15
+ Thermal rawXMLSection `xml:"thermal"`
16
+ Fan rawXMLSection `xml:"fan"`
17
+ Fans rawXMLSection `xml:"fans"`
18
+ Power rawXMLSection `xml:"power"`
19
+ PowerSupply rawXMLSection `xml:"power-supply"`
20
+}
21
+
22
+type rawXMLSection struct {
23
+ InnerXML string `xml:",innerxml"`
24
+}
25
+
26
+type environmentEntry struct {
27
+ Slot string `xml:"slot"`
28
+ Name string `xml:"name"`
29
+ Description string `xml:"description"`
30
+ Alarm string `xml:"alarm"`
31
+ Inserted string `xml:"Inserted"`
32
+ Min string `xml:"min"`
33
+ Max string `xml:"max"`
34
+ DegreesC string `xml:"DegreesC"`
35
+ RPMs string `xml:"RPMs"`
36
+ Volts string `xml:"Volts"`
37
+}
38
+
39
+type environmentSensor struct {
40
+ kind string
41
+ entry environmentEntry
42
+}
43
+
44
+func (c *Collector) collectEnvironmentMetrics(ctx context.Context) (bool, error) {
45
+ body, err := c.apiClient.op(ctx, environmentCommand)
46
+ if err != nil {
47
+ return false, fmt.Errorf("environment metricset: %s API call: %w", panosCommandName(environmentCommand), err)
48
+ }
49
+
50
+ env, err := parseEnvironment(body)
51
+ if err != nil {
52
+ return false, fmt.Errorf("environment metricset: %s response: %w", panosCommandName(environmentCommand), err)
53
+ }
54
+
55
+ sensors := env.sensors()
56
+
57
+ var hasMetrics bool
58
+ var errs []error
59
+ for _, sensor := range sensors {
60
+ entry := sensor.entry
61
+ labels := environmentLabelValues(sensor.kind, entry)
62
+ switch sensor.kind {
63
+ case "temperature":
64
+ alarm, err := parsePANOSAlarmField("environment temperature "+environmentSensorName(entry)+" alarm", entry.Alarm)
65
+ if err != nil {
66
+ errs = append(errs, err)
67
+ } else {
68
+ hasMetrics = true
69
+ observeStateSetVec(c.metrics.env.sensorAlarm, alarmState(alarm), labels...)
70
+ }
71
+ value, err := parseRequiredPANOSDecimalField("environment temperature "+environmentSensorName(entry), entry.DegreesC, 1000)
72
+ if err != nil {
73
+ errs = append(errs, err)
74
+ continue
75
+ }
76
+ hasMetrics = true
77
+ c.metrics.env.temperature.WithLabelValues(labels...).Observe(float64(value))
78
+ case "fan":
79
+ alarm, err := parsePANOSAlarmField("environment fan "+environmentSensorName(entry)+" alarm", entry.Alarm)
80
+ if err != nil {
81
+ errs = append(errs, err)
82
+ } else {
83
+ hasMetrics = true
84
+ observeStateSetVec(c.metrics.env.sensorAlarm, alarmState(alarm), labels...)
85
+ }
86
+ value, err := parseRequiredPANOSIntField("environment fan "+environmentSensorName(entry)+" RPMs", entry.RPMs)
87
+ if err != nil {
88
+ errs = append(errs, err)
89
+ continue
90
+ }
91
+ hasMetrics = true
92
+ c.metrics.env.fanSpeed.WithLabelValues(labels...).Observe(float64(value))
93
+ case "voltage":
94
+ alarm, err := parsePANOSAlarmField("environment voltage "+environmentSensorName(entry)+" alarm", entry.Alarm)
95
+ if err != nil {
96
+ errs = append(errs, err)
97
+ } else {
98
+ hasMetrics = true
99
+ observeStateSetVec(c.metrics.env.sensorAlarm, alarmState(alarm), labels...)
100
+ }
101
+ value, err := parseRequiredPANOSDecimalField("environment voltage "+environmentSensorName(entry), entry.Volts, 1000)
102
+ if err != nil {
103
+ errs = append(errs, err)
104
+ continue
105
+ }
106
+ hasMetrics = true
107
+ c.metrics.env.voltage.WithLabelValues(labels...).Observe(float64(value))
108
+ case "power_supply":
109
+ inserted, insertedErr := parsePANOSAffirmativeField("environment power supply "+environmentSensorName(entry)+" inserted", entry.Inserted)
110
+ if insertedErr != nil {
111
+ errs = append(errs, insertedErr)
112
+ }
113
+ alarm, alarmErr := parsePANOSAlarmField("environment power supply "+environmentSensorName(entry)+" alarm", entry.Alarm)
114
+ if alarmErr != nil {
115
+ errs = append(errs, alarmErr)
116
+ }
117
+ if insertedErr == nil {
118
+ hasMetrics = true
119
+ observeStateSetVec(c.metrics.env.powerSupplyPresence, boolState(inserted, "present", "absent"), labels...)
120
+ }
121
+ if alarmErr == nil {
122
+ hasMetrics = true
123
+ observeStateSetVec(c.metrics.env.powerSupplyAlarm, alarmState(alarm), labels...)
124
+ }
125
+ }
126
+ }
127
+
128
+ return hasMetrics, errors.Join(errs...)
129
+}
130
+
131
+type environmentMetrics struct {
132
+ ThermalEntries []environmentEntry
133
+ FanEntries []environmentEntry
134
+ VoltageEntries []environmentEntry
135
+ PowerSupplyEntries []environmentEntry
136
+}
137
+
138
+func (m environmentMetrics) sensors() []environmentSensor {
139
+ total := len(m.ThermalEntries) + len(m.FanEntries) + len(m.VoltageEntries) + len(m.PowerSupplyEntries)
140
+ sensors := make([]environmentSensor, 0, total)
141
+ for _, entry := range m.ThermalEntries {
142
+ sensors = append(sensors, environmentSensor{kind: "temperature", entry: entry})
143
+ }
144
+ for _, entry := range m.FanEntries {
145
+ sensors = append(sensors, environmentSensor{kind: "fan", entry: entry})
146
+ }
147
+ for _, entry := range m.VoltageEntries {
148
+ sensors = append(sensors, environmentSensor{kind: "voltage", entry: entry})
149
+ }
150
+ for _, entry := range m.PowerSupplyEntries {
151
+ sensors = append(sensors, environmentSensor{kind: "power_supply", entry: entry})
152
+ }
153
+ return sensors
154
+}
155
+
156
+func parseEnvironment(body []byte) (environmentMetrics, error) {
157
+ var result environmentResult
158
+ if err := decodePANOSResult(body, "PAN-OS environment response", &result); err != nil {
159
+ return environmentMetrics{}, err
160
+ }
161
+ if !result.hasAnySection() {
162
+ return environmentMetrics{}, missingPANOSResultError{expected: "<thermal>, <fan>, <fans>, <power>, or <power-supply>"}
163
+ }
164
+
165
+ thermal, err := decodeEnvironmentEntries(result.Thermal.InnerXML)
166
+ if err != nil {
167
+ return environmentMetrics{}, fmt.Errorf("thermal entries: %w", err)
168
+ }
169
+ fan, err := decodeEnvironmentFanEntries(result.Fan.InnerXML, result.Fans.InnerXML)
170
+ if err != nil {
171
+ return environmentMetrics{}, fmt.Errorf("fan entries: %w", err)
172
+ }
173
+ voltage, err := decodeEnvironmentEntries(result.Power.InnerXML)
174
+ if err != nil {
175
+ return environmentMetrics{}, fmt.Errorf("voltage entries: %w", err)
176
+ }
177
+ psu, err := decodeEnvironmentEntries(result.PowerSupply.InnerXML)
178
+ if err != nil {
179
+ return environmentMetrics{}, fmt.Errorf("power supply entries: %w", err)
180
+ }
181
+
182
+ return environmentMetrics{
183
+ ThermalEntries: thermal,
184
+ FanEntries: fan,
185
+ VoltageEntries: voltage,
186
+ PowerSupplyEntries: psu,
187
+ }, nil
188
+}
189
+
190
+func (r environmentResult) hasAnySection() bool {
191
+ return firstNonEmpty(r.Thermal.InnerXML, r.Fan.InnerXML, r.Fans.InnerXML, r.Power.InnerXML, r.PowerSupply.InnerXML) != ""
192
+}
193
+
194
+func decodeEnvironmentFanEntries(sections ...string) ([]environmentEntry, error) {
195
+ var entries []environmentEntry
196
+ seen := make(map[string]bool)
197
+ for _, section := range sections {
198
+ decoded, err := decodeEnvironmentEntries(section)
199
+ if err != nil {
200
+ return nil, err
201
+ }
202
+ for _, entry := range decoded {
203
+ key := environmentEntryIdentity(entry)
204
+ if seen[key] {
205
+ continue
206
+ }
207
+ seen[key] = true
208
+ entries = append(entries, entry)
209
+ }
210
+ }
211
+ return entries, nil
212
+}
213
+
214
+func environmentEntryIdentity(entry environmentEntry) string {
215
+ return firstNonEmpty(entry.Slot, "unknown") + "\x00" + environmentSensorName(entry)
216
+}
217
+
218
+func decodeEnvironmentEntries(innerXML string) ([]environmentEntry, error) {
219
+ if strings.TrimSpace(innerXML) == "" {
220
+ return nil, nil
221
+ }
222
+
223
+ decoder := xml.NewDecoder(strings.NewReader(innerXML))
224
+ var entries []environmentEntry
225
+
226
+ for {
227
+ tok, err := decoder.Token()
228
+ if err != nil {
229
+ if err == io.EOF {
230
+ return entries, nil
231
+ }
232
+ return nil, err
233
+ }
234
+
235
+ start, ok := tok.(xml.StartElement)
236
+ if !ok || start.Name.Local != "entry" {
237
+ continue
238
+ }
239
+
240
+ var entry environmentEntry
241
+ if err := decoder.DecodeElement(&entry, &start); err != nil {
242
+ return nil, err
243
+ }
244
+ if firstNonEmpty(entry.Description, entry.Name, entry.Slot) == "" {
245
+ continue
246
+ }
247
+ entries = append(entries, entry)
248
+ }
249
+}
250
+
251
+func environmentSensorName(entry environmentEntry) string {
252
+ return firstNonEmpty(entry.Description, entry.Name, "unknown")
253
+}
src/go/plugin/go.d/collector/panos/collect_ha.go
new
+131
@@ -0,0 +1,131 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+)
10
+
11
+type haResult struct {
12
+ Enabled string `xml:"enabled"`
13
+ Group haGroup `xml:"group"`
14
+}
15
+
16
+type haGroup struct {
17
+ Mode string `xml:"mode"`
18
+ RunningSync string `xml:"running-sync"`
19
+ LocalInfo haInfo `xml:"local-info"`
20
+ PeerInfo haInfo `xml:"peer-info"`
21
+}
22
+
23
+type haInfo struct {
24
+ State string `xml:"state"`
25
+ ConnStatus string `xml:"conn-status"`
26
+ StateSync string `xml:"state-sync"`
27
+ ConnHA1 haConn `xml:"conn-ha1"`
28
+ ConnHA1B haConn `xml:"conn-ha1-backup"`
29
+ ConnHA2 haConn `xml:"conn-ha2"`
30
+ ConnHA2B haConn `xml:"conn-ha2-backup"`
31
+}
32
+
33
+type haConn struct {
34
+ Status string `xml:"conn-status"`
35
+}
36
+
37
+func (c *Collector) collectHAMetrics(ctx context.Context) (bool, error) {
38
+ body, err := c.apiClient.op(ctx, haStateCommand)
39
+ if err != nil {
40
+ return false, fmt.Errorf("ha metricset: %s API call: %w", panosCommandName(haStateCommand), err)
41
+ }
42
+
43
+ ha, err := parseHAState(body)
44
+ if err != nil {
45
+ return false, fmt.Errorf("ha metricset: %s response: %w", panosCommandName(haStateCommand), err)
46
+ }
47
+ if firstNonEmpty(ha.Enabled, ha.Group.LocalInfo.State, ha.Group.PeerInfo.State) == "" {
48
+ return false, fmt.Errorf("ha metricset: %s response: %w", panosCommandName(haStateCommand), missingPANOSResultError{expected: "<enabled> or <group>"})
49
+ }
50
+
51
+ enabled, err := c.haEnabledStatus(ha)
52
+ if err != nil {
53
+ return false, fmt.Errorf("ha metricset: %s response: %w", panosCommandName(haStateCommand), err)
54
+ }
55
+ if !enabled && firstNonEmpty(ha.Group.LocalInfo.State, ha.Group.PeerInfo.State, ha.Group.RunningSync) == "" {
56
+ observeStateSet(c.metrics.ha.status, "disabled")
57
+ return true, nil
58
+ }
59
+
60
+ localState := normalizeHAState(ha.Group.LocalInfo.State)
61
+ peerState := normalizeHAState(ha.Group.PeerInfo.State)
62
+ stateSync := firstNonEmpty(ha.Group.RunningSync, ha.Group.LocalInfo.StateSync)
63
+
64
+ observeStateSet(c.metrics.ha.status, boolState(enabled, "enabled", "disabled"))
65
+ observeStateSet(c.metrics.ha.localState, localState)
66
+ observeStateSet(c.metrics.ha.peerState, peerState)
67
+ if ha.Group.PeerInfo.ConnStatus != "" {
68
+ observeStateSet(c.metrics.ha.peerConnectionStatus, normalizeUpDownState(ha.Group.PeerInfo.ConnStatus))
69
+ }
70
+ if stateSync != "" {
71
+ observeStateSet(c.metrics.ha.stateSync, normalizeHASyncState(stateSync))
72
+ }
73
+ c.observeHALinkStatus("ha1", ha.Group.PeerInfo.ConnHA1.Status)
74
+ c.observeHALinkStatus("ha1_backup", ha.Group.PeerInfo.ConnHA1B.Status)
75
+ c.observeHALinkStatus("ha2", ha.Group.PeerInfo.ConnHA2.Status)
76
+ c.observeHALinkStatus("ha2_backup", ha.Group.PeerInfo.ConnHA2B.Status)
77
+ return true, nil
78
+}
79
+
80
+func (c *Collector) observeHALinkStatus(link, status string) {
81
+ state := normalizeUpDownState(status)
82
+ if state == "" {
83
+ return
84
+ }
85
+
86
+ observeStateSetVec(c.metrics.ha.linkStatus, state, link)
87
+}
88
+
89
+func parseHAState(body []byte) (haResult, error) {
90
+ var result haResult
91
+ if err := decodePANOSResult(body, "PAN-OS HA response", &result); err != nil {
92
+ return haResult{}, err
93
+ }
94
+ return result, nil
95
+}
96
+
97
+func normalizeHAState(state string) string {
98
+ state = strings.ToLower(strings.TrimSpace(state))
99
+ state = strings.ReplaceAll(state, "-", "_")
100
+ state = strings.ReplaceAll(state, " ", "_")
101
+ switch state {
102
+ case "":
103
+ return ""
104
+ case "active", "passive", "suspended", "unknown":
105
+ return state
106
+ case "nonfunctional", "non_functional", "non_function":
107
+ return "non_functional"
108
+ default:
109
+ return "unknown"
110
+ }
111
+}
112
+
113
+func (c *Collector) haEnabledStatus(ha haResult) (bool, error) {
114
+ if strings.TrimSpace(ha.Enabled) != "" {
115
+ return parsePANOSAffirmativeField("HA enabled", ha.Enabled)
116
+ }
117
+ return firstNonEmpty(ha.Group.LocalInfo.State, ha.Group.PeerInfo.State, ha.Group.RunningSync) != "", nil
118
+}
119
+
120
+func normalizeHASyncState(v string) string {
121
+ switch strings.ToLower(strings.TrimSpace(v)) {
122
+ case "synchronized", "complete":
123
+ return "synchronized"
124
+ case "not synchronized", "not-synchronized", "not_synchronized", "unsynchronized", "out of sync", "out-of-sync", "out_of_sync", "incomplete", "syncing", "synchronizing":
125
+ return "not_synchronized"
126
+ case "":
127
+ return ""
128
+ default:
129
+ return "unknown"
130
+ }
131
+}
src/go/plugin/go.d/collector/panos/collect_ipsec.go
new
+107
@@ -0,0 +1,107 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "strings"
10
+)
11
+
12
+type ipsecResult struct {
13
+ NTun string `xml:"ntun"`
14
+ Entries *ipsecEntries `xml:"entries"`
15
+}
16
+
17
+type ipsecEntries struct {
18
+ Entries []ipsecTunnel `xml:"entry"`
19
+}
20
+
21
+type ipsecTunnel struct {
22
+ Name string `xml:"name"`
23
+ Gateway string `xml:"gateway"`
24
+ Remote string `xml:"remote"`
25
+ Protocol string `xml:"proto"`
26
+ Encryption string `xml:"enc"`
27
+ Remain string `xml:"remain"`
28
+ TID string `xml:"tid"`
29
+ ISPI string `xml:"i_spi"`
30
+ OSPI string `xml:"o_spi"`
31
+}
32
+
33
+func (c *Collector) collectIPSecMetrics(ctx context.Context) (bool, error) {
34
+ body, err := c.apiClient.op(ctx, ipsecSACommand)
35
+ if err != nil {
36
+ return false, fmt.Errorf("ipsec metricset: %s API call: %w", panosCommandName(ipsecSACommand), err)
37
+ }
38
+
39
+ payload, err := parseIPSecTunnels(body)
40
+ if err != nil {
41
+ return false, fmt.Errorf("ipsec metricset: %s response: %w", panosCommandName(ipsecSACommand), err)
42
+ }
43
+ if !payload.found {
44
+ return false, fmt.Errorf("ipsec metricset: %s response: %w", panosCommandName(ipsecSACommand), missingPANOSResultError{expected: "<ntun> or <entries>"})
45
+ }
46
+
47
+ c.metrics.ipsec.tunnelsActive.Observe(float64(payload.activeCount))
48
+
49
+ var errs []error
50
+ if payload.entriesFound && payload.activeCount != int64(len(payload.tunnels)) {
51
+ errs = append(errs, fmt.Errorf("IPsec active tunnel count mismatch: ntun=%d entries=%d; per-tunnel lifetime metrics may be incomplete", payload.activeCount, len(payload.tunnels)))
52
+ }
53
+ for _, tunnel := range payload.tunnels {
54
+ key := ipsecTunnelKey(tunnel)
55
+ value, err := parseRequiredPANOSIntField("IPsec tunnel "+firstNonEmpty(tunnel.Name, key)+" remain", tunnel.Remain)
56
+ if err != nil {
57
+ errs = append(errs, err)
58
+ continue
59
+ }
60
+ c.metrics.ipsec.saLifetime.WithLabelValues(ipsecTunnelLabelValues(tunnel)...).Observe(float64(value))
61
+ }
62
+ return true, errors.Join(errs...)
63
+}
64
+
65
+type ipsecTunnelPayload struct {
66
+ tunnels []ipsecTunnel
67
+ activeCount int64
68
+ found bool
69
+ entriesFound bool
70
+}
71
+
72
+func parseIPSecTunnels(body []byte) (ipsecTunnelPayload, error) {
73
+ var result ipsecResult
74
+ if err := decodePANOSResult(body, "PAN-OS IPsec response", &result); err != nil {
75
+ return ipsecTunnelPayload{}, err
76
+ }
77
+ if result.Entries == nil && strings.TrimSpace(result.NTun) == "" {
78
+ return ipsecTunnelPayload{}, nil
79
+ }
80
+
81
+ payload := ipsecTunnelPayload{
82
+ found: true,
83
+ entriesFound: result.Entries != nil,
84
+ }
85
+ var activeCount int64
86
+ if strings.TrimSpace(result.NTun) != "" {
87
+ count, err := parseRequiredPANOSIntField("IPsec active tunnel count", result.NTun)
88
+ if err != nil {
89
+ return ipsecTunnelPayload{found: true, entriesFound: result.Entries != nil}, err
90
+ }
91
+ activeCount = count
92
+ }
93
+ if result.Entries == nil {
94
+ payload.activeCount = activeCount
95
+ return payload, nil
96
+ }
97
+ if strings.TrimSpace(result.NTun) == "" {
98
+ activeCount = int64(len(result.Entries.Entries))
99
+ }
100
+ payload.tunnels = result.Entries.Entries
101
+ payload.activeCount = activeCount
102
+ return payload, nil
103
+}
104
+
105
+func ipsecTunnelKey(tunnel ipsecTunnel) string {
106
+ return cleanID(firstNonEmpty(tunnel.Name, "unknown") + "_" + tunnel.Gateway + "_" + tunnel.Remote + "_" + firstNonEmpty(tunnel.TID, tunnel.ISPI, tunnel.OSPI))
107
+}
src/go/plugin/go.d/collector/panos/collect_licenses.go
new
+142
@@ -0,0 +1,142 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "strings"
10
+ "time"
11
+)
12
+
13
+const licenseNeverExpires = int64(-1)
14
+
15
+type licenseInfoResult struct {
16
+ Licenses *licenseEntries `xml:"licenses"`
17
+}
18
+
19
+type licenseEntries struct {
20
+ Entries []licenseEntry `xml:"entry"`
21
+}
22
+
23
+type licenseEntry struct {
24
+ Feature string `xml:"feature"`
25
+ Description string `xml:"description"`
26
+ Expires string `xml:"expires"`
27
+ Expired string `xml:"expired"`
28
+}
29
+
30
+func (c *Collector) collectLicenseMetrics(ctx context.Context) (bool, error) {
31
+ body, err := c.apiClient.op(ctx, licenseInfoCommand)
32
+ if err != nil {
33
+ return false, fmt.Errorf("licenses metricset: %s API call: %w", panosCommandName(licenseInfoCommand), err)
34
+ }
35
+
36
+ licenses, found, err := parseLicenses(body)
37
+ if err != nil {
38
+ return false, fmt.Errorf("licenses metricset: %s response: %w", panosCommandName(licenseInfoCommand), err)
39
+ }
40
+ if !found {
41
+ return false, fmt.Errorf("licenses metricset: %s response: %w", panosCommandName(licenseInfoCommand), missingPANOSResultError{expected: "<licenses>"})
42
+ }
43
+
44
+ var expired int64
45
+ var errs []error
46
+ for _, entry := range licenses {
47
+ labels := licenseLabelValues(entry)
48
+ isExpired, err := c.licenseExpiredStatus(entry)
49
+ if err != nil {
50
+ errs = append(errs, fmt.Errorf("license %s expired status: %w", firstNonEmpty(entry.Feature, "unknown"), err))
51
+ } else {
52
+ observeStateSetVec(c.metrics.lic.status, boolState(!isExpired, "valid", "expired"), labels...)
53
+ }
54
+ if err == nil && isExpired {
55
+ expired++
56
+ continue
57
+ }
58
+
59
+ days, ok, err := c.licenseDaysUntilExpiration(entry)
60
+ if err != nil {
61
+ errs = append(errs, fmt.Errorf("license %s expiration: %w", firstNonEmpty(entry.Feature, "unknown"), err))
62
+ continue
63
+ }
64
+ if ok {
65
+ c.metrics.lic.timeUntilExpiration.WithLabelValues(labels...).Observe(float64(days))
66
+ }
67
+ }
68
+
69
+ c.metrics.lic.countTotal.Observe(float64(len(licenses)))
70
+ c.metrics.lic.countExpired.Observe(float64(expired))
71
+ return true, errors.Join(errs...)
72
+}
73
+
74
+func parseLicenses(body []byte) ([]licenseEntry, bool, error) {
75
+ var result licenseInfoResult
76
+ if err := decodePANOSResult(body, "PAN-OS licenses response", &result); err != nil {
77
+ return nil, false, err
78
+ }
79
+ if result.Licenses == nil {
80
+ return nil, false, nil
81
+ }
82
+ return result.Licenses.Entries, true, nil
83
+}
84
+
85
+func (c *Collector) licenseExpiredStatus(entry licenseEntry) (bool, error) {
86
+ raw := strings.TrimSpace(entry.Expired)
87
+ switch strings.ToLower(raw) {
88
+ case "yes", "true", "expired":
89
+ return true, nil
90
+ case "no", "false", "valid":
91
+ return false, nil
92
+ case "":
93
+ expires := strings.TrimSpace(entry.Expires)
94
+ if strings.EqualFold(expires, "never") {
95
+ return false, nil
96
+ }
97
+ if expires == "" {
98
+ return false, errors.New("missing status")
99
+ }
100
+ exp, err := parseLicenseExpirationDate(expires)
101
+ if err != nil {
102
+ return false, fmt.Errorf("missing status and %w", err)
103
+ }
104
+ now := c.now().UTC()
105
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
106
+ expireDay := time.Date(exp.Year(), exp.Month(), exp.Day(), 0, 0, 0, 0, time.UTC)
107
+ return expireDay.Before(today), nil
108
+ default:
109
+ return false, fmt.Errorf("invalid status %q", raw)
110
+ }
111
+}
112
+
113
+func (c *Collector) licenseDaysUntilExpiration(entry licenseEntry) (int64, bool, error) {
114
+ expires := strings.TrimSpace(entry.Expires)
115
+ if strings.EqualFold(expires, "never") {
116
+ return licenseNeverExpires, true, nil
117
+ }
118
+ if expires == "" {
119
+ return 0, false, errors.New("missing expiration date")
120
+ }
121
+ exp, err := parseLicenseExpirationDate(expires)
122
+ if err != nil {
123
+ return 0, false, err
124
+ }
125
+
126
+ now := c.now().UTC()
127
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
128
+ expireDay := time.Date(exp.Year(), exp.Month(), exp.Day(), 0, 0, 0, 0, time.UTC)
129
+ days := int64(expireDay.Sub(today).Hours() / 24)
130
+ if days < 0 {
131
+ return 0, false, nil
132
+ }
133
+ return days, true, nil
134
+}
135
+
136
+func parseLicenseExpirationDate(expires string) (time.Time, error) {
137
+ exp, err := time.ParseInLocation("January 02, 2006", expires, time.UTC)
138
+ if err != nil {
139
+ return time.Time{}, fmt.Errorf("invalid expiration date %q", expires)
140
+ }
141
+ return exp, nil
142
+}
src/go/plugin/go.d/collector/panos/collect_system.go
new
+80
@@ -0,0 +1,80 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "strings"
9
+)
10
+
11
+type systemInfo struct {
12
+ Hostname string `xml:"hostname"`
13
+ DeviceName string `xml:"devicename"`
14
+ Model string `xml:"model"`
15
+ Serial string `xml:"serial"`
16
+ SWVersion string `xml:"sw-version"`
17
+ Uptime string `xml:"uptime"`
18
+ CertificateStatus string `xml:"device-certificate-status"`
19
+ OperationalMode string `xml:"operational-mode"`
20
+}
21
+
22
+type systemInfoResult struct {
23
+ System systemInfo `xml:"system"`
24
+}
25
+
26
+func (c *Collector) collectSystemMetrics(ctx context.Context) (bool, error) {
27
+ info, err := c.querySystemInfo(ctx)
28
+ if err != nil {
29
+ return false, fmt.Errorf("system metricset: %w", err)
30
+ }
31
+
32
+ uptime, err := parseRequiredPANOSDurationField("system uptime", info.Uptime)
33
+ if err != nil {
34
+ return false, fmt.Errorf("system metricset: %s response: %w", panosCommandName(systemInfoCommand), err)
35
+ }
36
+
37
+ labels := systemLabelValues(info)
38
+ c.metrics.system.uptime.WithLabelValues(labels...).Observe(float64(uptime))
39
+
40
+ certStatus := strings.TrimSpace(info.CertificateStatus)
41
+ if certStatus != "" {
42
+ certValid := strings.EqualFold(certStatus, "valid")
43
+ observeStateSetVec(c.metrics.system.certStatus, boolState(certValid, "valid", "invalid"), labels...)
44
+ }
45
+
46
+ operationalMode := strings.TrimSpace(info.OperationalMode)
47
+ if operationalMode != "" {
48
+ normalMode := strings.EqualFold(operationalMode, "normal")
49
+ observeStateSetVec(c.metrics.system.operationalMode, boolState(normalMode, "normal", "other"), labels...)
50
+ }
51
+ return true, nil
52
+}
53
+
54
+func (c *Collector) querySystemInfo(ctx context.Context) (systemInfo, error) {
55
+ body, err := c.apiClient.op(ctx, systemInfoCommand)
56
+ if err != nil {
57
+ return systemInfo{}, fmt.Errorf("%s API call: %w", panosCommandName(systemInfoCommand), err)
58
+ }
59
+
60
+ info, err := parseSystemInfo(body)
61
+ if err != nil {
62
+ return systemInfo{}, fmt.Errorf("%s response: %w", panosCommandName(systemInfoCommand), err)
63
+ }
64
+ if !info.hasData() {
65
+ return systemInfo{}, fmt.Errorf("%s response: %w", panosCommandName(systemInfoCommand), missingPANOSResultError{expected: "<system>"})
66
+ }
67
+ return info, nil
68
+}
69
+
70
+func parseSystemInfo(body []byte) (systemInfo, error) {
71
+ var result systemInfoResult
72
+ if err := decodePANOSResult(body, "PAN-OS system info response", &result); err != nil {
73
+ return systemInfo{}, err
74
+ }
75
+ return result.System, nil
76
+}
77
+
78
+func (i systemInfo) hasData() bool {
79
+ return firstNonEmpty(i.Hostname, i.DeviceName, i.Model, i.Serial, i.SWVersion, i.Uptime, i.CertificateStatus, i.OperationalMode) != ""
80
+}
src/go/plugin/go.d/collector/panos/collector.go
new
+226
@@ -0,0 +1,226 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "errors"
9
+ "fmt"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/confopt"
14
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
15
+ "github.com/netdata/netdata/go/plugins/pkg/web"
16
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
17
+)
18
+
19
+//go:embed "config_schema.json"
20
+var configSchema string
21
+
22
+//go:embed "charts.yaml"
23
+var chartTemplateYAML string
24
+
25
+func init() {
26
+ collectorapi.Register("panos", collectorapi.Creator{
27
+ JobConfigSchema: configSchema,
28
+ Defaults: collectorapi.Defaults{
29
+ UpdateEvery: 60,
30
+ },
31
+ CreateV2: func() collectorapi.CollectorV2 { return New() },
32
+ Config: func() any { return &Config{} },
33
+ })
34
+}
35
+
36
+func New() *Collector {
37
+ store := metrix.NewCollectorStore()
38
+
39
+ return &Collector{
40
+ Config: Config{
41
+ HTTPConfig: web.HTTPConfig{
42
+ RequestConfig: web.RequestConfig{
43
+ URL: "https://127.0.0.1",
44
+ },
45
+ ClientConfig: web.ClientConfig{
46
+ Timeout: confopt.Duration(3 * time.Second),
47
+ },
48
+ },
49
+ },
50
+ store: store,
51
+ metrics: newCollectorMetrics(store),
52
+ routingEngine: routingEngineUnknown,
53
+ newAPIClient: newPangoAPIClient,
54
+ advancedBGPCommands: advancedBGPPeerCommands,
55
+ now: time.Now,
56
+ }
57
+}
58
+
59
+type Config struct {
60
+ Vnode string `yaml:"vnode,omitempty" json:"vnode"`
61
+ UpdateEvery int `yaml:"update_every,omitempty" json:"update_every"`
62
+ AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry"`
63
+ web.HTTPConfig `yaml:",inline" json:""`
64
+ APIKey string `yaml:"api_key,omitempty" json:"api_key"`
65
+ Vsys string `yaml:"vsys,omitempty" json:"vsys"`
66
+}
67
+
68
+type Collector struct {
69
+ collectorapi.Base
70
+ Config `yaml:",inline" json:""`
71
+
72
+ store metrix.CollectorStore
73
+ metrics *collectorMetrics
74
+
75
+ apiClient panosAPIClient
76
+
77
+ routingEngine routingEngine
78
+ bgpCommand string
79
+ noBGPProbedAt time.Time
80
+
81
+ newAPIClient func(Config) (panosAPIClient, error)
82
+ advancedBGPCommands []string
83
+ now func() time.Time
84
+}
85
+
86
+func (c *Collector) Configuration() any {
87
+ return c.Config
88
+}
89
+
90
+func (c *Collector) Init(context.Context) error {
91
+ if err := c.validateConfig(); err != nil {
92
+ return err
93
+ }
94
+
95
+ client, err := c.newAPIClient(c.Config)
96
+ if err != nil {
97
+ return fmt.Errorf("init PAN-OS API client: %w", err)
98
+ }
99
+ c.apiClient = client
100
+
101
+ c.Debugf("using URL %s", c.URL)
102
+ c.Debugf("using timeout: %s", c.Timeout)
103
+
104
+ return nil
105
+}
106
+
107
+func (c *Collector) Check(ctx context.Context) error {
108
+ if c.apiClient == nil {
109
+ return errors.New("PAN-OS API client not initialized")
110
+ }
111
+ defer c.logSystemInfo()
112
+
113
+ if err := contextError(ctx); err != nil {
114
+ return err
115
+ }
116
+ if _, err := c.querySystemInfo(ctx); err != nil {
117
+ return fmt.Errorf("check system info: %w", err)
118
+ }
119
+ return nil
120
+}
121
+
122
+func (c *Collector) Collect(ctx context.Context) error {
123
+ hasMetrics, err := c.collect(ctx)
124
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
125
+ return err
126
+ }
127
+ if !hasMetrics {
128
+ return err
129
+ }
130
+ if err != nil {
131
+ c.Limit(logKeyCollectPartialError, 1, recurringLogEvery).
132
+ Warningf("PAN-OS partial collection error: %v", err)
133
+ }
134
+ return nil
135
+}
136
+
137
+func (c *Collector) Cleanup(context.Context) {
138
+ if c.apiClient != nil {
139
+ c.apiClient.closeIdleConnections()
140
+ }
141
+}
142
+
143
+func (c *Collector) MetricStore() metrix.CollectorStore { return c.store }
144
+
145
+func (c *Collector) ChartTemplateYAML() string { return chartTemplateYAML }
146
+
147
+func (c *Collector) validateConfig() error {
148
+ if c.URL == "" {
149
+ return errors.New("config: url not configured")
150
+ }
151
+ if c.APIKey == "" && (c.Username == "" || c.Password == "") {
152
+ return errors.New("config: api_key or username/password must be set")
153
+ }
154
+ if c.ForceHTTP2 {
155
+ return errors.New("config: force_http2 is not supported by the panos collector")
156
+ }
157
+ if c.BearerTokenFile != "" {
158
+ return errors.New("config: bearer_token_file is not supported by the panos collector")
159
+ }
160
+ if c.Method != "" {
161
+ return errors.New("config: method is not supported by the panos collector")
162
+ }
163
+ if c.Body != "" {
164
+ return errors.New("config: body is not supported by the panos collector")
165
+ }
166
+ if c.NotFollowRedirect {
167
+ return errors.New("config: not_follow_redirects is not supported by the panos collector")
168
+ }
169
+ if c.ProxyUsername != "" || c.ProxyPassword != "" {
170
+ return errors.New("config: proxy_username/proxy_password are not supported; include proxy credentials in proxy_url")
171
+ }
172
+ if (c.TLSCert != "" && c.TLSKey == "") || (c.TLSKey != "" && c.TLSCert == "") {
173
+ return errors.New("config: tls_cert and tls_key must both be set")
174
+ }
175
+ return nil
176
+}
177
+
178
+const (
179
+ recurringLogEvery = time.Hour
180
+ logKeyCollectPartialError = "panos:collect:partial_error"
181
+ logKeySystemInfo = "panos:system_info"
182
+ logKeyPanorama = "panos:panorama"
183
+)
184
+
185
+func (c *Collector) logSystemInfo() {
186
+ if c.apiClient == nil {
187
+ return
188
+ }
189
+
190
+ info := c.apiClient.systemInfo()
191
+ if len(info) == 0 {
192
+ return
193
+ }
194
+
195
+ hostname := firstNonEmpty(info["hostname"], info["devicename"])
196
+ model := info["model"]
197
+ swVersion := info["sw-version"]
198
+ serial := info["serial"]
199
+ haState := firstNonEmpty(info["ha-state"], info["state"])
200
+
201
+ parts := make([]string, 0, 4)
202
+ if hostname != "" {
203
+ parts = append(parts, "hostname="+hostname)
204
+ }
205
+ if model != "" {
206
+ parts = append(parts, "model="+model)
207
+ }
208
+ if swVersion != "" {
209
+ parts = append(parts, "sw_version="+swVersion)
210
+ }
211
+ if serial != "" {
212
+ parts = append(parts, "serial="+serial)
213
+ }
214
+ if haState != "" {
215
+ parts = append(parts, "ha_state="+haState)
216
+ }
217
+
218
+ if len(parts) > 0 {
219
+ c.Limit(logKeySystemInfo, 1, 0).
220
+ Infof("connected to PAN-OS device: %s", strings.Join(parts, ", "))
221
+ }
222
+ if strings.Contains(strings.ToLower(model), "panorama") {
223
+ c.Limit(logKeyPanorama, 1, 0).
224
+ Warningf("PAN-OS device appears to be Panorama (model=%s); Panorama target proxy mode is not supported by this collector version", model)
225
+ }
226
+}
src/go/plugin/go.d/collector/panos/collector_test.go
new
+2467
@@ -0,0 +1,2467 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "errors"
9
+ "maps"
10
+ "os"
11
+ "sort"
12
+ "strconv"
13
+ "strings"
14
+ "testing"
15
+ "time"
16
+
17
+ "github.com/netdata/netdata/go/plugins/logger"
18
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
19
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
20
+ "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
21
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
22
+
23
+ "github.com/stretchr/testify/assert"
24
+ "github.com/stretchr/testify/require"
25
+)
26
+
27
+var (
28
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
29
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
30
+ dataLegacyBGPPeers, _ = os.ReadFile("testdata/legacy_bgp_peers.xml")
31
+ dataAdvancedBGPPeers, _ = os.ReadFile("testdata/advanced_bgp_peers.xml")
32
+ dataSystemInfo, _ = os.ReadFile("testdata/system_info.xml")
33
+ dataHAState, _ = os.ReadFile("testdata/ha_state.xml")
34
+ dataEnvironment, _ = os.ReadFile("testdata/environment.xml")
35
+ dataLicenses, _ = os.ReadFile("testdata/licenses.xml")
36
+ dataIPSecSA, _ = os.ReadFile("testdata/ipsec_sa.xml")
37
+)
38
+
39
+func Test_testDataIsValid(t *testing.T) {
40
+ for name, data := range map[string][]byte{
41
+ "dataConfigJSON": dataConfigJSON,
42
+ "dataConfigYAML": dataConfigYAML,
43
+ "dataLegacyBGPPeers": dataLegacyBGPPeers,
44
+ "dataAdvancedBGPPeers": dataAdvancedBGPPeers,
45
+ "dataSystemInfo": dataSystemInfo,
46
+ "dataHAState": dataHAState,
47
+ "dataEnvironment": dataEnvironment,
48
+ "dataLicenses": dataLicenses,
49
+ "dataIPSecSA": dataIPSecSA,
50
+ } {
51
+ require.NotNil(t, data, name)
52
+ }
53
+}
54
+
55
+func TestCollector_ConfigurationSerialize(t *testing.T) {
56
+ collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
57
+}
58
+
59
+func TestCollector_Init(t *testing.T) {
60
+ tests := map[string]struct {
61
+ setup func(*Collector)
62
+ keepFactory bool
63
+ wantErr string
64
+ check func(*testing.T, *Collector)
65
+ }{
66
+ "success with API key": {
67
+ setup: func(c *Collector) {
68
+ c.APIKey = "key"
69
+ },
70
+ check: func(t *testing.T, c *Collector) {
71
+ assert.NotNil(t, c.apiClient)
72
+ },
73
+ },
74
+ "success with username and password": {
75
+ setup: func(c *Collector) {
76
+ c.Username = "user"
77
+ c.Password = "pass"
78
+ },
79
+ check: func(t *testing.T, c *Collector) {
80
+ assert.NotNil(t, c.apiClient)
81
+ },
82
+ },
83
+ "api client factory error": {
84
+ setup: func(c *Collector) {
85
+ c.APIKey = "key"
86
+ c.newAPIClient = func(Config) (panosAPIClient, error) {
87
+ return nil, errors.New("factory failed")
88
+ }
89
+ },
90
+ keepFactory: true,
91
+ wantErr: "init PAN-OS API client: factory failed",
92
+ },
93
+ "URL not set": {
94
+ setup: func(c *Collector) {
95
+ c.URL = ""
96
+ c.APIKey = "key"
97
+ },
98
+ wantErr: "url not configured",
99
+ },
100
+ "auth not set": {
101
+ wantErr: "api_key or username/password",
102
+ },
103
+ "force_http2 is not supported": {
104
+ setup: func(c *Collector) {
105
+ c.APIKey = "key"
106
+ c.ForceHTTP2 = true
107
+ },
108
+ wantErr: "force_http2",
109
+ },
110
+ "request body is not supported": {
111
+ setup: func(c *Collector) {
112
+ c.APIKey = "key"
113
+ c.Body = "body"
114
+ },
115
+ wantErr: "body",
116
+ },
117
+ "bearer token file is not supported": {
118
+ setup: func(c *Collector) {
119
+ c.APIKey = "key"
120
+ c.BearerTokenFile = "/tmp/token"
121
+ },
122
+ wantErr: "bearer_token_file",
123
+ },
124
+ "request method is not supported": {
125
+ setup: func(c *Collector) {
126
+ c.APIKey = "key"
127
+ c.Method = "POST"
128
+ },
129
+ wantErr: "method",
130
+ },
131
+ "not following redirects is not supported": {
132
+ setup: func(c *Collector) {
133
+ c.APIKey = "key"
134
+ c.NotFollowRedirect = true
135
+ },
136
+ wantErr: "not_follow_redirects",
137
+ },
138
+ "proxy username is not supported": {
139
+ setup: func(c *Collector) {
140
+ c.APIKey = "key"
141
+ c.ProxyUsername = "proxy-user"
142
+ },
143
+ wantErr: "proxy_username/proxy_password",
144
+ },
145
+ "proxy password is not supported": {
146
+ setup: func(c *Collector) {
147
+ c.APIKey = "key"
148
+ c.ProxyPassword = "proxy-pass"
149
+ },
150
+ wantErr: "proxy_username/proxy_password",
151
+ },
152
+ "tls cert without key is rejected": {
153
+ setup: func(c *Collector) {
154
+ c.APIKey = "key"
155
+ c.TLSCert = "/tmp/client.pem"
156
+ },
157
+ wantErr: "tls_cert and tls_key",
158
+ },
159
+ "tls key without cert is rejected": {
160
+ setup: func(c *Collector) {
161
+ c.APIKey = "key"
162
+ c.TLSKey = "/tmp/client-key.pem"
163
+ },
164
+ wantErr: "tls_cert and tls_key",
165
+ },
166
+ }
167
+
168
+ for name, tc := range tests {
169
+ t.Run(name, func(t *testing.T) {
170
+ collr := New()
171
+ if tc.setup != nil {
172
+ tc.setup(collr)
173
+ }
174
+ if !tc.keepFactory {
175
+ collr.newAPIClient = func(Config) (panosAPIClient, error) {
176
+ return &mockAPIClient{}, nil
177
+ }
178
+ }
179
+
180
+ err := collr.Init(context.Background())
181
+ if tc.wantErr != "" {
182
+ require.ErrorContains(t, err, tc.wantErr)
183
+ return
184
+ }
185
+ require.NoError(t, err)
186
+ if tc.check != nil {
187
+ tc.check(t, collr)
188
+ }
189
+ })
190
+ }
191
+}
192
+
193
+func TestCollector_Check(t *testing.T) {
194
+ tests := map[string]struct {
195
+ client panosAPIClient
196
+ wantErr string
197
+ wantCommands []string
198
+ }{
199
+ "success probes system info only": {
200
+ client: &mockAPIClient{
201
+ responses: map[string][]byte{
202
+ systemInfoCommand: dataSystemInfo,
203
+ haStateCommand: []byte(`<response status="success"><result></result></response>`),
204
+ environmentCommand: []byte(`<response status="success"><result></result></response>`),
205
+ licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
206
+ ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
207
+ legacyBGPPeerCommand: dataLegacyBGPPeers,
208
+ },
209
+ },
210
+ wantCommands: []string{systemInfoCommand},
211
+ },
212
+ "malformed optional metricsets do not fail check": {
213
+ client: &mockAPIClient{
214
+ responses: map[string][]byte{
215
+ systemInfoCommand: dataSystemInfo,
216
+ haStateCommand: []byte(`<response status="success"><result></result></response>`),
217
+ environmentCommand: []byte(`<response status="success"><result></result></response>`),
218
+ licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
219
+ ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
220
+ },
221
+ },
222
+ wantCommands: []string{systemInfoCommand},
223
+ },
224
+ "fails when system info API call fails": {
225
+ client: &mockAPIClient{
226
+ errors: map[string]error{systemInfoCommand: errors.New("api error")},
227
+ },
228
+ wantErr: "api error",
229
+ wantCommands: []string{systemInfoCommand},
230
+ },
231
+ "fails when system info payload is missing": {
232
+ client: &mockAPIClient{
233
+ responses: map[string][]byte{systemInfoCommand: []byte(`<response status="success"><result></result></response>`)},
234
+ },
235
+ wantErr: "expected <system>",
236
+ wantCommands: []string{systemInfoCommand},
237
+ },
238
+ "fails when API client is not initialized": {
239
+ wantErr: "API client not initialized",
240
+ },
241
+ }
242
+
243
+ for name, tc := range tests {
244
+ t.Run(name, func(t *testing.T) {
245
+ collr := New()
246
+ collr.apiClient = tc.client
247
+ api, _ := tc.client.(*mockAPIClient)
248
+
249
+ err := collr.Check(context.Background())
250
+ if tc.wantErr != "" {
251
+ require.ErrorContains(t, err, tc.wantErr)
252
+ if tc.wantCommands != nil {
253
+ require.NotNil(t, api)
254
+ assert.Equal(t, tc.wantCommands, api.commands)
255
+ }
256
+ return
257
+ }
258
+ require.NoError(t, err)
259
+ require.NotNil(t, api)
260
+ assert.Equal(t, tc.wantCommands, api.commands)
261
+ })
262
+ }
263
+}
264
+
265
+func TestCollector_CheckStopsOnCanceledContext(t *testing.T) {
266
+ ctx, cancel := context.WithCancel(context.Background())
267
+ cancel()
268
+
269
+ api := &mockAPIClient{}
270
+ collr := New()
271
+ collr.apiClient = api
272
+
273
+ err := collr.Check(ctx)
274
+ require.ErrorIs(t, err, context.Canceled)
275
+ assert.Empty(t, api.commands)
276
+}
277
+
278
+func TestCollector_Cleanup(t *testing.T) {
279
+ tests := map[string]struct {
280
+ client *mockAPIClient
281
+ want int
282
+ }{
283
+ "client not initialized": {},
284
+ "client initialized": {
285
+ client: &mockAPIClient{},
286
+ want: 1,
287
+ },
288
+ }
289
+
290
+ for name, tc := range tests {
291
+ t.Run(name, func(t *testing.T) {
292
+ collr := New()
293
+ if tc.client != nil {
294
+ collr.apiClient = tc.client
295
+ }
296
+
297
+ assert.NotPanics(t, func() { collr.Cleanup(context.Background()) })
298
+ if tc.client != nil {
299
+ assert.Equal(t, tc.want, tc.client.closeCalls)
300
+ }
301
+ })
302
+ }
303
+}
304
+
305
+func TestCollector_CollectStopsOnCanceledContext(t *testing.T) {
306
+ tests := map[string]struct {
307
+ cancelBeforeCollect bool
308
+ cancelAfterCommand string
309
+ wantCommands []string
310
+ }{
311
+ "canceled before first API call": {
312
+ cancelBeforeCollect: true,
313
+ },
314
+ "canceled after system metricset": {
315
+ cancelAfterCommand: systemInfoCommand,
316
+ wantCommands: []string{systemInfoCommand},
317
+ },
318
+ }
319
+
320
+ for name, tc := range tests {
321
+ t.Run(name, func(t *testing.T) {
322
+ ctx, cancel := context.WithCancel(context.Background())
323
+ defer cancel()
324
+ if tc.cancelBeforeCollect {
325
+ cancel()
326
+ }
327
+
328
+ api := &mockAPIClient{}
329
+ api.onOp = func(_ context.Context, cmd string) {
330
+ if cmd == tc.cancelAfterCommand {
331
+ cancel()
332
+ }
333
+ }
334
+ collr := New()
335
+ collr.apiClient = api
336
+
337
+ err := collectOnceWithContext(t, collr, ctx)
338
+ require.ErrorIs(t, err, context.Canceled)
339
+ assert.Equal(t, tc.wantCommands, api.commands)
340
+ })
341
+ }
342
+}
343
+
344
+func TestCollector_MetricStore(t *testing.T) {
345
+ assert.NotNil(t, New().MetricStore())
346
+}
347
+
348
+func TestCollector_ChartTemplateYAML(t *testing.T) {
349
+ collr := New()
350
+
351
+ collecttest.AssertChartTemplateSchema(t, collr.ChartTemplateYAML())
352
+ spec, err := charttpl.DecodeYAML([]byte(collr.ChartTemplateYAML()))
353
+ require.NoError(t, err)
354
+ _, err = chartengine.Compile(spec, 1)
355
+ require.NoError(t, err)
356
+}
357
+
358
+func TestCollector_Collect(t *testing.T) {
359
+ type collectStep struct {
360
+ name string
361
+ setup func(*Collector, *mockAPIClient)
362
+ wantErr string
363
+ wantMetrics map[string]metrix.SampleValue
364
+ wantMissing []string
365
+ wantLog []string
366
+ notWantLog []string
367
+ check func(*testing.T, *Collector, *mockAPIClient, map[string]metrix.SampleValue)
368
+ }
369
+ tests := map[string]struct {
370
+ prepare func(*Collector, *mockAPIClient)
371
+ steps []collectStep
372
+ }{
373
+ "read-only telemetry and legacy BGP": {
374
+ prepare: func(c *Collector, api *mockAPIClient) {
375
+ api.responses = map[string][]byte{
376
+ systemInfoCommand: dataSystemInfo,
377
+ haStateCommand: dataHAState,
378
+ environmentCommand: dataEnvironment,
379
+ licenseInfoCommand: dataLicenses,
380
+ ipsecSACommand: dataIPSecSA,
381
+ legacyBGPPeerCommand: dataLegacyBGPPeers,
382
+ }
383
+ c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
384
+ },
385
+ steps: []collectStep{
386
+ {
387
+ name: "collects all read-only metricsets",
388
+ wantMetrics: map[string]metrix.SampleValue{
389
+ metricKey("system_uptime", systemLabels()): 183845,
390
+ stateMetricKey("system_device_certificate_status", "valid", systemLabels()): 1,
391
+ stateMetricKey("system_operational_mode", "normal", systemLabels()): 1,
392
+ stateMetricKey("ha_status", "enabled", nil): 1,
393
+ stateMetricKey("ha_status", "disabled", nil): 0,
394
+ stateMetricKey("ha_local_state", "active", nil): 1,
395
+ stateMetricKey("ha_peer_state", "passive", nil): 1,
396
+ stateMetricKey("ha_peer_connection_status", "up", nil): 1,
397
+ stateMetricKey("ha_peer_connection_status", "down", nil): 0,
398
+ stateMetricKey("ha_peer_connection_status", "unknown", nil): 0,
399
+ stateMetricKey("ha_state_sync_status", "synchronized", nil): 1,
400
+ stateMetricKey("ha_state_sync_status", "not_synchronized", nil): 0,
401
+ stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
402
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha1")): 1,
403
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha1")): 0,
404
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1")): 0,
405
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha1_backup")): 0,
406
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha1_backup")): 1,
407
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1_backup")): 0,
408
+ metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")): 40900,
409
+ metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")): 9157,
410
+ metricKey("environment_voltage", envLabels("voltage", "1", "3.3V Power Rail")): 3332,
411
+ stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("voltage", "1", "3.3V Power Rail")): 1,
412
+ stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("voltage", "1", "3.3V Power Rail")): 0,
413
+ stateMetricKey("environment_power_supply_presence_status", "present", envLabels("power_supply", "1", "Power Supply 1")): 1,
414
+ stateMetricKey("environment_power_supply_presence_status", "absent", envLabels("power_supply", "1", "Power Supply 1")): 0,
415
+ stateMetricKey("environment_power_supply_alarm_status", "clear", envLabels("power_supply", "1", "Power Supply 1")): 1,
416
+ stateMetricKey("environment_power_supply_alarm_status", "alarm", envLabels("power_supply", "1", "Power Supply 1")): 0,
417
+ metricKey("license_count_total", nil): 3,
418
+ metricKey("license_count_expired", nil): 1,
419
+ metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates")): 30,
420
+ stateMetricKey("license_status", "expired", licenseLabels("Premium Support", "Support entitlement")): 1,
421
+ metricKey("license_time_until_expiration", licenseLabels("GlobalProtect Portal", "Portal entitlement")): metrix.SampleValue(licenseNeverExpires),
422
+ metricKey("ipsec_tunnels_active", nil): 2,
423
+ metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "ESP", "G256")): 1727,
424
+ metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-b", "gw-branch-b", "203.0.113.20", "67", "ESP", "AES128")): 99,
425
+ stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()): 1,
426
+ },
427
+ wantMissing: []string{
428
+ metricKey("license_time_until_expiration", licenseLabels("Premium Support", "Support entitlement")),
429
+ "env_sensors_collection_discovered",
430
+ "license_collection_discovered",
431
+ "ipsec_tunnels_collection_discovered",
432
+ },
433
+ check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
434
+ assert.Equal(t, routingEngineLegacy, c.routingEngine)
435
+ collecttest.AssertChartCoverage(t, c, collecttest.ChartCoverageExpectation{})
436
+ },
437
+ },
438
+ },
439
+ },
440
+ "advanced BGP fallback": {
441
+ prepare: func(_ *Collector, api *mockAPIClient) {
442
+ api.responses = map[string][]byte{
443
+ legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
444
+ advancedBGPPeerCommands[0]: dataAdvancedBGPPeers,
445
+ advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
446
+ advancedBGPPeerCommands[2]: []byte(`<response status="success"><result></result></response>`),
447
+ }
448
+ },
449
+ steps: []collectStep{
450
+ {
451
+ name: "collects ARE peers after legacy empty success",
452
+ wantMetrics: map[string]metrix.SampleValue{
453
+ stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
454
+ metricKey("bgp_peer_uptime", advancedPeerLabels()): 93784,
455
+ metricKey("bgp_peer_prefixes_received_total", advancedPrefixLabels("ipv4", "unicast")): 100,
456
+ metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "lr-a"}): 1,
457
+ },
458
+ check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
459
+ assert.Equal(t, routingEngineAdvanced, c.routingEngine)
460
+ assert.Equal(t, advancedBGPPeerCommands[0], c.bgpCommand)
461
+ },
462
+ },
463
+ },
464
+ },
465
+ "no BGP state is cached": {
466
+ prepare: func(c *Collector, _ *mockAPIClient) {
467
+ now := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
468
+ c.now = func() time.Time { return now }
469
+ },
470
+ steps: []collectStep{
471
+ {
472
+ name: "initial full BGP probe",
473
+ wantMetrics: map[string]metrix.SampleValue{
474
+ metricKey("system_uptime", systemLabels()): 183845,
475
+ },
476
+ check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
477
+ assert.Equal(t, routingEngineNone, c.routingEngine)
478
+ assert.Len(t, api.commands, 9)
479
+ },
480
+ },
481
+ {
482
+ name: "cached no-BGP skips BGP commands",
483
+ setup: func(_ *Collector, api *mockAPIClient) {
484
+ api.commands = nil
485
+ },
486
+ wantMetrics: map[string]metrix.SampleValue{
487
+ metricKey("system_uptime", systemLabels()): 183845,
488
+ },
489
+ check: func(t *testing.T, _ *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
490
+ assert.Len(t, api.commands, 5)
491
+ },
492
+ },
493
+ {
494
+ name: "reprobes after no-BGP interval",
495
+ setup: func(c *Collector, api *mockAPIClient) {
496
+ api.commands = nil
497
+ c.now = func() time.Time {
498
+ return time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC).Add(noBGPReprobeInterval)
499
+ }
500
+ },
501
+ wantMetrics: map[string]metrix.SampleValue{
502
+ metricKey("system_uptime", systemLabels()): 183845,
503
+ },
504
+ check: func(t *testing.T, _ *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
505
+ assert.Len(t, api.commands, 9)
506
+ },
507
+ },
508
+ },
509
+ },
510
+ "BGP probe errors with empty success do not cache no-BGP": {
511
+ prepare: func(_ *Collector, api *mockAPIClient) {
512
+ api.responses = map[string][]byte{
513
+ legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
514
+ advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
515
+ advancedBGPPeerCommands[2]: []byte(`<response status="success"><result></result></response>`),
516
+ }
517
+ api.errors = map[string]error{
518
+ advancedBGPPeerCommands[0]: errors.New("advanced routing query failed"),
519
+ }
520
+ },
521
+ steps: []collectStep{
522
+ {
523
+ name: "first partial BGP probe failure",
524
+ wantMetrics: map[string]metrix.SampleValue{
525
+ metricKey("system_uptime", systemLabels()): 183845,
526
+ },
527
+ wantLog: []string{"advanced routing query failed"},
528
+ check: assertBGPProbeErrorNotCached,
529
+ },
530
+ {
531
+ name: "second cycle probes again",
532
+ setup: func(_ *Collector, api *mockAPIClient) {
533
+ api.commands = nil
534
+ },
535
+ wantMetrics: map[string]metrix.SampleValue{
536
+ metricKey("system_uptime", systemLabels()): 183845,
537
+ },
538
+ notWantLog: []string{"advanced routing query failed"},
539
+ check: assertBGPProbeErrorNotCached,
540
+ },
541
+ },
542
+ },
543
+ "stale cached BGP command reprobes": {
544
+ prepare: func(c *Collector, api *mockAPIClient) {
545
+ c.routingEngine = routingEngineLegacy
546
+ c.bgpCommand = legacyBGPPeerCommand
547
+ api.responses = map[string][]byte{
548
+ legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
549
+ advancedBGPPeerCommands[0]: dataAdvancedBGPPeers,
550
+ }
551
+ },
552
+ steps: []collectStep{
553
+ {
554
+ name: "empty cached legacy command tries ARE commands",
555
+ wantMetrics: map[string]metrix.SampleValue{
556
+ stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
557
+ },
558
+ check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
559
+ assert.Equal(t, routingEngineAdvanced, c.routingEngine)
560
+ assert.Equal(t, advancedBGPPeerCommands[0], c.bgpCommand)
561
+ assert.Equal(t, []string{
562
+ systemInfoCommand,
563
+ haStateCommand,
564
+ environmentCommand,
565
+ licenseInfoCommand,
566
+ ipsecSACommand,
567
+ legacyBGPPeerCommand,
568
+ advancedBGPPeerCommands[0],
569
+ }, api.commands)
570
+ },
571
+ },
572
+ },
573
+ },
574
+ "stale BGP labels are dropped between cycles": {
575
+ prepare: func(_ *Collector, api *mockAPIClient) {
576
+ api.responses = map[string][]byte{legacyBGPPeerCommand: dataLegacyBGPPeers}
577
+ },
578
+ steps: []collectStep{
579
+ {
580
+ name: "old remote AS",
581
+ wantMetrics: map[string]metrix.SampleValue{
582
+ stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()): 1,
583
+ },
584
+ },
585
+ {
586
+ name: "new remote AS replaces old label set",
587
+ setup: func(_ *Collector, api *mockAPIClient) {
588
+ api.responses[legacyBGPPeerCommand] = []byte(strings.Replace(string(dataLegacyBGPPeers), "<remote-as>65001</remote-as>", "<remote-as>65111</remote-as>", 1))
589
+ },
590
+ wantMetrics: map[string]metrix.SampleValue{
591
+ stateMetricKey("bgp_peer_state", "established", legacyPeerLabelsWithRemoteAS("65111")): 1,
592
+ },
593
+ wantMissing: []string{
594
+ stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()),
595
+ },
596
+ },
597
+ },
598
+ },
599
+ "malformed BGP peer preserves valid peers": {
600
+ prepare: func(_ *Collector, api *mockAPIClient) {
601
+ api.responses = map[string][]byte{
602
+ legacyBGPPeerCommand: []byte(`<response status="success"><result>
603
+ <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
604
+ <entry><peer-address>192.0.2.2</peer-address><status>Established</status><status-duration>120</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
605
+ </result></response>`),
606
+ }
607
+ },
608
+ steps: []collectStep{
609
+ {
610
+ name: "valid peer still emitted",
611
+ wantMetrics: map[string]metrix.SampleValue{
612
+ stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.2")): 1,
613
+ },
614
+ wantMissing: []string{
615
+ stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")),
616
+ },
617
+ wantLog: []string{`BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 msg-total-in: invalid integer`},
618
+ },
619
+ },
620
+ },
621
+ "malformed BGP prefix preserves peer": {
622
+ prepare: func(_ *Collector, api *mockAPIClient) {
623
+ api.responses = map[string][]byte{
624
+ legacyBGPPeerCommand: []byte(`<response status="success"><result>
625
+ <entry>
626
+ <peer-address>192.0.2.1</peer-address>
627
+ <status>Established</status>
628
+ <status-duration>60</status-duration>
629
+ <msg-total-in>10</msg-total-in>
630
+ <msg-total-out>20</msg-total-out>
631
+ <msg-update-in>3</msg-update-in>
632
+ <msg-update-out>4</msg-update-out>
633
+ <status-flap-counts>0</status-flap-counts>
634
+ <established-counts>1</established-counts>
635
+ <prefix-counter>
636
+ <entry name="ipv4-unicast"><incoming-total>abc</incoming-total><incoming-accepted>1</incoming-accepted><incoming-rejected>0</incoming-rejected><outgoing-advertised>2</outgoing-advertised></entry>
637
+ </prefix-counter>
638
+ </entry>
639
+ </result></response>`),
640
+ }
641
+ },
642
+ steps: []collectStep{
643
+ {
644
+ name: "peer metrics survive malformed prefix counter",
645
+ wantMetrics: map[string]metrix.SampleValue{
646
+ stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")): 1,
647
+ metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "default"}): 1,
648
+ },
649
+ wantMissing: []string{
650
+ metricKey("bgp_peer_prefixes_received_total", fallbackPrefixLabels("192.0.2.1", "ipv4", "unicast")),
651
+ },
652
+ wantLog: []string{`BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 ipv4-unicast incoming-total: invalid integer`},
653
+ },
654
+ },
655
+ },
656
+ "advanced BGP second command fallback": {
657
+ prepare: func(_ *Collector, api *mockAPIClient) {
658
+ api.responses = map[string][]byte{
659
+ legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
660
+ advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
661
+ advancedBGPPeerCommands[1]: dataAdvancedBGPPeers,
662
+ }
663
+ },
664
+ steps: []collectStep{
665
+ {
666
+ name: "collects ARE peers from second supported command",
667
+ wantMetrics: map[string]metrix.SampleValue{
668
+ stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
669
+ },
670
+ check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
671
+ assert.Equal(t, routingEngineAdvanced, c.routingEngine)
672
+ assert.Equal(t, advancedBGPPeerCommands[1], c.bgpCommand)
673
+ },
674
+ },
675
+ },
676
+ },
677
+ "advanced BGP third command fallback": {
678
+ prepare: func(_ *Collector, api *mockAPIClient) {
679
+ api.responses = map[string][]byte{
680
+ legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
681
+ advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
682
+ advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
683
+ advancedBGPPeerCommands[2]: dataAdvancedBGPPeers,
684
+ }
685
+ },
686
+ steps: []collectStep{
687
+ {
688
+ name: "collects ARE peers from third supported command",
689
+ wantMetrics: map[string]metrix.SampleValue{
690
+ stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
691
+ },
692
+ check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
693
+ assert.Equal(t, routingEngineAdvanced, c.routingEngine)
694
+ assert.Equal(t, advancedBGPPeerCommands[2], c.bgpCommand)
695
+ },
696
+ },
697
+ },
698
+ },
699
+ "cached BGP command failure reprobes alternate commands": {
700
+ prepare: func(c *Collector, api *mockAPIClient) {
701
+ c.routingEngine = routingEngineLegacy
702
+ c.bgpCommand = legacyBGPPeerCommand
703
+ api.errors = map[string]error{legacyBGPPeerCommand: errors.New("legacy BGP query failed")}
704
+ api.responses = map[string][]byte{
705
+ advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
706
+ advancedBGPPeerCommands[1]: dataAdvancedBGPPeers,
707
+ }
708
+ },
709
+ steps: []collectStep{
710
+ {
711
+ name: "cached command error does not prevent ARE fallback",
712
+ wantMetrics: map[string]metrix.SampleValue{
713
+ stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
714
+ },
715
+ wantLog: []string{"legacy BGP query failed"},
716
+ check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
717
+ assert.Equal(t, routingEngineAdvanced, c.routingEngine)
718
+ assert.Equal(t, advancedBGPPeerCommands[1], c.bgpCommand)
719
+ assert.Equal(t, []string{
720
+ systemInfoCommand,
721
+ haStateCommand,
722
+ environmentCommand,
723
+ licenseInfoCommand,
724
+ ipsecSACommand,
725
+ legacyBGPPeerCommand,
726
+ legacyBGPPeerCommand,
727
+ advancedBGPPeerCommands[0],
728
+ advancedBGPPeerCommands[1],
729
+ }, api.commands)
730
+ },
731
+ },
732
+ },
733
+ },
734
+ "unknown BGP state": {
735
+ prepare: func(_ *Collector, api *mockAPIClient) {
736
+ api.responses = map[string][]byte{
737
+ legacyBGPPeerCommand: []byte(`<response status="success"><result>
738
+ <entry><peer-address>192.0.2.1</peer-address><status>Clearing</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
739
+ </result></response>`),
740
+ }
741
+ },
742
+ steps: []collectStep{
743
+ {
744
+ name: "unrecognized non-empty state maps to unknown",
745
+ wantMetrics: map[string]metrix.SampleValue{
746
+ stateMetricKey("bgp_peer_state", "unknown", fallbackPeerLabels("192.0.2.1")): 1,
747
+ stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")): 0,
748
+ metricKey("bgp_vr_peers_by_state_unknown", metrix.Labels{"vr": "default"}): 1,
749
+ metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "default"}): 1,
750
+ metricKey("bgp_vr_peers_total_established", metrix.Labels{"vr": "default"}): 0,
751
+ },
752
+ },
753
+ },
754
+ },
755
+ "missing optional label values use fallbacks": {
756
+ prepare: func(_ *Collector, api *mockAPIClient) {
757
+ systemInfo := strings.Replace(string(dataSystemInfo), " <sw-version>11.1.2</sw-version>\n", "", 1)
758
+ bgpPeers := strings.Replace(string(dataLegacyBGPPeers), " <peer-group>edge</peer-group>\n", "", 1)
759
+ bgpPeers = strings.Replace(bgpPeers, " <remote-as>65001</remote-as>\n", "", 1)
760
+ api.responses = map[string][]byte{
761
+ systemInfoCommand: []byte(systemInfo),
762
+ licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><expires>June 01, 2026</expires><expired>no</expired></entry></licenses></result></response>`),
763
+ legacyBGPPeerCommand: []byte(bgpPeers),
764
+ }
765
+ },
766
+ steps: []collectStep{
767
+ {
768
+ name: "fallback label values are explicit",
769
+ wantMetrics: map[string]metrix.SampleValue{
770
+ metricKey("system_uptime", metrix.Labels{"hostname": "edge-fw-a", "model": "PA-850", "serial": "0123456789", "sw_version": "unknown"}): 183845,
771
+ stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "unknown")): 1,
772
+ stateMetricKey("bgp_peer_state", "established", metrix.Labels{"vr": "default", "peer_address": "192.0.2.1", "local_address": "192.0.2.254", "remote_as": "unknown_as", "peer_group": "unknown_group"}): 1,
773
+ },
774
+ },
775
+ },
776
+ },
777
+ "system abnormal status states": {
778
+ prepare: func(_ *Collector, api *mockAPIClient) {
779
+ systemInfo := strings.Replace(string(dataSystemInfo), "<device-certificate-status>Valid</device-certificate-status>", "<device-certificate-status>invalid</device-certificate-status>", 1)
780
+ systemInfo = strings.Replace(systemInfo, "<operational-mode>normal</operational-mode>", "<operational-mode>maintenance</operational-mode>", 1)
781
+ api.responses = map[string][]byte{systemInfoCommand: []byte(systemInfo)}
782
+ },
783
+ steps: []collectStep{
784
+ {
785
+ name: "invalid certificate and non-normal mode are explicit states",
786
+ wantMetrics: map[string]metrix.SampleValue{
787
+ stateMetricKey("system_device_certificate_status", "valid", systemLabels()): 0,
788
+ stateMetricKey("system_device_certificate_status", "invalid", systemLabels()): 1,
789
+ stateMetricKey("system_operational_mode", "normal", systemLabels()): 0,
790
+ stateMetricKey("system_operational_mode", "other", systemLabels()): 1,
791
+ },
792
+ },
793
+ },
794
+ },
795
+ "malformed system uptime is partial failure": {
796
+ prepare: func(_ *Collector, api *mockAPIClient) {
797
+ api.responses = map[string][]byte{
798
+ systemInfoCommand: []byte(strings.Replace(string(dataSystemInfo), "<uptime>2 days, 03:04:05</uptime>", "<uptime>soon</uptime>", 1)),
799
+ }
800
+ },
801
+ steps: []collectStep{
802
+ {
803
+ name: "other metricsets commit and system metrics are omitted",
804
+ wantMetrics: map[string]metrix.SampleValue{
805
+ stateMetricKey("ha_status", "enabled", nil): 1,
806
+ },
807
+ wantMissing: []string{metricKey("system_uptime", systemLabels())},
808
+ wantLog: []string{`system uptime: invalid duration`},
809
+ },
810
+ },
811
+ },
812
+ "malformed environment value preserves other metrics": {
813
+ prepare: func(_ *Collector, api *mockAPIClient) {
814
+ api.responses = map[string][]byte{
815
+ environmentCommand: []byte(`<response status="success"><result><thermal><entry><slot>1</slot><description>Temperature Inlet</description><DegreesC>not-a-number</DegreesC><alarm>True</alarm></entry></thermal></result></response>`),
816
+ }
817
+ },
818
+ steps: []collectStep{
819
+ {
820
+ name: "sensor alarm survives bad temperature",
821
+ wantMetrics: map[string]metrix.SampleValue{
822
+ metricKey("system_uptime", systemLabels()): 183845,
823
+ stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("temperature", "1", "Temperature Inlet")): 1,
824
+ stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("temperature", "1", "Temperature Inlet")): 0,
825
+ },
826
+ wantMissing: []string{
827
+ metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
828
+ },
829
+ wantLog: []string{`environment temperature Temperature Inlet: invalid decimal`},
830
+ },
831
+ },
832
+ },
833
+ "environment fan and fans sections are both collected": {
834
+ prepare: func(_ *Collector, api *mockAPIClient) {
835
+ api.responses = map[string][]byte{
836
+ environmentCommand: []byte(`<response status="success"><result>
837
+ <fan>
838
+ <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9000</RPMs><alarm>False</alarm></entry>
839
+ </fan>
840
+ <fans>
841
+ <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9100</RPMs><alarm>False</alarm></entry>
842
+ <entry><slot>2</slot><description>Fan 2 RPM</description><RPMs>9200</RPMs><alarm>True</alarm></entry>
843
+ </fans>
844
+ </result></response>`),
845
+ }
846
+ },
847
+ steps: []collectStep{
848
+ {
849
+ name: "first duplicate fan wins and second fan is collected",
850
+ wantMetrics: map[string]metrix.SampleValue{
851
+ metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")): 9000,
852
+ stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("fan", "1", "Fan 1 RPM")): 1,
853
+ stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("fan", "1", "Fan 1 RPM")): 0,
854
+ metricKey("environment_fan_speed", envLabels("fan", "2", "Fan 2 RPM")): 9200,
855
+ stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("fan", "2", "Fan 2 RPM")): 0,
856
+ stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("fan", "2", "Fan 2 RPM")): 1,
857
+ },
858
+ },
859
+ },
860
+ },
861
+ "malformed power supply alarm preserves presence": {
862
+ prepare: func(_ *Collector, api *mockAPIClient) {
863
+ api.responses = map[string][]byte{
864
+ environmentCommand: []byte(`<response status="success"><result>
865
+ <power-supply>
866
+ <entry><slot>1</slot><description>Power Supply 1</description><Inserted>False</Inserted><alarm>maybe</alarm></entry>
867
+ </power-supply>
868
+ </result></response>`),
869
+ }
870
+ },
871
+ steps: []collectStep{
872
+ {
873
+ name: "presence commits and alarm is omitted",
874
+ wantMetrics: map[string]metrix.SampleValue{
875
+ stateMetricKey("environment_power_supply_presence_status", "present", envLabels("power_supply", "1", "Power Supply 1")): 0,
876
+ stateMetricKey("environment_power_supply_presence_status", "absent", envLabels("power_supply", "1", "Power Supply 1")): 1,
877
+ },
878
+ wantMissing: []string{
879
+ stateMetricKey("environment_power_supply_alarm_status", "clear", envLabels("power_supply", "1", "Power Supply 1")),
880
+ stateMetricKey("environment_power_supply_alarm_status", "alarm", envLabels("power_supply", "1", "Power Supply 1")),
881
+ },
882
+ wantLog: []string{`environment power supply Power Supply 1 alarm: invalid status`},
883
+ },
884
+ },
885
+ },
886
+ "empty environment payload is partial success": {
887
+ prepare: func(_ *Collector, api *mockAPIClient) {
888
+ api.responses = map[string][]byte{
889
+ environmentCommand: []byte(`<response status="success"><result></result></response>`),
890
+ }
891
+ },
892
+ steps: []collectStep{
893
+ {
894
+ name: "system metrics commit and environment metrics are absent",
895
+ wantMetrics: map[string]metrix.SampleValue{
896
+ metricKey("system_uptime", systemLabels()): 183845,
897
+ },
898
+ wantMissing: []string{
899
+ metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")),
900
+ },
901
+ wantLog: []string{
902
+ "environment metricset",
903
+ "expected <thermal>, <fan>, <fans>, <power>, or <power-supply>",
904
+ },
905
+ },
906
+ },
907
+ },
908
+ "HA priority fields are ignored": {
909
+ prepare: func(_ *Collector, api *mockAPIClient) {
910
+ api.responses = map[string][]byte{
911
+ haStateCommand: []byte(strings.Replace(string(dataHAState), "<priority>100</priority>", "<priority>high</priority>", 1)),
912
+ }
913
+ },
914
+ steps: []collectStep{
915
+ {
916
+ name: "malformed priority does not affect HA state collection",
917
+ wantMetrics: map[string]metrix.SampleValue{
918
+ stateMetricKey("ha_status", "enabled", nil): 1,
919
+ stateMetricKey("ha_status", "disabled", nil): 0,
920
+ stateMetricKey("ha_local_state", "active", nil): 1,
921
+ stateMetricKey("ha_peer_state", "passive", nil): 1,
922
+ stateMetricKey("ha_state_sync_status", "synchronized", nil): 1,
923
+ stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
924
+ },
925
+ notWantLog: []string{"PAN-OS partial collection error"},
926
+ },
927
+ },
928
+ },
929
+ "HA disabled emits disabled status": {
930
+ prepare: func(_ *Collector, api *mockAPIClient) {
931
+ api.responses = map[string][]byte{
932
+ haStateCommand: []byte(`<response status="success"><result><enabled>no</enabled></result></response>`),
933
+ }
934
+ },
935
+ steps: []collectStep{
936
+ {
937
+ name: "disabled status commits without HA detail samples",
938
+ wantMetrics: map[string]metrix.SampleValue{
939
+ stateMetricKey("ha_status", "enabled", nil): 0,
940
+ stateMetricKey("ha_status", "disabled", nil): 1,
941
+ },
942
+ wantMissing: []string{
943
+ stateMetricKey("ha_local_state", "unknown", nil),
944
+ stateMetricKey("ha_peer_state", "unknown", nil),
945
+ },
946
+ },
947
+ },
948
+ },
949
+ "missing HA binary status fields are omitted": {
950
+ prepare: func(_ *Collector, api *mockAPIClient) {
951
+ api.responses = map[string][]byte{
952
+ haStateCommand: []byte(`<response status="success"><result>
953
+ <enabled>yes</enabled>
954
+ <group>
955
+ <mode>Active-Passive</mode>
956
+ <local-info>
957
+ <state>active</state>
958
+ <priority>100</priority>
959
+ </local-info>
960
+ <peer-info>
961
+ <state>passive</state>
962
+ <priority>110</priority>
963
+ <conn-ha1-backup>
964
+ <conn-status>down</conn-status>
965
+ </conn-ha1-backup>
966
+ <conn-ha2>
967
+ <conn-status>probing</conn-status>
968
+ </conn-ha2>
969
+ </peer-info>
970
+ </group>
971
+ </result></response>`),
972
+ }
973
+ },
974
+ steps: []collectStep{
975
+ {
976
+ name: "missing peer/sync/link fields produce gaps, explicit down and unknown remain state sets",
977
+ wantMetrics: map[string]metrix.SampleValue{
978
+ stateMetricKey("ha_status", "enabled", nil): 1,
979
+ stateMetricKey("ha_status", "disabled", nil): 0,
980
+ stateMetricKey("ha_local_state", "active", nil): 1,
981
+ stateMetricKey("ha_peer_state", "passive", nil): 1,
982
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha1_backup")): 0,
983
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha1_backup")): 1,
984
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1_backup")): 0,
985
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha2")): 0,
986
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha2")): 0,
987
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha2")): 1,
988
+ },
989
+ wantMissing: []string{
990
+ stateMetricKey("ha_peer_connection_status", "up", nil),
991
+ stateMetricKey("ha_peer_connection_status", "down", nil),
992
+ stateMetricKey("ha_peer_connection_status", "unknown", nil),
993
+ stateMetricKey("ha_state_sync_status", "synchronized", nil),
994
+ stateMetricKey("ha_state_sync_status", "not_synchronized", nil),
995
+ stateMetricKey("ha_state_sync_status", "unknown", nil),
996
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha1")),
997
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha1")),
998
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1")),
999
+ stateMetricKey("ha_link_status", "up", haLinkLabels("ha2_backup")),
1000
+ stateMetricKey("ha_link_status", "down", haLinkLabels("ha2_backup")),
1001
+ stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha2_backup")),
1002
+ },
1003
+ },
1004
+ },
1005
+ },
1006
+ "HA non-happy states are normalized": {
1007
+ prepare: func(_ *Collector, api *mockAPIClient) {
1008
+ api.responses = map[string][]byte{
1009
+ haStateCommand: []byte(`<response status="success"><result>
1010
+ <enabled>yes</enabled>
1011
+ <group>
1012
+ <running-sync>incomplete</running-sync>
1013
+ <local-info><state>suspended</state></local-info>
1014
+ <peer-info>
1015
+ <state>non-functional</state>
1016
+ <conn-status>probing</conn-status>
1017
+ </peer-info>
1018
+ </group>
1019
+ </result></response>`),
1020
+ }
1021
+ },
1022
+ steps: []collectStep{
1023
+ {
1024
+ name: "suspended non-functional and unknown connection states are explicit",
1025
+ wantMetrics: map[string]metrix.SampleValue{
1026
+ stateMetricKey("ha_local_state", "suspended", nil): 1,
1027
+ stateMetricKey("ha_peer_state", "non_functional", nil): 1,
1028
+ stateMetricKey("ha_peer_connection_status", "unknown", nil): 1,
1029
+ stateMetricKey("ha_state_sync_status", "not_synchronized", nil): 1,
1030
+ stateMetricKey("ha_state_sync_status", "synchronized", nil): 0,
1031
+ stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
1032
+ stateMetricKey("ha_peer_connection_status", "up", nil): 0,
1033
+ stateMetricKey("ha_peer_connection_status", "down", nil): 0,
1034
+ stateMetricKey("ha_peer_state", "active", nil): 0,
1035
+ stateMetricKey("ha_peer_state", "passive", nil): 0,
1036
+ stateMetricKey("ha_peer_state", "suspended", nil): 0,
1037
+ stateMetricKey("ha_peer_state", "unknown", nil): 0,
1038
+ stateMetricKey("ha_local_state", "active", nil): 0,
1039
+ stateMetricKey("ha_local_state", "passive", nil): 0,
1040
+ stateMetricKey("ha_local_state", "non_functional", nil): 0,
1041
+ stateMetricKey("ha_local_state", "unknown", nil): 0,
1042
+ },
1043
+ },
1044
+ },
1045
+ },
1046
+ "malformed license expiration does not emit fake never value": {
1047
+ prepare: func(_ *Collector, api *mockAPIClient) {
1048
+ api.responses = map[string][]byte{
1049
+ licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><description>Threat prevention updates</description><expires>tomorrow-ish</expires><expired>no</expired></entry></licenses></result></response>`),
1050
+ }
1051
+ },
1052
+ steps: []collectStep{
1053
+ {
1054
+ name: "status commits and expiration is omitted",
1055
+ wantMetrics: map[string]metrix.SampleValue{
1056
+ metricKey("license_count_total", nil): 1,
1057
+ stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "Threat prevention updates")): 1,
1058
+ },
1059
+ wantMissing: []string{metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates"))},
1060
+ wantLog: []string{`license Threat Prevention expiration: invalid expiration date`},
1061
+ },
1062
+ },
1063
+ },
1064
+ "license expiration edge cases": {
1065
+ prepare: func(c *Collector, api *mockAPIClient) {
1066
+ c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
1067
+ api.responses = map[string][]byte{
1068
+ licenseInfoCommand: []byte(`<response status="success"><result><licenses>
1069
+ <entry><feature>Expires Today</feature><description>today</description><expires>May 02, 2026</expires><expired>no</expired></entry>
1070
+ <entry><feature>Future</feature><description>future</description><expires>June 01, 2026</expires><expired>no</expired></entry>
1071
+ <entry><feature>Never</feature><description>never</description><expires>Never</expires><expired>no</expired></entry>
1072
+ <entry><feature>Explicitly Expired</feature><description>explicit expired</description><expires>April 01, 2026</expires><expired>yes</expired></entry>
1073
+ <entry><feature>Date Expired</feature><description>date expired</description><expires>April 01, 2026</expires></entry>
1074
+ </licenses></result></response>`),
1075
+ }
1076
+ },
1077
+ steps: []collectStep{
1078
+ {
1079
+ name: "expired licenses trigger status only",
1080
+ wantMetrics: map[string]metrix.SampleValue{
1081
+ metricKey("license_count_total", nil): 5,
1082
+ metricKey("license_count_expired", nil): 2,
1083
+ stateMetricKey("license_status", "valid", licenseLabels("Expires Today", "today")): 1,
1084
+ metricKey("license_time_until_expiration", licenseLabels("Expires Today", "today")): 0,
1085
+ metricKey("license_time_until_expiration", licenseLabels("Future", "future")): 30,
1086
+ metricKey("license_time_until_expiration", licenseLabels("Never", "never")): metrix.SampleValue(licenseNeverExpires),
1087
+ stateMetricKey("license_status", "expired", licenseLabels("Explicitly Expired", "explicit expired")): 1,
1088
+ stateMetricKey("license_status", "expired", licenseLabels("Date Expired", "date expired")): 1,
1089
+ },
1090
+ wantMissing: []string{
1091
+ metricKey("license_time_until_expiration", licenseLabels("Explicitly Expired", "explicit expired")),
1092
+ metricKey("license_time_until_expiration", licenseLabels("Date Expired", "date expired")),
1093
+ },
1094
+ },
1095
+ },
1096
+ },
1097
+ "missing licenses payload is partial success": {
1098
+ prepare: func(_ *Collector, api *mockAPIClient) {
1099
+ api.responses = map[string][]byte{
1100
+ licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
1101
+ }
1102
+ },
1103
+ steps: []collectStep{
1104
+ {
1105
+ name: "system commits and license metrics are absent",
1106
+ wantMetrics: map[string]metrix.SampleValue{
1107
+ metricKey("system_uptime", systemLabels()): 183845,
1108
+ },
1109
+ wantMissing: []string{metricKey("license_count_total", nil)},
1110
+ wantLog: []string{
1111
+ "licenses metricset",
1112
+ "expected <licenses>",
1113
+ },
1114
+ },
1115
+ },
1116
+ },
1117
+ "malformed license status omits status dimensions": {
1118
+ prepare: func(c *Collector, api *mockAPIClient) {
1119
+ c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
1120
+ api.responses = map[string][]byte{
1121
+ licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><description>Threat prevention updates</description><expires>June 01, 2026</expires><expired>maybe</expired></entry></licenses></result></response>`),
1122
+ }
1123
+ },
1124
+ steps: []collectStep{
1125
+ {
1126
+ name: "expiration commits and status is omitted",
1127
+ wantMetrics: map[string]metrix.SampleValue{
1128
+ metricKey("license_count_total", nil): 1,
1129
+ metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates")): 30,
1130
+ },
1131
+ wantMissing: []string{
1132
+ stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "Threat prevention updates")),
1133
+ stateMetricKey("license_status", "expired", licenseLabels("Threat Prevention", "Threat prevention updates")),
1134
+ },
1135
+ wantLog: []string{`license Threat Prevention expired status: invalid status`},
1136
+ },
1137
+ },
1138
+ },
1139
+ "malformed IPsec lifetime preserves active tunnel count": {
1140
+ prepare: func(_ *Collector, api *mockAPIClient) {
1141
+ api.responses = map[string][]byte{
1142
+ ipsecSACommand: []byte(`<response status="success"><result><ntun>1</ntun><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>soon</remain><tid>66</tid></entry></entries></result></response>`),
1143
+ }
1144
+ },
1145
+ steps: []collectStep{
1146
+ {
1147
+ name: "bad tunnel lifetime is omitted",
1148
+ wantMetrics: map[string]metrix.SampleValue{metricKey("ipsec_tunnels_active", nil): 1},
1149
+ wantMissing: []string{metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown"))},
1150
+ wantLog: []string{`IPsec tunnel branch-a remain: invalid integer`},
1151
+ },
1152
+ },
1153
+ },
1154
+ "IPsec summary-only response uses ntun": {
1155
+ prepare: func(_ *Collector, api *mockAPIClient) {
1156
+ api.responses = map[string][]byte{
1157
+ ipsecSACommand: []byte(`<response status="success"><result><ntun>2</ntun></result></response>`),
1158
+ }
1159
+ },
1160
+ steps: []collectStep{
1161
+ {
1162
+ name: "active count commits without tunnel instances",
1163
+ wantMetrics: map[string]metrix.SampleValue{metricKey("ipsec_tunnels_active", nil): 2},
1164
+ wantMissing: []string{metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("unknown", "unknown", "unknown", "unknown", "unknown", "unknown"))},
1165
+ },
1166
+ },
1167
+ },
1168
+ "IPsec count mismatch is partial success": {
1169
+ prepare: func(_ *Collector, api *mockAPIClient) {
1170
+ api.responses = map[string][]byte{
1171
+ ipsecSACommand: []byte(`<response status="success"><result><ntun>2</ntun><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>60</remain><tid>66</tid></entry></entries></result></response>`),
1172
+ }
1173
+ },
1174
+ steps: []collectStep{
1175
+ {
1176
+ name: "count and tunnel metrics commit",
1177
+ wantMetrics: map[string]metrix.SampleValue{
1178
+ metricKey("ipsec_tunnels_active", nil): 2,
1179
+ metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown")): 60,
1180
+ },
1181
+ wantLog: []string{"IPsec active tunnel count mismatch: ntun=2 entries=1"},
1182
+ },
1183
+ },
1184
+ },
1185
+ "IPsec entries-only response infers active count": {
1186
+ prepare: func(_ *Collector, api *mockAPIClient) {
1187
+ api.responses = map[string][]byte{
1188
+ ipsecSACommand: []byte(`<response status="success"><result><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>60</remain><tid>66</tid></entry></entries></result></response>`),
1189
+ }
1190
+ },
1191
+ steps: []collectStep{
1192
+ {
1193
+ name: "entries length becomes active tunnel count",
1194
+ wantMetrics: map[string]metrix.SampleValue{
1195
+ metricKey("ipsec_tunnels_active", nil): 1,
1196
+ metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown")): 60,
1197
+ },
1198
+ },
1199
+ },
1200
+ },
1201
+ "malformed IPsec active count is partial failure": {
1202
+ prepare: func(_ *Collector, api *mockAPIClient) {
1203
+ api.responses = map[string][]byte{
1204
+ ipsecSACommand: []byte(`<response status="success"><result><ntun>two</ntun></result></response>`),
1205
+ }
1206
+ },
1207
+ steps: []collectStep{
1208
+ {
1209
+ name: "system commits and IPsec active count is omitted",
1210
+ wantMetrics: map[string]metrix.SampleValue{
1211
+ metricKey("system_uptime", systemLabels()): 183845,
1212
+ },
1213
+ wantMissing: []string{metricKey("ipsec_tunnels_active", nil)},
1214
+ wantLog: []string{`IPsec active tunnel count: invalid integer`},
1215
+ },
1216
+ },
1217
+ },
1218
+ "missing IPsec payload is partial success": {
1219
+ prepare: func(_ *Collector, api *mockAPIClient) {
1220
+ api.responses = map[string][]byte{
1221
+ ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
1222
+ }
1223
+ },
1224
+ steps: []collectStep{
1225
+ {
1226
+ name: "system commits and IPsec metrics are absent",
1227
+ wantMetrics: map[string]metrix.SampleValue{
1228
+ metricKey("system_uptime", systemLabels()): 183845,
1229
+ },
1230
+ wantMissing: []string{metricKey("ipsec_tunnels_active", nil)},
1231
+ wantLog: []string{
1232
+ "ipsec metricset",
1233
+ "expected <ntun> or <entries>",
1234
+ },
1235
+ },
1236
+ },
1237
+ },
1238
+ "all metricsets fail": {
1239
+ prepare: func(_ *Collector, api *mockAPIClient) {
1240
+ api.errors = allCommandErrors(errors.New("api error"))
1241
+ },
1242
+ steps: []collectStep{
1243
+ {
1244
+ name: "public Collect returns an error",
1245
+ wantErr: "api error",
1246
+ notWantLog: []string{
1247
+ "api error",
1248
+ "PAN-OS partial collection error",
1249
+ },
1250
+ },
1251
+ },
1252
+ },
1253
+ }
1254
+
1255
+ for name, tc := range tests {
1256
+ t.Run(name, func(t *testing.T) {
1257
+ collr := New()
1258
+ var logBuf bytes.Buffer
1259
+ collr.Logger = logger.NewWithWriter(&logBuf)
1260
+ api := &mockAPIClient{}
1261
+ collr.apiClient = api
1262
+ if tc.prepare != nil {
1263
+ tc.prepare(collr, api)
1264
+ }
1265
+
1266
+ for _, step := range tc.steps {
1267
+ t.Run(step.name, func(t *testing.T) {
1268
+ if step.setup != nil {
1269
+ step.setup(collr, api)
1270
+ }
1271
+
1272
+ logBuf.Reset()
1273
+ mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1274
+ logOutput := logBuf.String()
1275
+ if step.wantErr != "" {
1276
+ require.ErrorContains(t, err, step.wantErr)
1277
+ assertExpectedLogs(t, logOutput, step.wantLog, step.notWantLog)
1278
+ return
1279
+ }
1280
+ require.NoError(t, err)
1281
+ assertExpectedMetrics(t, mx, step.wantMetrics)
1282
+ assertMissingMetrics(t, mx, step.wantMissing)
1283
+ assertExpectedLogs(t, logOutput, step.wantLog, step.notWantLog)
1284
+ if step.check != nil {
1285
+ step.check(t, collr, api, mx)
1286
+ }
1287
+ })
1288
+ }
1289
+ })
1290
+ }
1291
+}
1292
+
1293
+func TestCollector_Collect_ReturnsMetricsetAPIErrors(t *testing.T) {
1294
+ tests := map[string]struct {
1295
+ command string
1296
+ wantMetric string
1297
+ wantMissing string
1298
+ wantLog string
1299
+ }{
1300
+ "system": {
1301
+ command: systemInfoCommand,
1302
+ wantMetric: stateMetricKey("ha_status", "enabled", nil),
1303
+ wantMissing: metricKey("system_uptime", systemLabels()),
1304
+ wantLog: "system metricset: system info query API call: transport failed",
1305
+ },
1306
+ "ha": {
1307
+ command: haStateCommand,
1308
+ wantMetric: metricKey("system_uptime", systemLabels()),
1309
+ wantMissing: stateMetricKey("ha_status", "enabled", nil),
1310
+ wantLog: "ha metricset: HA state query API call: transport failed",
1311
+ },
1312
+ "environment": {
1313
+ command: environmentCommand,
1314
+ wantMetric: metricKey("system_uptime", systemLabels()),
1315
+ wantMissing: metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
1316
+ wantLog: "environment metricset: environmentals query API call: transport failed",
1317
+ },
1318
+ "licenses": {
1319
+ command: licenseInfoCommand,
1320
+ wantMetric: metricKey("system_uptime", systemLabels()),
1321
+ wantMissing: metricKey("license_count_total", nil),
1322
+ wantLog: "licenses metricset: license info query API call: transport failed",
1323
+ },
1324
+ "ipsec": {
1325
+ command: ipsecSACommand,
1326
+ wantMetric: metricKey("system_uptime", systemLabels()),
1327
+ wantMissing: metricKey("ipsec_tunnels_active", nil),
1328
+ wantLog: "ipsec metricset: IPsec SA query API call: transport failed",
1329
+ },
1330
+ }
1331
+
1332
+ for name, tc := range tests {
1333
+ t.Run(name, func(t *testing.T) {
1334
+ var logBuf bytes.Buffer
1335
+ collr := New()
1336
+ collr.Logger = logger.NewWithWriter(&logBuf)
1337
+ collr.apiClient = &mockAPIClient{
1338
+ errors: map[string]error{tc.command: errors.New("transport failed")},
1339
+ }
1340
+
1341
+ mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1342
+ require.NoError(t, err)
1343
+ assertMetricPresent(t, mx, tc.wantMetric)
1344
+ assertMissingMetrics(t, mx, []string{tc.wantMissing})
1345
+ assert.Contains(t, logBuf.String(), tc.wantLog)
1346
+ })
1347
+ }
1348
+}
1349
+
1350
+func TestCollector_Collect_ReportsMalformedXMLResponse(t *testing.T) {
1351
+ tests := map[string]struct {
1352
+ command string
1353
+ wantMetric string
1354
+ wantMissing string
1355
+ wantLog string
1356
+ }{
1357
+ "system": {
1358
+ command: systemInfoCommand,
1359
+ wantMetric: stateMetricKey("ha_status", "enabled", nil),
1360
+ wantMissing: metricKey("system_uptime", systemLabels()),
1361
+ wantLog: "parse PAN-OS system info response",
1362
+ },
1363
+ "ha": {
1364
+ command: haStateCommand,
1365
+ wantMetric: metricKey("system_uptime", systemLabels()),
1366
+ wantMissing: stateMetricKey("ha_status", "enabled", nil),
1367
+ wantLog: "parse PAN-OS HA response",
1368
+ },
1369
+ "environment": {
1370
+ command: environmentCommand,
1371
+ wantMetric: metricKey("system_uptime", systemLabels()),
1372
+ wantMissing: metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
1373
+ wantLog: "parse PAN-OS environment response",
1374
+ },
1375
+ "licenses": {
1376
+ command: licenseInfoCommand,
1377
+ wantMetric: metricKey("system_uptime", systemLabels()),
1378
+ wantMissing: metricKey("license_count_total", nil),
1379
+ wantLog: "parse PAN-OS licenses response",
1380
+ },
1381
+ "ipsec": {
1382
+ command: ipsecSACommand,
1383
+ wantMetric: metricKey("system_uptime", systemLabels()),
1384
+ wantMissing: metricKey("ipsec_tunnels_active", nil),
1385
+ wantLog: "parse PAN-OS IPsec response",
1386
+ },
1387
+ "bgp": {
1388
+ command: legacyBGPPeerCommand,
1389
+ wantMetric: metricKey("system_uptime", systemLabels()),
1390
+ wantMissing: stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()),
1391
+ wantLog: "parse PAN-OS BGP response",
1392
+ },
1393
+ }
1394
+
1395
+ for name, tc := range tests {
1396
+ t.Run(name, func(t *testing.T) {
1397
+ var logBuf bytes.Buffer
1398
+ collr := New()
1399
+ collr.Logger = logger.NewWithWriter(&logBuf)
1400
+ collr.apiClient = &mockAPIClient{
1401
+ responses: map[string][]byte{tc.command: []byte(`<response status="success"><result><broken></result></response>`)},
1402
+ }
1403
+
1404
+ mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1405
+ require.NoError(t, err)
1406
+ assertMetricPresent(t, mx, tc.wantMetric)
1407
+ assertMissingMetrics(t, mx, []string{tc.wantMissing})
1408
+ assert.Contains(t, logBuf.String(), tc.wantLog)
1409
+ })
1410
+ }
1411
+}
1412
+
1413
+func TestPangoAPIClient_Op(t *testing.T) {
1414
+ tests := map[string]struct {
1415
+ client *pangoAPIClient
1416
+ check func(*testing.T, *pangoAPIClient, *mockPangoOperator, []byte, error)
1417
+ }{
1418
+ "refreshes API key once on unauthorized operation": {
1419
+ client: &pangoAPIClient{
1420
+ client: &mockPangoOperator{
1421
+ responses: []mockPangoResponse{
1422
+ {err: errors.New("code 16: Unauthorized")},
1423
+ {body: []byte("<response status=\"success\"/>")},
1424
+ },
1425
+ },
1426
+ canRefresh: true,
1427
+ },
1428
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1429
+ require.NoError(t, err)
1430
+ assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1431
+ assert.Equal(t, 2, operator.opCalls)
1432
+ assert.Equal(t, 1, operator.refreshCalls)
1433
+ },
1434
+ },
1435
+ "passes vsys to pango operation": {
1436
+ client: &pangoAPIClient{
1437
+ client: &mockPangoOperator{
1438
+ responses: []mockPangoResponse{
1439
+ {body: []byte("<response status=\"success\"/>")},
1440
+ },
1441
+ },
1442
+ vsys: "vsys2",
1443
+ initialized: true,
1444
+ },
1445
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1446
+ require.NoError(t, err)
1447
+ assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1448
+ assert.Equal(t, []string{"vsys2"}, operator.vsys)
1449
+ },
1450
+ },
1451
+ "initialize non unauthorized error is not refreshed": {
1452
+ client: &pangoAPIClient{
1453
+ client: &mockPangoOperator{
1454
+ initializeErr: errors.New("dial tcp failed"),
1455
+ },
1456
+ canRefresh: true,
1457
+ },
1458
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1459
+ require.ErrorContains(t, err, "dial tcp failed")
1460
+ assert.Nil(t, body)
1461
+ assert.Equal(t, 1, operator.initializeCalls)
1462
+ assert.Equal(t, 0, operator.refreshCalls)
1463
+ assert.Equal(t, 0, operator.opCalls)
1464
+ },
1465
+ },
1466
+ "refreshes API key when initialize finds expired key": {
1467
+ client: &pangoAPIClient{
1468
+ client: &mockPangoOperator{
1469
+ initializeErrs: []error{errors.New("code 16: Unauthorized"), nil},
1470
+ responses: []mockPangoResponse{
1471
+ {body: []byte("<response status=\"success\"/>")},
1472
+ },
1473
+ },
1474
+ canRefresh: true,
1475
+ },
1476
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1477
+ require.NoError(t, err)
1478
+ assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1479
+ assert.Equal(t, 2, operator.initializeCalls)
1480
+ assert.Equal(t, 1, operator.opCalls)
1481
+ assert.Equal(t, 1, operator.refreshCalls)
1482
+ },
1483
+ },
1484
+ "refresh failure after unauthorized initialize is returned": {
1485
+ client: &pangoAPIClient{
1486
+ client: &mockPangoOperator{
1487
+ initializeErrs: []error{errors.New("code 16: Unauthorized")},
1488
+ refreshErr: errors.New("refresh failed"),
1489
+ },
1490
+ canRefresh: true,
1491
+ },
1492
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1493
+ require.ErrorContains(t, err, "refresh PAN-OS API key after unauthorized initialization")
1494
+ require.ErrorContains(t, err, "refresh failed")
1495
+ assert.Nil(t, body)
1496
+ assert.Equal(t, 1, operator.initializeCalls)
1497
+ assert.Equal(t, 1, operator.refreshCalls)
1498
+ assert.Equal(t, 0, operator.opCalls)
1499
+ },
1500
+ },
1501
+ "reinitialize failure after refresh is returned": {
1502
+ client: &pangoAPIClient{
1503
+ client: &mockPangoOperator{
1504
+ initializeErrs: []error{errors.New("code 16: Unauthorized"), errors.New("still unauthorized")},
1505
+ },
1506
+ canRefresh: true,
1507
+ },
1508
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1509
+ require.ErrorContains(t, err, "re-initialize PAN-OS API client after key refresh")
1510
+ require.ErrorContains(t, err, "still unauthorized")
1511
+ assert.Nil(t, body)
1512
+ assert.Equal(t, 2, operator.initializeCalls)
1513
+ assert.Equal(t, 1, operator.refreshCalls)
1514
+ assert.Equal(t, 0, operator.opCalls)
1515
+ },
1516
+ },
1517
+ "does not refresh API key on unrelated response code": {
1518
+ client: &pangoAPIClient{
1519
+ client: &mockPangoOperator{
1520
+ responses: []mockPangoResponse{
1521
+ {err: errors.New("code 160: operation failed")},
1522
+ {body: []byte("<response status=\"success\"/>")},
1523
+ },
1524
+ },
1525
+ canRefresh: true,
1526
+ },
1527
+ check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1528
+ require.ErrorContains(t, err, "code 160")
1529
+ assert.Nil(t, body)
1530
+ assert.Equal(t, 1, operator.opCalls)
1531
+ assert.Equal(t, 0, operator.refreshCalls)
1532
+ },
1533
+ },
1534
+ "resets initialization when refresh fails": {
1535
+ client: &pangoAPIClient{
1536
+ client: &mockPangoOperator{
1537
+ responses: []mockPangoResponse{
1538
+ {err: errors.New("code 16: Unauthorized")},
1539
+ },
1540
+ refreshErr: errors.New("refresh failed with key=secret"),
1541
+ },
1542
+ canRefresh: true,
1543
+ initialized: true,
1544
+ },
1545
+ check: func(t *testing.T, client *pangoAPIClient, _ *mockPangoOperator, body []byte, err error) {
1546
+ require.Error(t, err)
1547
+ assert.Nil(t, body)
1548
+ assert.False(t, client.initialized)
1549
+ assert.NotContains(t, err.Error(), "secret")
1550
+ assert.Contains(t, err.Error(), "key=<redacted>")
1551
+ },
1552
+ },
1553
+ }
1554
+
1555
+ for name, tc := range tests {
1556
+ t.Run(name, func(t *testing.T) {
1557
+ operator := tc.client.client.(*mockPangoOperator)
1558
+ body, err := tc.client.op(context.Background(), "cmd")
1559
+ tc.check(t, tc.client, operator, body, err)
1560
+ })
1561
+ }
1562
+}
1563
+
1564
+func TestIsUnauthorizedError(t *testing.T) {
1565
+ tests := map[string]struct {
1566
+ err error
1567
+ want bool
1568
+ }{
1569
+ "nil": {
1570
+ err: nil,
1571
+ want: false,
1572
+ },
1573
+ "unauthorized": {
1574
+ err: errors.New("Unauthorized"),
1575
+ want: true,
1576
+ },
1577
+ "code 16": {
1578
+ err: errors.New("code 16: Unauthorized"),
1579
+ want: true,
1580
+ },
1581
+ "code colon 16": {
1582
+ err: errors.New("code: 16"),
1583
+ want: true,
1584
+ },
1585
+ "code 22": {
1586
+ err: errors.New("code 22: session timed out"),
1587
+ want: true,
1588
+ },
1589
+ "code 403": {
1590
+ err: errors.New("code 403: forbidden"),
1591
+ want: true,
1592
+ },
1593
+ "forbidden": {
1594
+ err: errors.New("forbidden"),
1595
+ want: true,
1596
+ },
1597
+ "session timed out": {
1598
+ err: errors.New("session timed out"),
1599
+ want: true,
1600
+ },
1601
+ "code 160": {
1602
+ err: errors.New("code 160: operation failed"),
1603
+ want: false,
1604
+ },
1605
+ "code 162": {
1606
+ err: errors.New("code 162: operation failed"),
1607
+ want: false,
1608
+ },
1609
+ "connection refused": {
1610
+ err: errors.New("dial tcp 192.0.2.1:443: connect: connection refused"),
1611
+ want: false,
1612
+ },
1613
+ "tls error": {
1614
+ err: errors.New("tls: failed to verify certificate"),
1615
+ want: false,
1616
+ },
1617
+ }
1618
+
1619
+ for name, tc := range tests {
1620
+ t.Run(name, func(t *testing.T) {
1621
+ assert.Equal(t, tc.want, isUnauthorizedError(tc.err))
1622
+ })
1623
+ }
1624
+}
1625
+
1626
+func TestSanitizePANOSAPIError(t *testing.T) {
1627
+ tests := map[string]struct {
1628
+ err error
1629
+ notWant []string
1630
+ want []string
1631
+ }{
1632
+ "password query parameter": {
1633
+ err: errors.New("https://fw.example.invalid/api/?type=keygen&user=netdata&password=secret"),
1634
+ notWant: []string{"netdata", "secret"},
1635
+ want: []string{"type=keygen", "user=<redacted>", "password=<redacted>"},
1636
+ },
1637
+ "username query parameter": {
1638
+ err: errors.New("https://fw.example.invalid/api/?username=netdata&api_key=secret"),
1639
+ notWant: []string{"netdata", "secret"},
1640
+ want: []string{"username=<redacted>", "api_key=<redacted>"},
1641
+ },
1642
+ }
1643
+
1644
+ for name, tc := range tests {
1645
+ t.Run(name, func(t *testing.T) {
1646
+ err := sanitizePANOSAPIError(tc.err)
1647
+ require.Error(t, err)
1648
+ for _, s := range tc.notWant {
1649
+ assert.NotContains(t, err.Error(), s)
1650
+ }
1651
+ for _, s := range tc.want {
1652
+ assert.Contains(t, err.Error(), s)
1653
+ }
1654
+ })
1655
+ }
1656
+}
1657
+
1658
+func TestParseBGPPeers(t *testing.T) {
1659
+ tests := map[string]struct {
1660
+ data []byte
1661
+ wantLen int
1662
+ wantErr string
1663
+ validate func(*testing.T, []bgpPeer)
1664
+ }{
1665
+ "legacy": {
1666
+ data: dataLegacyBGPPeers,
1667
+ wantLen: 2,
1668
+ validate: func(t *testing.T, peers []bgpPeer) {
1669
+ assert.Equal(t, "default", peers[0].VR)
1670
+ assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1671
+ assert.Equal(t, "192.0.2.254", peers[0].LocalAddress)
1672
+ assert.Equal(t, "edge", peers[0].PeerGroup)
1673
+ assert.Equal(t, "65001", peers[0].RemoteAS)
1674
+ assert.Equal(t, "established", peers[0].State)
1675
+ assert.Equal(t, "ipv4", peers[0].PrefixCounters[0].AFI)
1676
+ assert.Equal(t, "unicast", peers[0].PrefixCounters[0].SAFI)
1677
+ assert.Equal(t, "198.51.100.1", peers[1].PeerAddress)
1678
+ assert.Equal(t, "active", peers[1].State)
1679
+ },
1680
+ },
1681
+ "advanced": {
1682
+ data: dataAdvancedBGPPeers,
1683
+ wantLen: 1,
1684
+ validate: func(t *testing.T, peers []bgpPeer) {
1685
+ assert.Equal(t, "lr-a", peers[0].VR)
1686
+ assert.Equal(t, "203.0.113.1", peers[0].PeerAddress)
1687
+ assert.Equal(t, "openconfirm", peers[0].State)
1688
+ assert.Equal(t, int64(93784), peers[0].Uptime)
1689
+ },
1690
+ },
1691
+ "error response with nested lines": {
1692
+ data: []byte(`<response status="error" code="16"><msg><line>Unauthorized</line><line>Invalid API key</line></msg></response>`),
1693
+ wantErr: "Unauthorized; Invalid API key",
1694
+ },
1695
+ "error response with result message": {
1696
+ data: []byte(`<response status="error" code="400"><result><msg>Parameter "format" is required while exporting certificate</msg></result></response>`),
1697
+ wantErr: `Parameter "format" is required while exporting certificate`,
1698
+ },
1699
+ "malformed numeric field fails peer": {
1700
+ data: []byte(`<response status="success"><result><entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry></result></response>`),
1701
+ wantErr: `BGP peer 192.0.2.1 msg-total-in: invalid integer "abc"`,
1702
+ },
1703
+ "missing numeric field fails peer": {
1704
+ data: []byte(`<response status="success"><result><entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>1</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts></entry></result></response>`),
1705
+ wantErr: "BGP peer 192.0.2.1 established-counts: missing integer",
1706
+ },
1707
+ "malformed peer is skipped when another peer is valid": {
1708
+ data: []byte(`<response status="success"><result>
1709
+ <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1710
+ <entry><peer-address>192.0.2.2</peer-address><status>Established</status><status-duration>120</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1711
+ </result></response>`),
1712
+ wantLen: 1,
1713
+ wantErr: `BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 msg-total-in: invalid integer "abc"`,
1714
+ validate: func(t *testing.T, peers []bgpPeer) {
1715
+ assert.Equal(t, "192.0.2.2", peers[0].PeerAddress)
1716
+ assert.Equal(t, int64(120), peers[0].Uptime)
1717
+ },
1718
+ },
1719
+ "malformed prefix counter preserves peer": {
1720
+ data: []byte(`<response status="success"><result>
1721
+ <entry>
1722
+ <peer-address>192.0.2.1</peer-address>
1723
+ <status>Established</status>
1724
+ <status-duration>60</status-duration>
1725
+ <msg-total-in>10</msg-total-in>
1726
+ <msg-total-out>20</msg-total-out>
1727
+ <msg-update-in>3</msg-update-in>
1728
+ <msg-update-out>4</msg-update-out>
1729
+ <status-flap-counts>0</status-flap-counts>
1730
+ <established-counts>1</established-counts>
1731
+ <prefix-counter>
1732
+ <entry name="ipv4-unicast"><incoming-total>bad</incoming-total><incoming-accepted>1</incoming-accepted><incoming-rejected>0</incoming-rejected><outgoing-advertised>2</outgoing-advertised></entry>
1733
+ <entry name="ipv6-unicast"><incoming-total>7</incoming-total><incoming-accepted>6</incoming-accepted><incoming-rejected>1</incoming-rejected><outgoing-advertised>3</outgoing-advertised></entry>
1734
+ </prefix-counter>
1735
+ </entry>
1736
+ </result></response>`),
1737
+ wantLen: 1,
1738
+ wantErr: `BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 ipv4-unicast incoming-total: invalid integer "bad"`,
1739
+ validate: func(t *testing.T, peers []bgpPeer) {
1740
+ assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1741
+ require.Len(t, peers[0].PrefixCounters, 1)
1742
+ assert.Equal(t, "ipv6", peers[0].PrefixCounters[0].AFI)
1743
+ assert.Equal(t, "unicast", peers[0].PrefixCounters[0].SAFI)
1744
+ assert.Equal(t, int64(7), peers[0].PrefixCounters[0].IncomingTotal)
1745
+ },
1746
+ },
1747
+ "deduplicates same vr and peer": {
1748
+ data: []byte(`<response status="success"><result>
1749
+ <entry><vr>default</vr><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1750
+ <entry><vr>default</vr><peer-address>192.0.2.1</peer-address><status>Active</status><status-duration>120</status-duration><msg-total-in>11</msg-total-in><msg-total-out>21</msg-total-out><msg-update-in>4</msg-update-in><msg-update-out>5</msg-update-out><status-flap-counts>1</status-flap-counts><established-counts>2</established-counts></entry>
1751
+ </result></response>`),
1752
+ wantLen: 1,
1753
+ validate: func(t *testing.T, peers []bgpPeer) {
1754
+ assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1755
+ assert.Equal(t, "established", peers[0].State)
1756
+ assert.Equal(t, int64(10), peers[0].MessagesIn)
1757
+ },
1758
+ },
1759
+ "uses peer name when peer address is missing": {
1760
+ data: []byte(`<response status="success"><result>
1761
+ <entry name="peer-a"><state>Established</state><uptime>60</uptime><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><flap-count>0</flap-count><established-counts>1</established-counts></entry>
1762
+ </result></response>`),
1763
+ wantLen: 1,
1764
+ validate: func(t *testing.T, peers []bgpPeer) {
1765
+ assert.Equal(t, "peer-a", peers[0].PeerAddress)
1766
+ assert.Equal(t, "established", peers[0].State)
1767
+ },
1768
+ },
1769
+ "attribute-only peer fields": {
1770
+ data: []byte(`<response status="success"><result>
1771
+ <entry peer-address="192.0.2.4" vr="vr-a" peer-group="edge" remote-as="65010"><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1772
+ </result></response>`),
1773
+ wantLen: 1,
1774
+ validate: func(t *testing.T, peers []bgpPeer) {
1775
+ assert.Equal(t, "vr-a", peers[0].VR)
1776
+ assert.Equal(t, "192.0.2.4", peers[0].PeerAddress)
1777
+ assert.Equal(t, "edge", peers[0].PeerGroup)
1778
+ assert.Equal(t, "65010", peers[0].RemoteAS)
1779
+ },
1780
+ },
1781
+ "prefix counters without afi safi use unknown family": {
1782
+ data: []byte(`<response status="success"><result>
1783
+ <entry>
1784
+ <peer-address>192.0.2.1</peer-address>
1785
+ <status>Established</status>
1786
+ <status-duration>60</status-duration>
1787
+ <msg-total-in>10</msg-total-in>
1788
+ <msg-total-out>20</msg-total-out>
1789
+ <msg-update-in>3</msg-update-in>
1790
+ <msg-update-out>4</msg-update-out>
1791
+ <status-flap-counts>0</status-flap-counts>
1792
+ <established-counts>1</established-counts>
1793
+ <prefix-counter>
1794
+ <entry><incoming-total>7</incoming-total><incoming-accepted>6</incoming-accepted><incoming-rejected>1</incoming-rejected><outgoing-advertised>3</outgoing-advertised></entry>
1795
+ </prefix-counter>
1796
+ </entry>
1797
+ </result></response>`),
1798
+ wantLen: 1,
1799
+ validate: func(t *testing.T, peers []bgpPeer) {
1800
+ require.Len(t, peers[0].PrefixCounters, 1)
1801
+ assert.Equal(t, "unknown", peers[0].PrefixCounters[0].AFI)
1802
+ assert.Equal(t, "unknown", peers[0].PrefixCounters[0].SAFI)
1803
+ assert.Equal(t, int64(7), peers[0].PrefixCounters[0].IncomingTotal)
1804
+ },
1805
+ },
1806
+ "container entries without peer data are skipped": {
1807
+ data: []byte(`<response status="success"><result>
1808
+ <entry name="default">
1809
+ <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1810
+ </entry>
1811
+ </result></response>`),
1812
+ wantLen: 1,
1813
+ validate: func(t *testing.T, peers []bgpPeer) {
1814
+ assert.Equal(t, "default", peers[0].VR)
1815
+ assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1816
+ },
1817
+ },
1818
+ "deep nesting beyond limit is truncated": {
1819
+ data: deepNestedBGPPeerXML(maxBGPPeerEntryDepth + 1),
1820
+ validate: func(t *testing.T, peers []bgpPeer) {
1821
+ assert.Empty(t, peers)
1822
+ },
1823
+ },
1824
+ "placeholder uptime fails peer": {
1825
+ data: []byte(`<response status="success"><result>
1826
+ <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>n/a</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1827
+ </result></response>`),
1828
+ wantErr: `BGP peer 192.0.2.1 uptime: invalid duration "n/a"`,
1829
+ },
1830
+ "zero uptime is accepted": {
1831
+ data: []byte(`<response status="success"><result>
1832
+ <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>0</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1833
+ </result></response>`),
1834
+ wantLen: 1,
1835
+ validate: func(t *testing.T, peers []bgpPeer) {
1836
+ assert.Equal(t, int64(0), peers[0].Uptime)
1837
+ },
1838
+ },
1839
+ }
1840
+
1841
+ for name, tc := range tests {
1842
+ t.Run(name, func(t *testing.T) {
1843
+ peers, err := parseBGPPeers(tc.data)
1844
+ if tc.wantErr != "" {
1845
+ require.ErrorContains(t, err, tc.wantErr)
1846
+ } else {
1847
+ require.NoError(t, err)
1848
+ }
1849
+ if tc.wantLen > 0 {
1850
+ require.Len(t, peers, tc.wantLen)
1851
+ }
1852
+ if tc.validate != nil {
1853
+ tc.validate(t, peers)
1854
+ }
1855
+ })
1856
+ }
1857
+}
1858
+
1859
+func TestParseReadOnlyTelemetry(t *testing.T) {
1860
+ tests := map[string]func(*testing.T){
1861
+ "system": func(t *testing.T) {
1862
+ system, err := parseSystemInfo(dataSystemInfo)
1863
+ require.NoError(t, err)
1864
+ assert.Equal(t, "edge-fw-a", system.Hostname)
1865
+ assert.Equal(t, "11.1.2", system.SWVersion)
1866
+ },
1867
+ "ha": func(t *testing.T) {
1868
+ ha, err := parseHAState(dataHAState)
1869
+ require.NoError(t, err)
1870
+ assert.Equal(t, "yes", ha.Enabled)
1871
+ assert.Equal(t, "active", normalizeHAState(ha.Group.LocalInfo.State))
1872
+ assert.Equal(t, "passive", normalizeHAState(ha.Group.PeerInfo.State))
1873
+ },
1874
+ "environment": func(t *testing.T) {
1875
+ env, err := parseEnvironment(dataEnvironment)
1876
+ require.NoError(t, err)
1877
+ require.Len(t, env.ThermalEntries, 1)
1878
+ require.Len(t, env.FanEntries, 1)
1879
+ require.Len(t, env.VoltageEntries, 1)
1880
+ require.Len(t, env.PowerSupplyEntries, 1)
1881
+ assert.Equal(t, "Temperature Inlet", env.ThermalEntries[0].Description)
1882
+ assert.Equal(t, "3.332", env.VoltageEntries[0].Volts)
1883
+ },
1884
+ "environment fan and fans": func(t *testing.T) {
1885
+ env, err := parseEnvironment([]byte(`<response status="success"><result>
1886
+ <fan>
1887
+ <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9000</RPMs><alarm>False</alarm></entry>
1888
+ </fan>
1889
+ <fans>
1890
+ <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9100</RPMs><alarm>False</alarm></entry>
1891
+ <entry><slot>2</slot><description>Fan 2 RPM</description><RPMs>9200</RPMs><alarm>True</alarm></entry>
1892
+ </fans>
1893
+ </result></response>`))
1894
+ require.NoError(t, err)
1895
+ require.Len(t, env.FanEntries, 2)
1896
+ assert.Equal(t, "Fan 1 RPM", env.FanEntries[0].Description)
1897
+ assert.Equal(t, "9000", env.FanEntries[0].RPMs)
1898
+ assert.Equal(t, "Fan 2 RPM", env.FanEntries[1].Description)
1899
+ assert.Equal(t, "9200", env.FanEntries[1].RPMs)
1900
+ },
1901
+ "licenses": func(t *testing.T) {
1902
+ licenses, found, err := parseLicenses(dataLicenses)
1903
+ require.NoError(t, err)
1904
+ assert.True(t, found)
1905
+ require.Len(t, licenses, 3)
1906
+ assert.Equal(t, "Threat Prevention", licenses[0].Feature)
1907
+ },
1908
+ "ipsec": func(t *testing.T) {
1909
+ ipsecPayload, err := parseIPSecTunnels(dataIPSecSA)
1910
+ require.NoError(t, err)
1911
+ assert.True(t, ipsecPayload.found)
1912
+ assert.True(t, ipsecPayload.entriesFound)
1913
+ assert.Equal(t, int64(2), ipsecPayload.activeCount)
1914
+ require.Len(t, ipsecPayload.tunnels, 2)
1915
+ assert.Equal(t, "branch-a", ipsecPayload.tunnels[0].Name)
1916
+ },
1917
+ }
1918
+
1919
+ for name, run := range tests {
1920
+ t.Run(name, run)
1921
+ }
1922
+}
1923
+
1924
+func TestParserHelpers(t *testing.T) {
1925
+ t.Run("normalize BGP state", func(t *testing.T) {
1926
+ tests := map[string]string{
1927
+ "Established": "established",
1928
+ "OpenConfirm": "openconfirm",
1929
+ "Open-Sent": "opensent",
1930
+ "Active": "active",
1931
+ "Connect": "connect",
1932
+ "Idle": "idle",
1933
+ "unknown-state": "unknown",
1934
+ "": "",
1935
+ }
1936
+ for in, want := range tests {
1937
+ assert.Equal(t, want, normalizeBGPState(in), in)
1938
+ }
1939
+ })
1940
+
1941
+ t.Run("parse PAN-OS duration", func(t *testing.T) {
1942
+ tests := map[string]int64{
1943
+ "3600": 3600,
1944
+ "0": 0,
1945
+ "01:00:00": 3600,
1946
+ "1 days 02:03:04": 93784,
1947
+ "30m": 1800,
1948
+ "2 mins": 120,
1949
+ "5 secs": 5,
1950
+ "2 hours 5 seconds": 7205,
1951
+ "": 0,
1952
+ }
1953
+ for in, want := range tests {
1954
+ got, err := parsePANOSDurationField("duration", in)
1955
+ require.NoError(t, err, in)
1956
+ assert.Equal(t, want, got, in)
1957
+ }
1958
+ })
1959
+
1960
+ t.Run("strict parsers report malformed values", func(t *testing.T) {
1961
+ tests := map[string]struct {
1962
+ parse func() error
1963
+ wantErr string
1964
+ }{
1965
+ "invalid integer": {
1966
+ parse: func() error {
1967
+ _, err := parsePANOSIntField("test integer", "not-an-int")
1968
+ return err
1969
+ },
1970
+ wantErr: `test integer: invalid integer "not-an-int"`,
1971
+ },
1972
+ "missing integer": {
1973
+ parse: func() error {
1974
+ _, err := parseRequiredPANOSIntField("test integer", "")
1975
+ return err
1976
+ },
1977
+ wantErr: "test integer: missing integer",
1978
+ },
1979
+ "invalid decimal": {
1980
+ parse: func() error {
1981
+ _, err := parsePANOSDecimalField("test decimal", "not-a-decimal", 1000)
1982
+ return err
1983
+ },
1984
+ wantErr: `test decimal: invalid decimal "not-a-decimal"`,
1985
+ },
1986
+ "missing decimal": {
1987
+ parse: func() error {
1988
+ _, err := parseRequiredPANOSDecimalField("test decimal", "", 1000)
1989
+ return err
1990
+ },
1991
+ wantErr: "test decimal: missing decimal",
1992
+ },
1993
+ "invalid duration": {
1994
+ parse: func() error {
1995
+ _, err := parsePANOSDurationField("test duration", "since reboot")
1996
+ return err
1997
+ },
1998
+ wantErr: `test duration: invalid duration "since reboot"`,
1999
+ },
2000
+ "missing duration": {
2001
+ parse: func() error {
2002
+ _, err := parseRequiredPANOSDurationField("test duration", "")
2003
+ return err
2004
+ },
2005
+ wantErr: "test duration: missing duration",
2006
+ },
2007
+ "invalid clock duration": {
2008
+ parse: func() error {
2009
+ _, err := parsePANOSDurationField("test duration", "01:99:00")
2010
+ return err
2011
+ },
2012
+ wantErr: `test duration: invalid duration "01:99:00"`,
2013
+ },
2014
+ "placeholder duration never": {
2015
+ parse: func() error {
2016
+ _, err := parsePANOSDurationField("test duration", "never")
2017
+ return err
2018
+ },
2019
+ wantErr: `test duration: invalid duration "never"`,
2020
+ },
2021
+ "placeholder duration dash": {
2022
+ parse: func() error {
2023
+ _, err := parsePANOSDurationField("test duration", "-")
2024
+ return err
2025
+ },
2026
+ wantErr: `test duration: invalid duration "-"`,
2027
+ },
2028
+ "placeholder duration n/a": {
2029
+ parse: func() error {
2030
+ _, err := parsePANOSDurationField("test duration", "n/a")
2031
+ return err
2032
+ },
2033
+ wantErr: `test duration: invalid duration "n/a"`,
2034
+ },
2035
+ }
2036
+ for name, tc := range tests {
2037
+ t.Run(name, func(t *testing.T) {
2038
+ assert.EqualError(t, tc.parse(), tc.wantErr)
2039
+ })
2040
+ }
2041
+ })
2042
+
2043
+ t.Run("normalize address", func(t *testing.T) {
2044
+ tests := map[string]string{
2045
+ "192.0.2.1:179": "192.0.2.1",
2046
+ "192.0.2.1": "192.0.2.1",
2047
+ "[2001:db8::1]:179": "2001:db8::1",
2048
+ "2001:db8::1": "2001:db8::1",
2049
+ "[2001:db8::1]": "2001:db8::1",
2050
+ "fw.example.invalid": "fw.example.invalid",
2051
+ "example.invalid:179": "example.invalid",
2052
+ "example.invalid:bgp": "example.invalid",
2053
+ }
2054
+ for in, want := range tests {
2055
+ assert.Equal(t, want, normalizeAddress(in), in)
2056
+ }
2057
+ })
2058
+
2059
+ t.Run("normalize AFI SAFI", func(t *testing.T) {
2060
+ tests := map[string]struct {
2061
+ wantAFI string
2062
+ wantSAFI string
2063
+ }{
2064
+ "bgpAfiIpv4-unicast": {wantAFI: "ipv4", wantSAFI: "unicast"},
2065
+ "ipv6-unicast": {wantAFI: "ipv6", wantSAFI: "unicast"},
2066
+ }
2067
+ for in, want := range tests {
2068
+ afi, safi := normalizeAFISAFI(in)
2069
+ assert.Equal(t, want.wantAFI, afi, in)
2070
+ assert.Equal(t, want.wantSAFI, safi, in)
2071
+ }
2072
+ })
2073
+
2074
+ t.Run("PAN-OS response code names", func(t *testing.T) {
2075
+ tests := map[string]string{
2076
+ "1": "Unknown command",
2077
+ "6": "Bad XPath",
2078
+ "16": "Unauthorized",
2079
+ "22": "Session timed out",
2080
+ "400": "Bad request",
2081
+ " 403 ": "Forbidden",
2082
+ "unknown": "",
2083
+ }
2084
+ for code, want := range tests {
2085
+ assert.Equal(t, want, panosResponseCodeName(code), code)
2086
+ }
2087
+ })
2088
+}
2089
+
2090
+func TestParseAPIURL(t *testing.T) {
2091
+ tests := map[string]struct {
2092
+ raw string
2093
+ want panosAPIURL
2094
+ wantFail bool
2095
+ }{
2096
+ "https host": {
2097
+ raw: "https://192.0.2.1",
2098
+ want: panosAPIURL{
2099
+ protocol: "https",
2100
+ hostname: "192.0.2.1",
2101
+ },
2102
+ },
2103
+ "http port api path": {
2104
+ raw: "http://fw.example.invalid:8443/api",
2105
+ want: panosAPIURL{
2106
+ protocol: "http",
2107
+ hostname: "fw.example.invalid",
2108
+ port: 8443,
2109
+ },
2110
+ },
2111
+ "ipv4 port api path": {
2112
+ raw: "https://192.0.2.1:8443/api",
2113
+ want: panosAPIURL{
2114
+ protocol: "https",
2115
+ hostname: "192.0.2.1",
2116
+ port: 8443,
2117
+ },
2118
+ },
2119
+ "ipv6": {
2120
+ raw: "https://[2001:db8::1]/",
2121
+ want: panosAPIURL{
2122
+ protocol: "https",
2123
+ hostname: "[2001:db8::1]",
2124
+ },
2125
+ },
2126
+ "ipv6 port": {
2127
+ raw: "https://[2001:db8::1]:8443/api",
2128
+ want: panosAPIURL{
2129
+ protocol: "https",
2130
+ hostname: "[2001:db8::1]",
2131
+ port: 8443,
2132
+ },
2133
+ },
2134
+ "bad scheme": {
2135
+ raw: "ftp://192.0.2.1",
2136
+ wantFail: true,
2137
+ },
2138
+ "embedded credentials": {
2139
+ raw: "https://user:pass@192.0.2.1",
2140
+ wantFail: true,
2141
+ },
2142
+ "bad path": {
2143
+ raw: "https://192.0.2.1/other",
2144
+ wantFail: true,
2145
+ },
2146
+ "query": {
2147
+ raw: "https://192.0.2.1/api?type=keygen",
2148
+ wantFail: true,
2149
+ },
2150
+ "fragment": {
2151
+ raw: "https://192.0.2.1/api#fragment",
2152
+ wantFail: true,
2153
+ },
2154
+ "port zero": {
2155
+ raw: "https://192.0.2.1:0/api",
2156
+ wantFail: true,
2157
+ },
2158
+ "port greater than max": {
2159
+ raw: "https://192.0.2.1:65536/api",
2160
+ wantFail: true,
2161
+ },
2162
+ "non numeric port": {
2163
+ raw: "https://192.0.2.1:not-a-port/api",
2164
+ wantFail: true,
2165
+ },
2166
+ }
2167
+
2168
+ for name, test := range tests {
2169
+ t.Run(name, func(t *testing.T) {
2170
+ got, err := parseAPIURL(test.raw)
2171
+ if test.wantFail {
2172
+ assert.Error(t, err)
2173
+ } else {
2174
+ require.NoError(t, err)
2175
+ assert.Equal(t, test.want, got)
2176
+ }
2177
+ })
2178
+ }
2179
+}
2180
+
2181
+func assertBGPProbeErrorNotCached(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
2182
+ t.Helper()
2183
+ assert.Equal(t, routingEngineUnknown, c.routingEngine)
2184
+ assert.True(t, c.noBGPProbedAt.IsZero())
2185
+ assert.Len(t, api.commands, 9)
2186
+}
2187
+
2188
+func collectOnceWithContext(t *testing.T, c *Collector, ctx context.Context) error {
2189
+ t.Helper()
2190
+
2191
+ managed, ok := metrix.AsCycleManagedStore(c.MetricStore())
2192
+ require.True(t, ok)
2193
+
2194
+ cycle := managed.CycleController()
2195
+ committed := false
2196
+ cycle.BeginCycle()
2197
+ defer func() {
2198
+ if !committed {
2199
+ cycle.AbortCycle()
2200
+ }
2201
+ }()
2202
+
2203
+ if err := c.Collect(ctx); err != nil {
2204
+ return err
2205
+ }
2206
+ require.NoError(t, cycle.CommitCycleSuccess())
2207
+ committed = true
2208
+ return nil
2209
+}
2210
+
2211
+func allCommandErrors(err error) map[string]error {
2212
+ return map[string]error{
2213
+ systemInfoCommand: err,
2214
+ haStateCommand: err,
2215
+ environmentCommand: err,
2216
+ licenseInfoCommand: err,
2217
+ ipsecSACommand: err,
2218
+ legacyBGPPeerCommand: err,
2219
+ advancedBGPPeerCommands[0]: err,
2220
+ advancedBGPPeerCommands[1]: err,
2221
+ advancedBGPPeerCommands[2]: err,
2222
+ }
2223
+}
2224
+
2225
+func assertExpectedMetrics(t *testing.T, got map[string]metrix.SampleValue, want map[string]metrix.SampleValue) {
2226
+ t.Helper()
2227
+ for key, wantValue := range want {
2228
+ gotValue, ok := got[key]
2229
+ require.True(t, ok, "metric %s", key)
2230
+ assert.Equal(t, wantValue, gotValue, key)
2231
+ }
2232
+}
2233
+
2234
+func assertMetricPresent(t *testing.T, got map[string]metrix.SampleValue, key string) {
2235
+ t.Helper()
2236
+ _, ok := got[key]
2237
+ require.True(t, ok, "metric %s", key)
2238
+}
2239
+
2240
+func assertMissingMetrics(t *testing.T, got map[string]metrix.SampleValue, missing []string) {
2241
+ t.Helper()
2242
+ for _, key := range missing {
2243
+ _, ok := got[key]
2244
+ assert.False(t, ok, "metric %s", key)
2245
+ }
2246
+}
2247
+
2248
+func assertExpectedLogs(t *testing.T, got string, want, notWant []string) {
2249
+ t.Helper()
2250
+ for _, text := range want {
2251
+ assert.Contains(t, got, text)
2252
+ }
2253
+ for _, text := range notWant {
2254
+ assert.NotContains(t, got, text)
2255
+ }
2256
+}
2257
+
2258
+func metricKey(name string, labels metrix.Labels) string {
2259
+ if len(labels) == 0 {
2260
+ return name
2261
+ }
2262
+
2263
+ keys := make([]string, 0, len(labels))
2264
+ for key := range labels {
2265
+ keys = append(keys, key)
2266
+ }
2267
+ sort.Strings(keys)
2268
+
2269
+ var b strings.Builder
2270
+ b.WriteString(name)
2271
+ b.WriteByte('{')
2272
+ for i, key := range keys {
2273
+ if i > 0 {
2274
+ b.WriteByte(',')
2275
+ }
2276
+ b.WriteString(key)
2277
+ b.WriteByte('=')
2278
+ b.WriteString(strconv.Quote(labels[key]))
2279
+ }
2280
+ b.WriteByte('}')
2281
+ return b.String()
2282
+}
2283
+
2284
+func stateMetricKey(name, state string, labels metrix.Labels) string {
2285
+ return metricKey(name, stateLabels(name, state, labels))
2286
+}
2287
+
2288
+func stateLabels(name, state string, labels metrix.Labels) metrix.Labels {
2289
+ out := make(metrix.Labels, len(labels)+1)
2290
+ maps.Copy(out, labels)
2291
+ out[name] = state
2292
+ return out
2293
+}
2294
+
2295
+func systemLabels() metrix.Labels {
2296
+ return metrix.Labels{"hostname": "edge-fw-a", "model": "PA-850", "serial": "0123456789", "sw_version": "11.1.2"}
2297
+}
2298
+
2299
+func envLabels(sensorType, slot, sensor string) metrix.Labels {
2300
+ return metrix.Labels{"sensor_type": sensorType, "slot": slot, "sensor": sensor}
2301
+}
2302
+
2303
+func haLinkLabels(link string) metrix.Labels {
2304
+ return metrix.Labels{"link": link}
2305
+}
2306
+
2307
+func licenseLabels(feature, description string) metrix.Labels {
2308
+ return metrix.Labels{"feature": feature, "description": description}
2309
+}
2310
+
2311
+func ipsecLabels(tunnel, gateway, remote, tunnelID, protocol, encryption string) metrix.Labels {
2312
+ return metrix.Labels{
2313
+ "tunnel": tunnel,
2314
+ "gateway": gateway,
2315
+ "remote": remote,
2316
+ "tunnel_id": tunnelID,
2317
+ "protocol": protocol,
2318
+ "encryption": encryption,
2319
+ }
2320
+}
2321
+
2322
+func legacyPeerLabels() metrix.Labels {
2323
+ return legacyPeerLabelsWithRemoteAS("65001")
2324
+}
2325
+
2326
+func legacyPeerLabelsWithRemoteAS(remoteAS string) metrix.Labels {
2327
+ return metrix.Labels{
2328
+ "vr": "default",
2329
+ "peer_address": "192.0.2.1",
2330
+ "local_address": "192.0.2.254",
2331
+ "remote_as": remoteAS,
2332
+ "peer_group": "edge",
2333
+ }
2334
+}
2335
+
2336
+func advancedPeerLabels() metrix.Labels {
2337
+ return metrix.Labels{
2338
+ "vr": "lr-a",
2339
+ "peer_address": "203.0.113.1",
2340
+ "local_address": "203.0.113.254",
2341
+ "remote_as": "65100",
2342
+ "peer_group": "core",
2343
+ }
2344
+}
2345
+
2346
+func advancedPrefixLabels(afi, safi string) metrix.Labels {
2347
+ labels := advancedPeerLabels()
2348
+ labels["afi"] = afi
2349
+ labels["safi"] = safi
2350
+ return labels
2351
+}
2352
+
2353
+func fallbackPeerLabels(peerAddress string) metrix.Labels {
2354
+ return metrix.Labels{
2355
+ "vr": "default",
2356
+ "peer_address": peerAddress,
2357
+ "local_address": "unknown",
2358
+ "remote_as": "unknown_as",
2359
+ "peer_group": "unknown_group",
2360
+ }
2361
+}
2362
+
2363
+func fallbackPrefixLabels(peerAddress, afi, safi string) metrix.Labels {
2364
+ labels := fallbackPeerLabels(peerAddress)
2365
+ labels["afi"] = afi
2366
+ labels["safi"] = safi
2367
+ return labels
2368
+}
2369
+
2370
+func deepNestedBGPPeerXML(depth int) []byte {
2371
+ var b strings.Builder
2372
+ b.WriteString(`<response status="success"><result>`)
2373
+ for i := range depth {
2374
+ b.WriteString(`<entry name="container`)
2375
+ b.WriteString(strconv.Itoa(i))
2376
+ b.WriteString(`">`)
2377
+ }
2378
+ b.WriteString(`<entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>`)
2379
+ for range depth {
2380
+ b.WriteString(`</entry>`)
2381
+ }
2382
+ b.WriteString(`</result></response>`)
2383
+ return []byte(b.String())
2384
+}
2385
+
2386
+type mockAPIClient struct {
2387
+ responses map[string][]byte
2388
+ errors map[string]error
2389
+ commands []string
2390
+ info map[string]string
2391
+ closeCalls int
2392
+ onOp func(context.Context, string)
2393
+}
2394
+
2395
+func (m *mockAPIClient) op(ctx context.Context, cmd string) ([]byte, error) {
2396
+ m.commands = append(m.commands, cmd)
2397
+ if m.onOp != nil {
2398
+ m.onOp(ctx, cmd)
2399
+ }
2400
+ if err := m.errors[cmd]; err != nil {
2401
+ return nil, err
2402
+ }
2403
+ if resp := m.responses[cmd]; resp != nil {
2404
+ return resp, nil
2405
+ }
2406
+ switch cmd {
2407
+ case systemInfoCommand:
2408
+ return dataSystemInfo, nil
2409
+ case haStateCommand:
2410
+ return dataHAState, nil
2411
+ case environmentCommand:
2412
+ return dataEnvironment, nil
2413
+ case licenseInfoCommand:
2414
+ return dataLicenses, nil
2415
+ case ipsecSACommand:
2416
+ return dataIPSecSA, nil
2417
+ case legacyBGPPeerCommand, advancedBGPPeerCommands[0], advancedBGPPeerCommands[1], advancedBGPPeerCommands[2]:
2418
+ return []byte(`<response status="success"><result></result></response>`), nil
2419
+ default:
2420
+ return []byte(`<response status="success"><result></result></response>`), nil
2421
+ }
2422
+}
2423
+
2424
+func (m *mockAPIClient) closeIdleConnections() { m.closeCalls++ }
2425
+
2426
+func (m *mockAPIClient) systemInfo() map[string]string { return m.info }
2427
+
2428
+type mockPangoResponse struct {
2429
+ body []byte
2430
+ err error
2431
+}
2432
+
2433
+type mockPangoOperator struct {
2434
+ initializeErr error
2435
+ initializeErrs []error
2436
+ refreshErr error
2437
+ initializeCalls int
2438
+ responses []mockPangoResponse
2439
+ info map[string]string
2440
+ vsys []string
2441
+ opCalls int
2442
+ refreshCalls int
2443
+}
2444
+
2445
+func (m *mockPangoOperator) Initialize() error {
2446
+ if len(m.initializeErrs) > 0 {
2447
+ err := m.initializeErrs[min(m.initializeCalls, len(m.initializeErrs)-1)]
2448
+ m.initializeCalls++
2449
+ return err
2450
+ }
2451
+ m.initializeCalls++
2452
+ return m.initializeErr
2453
+}
2454
+
2455
+func (m *mockPangoOperator) Op(_ any, vsys string, _, _ any) ([]byte, error) {
2456
+ m.vsys = append(m.vsys, vsys)
2457
+ resp := m.responses[m.opCalls]
2458
+ m.opCalls++
2459
+ return resp.body, resp.err
2460
+}
2461
+
2462
+func (m *mockPangoOperator) RetrieveApiKey() error {
2463
+ m.refreshCalls++
2464
+ return m.refreshErr
2465
+}
2466
+
2467
+func (m *mockPangoOperator) SystemInfo() map[string]string { return m.info }
src/go/plugin/go.d/collector/panos/commands.go
new
+11
@@ -0,0 +1,11 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+const (
6
+ systemInfoCommand = "<show><system><info></info></system></show>"
7
+ haStateCommand = "<show><high-availability><state></state></high-availability></show>"
8
+ environmentCommand = "<show><system><environmentals></environmentals></system></show>"
9
+ licenseInfoCommand = "<request><license><info></info></license></request>"
10
+ ipsecSACommand = "<show><vpn><ipsec-sa></ipsec-sa></vpn></show>"
11
+)
src/go/plugin/go.d/collector/panos/config_schema.json
new
+178
@@ -0,0 +1,178 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "title": "Palo Alto Networks PAN-OS collector configuration.",
5
+ "type": "object",
6
+ "properties": {
7
+ "update_every": {
8
+ "title": "Update every",
9
+ "description": "Data collection interval, measured in seconds.",
10
+ "type": "integer",
11
+ "minimum": 1,
12
+ "default": 60
13
+ },
14
+ "autodetection_retry": {
15
+ "title": "Detection retry",
16
+ "description": "Recheck interval in seconds. Zero means no recheck will be scheduled.",
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "default": 0
20
+ },
21
+ "url": {
22
+ "title": "URL",
23
+ "description": "The base URL of the PAN-OS firewall management interface.",
24
+ "type": "string",
25
+ "default": "https://127.0.0.1",
26
+ "format": "uri"
27
+ },
28
+ "timeout": {
29
+ "title": "Timeout",
30
+ "description": "PAN-OS XML API request timeout, measured in seconds.",
31
+ "type": "number",
32
+ "minimum": 1,
33
+ "default": 3
34
+ },
35
+ "api_key": {
36
+ "title": "API key",
37
+ "description": "PAN-OS XML API key. Takes priority over username/password key generation.",
38
+ "type": "string",
39
+ "sensitive": true
40
+ },
41
+ "username": {
42
+ "title": "Username",
43
+ "description": "PAN-OS administrator username used to generate an API key.",
44
+ "type": "string",
45
+ "sensitive": true
46
+ },
47
+ "password": {
48
+ "title": "Password",
49
+ "description": "PAN-OS administrator password used to generate an API key.",
50
+ "type": "string",
51
+ "sensitive": true
52
+ },
53
+ "vsys": {
54
+ "title": "VSYS",
55
+ "description": "Optional PAN-OS virtual system scope for operational commands.",
56
+ "type": "string"
57
+ },
58
+ "vnode": {
59
+ "title": "Vnode",
60
+ "description": "Associates this data collection job with a Virtual Node.",
61
+ "type": "string"
62
+ },
63
+ "tls_skip_verify": {
64
+ "title": "Skip TLS verification",
65
+ "description": "If set, TLS certificate verification will be skipped.",
66
+ "type": "boolean",
67
+ "default": false
68
+ },
69
+ "tls_ca": {
70
+ "title": "TLS CA",
71
+ "description": "The path to the CA certificate file for TLS verification.",
72
+ "type": "string",
73
+ "pattern": "^$|^/"
74
+ },
75
+ "tls_cert": {
76
+ "title": "TLS certificate",
77
+ "description": "The path to the client certificate file for TLS authentication.",
78
+ "type": "string",
79
+ "pattern": "^$|^/"
80
+ },
81
+ "tls_key": {
82
+ "title": "TLS key",
83
+ "description": "The path to the client key file for TLS authentication.",
84
+ "type": "string",
85
+ "pattern": "^$|^/"
86
+ },
87
+ "proxy_url": {
88
+ "title": "Proxy URL",
89
+ "description": "The URL of the proxy server.",
90
+ "type": "string"
91
+ },
92
+ "headers": {
93
+ "title": "Headers",
94
+ "description": "Additional HTTP headers to include in PAN-OS XML API requests.",
95
+ "type": [
96
+ "object",
97
+ "null"
98
+ ],
99
+ "additionalProperties": {
100
+ "type": "string"
101
+ }
102
+ }
103
+ },
104
+ "required": [
105
+ "url"
106
+ ],
107
+ "dependencies": {
108
+ "tls_cert": [
109
+ "tls_key"
110
+ ],
111
+ "tls_key": [
112
+ "tls_cert"
113
+ ]
114
+ }
115
+ },
116
+ "uiSchema": {
117
+ "uiOptions": {
118
+ "fullPage": true
119
+ },
120
+ "ui:flavour": "tabs",
121
+ "ui:options": {
122
+ "tabs": [
123
+ {
124
+ "title": "Base",
125
+ "fields": [
126
+ "update_every",
127
+ "autodetection_retry",
128
+ "url",
129
+ "timeout",
130
+ "vsys",
131
+ "vnode"
132
+ ]
133
+ },
134
+ {
135
+ "title": "Auth",
136
+ "fields": [
137
+ "api_key",
138
+ "username",
139
+ "password"
140
+ ]
141
+ },
142
+ {
143
+ "title": "TLS",
144
+ "fields": [
145
+ "tls_skip_verify",
146
+ "tls_ca",
147
+ "tls_cert",
148
+ "tls_key"
149
+ ]
150
+ },
151
+ {
152
+ "title": "Proxy",
153
+ "fields": [
154
+ "proxy_url"
155
+ ]
156
+ },
157
+ {
158
+ "title": "Headers",
159
+ "fields": [
160
+ "headers"
161
+ ]
162
+ }
163
+ ]
164
+ },
165
+ "api_key": {
166
+ "ui:widget": "password"
167
+ },
168
+ "username": {
169
+ "ui:widget": "password"
170
+ },
171
+ "password": {
172
+ "ui:widget": "password"
173
+ },
174
+ "vnode": {
175
+ "ui:placeholder": "To use this option, first create a Virtual Node and then reference its name here."
176
+ }
177
+ }
178
+}
src/go/plugin/go.d/collector/panos/integrations/palo_alto_networks_pan-os.md
new
+509
@@ -0,0 +1,509 @@
1
+<!--startmeta
2
+custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/collector/panos/README.md"
3
+meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/collector/panos/metadata.yaml"
4
+sidebar_label: "Palo Alto Networks PAN-OS"
5
+learn_status: "Published"
6
+learn_rel_path: "Collecting Metrics/Collectors/Networking"
7
+keywords: ['palo alto', 'pan-os', 'panos', 'firewall', 'bgp', 'ha', 'ipsec', 'licenses']
8
+message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
9
+endmeta-->
10
+
11
+# Palo Alto Networks PAN-OS
12
+
13
+
14
+<img src="https://netdata.cloud/img/paloalto.svg" width="150"/>
15
+
16
+
17
+Plugin: go.d.plugin
18
+Module: panos
19
+
20
+<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
21
+
22
+## Overview
23
+
24
+This collector monitors Palo Alto Networks PAN-OS firewalls.
25
+It collects read-only XML API telemetry for BGP, system status, HA, environment sensors, licenses, and IPsec SAs.
26
+
27
+
28
+It gathers metrics by periodically running PAN-OS XML API operational commands through the pango SDK.
29
+
30
+
31
+This collector is supported on all platforms.
32
+
33
+This collector supports collecting metrics from multiple instances of this integration, including remote instances.
34
+
35
+The configured PAN-OS account must be allowed to use the XML API and run read-only operational requests for the collected telemetry.
36
+
37
+
38
+### Default Behavior
39
+
40
+#### Auto-Detection
41
+
42
+This collector does not auto-detect PAN-OS firewalls. A job must be configured with the firewall management URL and credentials.
43
+
44
+
45
+#### Limits
46
+
47
+The default collection interval is 60 seconds to keep polling load conservative on the PAN-OS management plane.
48
+
49
+
50
+#### Performance Impact
51
+
52
+Each collection runs PAN-OS XML API operational requests. The collector uses serial requests and caps the SDK transport to 2 connections per firewall job. Multiple jobs targeting the same firewall multiply that per-job connection budget, so configure one job per firewall management interface unless you intentionally need separate scopes.
53
+
54
+
55
+## Setup
56
+
57
+
58
+You can configure the **panos** collector in two ways:
59
+
60
+| Method | Best for | How to |
61
+|-----------------------|------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
62
+| [**UI**](#via-ui) | Fast setup without editing files | Go to **Nodes → Configure this node → Collectors → Jobs**, search for **panos**, then click **+** to add a job. |
63
+| [**File**](#via-file) | If you prefer configuring via file, or need to automate deployments (e.g., with Ansible) | Edit `go.d/panos.conf` and add a job. |
64
+
65
+:::important
66
+
67
+UI configuration requires paid Netdata Cloud plan.
68
+
69
+:::
70
+
71
+
72
+### Prerequisites
73
+
74
+#### PAN-OS XML API access
75
+
76
+Enable XML API access and provide either an API key or username/password credentials that can generate one.
77
+The account needs permission to run operational commands.
78
+
79
+
80
+
81
+### Configuration
82
+
83
+#### Options
84
+
85
+The following options can be defined globally: update_every, autodetection_retry.
86
+
87
+
88
+<details open><summary>Config options</summary>
89
+
90
+
91
+
92
+| Group | Option | Description | Default | Required |
93
+|:------|:-----|:------------|:--------|:---------:|
94
+| **Collection** | update_every | Data collection interval (seconds). | 60 | no |
95
+| | autodetection_retry | Autodetection retry interval (seconds). Set 0 to disable. | 0 | no |
96
+| **Target** | url | PAN-OS management interface URL. The path must be empty, `/`, or `/api`. | https://127.0.0.1 | yes |
97
+| | timeout | PAN-OS XML API request timeout (seconds). | 3 | no |
98
+| | vsys | Optional PAN-OS virtual system scope for operational commands. | | no |
99
+| **Auth** | api_key | PAN-OS XML API key. Takes priority over username/password key generation. | | no |
100
+| | username | PAN-OS username used for API key generation. | | no |
101
+| | password | PAN-OS password used for API key generation. | | no |
102
+| **TLS** | tls_skip_verify | Skip TLS certificate and hostname verification. | no | no |
103
+| | tls_ca | Path to CA bundle used to validate the server certificate. | | no |
104
+| | tls_cert | Path to client TLS certificate. | | no |
105
+| | tls_key | Path to client TLS private key. | | no |
106
+| **Proxy** | proxy_url | HTTP proxy URL. Include proxy credentials in the URL if needed. | | no |
107
+| **Headers** | headers | Additional HTTP headers. | | no |
108
+| **Virtual Node** | vnode | Associates this data collection job with a Virtual Node. | | no |
109
+
110
+
111
+</details>
112
+
113
+
114
+#### via UI
115
+
116
+Configure the **panos** collector from the Netdata web interface:
117
+
118
+1. Go to **Nodes**.
119
+2. Select the node **where you want the panos data-collection job to run** and click the :gear: (**Configure this node**). That node will run the data collection.
120
+3. The **Collectors → Jobs** view opens by default.
121
+4. In the Search box, type _panos_ (or scroll the list) to locate the **panos** collector.
122
+5. Click the **+** next to the **panos** collector to add a new job.
123
+6. Fill in the job fields, then click **Test** to verify the configuration and **Submit** to save.
124
+ - **Test** runs the job with the provided settings and shows whether data can be collected.
125
+ - If it fails, an error message appears with details (for example, connection refused, timeout, or command execution errors), so you can adjust and retest.
126
+
127
+
128
+#### via File
129
+
130
+The configuration file name for this integration is `go.d/panos.conf`.
131
+
132
+The file format is YAML. Generally, the structure is:
133
+
134
+```yaml
135
+update_every: 1
136
+autodetection_retry: 0
137
+jobs:
138
+ - name: some_name1
139
+ - name: some_name2
140
+```
141
+You can edit the configuration file using the [`edit-config`](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#edit-configuration-files) script from the
142
+Netdata [config directory](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#locate-your-config-directory).
143
+
144
+```bash
145
+cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
146
+sudo ./edit-config go.d/panos.conf
147
+```
148
+
149
+##### Examples
150
+
151
+###### API key
152
+
153
+Collecting PAN-OS metrics with an existing XML API key.
154
+
155
+```yaml
156
+jobs:
157
+ - name: firewall
158
+ url: https://192.0.2.1
159
+ api_key: YOUR_PANOS_XML_API_KEY
160
+ update_every: 60
161
+ timeout: 3
162
+
163
+```
164
+###### Username/password key generation
165
+
166
+Letting the collector generate and reuse an API key through PAN-OS.
167
+
168
+<details open><summary>Config</summary>
169
+
170
+```yaml
171
+jobs:
172
+ - name: firewall
173
+ url: https://192.0.2.1
174
+ username: netdata
175
+ password: YOUR_PASSWORD
176
+ update_every: 60
177
+ timeout: 3
178
+
179
+```
180
+</details>
181
+
182
+
183
+
184
+## Alerts
185
+
186
+
187
+The following alerts are available:
188
+
189
+| Alert name | On metric | Description |
190
+|:------------|:----------|:------------|
191
+| [ panos_bgp_peer_not_established ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.bgp.peer.state | Critical when a BGP peer has not been established for 5 minutes. |
192
+| [ panos_device_certificate_invalid ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.system.device_certificate_status | Critical when PAN-OS reports the device certificate as invalid. |
193
+| [ panos_ha_peer_connection_down ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.ha.peer.connection_status | Critical when the HA peer connection has not been up for 5 minutes. |
194
+| [ panos_environment_sensor_alarm ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.environment.sensor_alarm_status | Critical when PAN-OS reports an environment sensor alarm. |
195
+| [ panos_license_expired ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.license.status | Critical when PAN-OS reports a license as expired. |
196
+| [ panos_license_expires_soon ](https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf) | panos.license.time_until_expiration | Warning under 30 days before expiration, critical under 7 days. Expired licenses trigger panos_license_expired instead. |
197
+
198
+
199
+## Metrics
200
+
201
+Metrics grouped by *scope*.
202
+
203
+The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels.
204
+
205
+
206
+
207
+### Per System
208
+
209
+These metrics refer to the PAN-OS device.
210
+
211
+Labels:
212
+
213
+| Label | Description |
214
+|:-----------|:----------------|
215
+| hostname | PAN-OS hostname. |
216
+| model | PAN-OS model. |
217
+| serial | Device serial number. |
218
+| sw_version | PAN-OS software version. |
219
+
220
+Metrics:
221
+
222
+| Metric | Dimensions | Unit |
223
+|:------|:----------|:----|
224
+| panos.system.uptime | uptime | seconds |
225
+| panos.system.device_certificate_status | valid, invalid | status |
226
+| panos.system.operational_mode | normal, other | mode |
227
+
228
+### Per High availability
229
+
230
+These metrics refer to the PAN-OS HA pair state reported by the local firewall.
231
+
232
+This scope has no labels.
233
+
234
+Metrics:
235
+
236
+| Metric | Dimensions | Unit |
237
+|:------|:----------|:----|
238
+| panos.ha.status | enabled, disabled | status |
239
+| panos.ha.local.state | active, passive, non_functional, suspended, unknown | state |
240
+| panos.ha.peer.state | active, passive, non_functional, suspended, unknown | state |
241
+| panos.ha.peer.connection_status | up, down, unknown | status |
242
+| panos.ha.state_sync_status | synchronized, not_synchronized, unknown | status |
243
+
244
+### Per High availability link
245
+
246
+These metrics refer to a single PAN-OS HA link.
247
+
248
+Labels:
249
+
250
+| Label | Description |
251
+|:-----------|:----------------|
252
+| link | HA link name. |
253
+
254
+Metrics:
255
+
256
+| Metric | Dimensions | Unit |
257
+|:------|:----------|:----|
258
+| panos.ha.link_status | up, down, unknown | status |
259
+
260
+### Per Environment sensor
261
+
262
+These metrics refer to a single PAN-OS environment sensor.
263
+
264
+Labels:
265
+
266
+| Label | Description |
267
+|:-----------|:----------------|
268
+| slot | Hardware slot. |
269
+| sensor | Sensor description. |
270
+| sensor_type | Sensor type. |
271
+
272
+Metrics:
273
+
274
+| Metric | Dimensions | Unit |
275
+|:------|:----------|:----|
276
+| panos.environment.temperature | temperature | Celsius |
277
+| panos.environment.fan_speed | speed | RPM |
278
+| panos.environment.voltage | voltage | Volts |
279
+| panos.environment.sensor_alarm_status | clear, alarm | status |
280
+| panos.environment.power_supply_presence_status | present, absent | status |
281
+| panos.environment.power_supply_alarm_status | clear, alarm | status |
282
+
283
+### Per License summary
284
+
285
+These metrics summarize PAN-OS licenses.
286
+
287
+This scope has no labels.
288
+
289
+Metrics:
290
+
291
+| Metric | Dimensions | Unit |
292
+|:------|:----------|:----|
293
+| panos.license.count | total, expired | licenses |
294
+
295
+### Per License
296
+
297
+These metrics refer to one PAN-OS license.
298
+
299
+Labels:
300
+
301
+| Label | Description |
302
+|:-----------|:----------------|
303
+| feature | License feature name. |
304
+| description | License description. |
305
+
306
+Metrics:
307
+
308
+| Metric | Dimensions | Unit |
309
+|:------|:----------|:----|
310
+| panos.license.status | valid, expired | status |
311
+| panos.license.time_until_expiration | time_until_expiration | days |
312
+
313
+### Per IPsec summary
314
+
315
+These metrics summarize active PAN-OS IPsec security associations.
316
+
317
+This scope has no labels.
318
+
319
+Metrics:
320
+
321
+| Metric | Dimensions | Unit |
322
+|:------|:----------|:----|
323
+| panos.ipsec.tunnels | active | tunnels |
324
+
325
+### Per IPsec tunnel
326
+
327
+These metrics refer to one active PAN-OS IPsec security association.
328
+
329
+Labels:
330
+
331
+| Label | Description |
332
+|:-----------|:----------------|
333
+| tunnel | Tunnel name. |
334
+| gateway | Gateway name. |
335
+| remote | Remote peer. |
336
+| tunnel_id | PAN-OS tunnel identifier. |
337
+| protocol | Tunnel protocol. |
338
+| encryption | Encryption algorithm. |
339
+
340
+Metrics:
341
+
342
+| Metric | Dimensions | Unit |
343
+|:------|:----------|:----|
344
+| panos.ipsec.tunnel.sa_lifetime | remaining_lifetime | seconds |
345
+
346
+### Per BGP peer
347
+
348
+These metrics refer to a single BGP peer on a PAN-OS virtual router or logical router.
349
+
350
+Labels:
351
+
352
+| Label | Description |
353
+|:-----------|:----------------|
354
+| vr | PAN-OS virtual router or logical router. |
355
+| peer_address | BGP peer address. |
356
+| local_address | Local BGP address. |
357
+| remote_as | Remote autonomous system. |
358
+| peer_group | PAN-OS peer group. |
359
+
360
+Metrics:
361
+
362
+| Metric | Dimensions | Unit |
363
+|:------|:----------|:----|
364
+| panos.bgp.peer.state | idle, connect, active, opensent, openconfirm, established, unknown | state |
365
+| panos.bgp.peer.uptime | uptime | seconds |
366
+| panos.bgp.peer.messages | in, out | messages/s |
367
+| panos.bgp.peer.updates | in, out | messages/s |
368
+| panos.bgp.peer.flaps | flaps | flaps/s |
369
+| panos.bgp.peer.established_transitions | established | transitions/s |
370
+
371
+### Per BGP peer address family
372
+
373
+These metrics refer to one AFI/SAFI family for a BGP peer.
374
+
375
+Labels:
376
+
377
+| Label | Description |
378
+|:-----------|:----------------|
379
+| vr | PAN-OS virtual router or logical router. |
380
+| peer_address | BGP peer address. |
381
+| local_address | Local BGP address. |
382
+| remote_as | Remote autonomous system. |
383
+| peer_group | PAN-OS peer group. |
384
+| afi | Address family. |
385
+| safi | Subsequent address family. |
386
+
387
+Metrics:
388
+
389
+| Metric | Dimensions | Unit |
390
+|:------|:----------|:----|
391
+| panos.bgp.peer.prefixes_received | total, accepted, rejected | prefixes |
392
+| panos.bgp.peer.prefixes_advertised | advertised | prefixes |
393
+
394
+### Per BGP virtual router
395
+
396
+These metrics refer to one PAN-OS virtual router or logical router.
397
+
398
+Labels:
399
+
400
+| Label | Description |
401
+|:-----------|:----------------|
402
+| vr | PAN-OS virtual router or logical router. |
403
+
404
+Metrics:
405
+
406
+| Metric | Dimensions | Unit |
407
+|:------|:----------|:----|
408
+| panos.bgp.vr.peers_by_state | idle, connect, active, opensent, openconfirm, established, unknown | peers |
409
+| panos.bgp.vr.peers_total | configured, established | peers |
410
+
411
+
412
+
413
+## Troubleshooting
414
+
415
+### Debug Mode
416
+
417
+**Important**: Debug mode is not supported for data collection jobs created via the UI using the Dyncfg feature.
418
+
419
+To troubleshoot issues with the `panos` collector, run the `go.d.plugin` with the debug option enabled. The output
420
+should give you clues as to why the collector isn't working.
421
+
422
+- Navigate to the `plugins.d` directory, usually at `/usr/libexec/netdata/plugins.d/`. If that's not the case on
423
+ your system, open `netdata.conf` and look for the `plugins` setting under `[directories]`.
424
+
425
+ ```bash
426
+ cd /usr/libexec/netdata/plugins.d/
427
+ ```
428
+
429
+- Switch to the `netdata` user.
430
+
431
+ ```bash
432
+ sudo -u netdata -s
433
+ ```
434
+
435
+- Run the `go.d.plugin` to debug the collector:
436
+
437
+ ```bash
438
+ ./go.d.plugin -d -m panos
439
+ ```
440
+
441
+ To debug a specific job:
442
+
443
+ ```bash
444
+ ./go.d.plugin -d -m panos -j jobName
445
+ ```
446
+
447
+### Getting Logs
448
+
449
+If you're encountering problems with the `panos` collector, follow these steps to retrieve logs and identify potential issues:
450
+
451
+- **Run the command** specific to your system (systemd, non-systemd, or Docker container).
452
+- **Examine the output** for any warnings or error messages that might indicate issues. These messages should provide clues about the root cause of the problem.
453
+
454
+#### System with systemd
455
+
456
+Use the following command to view logs generated since the last Netdata service restart:
457
+
458
+```bash
459
+journalctl _SYSTEMD_INVOCATION_ID="$(systemctl show --value --property=InvocationID netdata)" --namespace=netdata --grep panos
460
+```
461
+
462
+#### System without systemd
463
+
464
+Locate the collector log file, typically at `/var/log/netdata/collector.log`, and use `grep` to filter for collector's name:
465
+
466
+```bash
467
+grep panos /var/log/netdata/collector.log
468
+```
469
+
470
+**Note**: This method shows logs from all restarts. Focus on the **latest entries** for troubleshooting current issues.
471
+
472
+#### Docker Container
473
+
474
+If your Netdata runs in a Docker container named "netdata" (replace if different), use this command:
475
+
476
+```bash
477
+docker logs netdata 2>&1 | grep panos
478
+```
479
+
480
+### No BGP charts are created
481
+
482
+Verify that BGP is configured and that the account can run PAN-OS XML API operational requests.
483
+The collector logs when no legacy or Advanced Routing Engine BGP peer command returns peers.
484
+Advanced Routing Engine parsing is best-effort until validated with sanitized real PAN-OS ARE XML output.
485
+
486
+
487
+### Panorama proxy collection is unsupported
488
+
489
+Configure one job per firewall management interface. This collector does not support using Panorama as a target proxy for managed firewalls in v1.
490
+
491
+
492
+### A metricset fails but other charts work
493
+
494
+The collector keeps successful metricsets running and logs the failing metricset name and XML command context.
495
+
496
+
497
+### PAN-OS accepted a command but no telemetry appears
498
+
499
+A "success response has no recognized telemetry payload" error means PAN-OS accepted the operational command, but the XML result did not contain the expected section for that metricset.
500
+Verify the account permissions and platform support for the metricset, or provide a sanitized XML sample so the parser can be updated.
501
+
502
+
503
+### A PAN-OS value cannot be parsed
504
+
505
+The collector reports missing or invalid integer, decimal, duration, status, license expiration, and IPsec tunnel-count values with the metricset, field, entity name, and raw value when present.
506
+It does not silently convert missing or malformed values to zero, report fake valid status, or treat unrecognized license dates as never-expiring licenses.
507
+
508
+
509
+
src/go/plugin/go.d/collector/panos/labels.go
new
+81
@@ -0,0 +1,81 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "regexp"
7
+ "strings"
8
+)
9
+
10
+func systemLabelValues(info systemInfo) []string {
11
+ return []string{
12
+ labelValue(firstNonEmpty(info.Hostname, info.DeviceName), "unknown"),
13
+ labelValue(info.Model, "unknown"),
14
+ labelValue(info.Serial, "unknown"),
15
+ labelValue(info.SWVersion, "unknown"),
16
+ }
17
+}
18
+
19
+func environmentLabelValues(sensorType string, entry environmentEntry) []string {
20
+ return []string{
21
+ labelValue(sensorType, "unknown"),
22
+ labelValue(firstNonEmpty(entry.Slot, "unknown"), "unknown"),
23
+ labelValue(environmentSensorName(entry), "unknown"),
24
+ }
25
+}
26
+
27
+func licenseLabelValues(entry licenseEntry) []string {
28
+ return []string{
29
+ labelValue(firstNonEmpty(entry.Feature, "unknown"), "unknown"),
30
+ labelValue(entry.Description, "unknown"),
31
+ }
32
+}
33
+
34
+func ipsecTunnelLabelValues(tunnel ipsecTunnel) []string {
35
+ return []string{
36
+ labelValue(firstNonEmpty(tunnel.Name, "unknown"), "unknown"),
37
+ labelValue(tunnel.Gateway, "unknown"),
38
+ labelValue(tunnel.Remote, "unknown"),
39
+ labelValue(firstNonEmpty(tunnel.TID, tunnel.ISPI, tunnel.OSPI), "unknown"),
40
+ labelValue(tunnel.Protocol, "unknown"),
41
+ labelValue(tunnel.Encryption, "unknown"),
42
+ }
43
+}
44
+
45
+func peerLabelValues(peer bgpPeer) []string {
46
+ return []string{
47
+ labelValue(firstNonEmpty(peer.VR, "default"), "default"),
48
+ labelValue(peer.PeerAddress, "unknown"),
49
+ labelValue(peer.LocalAddress, "unknown"),
50
+ labelValue(peer.RemoteAS, "unknown_as"),
51
+ labelValue(peer.PeerGroup, "unknown_group"),
52
+ }
53
+}
54
+
55
+func prefixLabelValues(peer bgpPeer, counter bgpPrefixCounter) []string {
56
+ values := append([]string(nil), peerLabelValues(peer)...)
57
+ values = append(values, labelValue(counter.AFI, "unknown"), labelValue(counter.SAFI, "unknown"))
58
+ return values
59
+}
60
+
61
+func labelValue(value, fallback string) string {
62
+ value = strings.TrimSpace(value)
63
+ if value == "" {
64
+ return fallback
65
+ }
66
+ return value
67
+}
68
+
69
+var invalidIDChars = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
70
+
71
+func cleanID(value string) string {
72
+ value = strings.ToLower(strings.TrimSpace(value))
73
+ value = strings.ReplaceAll(value, ".", "_")
74
+ value = strings.ReplaceAll(value, ":", "_")
75
+ value = invalidIDChars.ReplaceAllString(value, "_")
76
+ value = strings.Trim(value, "_")
77
+ if value == "" {
78
+ return "unknown"
79
+ }
80
+ return value
81
+}
src/go/plugin/go.d/collector/panos/metadata.yaml
new
+544
@@ -0,0 +1,544 @@
1
+plugin_name: go.d.plugin
2
+modules:
3
+ - meta:
4
+ id: collector-go.d.plugin-panos
5
+ plugin_name: go.d.plugin
6
+ module_name: panos
7
+ monitored_instance:
8
+ name: Palo Alto Networks PAN-OS
9
+ link: https://www.paloaltonetworks.com/network-security
10
+ categories:
11
+ - data-collection.networking
12
+ icon_filename: paloalto.svg
13
+ related_resources:
14
+ integrations:
15
+ list: []
16
+ alternative_monitored_instances: []
17
+ info_provided_to_referring_integrations:
18
+ description: ""
19
+ keywords:
20
+ - palo alto
21
+ - pan-os
22
+ - panos
23
+ - firewall
24
+ - bgp
25
+ - ha
26
+ - ipsec
27
+ - licenses
28
+ overview:
29
+ data_collection:
30
+ metrics_description: |
31
+ This collector monitors Palo Alto Networks PAN-OS firewalls.
32
+ It collects read-only XML API telemetry for BGP, system status, HA, environment sensors, licenses, and IPsec SAs.
33
+ method_description: |
34
+ It gathers metrics by periodically running PAN-OS XML API operational commands through the pango SDK.
35
+ default_behavior:
36
+ auto_detection:
37
+ description: |
38
+ This collector does not auto-detect PAN-OS firewalls. A job must be configured with the firewall management URL and credentials.
39
+ limits:
40
+ description: |
41
+ The default collection interval is 60 seconds to keep polling load conservative on the PAN-OS management plane.
42
+ performance_impact:
43
+ description: |
44
+ Each collection runs PAN-OS XML API operational requests. The collector uses serial requests and caps the SDK transport to 2 connections per firewall job. Multiple jobs targeting the same firewall multiply that per-job connection budget, so configure one job per firewall management interface unless you intentionally need separate scopes.
45
+ additional_permissions:
46
+ description: |
47
+ The configured PAN-OS account must be allowed to use the XML API and run read-only operational requests for the collected telemetry.
48
+ multi_instance: true
49
+ supported_platforms:
50
+ include: []
51
+ exclude: []
52
+ setup:
53
+ prerequisites:
54
+ list:
55
+ - title: PAN-OS XML API access
56
+ description: |
57
+ Enable XML API access and provide either an API key or username/password credentials that can generate one.
58
+ The account needs permission to run operational commands.
59
+ configuration:
60
+ file:
61
+ name: go.d/panos.conf
62
+ options:
63
+ description: |
64
+ The following options can be defined globally: update_every, autodetection_retry.
65
+ folding:
66
+ title: Config options
67
+ enabled: true
68
+ list:
69
+ - name: update_every
70
+ description: Data collection interval (seconds).
71
+ default_value: 60
72
+ required: false
73
+ group: Collection
74
+ - name: autodetection_retry
75
+ description: Autodetection retry interval (seconds). Set 0 to disable.
76
+ default_value: 0
77
+ required: false
78
+ group: Collection
79
+ - name: url
80
+ description: PAN-OS management interface URL. The path must be empty, `/`, or `/api`.
81
+ default_value: https://127.0.0.1
82
+ required: true
83
+ group: Target
84
+ - name: timeout
85
+ description: PAN-OS XML API request timeout (seconds).
86
+ default_value: 3
87
+ required: false
88
+ group: Target
89
+ - name: vsys
90
+ description: Optional PAN-OS virtual system scope for operational commands.
91
+ default_value: ""
92
+ required: false
93
+ group: Target
94
+ - name: api_key
95
+ description: PAN-OS XML API key. Takes priority over username/password key generation.
96
+ default_value: ""
97
+ required: false
98
+ group: Auth
99
+ - name: username
100
+ description: PAN-OS username used for API key generation.
101
+ default_value: ""
102
+ required: false
103
+ group: Auth
104
+ - name: password
105
+ description: PAN-OS password used for API key generation.
106
+ default_value: ""
107
+ required: false
108
+ group: Auth
109
+ - name: tls_skip_verify
110
+ description: Skip TLS certificate and hostname verification.
111
+ default_value: no
112
+ required: false
113
+ group: TLS
114
+ - name: tls_ca
115
+ description: Path to CA bundle used to validate the server certificate.
116
+ default_value: ""
117
+ required: false
118
+ group: TLS
119
+ - name: tls_cert
120
+ description: Path to client TLS certificate.
121
+ default_value: ""
122
+ required: false
123
+ group: TLS
124
+ - name: tls_key
125
+ description: Path to client TLS private key.
126
+ default_value: ""
127
+ required: false
128
+ group: TLS
129
+ - name: proxy_url
130
+ description: HTTP proxy URL. Include proxy credentials in the URL if needed.
131
+ default_value: ""
132
+ required: false
133
+ group: Proxy
134
+ - name: headers
135
+ description: Additional HTTP headers.
136
+ default_value: ""
137
+ required: false
138
+ group: Headers
139
+ - name: vnode
140
+ description: Associates this data collection job with a Virtual Node.
141
+ default_value: ""
142
+ required: false
143
+ group: Virtual Node
144
+ examples:
145
+ folding:
146
+ title: Config
147
+ enabled: true
148
+ list:
149
+ - name: API key
150
+ description: Collecting PAN-OS metrics with an existing XML API key.
151
+ folding:
152
+ enabled: false
153
+ config: |
154
+ jobs:
155
+ - name: firewall
156
+ url: https://192.0.2.1
157
+ api_key: YOUR_PANOS_XML_API_KEY
158
+ update_every: 60
159
+ timeout: 3
160
+ - name: Username/password key generation
161
+ description: Letting the collector generate and reuse an API key through PAN-OS.
162
+ config: |
163
+ jobs:
164
+ - name: firewall
165
+ url: https://192.0.2.1
166
+ username: netdata
167
+ password: YOUR_PASSWORD
168
+ update_every: 60
169
+ timeout: 3
170
+ troubleshooting:
171
+ problems:
172
+ list:
173
+ - name: No BGP charts are created
174
+ description: |
175
+ Verify that BGP is configured and that the account can run PAN-OS XML API operational requests.
176
+ The collector logs when no legacy or Advanced Routing Engine BGP peer command returns peers.
177
+ Advanced Routing Engine parsing is best-effort until validated with sanitized real PAN-OS ARE XML output.
178
+ - name: Panorama proxy collection is unsupported
179
+ description: |
180
+ Configure one job per firewall management interface. This collector does not support using Panorama as a target proxy for managed firewalls in v1.
181
+ - name: A metricset fails but other charts work
182
+ description: |
183
+ The collector keeps successful metricsets running and logs the failing metricset name and XML command context.
184
+ - name: PAN-OS accepted a command but no telemetry appears
185
+ description: |
186
+ A "success response has no recognized telemetry payload" error means PAN-OS accepted the operational command, but the XML result did not contain the expected section for that metricset.
187
+ Verify the account permissions and platform support for the metricset, or provide a sanitized XML sample so the parser can be updated.
188
+ - name: A PAN-OS value cannot be parsed
189
+ description: |
190
+ The collector reports missing or invalid integer, decimal, duration, status, license expiration, and IPsec tunnel-count values with the metricset, field, entity name, and raw value when present.
191
+ It does not silently convert missing or malformed values to zero, report fake valid status, or treat unrecognized license dates as never-expiring licenses.
192
+ alerts:
193
+ - name: panos_bgp_peer_not_established
194
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
195
+ metric: panos.bgp.peer.state
196
+ info: Critical when a BGP peer has not been established for 5 minutes.
197
+ - name: panos_device_certificate_invalid
198
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
199
+ metric: panos.system.device_certificate_status
200
+ info: Critical when PAN-OS reports the device certificate as invalid.
201
+ - name: panos_ha_peer_connection_down
202
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
203
+ metric: panos.ha.peer.connection_status
204
+ info: Critical when the HA peer connection has not been up for 5 minutes.
205
+ - name: panos_environment_sensor_alarm
206
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
207
+ metric: panos.environment.sensor_alarm_status
208
+ info: Critical when PAN-OS reports an environment sensor alarm.
209
+ - name: panos_license_expired
210
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
211
+ metric: panos.license.status
212
+ info: Critical when PAN-OS reports a license as expired.
213
+ - name: panos_license_expires_soon
214
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/panos.conf
215
+ metric: panos.license.time_until_expiration
216
+ info: Warning under 30 days before expiration, critical under 7 days. Expired licenses trigger panos_license_expired instead.
217
+ metrics:
218
+ folding:
219
+ title: Metrics
220
+ enabled: false
221
+ description: ""
222
+ availability: []
223
+ scopes:
224
+ - name: System
225
+ description: These metrics refer to the PAN-OS device.
226
+ labels:
227
+ - name: hostname
228
+ description: PAN-OS hostname.
229
+ - name: model
230
+ description: PAN-OS model.
231
+ - name: serial
232
+ description: Device serial number.
233
+ - name: sw_version
234
+ description: PAN-OS software version.
235
+ metrics:
236
+ - name: panos.system.uptime
237
+ description: System Uptime
238
+ unit: seconds
239
+ chart_type: line
240
+ dimensions:
241
+ - name: uptime
242
+ - name: panos.system.device_certificate_status
243
+ description: Device Certificate Status
244
+ unit: status
245
+ chart_type: stacked
246
+ dimensions:
247
+ - name: valid
248
+ - name: invalid
249
+ - name: panos.system.operational_mode
250
+ description: Operational Mode
251
+ unit: mode
252
+ chart_type: stacked
253
+ dimensions:
254
+ - name: normal
255
+ - name: other
256
+ - name: High availability
257
+ description: These metrics refer to the PAN-OS HA pair state reported by the local firewall.
258
+ labels: []
259
+ metrics:
260
+ - name: panos.ha.status
261
+ description: HA Status
262
+ unit: status
263
+ chart_type: stacked
264
+ dimensions:
265
+ - name: enabled
266
+ - name: disabled
267
+ - name: panos.ha.local.state
268
+ description: Local HA State
269
+ unit: state
270
+ chart_type: stacked
271
+ dimensions:
272
+ - name: active
273
+ - name: passive
274
+ - name: non_functional
275
+ - name: suspended
276
+ - name: unknown
277
+ - name: panos.ha.peer.state
278
+ description: Peer HA State
279
+ unit: state
280
+ chart_type: stacked
281
+ dimensions:
282
+ - name: active
283
+ - name: passive
284
+ - name: non_functional
285
+ - name: suspended
286
+ - name: unknown
287
+ - name: panos.ha.peer.connection_status
288
+ description: HA Peer Connection Status
289
+ unit: status
290
+ chart_type: stacked
291
+ dimensions:
292
+ - name: up
293
+ - name: down
294
+ - name: unknown
295
+ - name: panos.ha.state_sync_status
296
+ description: HA State Synchronization
297
+ unit: status
298
+ chart_type: stacked
299
+ dimensions:
300
+ - name: synchronized
301
+ - name: not_synchronized
302
+ - name: unknown
303
+ - name: High availability link
304
+ description: These metrics refer to a single PAN-OS HA link.
305
+ labels:
306
+ - name: link
307
+ description: HA link name.
308
+ metrics:
309
+ - name: panos.ha.link_status
310
+ description: HA Link Status
311
+ unit: status
312
+ chart_type: stacked
313
+ dimensions:
314
+ - name: up
315
+ - name: down
316
+ - name: unknown
317
+ - name: Environment sensor
318
+ description: These metrics refer to a single PAN-OS environment sensor.
319
+ labels:
320
+ - name: slot
321
+ description: Hardware slot.
322
+ - name: sensor
323
+ description: Sensor description.
324
+ - name: sensor_type
325
+ description: Sensor type.
326
+ metrics:
327
+ - name: panos.environment.temperature
328
+ description: Environment Temperature
329
+ unit: Celsius
330
+ chart_type: line
331
+ dimensions:
332
+ - name: temperature
333
+ - name: panos.environment.fan_speed
334
+ description: Environment Fan Speed
335
+ unit: RPM
336
+ chart_type: line
337
+ dimensions:
338
+ - name: speed
339
+ - name: panos.environment.voltage
340
+ description: Environment Voltage
341
+ unit: Volts
342
+ chart_type: line
343
+ dimensions:
344
+ - name: voltage
345
+ - name: panos.environment.sensor_alarm_status
346
+ description: Environment Sensor Alarm
347
+ unit: status
348
+ chart_type: stacked
349
+ dimensions:
350
+ - name: clear
351
+ - name: alarm
352
+ - name: panos.environment.power_supply_presence_status
353
+ description: Power Supply Presence
354
+ unit: status
355
+ chart_type: stacked
356
+ dimensions:
357
+ - name: present
358
+ - name: absent
359
+ - name: panos.environment.power_supply_alarm_status
360
+ description: Power Supply Alarm
361
+ unit: status
362
+ chart_type: stacked
363
+ dimensions:
364
+ - name: clear
365
+ - name: alarm
366
+ - name: License summary
367
+ description: These metrics summarize PAN-OS licenses.
368
+ labels: []
369
+ metrics:
370
+ - name: panos.license.count
371
+ description: Licenses
372
+ unit: licenses
373
+ chart_type: line
374
+ dimensions:
375
+ - name: total
376
+ - name: expired
377
+ - name: License
378
+ description: These metrics refer to one PAN-OS license.
379
+ labels:
380
+ - name: feature
381
+ description: License feature name.
382
+ - name: description
383
+ description: License description.
384
+ metrics:
385
+ - name: panos.license.status
386
+ description: License Status
387
+ unit: status
388
+ chart_type: stacked
389
+ dimensions:
390
+ - name: valid
391
+ - name: expired
392
+ - name: panos.license.time_until_expiration
393
+ description: Time until expiration for non-expired licenses. A value of -1 means PAN-OS reports that the license never expires.
394
+ unit: days
395
+ chart_type: line
396
+ dimensions:
397
+ - name: time_until_expiration
398
+ - name: IPsec summary
399
+ description: These metrics summarize active PAN-OS IPsec security associations.
400
+ labels: []
401
+ metrics:
402
+ - name: panos.ipsec.tunnels
403
+ description: IPsec Tunnels
404
+ unit: tunnels
405
+ chart_type: line
406
+ dimensions:
407
+ - name: active
408
+ - name: IPsec tunnel
409
+ description: These metrics refer to one active PAN-OS IPsec security association.
410
+ labels:
411
+ - name: tunnel
412
+ description: Tunnel name.
413
+ - name: gateway
414
+ description: Gateway name.
415
+ - name: remote
416
+ description: Remote peer.
417
+ - name: tunnel_id
418
+ description: PAN-OS tunnel identifier.
419
+ - name: protocol
420
+ description: Tunnel protocol.
421
+ - name: encryption
422
+ description: Encryption algorithm.
423
+ metrics:
424
+ - name: panos.ipsec.tunnel.sa_lifetime
425
+ description: IPsec Tunnel SA Remaining Lifetime
426
+ unit: seconds
427
+ chart_type: line
428
+ dimensions:
429
+ - name: remaining_lifetime
430
+ - name: BGP peer
431
+ description: These metrics refer to a single BGP peer on a PAN-OS virtual router or logical router.
432
+ labels:
433
+ - name: vr
434
+ description: PAN-OS virtual router or logical router.
435
+ - name: peer_address
436
+ description: BGP peer address.
437
+ - name: local_address
438
+ description: Local BGP address.
439
+ - name: remote_as
440
+ description: Remote autonomous system.
441
+ - name: peer_group
442
+ description: PAN-OS peer group.
443
+ metrics:
444
+ - name: panos.bgp.peer.state
445
+ description: BGP Peer State
446
+ unit: state
447
+ chart_type: stacked
448
+ dimensions:
449
+ - name: idle
450
+ - name: connect
451
+ - name: active
452
+ - name: opensent
453
+ - name: openconfirm
454
+ - name: established
455
+ - name: unknown
456
+ - name: panos.bgp.peer.uptime
457
+ description: BGP Peer Uptime
458
+ unit: seconds
459
+ chart_type: line
460
+ dimensions:
461
+ - name: uptime
462
+ - name: panos.bgp.peer.messages
463
+ description: BGP Peer Messages
464
+ unit: messages/s
465
+ chart_type: line
466
+ dimensions:
467
+ - name: in
468
+ - name: out
469
+ - name: panos.bgp.peer.updates
470
+ description: BGP Peer Updates
471
+ unit: messages/s
472
+ chart_type: line
473
+ dimensions:
474
+ - name: in
475
+ - name: out
476
+ - name: panos.bgp.peer.flaps
477
+ description: BGP Peer Flaps
478
+ unit: flaps/s
479
+ chart_type: line
480
+ dimensions:
481
+ - name: flaps
482
+ - name: panos.bgp.peer.established_transitions
483
+ description: BGP Peer Established Transitions
484
+ unit: transitions/s
485
+ chart_type: line
486
+ dimensions:
487
+ - name: established
488
+ - name: BGP peer address family
489
+ description: These metrics refer to one AFI/SAFI family for a BGP peer.
490
+ labels:
491
+ - name: vr
492
+ description: PAN-OS virtual router or logical router.
493
+ - name: peer_address
494
+ description: BGP peer address.
495
+ - name: local_address
496
+ description: Local BGP address.
497
+ - name: remote_as
498
+ description: Remote autonomous system.
499
+ - name: peer_group
500
+ description: PAN-OS peer group.
501
+ - name: afi
502
+ description: Address family.
503
+ - name: safi
504
+ description: Subsequent address family.
505
+ metrics:
506
+ - name: panos.bgp.peer.prefixes_received
507
+ description: BGP Peer Received Prefixes
508
+ unit: prefixes
509
+ chart_type: line
510
+ dimensions:
511
+ - name: total
512
+ - name: accepted
513
+ - name: rejected
514
+ - name: panos.bgp.peer.prefixes_advertised
515
+ description: BGP Peer Advertised Prefixes
516
+ unit: prefixes
517
+ chart_type: line
518
+ dimensions:
519
+ - name: advertised
520
+ - name: BGP virtual router
521
+ description: These metrics refer to one PAN-OS virtual router or logical router.
522
+ labels:
523
+ - name: vr
524
+ description: PAN-OS virtual router or logical router.
525
+ metrics:
526
+ - name: panos.bgp.vr.peers_by_state
527
+ description: BGP Peers by State
528
+ unit: peers
529
+ chart_type: stacked
530
+ dimensions:
531
+ - name: idle
532
+ - name: connect
533
+ - name: active
534
+ - name: opensent
535
+ - name: openconfirm
536
+ - name: established
537
+ - name: unknown
538
+ - name: panos.bgp.vr.peers_total
539
+ description: BGP Peers Total
540
+ unit: peers
541
+ chart_type: line
542
+ dimensions:
543
+ - name: configured
544
+ - name: established
src/go/plugin/go.d/collector/panos/metrix.go
new
+166
@@ -0,0 +1,166 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import "github.com/netdata/netdata/go/plugins/pkg/metrix"
6
+
7
+type collectorMetrics struct {
8
+ system systemMetrics
9
+ ha haMetrics
10
+ env environmentMetricInstruments
11
+ lic licenseMetricInstruments
12
+ ipsec ipsecMetricInstruments
13
+ bgp bgpMetricInstruments
14
+}
15
+
16
+type systemMetrics struct {
17
+ uptime metrix.SnapshotGaugeVec
18
+ certStatus metrix.SnapshotStateSetVec
19
+ operationalMode metrix.SnapshotStateSetVec
20
+}
21
+
22
+type haMetrics struct {
23
+ status metrix.StateSetInstrument
24
+ localState metrix.StateSetInstrument
25
+ peerState metrix.StateSetInstrument
26
+ peerConnectionStatus metrix.StateSetInstrument
27
+ stateSync metrix.StateSetInstrument
28
+ linkStatus metrix.SnapshotStateSetVec
29
+}
30
+
31
+type environmentMetricInstruments struct {
32
+ temperature metrix.SnapshotGaugeVec
33
+ fanSpeed metrix.SnapshotGaugeVec
34
+ voltage metrix.SnapshotGaugeVec
35
+ sensorAlarm metrix.SnapshotStateSetVec
36
+ powerSupplyPresence metrix.SnapshotStateSetVec
37
+ powerSupplyAlarm metrix.SnapshotStateSetVec
38
+}
39
+
40
+type licenseMetricInstruments struct {
41
+ countTotal metrix.SnapshotGauge
42
+ countExpired metrix.SnapshotGauge
43
+ status metrix.SnapshotStateSetVec
44
+ timeUntilExpiration metrix.SnapshotGaugeVec
45
+}
46
+
47
+type ipsecMetricInstruments struct {
48
+ tunnelsActive metrix.SnapshotGauge
49
+ saLifetime metrix.SnapshotGaugeVec
50
+}
51
+
52
+type bgpMetricInstruments struct {
53
+ peerState metrix.SnapshotStateSetVec
54
+ peerUptime metrix.SnapshotGaugeVec
55
+ peerMessagesIn metrix.SnapshotCounterVec
56
+ peerMessagesOut metrix.SnapshotCounterVec
57
+ peerUpdatesIn metrix.SnapshotCounterVec
58
+ peerUpdatesOut metrix.SnapshotCounterVec
59
+ peerFlaps metrix.SnapshotCounterVec
60
+ peerEstablishedTransitions metrix.SnapshotCounterVec
61
+
62
+ peerPrefixesReceivedTotal metrix.SnapshotGaugeVec
63
+ peerPrefixesReceivedAccepted metrix.SnapshotGaugeVec
64
+ peerPrefixesReceivedRejected metrix.SnapshotGaugeVec
65
+ peerPrefixesAdvertised metrix.SnapshotGaugeVec
66
+
67
+ vrPeersByState map[string]metrix.SnapshotGaugeVec
68
+ vrPeersConfigured metrix.SnapshotGaugeVec
69
+ vrPeersEstablished metrix.SnapshotGaugeVec
70
+}
71
+
72
+var bgpStates = []string{"idle", "connect", "active", "opensent", "openconfirm", "established", "unknown"}
73
+var certStatusStates = []string{"valid", "invalid"}
74
+var haStates = []string{"active", "passive", "non_functional", "suspended", "unknown"}
75
+var haStatusStates = []string{"enabled", "disabled"}
76
+var licenseStatusStates = []string{"valid", "expired"}
77
+var operationalModeStates = []string{"normal", "other"}
78
+var upDownStates = []string{"up", "down", "unknown"}
79
+var haSyncStates = []string{"synchronized", "not_synchronized", "unknown"}
80
+var alarmStates = []string{"clear", "alarm"}
81
+var presenceStates = []string{"present", "absent"}
82
+
83
+func newCollectorMetrics(store metrix.CollectorStore) *collectorMetrics {
84
+ meter := store.Write().SnapshotMeter("")
85
+ system := meter.Vec("hostname", "model", "serial", "sw_version")
86
+ haLink := meter.Vec("link")
87
+ environment := meter.Vec("sensor_type", "slot", "sensor")
88
+ licenses := meter.Vec("feature", "description")
89
+ ipsecTunnels := meter.Vec("tunnel", "gateway", "remote", "tunnel_id", "protocol", "encryption")
90
+ bgpPeer := meter.Vec("vr", "peer_address", "local_address", "remote_as", "peer_group")
91
+ bgpPrefix := meter.Vec("vr", "peer_address", "local_address", "remote_as", "peer_group", "afi", "safi")
92
+ bgpVR := meter.Vec("vr")
93
+
94
+ return &collectorMetrics{
95
+ system: systemMetrics{
96
+ uptime: system.Gauge("system_uptime"),
97
+ certStatus: newStateSetVec(system, "system_device_certificate_status", certStatusStates),
98
+ operationalMode: newStateSetVec(system, "system_operational_mode", operationalModeStates),
99
+ },
100
+ ha: haMetrics{
101
+ status: newStateSet(meter, "ha_status", haStatusStates),
102
+ localState: newStateSet(meter, "ha_local_state", haStates),
103
+ peerState: newStateSet(meter, "ha_peer_state", haStates),
104
+ peerConnectionStatus: newStateSet(meter, "ha_peer_connection_status", upDownStates),
105
+ stateSync: newStateSet(meter, "ha_state_sync_status", haSyncStates),
106
+ linkStatus: newStateSetVec(haLink, "ha_link_status", upDownStates),
107
+ },
108
+ env: environmentMetricInstruments{
109
+ temperature: environment.Gauge("environment_temperature"),
110
+ fanSpeed: environment.Gauge("environment_fan_speed"),
111
+ voltage: environment.Gauge("environment_voltage"),
112
+ sensorAlarm: newStateSetVec(environment, "environment_sensor_alarm_status", alarmStates),
113
+ powerSupplyPresence: newStateSetVec(environment, "environment_power_supply_presence_status", presenceStates),
114
+ powerSupplyAlarm: newStateSetVec(environment, "environment_power_supply_alarm_status", alarmStates),
115
+ },
116
+ lic: licenseMetricInstruments{
117
+ countTotal: meter.Gauge("license_count_total"),
118
+ countExpired: meter.Gauge("license_count_expired"),
119
+ status: newStateSetVec(licenses, "license_status", licenseStatusStates),
120
+ timeUntilExpiration: licenses.Gauge("license_time_until_expiration"),
121
+ },
122
+ ipsec: ipsecMetricInstruments{
123
+ tunnelsActive: meter.Gauge("ipsec_tunnels_active"),
124
+ saLifetime: ipsecTunnels.Gauge("ipsec_tunnel_sa_lifetime"),
125
+ },
126
+ bgp: bgpMetricInstruments{
127
+ peerState: newStateSetVec(bgpPeer, "bgp_peer_state", bgpStates),
128
+ peerUptime: bgpPeer.Gauge("bgp_peer_uptime"),
129
+ peerMessagesIn: bgpPeer.Counter("bgp_peer_messages_in"),
130
+ peerMessagesOut: bgpPeer.Counter("bgp_peer_messages_out"),
131
+ peerUpdatesIn: bgpPeer.Counter("bgp_peer_updates_in"),
132
+ peerUpdatesOut: bgpPeer.Counter("bgp_peer_updates_out"),
133
+ peerFlaps: bgpPeer.Counter("bgp_peer_flaps"),
134
+ peerEstablishedTransitions: bgpPeer.Counter("bgp_peer_established_transitions"),
135
+
136
+ peerPrefixesReceivedTotal: bgpPrefix.Gauge("bgp_peer_prefixes_received_total"),
137
+ peerPrefixesReceivedAccepted: bgpPrefix.Gauge("bgp_peer_prefixes_received_accepted"),
138
+ peerPrefixesReceivedRejected: bgpPrefix.Gauge("bgp_peer_prefixes_received_rejected"),
139
+ peerPrefixesAdvertised: bgpPrefix.Gauge("bgp_peer_prefixes_advertised"),
140
+
141
+ vrPeersByState: newBGPStateGauges(bgpVR, "bgp_vr_peers_by_state"),
142
+ vrPeersConfigured: bgpVR.Gauge("bgp_vr_peers_total_configured"),
143
+ vrPeersEstablished: bgpVR.Gauge("bgp_vr_peers_total_established"),
144
+ },
145
+ }
146
+}
147
+
148
+func newStateSet(meter metrix.SnapshotMeter, name string, states []string) metrix.StateSetInstrument {
149
+ return meter.StateSet(name, metrix.WithStateSetMode(metrix.ModeEnum), metrix.WithStateSetStates(states...))
150
+}
151
+
152
+func newBGPStateGauges(meter metrix.SnapshotVecMeter, prefix string) map[string]metrix.SnapshotGaugeVec {
153
+ return newStateGaugeVecs(meter, prefix, bgpStates)
154
+}
155
+
156
+func newStateSetVec(meter metrix.SnapshotVecMeter, name string, states []string) metrix.SnapshotStateSetVec {
157
+ return meter.StateSet(name, metrix.WithStateSetMode(metrix.ModeEnum), metrix.WithStateSetStates(states...))
158
+}
159
+
160
+func newStateGaugeVecs(meter metrix.SnapshotVecMeter, prefix string, states []string) map[string]metrix.SnapshotGaugeVec {
161
+ gauges := make(map[string]metrix.SnapshotGaugeVec, len(states))
162
+ for _, state := range states {
163
+ gauges[state] = meter.Gauge(prefix + "_" + state)
164
+ }
165
+ return gauges
166
+}
src/go/plugin/go.d/collector/panos/panos_values.go
new
+117
@@ -0,0 +1,117 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "fmt"
7
+ "math"
8
+ "strconv"
9
+ "strings"
10
+
11
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
12
+)
13
+
14
+func observeStateSet(instrument metrix.StateSetInstrument, active string) {
15
+ if active == "" {
16
+ return
17
+ }
18
+ instrument.Enable(active)
19
+}
20
+
21
+func observeStateSetVec(vec metrix.SnapshotStateSetVec, active string, labels ...string) {
22
+ if active == "" {
23
+ return
24
+ }
25
+ vec.WithLabelValues(labels...).Enable(active)
26
+}
27
+
28
+func boolState(ok bool, trueState, falseState string) string {
29
+ if ok {
30
+ return trueState
31
+ }
32
+ return falseState
33
+}
34
+
35
+func parsePANOSAffirmativeField(field, v string) (bool, error) {
36
+ raw := strings.TrimSpace(v)
37
+ switch strings.ToLower(raw) {
38
+ case "yes", "true", "enabled", "enable", "up", "valid":
39
+ return true, nil
40
+ case "no", "false", "disabled", "disable", "down", "invalid", "off", "absent", "not present", "not-present":
41
+ return false, nil
42
+ default:
43
+ if raw == "" {
44
+ return false, fmt.Errorf("%s: missing status", field)
45
+ }
46
+ return false, fmt.Errorf("%s: invalid status %q", field, raw)
47
+ }
48
+}
49
+
50
+func normalizeUpDownState(state string) string {
51
+ switch strings.ToLower(strings.TrimSpace(state)) {
52
+ case "":
53
+ return ""
54
+ case "up":
55
+ return "up"
56
+ case "down":
57
+ return "down"
58
+ default:
59
+ return "unknown"
60
+ }
61
+}
62
+
63
+func alarmState(alarm bool) string {
64
+ return boolState(alarm, "alarm", "clear")
65
+}
66
+
67
+func parsePANOSAlarmField(field, v string) (bool, error) {
68
+ raw := strings.TrimSpace(v)
69
+ switch strings.ToLower(raw) {
70
+ case "true", "yes", "on", "active", "alarm", "critical":
71
+ return true, nil
72
+ case "false", "no", "off", "inactive", "ok", "normal", "clear", "none":
73
+ return false, nil
74
+ default:
75
+ if raw == "" {
76
+ return false, fmt.Errorf("%s: missing status", field)
77
+ }
78
+ return false, fmt.Errorf("%s: invalid status %q", field, raw)
79
+ }
80
+}
81
+
82
+func parsePANOSDecimalField(field, v string, scale int64) (int64, error) {
83
+ raw := strings.TrimSpace(v)
84
+ v = strings.ReplaceAll(raw, ",", "")
85
+ if v == "" {
86
+ return 0, nil
87
+ }
88
+ f, err := strconv.ParseFloat(v, 64)
89
+ if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
90
+ return 0, fmt.Errorf("%s: invalid decimal %q", field, raw)
91
+ }
92
+ return int64(math.Round(f * float64(scale))), nil
93
+}
94
+
95
+func parseRequiredPANOSDecimalField(field, v string, scale int64) (int64, error) {
96
+ if strings.TrimSpace(v) == "" {
97
+ return 0, fmt.Errorf("%s: missing decimal", field)
98
+ }
99
+ return parsePANOSDecimalField(field, v, scale)
100
+}
101
+
102
+func panosCommandName(cmd string) string {
103
+ switch cmd {
104
+ case systemInfoCommand:
105
+ return "system info query"
106
+ case haStateCommand:
107
+ return "HA state query"
108
+ case environmentCommand:
109
+ return "environmentals query"
110
+ case licenseInfoCommand:
111
+ return "license info query"
112
+ case ipsecSACommand:
113
+ return "IPsec SA query"
114
+ default:
115
+ return bgpCommandName(cmd)
116
+ }
117
+}
src/go/plugin/go.d/collector/panos/panos_xml.go
new
+72
@@ -0,0 +1,72 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package panos
4
+
5
+import (
6
+ "encoding/xml"
7
+ "fmt"
8
+ "strings"
9
+)
10
+
11
+type panosResultResponse struct {
12
+ XMLName xml.Name `xml:"response"`
13
+ Status string `xml:"status,attr"`
14
+ Code string `xml:"code,attr"`
15
+ Message panosResponseMessage `xml:"msg"`
16
+ Result struct {
17
+ Message panosResponseMessage `xml:"msg"`
18
+ InnerXML string `xml:",innerxml"`
19
+ } `xml:"result"`
20
+}
21
+
22
+func decodePANOSResult(body []byte, context string, dst any) error {
23
+ innerXML, err := decodePANOSResultInner(body, context)
24
+ if err != nil {
25
+ return err
26
+ }
27
+ if strings.TrimSpace(innerXML) == "" || dst == nil {
28
+ return nil
29
+ }
30
+
31
+ wrapped := []byte("<result>" + innerXML + "</result>")
32
+ if err := xml.Unmarshal(wrapped, dst); err != nil {
33
+ return fmt.Errorf("parse %s result: %w", context, err)
34
+ }
35
+ return nil
36
+}
37
+
38
+func decodePANOSResultInner(body []byte, context string) (string, error) {
39
+ var resp panosResultResponse
40
+ if err := xml.Unmarshal(body, &resp); err != nil {
41
+ return "", fmt.Errorf("parse %s: %w", context, err)
42
+ }
43
+ if resp.failed() {
44
+ return "", panosResponseError{code: resp.Code, message: resp.errorMessage()}
45
+ }
46
+ return resp.Result.InnerXML, nil
47
+}
48
+
49
+func (r panosResultResponse) failed() bool {
50
+ status := strings.ToLower(strings.TrimSpace(r.Status))
51
+ if status == "error" || status == "failed" {
52
+ return true
53
+ }
54
+ code := strings.TrimSpace(r.Code)
55
+ // PAN-OS XML API uses 19 and 20 for successful command and operation responses.
56
+ if code == "" || code == "0" || code == "19" || code == "20" {
57
+ return false
58
+ }
59
+ return true
60
+}
61
+
62
+func (r panosResultResponse) errorMessage() string {
63
+ return firstNonEmpty(r.Message.String(), r.Result.Message.String(), panosResponseCodeName(r.Code))
64
+}
65
+
66
+type missingPANOSResultError struct {
67
+ expected string
68
+}
69
+
70
+func (e missingPANOSResultError) Error() string {
71
+ return fmt.Sprintf("PAN-OS XML API success response has no recognized telemetry payload; expected %s", e.expected)
72
+}
src/go/plugin/go.d/collector/panos/taxonomy.yaml
new
+82
@@ -0,0 +1,82 @@
1
+taxonomy_version: 1
2
+plugin_name: go.d.plugin
3
+module_name: panos
4
+placements:
5
+ - id: panos
6
+ section_id: remote-devices
7
+ title: Palo Alto Networks PAN-OS
8
+ icon: snmp
9
+ properties: { important: false, grouping: true }
10
+ items:
11
+ - type: group
12
+ id: system
13
+ title: System
14
+ items:
15
+ - panos.system.uptime
16
+ - panos.system.device_certificate_status
17
+ - panos.system.operational_mode
18
+ - type: group
19
+ id: high-availability
20
+ title: High Availability
21
+ short_name: HA
22
+ items:
23
+ - panos.ha.status
24
+ - panos.ha.local.state
25
+ - panos.ha.peer.state
26
+ - panos.ha.peer.connection_status
27
+ - panos.ha.state_sync_status
28
+ - panos.ha.link_status
29
+ - type: group
30
+ id: environment
31
+ title: Environment
32
+ items:
33
+ - type: group
34
+ id: sensors
35
+ title: Sensors
36
+ items:
37
+ - panos.environment.temperature
38
+ - panos.environment.fan_speed
39
+ - panos.environment.voltage
40
+ - panos.environment.sensor_alarm_status
41
+ - type: group
42
+ id: power-supplies
43
+ title: Power Supplies
44
+ items:
45
+ - panos.environment.power_supply_presence_status
46
+ - panos.environment.power_supply_alarm_status
47
+ - type: group
48
+ id: licenses
49
+ title: Licenses
50
+ items:
51
+ - panos.license.count
52
+ - panos.license.status
53
+ - panos.license.time_until_expiration
54
+ - type: group
55
+ id: ipsec
56
+ title: IPsec
57
+ items:
58
+ - panos.ipsec.tunnels
59
+ - panos.ipsec.tunnel.sa_lifetime
60
+ - type: group
61
+ id: bgp
62
+ title: BGP
63
+ items:
64
+ - type: group
65
+ id: peers
66
+ title: Peers
67
+ items:
68
+ - panos.bgp.peer.state
69
+ - panos.bgp.peer.uptime
70
+ - panos.bgp.peer.messages
71
+ - panos.bgp.peer.updates
72
+ - panos.bgp.peer.flaps
73
+ - panos.bgp.peer.established_transitions
74
+ - panos.bgp.peer.prefixes_received
75
+ - panos.bgp.peer.prefixes_advertised
76
+ - type: group
77
+ id: virtual-routers
78
+ title: Virtual Routers
79
+ short_name: VRs
80
+ items:
81
+ - panos.bgp.vr.peers_by_state
82
+ - panos.bgp.vr.peers_total
src/go/plugin/go.d/collector/panos/testdata/advanced_bgp_peers.xml
new
+28
@@ -0,0 +1,28 @@
1
+<response status="success">
2
+ <result>
3
+ <logical-router>
4
+ <entry name="lr-a">
5
+ <entry peer-address="203.0.113.1" peer-group="core">
6
+ <local-address>203.0.113.254</local-address>
7
+ <remote-as>65100</remote-as>
8
+ <bgp-state>OpenConfirm</bgp-state>
9
+ <uptime>1 days 02:03:04</uptime>
10
+ <msg-total-in>200</msg-total-in>
11
+ <msg-total-out>220</msg-total-out>
12
+ <msg-update-in>20</msg-update-in>
13
+ <msg-update-out>22</msg-update-out>
14
+ <status-flap-counts>4</status-flap-counts>
15
+ <established-counts>5</established-counts>
16
+ <prefix-counter>
17
+ <entry name="ipv4-unicast">
18
+ <incoming-total>100</incoming-total>
19
+ <incoming-accepted>90</incoming-accepted>
20
+ <incoming-rejected>10</incoming-rejected>
21
+ <outgoing-advertised>11</outgoing-advertised>
22
+ </entry>
23
+ </prefix-counter>
24
+ </entry>
25
+ </entry>
26
+ </logical-router>
27
+ </result>
28
+</response>
src/go/plugin/go.d/collector/panos/testdata/config.json
new
+26
@@ -0,0 +1,26 @@
1
+{
2
+ "vnode": "firewall-a",
3
+ "update_every": 60,
4
+ "autodetection_retry": 0,
5
+ "url": "https://192.0.2.1",
6
+ "body": "",
7
+ "method": "",
8
+ "timeout": 3,
9
+ "not_follow_redirects": false,
10
+ "api_key": "test-api-key",
11
+ "username": "netdata",
12
+ "password": "secret",
13
+ "bearer_token_file": "",
14
+ "vsys": "vsys1",
15
+ "tls_skip_verify": true,
16
+ "tls_ca": "/tmp/ca.pem",
17
+ "tls_cert": "/tmp/cert.pem",
18
+ "tls_key": "/tmp/key.pem",
19
+ "force_http2": false,
20
+ "proxy_url": "http://proxy.local:8080",
21
+ "proxy_username": "",
22
+ "proxy_password": "",
23
+ "headers": {
24
+ "X-Test": "test"
25
+ }
26
+}
src/go/plugin/go.d/collector/panos/testdata/config.yaml
new
+15
@@ -0,0 +1,15 @@
1
+vnode: firewall-a
2
+update_every: 60
3
+url: https://192.0.2.1
4
+timeout: 3
5
+api_key: test-api-key
6
+username: netdata
7
+password: secret
8
+vsys: vsys1
9
+tls_skip_verify: true
10
+tls_ca: /tmp/ca.pem
11
+tls_cert: /tmp/cert.pem
12
+tls_key: /tmp/key.pem
13
+proxy_url: http://proxy.local:8080
14
+headers:
15
+ X-Test: test
src/go/plugin/go.d/collector/panos/testdata/environment.xml
new
+50
@@ -0,0 +1,50 @@
1
+<?xml version="1.0"?>
2
+<response status="success">
3
+ <result>
4
+ <power-supply>
5
+ <Slot1>
6
+ <entry>
7
+ <slot>1</slot>
8
+ <description>Power Supply 1</description>
9
+ <Inserted>True</Inserted>
10
+ <alarm>False</alarm>
11
+ </entry>
12
+ </Slot1>
13
+ </power-supply>
14
+ <thermal>
15
+ <Slot1>
16
+ <entry>
17
+ <slot>1</slot>
18
+ <description>Temperature Inlet</description>
19
+ <DegreesC>40.9</DegreesC>
20
+ <min>0.0</min>
21
+ <max>60.0</max>
22
+ <alarm>False</alarm>
23
+ </entry>
24
+ </Slot1>
25
+ </thermal>
26
+ <fan>
27
+ <Slot1>
28
+ <entry>
29
+ <slot>1</slot>
30
+ <description>Fan 1 RPM</description>
31
+ <RPMs>9157</RPMs>
32
+ <min>2500</min>
33
+ <alarm>False</alarm>
34
+ </entry>
35
+ </Slot1>
36
+ </fan>
37
+ <power>
38
+ <Slot1>
39
+ <entry>
40
+ <slot>1</slot>
41
+ <description>3.3V Power Rail</description>
42
+ <Volts>3.332</Volts>
43
+ <min>2.97</min>
44
+ <max>3.63</max>
45
+ <alarm>True</alarm>
46
+ </entry>
47
+ </Slot1>
48
+ </power>
49
+ </result>
50
+</response>
src/go/plugin/go.d/collector/panos/testdata/ha_state.xml
new
+32
@@ -0,0 +1,32 @@
1
+<?xml version="1.0"?>
2
+<response status="success">
3
+ <result>
4
+ <enabled>yes</enabled>
5
+ <group>
6
+ <mode>Active-Passive</mode>
7
+ <running-sync>synchronized</running-sync>
8
+ <local-info>
9
+ <state>active</state>
10
+ <priority>100</priority>
11
+ <state-sync>Complete</state-sync>
12
+ </local-info>
13
+ <peer-info>
14
+ <state>passive</state>
15
+ <priority>110</priority>
16
+ <conn-status>up</conn-status>
17
+ <conn-ha1>
18
+ <conn-status>up</conn-status>
19
+ </conn-ha1>
20
+ <conn-ha1-backup>
21
+ <conn-status>down</conn-status>
22
+ </conn-ha1-backup>
23
+ <conn-ha2>
24
+ <conn-status>up</conn-status>
25
+ </conn-ha2>
26
+ <conn-ha2-backup>
27
+ <conn-status>up</conn-status>
28
+ </conn-ha2-backup>
29
+ </peer-info>
30
+ </group>
31
+ </result>
32
+</response>
src/go/plugin/go.d/collector/panos/testdata/ipsec_sa.xml
new
+28
@@ -0,0 +1,28 @@
1
+<?xml version="1.0"?>
2
+<response status="success">
3
+ <result>
4
+ <ntun>2</ntun>
5
+ <entries>
6
+ <entry>
7
+ <name>branch-a</name>
8
+ <gateway>gw-branch-a</gateway>
9
+ <remote>198.51.100.10</remote>
10
+ <proto>ESP</proto>
11
+ <enc>G256</enc>
12
+ <remain>1727</remain>
13
+ <tid>66</tid>
14
+ <i_spi>2300285266</i_spi>
15
+ <o_spi>1430731122</o_spi>
16
+ </entry>
17
+ <entry>
18
+ <name>branch-b</name>
19
+ <gateway>gw-branch-b</gateway>
20
+ <remote>203.0.113.20</remote>
21
+ <proto>ESP</proto>
22
+ <enc>AES128</enc>
23
+ <remain>99</remain>
24
+ <tid>67</tid>
25
+ </entry>
26
+ </entries>
27
+ </result>
28
+</response>
src/go/plugin/go.d/collector/panos/testdata/legacy_bgp_peers.xml
new
+90
@@ -0,0 +1,90 @@
1
+<response status="success">
2
+ <result>
3
+ <entry peer="edge-primary" vr="default">
4
+ <peer-group>edge</peer-group>
5
+ <peer-router-id>203.0.113.10</peer-router-id>
6
+ <local-address>192.0.2.254:0</local-address>
7
+ <remote-as>65001</remote-as>
8
+ <status>Established</status>
9
+ <status-duration>3600</status-duration>
10
+ <password-set>no</password-set>
11
+ <passive>no</passive>
12
+ <multi-hop-ttl>2</multi-hop-ttl>
13
+ <peer-address>192.0.2.1:179</peer-address>
14
+ <reflector-client>not-client</reflector-client>
15
+ <same-confederation>no</same-confederation>
16
+ <aggregate-confed-as>no</aggregate-confed-as>
17
+ <peering-type>External BGP</peering-type>
18
+ <connect-retry-interval>15</connect-retry-interval>
19
+ <open-delay>0</open-delay>
20
+ <idle-hold>15</idle-hold>
21
+ <prefix-limit>1000</prefix-limit>
22
+ <holdtime>90</holdtime>
23
+ <holdtime-config>90</holdtime-config>
24
+ <keepalive>30</keepalive>
25
+ <keepalive-config>30</keepalive-config>
26
+ <msg-total-in>100</msg-total-in>
27
+ <msg-total-out>120</msg-total-out>
28
+ <msg-update-in>10</msg-update-in>
29
+ <msg-update-out>12</msg-update-out>
30
+ <last-update-age>26</last-update-age>
31
+ <last-error />
32
+ <status-flap-counts>2</status-flap-counts>
33
+ <established-counts>3</established-counts>
34
+ <ORF-entry-received>0</ORF-entry-received>
35
+ <nexthop-self>no</nexthop-self>
36
+ <nexthop-thirdparty>yes</nexthop-thirdparty>
37
+ <nexthop-peer>no</nexthop-peer>
38
+ <config>
39
+ <remove-private-as>no</remove-private-as>
40
+ </config>
41
+ <peer-capability>
42
+ <list>
43
+ <capability>Multiprotocol Extensions(1)</capability>
44
+ <value>IPv4 Unicast</value>
45
+ </list>
46
+ <list>
47
+ <capability>Route Refresh(2)</capability>
48
+ <value>yes</value>
49
+ </list>
50
+ </peer-capability>
51
+ <prefix-counter>
52
+ <entry afi-safi="bgpAfiIpv4-unicast">
53
+ <incoming-total>40</incoming-total>
54
+ <incoming-accepted>38</incoming-accepted>
55
+ <incoming-rejected>2</incoming-rejected>
56
+ <policy-rejected>0</policy-rejected>
57
+ <outgoing-total>10</outgoing-total>
58
+ <outgoing-advertised>8</outgoing-advertised>
59
+ </entry>
60
+ <entry afi-safi="bgpAfiIpv6-unicast">
61
+ <incoming-total>6</incoming-total>
62
+ <incoming-accepted>6</incoming-accepted>
63
+ <incoming-rejected>0</incoming-rejected>
64
+ <policy-rejected>0</policy-rejected>
65
+ <outgoing-total>4</outgoing-total>
66
+ <outgoing-advertised>3</outgoing-advertised>
67
+ </entry>
68
+ </prefix-counter>
69
+ </entry>
70
+ <entry peer="transit-backup" vr="blue">
71
+ <peer-group>transit</peer-group>
72
+ <peer-router-id>203.0.113.20</peer-router-id>
73
+ <local-address>198.51.100.254</local-address>
74
+ <remote-as>65002</remote-as>
75
+ <status>Active</status>
76
+ <status-duration>00:05:30</status-duration>
77
+ <password-set>yes</password-set>
78
+ <passive>yes</passive>
79
+ <multi-hop-ttl>1</multi-hop-ttl>
80
+ <peer-address>198.51.100.1</peer-address>
81
+ <msg-total-in>7</msg-total-in>
82
+ <msg-total-out>9</msg-total-out>
83
+ <msg-update-in>1</msg-update-in>
84
+ <msg-update-out>0</msg-update-out>
85
+ <last-error>Hold timer expired</last-error>
86
+ <status-flap-counts>1</status-flap-counts>
87
+ <established-counts>1</established-counts>
88
+ </entry>
89
+ </result>
90
+</response>
src/go/plugin/go.d/collector/panos/testdata/licenses.xml
new
+28
@@ -0,0 +1,28 @@
1
+<?xml version="1.0"?>
2
+<response status="success">
3
+ <result>
4
+ <licenses>
5
+ <entry>
6
+ <feature>Threat Prevention</feature>
7
+ <description>Threat prevention updates</description>
8
+ <issued>May 01, 2026</issued>
9
+ <expires>June 01, 2026</expires>
10
+ <expired>no</expired>
11
+ </entry>
12
+ <entry>
13
+ <feature>Premium Support</feature>
14
+ <description>Support entitlement</description>
15
+ <issued>January 01, 2025</issued>
16
+ <expires>April 01, 2026</expires>
17
+ <expired>yes</expired>
18
+ </entry>
19
+ <entry>
20
+ <feature>GlobalProtect Portal</feature>
21
+ <description>Portal entitlement</description>
22
+ <issued>January 01, 2020</issued>
23
+ <expires>Never</expires>
24
+ <expired>no</expired>
25
+ </entry>
26
+ </licenses>
27
+ </result>
28
+</response>
src/go/plugin/go.d/collector/panos/testdata/system_info.xml
new
+15
@@ -0,0 +1,15 @@
1
+<?xml version="1.0"?>
2
+<response status="success">
3
+ <result>
4
+ <system>
5
+ <hostname>edge-fw-a</hostname>
6
+ <devicename>edge-fw-a</devicename>
7
+ <model>PA-850</model>
8
+ <serial>0123456789</serial>
9
+ <sw-version>11.1.2</sw-version>
10
+ <uptime>2 days, 03:04:05</uptime>
11
+ <operational-mode>normal</operational-mode>
12
+ <device-certificate-status>Valid</device-certificate-status>
13
+ </system>
14
+ </result>
15
+</response>
src/go/plugin/go.d/config/go.d.conf
+1
@@ -85,6 +85,7 @@ modules:
85
# openldap: yes
86
# openvpn: no
87
# openvpn_status_log: yes
88
+# panos: yes
89
# ping: yes
90
# pgbouncer: yes
91
# phpdaemon: yes
src/go/plugin/go.d/config/go.d/panos.conf
new
+33
@@ -0,0 +1,33 @@
1
+## All available configuration options, their descriptions and default values:
2
+## https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/panos#readme
3
+
4
+#jobs:
5
+# - name: firewall
6
+# url: https://192.0.2.1
7
+# api_key: YOUR_PANOS_XML_API_KEY
8
+# update_every: 60
9
+# timeout: 3
10
+#
11
+# - name: firewall_with_keygen
12
+# url: https://198.51.100.1
13
+# username: netdata
14
+# password: YOUR_PASSWORD
15
+# update_every: 60
16
+# timeout: 3
17
+#
18
+# - name: firewall_with_tls_and_vsys
19
+# url: https://203.0.113.1
20
+# api_key: YOUR_PANOS_XML_API_KEY
21
+# vsys: vsys1
22
+# vnode: firewall-a
23
+# tls_ca: /etc/netdata/panos-ca.pem
24
+# tls_cert: /etc/netdata/panos-client.pem
25
+# tls_key: /etc/netdata/panos-client-key.pem
26
+# proxy_url: http://proxy.local:8080
27
+# headers:
28
+# X-Example: value
29
+#
30
+# - name: lab_self_signed
31
+# url: https://203.0.113.2
32
+# api_key: YOUR_PANOS_XML_API_KEY
33
+# tls_skip_verify: yes
src/health/health.d/panos.conf
new
+93
@@ -0,0 +1,93 @@
1
+# you can disable an alarm notification by setting the 'to' line to: silent
2
+
3
+# --- Availability: BGP Peer State ---
4
+
5
+ template: panos_bgp_peer_not_established
6
+ on: panos.bgp.peer.state
7
+ class: Availability
8
+ type: Network
9
+component: Palo Alto Networks NGFW
10
+ lookup: min -5m unaligned of established
11
+ units: state
12
+ every: 1m
13
+ crit: $this != nan AND $this < 1
14
+ delay: down 5m multiplier 1.5 max 1h
15
+ summary: PAN-OS BGP peer ${label:peer_address} is not established
16
+ info: BGP peer ${label:peer_address} in virtual router or logical router ${label:vr} \
17
+ has not been established for the last 5 minutes
18
+ to: sysadmin
19
+
20
+# --- System: Device Certificate ---
21
+
22
+ template: panos_device_certificate_invalid
23
+ on: panos.system.device_certificate_status
24
+ class: Errors
25
+ type: Network
26
+component: Palo Alto Networks NGFW
27
+ lookup: max -5m unaligned of invalid
28
+ units: status
29
+ every: 1m
30
+ crit: $this != nan AND $this > 0
31
+ summary: PAN-OS device certificate is not valid
32
+ info: PAN-OS reports the device certificate status as invalid
33
+ to: sysadmin
34
+
35
+# --- Availability: HA ---
36
+
37
+ template: panos_ha_peer_connection_down
38
+ on: panos.ha.peer.connection_status
39
+ class: Availability
40
+ type: Network
41
+component: Palo Alto Networks NGFW
42
+ lookup: min -5m unaligned of up
43
+ units: status
44
+ every: 1m
45
+ crit: $this != nan AND $this < 1
46
+ delay: down 5m multiplier 1.5 max 1h
47
+ summary: PAN-OS HA peer connection is down
48
+ info: PAN-OS HA peer connection has not been up for the last 5 minutes
49
+ to: sysadmin
50
+
51
+# --- Hardware: Environment Sensors ---
52
+
53
+ template: panos_environment_sensor_alarm
54
+ on: panos.environment.sensor_alarm_status
55
+ class: Errors
56
+ type: Network
57
+component: Palo Alto Networks NGFW
58
+ lookup: max -5m unaligned of alarm
59
+ units: status
60
+ every: 1m
61
+ crit: $this != nan AND $this > 0
62
+ summary: PAN-OS environment sensor ${label:sensor} is in alarm
63
+ info: PAN-OS reports an environment alarm for ${label:sensor_type} sensor ${label:sensor} in slot ${label:slot}
64
+ to: sysadmin
65
+
66
+# --- Licensing ---
67
+
68
+ template: panos_license_expired
69
+ on: panos.license.status
70
+ class: Errors
71
+ type: Network
72
+component: Palo Alto Networks NGFW
73
+ lookup: max -5m unaligned of expired
74
+ units: status
75
+ every: 1m
76
+ crit: $this != nan AND $this > 0
77
+ summary: PAN-OS license ${label:feature} is expired
78
+ info: PAN-OS reports license ${label:feature} as expired
79
+ to: sysadmin
80
+
81
+ template: panos_license_expires_soon
82
+ on: panos.license.time_until_expiration
83
+ class: Errors
84
+ type: Network
85
+component: Palo Alto Networks NGFW
86
+ lookup: min -1h unaligned of time_until_expiration
87
+ units: days
88
+ every: 10m
89
+ warn: $this != nan AND $this >= 0 AND $this < 30
90
+ crit: $this != nan AND $this >= 0 AND $this < 7
91
+ summary: PAN-OS license ${label:feature} expires soon
92
+ info: PAN-OS license ${label:feature} has less than 30 days before expiration; expired licenses trigger panos_license_expired
93
+ to: sysadmin