Skip to content

Commit 98736a0

Browse files
committed
TUN-5915: New cloudflared command to allow to retrieve the token credentials for a Tunnel
1 parent 4836216 commit 98736a0

File tree

10 files changed

+153
-5
lines changed

10 files changed

+153
-5
lines changed

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 2022.3.4
2+
### New Features
3+
- It is now possible to retrieve the credentials that allow to run a Tunnel in case you forgot/lost them. This is
4+
achievable with: `cloudflared tunnel token --cred-file /path/to/file.json TUNNEL`. This new feature only works for
5+
Tunnels created with cloudflared version 2022.3.0 or more recent.
6+
17
## 2022.3.3
28
### Bug Fixes
39
- `cloudflared service install` now starts the underlying agent service on Windows operating system (similarly to the

cfapi/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
type TunnelClient interface {
88
CreateTunnel(name string, tunnelSecret []byte) (*TunnelWithToken, error)
99
GetTunnel(tunnelID uuid.UUID) (*Tunnel, error)
10+
GetTunnelToken(tunnelID uuid.UUID) (string, error)
1011
DeleteTunnel(tunnelID uuid.UUID) error
1112
ListTunnels(filter *TunnelFilter) ([]*Tunnel, error)
1213
ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error)

cfapi/tunnel.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,23 @@ func (r *RESTClient) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) {
116116
return nil, r.statusCodeToError("get tunnel", resp)
117117
}
118118

119+
func (r *RESTClient) GetTunnelToken(tunnelID uuid.UUID) (token string, err error) {
120+
endpoint := r.baseEndpoints.accountLevel
121+
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/token", tunnelID))
122+
resp, err := r.sendRequest("GET", endpoint, nil)
123+
if err != nil {
124+
return "", errors.Wrap(err, "REST request failed")
125+
}
126+
defer resp.Body.Close()
127+
128+
if resp.StatusCode == http.StatusOK {
129+
err = parseResponse(resp.Body, &token)
130+
return token, err
131+
}
132+
133+
return "", r.statusCodeToError("get tunnel token", resp)
134+
}
135+
119136
func (r *RESTClient) DeleteTunnel(tunnelID uuid.UUID) error {
120137
endpoint := r.baseEndpoints.accountLevel
121138
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID))

