diff --git a/CHANGES.md b/CHANGES.md index 4477dd0c..91da8fea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 ### Bug Fixes - `cloudflared service install` now starts the underlying agent service on Windows operating system (similarly to the diff --git a/cfapi/client.go b/cfapi/client.go index d1e794f7..fa5e7cb2 100644 --- a/cfapi/client.go +++ b/cfapi/client.go @@ -7,6 +7,7 @@ import ( type TunnelClient interface { CreateTunnel(name string, tunnelSecret []byte) (*TunnelWithToken, error) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) + GetTunnelToken(tunnelID uuid.UUID) (string, error) DeleteTunnel(tunnelID uuid.UUID) error ListTunnels(filter *TunnelFilter) ([]*Tunnel, error) ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) diff --git a/cfapi/tunnel.go b/cfapi/tunnel.go index cede09c9..87caf230 100644 --- a/cfapi/tunnel.go +++ b/cfapi/tunnel.go @@ -116,6 +116,23 @@ func (r *RESTClient) GetTunnel(tunnelID uuid.UUID) (*Tunnel, error) { 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 { endpoint := r.baseEndpoints.accountLevel endpoint.Path = path.Join(endpoint.Path, fmt.Sprintf("%v", tunnelID)) diff --git a/cmd/cloudflared/tunnel/cmd.go b/cmd/cloudflared/tunnel/cmd.go index 345c8f80..3eed3436 100644 --- a/cmd/cloudflared/tunnel/cmd.go +++ b/cmd/cloudflared/tunnel/cmd.go @@ -109,6 +109,7 @@ func Commands() []*cli.Command { buildIngressSubcommand(), buildDeleteCommand(), buildCleanupCommand(), + buildTokenCommand(), // for compatibility, allow following as tunnel subcommands proxydns.Command(true), cliutil.RemovedCommand("db-connect"), diff --git a/cmd/cloudflared/tunnel/subcommand_context.go b/cmd/cloudflared/tunnel/subcommand_context.go index 8490aa0c..df1d95f0 100644 --- a/cmd/cloudflared/tunnel/subcommand_context.go +++ b/cmd/cloudflared/tunnel/subcommand_context.go @@ -341,6 +341,21 @@ func (sc *subcommandContext) cleanupConnections(tunnelIDs []uuid.UUID) error { 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) { client, err := sc.client() if err != nil { diff --git a/cmd/cloudflared/tunnel/subcommand_context_test.go b/cmd/cloudflared/tunnel/subcommand_context_test.go index 31d04b05..82b866d9 100644 --- a/cmd/cloudflared/tunnel/subcommand_context_test.go +++ b/cmd/cloudflared/tunnel/subcommand_context_test.go @@ -216,6 +216,10 @@ func (d *deleteMockTunnelStore) GetTunnel(tunnelID uuid.UUID) (*cfapi.Tunnel, er return &tunnel.tunnel, nil } +func (d *deleteMockTunnelStore) GetTunnelToken(tunnelID uuid.UUID) (string, error) { + return "token", nil +} + func (d *deleteMockTunnelStore) DeleteTunnel(tunnelID uuid.UUID) error { tunnel, ok := d.mockTunnels[tunnelID] if !ok { diff --git a/cmd/cloudflared/tunnel/subcommands.go b/cmd/cloudflared/tunnel/subcommands.go index 905c8ad6..70c16545 100644 --- a/cmd/cloudflared/tunnel/subcommands.go +++ b/cmd/cloudflared/tunnel/subcommands.go @@ -714,6 +714,59 @@ func cleanupCommand(c *cli.Context) error { 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 { return &cli.Command{ Name: "route", diff --git a/component-tests/config.py b/component-tests/config.py index f53732f2..dd549081 100644 --- a/component-tests/config.py +++ b/component-tests/config.py @@ -72,13 +72,18 @@ class NamedTunnelConfig(NamedTunnelBaseConfig): return config - def get_token(self): - with open(self.credentials_file) as json_file: - creds = json.load(json_file) - token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]} - token_json_str = json.dumps(token_dict) + def get_tunnel_id(self): + return self.full_config["tunnel"] - return base64.b64encode(token_json_str.encode('utf-8')) + def get_token(self): + creds = self.get_credentials_json() + token_dict = {"a": creds["AccountTag"], "t": creds["TunnelID"], "s": creds["TunnelSecret"]} + token_json_str = json.dumps(token_dict) + 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) diff --git a/component-tests/test_token.py b/component-tests/test_token.py new file mode 100644 index 00000000..ed99cb05 --- /dev/null +++ b/component-tests/test_token.py @@ -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)) diff --git a/connection/connection.go b/connection/connection.go index ad14a85c..154350a6 100644 --- a/connection/connection.go +++ b/connection/connection.go @@ -2,6 +2,7 @@ package connection import ( "context" + "encoding/base64" "fmt" "io" "math" @@ -11,6 +12,7 @@ import ( "time" "github.com/google/uuid" + "github.com/pkg/errors" "github.com/cloudflare/cloudflared/tunnelrpc/pogs" "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 { Hostname string OriginCert []byte