From 000aaed92544646b040e9a1f9f114477f82fce23 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:09:30 -0400 Subject: [PATCH] fix(console): fall back to the kernel's 8x16 font on a small framebuffer The prod cmdline fixes fbcon to TER16x32, so a cramped on-screen framebuffer (a small panel, or a management console's low-resolution GOP mode) can leave the VT with fewer rows and columns than even the dashboard's compact render needs. Nothing checked the real console geometry against that floor before drawing. cryptos-console now reads the VT size at startup and, when it is below the floor RenderDashboard already uses to drop into its compact view, requests the kernel's built-in VGA8x16 font via KDFONTOP before the first draw, then re-reads the size. A failed switch is logged and the original size kept, so the compact view still runs. The ioctl sits behind a small interface so the decision logic is unit-tested with a fake switcher. internal/console now exports NeedsCompact so the console binary can reuse the existing floor instead of duplicating the numbers. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- cmd/cryptos-console/fontfallback.go | 50 +++++++++++++++ cmd/cryptos-console/fontfallback_linux.go | 64 +++++++++++++++++++ cmd/cryptos-console/fontfallback_other.go | 29 +++++++++ cmd/cryptos-console/fontfallback_test.go | 75 +++++++++++++++++++++++ cmd/cryptos-console/main.go | 8 ++- internal/console/needscompact_test.go | 45 ++++++++++++++ internal/console/screen.go | 9 +++ 7 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 cmd/cryptos-console/fontfallback.go create mode 100644 cmd/cryptos-console/fontfallback_linux.go create mode 100644 cmd/cryptos-console/fontfallback_other.go create mode 100644 cmd/cryptos-console/fontfallback_test.go create mode 100644 internal/console/needscompact_test.go diff --git a/cmd/cryptos-console/fontfallback.go b/cmd/cryptos-console/fontfallback.go new file mode 100644 index 0000000..420c025 --- /dev/null +++ b/cmd/cryptos-console/fontfallback.go @@ -0,0 +1,50 @@ +package main + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "log" + + "github.com/CryptOS-PKI/cryptos-node/internal/console" +) + +// fontSwitcher abstracts the console font switch behind an interface so +// ensureConsoleFits is unit-tested with a fake, never the real KDFONTOP ioctl. +type fontSwitcher interface { + // setDefaultFont switches the console tty to the kernel's built-in + // default font (VGA8x16, CONFIG_FONT_8x16), which leaves more rows and + // columns than the prod cmdline's TER16x32 on the same framebuffer. + setDefaultFont() error +} + +// ensureConsoleFits requests the kernel's built-in 8x16 font when cols x rows +// is below the floor the dashboard needs to drop out of its compact render +// (console.NeedsCompact), then re-reads the size with readSize so the caller +// draws at whatever the switch produced. A switch failure is logged and the +// original size is kept unchanged; the compact view still runs at that size. +func ensureConsoleFits(sw fontSwitcher, readSize func() (cols, rows int), cols, rows int) (int, int) { + if !console.NeedsCompact(cols, rows) { + return cols, rows + } + if err := sw.setDefaultFont(); err != nil { + log.Printf("console: font: %dx%d is below the compact floor; VGA8x16 fallback failed: %v", cols, rows, err) + return cols, rows + } + newCols, newRows := readSize() + log.Printf("console: font: %dx%d was below the compact floor; switched to VGA8x16, now %dx%d", cols, rows, newCols, newRows) + return newCols, newRows +} diff --git a/cmd/cryptos-console/fontfallback_linux.go b/cmd/cryptos-console/fontfallback_linux.go new file mode 100644 index 0000000..627f524 --- /dev/null +++ b/cmd/cryptos-console/fontfallback_linux.go @@ -0,0 +1,64 @@ +//go:build linux + +package main + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "unsafe" + + "golang.org/x/sys/unix" +) + +// KDFONTOP and the console_font_op layout (linux/kd.h) are not exposed by +// golang.org/x/sys/unix, so they are declared here. +const ( + kdFontOp = 0x4B72 // KDFONTOP + kdFontOpSetDefault = 2 // KD_FONT_OP_SET_DEFAULT +) + +// consoleFontOp mirrors struct console_font_op from linux/kd.h for the +// KD_FONT_OP_SET_DEFAULT operation, where data points to the font name and +// the other fields are unused. The pad field matches the compiler-inserted +// padding before the pointer on 64-bit (amd64, arm64), the only targets this +// binary builds for. +type consoleFontOp struct { + op uint32 + flags uint32 + width uint32 + height uint32 + charcount uint32 + pad uint32 + data *byte +} + +// vga8x16Name is the kernel's built-in 8x16 font name (CONFIG_FONT_8x16), +// NUL-terminated as KD_FONT_OP_SET_DEFAULT expects. +var vga8x16Name = append([]byte("VGA8x16"), 0) + +// kdFontSwitcher switches the console at fd to the kernel's built-in default +// font via the KDFONTOP ioctl. +type kdFontSwitcher struct{ fd int } + +func (s kdFontSwitcher) setDefaultFont() error { + op := consoleFontOp{op: kdFontOpSetDefault, data: &vga8x16Name[0]} + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(s.fd), uintptr(kdFontOp), uintptr(unsafe.Pointer(&op))) + if errno != 0 { + return errno + } + return nil +} diff --git a/cmd/cryptos-console/fontfallback_other.go b/cmd/cryptos-console/fontfallback_other.go new file mode 100644 index 0000000..f655742 --- /dev/null +++ b/cmd/cryptos-console/fontfallback_other.go @@ -0,0 +1,29 @@ +//go:build !linux + +package main + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "errors" + +// kdFontSwitcher is a no-op off Linux; the console only runs on the node's +// Linux framebuffer console. +type kdFontSwitcher struct{ fd int } + +func (s kdFontSwitcher) setDefaultFont() error { + return errors.New("console font fallback is only available on linux") +} diff --git a/cmd/cryptos-console/fontfallback_test.go b/cmd/cryptos-console/fontfallback_test.go new file mode 100644 index 0000000..a410007 --- /dev/null +++ b/cmd/cryptos-console/fontfallback_test.go @@ -0,0 +1,75 @@ +package main + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "errors" + "testing" +) + +// fakeFontSwitcher is a fontSwitcher fake so the fallback decision is tested +// without a real console tty. +type fakeFontSwitcher struct { + calls int + err error +} + +func (f *fakeFontSwitcher) setDefaultFont() error { + f.calls++ + return f.err +} + +func TestEnsureConsoleFitsRequestsTheFallbackBelowTheFloor(t *testing.T) { + sw := &fakeFontSwitcher{} + cols, rows := ensureConsoleFits(sw, func() (int, int) { return 80, 24 }, 38, 11) + if sw.calls != 1 { + t.Fatalf("setDefaultFont calls = %d, want 1", sw.calls) + } + if cols != 80 || rows != 24 { + t.Fatalf("got %dx%d, want the re-read 80x24", cols, rows) + } +} + +func TestEnsureConsoleFitsSkipsTheFallbackAtOrAboveTheFloor(t *testing.T) { + sw := &fakeFontSwitcher{} + readSize := func() (int, int) { + t.Fatal("readSize must not be called when the console already fits") + return 0, 0 + } + cols, rows := ensureConsoleFits(sw, readSize, 40, 12) + if sw.calls != 0 { + t.Fatalf("setDefaultFont calls = %d, want 0", sw.calls) + } + if cols != 40 || rows != 12 { + t.Fatalf("got %dx%d, want the original 40x12 unchanged", cols, rows) + } +} + +func TestEnsureConsoleFitsKeepsTheOriginalSizeOnFallbackError(t *testing.T) { + sw := &fakeFontSwitcher{err: errors.New("ioctl failed")} + readSize := func() (int, int) { + t.Fatal("readSize must not be called when the fallback failed") + return 0, 0 + } + cols, rows := ensureConsoleFits(sw, readSize, 38, 11) + if sw.calls != 1 { + t.Fatalf("setDefaultFont calls = %d, want 1", sw.calls) + } + if cols != 38 || rows != 11 { + t.Fatalf("got %dx%d, want the original 38x11 kept", cols, rows) + } +} diff --git a/cmd/cryptos-console/main.go b/cmd/cryptos-console/main.go index 54bb2ea..b72bb05 100644 --- a/cmd/cryptos-console/main.go +++ b/cmd/cryptos-console/main.go @@ -44,7 +44,13 @@ func main() { // Query the console size once at startup; the kernel sets a large font so // the frame fills the whole screen. On any failure this falls back to 80x24. - cols, rows := consoleSize(int(os.Stdout.Fd())) + fd := int(os.Stdout.Fd()) + cols, rows := consoleSize(fd) + // On a framebuffer too small for the prod cmdline's TER16x32 font, the VT + // can report fewer rows/cols than even the compact view needs. Fall back + // to the kernel's built-in 8x16 font before the first draw so the real + // console geometry, not just the reported one, has room for it. + cols, rows = ensureConsoleFits(kdFontSwitcher{fd: fd}, func() (int, int) { return consoleSize(fd) }, cols, rows) ver := buildinfo.Get().Version // Dial the local socket, retrying on failure so the console comes up even diff --git a/internal/console/needscompact_test.go b/internal/console/needscompact_test.go new file mode 100644 index 0000000..024bd1d --- /dev/null +++ b/internal/console/needscompact_test.go @@ -0,0 +1,45 @@ +package console_test + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "testing" + + "github.com/CryptOS-PKI/cryptos-node/internal/console" +) + +// NeedsCompact must agree with the threshold screen.render already uses to +// fall back to its compact render, since a userspace caller outside the +// package (the console font fallback) needs the same floor without +// duplicating the numbers. +func TestNeedsCompactMatchesTheFramedFloor(t *testing.T) { + cases := []struct { + cols, rows int + want bool + }{ + {40, 12, false}, + {80, 24, false}, + {39, 12, true}, + {40, 11, true}, + {38, 11, true}, + } + for _, c := range cases { + if got := console.NeedsCompact(c.cols, c.rows); got != c.want { + t.Errorf("NeedsCompact(%d, %d) = %v, want %v", c.cols, c.rows, got, c.want) + } + } +} diff --git a/internal/console/screen.go b/internal/console/screen.go index d47e5be..c047917 100644 --- a/internal/console/screen.go +++ b/internal/console/screen.go @@ -209,6 +209,15 @@ const ( minRows = 12 ) +// NeedsCompact reports whether cols x rows is below the floor a framed screen +// needs, the same threshold render uses to fall back to the compact view. A +// caller outside the package (the console's font fallback) uses this to +// decide when the real console geometry is too small, without duplicating +// the floor. +func NeedsCompact(cols, rows int) bool { + return cols < minCols || rows < minRows +} + // bodyLines lays the screen's body items out as rows within a frame inner // columns wide. This is the part of the framed layout that never gets // dropped or shortened, so its row count against rows-3 (the interior rows