From d7904e8797195fc7855d858be14c41a0c54bab2d Mon Sep 17 00:00:00 2001 From: Eva Lacy Date: Thu, 3 Oct 2024 15:19:38 -0700 Subject: [PATCH 1/5] Prevent Simultaneous Writes --- pfsenseapi/client.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pfsenseapi/client.go b/pfsenseapi/client.go index 86617ef..d16148c 100644 --- a/pfsenseapi/client.go +++ b/pfsenseapi/client.go @@ -10,6 +10,7 @@ import ( "golang.org/x/exp/slices" "io" "net/http" + "sync" "time" ) @@ -33,6 +34,7 @@ var ( type Client struct { client *http.Client Cfg Config + lock sync.Mutex System *SystemService Token *TokenService @@ -286,6 +288,10 @@ func (c *Client) get(ctx context.Context, endpoint string, queryMap map[string]s } func (c *Client) post(ctx context.Context, endpoint string, queryMap map[string]string, body []byte) ([]byte, error) { + // PFSense API cannot handle concurrent write requests + c.lock.Lock() + defer c.lock.Unlock() + res, err := c.do(ctx, http.MethodPost, endpoint, queryMap, body) if err != nil { return nil, err @@ -312,6 +318,10 @@ func (c *Client) post(ctx context.Context, endpoint string, queryMap map[string] } func (c *Client) put(ctx context.Context, endpoint string, queryMap map[string]string, body []byte) ([]byte, error) { + // PFSense API cannot handle concurrent write requests + c.lock.Lock() + defer c.lock.Unlock() + res, err := c.do(ctx, http.MethodPut, endpoint, queryMap, body) if err != nil { return nil, err @@ -338,6 +348,10 @@ func (c *Client) put(ctx context.Context, endpoint string, queryMap map[string]s } func (c *Client) delete(ctx context.Context, endpoint string, queryMap map[string]string) ([]byte, error) { + // PFSense API cannot handle concurrent write requests + c.lock.Lock() + defer c.lock.Unlock() + res, err := c.do(ctx, http.MethodDelete, endpoint, queryMap, nil) if err != nil { return nil, err From ee08491adb8003cd4a60c8ce0da13996961a64ca Mon Sep 17 00:00:00 2001 From: Eva Lacy Date: Thu, 3 Oct 2024 16:15:01 -0700 Subject: [PATCH 2/5] Delete Static Mappings, Update DHCP Settings --- pfsenseapi/dhcp.go | 191 +++++++++++++++---- pfsenseapi/dhcp_test.go | 77 +++++++- pfsenseapi/testdata/deletestaticmapping.json | 40 ++++ pfsenseapi/testdata/dhcpconfiguration.json | 43 +++++ pfsenseapi/testdata/liststaticmappings.json | 50 ++++- pfsenseapi/trueifpresent.go | 13 ++ 6 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 pfsenseapi/testdata/deletestaticmapping.json create mode 100644 pfsenseapi/testdata/dhcpconfiguration.json create mode 100644 pfsenseapi/trueifpresent.go diff --git a/pfsenseapi/dhcp.go b/pfsenseapi/dhcp.go index 9002013..5cb171f 100644 --- a/pfsenseapi/dhcp.go +++ b/pfsenseapi/dhcp.go @@ -3,12 +3,14 @@ package pfsenseapi import ( "context" "encoding/json" + "fmt" "strconv" ) const ( leasesEndpoint = "api/v1/services/dhcpd/lease" staticMappingEndpoint = "api/v1/services/dhcpd/static_mapping" + serverEndpoint = "api/v1/services/dhcpd" ) // DHCPService provides DHCP API methods @@ -41,33 +43,35 @@ type dhcpStaticMappingResponse struct { // DHCPStaticMapping represents a single DHCP static reservation type DHCPStaticMapping struct { - ID int `json:"id"` - Mac string `json:"mac"` - Cid string `json:"cid"` - IPaddr string `json:"ipaddr"` - Hostname string `json:"hostname"` - Descr string `json:"descr"` - Filename string `json:"filename"` - Rootpath string `json:"rootpath"` - DefaultLeaseTime string `json:"defaultleasetime"` - MaxLeaseTime string `json:"maxleasetime"` - Gateway string `json:"gateway"` - Domain string `json:"domain"` - DomainSearchList string `json:"domainsearchlist"` - DDNSDomain string `json:"ddnsdomain"` - DDNSDomainPrimary string `json:"ddnsdomainprimary"` - DDNSDomainSecondary string `json:"ddnsdomainsecondary"` - DDNSDomainkeyName string `json:"ddnsdomainkeyname"` - DDNSDomainkeyAlgorithm string `json:"ddnsdomainkeyalgorithm"` - DDNSDomainkey string `json:"ddnsdomainkey"` - TFTP string `json:"tftp"` - LDAP string `json:"ldap"` - NextServer string `json:"nextserver"` - Filename32 string `json:"filename32"` - Filename64 string `json:"filename64"` - Filename32Arm string `json:"filename32arm"` - Filename64Arm string `json:"filename64arm"` - NumberOptions string `json:"numberoptions"` + ID int `json:"id"` + Mac string `json:"mac"` + Cid string `json:"cid"` + IPaddr string `json:"ipaddr"` + Hostname string `json:"hostname"` + Descr string `json:"descr"` + Filename string `json:"filename"` + Rootpath string `json:"rootpath"` + DefaultLeaseTime string `json:"defaultleasetime"` + MaxLeaseTime string `json:"maxleasetime"` + Gateway string `json:"gateway"` + Domain string `json:"domain"` + DomainSearchList string `json:"domainsearchlist"` + DDNSDomain string `json:"ddnsdomain"` + DDNSDomainPrimary string `json:"ddnsdomainprimary"` + DDNSDomainSecondary string `json:"ddnsdomainsecondary"` + DDNSDomainkeyName string `json:"ddnsdomainkeyname"` + DDNSDomainkeyAlgorithm string `json:"ddnsdomainkeyalgorithm"` + DDNSDomainkey string `json:"ddnsdomainkey"` + DNSServers []string `json:"dnsserver"` + TFTP string `json:"tftp"` + LDAP string `json:"ldap"` + NextServer string `json:"nextserver"` + Filename32 string `json:"filename32"` + Filename64 string `json:"filename64"` + Filename32Arm string `json:"filename32arm"` + Filename64Arm string `json:"filename64arm"` + NumberOptions string `json:"numberoptions"` + ArpTableStaticEntry TrueIfPresent `json:"arp_table_static_entry"` } // DHCPStaticMappingRequest represents a single DHCP static reservation. This @@ -151,15 +155,37 @@ type dhcpStaticMappingRequestUpdate struct { Id int `json:"id"` } +func (s DHCPService) getStaticMappingObjectId(ctx context.Context, mappingInterface string, macAddress string) (int, error) { + mappings, err := s.ListStaticMappings(ctx, mappingInterface) + + if err != nil { + return 0, err + } + + for i, mapping := range mappings { + if mapping.Mac == macAddress { + return i, nil + } + } + + return 0, fmt.Errorf("Unable to find static mapping on interface %s with mac %s", mappingInterface, macAddress) +} + // UpdateStaticMapping modifies a DHCP static mapping. func (s DHCPService) UpdateStaticMapping( ctx context.Context, - idToUpdate int, + macAddress string, mappingData DHCPStaticMappingRequest, ) (*DHCPStaticMapping, error) { + id, err := s.getStaticMappingObjectId(ctx, mappingData.Interface, macAddress) + + if err != nil { + return nil, err + } + requestData := dhcpStaticMappingRequestUpdate{ DHCPStaticMappingRequest: mappingData, - Id: idToUpdate, + Id: id, } jsonData, err := json.Marshal(requestData) @@ -179,17 +205,19 @@ func (s DHCPService) UpdateStaticMapping( } // DeleteStaticMapping deletes a DHCP static mapping. -func (s DHCPService) DeleteStaticMapping( - ctx context.Context, - mappingInterface string, - idToDelete int, -) error { - _, err := s.client.delete( +func (s DHCPService) DeleteStaticMapping(ctx context.Context, mappingInterface string, macAddress string) error { + id, err := s.getStaticMappingObjectId(ctx, mappingInterface, macAddress) + + if err != nil { + return err + } + + _, err = s.client.delete( ctx, staticMappingEndpoint, map[string]string{ "interface": mappingInterface, - "id": strconv.Itoa(idToDelete), + "id": strconv.Itoa(id), }, ) if err != nil { @@ -197,3 +225,94 @@ func (s DHCPService) DeleteStaticMapping( } return nil } + +// DHCPServerConfigurationRequest updates the current DHCP Server (dhcpd) configuration for a specified interface +type DHCPServerConfigurationRequest struct { + DefaultLeaseTime *int `json:"defaultleasetime"` + DenyUnknown bool `json:"denyunknown"` + DNSServer []string `json:"dnsserver,omitempty"` + Domain string `json:"domain,omitempty"` + DomainSearchList []string `json:"domainsearchlist,omitempty"` + Enable bool `json:"enable"` + Gateway string `json:"gateway,omitempty"` + IgnoreBootP bool `json:"ignorebootp,omitempty"` + Interface string `json:"interface"` + MacAllow []string `json:"mac_allow,omitempty"` + MacDeny []string `json:"mac_deny,omitempty"` + MaxLeaseTime *int `json:"maxleasetime,omitempty"` + NumberOptions []interface{} `json:"numberoptions,omitempty"` + RangeFrom string `json:"range_from,omitempty"` + RangeTo string `json:"range_to,omitempty"` + StaticARP bool `json:"staticarp"` +} + +type DHCPRange struct { + From string `json:"from"` + To string `json:"to"` +} + +// DHCPServerConfiguration describes the current DHCP Server (dhcpd) configuration for a specified interface +type DHCPServerConfiguration struct { + DefaultLeaseTime OptionalJSONInt `json:"defaultleasetime"` + DenyUnknown TrueIfPresent `json:"denyunknown"` + DNSServer []string `json:"dnsserver"` + Domain string `json:"domain"` + DomainSearchList string `json:"domainsearchlist"` + Enable TrueIfPresent `json:"enable"` + Gateway string `json:"gateway"` + IgnoreBootP bool `json:"ignorebootp"` + Interface string `json:"interface"` + MacAllow string `json:"mac_allow"` + MacDeny string `json:"mac_deny"` + MaxLeaseTime OptionalJSONInt `json:"maxleasetime"` + NumberOptions string `json:"numberoptions"` + Range *DHCPRange `json:"range"` + StaticARP TrueIfPresent `json:"staticarp"` +} + +type dhcpServerResponse struct { + apiResponse + Data []*DHCPServerConfiguration `json:"data"` +} + +// ListServerConfigurations lists all DHCP server configurations +func (s DHCPService) ListServerConfigurations(ctx context.Context) ([]*DHCPServerConfiguration, error) { + response, err := s.client.get(ctx, serverEndpoint, nil) + if err != nil { + return nil, err + } + + resp := new(dhcpServerResponse) + if err = json.Unmarshal(response, resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +type dhcpServerUpdateResponse struct { + apiResponse + Data *DHCPServerConfiguration `json:"data"` +} + +// UpdateServerConfiguration modifies a DHCP server configuration. +func (s DHCPService) UpdateServerConfiguration( + ctx context.Context, + dhcpConfigData DHCPServerConfigurationRequest, +) (*DHCPServerConfiguration, error) { + jsonData, err := json.Marshal(dhcpConfigData) + if err != nil { + return nil, err + } + response, err := s.client.put(ctx, serverEndpoint, nil, jsonData) + if err != nil { + return nil, err + } + + resp := new(dhcpServerUpdateResponse) + if err = json.Unmarshal(response, resp); err != nil { + return nil, err + } + + resp.Data.Interface = dhcpConfigData.Interface + return resp.Data, nil +} diff --git a/pfsenseapi/dhcp_test.go b/pfsenseapi/dhcp_test.go index 7f07791..21769cf 100644 --- a/pfsenseapi/dhcp_test.go +++ b/pfsenseapi/dhcp_test.go @@ -65,5 +65,80 @@ func TestDHCPService_ListStaticMappings(t *testing.T) { newClient := NewClientWithNoAuth(server.URL) response, err := newClient.DHCP.ListStaticMappings(context.Background(), testInterface) require.NoError(t, err) - require.Len(t, response, 1) + require.Len(t, response, 4) +} + +func TestDHCPService_DeleteStaticMappings(t *testing.T) { + listResponse := mustReadFileString(t, "testdata/liststaticmappings.json") + deleteResponse := mustReadFileString(t, "testdata/deletestaticmapping.json") + + testInterface := "IOT" + mappingId := "3" + mappingMac := "00:1d:93:aa:4c" + + handler := func(w http.ResponseWriter, r *http.Request) { + + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "invalid request") + return + } + + interfaceValue := query.Get("interface") + + if interfaceValue != testInterface { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "invalid request") + return + } + + if r.Method == http.MethodDelete { + id := query.Get("id") + + if id != mappingId { + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprintf(w, "invalid request") + } else { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, deleteResponse) + } + } else { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, listResponse) + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + err := newClient.DHCP.DeleteStaticMapping(context.Background(), testInterface, mappingMac) + require.NoError(t, err) +} + +func TestDHCPService_UpdateDHCPConfiguration(t *testing.T) { + data := makeResultList(t, mustReadFileString(t, "testdata/dhcpconfiguration.json")) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(data.popStatus()) + _, _ = io.WriteString(w, data.popResult()) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + response, err := newClient.DHCP.UpdateServerConfiguration(context.Background(), DHCPServerConfigurationRequest{}) + require.NotNil(t, response) + require.NoError(t, err) + + response, err = newClient.DHCP.UpdateServerConfiguration(context.Background(), DHCPServerConfigurationRequest{}) + require.Nil(t, response) + require.Error(t, err) + + response, err = newClient.DHCP.UpdateServerConfiguration(context.Background(), DHCPServerConfigurationRequest{}) + require.Nil(t, response) + require.Error(t, err) } diff --git a/pfsenseapi/testdata/deletestaticmapping.json b/pfsenseapi/testdata/deletestaticmapping.json new file mode 100644 index 0000000..8a5c12d --- /dev/null +++ b/pfsenseapi/testdata/deletestaticmapping.json @@ -0,0 +1,40 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": { + "mac": "00:1d:93:aa:4c", + "cid": "test", + "ipaddr": "192.168.0.5", + "hostname": "", + "descr": "", + "arp_table_static_entry": "", + "filename": "", + "rootpath": "", + "defaultleasetime": "", + "maxleasetime": "", + "dnsserver": [ + "1.1.1.1", + "8.8.8.8" + ], + "gateway": "", + "domain": "", + "domainsearchlist": "", + "ddnsdomain": "", + "ddnsdomainprimary": "", + "ddnsdomainsecondary": "", + "ddnsdomainkeyname": "", + "ddnsdomainkeyalgorithm": "hmac-md5", + "ddnsdomainkey": "", + "tftp": "", + "ldap": "", + "nextserver": "", + "filename32": "", + "filename64": "", + "filename32arm": "", + "filename64arm": "", + "uefihttpboot": "", + "numberoptions": "" + } + } \ No newline at end of file diff --git a/pfsenseapi/testdata/dhcpconfiguration.json b/pfsenseapi/testdata/dhcpconfiguration.json new file mode 100644 index 0000000..96fa1d1 --- /dev/null +++ b/pfsenseapi/testdata/dhcpconfiguration.json @@ -0,0 +1,43 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": { + "interface": "opt4", + "range": { + "from": "192.168.1.2", + "to": "192.168.1.254" + }, + "failover_peerip": "", + "defaultleasetime": "", + "maxleasetime": "", + "netmask": "", + "gateway": "", + "domain": "", + "domainsearchlist": "", + "ddnsdomain": "", + "ddnsdomainprimary": "", + "ddnsdomainsecondary": "", + "ddnsdomainkeyname": "", + "ddnsdomainkeyalgorithm": "hmac-md5", + "ddnsdomainkey": "", + "mac_allow": "", + "mac_deny": "", + "ddnsclientupdates": "allow", + "tftp": "", + "ldap": "", + "nextserver": "", + "filename": "", + "filename32": "", + "filename64": "", + "filename32arm": "", + "filename64arm": "", + "rootpath": "", + "numberoptions": "", + "enable": "", + "denyunknown": "", + "staticmap": [] + } + } + \ No newline at end of file diff --git a/pfsenseapi/testdata/liststaticmappings.json b/pfsenseapi/testdata/liststaticmappings.json index 98f2a67..2cd8c7c 100644 --- a/pfsenseapi/testdata/liststaticmappings.json +++ b/pfsenseapi/testdata/liststaticmappings.json @@ -6,15 +6,52 @@ "data": [ { "id": 0, - "mac": "52:49:23:6e:ce:90", - "cid": "", - "ipaddr": "192.168.1.2", - "hostname": "host1", - "descr": "host1", + "mac": "b4:5e:1c:9a:3b", + "ipaddr": "192.168.0.2", + "cid": "one", + "descr": "one", + "hostname": "", + "domain": "", + "gateway": "" + }, + { + "id": 1, + "mac": "c0:57:22:6f:98:12", + "ipaddr": "192.168.0.3", + "cid": "two", + "descr": "two", + "hostname": "", + "domain": "", + "gateway": "", + "arp_table_static_entry": "" + }, + { + "id": 2, + "mac": "9e:af:34:56:7b", + "ipaddr": "192.168.0.4", + "cid": "three", + "descr": "three", + "hostname": "", + "domain": "", + "gateway": "", + "arp_table_static_entry": "" + }, + { + "id": 3, + "mac": "00:1d:93:aa:4c", + "cid": "four", + "ipaddr": "192.168.0.5", + "hostname": "", + "descr": "", + "arp_table_static_entry": "", "filename": "", "rootpath": "", "defaultleasetime": "", "maxleasetime": "", + "dnsserver": [ + "1.1.1.1", + "8.8.8.8" + ], "gateway": "", "domain": "", "domainsearchlist": "", @@ -31,7 +68,8 @@ "filename64": "", "filename32arm": "", "filename64arm": "", + "uefihttpboot": "", "numberoptions": "" } ] -} +} \ No newline at end of file diff --git a/pfsenseapi/trueifpresent.go b/pfsenseapi/trueifpresent.go new file mode 100644 index 0000000..1420a2a --- /dev/null +++ b/pfsenseapi/trueifpresent.go @@ -0,0 +1,13 @@ +package pfsenseapi + +// TrueIfPresent is designed to unmarshal PFSense boolean values that can indicate +// truth by having an empty string as the value of the property +type TrueIfPresent bool + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (tip *TrueIfPresent) UnmarshalJSON(data []byte) error { + // If it has any value at all it's true + *tip = true + + return nil +} From d934324002aac8a6e88deea5efb82de65ac9c565 Mon Sep 17 00:00:00 2001 From: Eva Lacy Date: Thu, 3 Oct 2024 16:36:31 -0700 Subject: [PATCH 3/5] Fixed Firewall Create Bug --- pfsenseapi/firewall.go | 125 +++++++++++++------- pfsenseapi/firewall_test.go | 56 +++++++++ pfsenseapi/jsonint.go | 64 ++++++++++ pfsenseapi/testdata/createfirewallrule.json | 30 +++++ pfsenseapi/testdata/listfirewallrules.json | 31 +++++ 5 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 pfsenseapi/firewall_test.go create mode 100644 pfsenseapi/jsonint.go create mode 100644 pfsenseapi/testdata/createfirewallrule.json create mode 100644 pfsenseapi/testdata/listfirewallrules.json diff --git a/pfsenseapi/firewall.go b/pfsenseapi/firewall.go index cc99872..e467601 100644 --- a/pfsenseapi/firewall.go +++ b/pfsenseapi/firewall.go @@ -199,33 +199,74 @@ func (s FirewallService) Apply(ctx context.Context) error { } type FirewallRule struct { - Id string `json:"id"` - Tracker string `json:"tracker"` - Type string `json:"type"` - Interface string `json:"interface"` - Ipprotocol string `json:"ipprotocol"` - Tag string `json:"tag"` - Tagged string `json:"tagged"` - Max string `json:"max"` - MaxSrcNodes string `json:"max-src-nodes"` - MaxSrcConn string `json:"max-src-conn"` - MaxSrcStates string `json:"max-src-states"` - Statetimeout string `json:"statetimeout"` - Statetype string `json:"statetype"` - Os string `json:"os"` - Source map[string]string `json:"source"` - Destination map[string]string `json:"destination"` - Descr string `json:"descr"` + ID string `json:"id"` + AckQueue string `json:"ackqueue,omitempty"` + Direction string `json:"direction"` + DefaultQueue string `json:"defaultqueue,omitempty"` + Disabled bool `json:"disabled"` + ICMPType string `json:"icmptype,omitempty"` + Dnpipe string `json:"dnpipe,omitempty"` + TCPFlags1 string `json:"tcpflags1"` + TCPFlags2 string `json:"tcpflags2"` + Floating string `json:"floating"` + Quick string `json:"quick"` + Protocol string `json:"protocol"` + Sched string `json:"sched"` + Gateway string `json:"gateway"` + Tracker JSONInt `json:"tracker"` + Type string `json:"type"` + PDNPipe string `json:"pdnpipe,omitempty"` + Log TrueIfPresent `json:"log"` + Interface string `json:"interface"` + IPProtocol string `json:"ipprotocol"` + Tag string `json:"tag"` + Tagged string `json:"tagged"` + Max string `json:"max"` + MaxSrcNodes string `json:"max-src-nodes"` + MaxSrcConn string `json:"max-src-conn"` + MaxSrcStates string `json:"max-src-states"` + Statetimeout string `json:"statetimeout"` + Statetype string `json:"statetype"` + Os string `json:"os"` + Source *FirewallTarget `json:"source,omitempty"` + Destination *FirewallTarget `json:"destination,omitempty"` + Descr string `json:"descr"` Updated struct { - Time string `json:"time"` - Username string `json:"username"` + Time JSONInt `json:"time"` + Username string `json:"username"` } `json:"updated"` Created struct { - Time string `json:"time"` - Username string `json:"username"` + Time JSONInt `json:"time"` + Username string `json:"username"` } `json:"created"` } +type FirewallTarget struct { + Network string `json:"network,omitempty"` + Address string `json:"address,omitempty"` + Not TrueIfPresent `json:"not,omitempty"` + Any TrueIfPresent `json:"any,omitempty"` + Port string `json:"port,omitempty"` +} + +func (t *FirewallTarget) TargetString() string { + if t.Any { + return "any" + } + + prefix := "" + + if t.Not { + prefix = "!" + } + + if t.Network != "" { + return prefix + t.Network + } + + return prefix + t.Address +} + type firewallRuleListResponse struct { apiResponse Data []*FirewallRule `json:"data"` @@ -263,30 +304,30 @@ func (s FirewallService) DeleteRule(ctx context.Context, tracker int, apply bool } type FirewallRuleRequest struct { - AckQueue string `json:"ackqueue"` - DefaultQueue string `json:"defaultqueue"` - Descr string `json:"descr"` - Direction string `json:"direction"` + AckQueue string `json:"ackqueue,omitempty"` + DefaultQueue string `json:"defaultqueue,omitempty"` + Descr string `json:"descr,omitempty"` + Direction string `json:"direction,omitempty"` Disabled bool `json:"disabled"` - Dnpipe string `json:"dnpipe"` - Dst string `json:"dst"` - DstPort string `json:"dstport"` + DNPipe string `json:"dnpipe,omitempty"` + Dst string `json:"dst,omitempty"` + DstPort string `json:"dstport,omitempty"` Floating bool `json:"floating"` - Gateway string `json:"gateway"` - IcmpType []string `json:"icmptype"` + Gateway string `json:"gateway,omitempty"` + ICMPType []string `json:"icmptype,omitempty"` Interface []string `json:"interface"` - IpProtocol string `json:"ipprotocol"` + IPProtocol string `json:"ipprotocol,omitempty"` Log bool `json:"log"` - Pdnpipe string `json:"pdnpipe"` - Protocol string `json:"protocol"` - Quick bool `json:"quick"` - Sched string `json:"sched"` - Src string `json:"src"` - SrcPort string `json:"srcport"` - StateType string `json:"statetype"` - TcpFlagsAny bool `json:"tcpflags_any"` - TcpFlags1 []string `json:"tcpflags1"` - TcpFlags2 []string `json:"tcpflags2"` + PDNPipe string `json:"pdnpipe,omitempty"` + Protocol string `json:"protocol,omitempty"` + Quick bool `json:"quick,omitempty"` + Sched string `json:"sched,omitempty"` + Src string `json:"src,omitempty"` + SrcPort string `json:"srcport,omitempty"` + StateType string `json:"statetype,omitempty"` + TCPFlagsAny bool `json:"tcpflags_any"` + TCPFlags1 []string `json:"tcpflags1,omitempty"` + TCPFlags2 []string `json:"tcpflags2,omitempty"` Top bool `json:"top"` Type string `json:"type"` } @@ -315,6 +356,7 @@ func (s FirewallService) CreateRule( if err != nil { return nil, err } + response, err := s.client.post(ctx, ruleEndpoint, nil, jsonData) if err != nil { return nil, err @@ -357,6 +399,7 @@ func (s FirewallService) UpdateRule( } resp := new(createRuleResponse) + if err = json.Unmarshal(response, resp); err != nil { return nil, err } diff --git a/pfsenseapi/firewall_test.go b/pfsenseapi/firewall_test.go new file mode 100644 index 0000000..2346059 --- /dev/null +++ b/pfsenseapi/firewall_test.go @@ -0,0 +1,56 @@ +package pfsenseapi + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFirewall_ListRules(t *testing.T) { + data := makeResultList(t, mustReadFileString(t, "testdata/listfirewallrules.json")) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(data.popStatus()) + _, _ = io.WriteString(w, data.popResult()) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + response, err := newClient.Firewall.ListRules(context.Background()) + require.NoError(t, err) + require.Len(t, response, 1) + + response, err = newClient.Firewall.ListRules(context.Background()) + require.Nil(t, response) + require.Error(t, err) + + response, err = newClient.Firewall.ListRules(context.Background()) + require.Nil(t, response) + require.Error(t, err) +} + +func TestFirewall_CreateRule(t *testing.T) { + data := mustReadFileString(t, "testdata/createfirewallrule.json") + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, data) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + + response, err := newClient.Firewall.CreateRule(context.Background(), FirewallRuleRequest{}, true) + require.NoError(t, err) + require.NotNil(t, response) +} diff --git a/pfsenseapi/jsonint.go b/pfsenseapi/jsonint.go new file mode 100644 index 0000000..baaf1b4 --- /dev/null +++ b/pfsenseapi/jsonint.go @@ -0,0 +1,64 @@ +package pfsenseapi + +import ( + "encoding/json" + "strconv" +) + +// OptionalInt can unmarshal both JSON numbers and strings into an integer. +type OptionalJSONInt struct { + Value *int +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (jsi *OptionalJSONInt) UnmarshalJSON(data []byte) error { + // Try unmarshalling as int + var intValue int + if err := json.Unmarshal(data, &intValue); err != nil { + var stringValue string + if err := json.Unmarshal(data, &stringValue); err != nil { + return err + } + + if stringValue == "" { + *jsi = OptionalJSONInt{} + return nil + } else { + intValue, err = strconv.Atoi(stringValue) + + if err != nil { + return err + } + } + } + + *jsi = OptionalJSONInt{ + Value: &intValue, + } + + return nil +} + +type JSONInt int + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (jsi *JSONInt) UnmarshalJSON(data []byte) error { + // Try unmarshalling as int + var intValue int + if err := json.Unmarshal(data, &intValue); err != nil { + var stringValue string + if err := json.Unmarshal(data, &stringValue); err != nil { + return err + } + + intValue, err = strconv.Atoi(stringValue) + + if err != nil { + return err + } + } + + *jsi = JSONInt(intValue) + + return nil +} diff --git a/pfsenseapi/testdata/createfirewallrule.json b/pfsenseapi/testdata/createfirewallrule.json new file mode 100644 index 0000000..9047be3 --- /dev/null +++ b/pfsenseapi/testdata/createfirewallrule.json @@ -0,0 +1,30 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": + { + "type": "pass", + "interface": "opt9", + "ipprotocol": "inet", + "protocol": "tcp", + "source": { + "address": "GuestNetworkAlias" + }, + "destination": { + "network": "(self)", + "port": "853" + }, + "descr": "DNS over TLS", + "tracker": "1702725724", + "created": { + "time": "1702725724", + "username": "admin (API)" + }, + "updated": { + "time": "1702725724", + "username": "admin (API)" + } + } + } \ No newline at end of file diff --git a/pfsenseapi/testdata/listfirewallrules.json b/pfsenseapi/testdata/listfirewallrules.json new file mode 100644 index 0000000..38e5b98 --- /dev/null +++ b/pfsenseapi/testdata/listfirewallrules.json @@ -0,0 +1,31 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": [ + { + "type": "pass", + "interface": "opt9", + "ipprotocol": "inet", + "protocol": "tcp", + "source": { + "address": "GuestNetworkAlias" + }, + "destination": { + "network": "(self)", + "port": "853" + }, + "descr": "DNS over TLS", + "tracker": "1702725724", + "created": { + "time": "1702725724", + "username": "admin (API)" + }, + "updated": { + "time": "1702725724", + "username": "admin (API)" + } + } + ] + } \ No newline at end of file From 83ff360d77ce9b57b49bc4a2b2d999baa3309fdd Mon Sep 17 00:00:00 2001 From: Eva Lacy Date: Thu, 3 Oct 2024 16:41:15 -0700 Subject: [PATCH 4/5] Delete VLAN --- pfsenseapi/interface.go | 202 ++++++++++++++++++++++------------- pfsenseapi/interface_test.go | 16 +++ 2 files changed, 141 insertions(+), 77 deletions(-) diff --git a/pfsenseapi/interface.go b/pfsenseapi/interface.go index 37e2f3b..20b7d66 100644 --- a/pfsenseapi/interface.go +++ b/pfsenseapi/interface.go @@ -3,6 +3,7 @@ package pfsenseapi import ( "context" "encoding/json" + "fmt" "strconv" ) @@ -18,36 +19,55 @@ type InterfaceService service // Interface represents a single interface. type Interface struct { - Enable string `json:"enable"` - If string `json:"if"` - Descr string `json:"descr"` - AliasAddress string `json:"alias-address"` - AliasSubnet string `json:"alias-subnet"` - Ipaddr string `json:"ipaddr"` - Dhcprejectfrom string `json:"dhcprejectfrom"` - AdvDhcpPtTimeout string `json:"adv_dhcp_pt_timeout"` - AdvDhcpPtRetry string `json:"adv_dhcp_pt_retry"` - AdvDhcpPtSelectTimeout string `json:"adv_dhcp_pt_select_timeout"` - AdvDhcpPtReboot string `json:"adv_dhcp_pt_reboot"` - AdvDhcpPtBackoffCutoff string `json:"adv_dhcp_pt_backoff_cutoff"` - AdvDhcpPtInitialInterval string `json:"adv_dhcp_pt_initial_interval"` - AdvDhcpPtValues string `json:"adv_dhcp_pt_values"` - AdvDhcpSendOptions string `json:"adv_dhcp_send_options"` - AdvDhcpRequestOptions string `json:"adv_dhcp_request_options"` - AdvDhcpRequiredOptions string `json:"adv_dhcp_required_options"` - AdvDhcpOptionModifiers string `json:"adv_dhcp_option_modifiers"` - AdvDhcpConfigAdvanced string `json:"adv_dhcp_config_advanced"` - AdvDhcpConfigFileOverride string `json:"adv_dhcp_config_file_override"` - AdvDhcpConfigFileOverridePath string `json:"adv_dhcp_config_file_override_path"` - Ipaddrv6 string `json:"ipaddrv6"` - Dhcp6Duid string `json:"dhcp6-duid"` - Dhcp6IaPdLen string `json:"dhcp6-ia-pd-len"` - AdvDhcp6PrefixSelectedInterface string `json:"adv_dhcp6_prefix_selected_interface"` - Blockpriv string `json:"blockpriv"` - Blockbogons string `json:"blockbogons"` - Subnet string `json:"subnet"` - Spoofmac string `json:"spoofmac"` - Name string `json:"name"` + Enable TrueIfPresent `json:"enable"` + If string `json:"if"` + Descr string `json:"descr"` + AliasAddress string `json:"alias-address"` + AliasSubnet OptionalJSONInt `json:"alias-subnet"` + Ipaddr string `json:"ipaddr"` + Dhcprejectfrom string `json:"dhcprejectfrom"` + AdvDhcpPtTimeout OptionalJSONInt `json:"adv_dhcp_pt_timeout,omitempty"` + AdvDhcpPtRetry OptionalJSONInt `json:"adv_dhcp_pt_retry,omitempty"` + AdvDhcpPtSelectTimeout OptionalJSONInt `json:"adv_dhcp_pt_select_timeout,omitempty"` + AdvDhcpPtReboot OptionalJSONInt `json:"adv_dhcp_pt_reboot,omitempty"` + AdvDhcpPtBackoffCutoff OptionalJSONInt `json:"adv_dhcp_pt_backoff_cutoff,omitempty"` + AdvDhcpPtInitialInterval OptionalJSONInt `json:"adv_dhcp_pt_initial_interval,omitempty"` + AdvDhcpPtValues string `json:"adv_dhcp_pt_values"` + AdvDhcpSendOptions string `json:"adv_dhcp_send_options"` + AdvDhcpRequestOptions string `json:"adv_dhcp_request_options"` + AdvDhcpRequiredOptions string `json:"adv_dhcp_required_options"` + AdvDhcpOptionModifiers string `json:"adv_dhcp_option_modifiers"` + AdvDhcpConfigAdvanced TrueIfPresent `json:"adv_dhcp_config_advanced"` + AdvDhcpConfigFileOverride TrueIfPresent `json:"adv_dhcp_config_file_override"` + AdvDhcpConfigFileOverridePath string `json:"adv_dhcp_config_file_override_path"` + Ipaddrv6 string `json:"ipaddrv6"` + Dhcp6Duid string `json:"dhcp6-duid"` + Dhcp6IaPdLen string `json:"dhcp6-ia-pd-len"` + AdvDhcp6PrefixSelectedInterface string `json:"adv_dhcp6_prefix_selected_interface"` + Blockpriv TrueIfPresent `json:"blockpriv"` + Blockbogons TrueIfPresent `json:"blockbogons"` + Subnet OptionalJSONInt `json:"subnet,omitempty"` + Spoofmac string `json:"spoofmac"` + Name string `json:"name"` + AdvDhcpConfigFileOverrideFile string `json:"adv_dhcp_config_file_override_file"` + Apply TrueIfPresent `json:"apply"` + Dhcpcvpt OptionalJSONInt `json:"dhcpcvpt,omitempty"` + Dhcphostname string `json:"dhcphostname"` + Dhcpvlanenable TrueIfPresent `json:"dhcpvlanenable"` + Gateway string `json:"gateway"` + Gateway6Rd string `json:"gateway-6rd"` + Gatewayv6 string `json:"gatewayv6"` + Ipv6Usev4Iface TrueIfPresent `json:"ipv6usev4iface"` + Media string `json:"media"` + Mss string `json:"mss"` + Mtu OptionalJSONInt `json:"mtu,omitempty"` + Prefix6Rd string `json:"prefix-6rd"` + Prefix6RdV4Plen OptionalJSONInt `json:"prefix-6rd-v4plen,omitempty"` + Subnetv6 string `json:"subnetv6"` + Track6Interface string `json:"track6-interface"` + Track6PrefixIdHex OptionalJSONInt `json:"track6-prefix-id-hex,omitempty"` + Type string `json:"type"` + Type6 string `json:"type6"` } type interfaceListResponse struct { @@ -95,47 +115,47 @@ func (s InterfaceService) DeleteInterface(ctx context.Context, interfaceID strin type InterfaceRequest struct { AdvDhcpConfigAdvanced bool `json:"adv_dhcp_config_advanced"` AdvDhcpConfigFileOverride bool `json:"adv_dhcp_config_file_override"` - AdvDhcpConfigFileOverrideFile string `json:"adv_dhcp_config_file_override_file"` - AdvDhcpOptionModifiers string `json:"adv_dhcp_option_modifiers"` - AdvDhcpPtBackoffCutoff int `json:"adv_dhcp_pt_backoff_cutoff"` - AdvDhcpPtInitialInterval int `json:"adv_dhcp_pt_initial_interval"` - AdvDhcpPtReboot int `json:"adv_dhcp_pt_reboot"` - AdvDhcpPtRetry int `json:"adv_dhcp_pt_retry"` - AdvDhcpPtSelectTimeout int `json:"adv_dhcp_pt_select_timeout"` - AdvDhcpPtTimeout int `json:"adv_dhcp_pt_timeout"` - AdvDhcpRequestOptions string `json:"adv_dhcp_request_options"` - AdvDhcpRequiredOptions string `json:"adv_dhcp_required_options"` - AdvDhcpSendOptions string `json:"adv_dhcp_send_options"` - AliasAddress string `json:"alias-address"` - AliasSubnet int `json:"alias-subnet"` + AdvDhcpConfigFileOverrideFile string `json:"adv_dhcp_config_file_override_file,omitempty"` + AdvDhcpOptionModifiers string `json:"adv_dhcp_option_modifiers,omitempty"` + AdvDhcpPtBackoffCutoff *int `json:"adv_dhcp_pt_backoff_cutoff,omitempty"` + AdvDhcpPtInitialInterval *int `json:"adv_dhcp_pt_initial_interval,omitempty"` + AdvDhcpPtReboot *int `json:"adv_dhcp_pt_reboot,omitempty"` + AdvDhcpPtRetry *int `json:"adv_dhcp_pt_retry,omitempty"` + AdvDhcpPtSelectTimeout *int `json:"adv_dhcp_pt_select_timeout,omitempty"` + AdvDhcpPtTimeout *int `json:"adv_dhcp_pt_timeout,omitempty"` + AdvDhcpRequestOptions string `json:"adv_dhcp_request_options,omitempty"` + AdvDhcpRequiredOptions string `json:"adv_dhcp_required_options,omitempty"` + AdvDhcpSendOptions string `json:"adv_dhcp_send_options,omitempty"` + AliasAddress string `json:"alias-address,omitempty"` + AliasSubnet *int `json:"alias-subnet,omitempty"` Apply bool `json:"apply"` Blockbogons bool `json:"blockbogons"` Blockpriv bool `json:"blockpriv"` Descr string `json:"descr"` - Dhcpcvpt int `json:"dhcpcvpt"` - Dhcphostname string `json:"dhcphostname"` - Dhcprejectfrom []string `json:"dhcprejectfrom"` + Dhcpcvpt *int `json:"dhcpcvpt,omitempty"` + Dhcphostname string `json:"dhcphostname,omitempty"` + Dhcprejectfrom []string `json:"dhcprejectfrom,omitempty"` Dhcpvlanenable bool `json:"dhcpvlanenable"` Enable bool `json:"enable"` - Gateway string `json:"gateway"` - Gateway6Rd string `json:"gateway-6rd"` - Gatewayv6 string `json:"gatewayv6"` + Gateway string `json:"gateway,omitempty"` + Gateway6Rd string `json:"gateway-6rd,omitempty"` + Gatewayv6 string `json:"gatewayv6,omitempty"` If string `json:"if"` - Ipaddr string `json:"ipaddr"` - Ipaddrv6 string `json:"ipaddrv6"` + Ipaddr string `json:"ipaddr,omitempty"` + Ipaddrv6 string `json:"ipaddrv6,omitempty"` Ipv6Usev4Iface bool `json:"ipv6usev4iface"` - Media string `json:"media"` - Mss string `json:"mss"` - Mtu int `json:"mtu"` - Prefix6Rd string `json:"prefix-6rd"` - Prefix6RdV4Plen int `json:"prefix-6rd-v4plen"` - Spoofmac string `json:"spoofmac"` - Subnet int `json:"subnet"` - Subnetv6 string `json:"subnetv6"` - Track6Interface string `json:"track6-interface"` - Track6PrefixIdHex int `json:"track6-prefix-id-hex"` - Type string `json:"type"` - Type6 string `json:"type6"` + Media string `json:"media,omitempty"` + Mss string `json:"mss,omitempty"` + Mtu *int `json:"mtu,omitempty"` + Prefix6Rd string `json:"prefix-6rd,omitempty"` + Prefix6RdV4Plen *int `json:"prefix-6rd-v4plen"` + Spoofmac string `json:"spoofmac,omitempty"` + Subnet *int `json:"subnet,omitempty"` + Subnetv6 string `json:"subnetv6,omitempty"` + Track6Interface string `json:"track6-interface,omitempty"` + Track6PrefixIdHex *int `json:"track6-prefix-id-hex,omitempty"` + Type string `json:"type,omitempty"` + Type6 string `json:"type6,omitempty"` } type createInterfaceResponse struct { @@ -173,12 +193,12 @@ type interfaceRequestUpdate struct { // UpdateInterface modifies an existing interface. func (s InterfaceService) UpdateInterface( ctx context.Context, - idToUpdate int, + idToUpdate string, interfaceData InterfaceRequest, ) (*Interface, error) { requestData := interfaceRequestUpdate{ InterfaceRequest: interfaceData, - Id: strconv.Itoa(idToUpdate), + Id: idToUpdate, } jsonData, err := json.Marshal(requestData) @@ -200,11 +220,11 @@ func (s InterfaceService) UpdateInterface( // VLAN represents a single VLAN. type VLAN struct { - If string `json:"if"` - Tag string `json:"tag"` - Pcp string `json:"pcp"` - Descr string `json:"descr"` - Vlanif string `json:"vlanif"` + If string `json:"if"` + Tag JSONInt `json:"tag"` + Pcp OptionalJSONInt `json:"pcp"` + Descr string `json:"descr"` + Vlanif string `json:"vlanif"` } type vlanListResponse struct { @@ -228,12 +248,18 @@ func (s InterfaceService) ListVLANs(ctx context.Context) ([]*VLAN, error) { } // DeleteVLAN deletes a VLAN. -func (s InterfaceService) DeleteVLAN(ctx context.Context, idToDelete int) error { - _, err := s.client.delete( +func (s InterfaceService) DeleteVLAN(ctx context.Context, vlanIf string) error { + i, err := s.getVLANIndex(ctx, vlanIf) + + if err != nil { + return err + } + + _, err = s.client.delete( ctx, interfaceVLANEndpoint, map[string]string{ - "id": strconv.Itoa(idToDelete), + "id": strconv.Itoa(i), }, ) if err != nil { @@ -242,10 +268,26 @@ func (s InterfaceService) DeleteVLAN(ctx context.Context, idToDelete int) error return nil } +func (s InterfaceService) getVLANIndex(ctx context.Context, vlanIf string) (int, error) { + vlans, err := s.ListVLANs(ctx) + + if err != nil { + return -1, err + } + + for i, vlan := range vlans { + if vlan.Vlanif == vlanIf { + return i, nil + } + } + + return -1, fmt.Errorf("Unable to find VLAN IF %s", vlanIf) +} + type VLANRequest struct { Descr string `json:"descr"` If string `json:"if"` - Pcp int `json:"pcp"` + Pcp *int `json:"pcp,omitempty"` Tag int `json:"tag"` } @@ -284,12 +326,18 @@ type vlanRequestUpdate struct { // UpdateVLAN modifies an existing VLAN. func (s InterfaceService) UpdateVLAN( ctx context.Context, - idToUpdate int, + vlanIf string, vlanData VLANRequest, ) (*VLAN, error) { + i, err := s.getVLANIndex(ctx, vlanIf) + + if err != nil { + return nil, err + } + requestData := vlanRequestUpdate{ VLANRequest: vlanData, - Id: idToUpdate, + Id: i, } jsonData, err := json.Marshal(requestData) @@ -297,7 +345,7 @@ func (s InterfaceService) UpdateVLAN( return nil, err } - response, err := s.client.put(ctx, interfaceEndpoint, nil, jsonData) + response, err := s.client.put(ctx, interfaceVLANEndpoint, nil, jsonData) if err != nil { return nil, err } diff --git a/pfsenseapi/interface_test.go b/pfsenseapi/interface_test.go index 702678d..c216a25 100644 --- a/pfsenseapi/interface_test.go +++ b/pfsenseapi/interface_test.go @@ -60,3 +60,19 @@ func TestInterfaceService_ListVLANs(t *testing.T) { require.NoError(t, err) require.Len(t, response, 2) } + +func TestInterfaceService_DeleteVLAN(t *testing.T) { + data := mustReadFileString(t, "testdata/listvlans.json") + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, data) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + err := newClient.Interface.DeleteVLAN(context.Background(), "ix3.20") + require.NoError(t, err) +} From ac07898f7dc60377c4f23c8752968e2fb362dd78 Mon Sep 17 00:00:00 2001 From: Eva Lacy Date: Thu, 3 Oct 2024 18:59:25 -0700 Subject: [PATCH 5/5] Added Unbound Host Override --- pfsenseapi/client.go | 5 +- pfsenseapi/stringarray.go | 22 +++ pfsenseapi/testdata/listhostoverrides.json | 22 +++ pfsenseapi/testdata/updatehostoverride.json | 14 ++ pfsenseapi/unbound.go | 184 ++++++++++++++++++++ pfsenseapi/unbound_test.go | 173 ++++++++++++++++++ 6 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 pfsenseapi/stringarray.go create mode 100644 pfsenseapi/testdata/listhostoverrides.json create mode 100644 pfsenseapi/testdata/updatehostoverride.json create mode 100644 pfsenseapi/unbound.go create mode 100644 pfsenseapi/unbound_test.go diff --git a/pfsenseapi/client.go b/pfsenseapi/client.go index d16148c..7c58717 100644 --- a/pfsenseapi/client.go +++ b/pfsenseapi/client.go @@ -7,11 +7,12 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/exp/slices" "io" "net/http" "sync" "time" + + "golang.org/x/exp/slices" ) var ( @@ -39,6 +40,7 @@ type Client struct { System *SystemService Token *TokenService DHCP *DHCPService + Unbound *UnboundService Status *StatusService Interface *InterfaceService Routing *RoutingService @@ -96,6 +98,7 @@ func NewClient(config Config) *Client { newClient.Routing = &RoutingService{client: newClient} newClient.Firewall = &FirewallService{client: newClient} newClient.User = &UserService{client: newClient} + newClient.Unbound = &UnboundService{client: newClient} return newClient } diff --git a/pfsenseapi/stringarray.go b/pfsenseapi/stringarray.go new file mode 100644 index 0000000..7b9338f --- /dev/null +++ b/pfsenseapi/stringarray.go @@ -0,0 +1,22 @@ +package pfsenseapi + +import "strings" + +// StringArray is designed to unmarshal PFSense string arrays which look like +// "192.168.0.1,192.168.1.1" +type StringArray []string + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (sa *StringArray) UnmarshalJSON(data []byte) error { + // Empty string is "" + if len(data) <= 2 { + *sa = make(StringArray, 0) + return nil + } + + // Remove quotes from string + data = data[1 : len(data)-1] + + *sa = strings.Split(string(data), ",") + return nil +} diff --git a/pfsenseapi/testdata/listhostoverrides.json b/pfsenseapi/testdata/listhostoverrides.json new file mode 100644 index 0000000..ac6062e --- /dev/null +++ b/pfsenseapi/testdata/listhostoverrides.json @@ -0,0 +1,22 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": [ + { + "host": "one", + "domain": "test.com", + "ip": "192.168.1.1", + "descr": "1", + "aliases": "" + }, + { + "host": "two", + "domain": "test.com", + "ip": "192.168.1.1", + "descr": "two", + "aliases": "" + } + ] + } \ No newline at end of file diff --git a/pfsenseapi/testdata/updatehostoverride.json b/pfsenseapi/testdata/updatehostoverride.json new file mode 100644 index 0000000..0db2cb5 --- /dev/null +++ b/pfsenseapi/testdata/updatehostoverride.json @@ -0,0 +1,14 @@ +{ + "status": "ok", + "code": 200, + "return": 0, + "message": "Success", + "data": + { + "host": "two", + "domain": "test.com", + "ip": "192.168.1.1", + "descr": "two", + "aliases": "" + } + } \ No newline at end of file diff --git a/pfsenseapi/unbound.go b/pfsenseapi/unbound.go new file mode 100644 index 0000000..ed9da8b --- /dev/null +++ b/pfsenseapi/unbound.go @@ -0,0 +1,184 @@ +package pfsenseapi + +import ( + "context" + "encoding/json" + "fmt" + "strconv" +) + +const ( + hostOverrideEndpoint = "api/v1/services/unbound/host_override" +) + +// Unbound provides Unbound API methods +type UnboundService service + +// Gateway represents a single routing gateway +type UnboundHostOverride struct { + Aliases *UnboundAliasesList `json:"aliases,omitempty"` + Description string `json:"descr"` + Domain string `json:"domain"` + Host string `json:"host"` + IP StringArray `json:"ip"` +} + +type UnboundAliasesList struct { + Items []*UnboundHostOverrideAlias `json:"item"` +} + +func (ual *UnboundAliasesList) UnmarshalJSON(data []byte) error { + if len(data) == 0 || (len(data) == 2 && string(data) == "\"\"") { + *ual = UnboundAliasesList{} + return nil + } + + type unboundAliasesList UnboundAliasesList + + aux := &struct { + *unboundAliasesList + }{ + unboundAliasesList: (*unboundAliasesList)(ual), + } + + return json.Unmarshal(data, &aux) +} + +type UnboundHostOverrideAlias struct { + Host string `json:"host"` + Domain string `json:"domain"` + Description string `json:"description"` +} + +type apiWriteResponse[ResponseType any] struct { + apiResponse + Data *ResponseType `json:"data"` +} + +type apiListResponse[ResponseType any] struct { + apiResponse + Data []ResponseType `json:"data"` +} + +type createHostOverride struct { + UnboundHostOverride + Apply bool `json:"apply"` +} + +func (s UnboundService) CreateHostOverride( + ctx context.Context, + hostOverride *UnboundHostOverride, + apply bool, +) (*UnboundHostOverride, error) { + jsonData, err := json.Marshal(&createHostOverride{ + UnboundHostOverride: *hostOverride, + Apply: apply, + }) + + if err != nil { + return nil, err + } + + response, err := s.client.post(ctx, hostOverrideEndpoint, nil, jsonData) + if err != nil { + return nil, err + } + + return s.parseWriteResponse(response) +} + +type updateHostOverride struct { + UnboundHostOverride + Apply bool `json:"apply"` + Id string `json:"id"` +} + +func (s UnboundService) parseWriteResponse( + response []byte, +) (*UnboundHostOverride, error) { + resp := new(apiWriteResponse[UnboundHostOverride]) + if err := json.Unmarshal(response, resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (s UnboundService) UpdateHostOverride( + ctx context.Context, + hostOverride *UnboundHostOverride, + apply bool, +) (*UnboundHostOverride, error) { + id, err := s.getHostOverridesObjectId(ctx, hostOverride.Host, hostOverride.Domain) + if err != nil { + return nil, fmt.Errorf("error finding override: %v", err) + } + + jsonData, err := json.Marshal(&updateHostOverride{ + UnboundHostOverride: *hostOverride, + Apply: apply, + Id: fmt.Sprint(id), + }) + + if err != nil { + return nil, fmt.Errorf("error marshaling request: %v", err) + } + + response, err := s.client.put(ctx, hostOverrideEndpoint, nil, jsonData) + if err != nil { + return nil, fmt.Errorf("error sending request: %v", err) + } + + return s.parseWriteResponse(response) +} + +// Gets the index of the Unbound Host Override, you can only have one host override with the same host, name and ip type +func (s UnboundService) getHostOverridesObjectId(ctx context.Context, host string, domain string) (int, error) { + list, err := s.ListHostOverrides(ctx) + + if err != nil { + return 0, err + } + + for i, item := range list { + if item.Host == host && item.Domain == domain { + return i, nil + } + } + + return 0, fmt.Errorf("Unable to find host override with host %s, domain %s", host, domain) +} + +func (s UnboundService) ListHostOverrides(ctx context.Context) ([]*UnboundHostOverride, error) { + response, err := s.client.get(ctx, hostOverrideEndpoint, nil) + if err != nil { + return nil, err + } + + resp := new(apiListResponse[*UnboundHostOverride]) + if err = json.Unmarshal(response, &resp); err != nil { + return nil, err + } + return resp.Data, nil +} + +func (s UnboundService) DeleteHostOverride(ctx context.Context, host string, domain string, apply bool) error { + id, err := s.getHostOverridesObjectId(ctx, host, domain) + + if err != nil { + return err + } + + queryMap := map[string]string{ + "id": fmt.Sprint(id), + "apply": strconv.FormatBool(apply), + } + + _, err = s.client.delete(ctx, hostOverrideEndpoint, queryMap) + + if err != nil { + return err + } + + return nil +} diff --git a/pfsenseapi/unbound_test.go b/pfsenseapi/unbound_test.go new file mode 100644 index 0000000..3f0aedb --- /dev/null +++ b/pfsenseapi/unbound_test.go @@ -0,0 +1,173 @@ +package pfsenseapi + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnbound_CreateHostOverride(t *testing.T) { + data := mustReadFileString(t, "testdata/listhostoverrides.json") + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + value := UnboundHostOverride{} + + body, err := io.ReadAll(r.Body) + + if err != nil { + t.Fatalf("Unable to read body %v", err) + } + + if err := json.Unmarshal(body, &value); err != nil { + t.Fatalf("Encountered error while parsing body %v", err) + } + + r := apiWriteResponse[UnboundHostOverride]{ + apiResponse: apiResponse{ + Status: "ok", + Code: 200, + Return: 0, + Message: "success", + }, + Data: &value, + } + + result, err := json.Marshal(r) + + if err != nil { + t.Fatalf("Encountered error while marshalling response %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + if _, err := w.Write(result); err != nil { + t.Errorf("Encountered error while writing response: %v", err) + } + } else { + w.WriteHeader(200) + _, _ = io.WriteString(w, data) + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + response, err := newClient.Unbound.CreateHostOverride(context.Background(), &UnboundHostOverride{ + Domain: "test.com", + Host: "two", + }, true) + require.NoError(t, err) + require.NotNil(t, response) +} + +func TestUnbound_UpdateHostOverride(t *testing.T) { + data := mustReadFileString(t, "testdata/listhostoverrides.json") + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + value := UnboundHostOverride{} + + body, err := io.ReadAll(r.Body) + + if err != nil { + t.Fatalf("Unable to read body %v", err) + } + + if err := json.Unmarshal(body, &value); err != nil { + t.Fatalf("Encountered error while parsing body %v", err) + } + + r := apiWriteResponse[UnboundHostOverride]{ + apiResponse: apiResponse{ + Status: "ok", + Code: 200, + Return: 0, + Message: "success", + }, + Data: &value, + } + + result, err := json.Marshal(r) + + if err != nil { + t.Fatalf("Encountered error while marshalling response %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + if _, err := w.Write(result); err != nil { + t.Errorf("encountered error while writing response: %v", err) + } + } else { + w.WriteHeader(200) + _, _ = io.WriteString(w, data) + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + response, err := newClient.Unbound.UpdateHostOverride(context.Background(), &UnboundHostOverride{ + Domain: "test.com", + Host: "two", + }, true) + require.NoError(t, err) + require.NotNil(t, response) +} + +func TestUnbound_ListHostOverrides(t *testing.T) { + data := makeResultList(t, mustReadFileString(t, "testdata/listhostoverrides.json")) + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(data.popStatus()) + _, _ = io.WriteString(w, data.popResult()) + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + response, err := newClient.Unbound.ListHostOverrides(context.Background()) + require.NoError(t, err) + require.Len(t, response, 2) + + response, err = newClient.Unbound.ListHostOverrides(context.Background()) + require.Error(t, err) + require.Nil(t, response) + + response, err = newClient.Unbound.ListHostOverrides(context.Background()) + require.Error(t, err) + require.Nil(t, response) + +} + +func TestUnbound_DeleteHostOverride(t *testing.T) { + data := mustReadFileString(t, "testdata/listhostoverrides.json") + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(200) + } else { + w.WriteHeader(200) + _, _ = io.WriteString(w, data) + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + newClient := NewClientWithNoAuth(server.URL) + err := newClient.Unbound.DeleteHostOverride(context.Background(), "two", "test.com", true) + require.NoError(t, err) +}