@cryptotaxi247 / netdata / commits / 59af74d16

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 &quot;format&quot; 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