Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 85 additions & 8 deletions cmd/cloudflared/tunnel/quick_tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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"
Expand All @@ -18,6 +19,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"

// RunQuickTunnel requests a tunnel from the specified service.
Expand Down Expand Up @@ -72,15 +77,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")
Expand All @@ -97,6 +108,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
Expand Down
111 changes: 111 additions & 0 deletions cmd/cloudflared/tunnel/quick_tunnel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package tunnel

import (
"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++
}
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
5 changes: 5 additions & 0 deletions vendor/modules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,11 @@ nhooyr.io/websocket/internal/bpool
nhooyr.io/websocket/internal/errd
nhooyr.io/websocket/internal/wsjs
nhooyr.io/websocket/internal/xsync
# rsc.io/qr v0.2.0
## explicit
rsc.io/qr
rsc.io/qr/coding
rsc.io/qr/gf256
# zombiezen.com/go/capnproto2 v2.18.0+incompatible
## explicit
zombiezen.com/go/capnproto2
Expand Down
27 changes: 27 additions & 0 deletions vendor/rsc.io/qr/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions vendor/rsc.io/qr/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading