From dbaf0b04728e322ba99fdca75f1a818efb55c59a Mon Sep 17 00:00:00 2001 From: Simon Law Date: Wed, 10 Dec 2025 12:13:36 -0800 Subject: [PATCH] cmd/tailscale/cli, util/qrcodes: format QR codes on Linux consoles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw Linux consoles support UTF-8, but we cannot assume that all UTF-8 characters are available. The default Fixed and Terminus fonts don’t contain half-block characters (`▀` and `▄`), but do contain the full-block character (`█`). Sometimes, Linux doesn’t have a framebuffer, so it falls back to VGA. When this happens, the full-block character could be anywhere in extended ASCII block, because we don’t know which code page is active. This PR introduces `--qr-format=auto` which tries to heuristically detect when Tailscale is printing to a raw Linux console, whether UTF-8 is enabled, and which block characters have been mapped in the console font. If Unicode characters are unavailable, the new `--qr-format=ascii` formatter uses `#` characters instead of full-block characters. Fixes #12935 Signed-off-by: Simon Law --- cmd/tailscale/cli/up.go | 24 ++--- cmd/tailscale/depaware.txt | 3 +- cmd/tailscaled/depaware-minbox.txt | 3 +- util/qrcodes/qrcodes.go | 67 ++++++++++++ util/qrcodes/qrcodes_linux.go | 160 +++++++++++++++++++++++++++++ util/qrcodes/qrcodes_notlinux.go | 14 +++ 6 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 util/qrcodes/qrcodes.go create mode 100644 util/qrcodes/qrcodes_linux.go create mode 100644 util/qrcodes/qrcodes_notlinux.go diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 2a3cbf75a..25e452b80 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -23,7 +23,6 @@ import ( shellquote "github.com/kballard/go-shellquote" "github.com/peterbourgon/ff/v3/ffcli" - qrcode "github.com/skip2/go-qrcode" "tailscale.com/feature/buildfeatures" _ "tailscale.com/feature/condregister/identityfederation" _ "tailscale.com/feature/condregister/oauthkey" @@ -39,6 +38,7 @@ import ( "tailscale.com/types/preftype" "tailscale.com/types/views" "tailscale.com/util/dnsname" + "tailscale.com/util/qrcodes" "tailscale.com/util/syspolicy/policyclient" "tailscale.com/version/distro" ) @@ -95,7 +95,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { // When adding new flags, prefer to put them under "tailscale set" instead // of here. Setting preferences via "tailscale up" is deprecated. upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs") - upf.StringVar(&upArgs.qrFormat, "qr-format", "small", "QR code formatting (small or large)") + upf.StringVar(&upArgs.qrFormat, "qr-format", string(qrcodes.FormatAuto), "QR code formatting (auto, ascii, large, small)") upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) upf.StringVar(&upArgs.clientID, "client-id", "", "Client ID used to generate authkeys via workload identity federation") upf.StringVar(&upArgs.clientSecretOrFile, "client-secret", "", `Client Secret used to generate authkeys via OAuth; if it begins with "file:", then it's a path to a file containing the secret`) @@ -717,12 +717,9 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE if upArgs.json { js := &upOutputJSON{AuthURL: authURL, BackendState: st.BackendState} - q, err := qrcode.New(authURL, qrcode.Medium) + png, err := qrcodes.EncodePNG(authURL, 128) if err == nil { - png, err := q.PNG(128) - if err == nil { - js.QR = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) - } + js.QR = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) } data, err := json.MarshalIndent(js, "", "\t") @@ -734,18 +731,9 @@ func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retE } else { fmt.Fprintf(Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", authURL) if upArgs.qr { - q, err := qrcode.New(authURL, qrcode.Medium) + _, err := qrcodes.Fprintln(Stderr, qrcodes.Format(upArgs.qrFormat), authURL) if err != nil { - log.Printf("QR code error: %v", err) - } else { - switch upArgs.qrFormat { - case "large": - fmt.Fprintf(Stderr, "%s\n", q.ToString(false)) - case "small": - fmt.Fprintf(Stderr, "%s\n", q.ToSmallString(false)) - default: - log.Printf("unknown QR code format: %q", upArgs.qrFormat) - } + log.Print(err) } } } diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 8b576ffc3..7c89633ac 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -47,7 +47,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep github.com/peterbourgon/ff/v3 from github.com/peterbourgon/ff/v3/ffcli+ github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli+ github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3 - github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli + github.com/skip2/go-qrcode from tailscale.com/util/qrcodes github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+ github.com/skip2/go-qrcode/reedsolomon from github.com/skip2/go-qrcode W 💣 github.com/tailscale/go-winio from tailscale.com/safesocket @@ -189,6 +189,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/util/must from tailscale.com/clientupdate/distsign+ tailscale.com/util/nocasemaps from tailscale.com/types/ipproto tailscale.com/util/prompt from tailscale.com/cmd/tailscale/cli + 💣 tailscale.com/util/qrcodes from tailscale.com/cmd/tailscale/cli tailscale.com/util/quarantine from tailscale.com/cmd/tailscale/cli tailscale.com/util/rands from tailscale.com/tsweb tailscale.com/util/set from tailscale.com/ipn+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index acc424103..85243c16b 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -33,7 +33,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de github.com/peterbourgon/ff/v3/ffcli from tailscale.com/cmd/tailscale/cli+ github.com/peterbourgon/ff/v3/internal from github.com/peterbourgon/ff/v3 💣 github.com/safchain/ethtool from tailscale.com/net/netkernelconf - github.com/skip2/go-qrcode from tailscale.com/cmd/tailscale/cli + github.com/skip2/go-qrcode from tailscale.com/util/qrcodes github.com/skip2/go-qrcode/bitset from github.com/skip2/go-qrcode+ github.com/skip2/go-qrcode/reedsolomon from github.com/skip2/go-qrcode 💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+ @@ -192,6 +192,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/util/osshare from tailscale.com/cmd/tailscaled tailscale.com/util/osuser from tailscale.com/ipn/ipnlocal+ tailscale.com/util/prompt from tailscale.com/cmd/tailscale/cli + 💣 tailscale.com/util/qrcodes from tailscale.com/cmd/tailscale/cli tailscale.com/util/race from tailscale.com/net/dns/resolver tailscale.com/util/racebuild from tailscale.com/logpolicy tailscale.com/util/rands from tailscale.com/ipn/ipnlocal+ diff --git a/util/qrcodes/qrcodes.go b/util/qrcodes/qrcodes.go new file mode 100644 index 000000000..2fec3e0c6 --- /dev/null +++ b/util/qrcodes/qrcodes.go @@ -0,0 +1,67 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package qrcodes formats QR codes according to the active terminal. +package qrcodes + +import ( + "fmt" + "io" + "strings" + + qrcode "github.com/skip2/go-qrcode" +) + +// --qr-format values +type Format string + +const ( + FormatAuto Format = "auto" + FormatASCII Format = "ascii" + FormatLarge Format = "large" + FormatSmall Format = "small" +) + +// Fprintln formats s according to [Format] and writes to w and a newline is +// appended. It returns the number of bytes written and any write error +// encountered. +func Fprintln(w io.Writer, format Format, s string) (n int, err error) { + const inverse = false // Modern scanners can read QR codes of any colour. + + q, err := qrcode.New(s, qrcode.Medium) + if err != nil { + return 0, fmt.Errorf("QR code error: %w", err) + } + + if format == FormatAuto { + format, err = detectFormat(w, inverse) + if err != nil { + return 0, fmt.Errorf("QR code error: %w", err) + } + } + + var out string + switch format { + case FormatASCII: + out = q.ToString(inverse) + out = strings.ReplaceAll(out, "█", "#") + case FormatLarge: + out = q.ToString(inverse) + case FormatSmall: + out = q.ToSmallString(inverse) + default: + return 0, fmt.Errorf("unknown QR code format: %q", format) + } + + return fmt.Fprintln(w, out) +} + +// EncodePNG renders a QR code for s as a PNG, with a width and height of size +// pixels. +func EncodePNG(s string, size int) ([]byte, error) { + q, err := qrcode.New(s, qrcode.Medium) + if err != nil { + return nil, err + } + return q.PNG(size) +} diff --git a/util/qrcodes/qrcodes_linux.go b/util/qrcodes/qrcodes_linux.go new file mode 100644 index 000000000..6904c486d --- /dev/null +++ b/util/qrcodes/qrcodes_linux.go @@ -0,0 +1,160 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux + +package qrcodes + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "unsafe" + + "github.com/mattn/go-isatty" + "golang.org/x/sys/unix" +) + +func detectFormat(w io.Writer, inverse bool) (format Format, _ error) { + var zero Format + + // Almost every terminal supports UTF-8, but the Linux + // console may have partial or no support, which is + // especially painful inside VMs. See #12935. + format = FormatSmall + + // Is the locale (LC_CTYPE) set to UTF-8? + locale, err := locale() + if err != nil { + return FormatASCII, fmt.Errorf("QR: %w", err) + } + const utf8 = ".UTF-8" + if !strings.HasSuffix(locale["LC_CTYPE"], utf8) && + !strings.HasSuffix(locale["LANG"], utf8) { + return FormatASCII, nil + } + + // Are we printing to a terminal? + f, ok := w.(*os.File) + if !ok { + return format, nil + } + if !isatty.IsTerminal(f.Fd()) { + return format, nil + } + fd := f.Fd() + + // On a Linux console, check that the current keyboard + // is in Unicode mode. See unicode_start(1). + const K_UNICODE = 0x03 + kbMode, err := ioctlGetKBMode(fd) + if err != nil { + if errors.Is(err, syscall.ENOTTY) { + return format, nil + } + return zero, err + } + if kbMode != K_UNICODE { + return FormatASCII, nil + } + + // On a raw Linux console, detect whether the block + // characters are available in the current font by + // consulting the Unicode-to-font mapping. + unimap, err := ioctlGetUniMap(fd) + if err != nil { + return zero, err + } + if _, ok := unimap['█']; ok { + format = FormatLarge + } + if _, ok := unimap['▀']; ok && inverse { + format = FormatSmall + } + if _, ok := unimap['▄']; ok && !inverse { + format = FormatSmall + } + + return format, nil +} + +func locale() (map[string]string, error) { + locale := map[string]string{ + "LANG": os.Getenv("LANG"), + "LC_CTYPE": os.Getenv("LC_CTYPE"), + } + + cmd := exec.Command("locale") + out, err := cmd.Output() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return locale, nil + } + return nil, fmt.Errorf("locale error: %w", err) + } + + for _, line := range strings.Split(string(out), "\n") { + if line == "" { + continue + } + k, v, found := strings.Cut(line, "=") + if !found { + continue + } + v, err := strconv.Unquote(v) + if err != nil { + continue + } + locale[k] = v + } + return locale, nil +} + +func ioctlGetKBMode(fd uintptr) (int, error) { + const KDGKBMODE = 0x4b44 + mode, err := unix.IoctlGetInt(int(fd), KDGKBMODE) + if err != nil { + return 0, fmt.Errorf("keyboard mode error: %w", err) + } + return mode, nil +} + +func ioctlGetUniMap(fd uintptr) (map[rune]int, error) { + const GIO_UNIMAP = 0x4B66 // get unicode-to-font mapping from kernel + var ud struct { + Count uint16 + Entries uintptr // pointer to unipair array + } + type unipair struct { + Unicode uint16 // Unicode value + FontPos uint16 // Font position in the console font + } + + // First, get the number of entries: + _, _, errno := unix.Syscall(unix.SYS_IOCTL, fd, GIO_UNIMAP, uintptr(unsafe.Pointer(&ud))) + if errno != 0 && !errors.Is(errno, syscall.ENOMEM) { + return nil, fmt.Errorf("unicode mapping error: %w", errno) + } + + // Then allocate enough space and get the entries themselves: + if ud.Count == 0 { + return nil, nil + } + entries := make([]unipair, ud.Count) + ud.Entries = uintptr(unsafe.Pointer(&entries[0])) + _, _, errno = unix.Syscall(unix.SYS_IOCTL, fd, GIO_UNIMAP, uintptr(unsafe.Pointer(&ud))) + if errno != 0 { + return nil, fmt.Errorf("unicode mapping error: %w", errno) + } + + unimap := make(map[rune]int) + for _, e := range entries { + unimap[rune(e.Unicode)] = int(e.FontPos) + } + return unimap, nil +} diff --git a/util/qrcodes/qrcodes_notlinux.go b/util/qrcodes/qrcodes_notlinux.go new file mode 100644 index 000000000..d5202b4e2 --- /dev/null +++ b/util/qrcodes/qrcodes_notlinux.go @@ -0,0 +1,14 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !linux + +package qrcodes + +import "io" + +func detectFormat(w io.Writer, inverse bool) (Format, error) { + // Assume all terminals can support the full set of UTF-8 block + // characters: (█, ▀, ▄). See #12935. + return FormatSmall, nil +}