From 372a4b7079ea927119d7da306fc3289a52343b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Garcia?= Date: Thu, 5 Mar 2026 16:31:24 +0000 Subject: [PATCH] TUN-10292: Add cloudflared management token command Create new management token command to support different resource permissions (logs, admin, host_details). This fixes failing component tests that need admin-level tokens to access management endpoints. - Add ManagementResource enum values: Admin, HostDetails - Create cmd/cloudflared/management package with token command - Extract shared utilities to cliutil/management.go (GetManagementToken, CreateStderrLogger) - Refactor tail/cmd.go to use shared utilities - Update component tests to use new command with admin resource Closes TUN-10292 --- cfapi/tunnel.go | 6 ++ cfapi/tunnel_test.go | 20 +++++ cmd/cloudflared/cliutil/management.go | 84 ++++++++++++++++++++ cmd/cloudflared/main.go | 3 + cmd/cloudflared/management/cmd.go | 105 +++++++++++++++++++++++++ cmd/cloudflared/management/cmd_test.go | 71 +++++++++++++++++ cmd/cloudflared/tail/cmd.go | 72 +---------------- component-tests/cli.py | 29 +++++-- component-tests/test_management.py | 44 +++++++++-- component-tests/test_tail.py | 10 +-- component-tests/util.py | 46 +++++++++++ 11 files changed, 406 insertions(+), 84 deletions(-) create mode 100644 cmd/cloudflared/cliutil/management.go create mode 100644 cmd/cloudflared/management/cmd.go create mode 100644 cmd/cloudflared/management/cmd_test.go diff --git a/cfapi/tunnel.go b/cfapi/tunnel.go index 539b2051..706ccf26 100644 --- a/cfapi/tunnel.go +++ b/cfapi/tunnel.go @@ -19,12 +19,18 @@ type ManagementResource int const ( Logs ManagementResource = iota + Admin + HostDetails ) func (r ManagementResource) String() string { switch r { case Logs: return "logs" + case Admin: + return "admin" + case HostDetails: + return "host_details" default: return "" } diff --git a/cfapi/tunnel_test.go b/cfapi/tunnel_test.go index da2c7f9f..b407d98e 100644 --- a/cfapi/tunnel_test.go +++ b/cfapi/tunnel_test.go @@ -89,6 +89,16 @@ func TestManagementResource_String(t *testing.T) { resource: Logs, want: "logs", }, + { + name: "Admin", + resource: Admin, + want: "admin", + }, + { + name: "HostDetails", + resource: HostDetails, + want: "host_details", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -115,6 +125,16 @@ func TestManagementEndpointPath(t *testing.T) { resource: Logs, want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/logs", }, + { + name: "Admin resource", + resource: Admin, + want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/admin", + }, + { + name: "HostDetails resource", + resource: HostDetails, + want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/host_details", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/cloudflared/cliutil/management.go b/cmd/cloudflared/cliutil/management.go new file mode 100644 index 00000000..1cf72775 --- /dev/null +++ b/cmd/cloudflared/cliutil/management.go @@ -0,0 +1,84 @@ +package cliutil + +import ( + "errors" + "fmt" + "io" + "os" + "time" + + "github.com/google/uuid" + "github.com/mattn/go-colorable" + "github.com/rs/zerolog" + "github.com/urfave/cli/v2" + + "github.com/cloudflare/cloudflared/cfapi" + cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags" + "github.com/cloudflare/cloudflared/credentials" +) + +// Error definitions for management token operations +var ( + ErrNoTunnelID = errors.New("no tunnel ID provided") + ErrInvalidTunnelID = errors.New("unable to parse provided tunnel id as a valid UUID") +) + +// GetManagementToken acquires a management token from Cloudflare API for the specified resource +func GetManagementToken(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource, buildInfo *BuildInfo) (string, error) { + userCreds, err := credentials.Read(c.String(cfdflags.OriginCert), log) + if err != nil { + return "", err + } + + var apiURL string + if userCreds.IsFEDEndpoint() { + apiURL = credentials.FedRampBaseApiURL + } else { + apiURL = c.String(cfdflags.ApiURL) + } + + client, err := userCreds.Client(apiURL, buildInfo.UserAgent(), log) + if err != nil { + return "", err + } + + tunnelIDString := c.Args().First() + if tunnelIDString == "" { + return "", ErrNoTunnelID + } + tunnelID, err := uuid.Parse(tunnelIDString) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidTunnelID, err) + } + + token, err := client.GetManagementToken(tunnelID, res) + if err != nil { + return "", err + } + + return token, nil +} + +// CreateStderrLogger creates a logger that outputs to stderr to avoid interfering with stdout +func CreateStderrLogger(c *cli.Context) *zerolog.Logger { + level, levelErr := zerolog.ParseLevel(c.String(cfdflags.LogLevel)) + if levelErr != nil { + level = zerolog.InfoLevel + } + var writer io.Writer + switch c.String(cfdflags.LogFormatOutput) { + case cfdflags.LogFormatOutputValueJSON: + // zerolog by default outputs as JSON + writer = os.Stderr + case cfdflags.LogFormatOutputValueDefault: + // "default" and unset use the same logger output format + fallthrough + default: + writer = zerolog.ConsoleWriter{ + Out: colorable.NewColorable(os.Stderr), + TimeFormat: time.RFC3339, + } + } + log := zerolog.New(writer).With().Timestamp().Logger().Level(level) + return &log +} diff --git a/cmd/cloudflared/main.go b/cmd/cloudflared/main.go index c7dc0cd6..e6ce73fe 100644 --- a/cmd/cloudflared/main.go +++ b/cmd/cloudflared/main.go @@ -13,6 +13,7 @@ import ( "github.com/cloudflare/cloudflared/cmd/cloudflared/access" "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags" + "github.com/cloudflare/cloudflared/cmd/cloudflared/management" "github.com/cloudflare/cloudflared/cmd/cloudflared/proxydns" "github.com/cloudflare/cloudflared/cmd/cloudflared/tail" "github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel" @@ -91,6 +92,7 @@ func main() { tracing.Init(Version) token.Init(Version) tail.Init(bInfo) + management.Init(bInfo) runApp(app, graceShutdownC) } @@ -152,6 +154,7 @@ To determine if an update happened in a script, check for error code 11.`, cmds = append(cmds, proxydns.Command()) // removed feature, only here for error message cmds = append(cmds, access.Commands()...) cmds = append(cmds, tail.Command()) + cmds = append(cmds, management.Command()) return cmds } diff --git a/cmd/cloudflared/management/cmd.go b/cmd/cloudflared/management/cmd.go new file mode 100644 index 00000000..f94649f7 --- /dev/null +++ b/cmd/cloudflared/management/cmd.go @@ -0,0 +1,105 @@ +package management + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/urfave/cli/v2" + + "github.com/cloudflare/cloudflared/cfapi" + "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" + cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags" + "github.com/cloudflare/cloudflared/credentials" +) + +var buildInfo *cliutil.BuildInfo + +// Init initializes the management package with build info +func Init(bi *cliutil.BuildInfo) { + buildInfo = bi +} + +// Command returns the management command with its subcommands +func Command() *cli.Command { + return &cli.Command{ + Name: "management", + Usage: "Monitor cloudflared tunnels via management API", + Category: "Management", + Hidden: true, + Subcommands: []*cli.Command{ + buildTokenSubcommand(), + }, + } +} + +// buildTokenSubcommand creates the token subcommand +func buildTokenSubcommand() *cli.Command { + return &cli.Command{ + Name: "token", + Action: cliutil.ConfiguredAction(tokenCommand), + Usage: "Get management access jwt for a specific resource", + UsageText: "cloudflared management token --resource TUNNEL_ID", + Description: "Get management access jwt for a tunnel with specified resource permissions (logs, admin, host_details)", + Hidden: true, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "resource", + Usage: "Resource type for token permissions: logs, admin, or host_details", + Required: true, + }, + &cli.StringFlag{ + Name: cfdflags.OriginCert, + Usage: "Path to the certificate generated for your origin when you run cloudflared login.", + EnvVars: []string{"TUNNEL_ORIGIN_CERT"}, + Value: credentials.FindDefaultOriginCertPath(), + }, + &cli.StringFlag{ + Name: cfdflags.LogLevel, + Value: "info", + Usage: "Application logging level {debug, info, warn, error, fatal}", + EnvVars: []string{"TUNNEL_LOGLEVEL"}, + }, + cliutil.FlagLogOutput, + }, + } +} + +// tokenCommand handles the token subcommand execution +func tokenCommand(c *cli.Context) error { + log := cliutil.CreateStderrLogger(c) + + // Parse and validate resource flag + resourceStr := c.String("resource") + resource, err := parseResource(resourceStr) + if err != nil { + return fmt.Errorf("invalid resource '%s': %w", resourceStr, err) + } + + // Get management token + token, err := cliutil.GetManagementToken(c, log, resource, buildInfo) + if err != nil { + return err + } + + // Output JSON to stdout + tokenResponse := struct { + Token string `json:"token"` + }{Token: token} + + return json.NewEncoder(os.Stdout).Encode(tokenResponse) +} + +// parseResource converts resource string to ManagementResource enum +func parseResource(resource string) (cfapi.ManagementResource, error) { + switch resource { + case "logs": + return cfapi.Logs, nil + case "admin": + return cfapi.Admin, nil + case "host_details": + return cfapi.HostDetails, nil + default: + return 0, fmt.Errorf("must be one of: logs, admin, host_details") + } +} diff --git a/cmd/cloudflared/management/cmd_test.go b/cmd/cloudflared/management/cmd_test.go new file mode 100644 index 00000000..f10d58ef --- /dev/null +++ b/cmd/cloudflared/management/cmd_test.go @@ -0,0 +1,71 @@ +package management + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cloudflare/cloudflared/cfapi" +) + +func TestParseResource_ValidResources(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected cfapi.ManagementResource + }{ + {"logs", cfapi.Logs}, + {"admin", cfapi.Admin}, + {"host_details", cfapi.HostDetails}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + result, err := parseResource(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseResource_InvalidResource(t *testing.T) { + t.Parallel() + + invalid := []string{"invalid", "LOGS", "Admin", "", "metrics", "host-details"} + + for _, input := range invalid { + t.Run(input, func(t *testing.T) { + t.Parallel() + _, err := parseResource(input) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be one of") + }) + } +} + +func TestCommandStructure(t *testing.T) { + t.Parallel() + + cmd := Command() + + assert.Equal(t, "management", cmd.Name) + assert.True(t, cmd.Hidden) + assert.Len(t, cmd.Subcommands, 1) + + tokenCmd := cmd.Subcommands[0] + assert.Equal(t, "token", tokenCmd.Name) + assert.True(t, tokenCmd.Hidden) + + // Verify required flags exist + var hasResourceFlag bool + for _, flag := range tokenCmd.Flags { + if flag.Names()[0] == "resource" { + hasResourceFlag = true + break + } + } + assert.True(t, hasResourceFlag, "token command should have --resource flag") +} diff --git a/cmd/cloudflared/tail/cmd.go b/cmd/cloudflared/tail/cmd.go index 4cfd744e..036835e4 100644 --- a/cmd/cloudflared/tail/cmd.go +++ b/cmd/cloudflared/tail/cmd.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "os" @@ -13,13 +12,11 @@ import ( "time" "github.com/google/uuid" - "github.com/mattn/go-colorable" "github.com/rs/zerolog" "github.com/urfave/cli/v2" "nhooyr.io/websocket" "github.com/cloudflare/cloudflared/cfapi" - "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags" "github.com/cloudflare/cloudflared/credentials" @@ -52,9 +49,9 @@ func buildTailManagementTokenSubcommand() *cli.Command { } func managementTokenCommand(c *cli.Context) error { - log := createLogger(c) + log := cliutil.CreateStderrLogger(c) - token, err := getManagementToken(c, log, cfapi.Logs) + token, err := cliutil.GetManagementToken(c, log, cfapi.Logs, buildInfo) if err != nil { return err } @@ -163,31 +160,6 @@ func handleValidationError(resp *http.Response, log *zerolog.Logger) { } } -// logger will be created to emit only against the os.Stderr as to not obstruct with normal output from -// management requests -func createLogger(c *cli.Context) *zerolog.Logger { - level, levelErr := zerolog.ParseLevel(c.String(cfdflags.LogLevel)) - if levelErr != nil { - level = zerolog.InfoLevel - } - var writer io.Writer - switch c.String(cfdflags.LogFormatOutput) { - case cfdflags.LogFormatOutputValueJSON: - // zerolog by default outputs as JSON - writer = os.Stderr - case cfdflags.LogFormatOutputValueDefault: - // "default" and unset use the same logger output format - fallthrough - default: - writer = zerolog.ConsoleWriter{ - Out: colorable.NewColorable(os.Stderr), - TimeFormat: time.RFC3339, - } - } - log := zerolog.New(writer).With().Timestamp().Logger().Level(level) - return &log -} - // parseFilters will attempt to parse provided filters to send to with the EventStartStreaming func parseFilters(c *cli.Context) (*management.StreamingFilters, error) { var level *management.LogLevel @@ -232,49 +204,13 @@ func parseFilters(c *cli.Context) (*management.StreamingFilters, error) { }, nil } -// getManagementToken will make a call to the Cloudflare API to acquire a management token for the requested tunnel. -func getManagementToken(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource) (string, error) { - userCreds, err := credentials.Read(c.String(cfdflags.OriginCert), log) - if err != nil { - return "", err - } - - var apiURL string - if userCreds.IsFEDEndpoint() { - apiURL = credentials.FedRampBaseApiURL - } else { - apiURL = c.String(cfdflags.ApiURL) - } - - client, err := userCreds.Client(apiURL, buildInfo.UserAgent(), log) - if err != nil { - return "", err - } - - tunnelIDString := c.Args().First() - if tunnelIDString == "" { - return "", errors.New("no tunnel ID provided") - } - tunnelID, err := uuid.Parse(tunnelIDString) - if err != nil { - return "", errors.New("unable to parse provided tunnel id as a valid UUID") - } - - token, err := client.GetManagementToken(tunnelID, res) - if err != nil { - return "", err - } - - return token, nil -} - // buildURL will build the management url to contain the required query parameters to authenticate the request. func buildURL(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource) (url.URL, error) { var err error token := c.String("token") if token == "" { - token, err = getManagementToken(c, log, res) + token, err = cliutil.GetManagementToken(c, log, res, buildInfo) if err != nil { return url.URL{}, fmt.Errorf("unable to acquire management token for requested tunnel id: %w", err) } @@ -325,7 +261,7 @@ func printJSON(log *management.Log, logger *zerolog.Logger) { // Run implements a foreground runner func Run(c *cli.Context) error { - log := createLogger(c) + log := cliutil.CreateStderrLogger(c) signals := make(chan os.Signal, 10) signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) diff --git a/component-tests/cli.py b/component-tests/cli.py index c0127fa3..619ae65d 100644 --- a/component-tests/cli.py +++ b/component-tests/cli.py @@ -30,7 +30,7 @@ class CloudflaredCli: listed = self._run_command(cmd_args, "list") return json.loads(listed.stdout) - def get_management_token(self, config, config_path): + def get_management_token(self, config, config_path, resource): basecmd = [config.cloudflared_binary] if config_path is not None: basecmd += ["--config", str(config_path)] @@ -38,18 +38,35 @@ class CloudflaredCli: if origincert: basecmd += ["--origincert", origincert] - cmd_args = ["tail", "token", config.get_tunnel_id()] + cmd_args = ["management", "token", "--resource", resource, config.get_tunnel_id()] cmd = basecmd + cmd_args result = run_subprocess(cmd, "token", self.logger, check=True, capture_output=True, timeout=15) return json.loads(result.stdout.decode("utf-8").strip())["token"] - def get_management_url(self, path, config, config_path): - access_jwt = self.get_management_token(config, config_path) + def get_tail_token(self, config, config_path): + """ + Get management token using the 'tail token' command. + Returns a token scoped for 'logs' resource. + """ + basecmd = [config.cloudflared_binary] + if config_path is not None: + basecmd += ["--config", str(config_path)] + origincert = get_config_from_file()["origincert"] + if origincert: + basecmd += ["--origincert", origincert] + + cmd_args = ["tail", "token", config.get_tunnel_id()] + cmd = basecmd + cmd_args + result = run_subprocess(cmd, "tail-token", self.logger, check=True, capture_output=True, timeout=15) + return json.loads(result.stdout.decode("utf-8").strip())["token"] + + def get_management_url(self, path, config, config_path, resource): + access_jwt = self.get_management_token(config, config_path, resource) connector_id = get_tunnel_connector_id() return f"https://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}" - def get_management_wsurl(self, path, config, config_path): - access_jwt = self.get_management_token(config, config_path) + def get_management_wsurl(self, path, config, config_path, resource): + access_jwt = self.get_management_token(config, config_path, resource) connector_id = get_tunnel_connector_id() return f"wss://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}" diff --git a/component-tests/test_management.py b/component-tests/test_management.py index cfc1ae73..3b7f2d93 100644 --- a/component-tests/test_management.py +++ b/component-tests/test_management.py @@ -1,10 +1,11 @@ #!/usr/bin/env python +import json import requests from conftest import CfdModes from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS from retrying import retry from cli import CloudflaredCli -from util import LOGGER, write_config, start_cloudflared, wait_tunnel_ready, send_requests +from util import LOGGER, write_config, start_cloudflared, wait_tunnel_ready, send_requests, decode_jwt_payload import platform """ @@ -35,7 +36,7 @@ class TestManagement: require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) connector_id = cfd_cli.get_connector_id(config)[0] - url = cfd_cli.get_management_url("host_details", config, config_path) + url = cfd_cli.get_management_url("host_details", config, config_path, resource="host_details") resp = send_request(url, headers=headers) # Assert response json. @@ -58,7 +59,7 @@ class TestManagement: with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True): wait_tunnel_ready(require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_url("metrics", config, config_path) + url = cfd_cli.get_management_url("metrics", config, config_path, resource="admin") resp = send_request(url) # Assert response. @@ -79,7 +80,7 @@ class TestManagement: with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True): wait_tunnel_ready(require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_url("debug/pprof/heap", config, config_path) + url = cfd_cli.get_management_url("debug/pprof/heap", config, config_path, resource="admin") resp = send_request(url) # Assert response. @@ -100,12 +101,45 @@ class TestManagement: with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1", "--management-diagnostics=false"], new_process=True): wait_tunnel_ready(require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_url("metrics", config, config_path) + url = cfd_cli.get_management_url("metrics", config, config_path, resource="admin") resp = send_request(url) # Assert response. assert resp.status_code == 404, "Expected cloudflared to return 404 for /metrics" + def test_tail_token_command(self, tmp_path, component_tests_config): + """ + Validates that 'cloudflared tail token' command returns a token + scoped for 'logs' and 'ping' resources. + """ + # TUN-7377: wait_tunnel_ready does not work properly in windows + if platform.system() == "Windows": + return + + config = component_tests_config(cfd_mode=CfdModes.NAMED, provide_ingress=False) + LOGGER.debug(config) + config_path = write_config(tmp_path, config.full_config) + + cfd_cli = CloudflaredCli(config, config_path, LOGGER) + token = cfd_cli.get_tail_token(config, config_path) + + # Verify token was returned + assert token, "Expected non-empty token to be returned" + + # Decode JWT payload to verify resource claims + claims = decode_jwt_payload(token) + + resource_tag = 'res' + # Verify the token has 'logs' and 'ping' in resource array + assert resource_tag in claims, f"Expected {resource_tag} claim in token" + assert isinstance(claims['res'], list), f"Expected {resource_tag} to be an array" + assert 'logs' in claims[resource_tag], \ + f"Expected 'logs' in resource array, got: {claims[resource_tag]}" + assert 'ping' in claims[resource_tag], \ + f"Expected 'ping' in resource array, got: {claims[resource_tag]}" + + LOGGER.info(f"Tail token successfully verified with resources: {claims[resource_tag]}") + diff --git a/component-tests/test_tail.py b/component-tests/test_tail.py index 3b8522ba..440cd60a 100644 --- a/component-tests/test_tail.py +++ b/component-tests/test_tail.py @@ -25,7 +25,7 @@ class TestTail: with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True): wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_wsurl("logs", config, config_path) + url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs") async with connect(url, open_timeout=5, close_timeout=3) as websocket: await websocket.send('{"type": "start_streaming"}') await websocket.send('{"type": "stop_streaming"}') @@ -44,7 +44,7 @@ class TestTail: with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True): wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_wsurl("logs", config, config_path) + url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs") async with connect(url, open_timeout=5, close_timeout=5) as websocket: # send start_streaming await websocket.send(json.dumps({ @@ -71,7 +71,7 @@ class TestTail: with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True): wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_wsurl("logs", config, config_path) + url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs") async with connect(url, open_timeout=5, close_timeout=5) as websocket: # send start_streaming with tcp logs only await websocket.send(json.dumps({ @@ -98,7 +98,7 @@ class TestTail: with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True): wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_wsurl("logs", config, config_path) + url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs") async with connect(url, open_timeout=5, close_timeout=5) as websocket: # send start_streaming with info logs only await websocket.send(json.dumps({ @@ -126,7 +126,7 @@ class TestTail: with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True): wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) cfd_cli = CloudflaredCli(config, config_path, LOGGER) - url = cfd_cli.get_management_wsurl("logs", config, config_path) + url = cfd_cli.get_management_wsurl("logs", config, config_path, resource="logs") task = asyncio.ensure_future(start_streaming_to_be_remotely_closed(url)) override_task = asyncio.ensure_future(start_streaming_override(url)) await asyncio.wait([task, override_task]) diff --git a/component-tests/util.py b/component-tests/util.py index b4c9b51b..f45a17fd 100644 --- a/component-tests/util.py +++ b/component-tests/util.py @@ -185,3 +185,49 @@ def send_request(session, url, require_ok): if require_ok: assert resp.status_code == 200, f"{url} returned {resp}" return resp if resp.status_code == 200 else None + + +def decode_jwt_payload(token): + """ + Decode the payload section of a JWT token without signature verification. + + JWT Structure: + ============== + A JWT consists of three Base64URL-encoded parts separated by dots: + HEADER.PAYLOAD.SIGNATURE + + The payload contains the JWT claims (the actual data/permissions). + + Args: + token (str): The complete JWT token string + + Returns: + dict: The decoded payload as a dictionary containing JWT claims + + Raises: + ValueError: If the token doesn't have exactly 3 parts + + Note: + This function does NOT verify the signature - it only decodes the payload. + Use this only when you trust the token source (e.g., tokens you just generated). + """ + import base64 + import json + + # Split JWT into its three components + parts = token.split('.') + if len(parts) != 3: + raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}") + + # Extract and decode the payload (middle section) + # Base64 requires padding to be a multiple of 4 characters + payload_encoded = parts[1] + remainder = len(payload_encoded) % 4 + if remainder != 0: + payload_padded = payload_encoded + '=' * (4 - remainder) + else: + payload_padded = payload_encoded + + # Decode from Base64URL format and parse JSON + decoded_payload = base64.urlsafe_b64decode(payload_padded) + return json.loads(decoded_payload)