master
go 287 lines 6.77 KB
Raw
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 }