TUN-5915: New cloudflared command to allow to retrieve the token credentials for a Tunnel

This commit is contained in:
Nuno Diegues 2022-03-22 12:46:07 +00:00
parent 4836216a9b
commit 98736a03e1
10 changed files with 154 additions and 6 deletions

View File

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

View File

@ -7,6 +7,7 @@ import (
type TunnelClient interface { type TunnelClient interface {
CreateTunnel(name string, tunnelSecret []byte) (*TunnelWithToken, error) CreateTunnel(name string, tunnelSecret []byte) (*TunnelWithToken, error)
GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error)
GetTunnelToken(tunnelID uuid.UUID) (string, error)
DeleteTunnel(tunnelID uuid.UUID) error DeleteTunnel(tunnelID uuid.UUID) error
ListTunnels(filter *TunnelFilter) ([]*Tunnel, error) ListTunnels(filter *TunnelFilter) ([]*Tunnel, error)
ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error)

View File

@ -116,6 +116,23 @@ func (r *RESTClient) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) {
return nil, r.statusCodeToError("get tunnel", resp) return nil, r.statusCodeToError("get tunnel", resp)
} }
func (r *RESTClient) GetTunnelToken(tunnelID uuid.UUID) (token string, err error) {
endpoint := r.baseEndpoints.accountLevel
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v/token", tunnelID))
resp, err := r.sendRequest("GET", endpoint, nil)
if err != nil {
return "", errors.Wrap(err, "REST request failed")
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
err = parseResponse(resp.Body, &token)
return token, err
}
return "", r.statusCodeToError("get tunnel token", resp)
}
func (r *RESTClient) DeleteTunnel(tunnelID uuid.UUID) error { func (r *RESTClient) DeleteTunnel(tunnelID uuid.UUID) error {
endpoint := r.baseEndpoints.accountLevel endpoint := r.baseEndpoints.accountLevel
endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID)) endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID))

View File

