Repository: cloudflare/cloudflare-go (go)

Issue:
Allow filtering of Listing Argo/Cloudflare Tunnels
### Current cloudflare-go version

v0.34.0

### Description

[`ArgoTunnels(ctx context.Context, accountID string)`](https://github.com/cloudflare/cloudflare-go/blob/master/argo_tunnel.go#L47) does not allow for filtering of tunnels

Suppose we only want to list non-deleted tunnels. This is possible via the [REST API endpoint](https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels) by providing Optional Parameters, i.e. `accounts/:account_identifier/tunnels?is_deleted=false`

Ideally, there could be a third parameter to `ArgoTunnels(...)` that allows for filtering similar to how Listing DNS Records is handled in `cloudflare-go`, i.e.

[`DNSRecords(ctx context.Context, zoneID string, rr DNSRecord)`](https://github.com/cloudflare/cloudflare-go/blob/master/dns.go#L94) where `rr` allows for filtering the list

### Use cases

https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels
https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnels

### Potential cloudflare-go usage

```go
https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels
https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnels
```


### References

https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels
https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnels

Pull request description:
argo_tunnel: Deprecate `ArgoTunnel` methods in favour of `Tunnel` methods
The Argo Tunnel product has been rebadged to be Tunnels and is due to be
[sunset in Feb 2024](https://api.cloudflare.com/#argo-tunnel-properties).

This brings the library in line with that expectation and deprecates the
older methods. The API is 1:1 compatible so just the method names are
changing. In addition, I've moved the newer methods to follow the
upcoming [experimental changes](https://github.com/cloudflare/cloudflare-go/blob/master/docs/experimental.md) for parameters which makes the methods
more consistent for use.

As a fallback measure I also updated the ArgoTunnel methods to use the
`cfd_tunnel` URL to ensure we don't have anyone left behind but the plan
is to remove these methods with the API endpoints so best poke people to
move now.

Closes #832


Patch:
diff --git a/argo_tunnel.go b/argo_tunnel.go
index 4ad69ca60..91d958622 100644
--- a/argo_tunnel.go
+++ b/argo_tunnel.go
@@ -44,8 +44,10 @@ type ArgoTunnelDetailResponse struct {
 // ArgoTunnels lists all tunnels.
 //
 // API reference: https://api.cloudflare.com/#argo-tunnel-list-argo-tunnels
+//
+// Deprecated: Use `Tunnels` instead.
 func (api *API) ArgoTunnels(ctx context.Context, accountID string) ([]ArgoTunnel, error) {
-	uri := fmt.Sprintf("/accounts/%s/tunnels", accountID)
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", accountID)
 
 	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, argoV1Header())
 	if err != nil {
@@ -63,8 +65,10 @@ func (api *API) ArgoTunnels(ctx context.Context, accountID string) ([]ArgoTunnel
 // ArgoTunnel returns a single Argo tunnel.
 //
 // API reference: https://api.cloudflare.com/#argo-tunnel-get-argo-tunnel
+//
+// Deprecated: Use `Tunnel` instead.
 func (api *API) ArgoTunnel(ctx context.Context, accountID, tunnelUUID string) (ArgoTunnel, error) {
-	uri := fmt.Sprintf("/accounts/%s/tunnels/%s", accountID, tunnelUUID)
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", accountID, tunnelUUID)
 
 	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, argoV1Header())
 	if err != nil {
@@ -82,8 +86,10 @@ func (api *API) ArgoTunnel(ctx context.Context, accountID, tunnelUUID string) (A
 // CreateArgoTunnel creates a new tunnel for the account.
 //
 // API reference: https://api.cloudflare.com/#argo-tunnel-create-argo-tunnel
+//
+// Deprecated: Use `CreateTunnel` instead.
 func (api *API) CreateArgoTunnel(ctx context.Context, accountID, name, secret string) (ArgoTunnel, error) {
-	uri := fmt.Sprintf("/accounts/%s/tunnels", accountID)
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", accountID)
 
 	tunnel := ArgoTunnel{Name: name, Secret: secret}
 
@@ -104,8 +110,10 @@ func (api *API) CreateArgoTunnel(ctx context.Context, accountID, name, secret st
 // DeleteArgoTunnel removes a single Argo tunnel.
 //
 // API reference: https://api.cloudflare.com/#argo-tunnel-delete-argo-tunnel
+//
+// Deprecated: Use `DeleteTunnel` instead.
 func (api *API) DeleteArgoTunnel(ctx context.Context, accountID, tunnelUUID string) error {
-	uri := fmt.Sprintf("/accounts/%s/tunnels/%s", accountID, tunnelUUID)
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", accountID, tunnelUUID)
 
 	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, argoV1Header())
 	if err != nil {
@@ -124,8 +132,10 @@ func (api *API) DeleteArgoTunnel(ctx context.Context, accountID, tunnelUUID stri
 // CleanupArgoTunnelConnections deletes any inactive connections on a tunnel.
 //
 // API reference: https://api.cloudflare.com/#argo-tunnel-clean-up-argo-tunnel-connections
+//
+// Deprecated: Use `CleanupTunnelConnections` instead.
 func (api *API) CleanupArgoTunnelConnections(ctx context.Context, accountID, tunnelUUID string) error {
-	uri := fmt.Sprintf("/accounts/%s/tunnels/%s/connections", accountID, tunnelUUID)
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", accountID, tunnelUUID)
 
 	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, argoV1Header())
 	if err != nil {
diff --git a/tunnel.go b/tunnel.go
new file mode 100644
index 000000000..6f0cad339
--- /dev/null
+++ b/tunnel.go
@@ -0,0 +1,289 @@
+package cloudflare
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/google/go-querystring/query"
+	"github.com/pkg/errors"
+)
+
+// Tunnel is the struct definition of a tunnel.
+type Tunnel struct {
+	ID             string             `json:"id,omitempty"`
+	Name           string             `json:"name,omitempty"`
+	Secret         string             `json:"tunnel_secret,omitempty"`
+	CreatedAt      *time.Time         `json:"created_at,omitempty"`
+	DeletedAt      *time.Time         `json:"deleted_at,omitempty"`
+	Connections    []TunnelConnection `json:"connections,omitempty"`
+	ConnsActiveAt  *time.Time         `json:"conns_active_at,omitempty"`
+	ConnInactiveAt *time.Time         `json:"conns_inactive_at,omitempty"`
+}
+
+// TunnelConnection represents the connections associated with a tunnel.
+type TunnelConnection struct {
+	ColoName           string `json:"colo_name"`
+	ID                 string `json:"id"`
+	IsPendingReconnect bool   `json:"is_pending_reconnect"`
+	ClientID           string `json:"client_id"`
+	ClientVersion      string `json:"client_version"`
+	OpenedAt           string `json:"opened_at"`
+	OriginIP           string `json:"origin_ip"`
+}
+
+// TunnelsDetailResponse is used for representing the API response payload for
+// multiple tunnels.
+type TunnelsDetailResponse struct {
+	Result []Tunnel `json:"result"`
+	Response
+}
+
+// TunnelDetailResponse is used for representing the API response payload for
+// a single tunnel.
+type TunnelDetailResponse struct {
+	Result Tunnel `json:"result"`
+	Response
+}
+
+type TunnelParams struct {
+	AccountID string
+	ID        string
+}
+
+type TunnelCreateParams struct {
+	AccountID string `json:"-"`
+	Name      string `json:"name,omitempty"`
+	Secret    string `json:"tunnel_secret,omitempty"`
+}
+
+type TunnelUpdateParams struct {
+	AccountID string `json:"-"`
+	Name      string `json:"name,omitempty"`
+	Secret    string `json:"tunnel_secret,omitempty"`
+}
+
+type TunnelDeleteParams struct {
+	AccountID string
+	ID        string
+}
+
+type TunnelCleanupParams struct {
+	AccountID string
+	ID        string
+}
+
+type TunnelTokenParams struct {
+	AccountID string
+	ID        string
+}
+
+type TunnelListParams struct {
+	AccountID string
+	Name      string     `url:"name,omitempty"`
+	UUID      string     `url:"uuid,omitempty"` // the tunnel ID
+	IsDeleted bool       `url:"is_deleted,omitempty"`
+	ExistedAt *time.Time `url:"existed_at,omitempty"`
+}
+
+// Tunnels lists all tunnels.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-list-cloudflare-tunnels
+func (api *API) Tunnels(ctx context.Context, params TunnelListParams) ([]Tunnel, error) {
+	if params.AccountID == "" {
+		return []Tunnel{}, ErrMissingAccountID
+	}
+
+	v, _ := query.Values(params)
+	queryParams := v.Encode()
+	if queryParams != "" {
+		queryParams = "?" + queryParams
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", params.AccountID)
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri+queryParams, nil, nil)
+	if err != nil {
+		return []Tunnel{}, err
+	}
+
+	var argoDetailsResponse TunnelsDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return []Tunnel{}, errors.Wrap(err, errUnmarshalError)
+	}
+	return argoDetailsResponse.Result, nil
+}
+
+// Tunnel returns a single Argo tunnel.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-get-cloudflare-tunnel
+func (api *API) Tunnel(ctx context.Context, params TunnelParams) (Tunnel, error) {
+	if params.AccountID == "" {
+		return Tunnel{}, ErrMissingAccountID
+	}
+
+	if params.ID == "" {
+		return Tunnel{}, errors.New("missing tunnel ID")
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", params.AccountID, params.ID)
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, nil)
+	if err != nil {
+		return Tunnel{}, err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return Tunnel{}, errors.Wrap(err, errUnmarshalError)
+	}
+	return argoDetailsResponse.Result, nil
+}
+
+// CreateTunnel creates a new tunnel for the account.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-create-cloudflare-tunnel
+func (api *API) CreateTunnel(ctx context.Context, params TunnelCreateParams) (Tunnel, error) {
+	if params.AccountID == "" {
+		return Tunnel{}, ErrMissingAccountID
+	}
+
+	if params.Name == "" {
+		return Tunnel{}, errors.New("missing tunnel name")
+	}
+
+	if params.Secret == "" {
+		return Tunnel{}, errors.New("missing tunnel secret")
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", params.AccountID)
+
+	tunnel := Tunnel{Name: params.Name, Secret: params.Secret}
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPost, uri, tunnel, nil)
+	if err != nil {
+		return Tunnel{}, err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return Tunnel{}, errors.Wrap(err, errUnmarshalError)
+	}
+
+	return argoDetailsResponse.Result, nil
+}
+
+// UpdateTunnel updates an existing tunnel for the account.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-update-cloudflare-tunnel
+func (api *API) UpdateTunnel(ctx context.Context, params TunnelUpdateParams) (Tunnel, error) {
+	if params.AccountID == "" {
+		return Tunnel{}, ErrMissingAccountID
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel", params.AccountID)
+
+	var tunnel Tunnel
+
+	if params.Name != "" {
+		tunnel.Name = params.Name
+	}
+
+	if params.Secret != "" {
+		tunnel.Secret = params.Secret
+	}
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodPatch, uri, tunnel, nil)
+	if err != nil {
+		return Tunnel{}, err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return Tunnel{}, errors.Wrap(err, errUnmarshalError)
+	}
+
+	return argoDetailsResponse.Result, nil
+}
+
+// DeleteTunnel removes a single Argo tunnel.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-delete-cloudflare-tunnel
+func (api *API) DeleteTunnel(ctx context.Context, params TunnelDeleteParams) error {
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s", params.AccountID, params.ID)
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, nil)
+	if err != nil {
+		return err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return errors.Wrap(err, errUnmarshalError)
+	}
+
+	return nil
+}
+
+// CleanupTunnelConnections deletes any inactive connections on a tunnel.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-clean-up-cloudflare-tunnel-connections
+func (api *API) CleanupTunnelConnections(ctx context.Context, params TunnelCleanupParams) error {
+	if params.AccountID == "" {
+		return ErrMissingAccountID
+	}
+
+	if params.ID == "" {
+		return errors.New("missing tunnel ID")
+	}
+
+	uri := fmt.Sprintf("/accounts/%s/cfd_tunnel/%s/connections", params.AccountID, params.ID)
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodDelete, uri, nil, nil)
+	if err != nil {
+		return err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return errors.Wrap(err, errUnmarshalError)
+	}
+
+	return nil
+}
+
+// TunnelToken that allows to run a tunnel.
+//
+// API reference: https://api.cloudflare.com/#cloudflare-tunnel-get-cloudflare-tunnel-token
+func (api *API) TunnelToken(ctx context.Context, params TunnelTokenParams) error {
+	if params.AccountID == "" {
+		return ErrMissingAccountID
+	}
+
+	if params.ID == "" {
+		return errors.New("missing tunnel ID")
+	}
+
+	uri := fmt.Sprintf("accounts/%s/cfd_tunnel/%s/token", params.AccountID, params.ID)
+
+	res, err := api.makeRequestContextWithHeaders(ctx, http.MethodGet, uri, nil, nil)
+	if err != nil {
+		return err
+	}
+
+	var argoDetailsResponse TunnelDetailResponse
+	err = json.Unmarshal(res, &argoDetailsResponse)
+	if err != nil {
+		return errors.Wrap(err, errUnmarshalError)
+	}
+
+	return nil
+}


Test patch:
diff --git a/argo_tunnel_test.go b/argo_tunnel_test.go
index 82dbb77b1..29f29e100 100644
--- a/argo_tunnel_test.go
+++ b/argo_tunnel_test.go
@@ -40,7 +40,7 @@ func TestArgoTunnels(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/tunnels", handler)
+	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel", handler)
 
 	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
 	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
@@ -91,7 +91,7 @@ func TestArgoTunnel(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/tunnels/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
+	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
 
 	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
 	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
@@ -142,7 +142,7 @@ func TestCreateArgoTunnel(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/tunnels", handler)
+	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel", handler)
 
 	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
 	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
@@ -193,7 +193,7 @@ func TestDeleteArgoTunnel(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/tunnels/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
+	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
 
 	err := client.DeleteArgoTunnel(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f174e90a-fafe-4643-bbbc-4a0ed4fc8415")
 	assert.NoError(t, err)
@@ -214,7 +214,7 @@ func TestCleanupArgoTunnelConnections(t *testing.T) {
 		`)
 	}
 
-	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/tunnels/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/connections", handler)
+	mux.HandleFunc("/accounts/01a7362d577a6c3019a474fd6f485823/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/connections", handler)
 
 	err := client.CleanupArgoTunnelConnections(context.Background(), "01a7362d577a6c3019a474fd6f485823", "f174e90a-fafe-4643-bbbc-4a0ed4fc8415")
 	assert.NoError(t, err)
diff --git a/tunnel_test.go b/tunnel_test.go
new file mode 100644
index 000000000..d6287544b
--- /dev/null
+++ b/tunnel_test.go
@@ -0,0 +1,249 @@
+package cloudflare
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTunnels(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": [
+				{
+					"id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+					"name": "blog",
+					"created_at": "2009-11-10T23:00:00Z",
+					"deleted_at": "2009-11-10T23:00:00Z",
+					"connections": [
+						{
+							"colo_name": "DFW",
+							"id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+							"is_pending_reconnect": false,
+							"client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+							"client_version": "2022.2.0",
+							"opened_at": "2021-01-25T18:22:34.317854Z",
+							"origin_ip": "85.12.78.6"
+						}
+					]
+				}
+			]
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	want := []Tunnel{{
+		ID:        "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+		Name:      "blog",
+		CreatedAt: &createdAt,
+		DeletedAt: &deletedAt,
+		Connections: []TunnelConnection{{
+			ColoName:           "DFW",
+			ID:                 "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+			IsPendingReconnect: false,
+			ClientID:           "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+			ClientVersion:      "2022.2.0",
+			OpenedAt:           "2021-01-25T18:22:34.317854Z",
+			OriginIP:           "85.12.78.6",
+		}},
+	}}
+
+	actual, err := client.Tunnels(context.Background(), TunnelListParams{AccountID: testAccountID, UUID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestTunnel(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodGet, r.Method, "Expected method 'GET', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success":true,
+			"errors":[],
+			"messages":[],
+			"result":{
+				"id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+				"name":"blog",
+				"created_at":"2009-11-10T23:00:00Z",
+				"deleted_at":"2009-11-10T23:00:00Z",
+				"connections": [
+					{
+						"colo_name": "DFW",
+						"id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+						"is_pending_reconnect": false,
+						"client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+						"client_version": "2022.2.0",
+						"opened_at": "2021-01-25T18:22:34.317854Z",
+						"origin_ip": "85.12.78.6"
+					}
+				]
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	want := Tunnel{
+		ID:        "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+		Name:      "blog",
+		CreatedAt: &createdAt,
+		DeletedAt: &deletedAt,
+		Connections: []TunnelConnection{{
+			ColoName:           "DFW",
+			ID:                 "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+			IsPendingReconnect: false,
+			ClientID:           "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+			ClientVersion:      "2022.2.0",
+			OpenedAt:           "2021-01-25T18:22:34.317854Z",
+			OriginIP:           "85.12.78.6",
+		}},
+	}
+
+	actual, err := client.Tunnel(context.Background(), TunnelParams{AccountID: testAccountID, ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestCreateTunnel(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success":true,
+			"errors":[],
+			"messages":[],
+			"result":{
+				"id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+				"name":"blog",
+				"created_at":"2009-11-10T23:00:00Z",
+				"deleted_at":"2009-11-10T23:00:00Z",
+				"connections": [
+					{
+						"colo_name": "DFW",
+						"id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+						"is_pending_reconnect": false,
+						"client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+						"client_version": "2022.2.0",
+						"opened_at": "2021-01-25T18:22:34.317854Z",
+						"origin_ip": "85.12.78.6"
+					}
+				]
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel", handler)
+
+	createdAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	deletedAt, _ := time.Parse(time.RFC3339, "2009-11-10T23:00:00Z")
+	want := Tunnel{
+		ID:        "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+		Name:      "blog",
+		CreatedAt: &createdAt,
+		DeletedAt: &deletedAt,
+		Connections: []TunnelConnection{{
+			ColoName:           "DFW",
+			ID:                 "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+			IsPendingReconnect: false,
+			ClientID:           "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+			ClientVersion:      "2022.2.0",
+			OpenedAt:           "2021-01-25T18:22:34.317854Z",
+			OriginIP:           "85.12.78.6",
+		}},
+	}
+
+	actual, err := client.CreateTunnel(context.Background(), TunnelCreateParams{AccountID: testAccountID, Name: "blog", Secret: "notarealsecret"})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, want, actual)
+	}
+}
+
+func TestDeleteTunnel(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success":true,
+			"errors":[],
+			"messages":[],
+			"result":{
+				"id":"f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+				"name":"blog",
+				"created_at":"2009-11-10T23:00:00Z",
+				"deleted_at":"2009-11-10T23:00:00Z",
+				"connections": [
+					{
+						"colo_name": "DFW",
+						"id": "f174e90a-fafe-4643-bbbc-4a0ed4fc8415",
+						"is_pending_reconnect": false,
+						"client_id": "dc6472cc-f1ae-44a0-b795-6b8a0ce29f90",
+						"client_version": "2022.2.0",
+						"opened_at": "2021-01-25T18:22:34.317854Z",
+						"origin_ip": "85.12.78.6"
+					}
+				]
+			}
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415", handler)
+
+	err := client.DeleteTunnel(context.Background(), TunnelDeleteParams{AccountID: testAccountID, ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"})
+	assert.NoError(t, err)
+}
+
+func TestCleanupTunnelConnections(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodDelete, r.Method, "Expected method 'DELETE', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": []
+		}
+		`)
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/cfd_tunnel/f174e90a-fafe-4643-bbbc-4a0ed4fc8415/connections", handler)
+
+	err := client.CleanupTunnelConnections(context.Background(), TunnelCleanupParams{AccountID: testAccountID, ID: "f174e90a-fafe-4643-bbbc-4a0ed4fc8415"})
+	assert.NoError(t, err)
+}