diff --git a/client.go b/client.go index 288ed3a82..b898d208f 100644 --- a/client.go +++ b/client.go @@ -57,6 +57,7 @@ type Client struct { DomainRecords *Resource Domains *Resource Events *Resource + Firewalls *Resource IPAddresses *Resource IPv6Pools *Resource IPv6Ranges *Resource @@ -257,6 +258,7 @@ func addResources(client *Client) { domainRecordsName: NewResource(client, domainRecordsName, domainRecordsEndpoint, true, DomainRecord{}, DomainRecordsPagedResponse{}), domainsName: NewResource(client, domainsName, domainsEndpoint, false, Domain{}, DomainsPagedResponse{}), eventsName: NewResource(client, eventsName, eventsEndpoint, false, Event{}, EventsPagedResponse{}), + firewallsName: NewResource(client, firewallsName, firewallsEndpoint, false, Firewall{}, FirewallsPagedResponse{}), imagesName: NewResource(client, imagesName, imagesEndpoint, false, Image{}, ImagesPagedResponse{}), instanceConfigsName: NewResource(client, instanceConfigsName, instanceConfigsEndpoint, true, InstanceConfig{}, InstanceConfigsPagedResponse{}), instanceDisksName: NewResource(client, instanceDisksName, instanceDisksEndpoint, true, InstanceDisk{}, InstanceDisksPagedResponse{}), @@ -306,6 +308,7 @@ func addResources(client *Client) { client.DomainRecords = resources[domainRecordsName] client.Domains = resources[domainsName] client.Events = resources[eventsName] + client.Firewalls = resources[firewallsName] client.IPAddresses = resources[ipaddressesName] client.IPv6Pools = resources[ipv6poolsName] client.IPv6Ranges = resources[ipv6rangesName] diff --git a/firewall_rules.go b/firewall_rules.go new file mode 100644 index 000000000..7dfc952ce --- /dev/null +++ b/firewall_rules.go @@ -0,0 +1,30 @@ +package linodego + +// NetworkProtocol enum type +type NetworkProtocol string + +// NetworkProtocol enum values +const ( + TCP NetworkProtocol = "TCP" + UDP NetworkProtocol = "UDP" + ICMP NetworkProtocol = "ALL" +) + +// NetworkAddresses are arrays of ipv4 and v6 addresses +type NetworkAddresses struct { + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` +} + +// A FirewallRule is a whitelist of ports, protocols, and addresses for which traffic should be allowed. +type FirewallRule struct { + Ports string `json:"ports"` + Protocol NetworkProtocol `json:"protocol"` + Addresses NetworkAddresses `json:"addresses"` +} + +// FirewallRuleSet is a pair of inbound and outbound rules that specify what network traffic should be allowed. +type FirewallRuleSet struct { + Inbound []FirewallRule `json:"inbound,omitempty"` + Outbound []FirewallRule `json:"outbound,omitempty"` +} diff --git a/firewalls.go b/firewalls.go new file mode 100644 index 000000000..21b4e43a9 --- /dev/null +++ b/firewalls.go @@ -0,0 +1,138 @@ +package linodego + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/linode/linodego/internal/parseabletime" +) + +// FirewallStatus enum type +type FirewallStatus string + +// FirewallStatus enums start with Firewall +const ( + FirewallEnabled FirewallStatus = "enabled" + FirewallDisabled FirewallStatus = "disabled" + FirewallDeleted FirewallStatus = "deleted" +) + +// A Firewall is a set of networking rules (iptables) applied to Devices with which it is associated +type Firewall struct { + ID int `json:"id"` + Label string `json:"label"` + Status FirewallStatus `json:"status"` + Tags []string `json:"tags,omitempty"` + Rules FirewallRuleSet `json:"rules"` + Created *time.Time `json:"-"` + Updated *time.Time `json:"-"` +} + +// DevicesCreationOptions fields are used when adding devices during the Firewall creation process. +type DevicesCreationOptions struct { + Linodes []string `json:"linodes,omitempty"` + NodeBalancers []string `json:"nodebalancers,omitempty"` +} + +// FirewallCreateOptions fields are those accepted by CreateFirewall +type FirewallCreateOptions struct { + Label string `json:"label,omitempty"` + Rules FirewallRuleSet `json:"rules"` + Tags []string `json:"tags,omitempty"` + Devices DevicesCreationOptions `json:"devices,omitempty"` +} + +// UnmarshalJSON for Firewall responses +func (i *Firewall) UnmarshalJSON(b []byte) error { + type Mask Firewall + + p := struct { + *Mask + Created *parseabletime.ParseableTime `json:"created"` + Updated *parseabletime.ParseableTime `json:"updated"` + }{ + Mask: (*Mask)(i), + } + + if err := json.Unmarshal(b, &p); err != nil { + return err + } + + i.Created = (*time.Time)(p.Created) + i.Updated = (*time.Time)(p.Updated) + + return nil +} + +// FirewallsPagedResponse represents a Linode API response for listing of Cloud Firewalls +type FirewallsPagedResponse struct { + *PageOptions + Data []Firewall `json:"data"` +} + +func (FirewallsPagedResponse) endpoint(c *Client) string { + endpoint, err := c.Firewalls.Endpoint() + if err != nil { + panic(err) + } + return endpoint +} + +func (resp *FirewallsPagedResponse) appendData(r *FirewallsPagedResponse) { + resp.Data = append(resp.Data, r.Data...) +} + +// ListFirewalls returns a paginated list of Cloud Firewalls +func (c *Client) ListFirewalls(ctx context.Context, opts *ListOptions) ([]Firewall, error) { + response := FirewallsPagedResponse{} + + err := c.listHelper(ctx, &response, opts) + + if err != nil { + return nil, err + } + + return response.Data, nil +} + +// CreateFirewall creates a single Firewall with at least one set of inbound or outbound rules +func (c *Client) CreateFirewall(ctx context.Context, createOpts FirewallCreateOptions) (*Firewall, error) { + var body string + e, err := c.Firewalls.Endpoint() + if err != nil { + return nil, err + } + + req := c.R(ctx).SetResult(&Firewall{}) + + if bodyData, err := json.Marshal(createOpts); err == nil { + body = string(bodyData) + } else { + return nil, NewError(err) + } + + r, err := coupleAPIErrors(req. + SetBody(body). + Post(e)) + + if err != nil { + return nil, err + } + return r.Result().(*Firewall), nil +} + +// DeleteFirewall deletes a single Firewall with the provided ID +func (c *Client) DeleteFirewall(ctx context.Context, id int) error { + e, err := c.Firewalls.Endpoint() + if err != nil { + return err + } + + req := c.R(ctx) + + e = fmt.Sprintf("%s/%d", e, id) + _, err = coupleAPIErrors(req.Delete(e)) + return err +} diff --git a/go.sum b/go.sum index 754aa4b80..ae0cf74ef 100644 --- a/go.sum +++ b/go.sum @@ -14,9 +14,8 @@ golang.org/x/net v0.0.0-20190628185345-da137c7871d7 h1:rTIdg5QFRR7XCaK4LCjBiPbx8 golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42Tnys***REMOVED***UPdjciGhY= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pagination.go b/pagination.go index 5ba74cdbf..a07997288 100644 --- a/pagination.go +++ b/pagination.go @@ -114,6 +114,12 @@ func (c *Client) listHelper(ctx context.Context, i interface{}, opts *ListOption results = r.Result().(*EventsPagedResponse).Results v.appendData(r.Result().(*EventsPagedResponse)) } + case *FirewallsPagedResponse: + if r, err = coupleAPIErrors(req.SetResult(FirewallsPagedResponse{}).Get(v.endpoint(c))); err == nil { + pages = r.Result().(*FirewallsPagedResponse).Pages + results = r.Result().(*FirewallsPagedResponse).Results + v.appendData(r.Result().(*FirewallsPagedResponse)) + } case *LKEClustersPagedResponse: if r, err = coupleAPIErrors(req.SetResult(LKEClustersPagedResponse{}).Get(v.endpoint(c))); err == nil { pages = r.Result().(*LKEClustersPagedResponse).Pages diff --git a/resources.go b/resources.go index 3c8c2950f..ff59ceb86 100644 --- a/resources.go +++ b/resources.go @@ -15,6 +15,7 @@ const ( domainRecordsName = "records" domainsName = "domains" eventsName = "events" + firewallsName = "firewalls" imagesName = "images" instanceConfigsName = "configs" instanceDisksName = "disks" @@ -62,6 +63,7 @@ const ( domainRecordsEndpoint = "domains/{{ .ID }}/records" domainsEndpoint = "domains" eventsEndpoint = "account/events" + firewallsEndpoint = "networking/firewalls" imagesEndpoint = "images" instanceConfigsEndpoint = "linode/instances/{{ .ID }}/configs" instanceDisksEndpoint = "linode/instances/{{ .ID }}/disks" diff --git a/test/integration/firewall_rules_test.go b/test/integration/firewall_rules_test.go new file mode 100644 index 000000000..f0fddb37a --- /dev/null +++ b/test/integration/firewall_rules_test.go @@ -0,0 +1,21 @@ +package integration + +import ( + "github.com/linode/linodego" +) + +var ( + testFirewallRule = linodego.FirewallRule{ + Ports: "22", + Protocol: "TCP", + Addresses: linodego.NetworkAddresses{ + IPv4: []string{"0.0.0.0/0"}, + IPv6: []string{"::0/0"}, + }, + } + + testFirewallRuleSet = linodego.FirewallRuleSet{ + Inbound: []linodego.FirewallRule{testFirewallRule}, + Outbound: []linodego.FirewallRule{testFirewallRule}, + } +) diff --git a/test/integration/firewalls_test.go b/test/integration/firewalls_test.go new file mode 100644 index 000000000..9f0c1b6a4 --- /dev/null +++ b/test/integration/firewalls_test.go @@ -0,0 +1,64 @@ +package integration + +import ( + "context" + "testing" + + "github.com/linode/linodego" +) + +var ( + testFirewallCreateOpts = linodego.FirewallCreateOptions{ + Label: "label", + Rules: testFirewallRuleSet, // borrowed from firewall_rules.test.go + Tags: []string{"testing"}, + } +) + +// TestListFirewalls should return a paginated list of Firewalls +func TestListFirewalls(t *testing.T) { + client, _, teardown, err := setupFirewall(t, []firewallModifier{ + func(createOpts *linodego.FirewallCreateOptions) { + createOpts.Label = randString(12, lowerBytes, digits) + "-linodego-testing" + }, + }, "fixtures/TestListFirewalls") + if err != nil { + t.Error(err) + } + defer teardown() + + result, err := client.ListFirewalls(context.Background(), nil) + if err != nil { + t.Errorf("Error listing Firewalls, expected struct, got error %v", err) + } + + if len(result) == 0 { + t.Errorf("Expected a list of Firewalls, but got none: %v", err) + } +} + +type firewallModifier func(*linodego.FirewallCreateOptions) + +func setupFirewall(t *testing.T, firewallModifiers []firewallModifier, fixturesYaml string) (*linodego.Client, *linodego.Firewall, func(), error) { + t.Helper() + var fixtureTeardown func() + client, fixtureTeardown := createTestClient(t, fixturesYaml) + + createOpts := testFirewallCreateOpts + for _, modifier := range firewallModifiers { + modifier(&createOpts) + } + + firewall, err := client.CreateFirewall(context.Background(), createOpts) + if err != nil { + t.Errorf("Error creating Firewall, expected struct, got error %v", err) + } + + teardown := func() { + if err := client.DeleteFirewall(context.Background(), firewall.ID); err != nil { + t.Errorf("Expected to delete a Firewall, but got %v", err) + } + fixtureTeardown() + } + return client, firewall, teardown, err +} diff --git a/test/integration/fixtures/TestListFirewalls.yaml b/test/integration/fixtures/TestListFirewalls.yaml new file mode 100644 index 000000000..343a97823 --- /dev/null +++ b/test/integration/fixtures/TestListFirewalls.yaml @@ -0,0 +1,241 @@ +--- +version: 1 +interactions: +- request: + body: '{"label":"a83ef3go2h76-linodego-testing","rules":{"inbound":[{"ports":"22","protocol":"TCP","addresses":{"ipv4":["0.0.0.0/0"],"ipv6":["::0/0"]}}],"outbound":[{"ports":"22","protocol":"TCP","addresses":{"ipv4":["0.0.0.0/0"],"ipv6":["::0/0"]}}]},"tags":["testing"],"devices":{}}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego 0.12.0 https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/firewalls + method: POST + response: + body: '{"id": 37, "label": "a83ef3go2h76-linodego-testing", "created": "2020-02-19T19:38:16", + "updated": "2020-02-19T19:38:16", "status": "enabled", "rules": {"inbound": + [{"ports": "22", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": + ["::0/0"]}}], "outbound": [{"ports": "22", "protocol": "TCP", "addresses": {"ipv4": + ["0.0.0.0/0"], "ipv6": ["::0/0"]}}]}, "tags": ["testing"]}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=60, s-maxage=60 + Connection: + - keep-alive + Content-Length: + - "386" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Date: + - Wed, 19 Feb 2020 19:38:16 GMT + Retry-After: + - "41" + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - firewall:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1600" + X-Ratelimit-Remaining: + - "1593" + X-Ratelimit-Reset: + - "1582141138" + X-Spec-Version: + - 4.14.0 + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego 0.12.0 https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/firewalls + method: GET + response: + body: '{"data": [{"id": 19, "label": "firewall19", "created": "2020-02-10T22:24:05", + "updated": "2020-02-10T22:24:05", "status": "disabled", "rules": {"inbound": + [{"ports": "22", "protocol": "TCP", "addresses": {"ipv4": ["172.104.2.4"]}}]}, + "tags": []}, {"id": 20, "label": "firewall20", "created": "2020-02-11T14:48:23", + "updated": "2020-02-11T14:48:23", "status": "disabled", "rules": {"inbound": + [{"ports": "22", "protocol": "TCP", "addresses": {"ipv4": ["172.104.2.4"]}}]}, + "tags": []}, {"id": 31, "label": "firewall31", "created": "2020-02-14T22:31:45", + "updated": "2020-02-14T22:31:45", "status": "disabled", "rules": {"inbound": + [{"ports": "3306", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": + ["::0/0"]}}], "outbound": [{"ports": "3306", "protocol": "TCP", "addresses": + {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}]}, "tags": []}, {"id": 32, "label": + "blah", "created": "2020-02-19T15:36:40", "updated": "2020-02-19T15:36:40", + "status": "enabled", "rules": {"inbound": [{"ports": "80", "protocol": "TCP", + "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}, {"ports": "443", "protocol": + "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}], "outbound": + [{"ports": "80", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": + ["::0/0"]}}, {"ports": "443", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], + "ipv6": ["::0/0"]}}]}, "tags": []}, {"id": 33, "label": "test-firewall", "created": + "2020-02-19T17:45:58", "updated": "2020-02-19T17:45:58", "status": "enabled", + "rules": {"inbound": [{"ports": "80", "protocol": "TCP", "addresses": {"ipv4": + ["0.0.0.0/0"], "ipv6": ["::0/0"]}}, {"ports": "443", "protocol": "TCP", "addresses": + {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}], "outbound": [{"ports": "80", "protocol": + "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}, {"ports": "443", + "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}]}, + "tags": []}, {"id": 34, "label": "asdf", "created": "2020-02-19T18:03:47", "updated": + "2020-02-19T18:03:47", "status": "disabled", "rules": {"inbound": [{"ports": + "443", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}], + "outbound": [{"ports": "443", "protocol": "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], + "ipv6": ["::0/0"]}}]}, "tags": []}, {"id": 37, "label": "a83ef3go2h76-linodego-testing", + "created": "2020-02-19T19:38:17", "updated": "2020-02-19T19:38:17", "status": + "enabled", "rules": {"inbound": [{"ports": "22", "protocol": "TCP", "addresses": + {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}], "outbound": [{"ports": "22", "protocol": + "TCP", "addresses": {"ipv4": ["0.0.0.0/0"], "ipv6": ["::0/0"]}}]}, "tags": ["testing"]}], + "page": 1, "pages": 1, "results": 7}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=0, s-maxage=0, no-cache, no-store + - private, max-age=60, s-maxage=60 + Connection: + - keep-alive + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Date: + - Wed, 19 Feb 2020 19:38:16 GMT + Retry-After: + - "41" + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - firewall:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1600" + X-Ratelimit-Remaining: + - "1592" + X-Ratelimit-Reset: + - "1582141138" + X-Spec-Version: + - 4.14.0 + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego 0.12.0 https://github.com/linode/linodego + url: https://api.linode.com/v4beta/networking/firewalls/37 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=60, s-maxage=60 + Connection: + - keep-alive + Content-Length: + - "2" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Date: + - Wed, 19 Feb 2020 19:38:17 GMT + Retry-After: + - "40" + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - firewall:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1600" + X-Ratelimit-Remaining: + - "1591" + X-Ratelimit-Reset: + - "1582141138" + X-Spec-Version: + - 4.14.0 + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: ""