From 75c3dca67f7e76595c0b4e121e140a299a91c1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20K=C3=A4mmerling?= Date: Wed, 10 Mar 2021 10:35:51 +0100 Subject: [PATCH] Add support for Firewalls (#166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lukas Kämmerling Co-authored-by: Günther Eberl --- go.mod | 2 +- go.sum | 5 +- hcloud/action.go | 102 ++++++- hcloud/action_test.go | 1 + hcloud/client.go | 2 + hcloud/error.go | 7 + hcloud/firewall.go | 371 +++++++++++++++++++++++ hcloud/firewall_test.go | 606 ++++++++++++++++++++++++++++++++++++++ hcloud/schema.go | 128 ++++++++ hcloud/schema/firewall.go | 98 ++++++ hcloud/schema/server.go | 38 ++- hcloud/schema_test.go | 102 ++++++- hcloud/server.go | 39 ++- hcloud/server_test.go | 46 +++ 14 files changed, 1516 insertions(+), 31 deletions(-) create mode 100644 hcloud/firewall.go create mode 100644 hcloud/firewall_test.go create mode 100644 hcloud/schema/firewall.go diff --git a/go.mod b/go.mod index c9f96552..7f2a7b20 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/hetznercloud/hcloud-go go 1.16 -require github.com/google/go-cmp v0.5.0 +require github.com/google/go-cmp v0.5.2 diff --git a/go.sum b/go.sum index 169675cd..2b51a989 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ -github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/hcloud/action.go b/hcloud/action.go index f9ff403a..e3e2091e 100644 --- a/hcloud/action.go +++ b/hcloud/action.go @@ -96,12 +96,16 @@ func (c *ActionClient) GetByID(ctx context.Context, id int) (*Action, *Response, // ActionListOpts specifies options for listing actions. type ActionListOpts struct { ListOpts + ID []int Status []ActionStatus Sort []string } func (l ActionListOpts) values() url.Values { vals := l.ListOpts.values() + for _, id := range l.ID { + vals.Add("id", fmt.Sprintf("%d", id)) + } for _, status := range l.Status { vals.Add("status", string(status)) } @@ -157,24 +161,95 @@ func (c *ActionClient) All(ctx context.Context) ([]*Action, error) { return allActions, nil } -// WatchProgress watches the action's progress until it completes with success or error. -func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-chan int, <-chan error) { - errCh := make(chan error, 1) +// AllWithOpts returns all actions for the given options. +func (c *ActionClient) AllWithOpts(ctx context.Context, opts ActionListOpts) ([]*Action, error) { + allActions := []*Action{} + + err := c.client.all(func(page int) (*Response, error) { + opts.Page = page + actions, resp, err := c.List(ctx, opts) + if err != nil { + return resp, err + } + allActions = append(allActions, actions...) + return resp, nil + }) + if err != nil { + return nil, err + } + + return allActions, nil +} + +// WatchOverallProgress watches several actions' progress until they complete with success or error. +func (c *ActionClient) WatchOverallProgress(ctx context.Context, actions []*Action) (<-chan int, <-chan error) { + errCh := make(chan error, len(actions)) progressCh := make(chan int) go func() { defer close(errCh) defer close(progressCh) + successIDs := make([]int, 0, len(actions)) + watchIDs := make(map[int]struct{}, len(actions)) + for _, action := range actions { + watchIDs[action.ID] = struct{}{} + } + ticker := time.NewTicker(c.client.pollInterval) - sendProgress := func(p int) { + defer ticker.Stop() + for { select { - case progressCh <- p: - break - default: + case <-ctx.Done(): + errCh <- ctx.Err() + return + case <-ticker.C: break } + + opts := ActionListOpts{} + for watchID := range watchIDs { + opts.ID = append(opts.ID, watchID) + } + + as, err := c.AllWithOpts(ctx, opts) + if err != nil { + errCh <- err + return + } + + for _, a := range as { + switch a.Status { + case ActionStatusSuccess: + delete(watchIDs, a.ID) + successIDs := append(successIDs, a.ID) + sendProgress(progressCh, int(float64(len(actions)-len(successIDs))/float64(len(actions))*100)) + case ActionStatusError: + delete(watchIDs, a.ID) + errCh <- fmt.Errorf("action %d failed: %w", a.ID, a.Error()) + } + } + + if len(watchIDs) == 0 { + return + } } + }() + + return progressCh, errCh +} + +// WatchProgress watches one action's progress until it completes with success or error. +func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-chan int, <-chan error) { + errCh := make(chan error, 1) + progressCh := make(chan int) + + go func() { + defer close(errCh) + defer close(progressCh) + + ticker := time.NewTicker(c.client.pollInterval) + defer ticker.Stop() for { select { @@ -193,9 +268,9 @@ func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-cha switch a.Status { case ActionStatusRunning: - sendProgress(a.Progress) + sendProgress(progressCh, a.Progress) case ActionStatusSuccess: - sendProgress(100) + sendProgress(progressCh, 100) errCh <- nil return case ActionStatusError: @@ -207,3 +282,12 @@ func (c *ActionClient) WatchProgress(ctx context.Context, action *Action) (<-cha return progressCh, errCh } + +func sendProgress(progressCh chan int, p int) { + select { + case progressCh <- p: + break + default: + break + } +} diff --git a/hcloud/action_test.go b/hcloud/action_test.go index e9bca526..ffaf5ce8 100644 --- a/hcloud/action_test.go +++ b/hcloud/action_test.go @@ -169,6 +169,7 @@ func TestActionClientWatchProgress(t *testing.T) { defer env.Teardown() callCount := 0 + env.Mux.HandleFunc("/actions/1", func(w http.ResponseWriter, r *http.Request) { callCount++ w.Header().Set("Content-Type", "application/json") diff --git a/hcloud/client.go b/hcloud/client.go index 0736bd42..25b713be 100644 --- a/hcloud/client.go +++ b/hcloud/client.go @@ -61,6 +61,7 @@ type Client struct { Action ActionClient Certificate CertificateClient Datacenter DatacenterClient + Firewall FirewallClient FloatingIP FloatingIPClient Image ImageClient ISO ISOClient @@ -162,6 +163,7 @@ func NewClient(options ...ClientOption) *Client { client.LoadBalancer = LoadBalancerClient{client: client} client.LoadBalancerType = LoadBalancerTypeClient{client: client} client.Certificate = CertificateClient{client: client} + client.Firewall = FirewallClient{client: client} return client } diff --git a/hcloud/error.go b/hcloud/error.go index db67652b..5066d7eb 100644 --- a/hcloud/error.go +++ b/hcloud/error.go @@ -51,6 +51,13 @@ const ( ErrorCodeNoSpaceLeftInLocation ErrorCode = "no_space_left_in_location" // There is no volume space left in the given location ErrorCodeVolumeAlreadyAttached ErrorCode = "volume_already_attached" // Volume is already attached to a server, detach first + // Firewall related error codes + ErrorCodeFirewallAlreadyApplied ErrorCode = "firewall_already_applied" // Firewall was already applied on resource + ErrorCodeFirewallAlreadyRemoved ErrorCode = "firewall_already_removed" // Firewall was already removed from the resource + ErrorCodeIncompatibleNetworkType ErrorCode = "incompatible_network_type" // The Network type is incompatible for the given resource + ErrorCodeResourceInUse ErrorCode = "resource_in_use" // Firewall must not be in use to be deleted + ErrorCodeServerAlreadyAdded ErrorCode = "server_already_added" // Server added more than one time to resource + // Deprecated error codes // The actual value of this error code is limit_reached. The new error code // rate_limit_exceeded for ratelimiting was introduced before Hetzner Cloud diff --git a/hcloud/firewall.go b/hcloud/firewall.go new file mode 100644 index 00000000..23613a44 --- /dev/null +++ b/hcloud/firewall.go @@ -0,0 +1,371 @@ +package hcloud + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "time" + + "github.com/hetznercloud/hcloud-go/hcloud/schema" +) + +// Firewall represents a Firewall in the Hetzner Cloud. +type Firewall struct { + ID int + Name string + Labels map[string]string + Created time.Time + Rules []FirewallRule + AppliedTo []FirewallResource +} + +// FirewallRule represents a Firewall's rules. +type FirewallRule struct { + Direction FirewallRuleDirection + SourceIPs []net.IPNet + DestinationIPs []net.IPNet + Protocol FirewallRuleProtocol + Port *string +} + +// FirewallRuleDirection specifies the direction of a Firewall rule. +type FirewallRuleDirection string + +const ( + // FirewallRuleDirectionIn specifies a rule for inbound traffic. + FirewallRuleDirectionIn FirewallRuleDirection = "in" + + // FirewallRuleDirectionOut specifies a rule for outbound traffic. + FirewallRuleDirectionOut FirewallRuleDirection = "out" +) + +// FirewallRuleProtocol specifies the protocol of a Firewall rule. +type FirewallRuleProtocol string + +const ( + // FirewallRuleProtocolTCP specifies a TCP rule. + FirewallRuleProtocolTCP FirewallRuleProtocol = "tcp" + // FirewallRuleProtocolUDP specifies a UDP rule. + FirewallRuleProtocolUDP FirewallRuleProtocol = "udp" + // FirewallRuleProtocolICMP specifies an ICMP rule. + FirewallRuleProtocolICMP FirewallRuleProtocol = "icmp" +) + +// FirewallResourceType specifies the resource to apply a Firewall on. +type FirewallResourceType string + +const ( + // FirewallResourceTypeServer specifies a Server. + FirewallResourceTypeServer FirewallResourceType = "server" +) + +// FirewallResource represents a resource to apply the new Firewall on. +type FirewallResource struct { + Type FirewallResourceType + Server *FirewallResourceServer +} + +// FirewallResourceServer represents a Server to apply a Firewall on. +type FirewallResourceServer struct { + ID int +} + +// FirewallClient is a client for the Firewalls API. +type FirewallClient struct { + client *Client +} + +// GetByID retrieves a Firewall by its ID. If the Firewall does not exist, nil is returned. +func (c *FirewallClient) GetByID(ctx context.Context, id int) (*Firewall, *Response, error) { + req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/firewalls/%d", id), nil) + if err != nil { + return nil, nil, err + } + + var body schema.FirewallGetResponse + resp, err := c.client.Do(req, &body) + if err != nil { + if IsError(err, ErrorCodeNotFound) { + return nil, resp, nil + } + return nil, nil, err + } + return FirewallFromSchema(body.Firewall), resp, nil +} + +// GetByName retrieves a Firewall by its name. If the Firewall does not exist, nil is returned. +func (c *FirewallClient) GetByName(ctx context.Context, name string) (*Firewall, *Response, error) { + if name == "" { + return nil, nil, nil + } + firewalls, response, err := c.List(ctx, FirewallListOpts{Name: name}) + if len(firewalls) == 0 { + return nil, response, err + } + return firewalls[0], response, err +} + +// Get retrieves a Firewall by its ID if the input can be parsed as an integer, otherwise it +// retrieves a Firewall by its name. If the Firewall does not exist, nil is returned. +func (c *FirewallClient) Get(ctx context.Context, idOrName string) (*Firewall, *Response, error) { + if id, err := strconv.Atoi(idOrName); err == nil { + return c.GetByID(ctx, int(id)) + } + return c.GetByName(ctx, idOrName) +} + +// FirewallListOpts specifies options for listing Firewalls. +type FirewallListOpts struct { + ListOpts + Name string +} + +func (l FirewallListOpts) values() url.Values { + vals := l.ListOpts.values() + if l.Name != "" { + vals.Add("name", l.Name) + } + return vals +} + +// List returns a list of Firewalls for a specific page. +// +// Please note that filters specified in opts are not taken into account +// when their value corresponds to their zero value or when they are empty. +func (c *FirewallClient) List(ctx context.Context, opts FirewallListOpts) ([]*Firewall, *Response, error) { + path := "/firewalls?" + opts.values().Encode() + req, err := c.client.NewRequest(ctx, "GET", path, nil) + if err != nil { + return nil, nil, err + } + + var body schema.FirewallListResponse + resp, err := c.client.Do(req, &body) + if err != nil { + return nil, nil, err + } + firewalls := make([]*Firewall, 0, len(body.Firewalls)) + for _, s := range body.Firewalls { + firewalls = append(firewalls, FirewallFromSchema(s)) + } + return firewalls, resp, nil +} + +// All returns all Firewalls. +func (c *FirewallClient) All(ctx context.Context) ([]*Firewall, error) { + allFirewalls := []*Firewall{} + + opts := FirewallListOpts{} + opts.PerPage = 50 + + err := c.client.all(func(page int) (*Response, error) { + opts.Page = page + firewalls, resp, err := c.List(ctx, opts) + if err != nil { + return resp, err + } + allFirewalls = append(allFirewalls, firewalls...) + return resp, nil + }) + if err != nil { + return nil, err + } + + return allFirewalls, nil +} + +// AllWithOpts returns all Firewalls for the given options. +func (c *FirewallClient) AllWithOpts(ctx context.Context, opts FirewallListOpts) ([]*Firewall, error) { + var allFirewalls []*Firewall + + err := c.client.all(func(page int) (*Response, error) { + opts.Page = page + firewalls, resp, err := c.List(ctx, opts) + if err != nil { + return resp, err + } + allFirewalls = append(allFirewalls, firewalls...) + return resp, nil + }) + if err != nil { + return nil, err + } + + return allFirewalls, nil +} + +// FirewallCreateOpts specifies options for creating a new Firewall. +type FirewallCreateOpts struct { + Name string + Labels map[string]string + Rules []FirewallRule + ApplyTo []FirewallResource +} + +// Validate checks if options are valid. +func (o FirewallCreateOpts) Validate() error { + if o.Name == "" { + return errors.New("missing name") + } + return nil +} + +// FirewallCreateResult is the result of a create Firewall call. +type FirewallCreateResult struct { + Firewall *Firewall + Actions []*Action +} + +// Create creates a new Firewall. +func (c *FirewallClient) Create(ctx context.Context, opts FirewallCreateOpts) (FirewallCreateResult, *Response, error) { + if err := opts.Validate(); err != nil { + return FirewallCreateResult{}, nil, err + } + reqBody := firewallCreateOptsToSchema(opts) + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return FirewallCreateResult{}, nil, err + } + req, err := c.client.NewRequest(ctx, "POST", "/firewalls", bytes.NewReader(reqBodyData)) + if err != nil { + return FirewallCreateResult{}, nil, err + } + + respBody := schema.FirewallCreateResponse{} + resp, err := c.client.Do(req, &respBody) + if err != nil { + return FirewallCreateResult{}, resp, err + } + result := FirewallCreateResult{ + Firewall: FirewallFromSchema(respBody.Firewall), + Actions: ActionsFromSchema(respBody.Actions), + } + return result, resp, nil +} + +// FirewallUpdateOpts specifies options for updating a Firewall. +type FirewallUpdateOpts struct { + Name string + Labels map[string]string +} + +// Update updates a Firewall. +func (c *FirewallClient) Update(ctx context.Context, firewall *Firewall, opts FirewallUpdateOpts) (*Firewall, *Response, error) { + reqBody := schema.FirewallUpdateRequest{} + if opts.Name != "" { + reqBody.Name = &opts.Name + } + if opts.Labels != nil { + reqBody.Labels = &opts.Labels + } + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/firewalls/%d", firewall.ID) + req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + respBody := schema.FirewallUpdateResponse{} + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return FirewallFromSchema(respBody.Firewall), resp, nil +} + +// Delete deletes a Firewall. +func (c *FirewallClient) Delete(ctx context.Context, firewall *Firewall) (*Response, error) { + req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/firewalls/%d", firewall.ID), nil) + if err != nil { + return nil, err + } + return c.client.Do(req, nil) +} + +// FirewallSetRulesOpts specifies options for setting rules of a Firewall. +type FirewallSetRulesOpts struct { + Rules []FirewallRule +} + +// SetRules sets the rules of a Firewall. +func (c *FirewallClient) SetRules(ctx context.Context, firewall *Firewall, opts FirewallSetRulesOpts) ([]*Action, *Response, error) { + reqBody := firewallSetRulesOptsToSchema(opts) + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/firewalls/%d/actions/set_rules", firewall.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody schema.FirewallActionSetRulesResponse + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionsFromSchema(respBody.Actions), resp, nil +} + +func (c *FirewallClient) ApplyResources(ctx context.Context, firewall *Firewall, resources []FirewallResource) ([]*Action, *Response, error) { + applyTo := make([]schema.FirewallResource, len(resources)) + for i, r := range resources { + applyTo[i] = firewallResourceToSchema(r) + } + + reqBody := schema.FirewallActionApplyToResourcesRequest{ApplyTo: applyTo} + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/firewalls/%d/actions/apply_to_resources", firewall.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody schema.FirewallActionApplyToResourcesResponse + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionsFromSchema(respBody.Actions), resp, nil +} + +func (c *FirewallClient) RemoveResources(ctx context.Context, firewall *Firewall, resources []FirewallResource) ([]*Action, *Response, error) { + removeFrom := make([]schema.FirewallResource, len(resources)) + for i, r := range resources { + removeFrom[i] = firewallResourceToSchema(r) + } + + reqBody := schema.FirewallActionRemoveFromResourcesRequest{RemoveFrom: removeFrom} + reqBodyData, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, err + } + + path := fmt.Sprintf("/firewalls/%d/actions/remove_from_resources", firewall.ID) + req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData)) + if err != nil { + return nil, nil, err + } + + var respBody schema.FirewallActionRemoveFromResourcesResponse + resp, err := c.client.Do(req, &respBody) + if err != nil { + return nil, resp, err + } + return ActionsFromSchema(respBody.Actions), resp, nil +} diff --git a/hcloud/firewall_test.go b/hcloud/firewall_test.go new file mode 100644 index 00000000..efbf32cf --- /dev/null +++ b/hcloud/firewall_test.go @@ -0,0 +1,606 @@ +package hcloud + +import ( + "context" + "encoding/json" + "net" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hetznercloud/hcloud-go/hcloud/schema" +) + +func TestFirewallCreateOptsValidate(t *testing.T) { + testCases := map[string]struct { + Opts FirewallCreateOpts + Valid bool + }{ + "empty": { + Opts: FirewallCreateOpts{}, + Valid: false, + }, + "all set": { + Opts: FirewallCreateOpts{ + Name: "name", + Labels: map[string]string{}, + }, + Valid: true, + }, + } + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + err := testCase.Opts.Validate() + if err == nil && !testCase.Valid || err != nil && testCase.Valid { + t.FailNow() + } + }) + } +} + +func TestFirewallClientGetByID(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(schema.FirewallGetResponse{ + Firewall: schema.Firewall{ + ID: 1, + }, + }) + }) + + ctx := context.Background() + + firewall, _, err := env.Client.Firewall.GetByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + if firewall == nil { + t.Fatal("no firewall") + } + if firewall.ID != 1 { + t.Errorf("unexpected firewall ID: %v", firewall.ID) + } + + t.Run("called via Get", func(t *testing.T) { + firewall, _, err := env.Client.Firewall.Get(ctx, "1") + if err != nil { + t.Fatal(err) + } + if firewall == nil { + t.Fatal("no firewall") + } + if firewall.ID != 1 { + t.Errorf("unexpected firewall ID: %v", firewall.ID) + } + }) +} + +func TestFirewallClientGetByIDNotFound(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(schema.ErrorResponse{ + Error: schema.Error{ + Code: string(ErrorCodeNotFound), + }, + }) + }) + + ctx := context.Background() + + firewall, _, err := env.Client.Firewall.GetByID(ctx, 1) + if err != nil { + t.Fatal(err) + } + if firewall != nil { + t.Fatal("expected no firewall") + } +} + +func TestFirewallClientGetByName(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "name=myfirewall" { + t.Fatal("missing name query") + } + json.NewEncoder(w).Encode(schema.FirewallListResponse{ + Firewalls: []schema.Firewall{ + { + ID: 1, + Name: "myfirewall", + }, + }, + }) + }) + + ctx := context.Background() + + firewall, _, err := env.Client.Firewall.GetByName(ctx, "myfirewall") + if err != nil { + t.Fatal(err) + } + if firewall == nil { + t.Fatal("no firewall") + } + if firewall.ID != 1 { + t.Errorf("unexpected firewall ID: %v", firewall.ID) + } + + t.Run("via Get", func(t *testing.T) { + firewall, _, err := env.Client.Firewall.Get(ctx, "myfirewall") + if err != nil { + t.Fatal(err) + } + if firewall == nil { + t.Fatal("no firewall") + } + if firewall.ID != 1 { + t.Errorf("unexpected firewall ID: %v", firewall.ID) + } + }) +} + +func TestFirewallClientGetByNameNotFound(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls", func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "name=myfirewall" { + t.Fatal("missing name query") + } + json.NewEncoder(w).Encode(schema.FirewallListResponse{ + Firewalls: []schema.Firewall{}, + }) + }) + + ctx := context.Background() + + firewall, _, err := env.Client.Firewall.GetByName(ctx, "myfirewall") + if err != nil { + t.Fatal(err) + } + if firewall != nil { + t.Fatal("unexpected firewall") + } +} + +func TestFirewallClientGetByNameEmpty(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + ctx := context.Background() + + firewall, _, err := env.Client.Firewall.GetByName(ctx, "") + if err != nil { + t.Fatal(err) + } + if firewall != nil { + t.Fatal("unexpected firewall") + } +} + +func TestFirewallCreate(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls", func(w http.ResponseWriter, r *http.Request) { + var reqBody schema.FirewallCreateRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallCreateRequest{ + Name: "myfirewall", + Labels: func() *map[string]string { + labels := map[string]string{"key": "value"} + return &labels + }(), + ApplyTo: []schema.FirewallResource{ + { + Type: "server", + Server: &schema.FirewallResourceServer{ + ID: 2, + }, + }, + }, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallCreateResponse{ + Firewall: schema.Firewall{ID: 1}, + }) + }) + + ctx := context.Background() + + opts := FirewallCreateOpts{ + Name: "myfirewall", + Labels: map[string]string{"key": "value"}, + ApplyTo: []FirewallResource{ + { + Type: FirewallResourceTypeServer, + Server: &FirewallResourceServer{ + ID: 2, + }, + }, + }, + } + _, _, err := env.Client.Firewall.Create(ctx, opts) + if err != nil { + t.Fatal(err) + } +} + +func TestFirewallCreateValidation(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + ctx := context.Background() + opts := FirewallCreateOpts{} + _, _, err := env.Client.Firewall.Create(ctx, opts) + if err == nil || err.Error() != "missing name" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFirewallDelete(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1", func(w http.ResponseWriter, r *http.Request) {}) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + _, err := env.Client.Firewall.Delete(ctx, firewall) + if err != nil { + t.Fatal(err) + } +} + +func TestFirewallClientUpdate(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Error("expected PUT") + } + var reqBody schema.FirewallUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallUpdateRequest{ + Name: String("test"), + Labels: func() *map[string]string { + labels := map[string]string{"key": "value"} + return &labels + }(), + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallUpdateResponse{ + Firewall: schema.Firewall{ + ID: 1, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + opts := FirewallUpdateOpts{ + Name: "test", + Labels: map[string]string{"key": "value"}, + } + updatedFirewall, _, err := env.Client.Firewall.Update(ctx, firewall, opts) + if err != nil { + t.Fatal(err) + } + if updatedFirewall.ID != 1 { + t.Errorf("unexpected firewall ID: %v", updatedFirewall.ID) + } +} + +func TestFirewallSetRules(t *testing.T) { + t.Run("direction in", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1/actions/set_rules", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + var reqBody schema.FirewallActionSetRulesRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallActionSetRulesRequest{ + Rules: []schema.FirewallRule{ + { + Direction: "in", + SourceIPs: []string{"10.0.0.5/32", "10.0.0.6/32"}, + Protocol: "icmp", + }, + }, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallActionSetRulesResponse{ + Actions: []schema.Action{ + { + ID: 1, + }, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + opts := FirewallSetRulesOpts{ + Rules: []FirewallRule{ + { + Direction: FirewallRuleDirectionIn, + SourceIPs: []net.IPNet{ + { + IP: net.ParseIP("10.0.0.5"), + Mask: net.CIDRMask(32, 32), + }, + { + IP: net.ParseIP("10.0.0.6"), + Mask: net.CIDRMask(32, 32), + }, + }, + Protocol: FirewallRuleProtocolICMP, + }, + }, + } + + actions, _, err := env.Client.Firewall.SetRules(ctx, firewall, opts) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].ID != 1 { + t.Errorf("unexpected actions: %v", actions) + } + }) + t.Run("direction out", func(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1/actions/set_rules", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + var reqBody schema.FirewallActionSetRulesRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallActionSetRulesRequest{ + Rules: []schema.FirewallRule{ + { + Direction: "out", + DestinationIPs: []string{"10.0.0.5/32", "10.0.0.6/32"}, + Protocol: "icmp", + }, + }, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallActionSetRulesResponse{ + Actions: []schema.Action{ + { + ID: 1, + }, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + opts := FirewallSetRulesOpts{ + Rules: []FirewallRule{ + { + Direction: FirewallRuleDirectionOut, + DestinationIPs: []net.IPNet{ + { + IP: net.ParseIP("10.0.0.5"), + Mask: net.CIDRMask(32, 32), + }, + { + IP: net.ParseIP("10.0.0.6"), + Mask: net.CIDRMask(32, 32), + }, + }, + Protocol: FirewallRuleProtocolICMP, + }, + }, + } + + actions, _, err := env.Client.Firewall.SetRules(ctx, firewall, opts) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].ID != 1 { + t.Errorf("unexpected actions: %v", actions) + } + }) +} + +func TestFirewallSetRulesEmpty(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1/actions/set_rules", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + var reqBody schema.FirewallActionSetRulesRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallActionSetRulesRequest{ + Rules: []schema.FirewallRule{}, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallActionSetRulesResponse{ + Actions: []schema.Action{ + { + ID: 1, + }, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + opts := FirewallSetRulesOpts{ + Rules: []FirewallRule{}, + } + + actions, _, err := env.Client.Firewall.SetRules(ctx, firewall, opts) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].ID != 1 { + t.Errorf("unexpected actions: %v", actions) + } +} + +func TestFirewallApplyToResources(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1/actions/apply_to_resources", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + var reqBody schema.FirewallActionApplyToResourcesRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallActionApplyToResourcesRequest{ + ApplyTo: []schema.FirewallResource{ + { + Type: "server", + Server: &schema.FirewallResourceServer{ID: 5}, + }, + }, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallActionApplyToResourcesResponse{ + Actions: []schema.Action{ + { + ID: 1, + }, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + resources := []FirewallResource{ + { + Type: FirewallResourceTypeServer, + Server: &FirewallResourceServer{ID: 5}, + }, + } + + actions, _, err := env.Client.Firewall.ApplyResources(ctx, firewall, resources) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].ID != 1 { + t.Errorf("unexpected actions: %v", actions) + } +} + +func TestFirewallRemoveFromResources(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/firewalls/1/actions/remove_from_resources", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Error("expected POST") + } + var reqBody schema.FirewallActionRemoveFromResourcesRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + expectedReqBody := schema.FirewallActionRemoveFromResourcesRequest{ + RemoveFrom: []schema.FirewallResource{ + { + Type: "server", + Server: &schema.FirewallResourceServer{ID: 5}, + }, + }, + } + if !cmp.Equal(expectedReqBody, reqBody) { + t.Log(cmp.Diff(expectedReqBody, reqBody)) + t.Error("unexpected request body") + } + json.NewEncoder(w).Encode(schema.FirewallActionRemoveFromResourcesResponse{ + Actions: []schema.Action{ + { + ID: 1, + }, + }, + }) + }) + + var ( + ctx = context.Background() + firewall = &Firewall{ID: 1} + ) + + resources := []FirewallResource{ + { + Type: FirewallResourceTypeServer, + Server: &FirewallResourceServer{ID: 5}, + }, + } + + actions, _, err := env.Client.Firewall.RemoveResources(ctx, firewall, resources) + if err != nil { + t.Fatal(err) + } + if len(actions) != 1 || actions[0].ID != 1 { + t.Errorf("unexpected actions: %v", actions) + } +} diff --git a/hcloud/schema.go b/hcloud/schema.go index f8ddc881..363898a6 100644 --- a/hcloud/schema.go +++ b/hcloud/schema.go @@ -184,6 +184,13 @@ func ServerPublicNetFromSchema(s schema.ServerPublicNet) ServerPublicNet { for _, id := range s.FloatingIPs { publicNet.FloatingIPs = append(publicNet.FloatingIPs, &FloatingIP{ID: id}) } + for _, fw := range s.Firewalls { + publicNet.Firewalls = append(publicNet.Firewalls, + &ServerFirewallStatus{ + Firewall: Firewall{ID: fw.ID}, + Status: FirewallStatus(fw.Status)}, + ) + } return publicNet } @@ -694,6 +701,50 @@ func PricingFromSchema(s schema.Pricing) Pricing { return p } +// FirewallFromSchema converts a schema.Firewall to a Firewall. +func FirewallFromSchema(s schema.Firewall) *Firewall { + f := &Firewall{ + ID: s.ID, + Name: s.Name, + Labels: map[string]string{}, + Created: s.Created, + } + for key, value := range s.Labels { + f.Labels[key] = value + } + for _, res := range s.AppliedTo { + r := FirewallResource{Type: FirewallResourceType(res.Type)} + if r.Type == FirewallResourceTypeServer { + r.Server = &FirewallResourceServer{ID: res.Server.ID} + } + f.AppliedTo = append(f.AppliedTo, r) + } + for _, rule := range s.Rules { + sourceIPs := []net.IPNet{} + for _, sourceIP := range rule.SourceIPs { + _, mask, err := net.ParseCIDR(sourceIP) + if err == nil && mask != nil { + sourceIPs = append(sourceIPs, *mask) + } + } + destinationIPs := []net.IPNet{} + for _, destinationIP := range rule.DestinationIPs { + _, mask, err := net.ParseCIDR(destinationIP) + if err == nil && mask != nil { + destinationIPs = append(destinationIPs, *mask) + } + } + f.Rules = append(f.Rules, FirewallRule{ + Direction: FirewallRuleDirection(rule.Direction), + SourceIPs: sourceIPs, + DestinationIPs: destinationIPs, + Protocol: FirewallRuleProtocol(rule.Protocol), + Port: rule.Port, + }) + } + return f +} + func loadBalancerCreateOptsToSchema(opts LoadBalancerCreateOpts) schema.LoadBalancerCreateRequest { req := schema.LoadBalancerCreateRequest{ Name: opts.Name, @@ -900,6 +951,83 @@ func loadBalancerUpdateServiceOptsToSchema(opts LoadBalancerUpdateServiceOpts) s return req } +func firewallCreateOptsToSchema(opts FirewallCreateOpts) schema.FirewallCreateRequest { + req := schema.FirewallCreateRequest{ + Name: opts.Name, + } + if opts.Labels != nil { + req.Labels = &opts.Labels + } + for _, rule := range opts.Rules { + schemaRule := schema.FirewallRule{ + Direction: string(rule.Direction), + Protocol: string(rule.Protocol), + Port: rule.Port, + } + switch rule.Direction { + case FirewallRuleDirectionOut: + schemaRule.DestinationIPs = make([]string, len(rule.DestinationIPs)) + for i, destinationIP := range rule.DestinationIPs { + schemaRule.DestinationIPs[i] = destinationIP.String() + } + case FirewallRuleDirectionIn: + schemaRule.SourceIPs = make([]string, len(rule.SourceIPs)) + for i, sourceIP := range rule.SourceIPs { + schemaRule.SourceIPs[i] = sourceIP.String() + } + } + req.Rules = append(req.Rules, schemaRule) + } + for _, res := range opts.ApplyTo { + schemaFirewallResource := schema.FirewallResource{ + Type: string(res.Type), + } + if res.Type == FirewallResourceTypeServer { + schemaFirewallResource.Server = &schema.FirewallResourceServer{ + ID: res.Server.ID, + } + } + + req.ApplyTo = append(req.ApplyTo, schemaFirewallResource) + } + return req +} + +func firewallSetRulesOptsToSchema(opts FirewallSetRulesOpts) schema.FirewallActionSetRulesRequest { + req := schema.FirewallActionSetRulesRequest{Rules: []schema.FirewallRule{}} + for _, rule := range opts.Rules { + schemaRule := schema.FirewallRule{ + Direction: string(rule.Direction), + Protocol: string(rule.Protocol), + Port: rule.Port, + } + switch rule.Direction { + case FirewallRuleDirectionOut: + schemaRule.DestinationIPs = make([]string, len(rule.DestinationIPs)) + for i, destinationIP := range rule.DestinationIPs { + schemaRule.DestinationIPs[i] = destinationIP.String() + } + case FirewallRuleDirectionIn: + schemaRule.SourceIPs = make([]string, len(rule.SourceIPs)) + for i, sourceIP := range rule.SourceIPs { + schemaRule.SourceIPs[i] = sourceIP.String() + } + } + req.Rules = append(req.Rules, schemaRule) + } + return req +} + +func firewallResourceToSchema(resource FirewallResource) schema.FirewallResource { + s := schema.FirewallResource{ + Type: string(resource.Type), + } + if resource.Type == FirewallResourceTypeServer { + s.Server = &schema.FirewallResourceServer{ID: resource.Server.ID} + } + return s +} + func serverMetricsFromSchema(s *schema.ServerGetMetricsResponse) (*ServerMetrics, error) { ms := ServerMetrics{ Start: s.Metrics.Start, diff --git a/hcloud/schema/firewall.go b/hcloud/schema/firewall.go new file mode 100644 index 00000000..fe8192c1 --- /dev/null +++ b/hcloud/schema/firewall.go @@ -0,0 +1,98 @@ +package schema + +import "time" + +// Firewall defines the schema of a Firewall. +type Firewall struct { + ID int `json:"id"` + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Created time.Time `json:"created"` + Rules []FirewallRule `json:"rules"` + AppliedTo []FirewallResource `json:"applied_to"` +} + +// FirewallRule defines the schema of a Firewall rule. +type FirewallRule struct { + Direction string `json:"direction"` + SourceIPs []string `json:"source_ips,omitempty"` + DestinationIPs []string `json:"destination_ips,omitempty"` + Protocol string `json:"protocol"` + Port *string `json:"port,omitempty"` +} + +// FirewallListResponse defines the schema of the response when listing Firewalls. +type FirewallListResponse struct { + Firewalls []Firewall `json:"firewalls"` +} + +// FirewallGetResponse defines the schema of the response when retrieving a single Firewall. +type FirewallGetResponse struct { + Firewall Firewall `json:"firewall"` +} + +// FirewallCreateRequest defines the schema of the request to create a Firewall. +type FirewallCreateRequest struct { + Name string `json:"name"` + Labels *map[string]string `json:"labels,omitempty"` + Rules []FirewallRule `json:"rules,omitempty"` + ApplyTo []FirewallResource `json:"apply_to,omitempty"` +} + +// FirewallResource defines the schema of a resource to apply the new Firewall on. +type FirewallResource struct { + Type string `json:"type"` + Server *FirewallResourceServer `json:"server"` +} + +// FirewallResourceServer defines the schema of a Server to apply a Firewall on. +type FirewallResourceServer struct { + ID int `json:"id"` +} + +// FirewallCreateResponse defines the schema of the response when creating a Firewall. +type FirewallCreateResponse struct { + Firewall Firewall `json:"firewall"` + Actions []Action `json:"actions"` +} + +// FirewallUpdateRequest defines the schema of the request to update a Firewall. +type FirewallUpdateRequest struct { + Name *string `json:"name,omitempty"` + Labels *map[string]string `json:"labels,omitempty"` +} + +// FirewallUpdateResponse defines the schema of the response when updating a Firewall. +type FirewallUpdateResponse struct { + Firewall Firewall `json:"firewall"` +} + +// FirewallActionSetRulesRequest defines the schema of the request when setting Firewall rules. +type FirewallActionSetRulesRequest struct { + Rules []FirewallRule `json:"rules"` +} + +// FirewallActionSetRulesResponse defines the schema of the response when setting Firewall rules. +type FirewallActionSetRulesResponse struct { + Actions []Action `json:"actions"` +} + +// FirewallActionApplyToResourcesRequest defines the schema of the request when applying a Firewall on resources. +type FirewallActionApplyToResourcesRequest struct { + ApplyTo []FirewallResource `json:"apply_to"` +} + +// FirewallActionApplyToResourcesResponse defines the schema of the response when applying a Firewall on resources. +type FirewallActionApplyToResourcesResponse struct { + Actions []Action `json:"actions"` +} + +// FirewallActionRemoveFromResourcesRequest defines the schema of the request when removing a Firewall from resources. +type FirewallActionRemoveFromResourcesRequest struct { + RemoveFrom []FirewallResource `json:"remove_from"` +} + +// FirewallActionRemoveFromResourcesResponse defines the schema of the response when removing a Firewall from resources. +type FirewallActionRemoveFromResourcesResponse struct { + Actions []Action `json:"actions"` +} diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go index 2a5ca130..29dd020a 100644 --- a/hcloud/schema/server.go +++ b/hcloud/schema/server.go @@ -38,6 +38,7 @@ type ServerPublicNet struct { IPv4 ServerPublicNetIPv4 `json:"ipv4"` IPv6 ServerPublicNetIPv6 `json:"ipv6"` FloatingIPs []int `json:"floating_ips"` + Firewalls []ServerFirewall `json:"firewalls"` } // ServerPublicNetIPv4 defines the schema of a server's public @@ -63,6 +64,13 @@ type ServerPublicNetIPv6DNSPtr struct { DNSPtr string `json:"dns_ptr"` } +// ServerFirewall defines the schema of a Server's Firewalls on +// a certain network interface. +type ServerFirewall struct { + ID int `json:"id"` + Status string `json:"status"` +} + // ServerPrivateNet defines the schema of a server's private network information. type ServerPrivateNet struct { Network int `json:"network"` @@ -86,18 +94,24 @@ type ServerListResponse struct { // ServerCreateRequest defines the schema for the request to // create a server. type ServerCreateRequest struct { - Name string `json:"name"` - ServerType interface{} `json:"server_type"` // int or string - Image interface{} `json:"image"` // int or string - SSHKeys []int `json:"ssh_keys,omitempty"` - Location string `json:"location,omitempty"` - Datacenter string `json:"datacenter,omitempty"` - UserData string `json:"user_data,omitempty"` - StartAfterCreate *bool `json:"start_after_create,omitempty"` - Labels *map[string]string `json:"labels,omitempty"` - Automount *bool `json:"automount,omitempty"` - Volumes []int `json:"volumes,omitempty"` - Networks []int `json:"networks,omitempty"` + Name string `json:"name"` + ServerType interface{} `json:"server_type"` // int or string + Image interface{} `json:"image"` // int or string + SSHKeys []int `json:"ssh_keys,omitempty"` + Location string `json:"location,omitempty"` + Datacenter string `json:"datacenter,omitempty"` + UserData string `json:"user_data,omitempty"` + StartAfterCreate *bool `json:"start_after_create,omitempty"` + Labels *map[string]string `json:"labels,omitempty"` + Automount *bool `json:"automount,omitempty"` + Volumes []int `json:"volumes,omitempty"` + Networks []int `json:"networks,omitempty"` + Firewalls []ServerCreateFirewalls `json:"firewalls,omitempty"` +} + +// ServerCreateFirewall defines which Firewalls to apply when creating a Server. +type ServerCreateFirewalls struct { + Firewall int `json:"firewall"` } // ServerCreateResponse defines the schema of the response when diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go index f5ad7682..8c8a2c91 100644 --- a/hcloud/schema_test.go +++ b/hcloud/schema_test.go @@ -569,7 +569,13 @@ func TestServerPublicNetFromSchema(t *testing.T) { "blocked": false, "dns_ptr": [] }, - "floating_ips": [4] + "floating_ips": [4], + "firewalls": [ + { + "id": 23, + "status": "applied" + } + ] }`) var s schema.ServerPublicNet @@ -587,6 +593,9 @@ func TestServerPublicNetFromSchema(t *testing.T) { if len(publicNet.FloatingIPs) != 1 || publicNet.FloatingIPs[0].ID != 4 { t.Errorf("unexpected Floating IPs: %v", publicNet.FloatingIPs) } + if len(publicNet.Firewalls) != 1 || publicNet.Firewalls[0].Firewall.ID != 23 || publicNet.Firewalls[0].Status != FirewallStatusApplied { + t.Errorf("unexpected Firewalls: %v", publicNet.Firewalls) + } } func TestServerPublicNetIPv4FromSchema(t *testing.T) { @@ -1675,7 +1684,13 @@ func TestCertificateFromSchema(t *testing.T) { "webmail.example.com", "www.example.com" ], - "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "used_by": [ + { + "id": 42, + "type": "server" + } + ] } `) var s schema.Certificate @@ -2635,3 +2650,86 @@ func TestLoadBalancerMetricsFromSchema(t *testing.T) { }) } } + +func TestFirewallFromSchema(t *testing.T) { + data := []byte(`{ + "id": 897, + "name": "my firewall", + "labels": { + "key": "value", + "key2": "value2" + }, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128" + ], + "destination_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128" + ], + "protocol": "tcp", + "port": "80" + } + ], + "applied_to": [ + { + "server": { + "id": 42 + }, + "type": "server" + } + ] + } +`) + var f schema.Firewall + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + firewall := FirewallFromSchema(f) + + if firewall.ID != 897 { + t.Errorf("unexpected ID: %v", firewall.ID) + } + if firewall.Name != "my firewall" { + t.Errorf("unexpected Name: %v", firewall.Name) + } + if firewall.Labels["key"] != "value" || firewall.Labels["key2"] != "value2" { + t.Errorf("unexpected Labels: %v", firewall.Labels) + } + if !firewall.Created.Equal(time.Date(2016, 01, 30, 23, 50, 00, 0, time.UTC)) { + t.Errorf("unexpected Created date: %v", firewall.Created) + } + if len(firewall.Rules) != 1 { + t.Errorf("unexpected Rules count: %d", len(firewall.Rules)) + } + if firewall.Rules[0].Direction != FirewallRuleDirectionIn { + t.Errorf("unexpected Rule Direction: %s", firewall.Rules[0].Direction) + } + if len(firewall.Rules[0].SourceIPs) != 3 { + t.Errorf("unexpected Rule SourceIPs count: %d", len(firewall.Rules[0].SourceIPs)) + } + if len(firewall.Rules[0].DestinationIPs) != 3 { + t.Errorf("unexpected Rule DestinationIPs count: %d", len(firewall.Rules[0].DestinationIPs)) + } + if firewall.Rules[0].Protocol != FirewallRuleProtocolTCP { + t.Errorf("unexpected Rule Protocol: %s", firewall.Rules[0].Protocol) + } + if *firewall.Rules[0].Port != "80" { + t.Errorf("unexpected Rule Port: %s", *firewall.Rules[0].Port) + } + if len(firewall.AppliedTo) != 1 { + t.Errorf("unexpected UsedBy count: %d", len(firewall.AppliedTo)) + } + if firewall.AppliedTo[0].Type != FirewallResourceTypeServer { + t.Errorf("unexpected UsedBy Type: %s", firewall.AppliedTo[0].Type) + } + if firewall.AppliedTo[0].Server.ID != 42 { + t.Errorf("unexpected UsedBy Server ID: %d", firewall.AppliedTo[0].Server.ID) + } +} diff --git a/hcloud/server.go b/hcloud/server.go index 13f66bca..8d593c06 100644 --- a/hcloud/server.go +++ b/hcloud/server.go @@ -76,11 +76,23 @@ const ( ServerStatusUnknown ServerStatus = "unknown" ) +// FirewallStatus specifies a Firewall's status. +type FirewallStatus string + +const ( + // FirewallStatusPending is the status when a Firewall is pending. + FirewallStatusPending FirewallStatus = "pending" + + // FirewallStatusApplied is the status when a Firewall is applied. + FirewallStatusApplied FirewallStatus = "applied" +) + // ServerPublicNet represents a server's public network. type ServerPublicNet struct { IPv4 ServerPublicNetIPv4 IPv6 ServerPublicNetIPv6 FloatingIPs []*FloatingIP + Firewalls []*ServerFirewallStatus } // ServerPublicNetIPv4 represents a server's public IPv4 address. @@ -90,7 +102,7 @@ type ServerPublicNetIPv4 struct { DNSPtr string } -// ServerPublicNetIPv6 represents a server's public IPv6 network and address. +// ServerPublicNetIPv6 represents a Server's public IPv6 network and address. type ServerPublicNetIPv6 struct { IP net.IP Network *net.IPNet @@ -98,7 +110,7 @@ type ServerPublicNetIPv6 struct { DNSPtr map[string]string } -// ServerPrivateNet defines the schema of a server's private network information. +// ServerPrivateNet defines the schema of a Server's private network information. type ServerPrivateNet struct { Network *Network IP net.IP @@ -111,6 +123,13 @@ func (s *ServerPublicNetIPv6) DNSPtrForIP(ip net.IP) string { return s.DNSPtr[ip.String()] } +// ServerFirewallStatus represents a Firewall and its status on a Server's +// network interface. +type ServerFirewallStatus struct { + Firewall Firewall + Status FirewallStatus +} + // ServerRescueType represents rescue types. type ServerRescueType string @@ -245,6 +264,12 @@ type ServerCreateOpts struct { Automount *bool Volumes []*Volume Networks []*Network + Firewalls []*ServerCreateFirewall +} + +// ServerCreateFirewall defines which Firewalls to apply when creating a Server. +type ServerCreateFirewall struct { + Firewall Firewall } // Validate checks if options are valid. @@ -305,7 +330,11 @@ func (c *ServerClient) Create(ctx context.Context, opts ServerCreateOpts) (Serve for _, network := range opts.Networks { reqBody.Networks = append(reqBody.Networks, network.ID) } - + for _, firewall := range opts.Firewalls { + reqBody.Firewalls = append(reqBody.Firewalls, schema.ServerCreateFirewalls{ + Firewall: firewall.Firewall.ID, + }) + } if opts.Location != nil { if opts.Location.ID != 0 { reqBody.Location = strconv.Itoa(opts.Location.ID) @@ -1001,7 +1030,7 @@ func (o *ServerGetMetricsOpts) addQueryParams(req *http.Request) error { return nil } -// ServerMetrics contains the metrics requested for a server. +// ServerMetrics contains the metrics requested for a Server. type ServerMetrics struct { Start time.Time End time.Time @@ -1015,7 +1044,7 @@ type ServerMetricsValue struct { Value string } -// GetMetrics obtains metrics for server. +// GetMetrics obtains metrics for Server. func (c *ServerClient) GetMetrics(ctx context.Context, server *Server, opts ServerGetMetricsOpts) (*ServerMetrics, *Response, error) { var respBody schema.ServerGetMetricsResponse diff --git a/hcloud/server_test.go b/hcloud/server_test.go index 561c2960..5d814327 100644 --- a/hcloud/server_test.go +++ b/hcloud/server_test.go @@ -631,6 +631,52 @@ func TestServersCreateWithUserData(t *testing.T) { } } +func TestServersCreateWithFirewalls(t *testing.T) { + env := newTestEnv() + defer env.Teardown() + + env.Mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) { + var reqBody schema.ServerCreateRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatal(err) + } + if len(reqBody.Firewalls) != 2 || reqBody.Firewalls[0].Firewall != 1 || reqBody.Firewalls[1].Firewall != 2 { + t.Errorf("unexpected Firewalls: %v", reqBody.Firewalls) + } + json.NewEncoder(w).Encode(schema.ServerCreateResponse{ + Server: schema.Server{ + ID: 1, + }, + NextActions: []schema.Action{ + {ID: 2}, + }, + }) + }) + + ctx := context.Background() + result, _, err := env.Client.Server.Create(ctx, ServerCreateOpts{ + Name: "test", + ServerType: &ServerType{ID: 1}, + Image: &Image{ID: 2}, + Firewalls: []*ServerCreateFirewall{ + {Firewall: Firewall{ID: 1}}, + {Firewall: Firewall{ID: 2}}, + }, + }) + if err != nil { + t.Fatal(err) + } + if result.Server == nil { + t.Fatal("no server") + } + if result.Server.ID != 1 { + t.Errorf("unexpected server ID: %v", result.Server.ID) + } + if len(result.NextActions) != 1 || result.NextActions[0].ID != 2 { + t.Errorf("unexpected next actions: %v", result.NextActions) + } +} + func TestServersCreateWithLabels(t *testing.T) { env := newTestEnv() defer env.Teardown()