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
This commit is contained in:
parent
649705d291
commit
372a4b7079
|
|
@ -19,12 +19,18 @@ type ManagementResource int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Logs ManagementResource = iota
|
Logs ManagementResource = iota
|
||||||
|
Admin
|
||||||
|
HostDetails
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r ManagementResource) String() string {
|
func (r ManagementResource) String() string {
|
||||||
switch r {
|
switch r {
|
||||||
case Logs:
|
case Logs:
|
||||||
return "logs"
|
return "logs"
|
||||||
|
case Admin:
|
||||||
|
return "admin"
|
||||||
|
case HostDetails:
|
||||||
|
return "host_details"
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,16 @@ func TestManagementResource_String(t *testing.T) {
|
||||||
resource: Logs,
|
resource: Logs,
|
||||||
want: "logs",
|
want: "logs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Admin",
|
||||||
|
resource: Admin,
|
||||||
|
want: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HostDetails",
|
||||||
|
resource: HostDetails,
|
||||||
|
want: "host_details",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -115,6 +125,16 @@ func TestManagementEndpointPath(t *testing.T) {
|
||||||
resource: Logs,
|
resource: Logs,
|
||||||
want: "b34cc7ce-925b-46ee-bc23-4cb5c18d8292/management/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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/access"
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/access"
|
||||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
|
||||||
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
|
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/proxydns"
|
||||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/tail"
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/tail"
|
||||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/tunnel"
|
||||||
|
|
@ -91,6 +92,7 @@ func main() {
|
||||||
tracing.Init(Version)
|
tracing.Init(Version)
|
||||||
token.Init(Version)
|
token.Init(Version)
|
||||||
tail.Init(bInfo)
|
tail.Init(bInfo)
|
||||||
|
management.Init(bInfo)
|
||||||
runApp(app, graceShutdownC)
|
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, proxydns.Command()) // removed feature, only here for error message
|
||||||
cmds = append(cmds, access.Commands()...)
|
cmds = append(cmds, access.Commands()...)
|
||||||
cmds = append(cmds, tail.Command())
|
cmds = append(cmds, tail.Command())
|
||||||
|
cmds = append(cmds, management.Command())
|
||||||
return cmds
|
return cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 <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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -13,13 +12,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattn/go-colorable"
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"nhooyr.io/websocket"
|
"nhooyr.io/websocket"
|
||||||
|
|
||||||
"github.com/cloudflare/cloudflared/cfapi"
|
"github.com/cloudflare/cloudflared/cfapi"
|
||||||
|
|
||||||
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
|
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
|
||||||
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
|
cfdflags "github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
|
||||||
"github.com/cloudflare/cloudflared/credentials"
|
"github.com/cloudflare/cloudflared/credentials"
|
||||||
|
|
@ -52,9 +49,9 @@ func buildTailManagementTokenSubcommand() *cli.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
func managementTokenCommand(c *cli.Context) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// parseFilters will attempt to parse provided filters to send to with the EventStartStreaming
|
||||||
func parseFilters(c *cli.Context) (*management.StreamingFilters, error) {
|
func parseFilters(c *cli.Context) (*management.StreamingFilters, error) {
|
||||||
var level *management.LogLevel
|
var level *management.LogLevel
|
||||||
|
|
@ -232,49 +204,13 @@ func parseFilters(c *cli.Context) (*management.StreamingFilters, error) {
|
||||||
}, nil
|
}, 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.
|
// 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) {
|
func buildURL(c *cli.Context, log *zerolog.Logger, res cfapi.ManagementResource) (url.URL, error) {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
token := c.String("token")
|
token := c.String("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
token, err = getManagementToken(c, log, res)
|
token, err = cliutil.GetManagementToken(c, log, res, buildInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return url.URL{}, fmt.Errorf("unable to acquire management token for requested tunnel id: %w", err)
|
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
|
// Run implements a foreground runner
|
||||||
func Run(c *cli.Context) error {
|
func Run(c *cli.Context) error {
|
||||||
log := createLogger(c)
|
log := cliutil.CreateStderrLogger(c)
|
||||||
|
|
||||||
signals := make(chan os.Signal, 10)
|
signals := make(chan os.Signal, 10)
|
||||||
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class CloudflaredCli:
|
||||||
listed = self._run_command(cmd_args, "list")
|
listed = self._run_command(cmd_args, "list")
|
||||||
return json.loads(listed.stdout)
|
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]
|
basecmd = [config.cloudflared_binary]
|
||||||
if config_path is not None:
|
if config_path is not None:
|
||||||
basecmd += ["--config", str(config_path)]
|
basecmd += ["--config", str(config_path)]
|
||||||
|
|
@ -38,18 +38,35 @@ class CloudflaredCli:
|
||||||
if origincert:
|
if origincert:
|
||||||
basecmd += ["--origincert", 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
|
cmd = basecmd + cmd_args
|
||||||
result = run_subprocess(cmd, "token", self.logger, check=True, capture_output=True, timeout=15)
|
result = run_subprocess(cmd, "token", self.logger, check=True, capture_output=True, timeout=15)
|
||||||
return json.loads(result.stdout.decode("utf-8").strip())["token"]
|
return json.loads(result.stdout.decode("utf-8").strip())["token"]
|
||||||
|
|
||||||
def get_management_url(self, path, config, config_path):
|
def get_tail_token(self, config, config_path):
|
||||||
access_jwt = self.get_management_token(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()
|
connector_id = get_tunnel_connector_id()
|
||||||
return f"https://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
|
return f"https://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
|
||||||
|
|
||||||
def get_management_wsurl(self, path, config, config_path):
|
def get_management_wsurl(self, path, config, config_path, resource):
|
||||||
access_jwt = self.get_management_token(config, config_path)
|
access_jwt = self.get_management_token(config, config_path, resource)
|
||||||
connector_id = get_tunnel_connector_id()
|
connector_id = get_tunnel_connector_id()
|
||||||
return f"wss://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
|
return f"wss://{MANAGEMENT_HOST_NAME}/{path}?connector_id={connector_id}&access_token={access_jwt}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
import json
|
||||||
import requests
|
import requests
|
||||||
from conftest import CfdModes
|
from conftest import CfdModes
|
||||||
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
|
from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
|
||||||
from retrying import retry
|
from retrying import retry
|
||||||
from cli import CloudflaredCli
|
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
|
import platform
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -35,7 +36,7 @@ class TestManagement:
|
||||||
require_min_connections=1)
|
require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
||||||
connector_id = cfd_cli.get_connector_id(config)[0]
|
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)
|
resp = send_request(url, headers=headers)
|
||||||
|
|
||||||
# Assert response json.
|
# 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):
|
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True):
|
||||||
wait_tunnel_ready(require_min_connections=1)
|
wait_tunnel_ready(require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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)
|
resp = send_request(url)
|
||||||
|
|
||||||
# Assert response.
|
# Assert response.
|
||||||
|
|
@ -79,7 +80,7 @@ class TestManagement:
|
||||||
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True):
|
with start_cloudflared(tmp_path, config, cfd_pre_args=["tunnel", "--ha-connections", "1"], new_process=True):
|
||||||
wait_tunnel_ready(require_min_connections=1)
|
wait_tunnel_ready(require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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)
|
resp = send_request(url)
|
||||||
|
|
||||||
# Assert response.
|
# 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):
|
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)
|
wait_tunnel_ready(require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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)
|
resp = send_request(url)
|
||||||
|
|
||||||
# Assert response.
|
# Assert response.
|
||||||
assert resp.status_code == 404, "Expected cloudflared to return 404 for /metrics"
|
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]}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class TestTail:
|
||||||
with start_cloudflared(tmp_path, config, cfd_args=["run", "--hello-world"], new_process=True):
|
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)
|
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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:
|
async with connect(url, open_timeout=5, close_timeout=3) as websocket:
|
||||||
await websocket.send('{"type": "start_streaming"}')
|
await websocket.send('{"type": "start_streaming"}')
|
||||||
await websocket.send('{"type": "stop_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):
|
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)
|
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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:
|
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
|
||||||
# send start_streaming
|
# send start_streaming
|
||||||
await websocket.send(json.dumps({
|
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):
|
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)
|
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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:
|
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
|
||||||
# send start_streaming with tcp logs only
|
# send start_streaming with tcp logs only
|
||||||
await websocket.send(json.dumps({
|
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):
|
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)
|
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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:
|
async with connect(url, open_timeout=5, close_timeout=5) as websocket:
|
||||||
# send start_streaming with info logs only
|
# send start_streaming with info logs only
|
||||||
await websocket.send(json.dumps({
|
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):
|
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)
|
wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1)
|
||||||
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
|
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))
|
task = asyncio.ensure_future(start_streaming_to_be_remotely_closed(url))
|
||||||
override_task = asyncio.ensure_future(start_streaming_override(url))
|
override_task = asyncio.ensure_future(start_streaming_override(url))
|
||||||
await asyncio.wait([task, override_task])
|
await asyncio.wait([task, override_task])
|
||||||
|
|
|
||||||
|
|
@ -185,3 +185,49 @@ def send_request(session, url, require_ok):
|
||||||
if require_ok:
|
if require_ok:
|
||||||
assert resp.status_code == 200, f"{url} returned {resp}"
|
assert resp.status_code == 200, f"{url} returned {resp}"
|
||||||
return resp if resp.status_code == 200 else None
|
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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue