Repository: hetznercloud/hcloud-go
Pull request #166 (2021-03-10 09:33:28)

Add support for Firewalls
Signed-off-by: Lukas Kämmerling <lukas.kaemmerling@hetzner-cloud.de>


Patch:
diff --git a/go.mod b/go.mod
index c9f9655..7f2a7b2 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 169675c..2b51a98 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 f9ff403..e3e2091 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/client.go b/hcloud/client.go
index 0736bd4..25b713b 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 db67652..5066d7e 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 0000000..23613a4
--- /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/schema.go b/hcloud/schema.go
index f8ddc88..363898a 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 0000000..fe8192c
--- /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 2a5ca13..29dd020 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/server.go b/hcloud/server.go
index 13f66bc..8d593c0 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
 


Test patch:
diff --git a/hcloud/action_test.go b/hcloud/action_test.go
index e9bca52..ffaf5ce 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/firewall_test.go b/hcloud/firewall_test.go
new file mode 100644
index 0000000..efbf32c
--- /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_test.go b/hcloud/schema_test.go
index f5ad768..8c8a2c9 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_test.go b/hcloud/server_test.go
index 561c296..5d81432 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()