diff --git a/cmd/cloudflared/tunnel/quick_tunnel.go b/cmd/cloudflared/tunnel/quick_tunnel.go index c5621357d44..5cf8e6faed8 100644 --- a/cmd/cloudflared/tunnel/quick_tunnel.go +++ b/cmd/cloudflared/tunnel/quick_tunnel.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/pkg/errors" + "rsc.io/qr" "github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil" "github.com/cloudflare/cloudflared/cmd/cloudflared/flags" @@ -19,6 +20,10 @@ import ( const httpTimeout = 15 * time.Second +// qrQuietZoneModules is the number of empty modules added around the rendered +// QR code. Four modules is the minimum quiet zone required by the QR spec. +const qrQuietZoneModules = 4 + 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 ( @@ -116,15 +121,21 @@ func RunQuickTunnel(sc *subcommandContext) error { TunnelID: tunnelID, } - url := data.Result.Hostname - if !strings.HasPrefix(url, "https://") { - url = "https://" + url - } + cliutil.LogTable(sc.log, quickTunnelURLDisplayLines(data.Result.Hostname)) - cliutil.LogTable(sc.log, []string{ - "Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):", - url, - }) + quickTunnelQRLines, err := quickTunnelQRCodeLines(data.Result.Hostname) + if err != nil { + sc.log.Warn().Err(err).Msg("Failed to generate quick Tunnel QR code") + } else { + // Filter out the all-white quiet-zone rows so the terminal output + // stays compact while the QR code itself remains scannable. + for _, line := range quickTunnelQRLines { + if line != "" { + sc.log.Info().Msg(line) + } + } + sc.log.Info().Msg("") + } if !sc.c.IsSet(flags.Protocol) { _ = sc.c.Set(flags.Protocol, "quic") @@ -141,6 +152,72 @@ func RunQuickTunnel(sc *subcommandContext) error { ) } +func quickTunnelURLDisplayLines(hostname string) []string { + return []string{ + "Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):", + normalizeQuickTunnelURL(hostname), + } +} + +func quickTunnelQRCodeLines(hostname string) ([]string, error) { + url := normalizeQuickTunnelURL(hostname) + code, err := qr.Encode(url, qr.L) + if err != nil { + return nil, errors.Wrap(err, "failed to create quick Tunnel QR code") + } + + return renderHalfBlockQRCode(code, qrQuietZoneModules), nil +} + +func renderHalfBlockQRCode(code *qr.Code, quietZone int) []string { + minX, minY, maxX, maxY := code.Size, code.Size, 0, 0 + for y := 0; y < code.Size; y++ { + for x := 0; x < code.Size; x++ { + if code.Black(x, y) { + minX = min(minX, x) + minY = min(minY, y) + maxX = max(maxX, x) + maxY = max(maxY, y) + } + } + } + + minX -= quietZone + minY -= quietZone + maxX += quietZone + maxY += quietZone + + lines := make([]string, 0, ((maxY-minY)+2)/2) + lineWidth := maxX - minX + 1 + for y := minY; y <= maxY; y += 2 { + var line strings.Builder + line.Grow(lineWidth) + for x := minX; x <= maxX; x++ { + top := code.Black(x, y) + bottom := y+1 <= maxY && code.Black(x, y+1) + switch { + case top && bottom: + line.WriteRune('█') + case top: + line.WriteRune('▀') + case bottom: + line.WriteRune('▄') + default: + line.WriteRune(' ') + } + } + lines = append(lines, line.String()) + } + return lines +} + +func normalizeQuickTunnelURL(hostname string) string { + if strings.HasPrefix(hostname, "https://") { + return hostname + } + return "https://" + hostname +} + type QuickTunnelResponse struct { Success bool Result QuickTunnel diff --git a/cmd/cloudflared/tunnel/quick_tunnel_test.go b/cmd/cloudflared/tunnel/quick_tunnel_test.go index 9aa1086018c..f5da485e83c 100644 --- a/cmd/cloudflared/tunnel/quick_tunnel_test.go +++ b/cmd/cloudflared/tunnel/quick_tunnel_test.go @@ -2,12 +2,115 @@ package tunnel import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "rsc.io/qr" ) +func TestQuickTunnelURLDisplayLinesNormalizeURL(t *testing.T) { + t.Parallel() + + lines := quickTunnelURLDisplayLines("example.trycloudflare.com") + + require.Len(t, lines, 2) + assert.Equal(t, "Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):", lines[0]) + assert.Equal(t, "https://example.trycloudflare.com", lines[1]) +} + +func TestQuickTunnelURLDisplayLinesPreserveHTTPSURL(t *testing.T) { + t.Parallel() + + lines := quickTunnelURLDisplayLines("https://example.trycloudflare.com") + + require.Len(t, lines, 2) + assert.Equal(t, "https://example.trycloudflare.com", lines[1]) +} + +func TestQuickTunnelQRCodeLinesUseCompactTerminalBlocks(t *testing.T) { + t.Parallel() + + lines, err := quickTunnelQRCodeLines("example.trycloudflare.com") + + require.NoError(t, err) + require.NotEmpty(t, lines) + qrOutput := strings.Join(lines, "\n") + assert.Contains(t, qrOutput, "▀") + assert.Contains(t, qrOutput, "▄") + assert.NotContains(t, qrOutput, "https://example.trycloudflare.com") +} + +func TestQuickTunnelQRCodeLinesKeepScanQuietZone(t *testing.T) { + t.Parallel() + + lines, err := quickTunnelQRCodeLines("example.trycloudflare.com") + + require.NoError(t, err) + require.Greater(t, len(lines), 4) + assert.Empty(t, strings.TrimSpace(lines[0])) + assert.Empty(t, strings.TrimSpace(lines[1])) + assert.Empty(t, strings.TrimSpace(lines[len(lines)-2])) + assert.Empty(t, strings.TrimSpace(lines[len(lines)-1])) + assert.NotEmpty(t, strings.TrimSpace(lines[2])) + for _, line := range lines[2 : len(lines)-2] { + assert.True(t, strings.HasPrefix(line, " ")) + } +} + +func TestQuickTunnelQRCodeLinesReturnsErrorForURLTooLong(t *testing.T) { + t.Parallel() + + // A URL longer than the largest QR version can encode. + longURL := strings.Repeat("a", 10000) + + _, err := quickTunnelQRCodeLines(longURL) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create quick Tunnel QR code") +} + +func TestRenderHalfBlockQRCodeMatchesSourceBitmap(t *testing.T) { + t.Parallel() + + url := "https://example.trycloudflare.com" + code, err := qr.Encode(url, qr.L) + require.NoError(t, err) + + quietZone := 2 + lines := renderHalfBlockQRCode(code, quietZone) + require.NotEmpty(t, lines) + + // Reconstruct a per-module bitmap from the half-block terminal output + // and compare it to the original QR code. + for row, line := range lines { + yTop := row*2 - quietZone + yBottom := yTop + 1 + col := 0 + for _, r := range line { + x := col - quietZone + switch r { + case '█': + assert.True(t, code.Black(x, yTop), "expected black at (%d,%d)", x, yTop) + assert.True(t, code.Black(x, yBottom), "expected black at (%d,%d)", x, yBottom) + case '▀': + assert.True(t, code.Black(x, yTop), "expected black at (%d,%d)", x, yTop) + assert.False(t, code.Black(x, yBottom), "expected white at (%d,%d)", x, yBottom) + case '▄': + assert.False(t, code.Black(x, yTop), "expected white at (%d,%d)", x, yTop) + assert.True(t, code.Black(x, yBottom), "expected black at (%d,%d)", x, yBottom) + case ' ': + assert.False(t, code.Black(x, yTop), "expected white at (%d,%d)", x, yTop) + assert.False(t, code.Black(x, yBottom), "expected white at (%d,%d)", x, yBottom) + default: + t.Fatalf("unexpected rune %q at row %d col %d", r, row, col) + } + col++ + } + } +} + func TestBuildQuickTunnelRequestBody_PublicMode(t *testing.T) { t.Parallel() diff --git a/go.mod b/go.mod index 2dcda06c92b..c1e5b490e89 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.1 nhooyr.io/websocket v1.8.7 + rsc.io/qr v0.2.0 zombiezen.com/go/capnproto2 v2.18.0+incompatible ) diff --git a/go.sum b/go.sum index 26c660064b6..87360e5abfb 100644 --- a/go.sum +++ b/go.sum @@ -298,5 +298,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= zombiezen.com/go/capnproto2 v2.18.0+incompatible h1:mwfXZniffG5mXokQGHUJWGnqIBggoPfT/CEwon9Yess= zombiezen.com/go/capnproto2 v2.18.0+incompatible/go.mod h1:XO5Pr2SbXgqZwn0m0Ru54QBqpOf4K5AYBO+8LAOBQEQ=