diff --git a/cmd/cloudflared/flags/flags.go b/cmd/cloudflared/flags/flags.go index f31ff60e..63f6434a 100644 --- a/cmd/cloudflared/flags/flags.go +++ b/cmd/cloudflared/flags/flags.go @@ -72,6 +72,9 @@ const ( // PostQuantum is the command line flag to force the connection to Cloudflare Edge to use Post Quantum cryptography PostQuantum = "post-quantum" + // AllowedMail is the command line flag to set allowed email addresses or domains for protected quick tunnels + AllowedMail = "allowed-mail" + // Features is the command line flag to opt into various features that are still being developed or tested Features = "features" diff --git a/cmd/cloudflared/tunnel/quick_tunnel.go b/cmd/cloudflared/tunnel/quick_tunnel.go index fdc38caf..c5621357 100644 --- a/cmd/cloudflared/tunnel/quick_tunnel.go +++ b/cmd/cloudflared/tunnel/quick_tunnel.go @@ -1,6 +1,7 @@ package tunnel import ( + "bytes" "encoding/json" "fmt" "io" @@ -20,6 +21,22 @@ const httpTimeout = 15 * time.Second const disclaimer = "Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee, are subject to the Cloudflare Online Services Terms of Use (https://www.cloudflare.com/website-terms/), and Cloudflare reserves the right to investigate your use of Tunnels for violations of such terms. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps" +const ( + quickTunnelAuthModeField = "auth_mode" + quickTunnelAuthModeOTP = "otp" +) + +// buildQuickTunnelRequestBody returns the provisioning request body. +// It returns a non-empty JSON body with auth_mode: otp when protected mode +// is requested, otherwise an empty body for public mode. +func buildQuickTunnelRequestBody(isProtected bool) ([]byte, error) { + if !isProtected { + return nil, nil + } + + return json.Marshal(map[string]string{quickTunnelAuthModeField: quickTunnelAuthModeOTP}) +} + // RunQuickTunnel requests a tunnel from the specified service. // We use this to power quick tunnels on trycloudflare.com, but the // service is open-source and could be used by anyone. @@ -27,6 +44,10 @@ func RunQuickTunnel(sc *subcommandContext) error { sc.log.Info().Msg(disclaimer) sc.log.Info().Msg("Requesting new quick Tunnel on trycloudflare.com...") + // TODO(TUN-10798): register the --allowed-mail flag so this path becomes reachable. + allowedMail := sc.c.StringSlice(flags.AllowedMail) + isProtected := len(allowedMail) > 0 + client := http.Client{ Transport: &http.Transport{ TLSHandshakeTimeout: httpTimeout, @@ -35,12 +56,18 @@ func RunQuickTunnel(sc *subcommandContext) error { Timeout: httpTimeout, } - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/tunnel", sc.c.String("quick-service")), nil) + reqBody, err := buildQuickTunnelRequestBody(isProtected) + if err != nil { + return errors.Wrap(err, "failed to build quick tunnel request body") + } + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/tunnel", sc.c.String("quick-service")), bytes.NewReader(reqBody)) if err != nil { return errors.Wrap(err, "failed to build quick tunnel request") } req.Header.Add("Content-Type", "application/json") req.Header.Add("User-Agent", buildInfo.UserAgent()) + resp, err := client.Do(req) if err != nil { return errors.Wrap(err, "failed to request quick Tunnel") @@ -48,19 +75,36 @@ func RunQuickTunnel(sc *subcommandContext) error { defer func() { _ = resp.Body.Close() }() // This will read the entire response into memory so we can print it in case of error - rsp_body, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) if err != nil { return errors.Wrap(err, "failed to read quick-tunnel response") } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var data QuickTunnelResponse + if err := json.Unmarshal(respBody, &data); err == nil && len(data.Errors) > 0 { + return fmt.Errorf("quick tunnel provisioning failed with status %d: %s", resp.StatusCode, formatQuickTunnelErrors(data.Errors)) + } + return fmt.Errorf("quick tunnel provisioning failed with status %d: %s", resp.StatusCode, string(respBody)) + } + var data QuickTunnelResponse - if err := json.Unmarshal(rsp_body, &data); err != nil { - rsp_string := string(rsp_body) + if err := json.Unmarshal(respBody, &data); err != nil { + respString := string(respBody) fields := map[string]interface{}{"status_code": resp.Status} - sc.log.Err(err).Fields(fields).Msgf("Error unmarshaling QuickTunnel response: %s", rsp_string) + sc.log.Err(err).Fields(fields).Msgf("Error unmarshaling QuickTunnel response: %s", respString) return errors.Wrap(err, "failed to unmarshal quick Tunnel") } + // TODO(TUN-10791): Add CLI-level coverage that provisioning errors are logged to users. + if len(data.Errors) > 0 { + return fmt.Errorf("quick tunnel provisioning failed: %s", formatQuickTunnelErrors(data.Errors)) + } + + if !data.Success { + return errors.New("quick tunnel provisioning failed") + } + tunnelID, err := uuid.Parse(data.Result.ID) if err != nil { return errors.Wrap(err, "failed to parse quick Tunnel ID") @@ -92,7 +136,7 @@ func RunQuickTunnel(sc *subcommandContext) error { return StartServer( sc.c, buildInfo, - &connection.TunnelProperties{Credentials: credentials, QuickTunnelUrl: data.Result.Hostname}, + &connection.TunnelProperties{Credentials: credentials, QuickTunnelUrl: data.Result.Hostname, IsProtected: isProtected}, sc.log, ) } @@ -108,6 +152,14 @@ type QuickTunnelError struct { Message string } +func formatQuickTunnelErrors(errors []QuickTunnelError) string { + messages := make([]string, len(errors)) + for i, e := range errors { + messages[i] = fmt.Sprintf("[%d] %s", e.Code, e.Message) + } + return strings.Join(messages, "; ") +} + type QuickTunnel struct { ID string `json:"id"` Name string `json:"name"` diff --git a/cmd/cloudflared/tunnel/quick_tunnel_test.go b/cmd/cloudflared/tunnel/quick_tunnel_test.go new file mode 100644 index 00000000..9aa10860 --- /dev/null +++ b/cmd/cloudflared/tunnel/quick_tunnel_test.go @@ -0,0 +1,64 @@ +package tunnel + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildQuickTunnelRequestBody_PublicMode(t *testing.T) { + t.Parallel() + + body, err := buildQuickTunnelRequestBody(false) + require.NoError(t, err) + assert.Empty(t, body) +} + +func TestBuildQuickTunnelRequestBody_ProtectedMode(t *testing.T) { + t.Parallel() + + body, err := buildQuickTunnelRequestBody(true) + require.NoError(t, err) + + var result map[string]string + require.NoError(t, json.Unmarshal(body, &result)) + assert.Equal(t, quickTunnelAuthModeOTP, result[quickTunnelAuthModeField]) +} + +func TestFormatQuickTunnelErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + errors []QuickTunnelError + expected string + }{ + { + name: "empty", + errors: []QuickTunnelError{}, + expected: "", + }, + { + name: "single error", + errors: []QuickTunnelError{{Code: 1, Message: "first error"}}, + expected: "[1] first error", + }, + { + name: "multiple errors", + errors: []QuickTunnelError{ + {Code: 1, Message: "first error"}, + {Code: 2, Message: "second error"}, + }, + expected: "[1] first error; [2] second error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, formatQuickTunnelErrors(tt.errors)) + }) + } +} diff --git a/component-tests/test_quicktunnels.py b/component-tests/test_quicktunnels.py index a6a9b0d9..b9f48a60 100644 --- a/component-tests/test_quicktunnels.py +++ b/component-tests/test_quicktunnels.py @@ -13,7 +13,7 @@ class TestQuickTunnels: time.sleep(10) url = get_quicktunnel_url() send_requests(url, 3, True) - + def test_quick_tunnel_url(self, tmp_path, component_tests_config): config = component_tests_config(cfd_mode=CfdModes.QUICK) LOGGER.debug(config) @@ -22,3 +22,26 @@ class TestQuickTunnels: time.sleep(10) url = get_quicktunnel_url() send_requests(url+"/ready", 3, True) + + def test_quick_tunnel_with_allowed_mail_flag_is_rejected(self, tmp_path, component_tests_config): + """ + Protected quick tunnels are intentionally dead code in this MR: the + --allowed-mail flag is not registered yet, so passing it must be + rejected by the CLI and must not create a quick tunnel. This test + documents that behavior until TUN-10798 registers the flag. + """ + config = component_tests_config(cfd_mode=CfdModes.QUICK) + LOGGER.debug(config) + result = start_cloudflared( + tmp_path, + config, + cfd_pre_args=["tunnel", "--ha-connections", "1"], + cfd_args=["--hello-world", "--allowed-mail", "test@example.com"], + new_process=False, + expect_success=False, + ) + output = result.stdout.decode("utf-8", errors="replace") + LOGGER.debug(output) + assert "flag provided but not defined: -allowed-mail" in output, \ + f"Expected --allowed-mail to be rejected, got output:\n{output}" + diff --git a/connection/connection.go b/connection/connection.go index 23ad2153..1c91e821 100644 --- a/connection/connection.go +++ b/connection/connection.go @@ -58,6 +58,7 @@ type Orchestrator interface { type TunnelProperties struct { Credentials Credentials QuickTunnelUrl string + IsProtected bool } // Credentials are stored in the credentials file and contain all info needed to run a tunnel.