Repository: hetznercloud/hcloud-go
Pull request #204 (2022-06-28 08:53:04)

Add Primary IP Support
Signed-off-by: Lukas Kämmerling <lukas.kaemmerling@hetzner-cloud.de>


Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index cfd77fb..f95e854 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -17,7 +17,7 @@ test:golangci-lint:
 
 test:tests:
   stage: test
-  image: golang:1.17
+  image: golang:1.18
   script:
     - go test -v -race ./...
   except:
diff --git a/hcloud/client.go b/hcloud/client.go
index ae2a7ea..04ddaec 100644
--- a/hcloud/client.go
+++ b/hcloud/client.go
@@ -82,6 +82,7 @@ type Client struct {
 	Volume           VolumeClient
 	PlacementGroup   PlacementGroupClient
 	RDNS             RDNSClient
+	PrimaryIP        PrimaryIPClient
 }
 
 // A ClientOption is used to configure a Client.
@@ -187,6 +188,7 @@ func NewClient(options ...ClientOption) *Client {
 	client.Firewall = FirewallClient{client: client}
 	client.PlacementGroup = PlacementGroupClient{client: client}
 	client.RDNS = RDNSClient{client: client}
+	client.PrimaryIP = PrimaryIPClient{client: client}
 
 	return client
 }
diff --git a/hcloud/hcloud.go b/hcloud/hcloud.go
index f9665d2..730c25d 100644
--- a/hcloud/hcloud.go
+++ b/hcloud/hcloud.go
@@ -2,4 +2,4 @@
 package hcloud
 
 // Version is the library's version following Semantic Versioning.
-const Version = "1.34.0"
+const Version = "1.35.0"
diff --git a/hcloud/pricing.go b/hcloud/pricing.go
index 5d1b23b..67be21f 100644
--- a/hcloud/pricing.go
+++ b/hcloud/pricing.go
@@ -11,6 +11,7 @@ type Pricing struct {
 	Image             ImagePricing
 	FloatingIP        FloatingIPPricing
 	FloatingIPs       []FloatingIPTypePricing
+	PrimaryIPs        []PrimaryIPPricing
 	Traffic           TrafficPricing
 	ServerBackup      ServerBackupPricing
 	ServerTypes       []ServerTypePricing
@@ -28,6 +29,14 @@ type Price struct {
 	Gross    string
 }
 
+// PrimaryIPPrice represents a price. Net amount and gross amount are
+// specified as strings and it is the user's responsibility to convert them to
+// appropriate types for calculations.
+type PrimaryIPPrice struct {
+	Net   string
+	Gross string
+}
+
 // ImagePricing provides pricing information for imaegs.
 type ImagePricing struct {
 	PerGBMonth Price
@@ -44,6 +53,20 @@ type FloatingIPTypePricing struct {
 	Pricings []FloatingIPTypeLocationPricing
 }
 
+// PrimaryIPTypePricing defines the schema of pricing information for a primary IP
+// type at a datacenter.
+type PrimaryIPTypePricing struct {
+	Datacenter string
+	Hourly     PrimaryIPPrice
+	Monthly    PrimaryIPPrice
+}
+
+// PrimaryIPTypePricing provides pricing information for PrimaryIPs
+type PrimaryIPPricing struct {
+	Type     string
+	Pricings []PrimaryIPTypePricing
+}
+
 // FloatingIPTypeLocationPricing provides pricing information for a Floating IP type
 // at a location.
 type FloatingIPTypeLocationPricing struct {
diff --git a/hcloud/primary_ip.go b/hcloud/primary_ip.go
new file mode 100644
index 0000000..c534c89
--- /dev/null
+++ b/hcloud/primary_ip.go
@@ -0,0 +1,399 @@
+package hcloud
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+)
+
+// PrimaryIP defines a Primary IP
+type PrimaryIP struct {
+	ID           int
+	IP           net.IP
+	Network      *net.IPNet
+	Labels       map[string]string
+	Name         string
+	Type         PrimaryIPType
+	Protection   PrimaryIPProtection
+	DNSPtr       map[string]string
+	AssigneeID   int
+	AssigneeType string
+	AutoDelete   bool
+	Blocked      bool
+	Created      time.Time
+	Datacenter   *Datacenter
+}
+
+// PrimaryIPProtection represents the protection level of a Primary IP.
+type PrimaryIPProtection struct {
+	Delete bool
+}
+
+// PrimaryIPDNSPTR contains reverse DNS information for a
+// IPv4 or IPv6 Primary IP.
+type PrimaryIPDNSPTR struct {
+	DNSPtr string
+	IP     string
+}
+
+// GetDNSPtrForIP searches for the dns assigned to the given IP address.
+// It returns an error if there is no dns set for the given IP address.
+func (p *PrimaryIP) GetDNSPtrForIP(ip net.IP) (string, error) {
+	dns, ok := p.DNSPtr[ip.String()]
+	if !ok {
+		return "", DNSNotFoundError{ip}
+	}
+
+	return dns, nil
+}
+
+// PrimaryIPType represents the type of Primary IP.
+type PrimaryIPType string
+
+// PrimaryIPType Primary IP types.
+const (
+	PrimaryIPTypeIPv4 PrimaryIPType = "ipv4"
+	PrimaryIPTypeIPv6 PrimaryIPType = "ipv6"
+)
+
+// PrimaryIPCreateOpts defines the request to
+// create a Primary IP.
+type PrimaryIPCreateOpts struct {
+	AssigneeID   *int              `json:"assignee_id,omitempty"`
+	AssigneeType string            `json:"assignee_type"`
+	AutoDelete   *bool             `json:"auto_delete,omitempty"`
+	Datacenter   string            `json:"datacenter,omitempty"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Name         string            `json:"name"`
+	Type         PrimaryIPType     `json:"type"`
+}
+
+// PrimaryIPCreateResult defines the response
+// when creating a Primary IP.
+type PrimaryIPCreateResult struct {
+	PrimaryIP *PrimaryIP
+	Action    *Action
+}
+
+// PrimaryIPUpdateOpts defines the request to
+// update a Primary IP.
+type PrimaryIPUpdateOpts struct {
+	AutoDelete *bool              `json:"auto_delete,omitempty"`
+	Labels     *map[string]string `json:"labels,omitempty"`
+	Name       string             `json:"name,omitempty"`
+}
+
+// PrimaryIPUpdateResult defines the response
+// when updating a Primary IP.
+type PrimaryIPUpdateResult struct {
+	PrimaryIP PrimaryIP `json:"primary_ip"`
+}
+
+// PrimaryIPAssignOpts defines the request to
+// assign a Primary IP to an assignee (usually a server).
+type PrimaryIPAssignOpts struct {
+	ID           int
+	AssigneeID   int    `json:"assignee_id"`
+	AssigneeType string `json:"assignee_type"`
+}
+
+// PrimaryIPAssignResult defines the response
+// when assigning a Primary IP to a assignee.
+type PrimaryIPAssignResult struct {
+	Action schema.Action `json:"action"`
+}
+
+// PrimaryIPChangeDNSPtrOpts defines the request to
+// change a DNS PTR entry from a Primary IP
+type PrimaryIPChangeDNSPtrOpts struct {
+	ID     int
+	DNSPtr string `json:"dns_ptr"`
+	IP     string `json:"ip"`
+}
+
+// PrimaryIPChangeDNSPtrResult defines the response
+// when assigning a Primary IP to a assignee.
+type PrimaryIPChangeDNSPtrResult struct {
+	Action schema.Action `json:"action"`
+}
+
+// PrimaryIPChangeProtectionOpts defines the request to
+// change protection configuration of a Primary IP
+type PrimaryIPChangeProtectionOpts struct {
+	ID     int
+	Delete bool `json:"delete"`
+}
+
+// PrimaryIPChangeProtectionResult defines the response
+// when changing a protection of a PrimaryIP
+type PrimaryIPChangeProtectionResult struct {
+	Action schema.Action `json:"action"`
+}
+
+// PrimaryIPClient is a client for the Primary IP API
+type PrimaryIPClient struct {
+	client *Client
+}
+
+// GetByID retrieves a Primary IP by its ID. If the Primary IP does not exist, nil is returned.
+func (c *PrimaryIPClient) GetByID(ctx context.Context, id int) (*PrimaryIP, *Response, error) {
+	req, err := c.client.NewRequest(ctx, "GET", fmt.Sprintf("/primary_ips/%d", id), nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.PrimaryIPGetResult
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		if IsError(err, ErrorCodeNotFound) {
+			return nil, resp, nil
+		}
+		return nil, nil, err
+	}
+	return PrimaryIPFromSchema(body.PrimaryIP), resp, nil
+}
+
+// GetByIP retrieves a Primary IP by its IP Address. If the Primary IP does not exist, nil is returned.
+func (c *PrimaryIPClient) GetByIP(ctx context.Context, ip string) (*PrimaryIP, *Response, error) {
+	if ip == "" {
+		return nil, nil, nil
+	}
+	primaryIPs, response, err := c.List(ctx, PrimaryIPListOpts{IP: ip})
+	if len(primaryIPs) == 0 {
+		return nil, response, err
+	}
+	return primaryIPs[0], response, err
+}
+
+// GetByName retrieves a Primary IP by its name. If the Primary IP does not exist, nil is returned.
+func (c *PrimaryIPClient) GetByName(ctx context.Context, name string) (*PrimaryIP, *Response, error) {
+	if name == "" {
+		return nil, nil, nil
+	}
+	primaryIPs, response, err := c.List(ctx, PrimaryIPListOpts{Name: name})
+	if len(primaryIPs) == 0 {
+		return nil, response, err
+	}
+	return primaryIPs[0], response, err
+}
+
+// Get retrieves a Primary IP by its ID if the input can be parsed as an integer, otherwise it
+// retrieves a Primary IP by its name. If the Primary IP does not exist, nil is returned.
+func (c *PrimaryIPClient) Get(ctx context.Context, idOrName string) (*PrimaryIP, *Response, error) {
+	if id, err := strconv.Atoi(idOrName); err == nil {
+		return c.GetByID(ctx, int(id))
+	}
+	return c.GetByName(ctx, idOrName)
+}
+
+// PrimaryIPListOpts specifies options for listing Primary IPs.
+type PrimaryIPListOpts struct {
+	ListOpts
+	Name string
+	IP   string
+	Sort []string
+}
+
+func (l PrimaryIPListOpts) values() url.Values {
+	vals := l.ListOpts.values()
+	if l.Name != "" {
+		vals.Add("name", l.Name)
+	}
+	if l.IP != "" {
+		vals.Add("ip", l.IP)
+	}
+	for _, sort := range l.Sort {
+		vals.Add("sort", sort)
+	}
+	return vals
+}
+
+// List returns a list of Primary IPs 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 *PrimaryIPClient) List(ctx context.Context, opts PrimaryIPListOpts) ([]*PrimaryIP, *Response, error) {
+	path := "/primary_ips?" + opts.values().Encode()
+	req, err := c.client.NewRequest(ctx, "GET", path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var body schema.PrimaryIPListResult
+	resp, err := c.client.Do(req, &body)
+	if err != nil {
+		return nil, nil, err
+	}
+	primaryIPs := make([]*PrimaryIP, 0, len(body.PrimaryIPs))
+	for _, s := range body.PrimaryIPs {
+		primaryIPs = append(primaryIPs, PrimaryIPFromSchema(s))
+	}
+	return primaryIPs, resp, nil
+}
+
+// All returns all Primary IPs.
+func (c *PrimaryIPClient) All(ctx context.Context) ([]*PrimaryIP, error) {
+	allPrimaryIPs := []*PrimaryIP{}
+
+	opts := PrimaryIPListOpts{}
+	opts.PerPage = 50
+
+	err := c.client.all(func(page int) (*Response, error) {
+		opts.Page = page
+		primaryIPs, resp, err := c.List(ctx, opts)
+		if err != nil {
+			return resp, err
+		}
+		allPrimaryIPs = append(allPrimaryIPs, primaryIPs...)
+		return resp, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return allPrimaryIPs, nil
+}
+
+// Create creates a Primary IP.
+func (c *PrimaryIPClient) Create(ctx context.Context, reqBody PrimaryIPCreateOpts) (*PrimaryIPCreateResult, *Response, error) {
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return &PrimaryIPCreateResult{}, nil, err
+	}
+
+	req, err := c.client.NewRequest(ctx, "POST", "/primary_ips", bytes.NewReader(reqBodyData))
+	if err != nil {
+		return &PrimaryIPCreateResult{}, nil, err
+	}
+
+	var respBody schema.PrimaryIPCreateResponse
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return &PrimaryIPCreateResult{}, resp, err
+	}
+	var action *Action
+	if respBody.Action != nil {
+		action = ActionFromSchema(*respBody.Action)
+	}
+	primaryIP := PrimaryIPFromSchema(respBody.PrimaryIP)
+	return &PrimaryIPCreateResult{
+		PrimaryIP: primaryIP,
+		Action:    action,
+	}, resp, nil
+}
+
+// Delete deletes a Primary IP.
+func (c *PrimaryIPClient) Delete(ctx context.Context, primaryIP *PrimaryIP) (*Response, error) {
+	req, err := c.client.NewRequest(ctx, "DELETE", fmt.Sprintf("/primary_ips/%d", primaryIP.ID), nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.client.Do(req, nil)
+}
+
+// Update updates a Primary IP.
+func (c *PrimaryIPClient) Update(ctx context.Context, primaryIP *PrimaryIP, reqBody PrimaryIPUpdateOpts) (*PrimaryIP, *Response, error) {
+	reqBodyData, err := json.Marshal(reqBody)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/primary_ips/%d", primaryIP.ID)
+	req, err := c.client.NewRequest(ctx, "PUT", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	respBody := PrimaryIPUpdateResult{}
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return &respBody.PrimaryIP, resp, nil
+}
+
+// Assign a Primary IP to a resource
+func (c *PrimaryIPClient) Assign(ctx context.Context, opts PrimaryIPAssignOpts) (*Action, *Response, error) {
+	reqBodyData, err := json.Marshal(opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/primary_ips/%d/actions/assign", opts.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var respBody PrimaryIPAssignResult
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, nil
+}
+
+// Unassign a Primary IP from a resource
+func (c *PrimaryIPClient) Unassign(ctx context.Context, id int) (*Action, *Response, error) {
+	path := fmt.Sprintf("/primary_ips/%d/actions/unassign", id)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader([]byte{}))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var respBody PrimaryIPAssignResult
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, nil
+}
+
+// ChangeDNSPtr Change the reverse DNS from a Primary IP
+func (c *PrimaryIPClient) ChangeDNSPtr(ctx context.Context, opts PrimaryIPChangeDNSPtrOpts) (*Action, *Response, error) {
+	reqBodyData, err := json.Marshal(opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/primary_ips/%d/actions/change_dns_ptr", opts.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var respBody PrimaryIPChangeDNSPtrResult
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, nil
+}
+
+// ChangeProtection Changes the protection configuration of a Primary IP.
+func (c *PrimaryIPClient) ChangeProtection(ctx context.Context, opts PrimaryIPChangeProtectionOpts) (*Action, *Response, error) {
+	reqBodyData, err := json.Marshal(opts)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	path := fmt.Sprintf("/primary_ips/%d/actions/change_protection", opts.ID)
+	req, err := c.client.NewRequest(ctx, "POST", path, bytes.NewReader(reqBodyData))
+	if err != nil {
+		return nil, nil, err
+	}
+
+	var respBody PrimaryIPChangeProtectionResult
+	resp, err := c.client.Do(req, &respBody)
+	if err != nil {
+		return nil, resp, err
+	}
+	return ActionFromSchema(respBody.Action), resp, nil
+}
diff --git a/hcloud/schema.go b/hcloud/schema.go
index 769f582..633a568 100644
--- a/hcloud/schema.go
+++ b/hcloud/schema.go
@@ -82,6 +82,40 @@ func FloatingIPFromSchema(s schema.FloatingIP) *FloatingIP {
 	return f
 }
 
+// PrimaryIPFromSchema converts a schema.PrimaryIP to a PrimaryIP.
+func PrimaryIPFromSchema(s schema.PrimaryIP) *PrimaryIP {
+	f := &PrimaryIP{
+		ID:         s.ID,
+		Type:       PrimaryIPType(s.Type),
+		AutoDelete: s.AutoDelete,
+
+		Created: s.Created,
+		Blocked: s.Blocked,
+		Protection: PrimaryIPProtection{
+			Delete: s.Protection.Delete,
+		},
+		Name:         s.Name,
+		AssigneeType: s.AssigneeType,
+		AssigneeID:   s.AssigneeID,
+		Datacenter:   DatacenterFromSchema(s.Datacenter),
+	}
+
+	if f.Type == PrimaryIPTypeIPv4 {
+		f.IP = net.ParseIP(s.IP)
+	} else {
+		f.IP, f.Network, _ = net.ParseCIDR(s.IP)
+	}
+	f.DNSPtr = map[string]string{}
+	for _, entry := range s.DNSPtr {
+		f.DNSPtr[entry.IP] = entry.DNSPtr
+	}
+	f.Labels = map[string]string{}
+	for key, value := range s.Labels {
+		f.Labels[key] = value
+	}
+	return f
+}
+
 // ISOFromSchema converts a schema.ISO to an ISO.
 func ISOFromSchema(s schema.ISO) *ISO {
 	return &ISO{
@@ -201,6 +235,7 @@ func ServerPublicNetFromSchema(s schema.ServerPublicNet) ServerPublicNet {
 // a ServerPublicNetIPv4.
 func ServerPublicNetIPv4FromSchema(s schema.ServerPublicNetIPv4) ServerPublicNetIPv4 {
 	return ServerPublicNetIPv4{
+		ID:      s.ID,
 		IP:      net.ParseIP(s.IP),
 		Blocked: s.Blocked,
 		DNSPtr:  s.DNSPtr,
@@ -211,6 +246,7 @@ func ServerPublicNetIPv4FromSchema(s schema.ServerPublicNetIPv4) ServerPublicNet
 // a ServerPublicNetIPv6.
 func ServerPublicNetIPv6FromSchema(s schema.ServerPublicNetIPv6) ServerPublicNetIPv6 {
 	ipv6 := ServerPublicNetIPv6{
+		ID:      s.ID,
 		Blocked: s.Blocked,
 		DNSPtr:  map[string]string{},
 	}
@@ -681,6 +717,24 @@ func PricingFromSchema(s schema.Pricing) Pricing {
 		}
 		p.FloatingIPs = append(p.FloatingIPs, FloatingIPTypePricing{Type: FloatingIPType(floatingIPType.Type), Pricings: pricings})
 	}
+	for _, primaryIPType := range s.PrimaryIPs {
+		var pricings []PrimaryIPTypePricing
+		for _, price := range primaryIPType.Prices {
+			p := PrimaryIPTypePricing{
+				Datacenter: price.Datacenter,
+				Monthly: PrimaryIPPrice{
+					Net:   price.PriceMonthly.Net,
+					Gross: price.PriceMonthly.Gross,
+				},
+				Hourly: PrimaryIPPrice{
+					Net:   price.PriceHourly.Net,
+					Gross: price.PriceHourly.Gross,
+				},
+			}
+			pricings = append(pricings, p)
+		}
+		p.PrimaryIPs = append(p.PrimaryIPs, PrimaryIPPricing{Type: primaryIPType.Type, Pricings: pricings})
+	}
 	for _, serverType := range s.ServerTypes {
 		var pricings []ServerTypeLocationPricing
 		for _, price := range serverType.Prices {
diff --git a/hcloud/schema/pricing.go b/hcloud/schema/pricing.go
index 277fa1a..b97a2f8 100644
--- a/hcloud/schema/pricing.go
+++ b/hcloud/schema/pricing.go
@@ -7,6 +7,7 @@ type Pricing struct {
 	Image             PricingImage              `json:"image"`
 	FloatingIP        PricingFloatingIP         `json:"floating_ip"`
 	FloatingIPs       []PricingFloatingIPType   `json:"floating_ips"`
+	PrimaryIPs        []PricingPrimaryIP        `json:"primary_ips"`
 	Traffic           PricingTraffic            `json:"traffic"`
 	ServerBackup      PricingServerBackup       `json:"server_backup"`
 	ServerTypes       []PricingServerType       `json:"server_types"`
@@ -92,3 +93,17 @@ type PricingLoadBalancerTypePrice struct {
 type PricingGetResponse struct {
 	Pricing Pricing `json:"pricing"`
 }
+
+// PricingPrimaryIPTypePrice defines the schema of pricing information for a primary IP
+// type at a datacenter.
+type PricingPrimaryIPTypePrice struct {
+	Datacenter   string `json:"datacenter"`
+	PriceHourly  Price  `json:"price_hourly"`
+	PriceMonthly Price  `json:"price_monthly"`
+}
+
+// PricingPrimaryIP define the schema of pricing information for a primary IP at a datacenter
+type PricingPrimaryIP struct {
+	Type   string                      `json:"type"`
+	Prices []PricingPrimaryIPTypePrice `json:"prices"`
+}
diff --git a/hcloud/schema/primary_ip.go b/hcloud/schema/primary_ip.go
new file mode 100644
index 0000000..b21e28b
--- /dev/null
+++ b/hcloud/schema/primary_ip.go
@@ -0,0 +1,49 @@
+package schema
+
+import "time"
+
+// PrimaryIP defines a Primary IP
+type PrimaryIP struct {
+	ID           int                 `json:"id"`
+	IP           string              `json:"ip"`
+	Labels       map[string]string   `json:"labels"`
+	Name         string              `json:"name"`
+	Type         string              `json:"type"`
+	Protection   PrimaryIPProtection `json:"protection"`
+	DNSPtr       []PrimaryIPDNSPTR   `json:"dns_ptr"`
+	AssigneeID   int                 `json:"assignee_id"`
+	AssigneeType string              `json:"assignee_type"`
+	AutoDelete   bool                `json:"auto_delete"`
+	Blocked      bool                `json:"blocked"`
+	Created      time.Time           `json:"created"`
+	Datacenter   Datacenter          `json:"datacenter"`
+}
+
+// PrimaryIPProtection represents the protection level of a Primary IP.
+type PrimaryIPProtection struct {
+	Delete bool `json:"delete"`
+}
+
+// PrimaryIPDNSPTR contains reverse DNS information for a
+// IPv4 or IPv6 Primary IP.
+type PrimaryIPDNSPTR struct {
+	DNSPtr string `json:"dns_ptr"`
+	IP     string `json:"ip"`
+}
+
+// PrimaryIPCreateResponse defines the schema of the response
+// when creating a Primary IP.
+type PrimaryIPCreateResponse struct {
+	PrimaryIP PrimaryIP `json:"primary_ip"`
+	Action    *Action   `json:"action"`
+}
+
+// PrimaryIPGetResult defines the response when retrieving a single Primary IP.
+type PrimaryIPGetResult struct {
+	PrimaryIP PrimaryIP `json:"primary_ip"`
+}
+
+// PrimaryIPListResult defines the response when listing Primary IPs.
+type PrimaryIPListResult struct {
+	PrimaryIPs []PrimaryIP `json:"primary_ips"`
+}
diff --git a/hcloud/schema/server.go b/hcloud/schema/server.go
index 229e288..616b2eb 100644
--- a/hcloud/schema/server.go
+++ b/hcloud/schema/server.go
@@ -45,6 +45,7 @@ type ServerPublicNet struct {
 // ServerPublicNetIPv4 defines the schema of a server's public
 // network information for an IPv4.
 type ServerPublicNetIPv4 struct {
+	ID      int    `json:"id"`
 	IP      string `json:"ip"`
 	Blocked bool   `json:"blocked"`
 	DNSPtr  string `json:"dns_ptr"`
@@ -53,6 +54,7 @@ type ServerPublicNetIPv4 struct {
 // ServerPublicNetIPv6 defines the schema of a server's public
 // network information for an IPv6.
 type ServerPublicNetIPv6 struct {
+	ID      int                         `json:"id"`
 	IP      string                      `json:"ip"`
 	Blocked bool                        `json:"blocked"`
 	DNSPtr  []ServerPublicNetIPv6DNSPtr `json:"dns_ptr"`
@@ -109,9 +111,18 @@ type ServerCreateRequest struct {
 	Networks         []int                   `json:"networks,omitempty"`
 	Firewalls        []ServerCreateFirewalls `json:"firewalls,omitempty"`
 	PlacementGroup   int                     `json:"placement_group,omitempty"`
+	PublicNet        *ServerCreatePublicNet  `json:"public_net,omitempty"`
 }
 
-// ServerCreateFirewall defines which Firewalls to apply when creating a Server.
+// ServerCreatePublicNet defines the public network configuration of a server.
+type ServerCreatePublicNet struct {
+	EnableIPv4 bool `json:"enable_ipv4"`
+	EnableIPv6 bool `json:"enable_ipv6"`
+	IPv4ID     int  `json:"ipv4,omitempty"`
+	IPv6ID     int  `json:"ipv6,omitempty"`
+}
+
+// ServerCreateFirewalls defines which Firewalls to apply when creating a Server.
 type ServerCreateFirewalls struct {
 	Firewall int `json:"firewall"`
 }
diff --git a/hcloud/server.go b/hcloud/server.go
index b5254e1..142e842 100644
--- a/hcloud/server.go
+++ b/hcloud/server.go
@@ -98,19 +98,29 @@ type ServerPublicNet struct {
 
 // ServerPublicNetIPv4 represents a server's public IPv4 address.
 type ServerPublicNetIPv4 struct {
+	ID      int
 	IP      net.IP
 	Blocked bool
 	DNSPtr  string
 }
 
+func (n *ServerPublicNetIPv4) IsUnspecified() bool {
+	return n.IP == nil || n.IP.Equal(net.IPv4zero)
+}
+
 // ServerPublicNetIPv6 represents a Server's public IPv6 network and address.
 type ServerPublicNetIPv6 struct {
+	ID      int
 	IP      net.IP
 	Network *net.IPNet
 	Blocked bool
 	DNSPtr  map[string]string
 }
 
+func (n *ServerPublicNetIPv6) IsUnspecified() bool {
+	return n.IP == nil || n.IP.Equal(net.IPv6unspecified)
+}
+
 // ServerPrivateNet defines the schema of a Server's private network information.
 type ServerPrivateNet struct {
 	Network    *Network
@@ -120,8 +130,8 @@ type ServerPrivateNet struct {
 }
 
 // DNSPtrForIP returns the reverse dns pointer of the ip address.
-func (s *ServerPublicNetIPv6) DNSPtrForIP(ip net.IP) string {
-	return s.DNSPtr[ip.String()]
+func (n *ServerPublicNetIPv6) DNSPtrForIP(ip net.IP) string {
+	return n.DNSPtr[ip.String()]
 }
 
 // ServerFirewallStatus represents a Firewall and its status on a Server's
@@ -308,6 +318,14 @@ type ServerCreateOpts struct {
 	Networks         []*Network
 	Firewalls        []*ServerCreateFirewall
 	PlacementGroup   *PlacementGroup
+	PublicNet        *ServerCreatePublicNet
+}
+
+type ServerCreatePublicNet struct {
+	EnableIPv4 bool
+	EnableIPv6 bool
+	IPv4       *PrimaryIP
+	IPv6       *PrimaryIP
 }
 
 // ServerCreateFirewall defines which Firewalls to apply when creating a Server.
@@ -329,6 +347,11 @@ func (o ServerCreateOpts) Validate() error {
 	if o.Location != nil && o.Datacenter != nil {
 		return errors.New("location and datacenter are mutually exclusive")
 	}
+	if o.PublicNet != nil {
+		if !o.PublicNet.EnableIPv4 && !o.PublicNet.EnableIPv6 && len(o.Networks) == 0 {
+			return errors.New("missing networks when EnableIPv4 and EnableIPv6 is false")
+		}
+	}
 	return nil
 }
 
@@ -378,6 +401,19 @@ func (c *ServerClient) Create(ctx context.Context, opts ServerCreateOpts) (Serve
 			Firewall: firewall.Firewall.ID,
 		})
 	}
+
+	if opts.PublicNet != nil {
+		reqBody.PublicNet = &schema.ServerCreatePublicNet{
+			EnableIPv4: opts.PublicNet.EnableIPv4,
+			EnableIPv6: opts.PublicNet.EnableIPv6,
+		}
+		if opts.PublicNet.IPv4 != nil {
+			reqBody.PublicNet.IPv4ID = opts.PublicNet.IPv4.ID
+		}
+		if opts.PublicNet.IPv6 != nil {
+			reqBody.PublicNet.IPv6ID = opts.PublicNet.IPv6.ID
+		}
+	}
 	if opts.Location != nil {
 		if opts.Location.ID != 0 {
 			reqBody.Location = strconv.Itoa(opts.Location.ID)


Test patch:
diff --git a/hcloud/primary_ip_test.go b/hcloud/primary_ip_test.go
new file mode 100644
index 0000000..6c517ee
--- /dev/null
+++ b/hcloud/primary_ip_test.go
@@ -0,0 +1,488 @@
+package hcloud
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/hetznercloud/hcloud-go/hcloud/schema"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPrimaryIPClient(t *testing.T) {
+	t.Run("GetByID", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1", func(w http.ResponseWriter, r *http.Request) {
+			json.NewEncoder(w).Encode(schema.PrimaryIPGetResult{
+				PrimaryIP: schema.PrimaryIP{
+					ID: 1,
+				},
+			})
+		})
+
+		ctx := context.Background()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByID(ctx, 1)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP == nil {
+			t.Fatal("no primary_ip")
+		}
+		if primaryIP.ID != 1 {
+			t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID)
+		}
+
+		t.Run("via Get", func(t *testing.T) {
+			primaryIP, _, err := env.Client.PrimaryIP.Get(ctx, "1")
+			if err != nil {
+				t.Fatal(err)
+			}
+			if primaryIP == nil {
+				t.Fatal("no primary_ip")
+			}
+			if primaryIP.ID != 1 {
+				t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID)
+			}
+		})
+	})
+
+	t.Run("GetByID (not found)", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/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()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByID(ctx, 1)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP != nil {
+			t.Fatal("expected no primary_ip")
+		}
+	})
+
+	t.Run("GetByName", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			if r.URL.RawQuery != "name=fsn1-dc8" {
+				t.Fatal("missing name query")
+			}
+			json.NewEncoder(w).Encode(schema.PrimaryIPListResult{
+				PrimaryIPs: []schema.PrimaryIP{
+					{
+						ID: 1,
+					},
+				},
+			})
+		})
+
+		ctx := context.Background()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "fsn1-dc8")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP == nil {
+			t.Fatal("no primary_ip")
+		}
+		if primaryIP.ID != 1 {
+			t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID)
+		}
+
+		t.Run("via Get", func(t *testing.T) {
+			primaryIP, _, err := env.Client.PrimaryIP.Get(ctx, "fsn1-dc8")
+			if err != nil {
+				t.Fatal(err)
+			}
+			if primaryIP == nil {
+				t.Fatal("no primary_ip")
+			}
+			if primaryIP.ID != 1 {
+				t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID)
+			}
+		})
+	})
+
+	t.Run("GetByName (not found)", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			if r.URL.RawQuery != "name=fsn1-dc8" {
+				t.Fatal("missing name query")
+			}
+			json.NewEncoder(w).Encode(schema.PrimaryIPListResult{
+				PrimaryIPs: []schema.PrimaryIP{},
+			})
+		})
+
+		ctx := context.Background()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "fsn1-dc8")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP != nil {
+			t.Fatal("unexpected primary_ip")
+		}
+	})
+
+	t.Run("GetByName (empty)", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		ctx := context.Background()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByName(ctx, "")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP != nil {
+			t.Fatal("unexpected primary_ip")
+		}
+	})
+
+	t.Run("GetByIP", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			if r.URL.RawQuery != "ip=127.0.0.1" {
+				t.Fatal("missing name query")
+			}
+			json.NewEncoder(w).Encode(schema.PrimaryIPListResult{
+				PrimaryIPs: []schema.PrimaryIP{
+					{
+						ID: 1,
+					},
+				},
+			})
+		})
+
+		ctx := context.Background()
+		primaryIP, _, err := env.Client.PrimaryIP.GetByIP(ctx, "127.0.0.1")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP == nil {
+			t.Fatal("no primary_ip")
+		}
+		if primaryIP.ID != 1 {
+			t.Errorf("unexpected primary_ip ID: %v", primaryIP.ID)
+		}
+	})
+
+	t.Run("List", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			if page := r.URL.Query().Get("page"); page != "2" {
+				t.Errorf("expected page 2; got %q", page)
+			}
+			if perPage := r.URL.Query().Get("per_page"); perPage != "50" {
+				t.Errorf("expected per_page 50; got %q", perPage)
+			}
+			if name := r.URL.Query().Get("name"); name != "nbg1-dc3" {
+				t.Errorf("expected name nbg1-dc3; got %q", name)
+			}
+			json.NewEncoder(w).Encode(schema.PrimaryIPListResult{
+				PrimaryIPs: []schema.PrimaryIP{
+					{ID: 1},
+					{ID: 2},
+				},
+			})
+		})
+
+		opts := PrimaryIPListOpts{}
+		opts.Page = 2
+		opts.PerPage = 50
+		opts.Name = "nbg1-dc3"
+
+		ctx := context.Background()
+		primaryIPs, _, err := env.Client.PrimaryIP.List(ctx, opts)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(primaryIPs) != 2 {
+			t.Fatal("expected 2 primary_ips")
+		}
+	})
+
+	t.Run("All", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(struct {
+				PrimaryIPs []PrimaryIP `json:"primary_ips"`
+				Meta       schema.Meta `json:"meta"`
+			}{
+				PrimaryIPs: []PrimaryIP{
+					{ID: 1},
+					{ID: 2},
+					{ID: 3},
+				},
+				Meta: schema.Meta{
+					Pagination: &schema.MetaPagination{
+						Page:         1,
+						LastPage:     1,
+						PerPage:      3,
+						TotalEntries: 3,
+					},
+				},
+			})
+		})
+
+		ctx := context.Background()
+		primaryIPs, err := env.Client.PrimaryIP.All(ctx)
+		if err != nil {
+			t.Fatalf("PrimaryIP.List failed: %s", err)
+		}
+		if len(primaryIPs) != 3 {
+			t.Fatalf("expected 3 primary_ips; got %d", len(primaryIPs))
+		}
+		if primaryIPs[0].ID != 1 || primaryIPs[1].ID != 2 || primaryIPs[2].ID != 3 {
+			t.Errorf("unexpected primary_ips")
+		}
+	})
+	t.Run("Create", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody PrimaryIPCreateOpts
+			if r.Method != "POST" {
+				t.Error("expected POST")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			expectedReqBody := PrimaryIPCreateOpts{
+				Name:         "my-primary-ip",
+				Type:         PrimaryIPTypeIPv4,
+				AssigneeType: "server",
+				Datacenter:   "fsn-dc14",
+				Labels: func() map[string]string {
+					labels := map[string]string{"key": "value"}
+					return labels
+				}(),
+			}
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if !cmp.Equal(expectedReqBody, reqBody) {
+				t.Log(cmp.Diff(expectedReqBody, reqBody))
+				t.Error("unexpected request body")
+			}
+			json.NewEncoder(w).Encode(PrimaryIPCreateResult{
+				PrimaryIP: &PrimaryIP{ID: 1},
+				Action:    &Action{ID: 14},
+			})
+		})
+
+		ctx := context.Background()
+		opts := PrimaryIPCreateOpts{
+			Name:         "my-primary-ip",
+			Type:         PrimaryIPTypeIPv4,
+			AssigneeType: "server",
+			Labels:       map[string]string{"key": "value"},
+			Datacenter:   "fsn-dc14",
+		}
+
+		result, resp, err := env.Client.PrimaryIP.Create(ctx, opts)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.NotNil(t, result.PrimaryIP, "no primary IP returned")
+		assert.NotNil(t, result.Action, "no action returned")
+	})
+	t.Run("Update", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody PrimaryIPUpdateOpts
+			if r.Method != "PUT" {
+				t.Error("expected PUT")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			autoDelete := true
+			expectedReqBody := PrimaryIPUpdateOpts{
+				Name:       "my-primary-ip",
+				AutoDelete: &autoDelete,
+				Labels: func() *map[string]string {
+					labels := map[string]string{"key": "value"}
+					return &labels
+				}(),
+			}
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if !cmp.Equal(expectedReqBody, reqBody) {
+				t.Log(cmp.Diff(expectedReqBody, reqBody))
+				t.Error("unexpected request body")
+			}
+			json.NewEncoder(w).Encode(PrimaryIPUpdateResult{
+				PrimaryIP: PrimaryIP{ID: 1},
+			})
+		})
+
+		ctx := context.Background()
+		labels := map[string]string{"key": "value"}
+		autoDelete := true
+		opts := PrimaryIPUpdateOpts{
+			Name:       "my-primary-ip",
+			AutoDelete: &autoDelete,
+			Labels:     &labels,
+		}
+
+		primaryIP := PrimaryIP{ID: 1}
+		result, resp, err := env.Client.PrimaryIP.Update(ctx, &primaryIP, opts)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.Equal(t, *result, primaryIP, "no primary IP returned")
+	})
+	t.Run("Assign", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1/actions/assign", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody PrimaryIPAssignOpts
+			if r.Method != "POST" {
+				t.Error("expected POST")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			expectedReqBody := PrimaryIPAssignOpts{
+				AssigneeType: "server",
+				AssigneeID:   1,
+				ID:           1,
+			}
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if !cmp.Equal(expectedReqBody, reqBody) {
+				t.Log(cmp.Diff(expectedReqBody, reqBody))
+				t.Error("unexpected request body")
+			}
+			json.NewEncoder(w).Encode(PrimaryIPAssignResult{
+				Action: schema.Action{ID: 1},
+			})
+		})
+
+		ctx := context.Background()
+		opts := PrimaryIPAssignOpts{
+			AssigneeType: "server",
+			AssigneeID:   1,
+			ID:           1,
+		}
+
+		action, resp, err := env.Client.PrimaryIP.Assign(ctx, opts)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.NotNil(t, action, "no action returned")
+	})
+	t.Run("Unassign", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1/actions/unassign", func(w http.ResponseWriter, r *http.Request) {
+			if r.Method != "POST" {
+				t.Error("expected POST")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			json.NewEncoder(w).Encode(PrimaryIPAssignResult{
+				Action: schema.Action{ID: 1},
+			})
+		})
+
+		ctx := context.Background()
+
+		action, resp, err := env.Client.PrimaryIP.Unassign(ctx, 1)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.NotNil(t, action, "no action returned")
+	})
+	t.Run("ChangeDNSPtr", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1/actions/change_dns_ptr", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody PrimaryIPChangeDNSPtrOpts
+			if r.Method != "POST" {
+				t.Error("expected POST")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			expectedReqBody := PrimaryIPChangeDNSPtrOpts{
+				ID: 1,
+			}
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if !cmp.Equal(expectedReqBody, reqBody) {
+				t.Log(cmp.Diff(expectedReqBody, reqBody))
+				t.Error("unexpected request body")
+			}
+			json.NewEncoder(w).Encode(PrimaryIPChangeDNSPtrResult{
+				Action: schema.Action{ID: 1},
+			})
+		})
+
+		ctx := context.Background()
+		opts := PrimaryIPChangeDNSPtrOpts{
+			ID: 1,
+		}
+
+		action, resp, err := env.Client.PrimaryIP.ChangeDNSPtr(ctx, opts)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.NotNil(t, action, "no action returned")
+	})
+	t.Run("ChangeProtection", func(t *testing.T) {
+		env := newTestEnv()
+		defer env.Teardown()
+
+		env.Mux.HandleFunc("/primary_ips/1/actions/change_protection", func(w http.ResponseWriter, r *http.Request) {
+			var reqBody PrimaryIPChangeProtectionOpts
+			if r.Method != "POST" {
+				t.Error("expected POST")
+			}
+			w.Header().Set("Content-Type", "application/json")
+			expectedReqBody := PrimaryIPChangeProtectionOpts{
+				ID:     1,
+				Delete: true,
+			}
+			if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+				t.Fatal(err)
+			}
+			if !cmp.Equal(expectedReqBody, reqBody) {
+				t.Log(cmp.Diff(expectedReqBody, reqBody))
+				t.Error("unexpected request body")
+			}
+			json.NewEncoder(w).Encode(PrimaryIPChangeProtectionResult{
+				Action: schema.Action{ID: 1},
+			})
+		})
+
+		ctx := context.Background()
+		opts := PrimaryIPChangeProtectionOpts{
+			ID:     1,
+			Delete: true,
+		}
+
+		action, resp, err := env.Client.PrimaryIP.ChangeProtection(ctx, opts)
+		assert.NoError(t, err)
+		assert.NotNil(t, resp, "no response returned")
+		assert.NotNil(t, action, "no action returned")
+	})
+}
diff --git a/hcloud/schema_test.go b/hcloud/schema_test.go
index 4f2fb3b..9301d78 100644
--- a/hcloud/schema_test.go
+++ b/hcloud/schema_test.go
@@ -225,6 +225,203 @@ func TestFloatingIPFromSchema(t *testing.T) {
 	})
 }
 
+func TestPrimaryIPFromSchema(t *testing.T) {
+	t.Run("IPv6", func(t *testing.T) {
+		data := []byte(`{
+		"assignee_id": 17,
+		"assignee_type": "server",
+		"auto_delete": true,
+		"blocked": true,
+		"created": "2017-08-16T17:29:14+00:00",
+		"datacenter": {
+			"description": "Falkenstein DC Park 8",
+			"id": 42,
+			"location": {
+				"city": "Falkenstein",
+				"country": "DE",
+				"description": "Falkenstein DC Park 1",
+				"id": 1,
+				"latitude": 50.47612,
+				"longitude": 12.370071,
+				"name": "fsn1",
+				"network_zone": "eu-central"
+			},
+			"name": "fsn1-dc8",
+			"server_types": {
+				"available": [],
+				"available_for_migration": [],
+				"supported": []
+			}
+		},
+		"dns_ptr": [
+			{
+				"dns_ptr": "server.example.com",
+				"ip": "fe80::"
+			}
+		],
+		"id": 4711,
+		"ip": "fe80::/64",
+		"labels": {
+			"key": "value",
+			"key2": "value2"
+		},
+		"name": "Web Frontend",
+		"protection": {
+			"delete": true
+		},
+		"type": "ipv6"
+  }`)
+
+		var s schema.PrimaryIP
+		if err := json.Unmarshal(data, &s); err != nil {
+			t.Fatal(err)
+		}
+		primaryIP := PrimaryIPFromSchema(s)
+
+		if primaryIP.ID != 4711 {
+			t.Errorf("unexpected ID: %v", primaryIP.ID)
+		}
+		if !primaryIP.Blocked {
+			t.Errorf("unexpected value for Blocked: %v", primaryIP.Blocked)
+		}
+		if !primaryIP.AutoDelete {
+			t.Errorf("unexpected value for AutoDelete: %v", primaryIP.AutoDelete)
+		}
+		if primaryIP.Name != "Web Frontend" {
+			t.Errorf("unexpected name: %v", primaryIP.Name)
+		}
+
+		if primaryIP.IP.String() != "fe80::" {
+			t.Errorf("unexpected IP: %v", primaryIP.IP)
+		}
+		if primaryIP.Type != PrimaryIPTypeIPv6 {
+			t.Errorf("unexpected Type: %v", primaryIP.Type)
+		}
+		if primaryIP.AssigneeType != "server" {
+			t.Errorf("unexpected AssigneeType: %v", primaryIP.AssigneeType)
+		}
+		if primaryIP.AssigneeID != 17 {
+			t.Errorf("unexpected AssigneeID: %v", primaryIP.AssigneeID)
+		}
+		dnsPTR, err := primaryIP.GetDNSPtrForIP(primaryIP.IP)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP.DNSPtr == nil || dnsPTR == "" {
+			t.Errorf("unexpected DNS ptr: %v", primaryIP.DNSPtr)
+		}
+		if primaryIP.Datacenter.Name != "fsn1-dc8" {
+			t.Errorf("unexpected datacenter: %v", primaryIP.Datacenter)
+		}
+		if !primaryIP.Protection.Delete {
+			t.Errorf("unexpected Protection.Delete: %v", primaryIP.Protection.Delete)
+		}
+		if primaryIP.Labels["key"] != "value" || primaryIP.Labels["key2"] != "value2" {
+			t.Errorf("unexpected Labels: %v", primaryIP.Labels)
+		}
+		if !primaryIP.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) {
+			t.Errorf("unexpected created date: %v", primaryIP.Created)
+		}
+	})
+	t.Run("IPv4", func(t *testing.T) {
+		data := []byte(`{
+		"assignee_id": 17,
+		"assignee_type": "server",
+		"auto_delete": true,
+		"blocked": true,
+		"created": "2017-08-16T17:29:14+00:00",
+		"datacenter": {
+			"description": "Falkenstein DC Park 8",
+			"id": 42,
+			"location": {
+				"city": "Falkenstein",
+				"country": "DE",
+				"description": "Falkenstein DC Park 1",
+				"id": 1,
+				"latitude": 50.47612,
+				"longitude": 12.370071,
+				"name": "fsn1",
+				"network_zone": "eu-central"
+			},
+			"name": "fsn1-dc8",
+			"server_types": {
+				"available": [],
+				"available_for_migration": [],
+				"supported": []
+			}
+		},
+		"dns_ptr": [
+			{
+				"dns_ptr": "server.example.com",
+				"ip": "127.0.0.1"
+			}
+		],
+		"id": 4711,
+		"ip": "127.0.0.1",
+		"labels": {
+			"key": "value",
+			"key2": "value2"
+		},
+		"name": "Web Frontend",
+		"protection": {
+			"delete": true
+		},
+		"type": "ipv4"
+  }`)
+
+		var s schema.PrimaryIP
+		if err := json.Unmarshal(data, &s); err != nil {
+			t.Fatal(err)
+		}
+		primaryIP := PrimaryIPFromSchema(s)
+
+		if primaryIP.ID != 4711 {
+			t.Errorf("unexpected ID: %v", primaryIP.ID)
+		}
+		if !primaryIP.Blocked {
+			t.Errorf("unexpected value for Blocked: %v", primaryIP.Blocked)
+		}
+		if !primaryIP.AutoDelete {
+			t.Errorf("unexpected value for AutoDelete: %v", primaryIP.AutoDelete)
+		}
+		if primaryIP.Name != "Web Frontend" {
+			t.Errorf("unexpected name: %v", primaryIP.Name)
+		}
+
+		if primaryIP.IP.String() != "127.0.0.1" {
+			t.Errorf("unexpected IP: %v", primaryIP.IP)
+		}
+		if primaryIP.Type != PrimaryIPTypeIPv4 {
+			t.Errorf("unexpected Type: %v", primaryIP.Type)
+		}
+		if primaryIP.AssigneeType != "server" {
+			t.Errorf("unexpected AssigneeType: %v", primaryIP.AssigneeType)
+		}
+		if primaryIP.AssigneeID != 17 {
+			t.Errorf("unexpected AssigneeID: %v", primaryIP.AssigneeID)
+		}
+		dnsPTR, err := primaryIP.GetDNSPtrForIP(primaryIP.IP)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if primaryIP.DNSPtr == nil || dnsPTR == "" {
+			t.Errorf("unexpected DNS ptr: %v", primaryIP.DNSPtr)
+		}
+		if primaryIP.Datacenter.Name != "fsn1-dc8" {
+			t.Errorf("unexpected datacenter: %v", primaryIP.Datacenter)
+		}
+		if !primaryIP.Protection.Delete {
+			t.Errorf("unexpected Protection.Delete: %v", primaryIP.Protection.Delete)
+		}
+		if primaryIP.Labels["key"] != "value" || primaryIP.Labels["key2"] != "value2" {
+			t.Errorf("unexpected Labels: %v", primaryIP.Labels)
+		}
+		if !primaryIP.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) {
+			t.Errorf("unexpected created date: %v", primaryIP.Created)
+		}
+	})
+}
+
 func TestISOFromSchema(t *testing.T) {
 	data := []byte(`{
 		"id": 4711,
@@ -359,11 +556,7 @@ func TestServerFromSchema(t *testing.T) {
 		"status": "running",
 		"created": "2017-08-16T17:29:14+00:00",
 		"public_net": {
-			"ipv4": {
-				"ip": "1.2.3.4",
-				"blocked": false,
-				"dns_ptr": "server01.example.com"
-			},
+			"ipv4": null,
 			"ipv6": {
 				"ip": "2a01:4f8:1c11:3400::/64",
 				"blocked": false,
@@ -475,8 +668,11 @@ func TestServerFromSchema(t *testing.T) {
 	if !server.Created.Equal(time.Date(2017, 8, 16, 17, 29, 14, 0, time.UTC)) {
 		t.Errorf("unexpected created date: %v", server.Created)
 	}
-	if server.PublicNet.IPv4.IP.String() != "1.2.3.4" {
-		t.Errorf("unexpected public net IPv4 IP: %v", server.PublicNet.IPv4.IP)
+	if !server.PublicNet.IPv4.IsUnspecified() {
+		t.Errorf("unexpected public net IPv4: %v", server.PublicNet.IPv4)
+	}
+	if server.PublicNet.IPv6.IP.String() != "2a01:4f8:1c11:3400::" {
+		t.Errorf("unexpected public net IPv6 IP: %v", server.PublicNet.IPv6.IP)
 	}
 	if server.ServerType.ID != 2 {
 		t.Errorf("unexpected server type ID: %v", server.ServerType.ID)
@@ -577,11 +773,13 @@ func TestServerFromSchemaNoTraffic(t *testing.T) {
 func TestServerPublicNetFromSchema(t *testing.T) {
 	data := []byte(`{
 		"ipv4": {
+			"id": 1,
 			"ip": "1.2.3.4",
 			"blocked": false,
 			"dns_ptr": "server.example.com"
 		},
 		"ipv6": {
+			"id": 2,
 			"ip": "2a01:4f8:1c19:1403::/64",
 			"blocked": false,
 			"dns_ptr": []
@@ -600,10 +798,15 @@ func TestServerPublicNetFromSchema(t *testing.T) {
 		t.Fatal(err)
 	}
 	publicNet := ServerPublicNetFromSchema(s)
-
+	if publicNet.IPv4.ID != 1 {
+		t.Errorf("unexpected IPv4 ID: %v", publicNet.IPv4.ID)
+	}
 	if publicNet.IPv4.IP.String() != "1.2.3.4" {
 		t.Errorf("unexpected IPv4 IP: %v", publicNet.IPv4.IP)
 	}
+	if publicNet.IPv6.ID != 2 {
+		t.Errorf("unexpected IPv6 ID: %v", publicNet.IPv6.ID)
+	}
 	if publicNet.IPv6.Network.String() != "2a01:4f8:1c19:1403::/64" {
 		t.Errorf("unexpected IPv6 IP: %v", publicNet.IPv6.IP)
 	}
@@ -1837,6 +2040,24 @@ func TestPricingFromSchema(t *testing.T) {
 				"type": "ipv4"
 			  }
 			],
+		"primary_ips": [
+			{
+				"prices": [
+				{
+					"datacenter": "fsn1-dc8",
+					"price_hourly": {
+					"gross": "1.1900000000000000",
+					"net": "1.0000000000"
+					},
+					"price_monthly": {
+					"gross": "1.1900000000000000",
+					"net": "1.0000000000"
+					}
+				}
+				],
+				"type": "ipv4"
+			}
+		],
 		"traffic": {
 			"price_per_tb": {
 				"net": "1",
@@ -1953,6 +2174,35 @@ func TestPricingFromSchema(t *testing.T) {
 		}
 	}
 
+	if len(pricing.PrimaryIPs) != 1 {
+		t.Errorf("unexpected number of Primary IPs: %d", len(pricing.PrimaryIPs))
+	} else {
+		ip := pricing.PrimaryIPs[0]
+
+		if ip.Type != "ipv4" {
+			t.Errorf("unexpected .Type: %s", ip.Type)
+		}
+		if len(ip.Pricings) != 1 {
+			t.Errorf("unexpected number of prices: %d", len(ip.Pricings))
+		} else {
+			if ip.Pricings[0].Datacenter != "fsn1-dc8" {
+				t.Errorf("unexpected Datacenter: %v", ip.Pricings[0].Datacenter)
+			}
+			if ip.Pricings[0].Monthly.Net != "1.0000000000" {
+				t.Errorf("unexpected Monthly.Net: %v", ip.Pricings[0].Monthly.Net)
+			}
+			if ip.Pricings[0].Monthly.Gross != "1.1900000000000000" {
+				t.Errorf("unexpected Monthly.Gross: %v", ip.Pricings[0].Monthly.Gross)
+			}
+			if ip.Pricings[0].Hourly.Net != "1.0000000000" {
+				t.Errorf("unexpected Hourly.Net: %v", ip.Pricings[0].Hourly.Net)
+			}
+			if ip.Pricings[0].Hourly.Gross != "1.1900000000000000" {
+				t.Errorf("unexpected Hourly.Gross: %v", ip.Pricings[0].Hourly.Gross)
+			}
+		}
+	}
+
 	if pricing.Volume.PerGBMonthly.Currency != "EUR" {
 		t.Errorf("unexpected Traffic.PerTB.Currency: %v", pricing.Volume.PerGBMonthly.Currency)
 	}
diff --git a/hcloud/server_test.go b/hcloud/server_test.go
index a6463ff..4add328 100644
--- a/hcloud/server_test.go
+++ b/hcloud/server_test.go
@@ -461,6 +461,156 @@ func TestServersCreateWithNetworks(t *testing.T) {
 	}
 }
 
+func TestServersCreateWithPrivateNetworkOnly(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.Networks) != 2 || reqBody.Networks[0] != 1 || reqBody.Networks[1] != 2 {
+			t.Errorf("unexpected Networks: %v", reqBody.Networks)
+		}
+		if reqBody.PublicNet.EnableIPv4 != false {
+			t.Errorf("unexpected PublicNet.EnableIPv4: %v", reqBody.PublicNet.EnableIPv4)
+		}
+		if reqBody.PublicNet.EnableIPv6 != false {
+			t.Errorf("unexpected PublicNet.EnableIPv6: %v", reqBody.PublicNet.EnableIPv6)
+		}
+		if reqBody.PublicNet.IPv4ID != 0 {
+			t.Errorf("unexpected PublicNet.IPv4: %v", reqBody.PublicNet.IPv4ID)
+		}
+		if reqBody.PublicNet.IPv6ID != 0 {
+			t.Errorf("unexpected PublicNet.IPv6: %v", reqBody.PublicNet.IPv6ID)
+		}
+		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},
+		Networks: []*Network{
+			{ID: 1},
+			{ID: 2},
+		},
+		PublicNet: &ServerCreatePublicNet{
+			EnableIPv4: false,
+			EnableIPv6: false,
+		},
+	})
+	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 TestServersCreateWithIPv6Only(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 reqBody.PublicNet.EnableIPv4 != false {
+			t.Errorf("unexpected PublicNet.EnableIPv4: %v", reqBody.PublicNet.EnableIPv4)
+		}
+		if reqBody.PublicNet.EnableIPv6 != true {
+			t.Errorf("unexpected PublicNet.EnableIPv6: %v", reqBody.PublicNet.EnableIPv6)
+		}
+		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},
+		PublicNet:  &ServerCreatePublicNet{EnableIPv4: false, EnableIPv6: true},
+	})
+	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 TestServersCreateWithDefaultPublicNet(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 reqBody.PublicNet != nil {
+			t.Errorf("unexpected PublicNet: %v", reqBody.PublicNet)
+		}
+		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},
+	})
+	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 TestServersCreateWithDatacenterID(t *testing.T) {
 	env := newTestEnv()
 	defer env.Teardown()