@ -109,6 +109,7 @@ func Commands() []*cli.Command {
buildIngressSubcommand(), buildIngressSubcommand(),
buildDeleteCommand(), buildDeleteCommand(),
buildCleanupCommand(), buildCleanupCommand(),
buildTokenCommand(),
// for compatibility, allow following as tunnel subcommands // for compatibility, allow following as tunnel subcommands
proxydns.Command(true), proxydns.Command(true),
cliutil.RemovedCommand("db-connect"), cliutil.RemovedCommand("db-connect"),

View File

@ -341,6 +341,21 @@ func (sc *subcommandContext) cleanupConnections(tunnelIDs []uuid.UUID) error {
return nil return nil
} }
func (sc *subcommandContext) getTunnelTokenCredentials(tunnelID uuid.UUID) (*connection.TunnelToken, error) {
client, err := sc.client()
if err != nil {
return nil, err
}
token, err := client.GetTunnelToken(tunnelID)
if err != nil {
sc.log.Err(err).Msgf("Could not get the Token for the given Tunnel %v", tunnelID)
return nil, err
}
return ParseToken(token)
}
func (sc *subcommandContext) route(tunnelID uuid.UUID, r cfapi.HostnameRoute) (cfapi.HostnameRouteResult, error) { func (sc *subcommandContext) route(tunnelID uuid.UUID, r cfapi.HostnameRoute) (cfapi.HostnameRouteResult, error) {
client, err := sc.client() client, err := sc.client()
if err != nil { if err != nil {

View File

@ -216,6 +216,10 @@ func (d *deleteMockTunnelStore) GetTunnel(tunnelID uuid.UUID) (*cfapi.Tunnel, er
return &tunnel.tunnel, nil return &tunnel.tunnel, nil
} }
func (d *deleteMockTunnelStore) GetTunnelToken(tunnelID uuid.UUID) (string, error) {
return "token", nil
}
func (d *deleteMockTunnelStore) DeleteTunnel(tunnelID uuid.UUID) error { func (d *deleteMockTunnelStore) DeleteTunnel(tunnelID uuid.UUID) error {
tunnel, ok := d.mockTunnels[tunnelID] tunnel, ok := d.mockTunnels[tunnelID]
if !ok { if !ok {

View File

@ -714,6 +714,59 @@ func cleanupCommand(c *cli.Context) error {
return sc.cleanupConnections(tunnelIDs) return sc.cleanupConnections(tunnelIDs)
} }
func buildTokenCommand() *cli.Command {
return &cli.Command{
Name: "token",
Action: cliutil.ConfiguredAction(tokenCommand),
Usage: "Fetch the credentials token for an existing tunnel (by name or UUID) that allows to run it",
UsageText: "cloudflared tunnel [tunnel command options] token [subcommand options] TUNNEL",
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",
Flags: []cli.Flag{credentialsFileFlagCLIOnly},
CustomHelpTemplate: commandHelpTemplate(),
}
}
func tokenCommand(c *cli.Context) error {
sc, err := newSubcommandContext(c)
if err != nil {
return errors.Wrap(err, "error setting up logger")
}
warningChecker := updater.StartWarningCheck(c)
defer warningChecker.LogWarningIfAny(sc.log)
if c.NArg() != 1 {
return cliutil.UsageError(`"cloudflared tunnel token" requires exactly 1 argument, the name or UUID of tunnel to fetch the credentials token for.`)
}
tunnelID, err := sc.findID(c.Args().First())
if err != nil {
return errors.Wrap(err, "error parsing tunnel ID")
}
token, err := sc.getTunnelTokenCredentials(tunnelID)
if err != nil {
return err
}
if path := c.String(CredFileFlag); path != "" {
credentials := token.Credentials()
err := writeTunnelCredentials(path, &credentials)
if err != nil {
return errors.Wrapf(err, "error writing token credentials to JSON file in path %s", path)
}
return nil
}
encodedToken, err := token.Encode()
if err != nil {
return err
}
fmt.Printf("%s", encodedToken)
return nil
}
func buildRouteCommand() *cli.Command { func buildRouteCommand() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "route", Name: "route",

View File

@ -72,14 +72,19 @@ class NamedTunnelConfig(NamedTunnelBaseConfig):
return config return config
def get_tunnel_id(self):
return self.full_config["tunnel"]
def get_token(self): def get_token(self):
with open(self.credentials_file) as json_file: creds = self.get_credentials_json()
creds = json.load(json_file)
token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]} token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]}
token_json_str = json.dumps(token_dict) token_json_str = json.dumps(token_dict)
return base64.b64encode(token_json_str.encode('utf-8')) return base64.b64encode(token_json_str.encode('utf-8'))
def get_credentials_json(self):
with open(self.credentials_file) as json_file:
return json.load(json_file)
@dataclass(frozen=True) @dataclass(frozen=True)
class ClassicTunnelBaseConfig(BaseConfig): class ClassicTunnelBaseConfig(BaseConfig):

View File

@ -0,0 +1,35 @@
import base64
import json
from setup import get_config_from_file, persist_origin_cert
from util import start_cloudflared
class TestToken:
def test_get_token(self, tmp_path, component_tests_config):
config = component_tests_config()
tunnel_id = config.get_tunnel_id()
token_args = ["--origincert", cert_path(), "token", tunnel_id]
output = start_cloudflared(tmp_path, config, token_args)
assert parse_token(config.get_token()) == parse_token(output.stdout)
def test_get_credentials_file(self, tmp_path, component_tests_config):
config = component_tests_config()
tunnel_id = config.get_tunnel_id()
cred_file = tmp_path / "test_get_credentials_file.json"
token_args = ["--origincert", cert_path(), "token", "--cred-file", cred_file, tunnel_id]
start_cloudflared(tmp_path, config, token_args)
with open(cred_file) as json_file:
assert config.get_credentials_json() == json.load(json_file)
def cert_path():
return get_config_from_file()["origincert"]
def parse_token(token):
return json.loads(base64.b64decode(token))

View File

@ -2,6 +2,7 @@ package connection
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -11,6 +12,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pkg/errors"
"github.com/cloudflare/cloudflared/tunnelrpc/pogs" "github.com/cloudflare/cloudflared/tunnelrpc/pogs"
"github.com/cloudflare/cloudflared/websocket" "github.com/cloudflare/cloudflared/websocket"
@ -65,6 +67,15 @@ func (t TunnelToken) Credentials() Credentials {
} }
} }
func (t TunnelToken) Encode() (string, error) {
val, err := json.Marshal(t)
if err != nil {
return "", errors.Wrap(err, "could not JSON encode token")
}
return base64.StdEncoding.EncodeToString(val), nil
}
type ClassicTunnelProperties struct { type ClassicTunnelProperties struct {
Hostname string Hostname string
OriginCert []byte OriginCert []byte