mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-15 01:53:08 -04:00
This is a variant of "tailscale configure flash-appliance" but for running on Proxmox PVE hosts to make a Proxmox VM running the experimental Tailscale Appliance. This also makes the "Esc" key make the fbstatus GUI open up a terminal, instead of Control-Alt-F2 which is hard to type over NoVNC. And make gafpush unidirectional, to not require a local port be opened locally, which I hit while working on this. And make fbstatus included in all appliance variants, but bail out early and stop respawing if the machine has no framebuffer (e.g. AWS VMs). Updates #1866 Change-Id: I18ec2a16e4d5ff5574e16fe55c0e8d06cf4fab7f Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
822 lines
23 KiB
Go
822 lines
23 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build linux
|
|
|
|
// fbstatus is a Linux framebuffer status display for the Tailscale
|
|
// appliance. It draws the Tailscale logo, the tailscaled backend state,
|
|
// the device's tailnet IP addresses, and (when the device needs to be
|
|
// logged in) a QR code containing the login URL so a user can enroll
|
|
// the appliance into a tailnet by pointing their phone camera at the
|
|
// screen.
|
|
//
|
|
// fbstatus accesses the framebuffer via the Linux UAPI in
|
|
// include/uapi/linux/fb.h: FBIOGET_VSCREENINFO and FBIOGET_FSCREENINFO
|
|
// ioctls plus an mmap of /dev/fb0. Only 32-bit truecolor framebuffers
|
|
// (the Raspberry Pi default) are supported.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"encoding/binary"
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/skip2/go-qrcode"
|
|
xdraw "golang.org/x/image/draw"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/basicfont"
|
|
"golang.org/x/image/math/fixed"
|
|
"golang.org/x/sys/unix"
|
|
"tailscale.com/client/local"
|
|
"tailscale.com/ipn"
|
|
"tailscale.com/util/cloudenv"
|
|
)
|
|
|
|
//go:embed tailscale.png
|
|
var tailscalePNG []byte
|
|
|
|
// Linux framebuffer ioctl numbers, from include/uapi/linux/fb.h.
|
|
const (
|
|
fbioGetVScreenInfo = 0x4600
|
|
fbioGetFScreenInfo = 0x4602
|
|
)
|
|
|
|
// Linux VT ioctl numbers and KD_* modes, from include/uapi/linux/kd.h
|
|
// and include/uapi/linux/vt.h.
|
|
const (
|
|
kdSetMode = 0x4B3A
|
|
kdGraphics = 1
|
|
kdText = 0
|
|
vtActivate = 0x5606
|
|
vtWaitActive = 0x5607
|
|
)
|
|
|
|
// Byte offsets into the raw fb_var_screeninfo struct returned by
|
|
// FBIOGET_VSCREENINFO. All fields we read are little-endian uint32.
|
|
const (
|
|
vsOffXres = 0
|
|
vsOffYres = 4
|
|
vsOffBitsPerPixel = 24
|
|
vsOffRedOffset = 32 // start of struct fb_bitfield red
|
|
vsOffGreenOffset = 44 // start of struct fb_bitfield green
|
|
vsOffBlueOffset = 56 // start of struct fb_bitfield blue
|
|
)
|
|
|
|
// Byte offsets into the raw fb_fix_screeninfo struct returned by
|
|
// FBIOGET_FSCREENINFO. Layout assumes a 64-bit kernel (the gokrazy
|
|
// appliance targets — arm64/amd64 — are both 64-bit). smem_start and
|
|
// mmio_start are "unsigned long", which is 8 bytes on 64-bit.
|
|
const (
|
|
fsOffSmemLen = 24
|
|
fsOffLineLength = 48
|
|
)
|
|
|
|
var flagFB = flag.String("fb", "/dev/fb0", "framebuffer device to draw to")
|
|
|
|
// noFramebufferReason reports whether this host lacks a usable Linux
|
|
// framebuffer, along with a short human-readable explanation. If the
|
|
// framebuffer device is missing, or we're running on a cloud (currently
|
|
// only AWS) whose instances don't expose one, we return true.
|
|
func noFramebufferReason(fbPath string) (string, bool) {
|
|
if cloudenv.Get() == cloudenv.AWS {
|
|
return "running on AWS (no framebuffer)", true
|
|
}
|
|
if _, err := os.Stat(fbPath); err != nil {
|
|
return fmt.Sprintf("no framebuffer at %s: %v", fbPath, err), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
|
if err := run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
// Bail out early on cloud VMs that don't ship a framebuffer. We
|
|
// still kick off breakglass once DHCP succeeds (otherwise the
|
|
// appliance is unreachable — breakglass declares DontStartOnBoot
|
|
// and only runs when fbstatus pokes the supervisor), then exit
|
|
// 125 so the gokrazy supervisor stops respawning us. See
|
|
// https://gokrazy.org/development/process-interface/.
|
|
if reason, ok := noFramebufferReason(*flagFB); ok {
|
|
log.Printf("%s; starting breakglass after DHCP then exiting 125", reason)
|
|
startBreakglassAfterDHCP(&uiState{})
|
|
log.Printf("breakglass started; exiting 125 so gokrazy won't respawn fbstatus")
|
|
os.Exit(125)
|
|
}
|
|
|
|
if restore, err := claimVTGraphics(); err != nil {
|
|
log.Printf("could not put VT into graphics mode (fbcon may overdraw): %v", err)
|
|
} else {
|
|
defer restore()
|
|
}
|
|
|
|
fb, err := openFramebuffer(*flagFB)
|
|
if err != nil {
|
|
return fmt.Errorf("open framebuffer: %w", err)
|
|
}
|
|
defer fb.Close()
|
|
log.Printf("framebuffer %s: %dx%d, %d bpp, line=%d, RGB offsets %d/%d/%d",
|
|
*flagFB, fb.width, fb.height, fb.bpp, fb.lineLength,
|
|
fb.redShift, fb.greenShift, fb.blueShift)
|
|
|
|
logo, err := png.Decode(bytes.NewReader(tailscalePNG))
|
|
if err != nil {
|
|
return fmt.Errorf("decoding embedded logo: %w", err)
|
|
}
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
var lc local.Client
|
|
st := &uiState{fb: fb, logo: logo}
|
|
st.updateLAN()
|
|
st.render()
|
|
|
|
go st.pollLAN(ctx)
|
|
go startBreakglassAfterDHCP(st)
|
|
go watchKeyboardForConsole(ctx, st)
|
|
|
|
for ctx.Err() == nil {
|
|
if err := watchBusOnce(ctx, &lc, st); err != nil && ctx.Err() == nil {
|
|
log.Printf("ipn watch: %v; retrying in 2s", err)
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func watchBusOnce(ctx context.Context, lc *local.Client, st *uiState) error {
|
|
w, err := lc.WatchIPNBus(ctx,
|
|
ipn.NotifyInitialState|ipn.NotifyInitialPrefs|ipn.NotifyInitialStatus)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer w.Close()
|
|
|
|
loginRequested := false
|
|
|
|
for ctx.Err() == nil {
|
|
n, err := w.Next()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n.State != nil {
|
|
st.state = *n.State
|
|
// On a fresh appliance, tailscaled enters NeedsLogin but
|
|
// does not generate a login URL until someone asks. Trigger
|
|
// an interactive login so the control server sends us a URL
|
|
// (and thus a QR code appears on the display).
|
|
if *n.State == ipn.NeedsLogin && !loginRequested {
|
|
loginRequested = true
|
|
go func() {
|
|
if err := lc.StartLoginInteractive(ctx); err != nil {
|
|
log.Printf("StartLoginInteractive: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
if n.BrowseToURL != nil {
|
|
st.loginURL = *n.BrowseToURL
|
|
}
|
|
if n.InitialStatus != nil {
|
|
st.ips = append(st.ips[:0], n.InitialStatus.TailscaleIPs...)
|
|
}
|
|
if n.SelfChange != nil {
|
|
st.ips = st.ips[:0]
|
|
for _, p := range n.SelfChange.Addresses {
|
|
st.ips = append(st.ips, p.Addr())
|
|
}
|
|
}
|
|
st.render()
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// updateLAN scans network interfaces for a non-loopback interface with a
|
|
// hardware address, updating st.lanIP and st.lanMAC. Shows the MAC even
|
|
// if DHCP hasn't assigned an IP yet.
|
|
func (st *uiState) updateLAN() {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return
|
|
}
|
|
var bestMAC string
|
|
var bestIP string
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
if len(iface.HardwareAddr) == 0 {
|
|
continue
|
|
}
|
|
if bestMAC == "" {
|
|
bestMAC = iface.HardwareAddr.String()
|
|
}
|
|
if iface.Flags&net.FlagUp == 0 {
|
|
continue
|
|
}
|
|
// Prefer the first UP interface with a MAC.
|
|
if bestMAC != iface.HardwareAddr.String() && bestIP == "" {
|
|
bestMAC = iface.HardwareAddr.String()
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
|
bestMAC = iface.HardwareAddr.String()
|
|
bestIP = ipnet.IP.String()
|
|
}
|
|
}
|
|
}
|
|
st.lanMAC = bestMAC
|
|
st.lanIP = bestIP
|
|
}
|
|
|
|
// startBreakglassAfterDHCP waits until a LAN IP is assigned (meaning DHCP
|
|
// succeeded), then restarts breakglass. This ensures breakglass sees the
|
|
// real LAN address in PrivateInterfaceAddrs and binds to it, rather than
|
|
// only binding to 127.0.0.1.
|
|
func startBreakglassAfterDHCP(st *uiState) {
|
|
for {
|
|
st.updateLAN()
|
|
if st.lanIP != "" {
|
|
break
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
startBreakglass()
|
|
}
|
|
|
|
// startBreakglass asks the gokrazy init HTTP API (over its unix socket) to
|
|
// restart the breakglass service so it actually runs. By default breakglass
|
|
// calls DontStartOnBoot and exits on the first launch attempt; this poke
|
|
// tells the supervisor to try again (without GOKRAZY_FIRST_START=1).
|
|
func startBreakglass() {
|
|
const sock = "/run/gokrazy-http.sock"
|
|
hc := &http.Client{
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return d.DialContext(ctx, "unix", sock)
|
|
},
|
|
},
|
|
}
|
|
form := url.Values{
|
|
"path": {"/user/breakglass"},
|
|
"xsrftoken": {"1"},
|
|
}
|
|
req, err := http.NewRequest("POST", "http://gokrazy/restart", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
log.Printf("startBreakglass: %v", err)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.AddCookie(&http.Cookie{Name: "gokrazy_xsrf", Value: "1"})
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
log.Printf("startBreakglass: %v", err)
|
|
return
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode < 300 || resp.StatusCode == http.StatusSeeOther {
|
|
log.Printf("startBreakglass: restarted (status %s)", resp.Status)
|
|
} else {
|
|
log.Printf("startBreakglass: unexpected status %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
// watchKeyboardForConsole monitors keyboard input devices for VT-switching
|
|
// accelerators.
|
|
// - Ctrl-Alt-F2 (or plain Esc — easier to type in NoVNC where the
|
|
// Ctrl-Alt-Fn sequence doesn't always propagate) switches to VT2, a
|
|
// text-mode busybox shell. When that shell exits, we switch back
|
|
// automatically.
|
|
// - Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode).
|
|
//
|
|
// This mirrors standard Linux VT switching conventions plus a NoVNC-
|
|
// friendly shortcut.
|
|
func watchKeyboardForConsole(ctx context.Context, st *uiState) {
|
|
kbdPath := findKeyboard()
|
|
if kbdPath == "" {
|
|
log.Printf("no keyboard found for VT switching")
|
|
return
|
|
}
|
|
kbd, err := os.Open(kbdPath)
|
|
if err != nil {
|
|
log.Printf("open keyboard %s: %v", kbdPath, err)
|
|
return
|
|
}
|
|
defer kbd.Close()
|
|
|
|
ttyFile, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
|
|
if err != nil {
|
|
log.Printf("open /dev/tty0 for VT switch: %v", err)
|
|
return
|
|
}
|
|
defer ttyFile.Close()
|
|
ttyFd := int(ttyFile.Fd())
|
|
|
|
log.Printf("watching %s for Ctrl-Alt-F1/F2 and Esc (VT switching)", kbdPath)
|
|
|
|
// Linux input_event has the same layout on both arm64 and amd64
|
|
// (24 bytes: two uint64 timestamps + uint16 type + uint16 code +
|
|
// int32 value), so this parser handles both the Pi and Proxmox VM.
|
|
const evSize = 24
|
|
const evKey = 1 // EV_KEY
|
|
const keyEsc = 1 // KEY_ESC
|
|
const keyF1 = 59 // KEY_F1
|
|
const keyF2 = 60 // KEY_F2
|
|
const keyLeftCtrl = 29
|
|
const keyLeftAlt = 56
|
|
const keyRightCtrl = 97
|
|
const keyRightAlt = 100
|
|
const keyPress = 1
|
|
|
|
buf := make([]byte, evSize)
|
|
var ctrlHeld, altHeld bool
|
|
|
|
switchToFbstatus := func() {
|
|
st.paused.Store(false)
|
|
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 1)
|
|
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 1)
|
|
ioctlSetInt(ttyFile, kdSetMode, kdGraphics)
|
|
st.render()
|
|
}
|
|
switchToShell := func(reason string) {
|
|
st.paused.Store(true)
|
|
ioctlSetInt(ttyFile, kdSetMode, kdText)
|
|
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtActivate, 2)
|
|
syscall.Syscall(syscall.SYS_IOCTL, uintptr(ttyFd), vtWaitActive, 2)
|
|
go ensureShellOnVT2(switchToFbstatus)
|
|
log.Printf("%s: switched to text console", reason)
|
|
}
|
|
|
|
for ctx.Err() == nil {
|
|
n, err := kbd.Read(buf)
|
|
if err != nil || n < evSize {
|
|
continue
|
|
}
|
|
evType := binary.LittleEndian.Uint16(buf[16:18])
|
|
evCode := binary.LittleEndian.Uint16(buf[18:20])
|
|
evValue := int32(binary.LittleEndian.Uint32(buf[20:24]))
|
|
|
|
if evType != evKey {
|
|
continue
|
|
}
|
|
|
|
pressed := evValue == keyPress
|
|
released := evValue == 0
|
|
|
|
switch evCode {
|
|
case keyLeftCtrl, keyRightCtrl:
|
|
if pressed {
|
|
ctrlHeld = true
|
|
} else if released {
|
|
ctrlHeld = false
|
|
}
|
|
case keyLeftAlt, keyRightAlt:
|
|
if pressed {
|
|
altHeld = true
|
|
} else if released {
|
|
altHeld = false
|
|
}
|
|
case keyEsc:
|
|
if pressed && !ctrlHeld && !altHeld {
|
|
// Bare Esc: NoVNC-friendly shortcut to the shell.
|
|
switchToShell("Esc")
|
|
}
|
|
case keyF1:
|
|
if pressed && ctrlHeld && altHeld {
|
|
switchToFbstatus()
|
|
log.Printf("Ctrl-Alt-F1: switched to fbstatus")
|
|
}
|
|
case keyF2:
|
|
if pressed && ctrlHeld && altHeld {
|
|
switchToShell("Ctrl-Alt-F2")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensureShellOnVT2 spawns a busybox ash shell on /dev/tty2 if one isn't
|
|
// already running. The shell gets the VT2 tty as its controlling terminal
|
|
// so keyboard input on VT2 goes to it. When the shell exits, onExit is
|
|
// called (typically to switch back to VT1 / fbstatus graphics mode).
|
|
var shellOnVT2Running atomic.Bool
|
|
|
|
func ensureShellOnVT2(onExit func()) {
|
|
if !shellOnVT2Running.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
go func() {
|
|
defer shellOnVT2Running.Store(false)
|
|
defer func() {
|
|
if onExit != nil {
|
|
onExit()
|
|
}
|
|
}()
|
|
shell := "/tmp/serial-busybox/ash"
|
|
if _, err := os.Stat(shell); err != nil {
|
|
log.Printf("no shell at %s for VT2", shell)
|
|
return
|
|
}
|
|
tty, err := os.OpenFile("/dev/tty2", os.O_RDWR, 0)
|
|
if err != nil {
|
|
log.Printf("open /dev/tty2: %v", err)
|
|
return
|
|
}
|
|
defer tty.Close()
|
|
cmd := exec.Command(shell)
|
|
cmd.Stdin = tty
|
|
cmd.Stdout = tty
|
|
cmd.Stderr = tty
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
Setsid: true,
|
|
Setctty: true,
|
|
Ctty: 0, // index into cmd's file descriptors (stdin = tty)
|
|
}
|
|
cmd.Env = append(os.Environ(), "TERM=linux", "HOME=/tmp", "PATH=/tmp/serial-busybox:/user:/gokrazy")
|
|
log.Printf("starting shell on VT2")
|
|
if err := cmd.Run(); err != nil {
|
|
log.Printf("shell on VT2 exited: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// findKeyboard looks for a keyboard among /dev/input/event* devices by
|
|
// checking that the device's key capability bitmap has KEY_ESC set. The
|
|
// alternative "any non-zero key bitmap" check picks up the ACPI power
|
|
// button (which advertises KEY_POWER but no Esc) and misses the real
|
|
// keyboard on amd64 Proxmox VMs, where the AT keyboard is event1 but
|
|
// event0 is Power Button.
|
|
func findKeyboard() string {
|
|
matches, _ := filepath.Glob("/dev/input/event*")
|
|
for _, path := range matches {
|
|
name := filepath.Base(path)
|
|
capData, err := os.ReadFile("/sys/class/input/" + name + "/device/capabilities/key")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
fields := strings.Fields(strings.TrimSpace(string(capData)))
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
// The kernel prints capability bitmaps as space-separated 64-bit
|
|
// hex chunks, most-significant chunk first. The last chunk holds
|
|
// bits 0..63. KEY_ESC = 1, so its bit-mask is 1<<1 == 0x2.
|
|
low, err := strconv.ParseUint(fields[len(fields)-1], 16, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
const keyEscBit = 1 << 1
|
|
if low&keyEscBit != 0 {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pollLAN periodically refreshes LAN info and re-renders.
|
|
func (st *uiState) pollLAN(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
st.updateLAN()
|
|
st.render()
|
|
}
|
|
}
|
|
}
|
|
|
|
// uiState is the most-recently-known view of the appliance state that
|
|
// gets rendered to the framebuffer on each notify.
|
|
type uiState struct {
|
|
fb *framebuffer
|
|
logo image.Image
|
|
|
|
state ipn.State
|
|
loginURL string
|
|
ips []netip.Addr
|
|
|
|
lanIP string // LAN IPv4 address (from DHCP)
|
|
lanMAC string // MAC address of the primary interface
|
|
|
|
paused atomic.Bool // when true, render() is a no-op (VT switched away)
|
|
}
|
|
|
|
var (
|
|
bgColor = color.RGBA{0x10, 0x12, 0x20, 0xff} // near-black slate
|
|
fgColor = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
|
dimColor = color.RGBA{0xa0, 0xa6, 0xb8, 0xff}
|
|
stateOK = color.RGBA{0x4a, 0xc8, 0x82, 0xff} // green for Running
|
|
stateWait = color.RGBA{0xf0, 0xc8, 0x60, 0xff} // amber for NeedsLogin/Starting
|
|
)
|
|
|
|
// render composes the current state into an in-memory image and blits
|
|
// it to the framebuffer.
|
|
func (st *uiState) render() {
|
|
if st.paused.Load() {
|
|
return
|
|
}
|
|
w, h := st.fb.width, st.fb.height
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
draw.Draw(img, img.Bounds(), &image.Uniform{C: bgColor}, image.Point{}, draw.Src)
|
|
|
|
shortSide := min(w, h)
|
|
|
|
// Logo, scaled to ~25% of the shorter dimension, centered
|
|
// horizontally near the top.
|
|
logoSize := shortSide / 4
|
|
logoRect := image.Rect(0, 0, logoSize, logoSize).Add(image.Point{
|
|
X: (w - logoSize) / 2,
|
|
Y: shortSide / 16,
|
|
})
|
|
xdraw.ApproxBiLinear.Scale(img, logoRect, st.logo, st.logo.Bounds(), xdraw.Over, nil)
|
|
|
|
lineH := basicfont.Face7x13.Metrics().Height.Ceil()
|
|
textTop := logoRect.Max.Y + shortSide/24
|
|
|
|
// Hide the state line when the QR code is visible (the "Scan to
|
|
// enroll" label is clear enough context).
|
|
showState := !(st.state == ipn.NeedsLogin && st.loginURL != "")
|
|
if showState {
|
|
stateColor := dimColor
|
|
switch st.state {
|
|
case ipn.Running:
|
|
stateColor = stateOK
|
|
case ipn.NeedsLogin, ipn.Starting, ipn.NoState:
|
|
stateColor = stateWait
|
|
}
|
|
drawCenteredScaled(img, fmt.Sprintf("State: %s", stateLabel(st.state)),
|
|
stateColor, w/2, textTop, 3)
|
|
}
|
|
|
|
y := textTop + 3*lineH + shortSide/40
|
|
|
|
if len(st.ips) > 0 {
|
|
drawCenteredScaled(img, "Tailscale IPs:", dimColor, w/2, y, 2)
|
|
y += 2 * lineH
|
|
for _, a := range st.ips {
|
|
drawCenteredScaled(img, a.String(), fgColor, w/2, y, 2)
|
|
y += 2*lineH + 4
|
|
}
|
|
}
|
|
|
|
// QR code with the login URL when enrollment is needed.
|
|
if st.state == ipn.NeedsLogin && st.loginURL != "" {
|
|
qrSize := shortSide / 2
|
|
q, err := qrcode.New(st.loginURL, qrcode.Medium)
|
|
if err != nil {
|
|
log.Printf("qr encode %q: %v", st.loginURL, err)
|
|
} else {
|
|
q.DisableBorder = false
|
|
qrImg := q.Image(qrSize)
|
|
qrRect := qrImg.Bounds().Add(image.Point{
|
|
X: (w - qrSize) / 2,
|
|
Y: h - qrSize - shortSide/16,
|
|
})
|
|
draw.Draw(img, qrRect, qrImg, qrImg.Bounds().Min, draw.Src)
|
|
drawCenteredScaled(img, "Scan to enroll this device",
|
|
fgColor, w/2, qrRect.Min.Y-lineH*2-8, 2)
|
|
}
|
|
}
|
|
|
|
// LAN status pinned to the bottom-left corner.
|
|
{
|
|
lanY := h - lineH - 4
|
|
var lanText string
|
|
if st.lanIP != "" {
|
|
lanText = "LAN IP: " + st.lanIP
|
|
} else if st.lanMAC != "" {
|
|
lanText = "Waiting for DHCP (" + st.lanMAC + ")"
|
|
}
|
|
if lanText != "" {
|
|
face := basicfont.Face7x13
|
|
textW := font.MeasureString(face, lanText).Ceil()
|
|
small := image.NewRGBA(image.Rect(0, 0, textW, lineH))
|
|
d := font.Drawer{
|
|
Dst: small,
|
|
Src: &image.Uniform{C: dimColor},
|
|
Face: face,
|
|
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
|
|
}
|
|
d.DrawString(lanText)
|
|
dstRect := image.Rect(4, lanY, 4+textW, lanY+lineH)
|
|
draw.Draw(img, dstRect, small, image.Point{}, draw.Over)
|
|
}
|
|
}
|
|
|
|
st.fb.blit(img)
|
|
}
|
|
|
|
// drawCenteredScaled draws s with basicfont.Face7x13 at the given
|
|
// integer pixel scale, centered horizontally on x at top y, in col.
|
|
func drawCenteredScaled(dst *image.RGBA, s string, col color.Color, x, y, scale int) {
|
|
if s == "" {
|
|
return
|
|
}
|
|
face := basicfont.Face7x13
|
|
width := font.MeasureString(face, s).Ceil()
|
|
height := face.Metrics().Height.Ceil()
|
|
|
|
small := image.NewRGBA(image.Rect(0, 0, width, height))
|
|
d := font.Drawer{
|
|
Dst: small,
|
|
Src: &image.Uniform{C: col},
|
|
Face: face,
|
|
Dot: fixed.P(0, face.Metrics().Ascent.Ceil()),
|
|
}
|
|
d.DrawString(s)
|
|
|
|
scaledW, scaledH := width*scale, height*scale
|
|
dstRect := image.Rect(0, 0, scaledW, scaledH).Add(image.Point{
|
|
X: x - scaledW/2,
|
|
Y: y,
|
|
})
|
|
xdraw.NearestNeighbor.Scale(dst, dstRect, small, small.Bounds(), xdraw.Over, nil)
|
|
}
|
|
|
|
func stateLabel(s ipn.State) string {
|
|
switch s {
|
|
case ipn.NoState, ipn.Starting:
|
|
return "starting"
|
|
case ipn.NeedsLogin:
|
|
return "needs login"
|
|
case ipn.NeedsMachineAuth:
|
|
return "needs machine auth"
|
|
case ipn.Stopped:
|
|
return "stopped"
|
|
case ipn.Running:
|
|
return "running"
|
|
}
|
|
return strings.ToLower(s.String())
|
|
}
|
|
|
|
// framebuffer is an mmap'd Linux framebuffer device.
|
|
type framebuffer struct {
|
|
f *os.File
|
|
mem []byte
|
|
width int
|
|
height int
|
|
bpp int
|
|
lineLength int
|
|
|
|
// Bit offsets into a 32-bit pixel for each channel, from the
|
|
// fb_bitfield values returned by FBIOGET_VSCREENINFO.
|
|
redShift uint32
|
|
greenShift uint32
|
|
blueShift uint32
|
|
}
|
|
|
|
// openFramebuffer opens path, queries dimensions and pixel format via
|
|
// the FBIOGET_* ioctls, and mmaps the framebuffer memory.
|
|
//
|
|
// Only 32-bits-per-pixel framebuffers are supported. Raspberry Pi 3/4/5
|
|
// default to that.
|
|
func openFramebuffer(path string) (*framebuffer, error) {
|
|
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var (
|
|
vbuf [160]byte // fb_var_screeninfo
|
|
fbuf [80]byte // fb_fix_screeninfo
|
|
)
|
|
if err := ioctlGet(f, fbioGetVScreenInfo, vbuf[:]); err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %w", err)
|
|
}
|
|
if err := ioctlGet(f, fbioGetFScreenInfo, fbuf[:]); err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %w", err)
|
|
}
|
|
|
|
fb := &framebuffer{
|
|
f: f,
|
|
width: int(binary.LittleEndian.Uint32(vbuf[vsOffXres:])),
|
|
height: int(binary.LittleEndian.Uint32(vbuf[vsOffYres:])),
|
|
bpp: int(binary.LittleEndian.Uint32(vbuf[vsOffBitsPerPixel:])),
|
|
lineLength: int(binary.LittleEndian.Uint32(fbuf[fsOffLineLength:])),
|
|
redShift: binary.LittleEndian.Uint32(vbuf[vsOffRedOffset:]),
|
|
greenShift: binary.LittleEndian.Uint32(vbuf[vsOffGreenOffset:]),
|
|
blueShift: binary.LittleEndian.Uint32(vbuf[vsOffBlueOffset:]),
|
|
}
|
|
if fb.bpp != 32 {
|
|
f.Close()
|
|
return nil, fmt.Errorf("unsupported framebuffer bpp %d (only 32 is supported)", fb.bpp)
|
|
}
|
|
|
|
memLen := int(binary.LittleEndian.Uint32(fbuf[fsOffSmemLen:]))
|
|
mem, err := unix.Mmap(int(f.Fd()), 0, memLen,
|
|
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
|
if err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("mmap %s: %w", path, err)
|
|
}
|
|
fb.mem = mem
|
|
return fb, nil
|
|
}
|
|
|
|
func (fb *framebuffer) Close() error {
|
|
if fb.mem != nil {
|
|
unix.Munmap(fb.mem)
|
|
fb.mem = nil
|
|
}
|
|
return fb.f.Close()
|
|
}
|
|
|
|
// blit copies img into the mapped framebuffer, packing each
|
|
// image.RGBA pixel into the framebuffer's per-channel bit layout.
|
|
func (fb *framebuffer) blit(img *image.RGBA) {
|
|
srcStride := img.Stride
|
|
for y := 0; y < fb.height; y++ {
|
|
srcRow := img.Pix[y*srcStride : y*srcStride+fb.width*4]
|
|
dstRow := fb.mem[y*fb.lineLength:]
|
|
for x := 0; x < fb.width; x++ {
|
|
r := uint32(srcRow[x*4+0])
|
|
g := uint32(srcRow[x*4+1])
|
|
b := uint32(srcRow[x*4+2])
|
|
px := r<<fb.redShift | g<<fb.greenShift | b<<fb.blueShift
|
|
binary.LittleEndian.PutUint32(dstRow[x*4:], px)
|
|
}
|
|
}
|
|
}
|
|
|
|
// claimVTGraphics puts the active virtual terminal into KD_GRAPHICS so
|
|
// the kernel's framebuffer console (fbcon) stops drawing on /dev/fb0
|
|
// while fbstatus owns it. It returns a function that restores KD_TEXT.
|
|
//
|
|
// The Linux kernel applies VT mode to whatever VT is current; the open
|
|
// path /dev/tty0 always refers to the foreground VT, which on a
|
|
// headless gokrazy appliance is the only VT.
|
|
func claimVTGraphics() (restore func(), err error) {
|
|
f, err := os.OpenFile("/dev/tty0", os.O_RDWR, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ioctlSetInt(f, kdSetMode, kdGraphics); err != nil {
|
|
f.Close()
|
|
return nil, fmt.Errorf("KDSETMODE KD_GRAPHICS: %w", err)
|
|
}
|
|
return func() {
|
|
if err := ioctlSetInt(f, kdSetMode, kdText); err != nil {
|
|
log.Printf("KDSETMODE KD_TEXT on shutdown: %v", err)
|
|
}
|
|
f.Close()
|
|
}, nil
|
|
}
|
|
|
|
// ioctlSetInt runs an ioctl with a single integer arg, like KDSETMODE.
|
|
func ioctlSetInt(f *os.File, req uintptr, arg uintptr) error {
|
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req, arg)
|
|
if errno != 0 {
|
|
return errno
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ioctlGet runs an ioctl that fills a struct of len(buf) bytes in buf.
|
|
// Used for the FBIOGET_* ioctls; on success buf holds the kernel's
|
|
// fb_*_screeninfo struct.
|
|
func ioctlGet(f *os.File, req uintptr, buf []byte) error {
|
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), req,
|
|
uintptr(unsafe.Pointer(&buf[0])))
|
|
if errno != 0 {
|
|
return errno
|
|
}
|
|
return nil
|
|
}
|