/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ package fmcclient // https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Objects_in_the_REST_API.html#reference_jpj_jqc_bcb import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log" "net/http" "strings" "sync" "time" ) const MaxAuthTokenMinutes = 60 const MaxAuthTokenRefreshes = 3 const MinutesBeforeRefreshDeadline = 3 func NewAuthTokenExpiry(time.Time) time.Time { return time.Now().Add(MaxAuthTokenMinutes * time.Minute) } func withinAuthTokenRefreshWindow(expiry time.Time) bool { minUntilExpiry := time.Until(expiry).Minutes() return 0 < minUntilExpiry && minUntilExpiry <= MinutesBeforeRefreshDeadline } func authTokenRefreshDeadlineHasPassed( expiry time.Time, ) bool { return time.Until(expiry) <= time.Duration(0) } func maxAuthTokenRefreshesHasBeenReached(cnt int) bool { return cnt >= MaxAuthTokenRefreshes } const ( FMCStatusCodeOK int = 200 FMCStatusCodeCreated int = 201 FMCStatusCodeAccepted int = 202 FMCStatusCodeNoContent int = 204 FMCStatusCodeBadRequest int = 400 FMCStatusCodeNotFound int = 404 FMCStatusCodeMethodNotAllowed int = 405 FMCStatusCodeUnprocessableEntity int = 422 FMCStatusCodeTooManyRequests int = 429 ) func newFMCError( resp *http.Response, host, msg string, ) FMCError { fmcErr := &FMCErrorResponse{ Error: FMCError{ Host: host, HttpStatus: http.StatusText(resp.StatusCode), HttpStatusCode: resp.StatusCode, Err: msg, }, } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmcErr.Error } json.Unmarshal(body, &fmcErr) return fmcErr.Error } type FMCError struct { Host string HttpStatus string HttpStatusCode int Category string Severity string Code string Details string Messages []map[string]string Err string } func (e FMCError) Error() string { status := http.StatusText(e.HttpStatusCode) if status != "" { status = " " + status } return fmt.Sprintf( "%s at FMC host %s: http status %d%s; category: %s; severity: %s; code: %s; details: %s; messages: %s", e.Err, e.Host, e.HttpStatusCode, status, e.Category, e.Severity, e.Code, e.Details, e.Messages, ) } type FMCErrorResponse struct { Error FMCError } type Pager[A any] interface { Iter() <-chan *A LastErr() error } // Retrieve n items in the range [m*n, m*n + n - 1], // inclusive. Keep p pages buffered. type pager struct { fmc *FMC url string m uint64 n uint64 err error p int } /* From https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Objects_in_the_REST_API.html: > The REST API will serve only 25 results per page. This can > be increased up to 1000 using the limit query parameter. */ func newPager(url string, f *FMC, pageSize uint64) pager { if pageSize == 0 { pageSize = 25 } if pageSize > 1000 { pageSize = 1000 } return pager{ fmc: f, url: url, m: 0, n: pageSize, p: 2, } } type fmcAggregate[T any] struct { Items []*T `json:"items"` } func iteratePages[A any]( p *pager, f func() fmcAggregate[A], ) chan *fmcAggregate[A] { ch := make(chan *fmcAggregate[A], p.p) go func() { defer close(ch) for { q := fmt.Sprintf( "?expanded=true&limit=%d&offset=%d", p.n, p.m*p.n, ) resp, err := p.fmc.get(p.url + q) if err != nil { p.err = fmt.Errorf("pager: iteratePages: get fmc url %v: %v", p.url+q, err) return } if resp.StatusCode != FMCStatusCodeOK { p.err = newFMCError(resp, p.fmc.host, err.Error()) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { p.err = fmt.Errorf("pager: iteratePages: read response body: %v", err) return } //log.Printf("DEBUG: %s\n", body) tmp := f() ptr := &tmp err = json.Unmarshal(body, ptr) if err != nil { log.Printf("ERROR: failed to parse '%s'\n", body) p.err = fmt.Errorf("pager: iteratePages: unmarshal fmc response: %v", err) return } ch <- ptr head := &fmcResponseBase{} err = json.Unmarshal(body, head) if err != nil { p.err = fmt.Errorf("pager: iteratePages: unmarshal fmc head: %v", err) return } //log.Printf("DEBUG: iteratePages: cnt=%d, m=%d, n=%d", head.Paging.Count, p.m, p.n) if (p.m*p.n + p.n) >= head.Paging.Count { return } p.m++ } }() return ch } func iterateItems[A any]( p *pager, f func() fmcAggregate[A], ) <-chan *A { ch := make(chan *A, p.n) go func() { defer close(ch) for resp := range iteratePages(p, f) { for i := 0; i < len(resp.Items); i++ { ch <- resp.Items[i] } if p.err != nil { p.err = fmt.Errorf("iterateItems: %s", p.err) return } } }() return ch } type FMCLinks struct { Self string `json:"self"` } type fmcResponseBase struct { Links FMCLinks `json:"links"` Paging struct { Count uint64 `json:"count"` Limit uint64 `json:"limit"` Offset uint64 `json:"offset"` Pages uint64 `json:"pages"` } `json:"paging"` } type FTDAutoNATRules struct { Items []*FTDAutoNATRule `json:"items"` } type FTDAutoNATRule struct { Id string `json:"id"` Type string `json:"type"` // FTDAutoNatRule Name string `json:"name"` Description string `json:"description,omitEmpty"` Version string `json:"version,omitEmpty"` // ? Section string `json:"section,omitEmpty"` // ? NATType string `json:"natType"` // STATIC | DYNAMIC SourceInterface *FTDInterfaceObjectRef `json:"sourceInterface,omitempty"` DestinationInterface *FTDInterfaceObjectRef `json:"destinationInterface,omitempty"` OriginalNetwork *FMCNetworkObjectRef `json:"originalNetwork"` TranslatedNetwork *FMCNetworkObjectRef `json:"translatedNetwork"` ServiceProtocol string `json:"serviceProtocol,omitEmpty"` // TCP | UDP OriginalPort *int `json:"originalPort,omitEmpty"` TranslatedPort *int `json:"translatedPort,omitEmpty"` PATOptions *FTDPATOptions `json:"patOptions,omitEmpty"` InterfaceInTranslatedNetwork bool `json:"interfaceInTranslatedNetwork"` Fallthrough bool `json:"fallThrough,omitempty"` NoProxyARP bool `json:"noProxyArp"` RouteLookup bool `json:"routeLookup"` NetToNet bool `json:"netToNet"` DNS bool `json:"dns,omitEmpty"` InterfaceIPv6 bool `json:"interfaceIpv6"` } type FTDInterfaceObjectRef struct { Id string `json:"id"` Type string `json:"type"` // SecurityZone | InterfaceGroup? Name string `json:"name"` } type FTDPATOptions struct { BlockAllocation bool `json:"blockAllocation"` PATPoolAddress *FMCNetworkObjectRef `json:"patPoolAddress"` IncludeReserve bool `json:"includeReserve,omitEmpty"` FlatPortRange bool `json:"flatPortRange"` InterfacePAT bool `json:"interfacePat"` ExtendedPAT bool `json:"extendedPat"` RoundRobin bool `json:"roundRobin"` } type FMCFTDNATPolicy struct { Id string `json:"id"` Type string `json:"type"` // FTDNatPolicy Name string `json:"name"` Description string `json:"description"` } type FMCPolicyRef struct { Id string `json:"id"` Type string `json:"type"` // FTDNatPolicy | AccessPolicy Name string `json:"name"` } type FMCPolicyAssignmentTarget struct { Id string `json:"id"` Type string `json:"type"` // Device Name string `json:"name"` KeepLocalEvents bool `json:"keepLocalEvents"` ProhibitPacketTransfer bool `json:"prohibitPacketTransfer"` } type FMCPolicyAssignment struct { Id string `json:"id"` Type string `json:"type"` // PolicyAssignment Policy *FMCPolicyRef `json:"policy"` Targets []*FMCPolicyAssignmentTarget `json:"targets"` } type FMCAccessPolicy struct { Links FMCLinks `json:"links"` Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` } type FMCAccessPolicyRuleActionType string const ( FMCAccessPolicyRuleActionAllow FMCAccessPolicyRuleActionType = "ALLOW" FMCAccessPolicyRuleActionTrust FMCAccessPolicyRuleActionType = "TRUST" FMCAccessPolicyRuleActionMonitor FMCAccessPolicyRuleActionType = "MONITOR" FMCAccessPolicyRuleActionBlock FMCAccessPolicyRuleActionType = "BLOCK" FMCAccessPolicyRuleActionBlockReset FMCAccessPolicyRuleActionType = "BLOCK_RESET" FMCAccessPolicyRuleActionBlockInteractive FMCAccessPolicyRuleActionType = "BLOCK_INTERACTIVE" FMCAccessPolicyRuleActionBlockResetInteractive FMCAccessPolicyRuleActionType = "BLOCK_RESET_INTERACTIVE" ) type FMCAccessPolicyRuleComment struct { Comment string `json:"comment"` Date string `json:"date"` User struct { Name string `json:"name"` Type string `json:"type"` } `json:"user"` } type FMCSecurityZoneObject struct { Id string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` } type FMCSyslogConfigObject struct { Id string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` } type FMCAccessPolicyRuleMetadata struct { Category string `json:"category,omitempty"` Section string `json:"section,omitempty"` RuleIndex uint16 `json:"ruleIndex,omitempty"` } type FMCIPSPolicyRef struct { Id string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // IntrusionPolicy InspectionMode string `json:"inspectionMode,omitempty"` // DETECTION|PREVENTION } type FMCVariableSetRef struct { Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` // VariableSet } type FMCAccessPolicyRuleZones struct { Objects []*FMCSecurityZoneObject `json:"objects"` } type FMCAccessPolicyRuleNetworks struct { Objects []*FMCNetworkObjectRef `json:"objects,omitempty"` Literals []*FMCNetworkLiteral `json:"literals,omitempty"` } type FMCPortObjectRef struct { Id string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // ICMPV4Object|ICMPV6Object|ProtocolPortObject|PortObjectGroup } // N.B.: the type 'ICMPV4Object' has an upper-case 'V', as // opposed to its "Literal" counterpart. type FMCProtocolPortObject struct { Id string `json:"id"` Name string `json:"name,omitempty"` Protocol string `json:"protocol,omitempty"` Port string `json:"port,omitempty"` ICMPType string `json:"icmpType,omitempty"` Code int `json:"code,omitempty"` Type string `json:"type,omitempty"` // ICMPV4Object|ICMPV6Object|ProtocolPortObject Overridable bool `json:"overridable,omitempty"` Description string `json:"description,omitempty"` } type FMCPortObjectGroup struct { Id string `json:"id"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // PortObjectGroup Objects []*FMCPortObjectRef `json:"objects"` Overridable bool `json:"overridable,omitempty"` Description string `json:"description,omitempty"` } /* Protocol -9999 represents 'All'. Incidentally, the FMC * API provides protocol names rather than IP protocol * numbers, which FMC will fail to recognize on put/post. * Moreover, ICMP port literals are provided without protocol * fields, which are required for put/post. This baffling * behavior should be handled as a matter of course. */ type FMCPortLiteral struct { Type string `json:"type"` // ICMPv4PortLiteral|ICMPv6PortLiteral|PortLiteral Protocol string `json:"protocol"` // 0-255,-9999 Port string `json:"port,omitempty"` ICMPType string `json:"icmpType,omitempty"` Code int `json:"code,omitempty"` } type FMCAccessPolicyRulePorts struct { Objects []*FMCPortObjectRef `json:"objects"` Literals []*FMCPortLiteral `json:"literals,omitempty"` } type FMCApplicationRef struct { Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` // Application } type FMCAccessPolicyRuleApplications struct { Applications []*FMCApplicationRef `json:"applications"` } type FMCFilePolicyRef struct { Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` // FilePolicy } type FMCURLLiteral struct { URL string `json:"url"` Type string `json:"type"` // Url } type FMCAccessPolicyRuleURLs struct { Literals []*FMCURLLiteral `json:"literals"` } /* "Intrusion Policy, File Policy and Variable Set cannot be * selected when rule action is Block, Trust, Block with * reset or monitor" * * It is possible for FMC to allow rules with `logFiles` * enabled and no file policy set. This will fail in * put/post. */ type FMCAccessPolicyRule struct { Id string `json:"id,omitempty"` Name string `json:"name"` Type string `json:"type"` Metadata *FMCAccessPolicyRuleMetadata `json:"metadata,omitempty"` Action FMCAccessPolicyRuleActionType `json:"action"` Enabled bool `json:"enabled"` EnableSyslog bool `json:"enableSyslog"` SendEventsToFMC bool `json:"sendEventsToFMC"` IPSPolicy *FMCIPSPolicyRef `json:"ipsPolicy,omitempty"` FilePolicy *FMCFilePolicyRef `json:"filePolicy,omitempty"` VariableSet *FMCVariableSetRef `json:"variableSet,omitempty"` LogBegin bool `json:"logBegin"` LogEnd bool `json:"logEnd"` LogFiles bool `json:"logFiles"` SourceZones *FMCAccessPolicyRuleZones `json:"sourceZones"` DestinationZones *FMCAccessPolicyRuleZones `json:"destinationZones"` SourceNetworks *FMCAccessPolicyRuleNetworks `json:"sourceNetworks,omitempty"` DestinationNetworks *FMCAccessPolicyRuleNetworks `json:"destinationNetworks,omitempty"` SourcePorts *FMCAccessPolicyRulePorts `json:"sourcePorts,omitempty"` DestinationPorts *FMCAccessPolicyRulePorts `json:"destinationPorts,omitempty"` CommentHistoryList []*FMCAccessPolicyRuleComment `json:"commentHistoryList,omitempty"` Applications *FMCAccessPolicyRuleApplications `json:"applications,omitempty"` URLs *FMCAccessPolicyRuleURLs `json:"urls,omitempty"` } type FMCNetworkObjectRef struct { Id string `json:"id"` Name string `json:"name"` Type string `json:"type"` // Host|Network|Range|FQDN|NetworkGroup } type FMCNetworkObject struct { Id string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // Host|Network|Range|FQDN Value string `json:"value,omitempty"` Overridable bool `json:"overridable,omitempty"` } type FMCNetworkGroupMetadata struct { Domain struct { Id string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` // Domain } } /* It has been observed that literals with type 'Host' can * have an erroneous/obsolete '/32' prefix length in FMC, * causing put/post to fail later. This must be accounted * for. */ type FMCNetworkLiteral struct { Type string `json:"type"` // Network|Host Value string `json:"value"` } type FMCNetworkGroup struct { Id string `json:"id,omitempty"` Name string `json:"name,omitempty"` Type string `json:"type,omitempty"` Overridable bool `json:"overridable,omitempty"` Metadata *FMCNetworkGroupMetadata `json:"metadata,omitempty"` Literals []*FMCNetworkLiteral `json:"literals,omitempty"` Objects []*FMCNetworkObjectRef `json:"objects,omitempty"` } type nameToUUID struct { Name string UUID string } func New( ctx context.Context, cl *http.Client, host, user, pass string, ) (*FMC, error) { f := &FMC{ ctx: ctx, client: cl, host: host, basePath: "/api/fmc_config/v1", user: user, pass: pass, authMutex: &sync.Mutex{}, domains: make(map[string]string), } var err error if host == "" || user == "" || pass == "" { err = errors.New("Empty host, user, or pass") } return f, err } func checkForCiscoError( resp *http.Response, respBody []byte, ) error { /* According to https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Objects_in_the_REST_API.html the API cannot accept a message with a payload greater than 20480 bytes (20 KB). This applies to both the REST API and to API Explorer. This is not a configurable parameter. If a message exceeds this limit, the API will give an HTTP 422 error. By contrast, https://www.cisco.com/c/en/us/td/docs/security/firepower/70/api/REST/firepower_management_center_rest_api_quick_start_guide_70/Objects_In_The_REST_API.html claims the API cannot accept a message with payload greater than 2048000 bytes (2 MB). I can only hope Cisco typo-ed the former when they meant the latter. */ if resp.StatusCode == FMCStatusCodeUnprocessableEntity { return fmt.Errorf("server returned error: %+v: %s", resp, string(respBody)) } /* From https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Objects_in_the_REST_API.html: > The FMC REST API implements rate limiting to reduce > network load. > > The API will accept no more than 120 messages per minute > from an individual IP address. It will only allow 10 > simultaneous connections *per IP address*. These are not > configurable parameters. > > If a client exceeds these limits, the API will give an > HTTP 429 error. However, https://www.cisco.com/c/en/us/td/docs/security/firepower/70/api/REST/firepower_management_center_rest_api_quick_start_guide_70/Objects_In_The_REST_API.html states the following: > – "Too Many Requests". Too many requests were sent to > the API. This error will occur if you send more than 120 > requests per minute. > – Too many concurrent requests. The system cannot > accept more than 10 parallel requests *from all > clients*. > – Too many write operations per server. The API will > only allow one PUT, POST, or DELETE request *per user* > on a server at a time. If true, these latter limits constitute a substantial reduction in acceptable client behaviors. TODO: Prevent this occurring in the first place. */ if resp.StatusCode == FMCStatusCodeTooManyRequests { return fmt.Errorf("server returned error: %+v: %s", resp, string(respBody)) } if resp.StatusCode < 200 || 299 < resp.StatusCode { return fmt.Errorf("server returned error: %+v: %s", resp, string(respBody)) } return nil } type Mode int const ( ModeReadOnly Mode = iota ModeWrite ) type FMC struct { ctx context.Context client *http.Client host string basePath string user string pass string authToken string refreshToken string refreshCount int tokenExpiry time.Time watchdogCancel context.CancelFunc authMutex *sync.Mutex domains map[string]string mode Mode } func (f *FMC) Arm() Mode { f.mode = ModeWrite return f.mode } func (f *FMC) Disarm() Mode { f.mode = ModeReadOnly return f.mode } func (f *FMC) authTokenInvalid() bool { if f.authToken == "" || f.refreshCount > MaxAuthTokenRefreshes || time.Until(f.tokenExpiry) <= time.Duration(0) { return true } return false } func (f *FMC) resetRefreshToken() { f.refreshToken = "" f.refreshCount = 0 f.tokenExpiry = time.Time{} return } func (f *FMC) processAuthResponse( resp *http.Response, ) error { /* TODO: Is this actually a failure? For the life of me, I can't recall why this was necessary. A successful authentication with status 204 is presented here: https://www.cisco.com/c/en/us/support/docs/security/firepower-management-center/215918-how-to-generate-authentication-token-for.html */ if resp.StatusCode != FMCStatusCodeNoContent { return newFMCError(resp, f.host, "authentication failure") } f.tokenExpiry = NewAuthTokenExpiry(time.Now()) f.authToken = resp.Header.Get("X-Auth-Access-Token") f.refreshToken = resp.Header.Get("X-Auth-Refresh-Token") return nil } // See https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Connecting_with_a_Client.html func (f *FMC) Authenticate() error { f.authMutex.Lock() defer f.authMutex.Unlock() if f.authTokenInvalid() || maxAuthTokenRefreshesHasBeenReached(f.refreshCount) || authTokenRefreshDeadlineHasPassed(f.tokenExpiry) { if f.watchdogCancel != nil { f.watchdogCancel() } f.resetRefreshToken() req, err := makeAuthAccessRequest( f.ctx, f.host, f.user, f.pass, ) if err != nil { return err } resp, err := f.client.Do(req) if err != nil { return err } var domains []nameToUUID json.Unmarshal( []byte(resp.Header.Get("domains")), &domains, ) for i := 0; i < len(domains); i++ { f.domains[domains[i].Name] = domains[i].UUID } if err = f.processAuthResponse(resp); err != nil { return err } info("authenticated to FMC") ctx, cancel := context.WithCancel(f.ctx) f.watchdogCancel = cancel f.startTokenWatchdog(ctx) } else { if !withinAuthTokenRefreshWindow(f.tokenExpiry) { return nil } req, err := makeAuthRefreshRequest( f.ctx, f.host, f.authToken, f.refreshToken, ) if err != nil { return err } resp, err := f.client.Do(req) if err != nil { return err } if err = f.processAuthResponse(resp); err != nil { return err } info("refreshed FMC auth token") f.refreshCount++ } return nil } func (f *FMC) startTokenWatchdog(ctx context.Context) { info("starting auth token watchdog") go func() { defer info("stopping auth token watchdog") for { select { case <-ctx.Done(): return case <-time.After(100 * time.Millisecond): if f.authTokenInvalid() { info("auth token has invalidated") return } if err := f.Authenticate(); err != nil { warn(err.Error()) return } } } }() return } func (f *FMC) get(url string) ( resp *http.Response, err error, ) { req, err := http.NewRequestWithContext(f.ctx, "GET", url, nil) if err != nil { return } req.Header.Add("X-Auth-Access-Token", f.authToken) resp, err = f.client.Do(req) return } func (f *FMC) post( url string, body io.Reader, ) (resp *http.Response, err error) { req, err := http.NewRequestWithContext(f.ctx, "POST", url, body) if err != nil { return } req.Header.Add("Content-Type", "application/json") req.Header.Add("X-Auth-Access-Token", f.authToken) resp, err = f.client.Do(req) return } func (f *FMC) put( url string, body io.Reader, ) (resp *http.Response, err error) { req, err := http.NewRequestWithContext(f.ctx, "PUT", url, body) if err != nil { return } req.Header.Add("Content-Type", "application/json") req.Header.Add("X-Auth-Access-Token", f.authToken) resp, err = f.client.Do(req) return } func (f *FMC) postGoThing( url string, goThing any, ) (respBody []byte, err error) { reqBody, err := json.Marshal(goThing) if err != nil { err = fmt.Errorf("failed to marshal json: %v: '%#v'", err, goThing) return } resp, err := f.post(url, bytes.NewReader(reqBody)) if err != nil { err = fmt.Errorf("failed to post: %v: '%s'", err, reqBody) return } defer resp.Body.Close() respBody, err = io.ReadAll(resp.Body) if err != nil { err = fmt.Errorf("failed to read response body: %v", err) return } if err = checkForCiscoError(resp, respBody); err != nil { log.Printf("DEBUG: req body: %s", string(reqBody)) return } return respBody, nil } func (f *FMC) putGoThing( url string, goThing any, ) (respBody []byte, err error) { reqBody, err := json.Marshal(goThing) if err != nil { err = fmt.Errorf("failed to marshal json: %v: '%#v'", err, goThing) return } resp, err := f.put(url, bytes.NewReader(reqBody)) if err != nil { err = fmt.Errorf("failed to put: %v: '%s'", err, reqBody) return } defer resp.Body.Close() respBody, err = io.ReadAll(resp.Body) if err != nil { err = fmt.Errorf("failed to read response body: %v", err) return } if err = checkForCiscoError(resp, respBody); err != nil { log.Printf("DEBUG: req body: %s", string(reqBody)) return } return respBody, nil } func (f *FMC) Domain(s string) (string, bool) { v, ok := f.domains[s] return v, ok } func (f *FMC) Domains() []string { keys := make([]string, 0, len(f.domains)) for k := range f.domains { keys = append(keys, k) } return keys } type FMCAccessPoliciesPager struct { pager } func ( p *FMCAccessPoliciesPager, ) Iter() <-chan *FMCAccessPolicy { return iterateItems( &p.pager, func() fmcAggregate[FMCAccessPolicy] { return fmcAggregate[FMCAccessPolicy]{} }, ) } func (p *FMCAccessPoliciesPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc access policies pager: %v", p.err) } return } func (f *FMC) AccessPolicies( domain string, pageSize uint64, ) (*FMCAccessPoliciesPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("access policies: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/policy/accesspolicies", f.host, f.basePath, domainUUID, ) return &FMCAccessPoliciesPager{ pager: newPager(url, f, pageSize), }, nil } type FMCAccessPolicyRulesPager struct { pager } func ( p *FMCAccessPolicyRulesPager, ) Iter() <-chan *FMCAccessPolicyRule { return iterateItems( &p.pager, func() fmcAggregate[FMCAccessPolicyRule] { return fmcAggregate[FMCAccessPolicyRule]{} }, ) } func (p *FMCAccessPolicyRulesPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc access policy rules pager: %v", p.err) } return } func (f *FMC) AccessPolicyRules( domain, policyUUID string, pageSize uint64, ) (*FMCAccessPolicyRulesPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("access policy rules: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/policy/accesspolicies/%s/accessrules", f.host, f.basePath, domainUUID, policyUUID, ) return &FMCAccessPolicyRulesPager{ pager: newPager(url, f, pageSize), }, nil } func (f *FMC) AddAccessPolicyRules( domain, policyUUID string, rules []*FMCAccessPolicyRule, ) error { if f.mode != ModeWrite { return fmt.Errorf("add access policy rules: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("add access policy rules: unknown domain %v", domain) } // TODO: permit specification of categories, sections //category := url.PathEscape(rule.Metadata.Category) url := fmt.Sprintf( "https://%s%s/domain/%s/policy/accesspolicies/%s/accessrules?bulk=true", f.host, f.basePath, domainUUID, policyUUID, ) var err error for _, rule := range rules { rule.Id = "" rule.Metadata = nil if rule.SourceNetworks != nil && rule.DestinationNetworks != nil { if len(rule.SourceNetworks.Objects) > 42 && len(rule.SourceNetworks.Objects) < 50 || len(rule.DestinationNetworks.Objects) > 42 && len(rule.DestinationNetworks.Objects) < 50 { log.Printf("WARNING: network object count is approaching limit of 50: '%s'", rule.Name) } if len(rule.SourceNetworks.Objects) == 50 || len(rule.DestinationNetworks.Objects) == 50 { log.Printf("WARNING: limit of 50 network objects reached: '%s'", rule.Name) } if len(rule.SourceNetworks.Objects) > 50 || len(rule.DestinationNetworks.Objects) > 50 { log.Printf("ERROR: add access policy rules: unable to add rule: network object count exceeds 50-object limit: '%s'", rule.Name) err = fmt.Errorf("add access policy rules: unable to add rule: network object count exceeded 50-object limit") } } } if err != nil { return err } maxPost := 10 for i := 0; i < len(rules); i += maxPost { log.Printf("DEBUG: add access policy rules: posting seq %d", i+1) last := min(i+maxPost, len(rules)) _, err := f.postGoThing(url, rules[i:last]) if err != nil { return err } } return nil } func (f *FMC) UpdateAccessPolicyRule( domain, policyUUID string, rule *FMCAccessPolicyRule, ) error { if f.mode != ModeWrite { return fmt.Errorf("update access policy rule: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("update access policy rule: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/policy/accesspolicies/%s/accessrules/%s", f.host, f.basePath, domainUUID, policyUUID, rule.Id, ) rule.Metadata = nil if rule.SourceNetworks != nil && rule.DestinationNetworks != nil { if len(rule.SourceNetworks.Objects) > 42 && len(rule.SourceNetworks.Objects) < 50 || len(rule.DestinationNetworks.Objects) > 42 && len(rule.DestinationNetworks.Objects) < 50 { log.Printf("WARNING: network object count is approaching limit of 50: '%s'", rule.Name) } if len(rule.SourceNetworks.Objects) == 50 || len(rule.DestinationNetworks.Objects) == 50 { log.Printf("WARNING: limit of 50 network objects reached: '%s'", rule.Name) } if len(rule.SourceNetworks.Objects) > 50 || len(rule.DestinationNetworks.Objects) > 50 { return fmt.Errorf("change access policy rule: unable to change rule: network object count exceeds 50-object limit: '%s'", rule.Name) } } _, err := f.putGoThing(url, rule) //log.Printf("DEBUG: post response body: %s", string(respBody)) return err } func (f *FMC) FTDNATPolicies( domain string, pageSize uint64, ) (*FMCFTDNATPoliciesPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("ftd nat policies: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/policy/ftdnatpolicies?expanded=true", f.host, f.basePath, domainUUID, ) return &FMCFTDNATPoliciesPager{ pager: newPager(url, f, pageSize), }, nil } type FMCFTDNATPoliciesPager struct { pager } func ( p *FMCFTDNATPoliciesPager, ) Iter() <-chan *FMCFTDNATPolicy { return iterateItems( &p.pager, func() fmcAggregate[FMCFTDNATPolicy] { return fmcAggregate[FMCFTDNATPolicy]{} }, ) } func (p *FMCFTDNATPoliciesPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc ftd nat policies pager: %v", p.err) } return } func (f *FMC) PolicyAssignments( domain string, pageSize uint64, ) (*FMCPolicyAssignmentsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("policy assignments: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/assignment/policyassignments", f.host, f.basePath, domainUUID, ) return &FMCPolicyAssignmentsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCPolicyAssignmentsPager struct { pager } func ( p *FMCPolicyAssignmentsPager, ) Iter() <-chan *FMCPolicyAssignment { return iterateItems( &p.pager, func() fmcAggregate[FMCPolicyAssignment] { return fmcAggregate[FMCPolicyAssignment]{} }, ) } func (p *FMCPolicyAssignmentsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc policy assignments pager: %v", p.err) } return } func (f *FMC) UpdatePolicyAssignment( domain string, assignment *FMCPolicyAssignment, ) error { if f.mode != ModeWrite { return fmt.Errorf("update policy assignment: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("update policy assignment: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/assignment/policyassignments/%s", f.host, f.basePath, domainUUID, assignment.Id, ) //assignment.Metadata = nil _, err := f.putGoThing(url, assignment) return err } func (f *FMC) AddNetworkObjects( domain string, objects map[string]*FMCNetworkObject, ) error { if f.mode != ModeWrite { return fmt.Errorf("add network host object: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("add network host object: unknown domain %v", domain) } typeToPath := map[string]string{ "Host": "hosts", "Network": "networks", "Range": "ranges", "FQDN": "fqdns", } url := func(suffix string) string { return fmt.Sprintf( "https://%s%s/domain/%s/object/%s?bulk=true", f.host, f.basePath, domainUUID, suffix, ) } objectsByType := make(map[string][]*FMCNetworkObject) for k, _ := range typeToPath { objectsByType[k] = make([]*FMCNetworkObject, 0, 32) } for _, e := range objects { objectsByType[e.Type] = append(objectsByType[e.Type], e) } maxPost := 500 for k, os := range objectsByType { suffix, ok := typeToPath[k] if !ok { return fmt.Errorf("add network object: unknown object type %v", k) } for i := 0; i < len(os); i += maxPost { log.Printf("DEBUG: add network objects: posting type %s, block %d", k, i+1) last := min(i+maxPost, len(os)) _, err := f.postGoThing(url(suffix), os[i:last]) if err != nil { return err } } } return nil } func min(a, b int) int { if a < b { return a } return b } func (f *FMC) AddNetworkGroups( domain string, groups map[string]*FMCNetworkGroup, ) error { if f.mode != ModeWrite { return fmt.Errorf("add network host object: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("add network host object: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/networkgroups?bulk=true", f.host, f.basePath, domainUUID, ) unsorted := make(map[string]bool) for k, _ := range groups { unsorted[k] = true } antichains := make([][]*FMCNetworkGroup, 0, 10) antichains = append(antichains, make([]*FMCNetworkGroup, 0, 32)) nextAntichain := make(map[string]bool) separable := func(key string) bool { for _, e := range groups[key].Objects { if e.Type != "NetworkGroup" { continue } if unsorted[strings.ToLower(e.Name)] || nextAntichain[strings.ToLower(e.Name)] { return false } } return true } lastCnt := -1 for { depth := len(antichains) - 1 lastCnt = len(unsorted) for k, _ := range unsorted { if separable(k) { groups[k].Id = "" groups[k].Metadata = nil antichains[depth] = append(antichains[depth], groups[k]) nextAntichain[k] = true delete(unsorted, k) } } if len(unsorted) == lastCnt { break } if len(unsorted) > 0 { nextAntichain = make(map[string]bool) antichains = append(antichains, make([]*FMCNetworkGroup, 0, 32)) } } if len(unsorted) > 0 { return fmt.Errorf("WARN: add network groups: provided group set contains cycles: culprits %+v", unsorted) } posted := make(map[string]string) maxPost := 50 for j, a := range antichains { for _, e := range a { for _, r := range e.Objects { if r.Type == "NetworkGroup" { if uuid, ok := posted[strings.ToLower(r.Name)]; ok { r.Id = uuid } } } } for i := 0; i < len(a); i += maxPost { log.Printf("DEBUG: add network groups: posting antichain %d, seq %d", j, i+1) last := min(i+maxPost, len(a)) respBody, err := f.postGoThing(url, a[i:last]) if err != nil { return err } response := &FMCAddNetworkGroupsResponse{} err = json.Unmarshal(respBody, response) if err != nil { log.Printf("ERROR: add network groups: failed to parse '%s'\n", respBody) return fmt.Errorf("pager: iteratePages: unmarshal fmc response: %v", err) } for _, i := range response.Items { posted[strings.ToLower(i.Name)] = i.Id } } } return nil } type FMCAddNetworkGroupsResponse struct { Items []*FMCNetworkGroup `json:"items"` } func (f *FMC) AddNetworkHostObject( domain string, object *FMCNetworkObject, ) error { if f.mode != ModeWrite { return fmt.Errorf("add network host object: client is not armed; not allowed to change fmc") } domainUUID, ok := f.domains[domain] if !ok { return fmt.Errorf("add network host object: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/hosts?bulk=true", f.host, f.basePath, domainUUID, ) _, err := f.postGoThing(url, object) //log.Printf("DEBUG: post response body: %s", string(respBody)) return err } func (f *FMC) NetworkHostObjects( domain string, pageSize uint64, ) (*FMCNetworkObjectsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("network host objects: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/hosts", f.host, f.basePath, domainUUID, ) return &FMCNetworkObjectsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCNetworkObjectsPager struct { pager } func ( p *FMCNetworkObjectsPager, ) Iter() <-chan *FMCNetworkObject { return iterateItems( &p.pager, func() fmcAggregate[FMCNetworkObject] { return fmcAggregate[FMCNetworkObject]{} }, ) } func (p *FMCNetworkObjectsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc network objects pager: %v", p.err) } return } /* Retrieve both network and host objects */ func (f *FMC) NetworkAddresses( domain string, pageSize uint64, ) (*FMCNetworkObjectsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("network addresses: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/networkaddresses", f.host, f.basePath, domainUUID, ) return &FMCNetworkObjectsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCNetworkGroupsPager struct { pager } func ( p *FMCNetworkGroupsPager, ) Iter() <-chan *FMCNetworkGroup { return iterateItems( &p.pager, func() fmcAggregate[FMCNetworkGroup] { return fmcAggregate[FMCNetworkGroup]{} }, ) } func (p *FMCNetworkGroupsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc network groups pager: %v", p.err) } return } /* Retrieve network groups */ func (f *FMC) NetworkGroups( domain string, pageSize uint64, ) (*FMCNetworkGroupsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("network groups: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/networkgroups", f.host, f.basePath, domainUUID, ) return &FMCNetworkGroupsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCSecurityZoneObjectsPager struct { pager } func ( p *FMCSecurityZoneObjectsPager, ) Iter() <-chan *FMCSecurityZoneObject { return iterateItems( &p.pager, func() fmcAggregate[FMCSecurityZoneObject] { return fmcAggregate[FMCSecurityZoneObject]{} }, ) } func (p *FMCSecurityZoneObjectsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc security zone objects pager: %v", p.err) } return } func (f *FMC) SecurityZones( domain string, pageSize uint64, ) (*FMCSecurityZoneObjectsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("security zones: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/securityzones", f.host, f.basePath, domainUUID, ) return &FMCSecurityZoneObjectsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCProtocolPortObjectsPager struct { pager } func ( p *FMCProtocolPortObjectsPager, ) Iter() <-chan *FMCProtocolPortObject { return iterateItems( &p.pager, func() fmcAggregate[FMCProtocolPortObject] { return fmcAggregate[FMCProtocolPortObject]{} }, ) } func (p *FMCProtocolPortObjectsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc protocol-port objects pager: %v", p.err) } return } func (f *FMC) Ports( domain string, pageSize uint64, ) (*FMCProtocolPortObjectsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("port objects: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/ports", f.host, f.basePath, domainUUID, ) return &FMCProtocolPortObjectsPager{ pager: newPager(url, f, pageSize), }, nil } type FMCPortObjectGroupsPager struct { pager } func ( p *FMCPortObjectGroupsPager, ) Iter() <-chan *FMCPortObjectGroup { return iterateItems( &p.pager, func() fmcAggregate[FMCPortObjectGroup] { return fmcAggregate[FMCPortObjectGroup]{} }, ) } func (p *FMCPortObjectGroupsPager) LastErr() (err error) { if p.err != nil { err = fmt.Errorf("fmc port object groups pager: %v", p.err) } return } func (f *FMC) PortGroups( domain string, pageSize uint64, ) (*FMCPortObjectGroupsPager, error) { domainUUID, ok := f.domains[domain] if !ok { return nil, fmt.Errorf("port object groups: unknown domain %v", domain) } url := fmt.Sprintf( "https://%s%s/domain/%s/object/portobjectgroups", f.host, f.basePath, domainUUID, ) return &FMCPortObjectGroupsPager{ pager: newPager(url, f, pageSize), }, nil } // See https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Connecting_with_a_Client.html func makeAuthAccessRequest( ctx context.Context, host, user, pass string, ) (*http.Request, error) { url := fmt.Sprintf( "https://%s/api/fmc_platform/v1/auth/generatetoken", host, ) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return req, err } req.SetBasicAuth(user, pass) return req, nil } // See https://www.cisco.com/c/en/us/td/docs/security/firepower/620/api/REST/Firepower_Management_Center_REST_API_Quick_Start_Guide_620/Connecting_with_a_Client.html func makeAuthRefreshRequest( ctx context.Context, host, authToken, refreshToken string, ) (*http.Request, error) { url := fmt.Sprintf( "https://%s/api/fmc_platform/v1/auth/refreshtoken", host, ) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return req, err } req.Header.Add("X-Auth-Access-Token", authToken) req.Header.Add("X-Auth-Refresh-Token", refreshToken) return req, nil } func warn(m string) { log.Printf("Warn: %s\n", m) return } func info(m string) { log.Printf("info: %s\n", m) return } func debug(m string) { log.Printf("debug: %s\n", m) return }