| 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 | } |