diff --git a/.gitignore b/.gitignore index e1f6be02e..9d570d954 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ client/web/build/assets # Ignore syncthing state directory. /.stfolder +fbstatus +gafpush diff --git a/cmd/fbstatus/fbstatus.go b/cmd/fbstatus/fbstatus.go new file mode 100644 index 000000000..140e9a7c2 --- /dev/null +++ b/cmd/fbstatus/fbstatus.go @@ -0,0 +1,763 @@ +// 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" + "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" +) + +//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") + +func main() { + flag.Parse() + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + 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 Ctrl-Alt-F1/F2. +// Ctrl-Alt-F2 switches to VT2 (text mode with a busybox shell). +// Ctrl-Alt-F1 switches back to VT1 (fbstatus graphics mode). +// This mirrors standard Linux VT switching conventions. +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 (VT switching)", kbdPath) + + // Linux input_event on arm64: {uint64 sec, uint64 usec, uint16 type, uint16 code, int32 value} + // TODO: verify this layout also works on amd64 (e.g. the Proxmox + // framebuffer), where it should be the same size and shape. + const evSize = 24 + const evKey = 1 // EV_KEY + 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 + + 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 keyF1: + if pressed && ctrlHeld && altHeld { + // Switch to VT1 (fbstatus graphics). + 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() + log.Printf("Ctrl-Alt-F1: switched to fbstatus") + } + case keyF2: + if pressed && ctrlHeld && altHeld { + // Switch to VT2 (text console with shell). + 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() + log.Printf("Ctrl-Alt-F2: switched to text console") + } + } + } +} + +// 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. +var shellOnVT2Running atomic.Bool + +func ensureShellOnVT2() { + if !shellOnVT2Running.CompareAndSwap(false, true) { + return + } + go func() { + defer shellOnVT2Running.Store(false) + 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 for EV_KEY capability with KEY_ESC support. +func findKeyboard() string { + matches, _ := filepath.Glob("/dev/input/event*") + for _, path := range matches { + f, err := os.Open(path) + if err != nil { + continue + } + // EVIOCGBIT(EV_KEY) = ioctl to get key capability bitmap + // We just try reading an EV_KEY event; if the device has keys it'll work. + // Simpler: check /sys/class/input/eventN/device/capabilities/key + name := filepath.Base(path) + capPath := "/sys/class/input/" + name + "/device/capabilities/key" + cap, err := os.ReadFile(capPath) + f.Close() + if err != nil { + continue + } + // A keyboard has a non-zero key capability bitmap. + if strings.TrimSpace(string(cap)) != "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<