mirror of
https://github.com/cloudflare/cloudflared.git
synced 2026-10-12 02:12:14 +00:00
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:
committed by
Miguel da Costa Martins Marcelino
parent
97461b69e6
commit
79c7547d81
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user