TUN-10799: Negotiate protected mode during Quick Tunnel provisioning

## Summary

Lands the request and data plumbing needed for protected Quick Tunnel provisioning.

This is intentionally staged dead code: `--allowed-mail` is not registered yet, so protected provisioning is not user-reachable in this MR. The existing public Quick Tunnel path remains unchanged. CLI registration and release notes will land when the feature becomes user-reachable.

## Changes

- Add `auth_mode` constants for protected quick tunnels
- Add `buildQuickTunnelRequestBody` helper to construct the OTP request body
- Read `--allowed-mail` values and compute `isProtected` once the flag is registered
- Send `{"auth_mode":"otp"}` when protected mode is requested
- Set `TunnelProperties.IsProtected` for downstream auth handlers
- Preserve structured Worker errors for non-2xx and unsuccessful responses
- Add unit tests for public and protected request bodies
- Add a TODO for CLI-level provisioning-error coverage under TUN-10791

## Validation

- `make test lint`
- Built the `cloudflared` binary locally
- Confirmed `--allowed-mail` is currently rejected as an undefined flag
- Confirmed a public invocation sends a bodyless provisioning request to a mock service
- Confirmed the mock Worker's `503` / `[1234] expected test failure` response is shown by the CLI

## Acceptance criteria

- [x] Protected request construction sends only the supported authentication mode
- [x] Public provisioning request remains bodyless
- [x] Unit tests cover public and protected request bodies
- [x] Current staged CLI behavior manually verified
- [x] Register `--allowed-mail` and make protected mode user-reachable (follow-up)
- [ ] Full CLI/integration coverage under TUN-10791

## Related

- Epic: RM-25152
- Previous/foundation work: TUN-10798
- End-to-end coverage: TUN-10791
This commit is contained in:
Alessandro Frigerio
2026-09-03 13:03:59 +00:00
committed by Miguel da Costa Martins Marcelino
parent 97461b69e6
commit 79c7547d81
5 changed files with 150 additions and 7 deletions
+3
View File
@@ -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"
+58 -6
View File
@@ -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"`
@@ -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))
})
}
}
+24 -1
View File
@@ -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", "[email protected]"],
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}"
+1
View File
@@ -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.