From 4c22d22df556c404aa8301a1b7c2ca645d13499f Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 1 Jul 2026 15:01:06 +0000 Subject: [PATCH] cmd/fbstatus: add framebuffer status display for the Tailscale appliance Adds a Linux-only framebuffer status display (cmd/fbstatus) that draws to /dev/fb0 on the Tailscale gokrazy appliance. It shows: - the Tailscale logo - the current tailscaled state (starting, needs login, running) - a QR code with the login URL when enrollment is needed (triggers StartLoginInteractive automatically so the URL appears without user action) - the LAN IP or "Waiting for DHCP (MAC)" pinned at the bottom-left - Tailscale IPs once connected VT switching: Ctrl-Alt-F2 drops to a busybox text shell on VT2 (for debugging with a USB keyboard), Ctrl-Alt-F1 returns to the GUI. Rendering pauses while the text VT is active. On boot, fbstatus pokes the gokrazy unix socket API to restart the breakglass SSH service (which uses DontStartOnBoot by default). It waits until DHCP assigns an IP so breakglass binds to the LAN address rather than just localhost. Included in the tsapp-pi.arm64 gokrazy build by default. Updates #1866 Change-Id: Ifdce4ad8e8c2e1005c840f579e637974a0a266d3 Signed-off-by: Brad Fitzpatrick --- .gitignore | 2 + cmd/fbstatus/fbstatus.go | 763 +++++++++++++++++++++++++++++ cmd/fbstatus/fbstatus_other.go | 17 + cmd/fbstatus/tailscale.png | Bin 0 -> 14069 bytes flakehashes.json | 2 +- go.mod | 2 +- gokrazy/tsapp-pi.arm64/config.json | 5 +- 7 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 cmd/fbstatus/fbstatus.go create mode 100644 cmd/fbstatus/fbstatus_other.go create mode 100644 cmd/fbstatus/tailscale.png 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< zDM-i0GrT{q=a2aQvb*;^edf%Z>zs4#OI>X>8cJ450051Ky0RVsNca;8D9GT~j(7hN z0FpHt$_fVFi1i5zCyV*Sjeo>ZPHql+C$%4Kx2=7{*mUXR8Pjfbs+buM1oyqk=ulTM zV9iF;541OBDXe!?@&xB_I`B7kq=*^nOY5Z@Df2VwslAjwzg)xnh0-nJ(&w*=Wj1Pz z(XCHe`6-HqOzTVc!^_w4w!ZZ@MW$XqSmPy|sonD-)GrcmI_CYvO8oxVY&CiC$iZQL zm6xTBaHDL&D)H{Nqh6CGGH|5|qRgI7yHE5O%f0&_vjn80@7 z!uv~@#& z7363Lq5Ge!2zeM3bKmKZx0}-OreHucDDAQwnHUZ4Kf08}w0nhVx8JFNNremeweRn`AU|=D4 z&Y@UaTbtD{%W;(IcXDh;dSC^_6|xKm7oTOxjXiXKNF+YLS&p6{Q+~?t(Xk8 z5wUNNNwC5WWyu-Kr#1UnP~iIIRmvK5;}l;KEG-`nOec=y3T*m43?Qf|X_8>i{$^l! zov!iZ=Wr7psQVpDPJ!$=4F+57aBXccFc+408UMTq&PxlNLu)dH4{UVDpdi9^^0R2Zn3ih_=4cmoefu|#M>%MobS*_B| zB7ibq4x-O=f4|{iZ#`ll$JkEj+8s||zmEd(w_^G zB~jqs)iTo<_Dy^e!crJvUt3?-M#Lh(RY8rdCwSWDlj~HQ!rUl=A0};{2mM}0IR~#<0B~W=@j`M+&|Swc zp?_#&02q(Wur%kH_5iIB^&?_r0IPPUm}N)_Ta3WY(1Fd5t2_EQo+qLt*BJmG0brvm&muojfJ0A$`3gCp_@vlyaNwPOH;?c;)M=>; zX0xIK6(YZOb9j*8A$-j9NFL|0lekh@G9BbJ`juk}sAMMnFE zJ$^QgMxmcs&lHMlRE*OBEZL8Wn{M}f!SFxW!ffCu02@xzfJTA@0su?D8zf0-A23XB zZ50jT+u#K{dS%Gbc52t34m*e!(x`I=AmvN0guJYrbd`yP`As|j{-KLt5RU-E@9H+l zmsZaJuOqm1`prlF7;K#oqQ78Y{vH7L#D3$=DVN&Lplf$AJpeR(=#NGE5V}+91QDCw zRsi^1$jSCMrCd5Z1M=Wz_^jmj^z=M{%2~3js}l?C@hH%JPPU#uB~ugp@cDcifWXy5 zLxijuveos#=IJni>h`Xs+%-AJvUc<>O`xnPGOL_W`mX>=E5)jW@muw|F3P1}XBxJ) zwxl9YgjFB4yJCtk`wPB7L14ds^+NEMk-|snm24H*CgU6%vb-PBdWk4DzC#Wxv{eDC z)_9QV{I;wgu{1RC5)SEWdhVVk$AK0(_UIwzAqs$?*bK3ipJSJtr9qlT0Nt(TtVh3?>Y18kvZ1<2(*TDmgSvDy3PlX=^kdlmG z<&+IPkHE`mCLBvcik+XISEB*oy4@EGZ2ksGt1J-#+|2a&qr8sO8ve=vO--n`;M050 z6*{luS_^U@4Et_yHZ`kXjXJU*#U3?3FM{30MtN4Ve9Hk13vhn8=z%rVgrAi$f?Fdd zpI`&`s-oy2R;^gL!6b8Il^MX>j9gGlh7bk-mcgJ{X0kZb2AF8a6fgs@sdi3tp92Nf z;o@*H@t(O8&M0)<+vq*maow`F1p1g39V>GR0kCG$7Bt&T}ttM~+B!HV&OLD(}4z#tM z?Cp2^veRz<;2w$_w$zC4-}6LpKRgAc@-58)uCvmdv#rjd9cqPo^WfxZH)nF}2o%Hp zyN0u{68L$q;}TJ?TAl>HGO=_Q!44p>0`NVBUbY2L9|05q9j#Yv_||U^zN8O(GArV8 zlUhBNJgu$m9L-W=UioA^rf$7#Y!E+E>NvFaR!6RIb*O-C{PXP7?GC9$F305x|ARka zWaUdH6CQn9T3r%}Hqy4A9G8sr_}B9v?Cf$$-~vXlm;TmlYqN22l@}Hk;#RHEE6P06 zzuyZSwp4G_ISxFM5V^J_&vAjH*rNLByo#08xzs!c4iOF+j^h1EUlF1NsqIyd$nfpF zgQW#?29C=ifirQ-2MDcklNGCFaQim9`rEn7p-b7`S#C>8L3hegx>Dj-9k4#f>*d=8 zJu=cRUQ_;F0pouwW@%T`VN)@l{Bn<1%ihbwo@~8UcY-TILPBuEh?unH+4qNs=e7$X zrUZ4g<=)0~h$?HM-h_vdeYQM{Hxf5{2G_ie2xnPXSgx4B5b?4iM|pfCHM@gmhmU_O z?1<$`e7Tx3o4y?d&Xwmb$s1OBEXUQkm?NhtvFbEcSVr-&k4Z_LL0DV9b0U@x+(rRY zwC_f(lvO+nZ>`SG9g^#|u@m}#>KBuIw`L_@L|b$d9ryoE9ENp@JxABDZjictL0y%Q z*k#b{LxtMg>%XDgOrgREFuwasE%2@M^X%TyYQY!|SCcBij;wsD_$qNuV0swc&cXF^ZmupDzNMV28U?mG@IvGwuFK67OS2kpT1KyuDj zshvvomfKt}lw;aEJ39O?5sMCLvwvw=AWbAmI!aA3GSs&P43y>eE4yy6472t$nXZ2) z=V~`2xe9~@k+b?(fr?W;^+}+Zf^ctDvp;?iWUzVFSdJqcq>G=I(F*WBFFL4m)C-1 z{O85Q#P%*@wk|K^tdEsDx))m1`Q3=8|7O!dJ64aWcOEP2@JpY+)H9z%p&!FbEQr67 zMA~o>8`zs6IyKtT!AD|mG{2(y)so*fng^E~Pr7R7@RV(=JL_Q8Zr9;u9+M}P#8YmQ zhxjjN!G{kN0MVr9wgRW-=lfzh)N%YkJZtv8(CoQvnS4}$; zyqUe&Z+aO?M+d>4xaZh@$ADk-j2)`h_G1nWG|P+qS|1gMb9!C|G)D($*ODs<6G$go z`9l5K3r+I9@DV^)l7y$kv-N3-DvP3MPXnudf}y-4MU!&zO!VGx$rtUgzmC|e4?-{{ zgb_ilm^(rH;@_IDvNvDSJmnrL_#z!Rl>lnb&0=}^FFn#Zx>c-h z<+@YMbo9~x@8nPBGL@FdbU;jBLr`e8|1$3~5OJ}bd26C%rXXMan(fA}WkmXJ{aH=K zcasqaEgVLsg_vSY$kU#`4l!sEY&LzM(DbuK7`;ZSJHf_!JJ2?@3t@{ldUk@Yhc5w^yK?weMl|fh@h7X4B7`+$ z!HT`G%GK-Rl+4W0bn>|FZcg}kRo?^9^HQGjnTK{olv|ku&_M&xG0jD(VK-D@B{k$$ z=h^HycNvKdTDiJhAoVlUq_zd6+X{;@PjIuA+~SI&T@G$`J4 zb}<`u$N8xe&FAIQHLl$Pn~aw&9Qn`Z*U2%y9W~Cg-B0=sNBwWRo}oWm zjE*)oKCgtPsgzFcpkW$LHB($5HF5k?TgZcefXVJpZ7Ls3UEC>4B9#c|f%n>a`IKbt z;uD^pdm5sqUAu~nm4obwr|VCHo2opfIt4FR#m*|!FPO5t*@gD{d+`v0h!6AfvmGpTZ*IH=2$S{qPqfpac!PYt| z@8=U(na(8dDf8vhS2!}zxe!<7k!ZmD?hz`+HTFUe=~dtL(xLXtB!BPP+iY7g!)vZP zWoOljIBK1a_|;;~>U$2Y^0P(q$y%V}B!Vuh*uEXB%Xx_+hY?p8!q#pGu zWMZ(2bs7xM^Ho}vze$2^$fBax6z%C^{f%XW-~T8dp3P3a_#r>R<={ugz$QIFrdgly z(HpwQ*n2na)sRTQKPoB<^;E4LPU_mL&0CX^rxD!fCP9IImieZLb3yDLrNInak$NqH zKNDJ(7JGfKDYGhxf9O7}boa={Kp@;YGilNd01$fhq%{469?s+ z4e?9+LrTZ0Vji9LoSqwHu2XW=v#(^@4W*5wZAHn~O-_UJ>46$N==%*9sa4mreYY#S z{5vZ*Gj?Z&tS}QfYR9xOIjLrCOsNwIh=~h8SzGA$^73*j-EDrHPy1K7IN7HQw-k83 zd3L3D=g}tY4bl(Kr~Xet0F#Hh zL2t>GBDzS-hfzD4y`m-H*M)a|t(I=PgkFG5IA^%;A*RT|ijSWSb)g3Uyr8E;Apy4{g#6&VGW__z_Y?D=foyHGv~6!~#AdAAwX6QD zf33eq|JjAbZ7VB0eq+0!z_RwyVpnpPyiG+sN4W(9!{Opgg^-}&99PrhA8t%7{PHd% zho$(wn=^?-B9|Y@kvQ#%l^k(T`GtXJ|+g5+nL&lMecT8*VK1E zzPb}IzS5%I;w-@+6@^POz!| zvC?e0)uLsAcx5(D0Z!}xF+R;=`^PHcd9UT^VUKb1C%A)uHE(}Bc7&jxtA0B%^(>(@ z{1x^&m{A5Vq9cNO?y|-)l6+HyOLrDa_(2x@v^M#8zTz$TmulXW^LxkzHu^A!p zCo8i5q3_XhW`}Xr?fq39xnD785(*Hg|4;Yde(4MG@GB$`k5?Q@2AON-Jq*E`{1wbf zXC0EXcOh$Yeay_l#^K*gpB5cnKj)aOaR&DU&DUh*QaOM2cvS>5()sCz%|<=3E=2I% zjEK+1a(-~t6iUjU@*KSWr2{crTW#MNiHb`+XebY%;2EnIJS4#eII2UT^!@c>W6Nhv za)bjLC2w!mdnx91=1w{06$ov4MvjE&aEyZd+pc4l(Vq2KB>_T+H5X;0GC4OoJ_@%! zMaT=eod`N^D?ZX8tXUByhPyBEbnE%|J%tl);`o6M-_jEKPB{h|ErF>Rer-^$1L}3pxyknLC4iSJ*vScU!a)ax^Fie zt{bi)n3^{|>rd0oiQwjrKWq`_e!0<}RU zqaIIm-)=YNClrQAUg5M^%Q2B^cKCGv3O73m7JXn&QvQ2rByK~+Eys^W{%+Ar(Gn)> zHItBVK#v_J=j}JXXs#j~UIqI5{#j}R%7!%y9>R&8@xBi-aeG`Rb0F+XAq_G5k*ew_P7^?OIy zJX>u!#h3tv5&@!@_SolA>T=Y#Rz}@qcPVSnKFX`^46C2zc3Au4zb1vmw(t>Fvn7^~ zQhtsSee%7Ox1t*#n}zAlofI)g4+{L1+W3mwP;)bD*&X3+$-VKR2&W2RU`Sf&GI6)Q z-H^R_(45xgZrOJqLDWGMn7&e4)$^&X_zV*5C`?{1yFX!P|EjRn>VM#{jmv6fDfj2R z>ECaaN25!%%5NN_{=WDRc}GLkxvIqD&@nI+3ekG3>QMyjY;a<9tkgZTGE}j5B>&|p zcbb+w*$-+s6*z++JzwF@TNKU^bJy91jzN46`{A4kUTtq0dNDgVvZsR?HJ`OMZj`7| z4S*o?1?*z4=gvtNV$xCpPedzH#gO2+a-m8AJ&>N2*kV8cW_q$;o#sv#-N)(qk>Dyf z;~5~#1Gv?V3oIpu1O)||N6q4rr9hAZ*o=;7>VQ2jF+FF4SbmefDX%yuB4U9Rf^^|J z)laU8LgsZ&n{+U%!x+=a#~bkEUqzu{4@<$DWVoB>NlF%HR^17>XG*?n6Pt7!jxddK zvq@tpDWc#tkctc+k z&s3aQ$qoKqBw=jDRvSyw0bZawLtwY=`IV`=cRM8wWB$&A_b*|Tgym;-h{#tNG;Uk2Y6eq|CY zHY@JnCfg(*je3{daqgjTnf2#niC~EbQ%dcsO?%tTSU?|V68^HW)0peoPsP;l5}}^+ zx{3*m8{e6$T;dOl|A|1r4jiyF_^+U}Ln%IraUvnw&g5uBk zQy^I1`B~C-ORrLi)Z8o7rSP7AZ(KtQy@Kl*2@ApWNROHl^B?WbettWbY$lZ=)3PRj!F<}(`-DKx_M-FeljeaaqgbGQ<=A1!6D~2Oe=@`R{7bx z=ze6IV-^nt=BT&Nv1A$d$q-BdG^&O2gB51^jEu7Esx46sgNgX#{V{0nsdvrWWEe)S z(FD?vfH`q)Egc=YnhR)%($7;+KglyyQ7R9K+SLfHO>x(T zK0G`eUs-I&^1?^GMWacX1Hn-rz{hzbZ>H<(_~5U*bDXo(c!hpxwS!&i zq=?2o1Yl`;8u|}-_^*$_JmviWIpXfX6mc?NSa<=3f@MQ&4#GDFy~m$w-JVO1m^EV0 znW?y6;ZftlUcP4HOx(%bo*EjGt`FZ1pAGFe4`5K8=PyJj=wJ%?Tloe2_`mm)(25lv z_uKAxx|&x$3X_V2$9-B-BhKX$A25T-Omeu*AGmFp9oQ-;Ft25|h>XX2q9eiC{?E`D7wP3-C?*xohPIN$b^fFZPWCd(%iImvVuN1`kl z2xC9xI&z1+#9a5>>s>pkQz&VT|8ghs-5^ad^*N@6!)w^cKE-E$!pr&o{qK`7h<=kh zWk39R!D~F7Y{}Pd*HA^QoOa?(2k5k2px=2(c|74Ym6&WNy}_7UuDA#zrJYiVUT85A zkgLb&Porwa5j@%hcK60y7g3DG@ALG(%j5d>%j~+6Zj$V@y=1t&_ES@2{ys3%XY@~W zHKQ3`&k{@7Uhj~a;`O#+LV_p!IOz9btjIsxGJlfP2_Zn7Km0w2y!>Y06d!3hMC-6I z+m}X6m+by-qNxvn9 z*FE3+Mn$|E{P(2U+{(Rv^}Jghy3BEp#QTO=iK$9q?GIaCbNy!``uXeyN-DeWJBD;~ zh#=9*{m=VzF);72fO4kw!|x7#;fgD%BSIwZJ-v!cNOx&~_dD;Ar>*m5-{HcOZkIHd z2FHTh=8W+dk&#iaZ+Q7!DjIBUZH@Yz`3fC*a&zFfz3LrL?(;*%2}*n| zbi$)CKh-%TnJ~3s{;6&LnHd>tTk~zfSao`E{v;-!@bTj>SKgTm&ISh9m(Hr$ABcTA z^3LVsPa+OgF^lztt=M8tn==C!gI0?0O;$LSx{)rmqhkRN0XNJ}k6P%r(*T({Lu?WW z=z&2NTe-e9WmI3fW~`Hg!!O0mnF0$!UEh(HAP>4pe{nRKu$&y%DJ5kS`S{<8#Z|2vz9{#<8X$ zia}1TP4y`PtbUcB3fX+|vihgBiu z!V3=~x-=1mo};V|&qVuaUW=XwB-{%KjqsM-Bh^@kl@FDLdQ=lF7ugdX3qzg%-Egy! zK%&8pTXGyZ?;Wt|tlMkjZlrn2x2=7qtv$kPxE9y~*8t zsHGYdWt6KbeSwnlS1l)_RMPxJ%0+{UF~a+gZX> zsX_!hRKfJE3wjHEDRW2H(CsorvyYSIUFI1bm=fa9ti(Tv@odsI7R)WlblIMDCCP>k zY-zor^>XF&=ny@1QmP;SCNktvqoo@;{FavKB=BhmkLi#P5L$X@UEfM&ffOPKT_=aUK=lxvh#byw&lQ zY{RX$ba5tEIku{hFcshUCL$IUy4AWIa9?oC_=-lKN|4v}Wf&w!q5o~PH_@K*bsnAF z{iknqEFg&RRu^))8?#=?4E!oaI=)ph#|+O6CN*Dhp<4Sd0#7)Jz{6fyG(mv%ZRkWa zC{e4QSMP?MaT`zqCRDeVAi`r|YXSr|wD%wY+1$!NPUv2~ z@RK`duJVxplI4@Q{}6Oa-2X{D`BzJ_gM^(z3?o+Jg+KB{`2@n00`zJ4O|yR{g468h z$kReXNR@n{+d=ZT6WSf6Ag`|%&F(@luX>ry`PZH~d$b91sl5p_R1UIm0J?OC_ZNZA zYPOH<%{kUW3KhiCaHV!1uuubrr&CwzNp=>dlL6|=V${tS6Yw0Nj)F=`>2L9 zpzW5t6mpB>@*m1PwWBA$2LEmNcj>yX4U29ER%zxwE7;_5`ciuy7*JbU>)!pBFokRO zdyG4HTm_7h@xC4oyeb8%iE4jePnU)h6v(*&^{vtl%r@<$NGycq>nQQIC!k(^^WPva`(u=&mYwbrm zCa-31)bS3POQK^t0Uw*T{8_G;oSR$vhK(5|#eMYApj17}fF3r$2)aFezJG`638hUx z_rFCMZuy$QidRL2NV9gi8vW_}wO=`0LBkBFkcWlmlYcWR5rNI`l8Ly*(0g+h7$o}M zyWW8$Y72Y$%nwNG3VyWwmw-XtC0S-A&6^*R#Uz_JvEe+-@l0XkU;EH$BkJ~vuk4cj z2O-FDP3`T0|ZIc;~j`TB%j&xxTry@ov=P~$xH zB&;$Zqd@*JEku1orS`ItxliBq#wrX}Rdp*LlTC;S=eho9ETlx$G*>AU=k(M$no5Xd z$?lZIFyl@Vb)G{m1@e6Q36lqk>ZJP4#O??TPa1v7-#@0WU3wyE z%$UG13G>ydF>jb_ShJIl_27d7eI^@pRhR z+PcxD?$Fjfk?b=O*F}unJKI9qk!Gm7H|~|}JXJSgo*_&9I$+@Stltuyx5V1i@} z=NRHLbj7~@XeH<75+CjRrlzLF-KJ6C7RzJL8W$eCvC@#1vSmfwwUQ#!?H}wdWlsSq z+|%dr`2MYWw?EHQRY?tFyDM3%BsiC9)1z5@yI-lswA&)4$&a>F0W%ke;p@i*Jd{lh zX$lT74`ZH!uz&hgk}I-exz#7$BB%9H1EC#7cfF6M`(JOKzm@l&5Iz{WWK1>Na7c8_ zCkeD3-I# zF%n(rciqDB)&59Y#c7iM{F>#Agxo1{dWXH#527KM?Y)gD*RUk-#*EBNXL|A3Aw15; zFZ_PN60Y}I#b6|Qk*xW18lHG_fV^dX|6LY zJ!IBpGti{tCr~Tbrq8C+yTORKFDIG7S}87NB;dA=FguA5)!JmOO#P#KPPtyk-wby= zUtM|7)z!sN^ak=b>az{_MHa2j)qnH^d$t@{DQXiX9&}JYhu8T1@`Z&-?HPS(`Hq58T^e3-$gDv)-zC3V zvw8`;YToC53Q~T|`<|&asEV0_1I4pI`@aE6u}{u|8Sk=-Ww(Q38qvixTF?NskzcT7 z|9dAkWh4o~JO)mo&6PnWg&tRx6Np5!S8Um>@QLffI^8gT`D zD=`p-?qZT&b_XZ+&FTLLSs8{Ba;C?b`uh6DMIlH|Dr$hf{?$EJIL~KmY_tu$r&xmA zGy0fI#)o-Spv*Rw8&cKGM$^iqsDKA_H7V4ye?<`n$$=yi3Ftpa7=yX`fw?h$NH}|b z=O*IYo$Oyy%SM47kex@N5cA1pZ!A4y%JFG40NDNBvkAwVLUjqWupb29`&*d_gn+&c zP8Nrw4oIYw(Ym~=k+YE$|EVUiCOZ8z#&jw(=Iz=3E7lQ=g}sTtcm=C5>2i=B_9{|1GNRVy0tZ=nrn z0nxkkmg2D4YrX8*xmtJ7EkyD*?GbE6K5#3&XqOhtN%u(zg=>IqwyPY7no=AfAIhcS zT)Lj|+r3xD2*wIlIL(&QX9rz(Cp>{*J?r!{$j9rv6*FgFJ~ieETJKUqf;p&I^C+Jh zhL2LHOTQdWYWy19v2ya-AeL5bfH~ssUo$EI?qRFOamGtN4EIuRO6)$0&p6>sjz{wr zySuw5kF?eo71E;DF^cElkYNurci1Mg>i^JUZ)<5;l!yyK`7-Czm>&(<&vJmV;fJtk z3g(2zIe?(Af-R^=F4+-_vSAOzk2hW5VD@FbZ>BuR(G@TPf#n%#T(gp#Kx7fSM&L{u z=$%R#>eGR4wt=?07aFoNasr0x`ydN-70h9%_Mk>x)lKjHAhUU)Ol6?`U}?#FZ@vVo zblt6vq^>~%=7CwsE-jj1bw&2AC-N(t%h6>G{&Y^+#PZ9C6hc}{(F8noBw8G zsmG7U8*%ORX;`7K4_KzzyWrnDAVJ<2j~P44t=9xn1d+pH@V@7<-}_J+wUGg5OYsAgq#d=+dn;;O9`*v@ux$7c~mMbBPabe z|3@TTk~!Xom~?ZY+ze%gv^PkKKhh{;cWZ!2Tyx27OyxDHeJRO%u*f9+DFzF#6?(CO zMxF^d3?G8qY$j`B8Vhfi7luYCLn=cCXNu4w5?#oj^DR!t8dqL}7sGqR#(}Htbxq(o z(j}Wv3s6@3P&wiZjzdjx4^xa0;`(_;0<*KT?#&r!fF_C2TmF~3w?BUh!zQ`|xXn2! zz>Du!h4&qsUIOlcrl!7N7`@6ez=>E??L^*a{0O*DUc4|DAj}v5dPM-4tHNh7kA(pR z4;&n698rIr1q8z>ZhmZQvid{=6yZTXXx*I=(IyAKl)ygRL~q5R!|Es5Rx(PpOq;6j z+U7rsYhfClgAhRb>%^q7*MW>P=jJ7#eir$A*N*%hq|+MrbFO`vk#|hm38ol zj||%{DETvY+b)p~GQ2B#ojRq)0_dd%KHrxga4r5cMKXN5^e#RN+FF}@I3H@w04t(q zEWuLZ*Uh4DKI3qaz@_ck1|CUp>dh9uWUg2XzVUT{Srs>E#RJD+@&$ z*5ECWLRa*GxLudOFU6oto^of9+FNjy>}Crds+w%-2TVL?o_W=rXB#GO8j1bJ3-vt2 zsTm`E+%lWme#Az~0w|%mw=4S4aT=1=gNl%c2KL1SRRwa83QziQH19{PN5uiIe^<&` z;D1bz6Hv%zrKN;LfmiZP1;(FGfzK}J!BYnTgQw%*d0A1kTy?$p73gMJU2zM3h=97Y zekwrcp(jx#B67$JIiCBU#>o--atFQuac6*CTB;)GWv5*}NGzsM89~^;6QL3mal|xy zAp;bak?RE8&vYO}ki7jt8*<9d6o5tp5u93a6=oC-Od=rZo<43Af6SG+S)dB+1Gxc6 z%3&gW5p~&IB}1g3NCO2){4P+Td}+Y+&Aa#s9^j$jOXBqGP*jo%iA~_cu_=M!6x>%t zG0O)?vqtPz@0(_O2nyf~m4+yQncEkEXkM#7|9-%vROx48Q0`KFyAx)c0?5XM1foot zucMg(RxXPP9}E966SQVr+`d=-3Urh2b~XgA(HuXMq(e4j7#5x1)weyuD}be9>n`+J z3frQ;4T~dE00?DICwDgg5|h04M0!tVcb5%uuVE$dUI9{JkmtZX={5zokw#4WLX(`9 z-IaqNHQd!S22I)9UK$#L&Apa>bVWizDEJ=&j%_sgd*xNP1cL`dI#FuBG|VIT~W?>txjqz(t^*8L;b)D*CK9hr6f?P+pje-&}~ z){?y{M?}Kk{BUPoqZa}~1ofs0(?5x3yE%H<@m)WuN*incZv3F;aKvMp;~qR& TPdE!t8~_?B+RFKg_n!Yh!VnPt literal 0 HcmV?d00001 diff --git a/flakehashes.json b/flakehashes.json index 1deee47a4..cb7f6130f 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -4,7 +4,7 @@ "sri": "sha256-cY5yryX+p/xtoTv+WZEKFagiIl0OREHnJY1Bk5VpVVc=" }, "vendor": { - "goModSum": "sha256-yH8WS3nFwmRtFLhoToIEPAoJz4qIQrOJhOU/HlUMH44=", + "goModSum": "sha256-evStNa6VrP7dYOuUIIj8nFPCFVUJ/GzcAefl2C4h8QA=", "sri": "sha256-UrvJ5fM+Oqgu2pZwhg5AnUcgi8wPwZ8qDwWpXNmKaPk=" } } diff --git a/go.mod b/go.mod index 67f9bb89e..9b949348a 100644 --- a/go.mod +++ b/go.mod @@ -508,7 +508,7 @@ require ( gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/image v0.41.0 // indirect + golang.org/x/image v0.41.0 golang.org/x/text v0.37.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/gokrazy/tsapp-pi.arm64/config.json b/gokrazy/tsapp-pi.arm64/config.json index 6a987c9d2..83af0c190 100644 --- a/gokrazy/tsapp-pi.arm64/config.json +++ b/gokrazy/tsapp-pi.arm64/config.json @@ -3,7 +3,7 @@ "Update": { "NoPassword": true }, - "SerialConsole": "ttyS0,115200", + "SerialConsole": "serial0,115200", "GokrazyPackages": [ "github.com/gokrazy/gokrazy/cmd/dhcp", "github.com/gokrazy/gokrazy/cmd/randomd", @@ -12,13 +12,14 @@ "Packages": [ "github.com/gokrazy/serial-busybox", "github.com/gokrazy/breakglass", + "tailscale.com/cmd/fbstatus", "tailscale.com/cmd/tailscale", "tailscale.com/cmd/tailscaled" ], "PackageConfig": { "github.com/gokrazy/breakglass": { "CommandLineFlags": [ - "-authorized_keys=ec2" + "-authorized_keys=/perm/breakglass.authorized_keys" ] }, "tailscale.com/cmd/tailscale": {