cmd/cloudflared/tunnel/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func Commands() []*cli.Command {
109109
buildIngressSubcommand(),
110110
buildDeleteCommand(),
111111
buildCleanupCommand(),
112+
buildTokenCommand(),
112113
// for compatibility, allow following as tunnel subcommands
113114
proxydns.Command(true),
114115
cliutil.RemovedCommand("db-connect"),

cmd/cloudflared/tunnel/subcommand_context.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,21 @@ func (sc *subcommandContext) cleanupConnections(tunnelIDs []uuid.UUID) error {
341341
return nil
342342
}
343343

344+
func (sc *subcommandContext) getTunnelTokenCredentials(tunnelID uuid.UUID) (*connection.TunnelToken, error) {
345+
client, err := sc.client()
346+
if err != nil {
347+
return nil, err
348+
}
349+
350+
token, err := client.GetTunnelToken(tunnelID)
351+
if err != nil {
352+
sc.log.Err(err).Msgf("Could not get the Token for the given Tunnel %v", tunnelID)
353+
return nil, err
354+
}
355+
356+
return ParseToken(token)
357+
}
358+
344359
func (sc *subcommandContext) route(tunnelID uuid.UUID, r cfapi.HostnameRoute) (cfapi.HostnameRouteResult, error) {
345360
client, err := sc.client()
346361
if err != nil {

cmd/cloudflared/tunnel/subcommand_context_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ func (d *deleteMockTunnelStore) GetTunnel(tunnelID uuid.UUID) (*cfapi.Tunnel, er
216216
return &tunnel.tunnel, nil
217217
}
218218

219+
func (d *deleteMockTunnelStore) GetTunnelToken(tunnelID uuid.UUID) (string, error) {
220+
return "token", nil
221+
}
222+
219223
func (d *deleteMockTunnelStore) DeleteTunnel(tunnelID uuid.UUID) error {
220224
tunnel, ok := d.mockTunnels[tunnelID]
221225
if !ok {

cmd/cloudflared/tunnel/subcommands.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,59 @@ func cleanupCommand(c *cli.Context) error {
714714
return sc.cleanupConnections(tunnelIDs)
715715
}
716716

717+
func buildTokenCommand() *cli.Command {
718+
return &cli.Command{
719+
Name: "token",
720+
Action: cliutil.ConfiguredAction(tokenCommand),
721+
Usage: "Fetch the credentials token for an existing tunnel (by name or UUID) that allows to run it",
722+
UsageText: "cloudflared tunnel [tunnel command options] token [subcommand options] TUNNEL",
723+
Description: "cloudflared tunnel token will fetch the credentials token for a given tunnel (by its name or UUID), which is then used to run the tunnel. This command fails if the tunnel does not exist or has been deleted. Use the flag `cloudflared tunnel token --cred-file /my/path/file.json TUNNEL` to output the token to the credentials JSON file. Note: this command only works for Tunnels created since cloudflared version 2022.3.0",
724+
Flags: []cli.Flag{credentialsFileFlagCLIOnly},
725+
CustomHelpTemplate: commandHelpTemplate(),
726+
}
727+
}
728+
729+
func tokenCommand(c *cli.Context) error {
730+
sc, err := newSubcommandContext(c)
731+
if err != nil {
732+
return errors.Wrap(err, "error setting up logger")
733+
}
734+
735+
warningChecker := updater.StartWarningCheck(c)
736+
defer warningChecker.LogWarningIfAny(sc.log)
737+
738+
if c.NArg() != 1 {
739+
return cliutil.UsageError(`"cloudflared tunnel token" requires exactly 1 argument, the name or UUID of tunnel to fetch the credentials token for.`)
740+
}
741+
tunnelID, err := sc.findID(c.Args().First())
742+
if err != nil {
743+
return errors.Wrap(err, "error parsing tunnel ID")
744+
}
745+
746+
token, err := sc.getTunnelTokenCredentials(tunnelID)
747+
if err != nil {
748+
return err
749+
}
750+
751+
if path := c.String(CredFileFlag); path != "" {
752+
credentials := token.Credentials()
753+
err := writeTunnelCredentials(path, &credentials)
754+
if err != nil {
755+
return errors.Wrapf(err, "error writing token credentials to JSON file in path %s", path)
756+
}
757+
758+
return nil
759+
}
760+
761+
encodedToken, err := token.Encode()
762+
if err != nil {
763+
return err
764+
}
765+
766+
fmt.Printf("%s", encodedToken)
767+
return nil
768+
}
769+
717770
func buildRouteCommand() *cli.Command {
718771
return &cli.Command{
719772
Name: "route",

component-tests/config.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,18 @@ def base_config(self):
7272

7373
return config
7474

75+
def get_tunnel_id(self):
76+
return self.full_config["tunnel"]
77+
7578
def get_token(self):
76-
with open(self.credentials_file) as json_file:
77-
creds = json.load(json_file)
78-
token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]}
79-
token_json_str = json.dumps(token_dict)
79+
creds = self.get_credentials_json()
80+
token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]}
81+
token_json_str = json.dumps(token_dict)
82+
return base64.b64encode(token_json_str.encode('utf-8'))
8083

81-
return base64.b64encode(token_json_str.encode('utf-8'))
84+
def get_credentials_json(self):
85+
with open(self.credentials_file) as json_file:
86+
return json.load(json_file)
8287

8388

8489
@dataclass(frozen=True)

component-tests/test_token.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import base64
2+
import json
3+
4+
from setup import get_config_from_file, persist_origin_cert
5+
from util import start_cloudflared
6+
7+
8+
class TestToken:
9+
def test_get_token(self, tmp_path, component_tests_config):
10+
config = component_tests_config()
11+
tunnel_id = config.get_tunnel_id()
12+
13+
token_args = ["--origincert", cert_path(), "token", tunnel_id]
14+
output = start_cloudflared(tmp_path, config, token_args)
15+
16+
assert parse_token(config.get_token()) == parse_token(output.stdout)
17+
18+
def test_get_credentials_file(self, tmp_path, component_tests_config):
19+
config = component_tests_config()
20+
tunnel_id = config.get_tunnel_id()
21+
22+
cred_file = tmp_path / "test_get_credentials_file.json"
23+
token_args = ["--origincert", cert_path(), "token", "--cred-file", cred_file, tunnel_id]
24+
start_cloudflared(tmp_path, config, token_args)
25+
26+
with open(cred_file) as json_file:
27+
assert config.get_credentials_json() == json.load(json_file)
28+
29+
30+
def cert_path():
31+
return get_config_from_file()["origincert"]
32+
33+
34+
def parse_token(token):
35+
return json.loads(base64.b64decode(token))

connection/connection.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package connection
22

33
import (
44
"context"
5+
"encoding/base64"
56
"fmt"
67
"io"
78
"math"
@@ -11,6 +12,7 @@ import (
1112
"time"
1213

1314
"github.com/google/uuid"
15+
"github.com/pkg/errors"
1416

1517
"github.com/cloudflare/cloudflared/tunnelrpc/pogs"
1618
"github.com/cloudflare/cloudflared/websocket"
@@ -65,6 +67,15 @@ func (t TunnelToken) Credentials() Credentials {
6567
}
6668
}
6769

70+
func (t TunnelToken) Encode() (string, error) {
71+
val, err := json.Marshal(t)
72+
if err != nil {
73+
return "", errors.Wrap(err, "could not JSON encode token")
74+
}
75+
76+
return base64.StdEncoding.EncodeToString(val), nil
77+
}
78+
6879
type ClassicTunnelProperties struct {
6980
Hostname string
7081
OriginCert []byte

0 commit comments

Comments
 (0)