tconsole: init

This introduces the 'tconsole' (terminal console), the default
interface to show in /dev/tty1 on a Metropolis node.

Currently it just shows some basic status in a single page. Upcoming
changes will reintroduce a simple log dump on a different page, as well
as entirely new features like supervision tree inspection.

To iterate quickly on the console, a 'standalone' target is added which
exercises the console on the user's terminal with fake node data.
However only the actual console in Linux displays colours as intended.

Change-Id: I5cfba2bdb320daa080a073e76bf0494aeab6a4d4
Reviewed-on: https://review.monogon.dev/c/monogon/+/3371
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
diff --git a/build/bazel/go.MODULE.bazel b/build/bazel/go.MODULE.bazel
index 4d69a62..e5de395 100644
--- a/build/bazel/go.MODULE.bazel
+++ b/build/bazel/go.MODULE.bazel
@@ -22,6 +22,7 @@
     "com_github_corverroos_commentwrap",
     "com_github_diskfs_go_diskfs",
     "com_github_docker_distribution",
+    "com_github_gdamore_tcell_v2",
     "com_github_go_delve_delve",
     "com_github_golang_migrate_migrate_v4",
     "com_github_google_cel_go",
@@ -58,6 +59,7 @@
     "com_github_prometheus_client_golang",
     "com_github_prometheus_node_exporter",
     "com_github_pseudomuto_protoc_gen_doc",
+    "com_github_rivo_uniseg",
     "com_github_rmohr_bazeldnf",
     "com_github_sbezverk_nfproxy",
     "com_github_spf13_cobra",
diff --git a/go.mod b/go.mod
index c306260..2dfac51 100644
--- a/go.mod
+++ b/go.mod
@@ -82,6 +82,7 @@
 	github.com/corverroos/commentwrap v0.0.0-20191204065359-2926638be44c
 	github.com/diskfs/go-diskfs v1.2.0
 	github.com/docker/distribution v2.8.2+incompatible
+	github.com/gdamore/tcell/v2 v2.7.4
 	github.com/go-delve/delve v1.8.2
 	github.com/golang-migrate/migrate/v4 v4.15.2
 	github.com/google/cel-go v0.20.1
@@ -163,6 +164,11 @@
 )
 
 require (
+	github.com/gdamore/encoding v1.0.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+)
+
+require (
 	cloud.google.com/go v0.112.1 // indirect
 	cloud.google.com/go/compute/metadata v0.3.0 // indirect
 	cloud.google.com/go/iam v1.1.6 // indirect
@@ -312,7 +318,7 @@
 	github.com/mailru/easyjson v0.7.7 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-isatty v0.0.19 // indirect
-	github.com/mattn/go-runewidth v0.0.14 // indirect
+	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/mattn/go-sqlite3 v1.14.17 // indirect
 	github.com/mattn/go-xmlrpc v0.0.3 // indirect
 	github.com/mdlayher/socket v0.5.0 // indirect
@@ -364,7 +370,7 @@
 	github.com/prometheus/procfs v0.15.1 // indirect
 	github.com/pseudomuto/protokit v0.2.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
-	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/rivo/uniseg v0.4.3
 	github.com/riza-io/grpc-go v0.2.0 // indirect
 	github.com/robfig/cron/v3 v3.0.1 // indirect
 	github.com/rs/cors v1.8.0 // indirect
diff --git a/go.sum b/go.sum
index 895f114..a4bc1c8 100644
--- a/go.sum
+++ b/go.sum
@@ -2014,6 +2014,10 @@
 github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
 github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
 github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
+github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -2719,6 +2723,8 @@
 github.com/linuxkit/virtsock v0.0.0-20201010232012-f8cee7dfc7a3/go.mod h1:3r6x7q95whyfWQpmGZTu3gk3v2YkMi05HEzl7Tf7YEo=
 github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lufia/iostat v1.2.1 h1:tnCdZBIglgxD47RyD55kfWQcJMGzO+1QBziSQfesf2k=
 github.com/lufia/iostat v1.2.1/go.mod h1:rEPNA0xXgjHQjuI5Cy05sLlS2oRcSlWHRLrvh/AQ+Pg=
 github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
@@ -2767,8 +2773,8 @@
 github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
-github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
@@ -3208,8 +3214,9 @@
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ=
 github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8=
 github.com/rmohr/bazeldnf v0.5.4 h1:xYSQoQHuCZY+2mZJtt+2KN0G6TmeEOPR6cxPZomTXX4=
diff --git a/metropolis/node/core/BUILD.bazel b/metropolis/node/core/BUILD.bazel
index c6fdb18..b511bb9 100644
--- a/metropolis/node/core/BUILD.bazel
+++ b/metropolis/node/core/BUILD.bazel
@@ -33,6 +33,7 @@
         "//metropolis/node/core/network",
         "//metropolis/node/core/roleserve",
         "//metropolis/node/core/rpc/resolver",
+        "//metropolis/node/core/tconsole",
         "//metropolis/node/core/time",
         "//metropolis/node/core/update",
         "//metropolis/proto/api",
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 3d2969f..d103693 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -33,6 +33,7 @@
 	"source.monogon.dev/metropolis/node/core/network"
 	"source.monogon.dev/metropolis/node/core/roleserve"
 	"source.monogon.dev/metropolis/node/core/rpc/resolver"
+	"source.monogon.dev/metropolis/node/core/tconsole"
 	timesvc "source.monogon.dev/metropolis/node/core/time"
 	"source.monogon.dev/metropolis/node/core/update"
 	mversion "source.monogon.dev/metropolis/version"
@@ -52,13 +53,8 @@
 	// Root system logtree.
 	lt := logtree.New()
 
-	// Set up logger for Metropolis. Currently logs everything to /dev/tty0 and
-	// /dev/ttyS{0,1}.
-	consoles := []*console{
-		{
-			path:     "/dev/tty0",
-			maxWidth: 80,
-		},
+	// Set up logger for Metropolis. Currently logs everything to /dev/ttyS{0,1}.
+	serialConsoles := []*console{
 		{
 			path:     "/dev/ttyS0",
 			maxWidth: 120,
@@ -73,7 +69,7 @@
 	crash := make(chan string)
 
 	// Open up consoles and set up logging from logtree and crash channel.
-	for _, c := range consoles {
+	for _, c := range serialConsoles {
 		f, err := os.OpenFile(c.path, os.O_WRONLY, 0)
 		if err != nil {
 			continue
@@ -99,7 +95,7 @@
 	}
 
 	// Initialize persistent panic handler early
-	initPanicHandler(lt, consoles)
+	initPanicHandler(lt, serialConsoles)
 
 	// Initial logger. Used until we get to a supervisor.
 	logger := lt.MustLeveledFor("init")
@@ -215,6 +211,19 @@
 			return fmt.Errorf("when starting debug service: %w", err)
 		}
 
+		// Initialize interactive consoles.
+		interactiveConsoles := []string{"/dev/tty0"}
+		for _, c := range interactiveConsoles {
+			console, err := tconsole.New(tconsole.TerminalLinux, c, &networkSvc.Status, &rs.LocalRoles, &rs.CuratorConnection)
+			if err != nil {
+				logger.Info("Failed to initialize interactive console at %s: %v", c, err)
+				// TODO: fall back to logger
+			} else {
+				logger.Info("Started interactive console at %s", c)
+				supervisor.Run(ctx, "console-"+c, console.Run)
+			}
+		}
+
 		// Start cluster manager. This kicks off cluster membership machinery,
 		// which will either start a new cluster, enroll into one or join one.
 		m := cluster.NewManager(root, networkSvc, rs, updateSvc, nodeParams, haveTPM)
@@ -249,7 +258,7 @@
 	ctxC()
 	time.Sleep(time.Second)
 	// After a bit, kill all console log readers.
-	for _, c := range consoles {
+	for _, c := range serialConsoles {
 		if c.reader == nil {
 			continue
 		}
diff --git a/metropolis/node/core/tconsole/BUILD.bazel b/metropolis/node/core/tconsole/BUILD.bazel
new file mode 100644
index 0000000..c8ca99e
--- /dev/null
+++ b/metropolis/node/core/tconsole/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "tconsole",
+    srcs = [
+        "colors.go",
+        "draw.go",
+        "page_status.go",
+        "tconsole.go",
+    ],
+    importpath = "source.monogon.dev/metropolis/node/core/tconsole",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//metropolis/node/core/network",
+        "//metropolis/node/core/roleserve",
+        "//metropolis/proto/common",
+        "//metropolis/version",
+        "//osbase/event",
+        "//osbase/supervisor",
+        "//version",
+        "@com_github_gdamore_tcell_v2//:tcell",
+        "@com_github_rivo_uniseg//:uniseg",
+    ],
+)
diff --git a/metropolis/node/core/tconsole/colors.go b/metropolis/node/core/tconsole/colors.go
new file mode 100644
index 0000000..d1d5793
--- /dev/null
+++ b/metropolis/node/core/tconsole/colors.go
@@ -0,0 +1,87 @@
+package tconsole
+
+import (
+	"fmt"
+
+	"github.com/gdamore/tcell/v2"
+)
+
+// Terminal is a supported terminal kind. This is a simplistic alternative to
+// terminfo.
+type Terminal string
+
+const (
+	// TerminalLinux is a linux VTY console.
+	TerminalLinux Terminal = "linux"
+	// TerminalGeneric is any other terminal that is supported by tcell.
+	TerminalGeneric Terminal = "generic"
+)
+
+// color type separate from tcell.Color. This is necessary because tcell does not
+// support color remapping in Linux VTYs, only static colors, and we have to
+// build our own color system on top of it.
+//
+// Our color type is abstract, it denotes a given colour in terms of human
+// perception, not an actual colour code.
+type color string
+
+const (
+	// colorBlack is a deep black, suitable for use as a foreground.
+	colorBlack color = "black"
+	// colorPink is a light pink, suitable for use as a background.
+	colorPink color = "pink"
+	// colorBlue is a light blue, suitable for use as a background.
+	colorBlue color = "blue"
+)
+
+// colorDef is an entry for a palette and defines a tcell color to be used from
+// tcell, and an optional RGB colour to remap the given tcell.Color into.
+type colorDef struct {
+	ansiColor tcell.Color
+	rgbColor  uint32
+}
+
+type palette map[color]colorDef
+
+var (
+	// paletteLinux is the full-colour palette used on Linux consoles, with exact RGB
+	// color definitions.
+	paletteLinux = palette{
+		colorBlack: colorDef{tcell.ColorBlack + 0, 0x000000},
+		colorPink:  colorDef{tcell.ColorBlack + 1, 0xeedfda},
+		colorBlue:  colorDef{tcell.ColorBlack + 2, 0x919ba7},
+	}
+	// paletteGeneric is a fallback palette used on systems which do not support
+	// colour remapping and only have 2 colours: black and white.
+	paletteGeneric = palette{
+		colorBlack: colorDef{tcell.ColorBlack, 0},
+		colorPink:  colorDef{tcell.ColorWhite, 0},
+		colorBlue:  colorDef{tcell.ColorWhite, 0},
+	}
+)
+
+// linuxConsoleOverrideColor uses Linux-specific ANSI OSC codes to remap a given
+// colour to an RGB value. See console_codes(4) for more information.
+//
+// The function returns a string which must be sent to the terminal.
+func linuxConsoleOverrideColor(co tcell.Color, rgb uint32) string {
+	return fmt.Sprintf("\x1b]P%x%06x", int(co-tcell.ColorBlack), rgb)
+}
+
+// setupLinuxConsole configures a given palette with rgcColor date to be
+// displayed on the user screen.
+//
+// The function returns a string which must be sent to the terminal.
+func (p *palette) setupLinuxConsole() string {
+	res := ""
+	for _, v := range *p {
+		res += linuxConsoleOverrideColor(v.ansiColor, v.rgbColor)
+	}
+	return res
+}
+
+// color retrieves a tcell.Color to use for a given color, respecting the
+// currently set palette.
+func (c *Console) color(col color) tcell.Color {
+	return c.palette[col].ansiColor
+}
diff --git a/metropolis/node/core/tconsole/draw.go b/metropolis/node/core/tconsole/draw.go
new file mode 100644
index 0000000..d88e35b
--- /dev/null
+++ b/metropolis/node/core/tconsole/draw.go
@@ -0,0 +1,85 @@
+package tconsole
+
+import (
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/uniseg"
+)
+
+// drawText draws a single line of text from left to right, starting at x and y.
+func (c *Console) drawText(x, y int, text string, style tcell.Style) {
+	g := uniseg.NewGraphemes(text)
+	for g.Next() {
+		runes := g.Runes()
+		c.screen.SetContent(x, y, runes[0], runes[1:], style)
+		x += 1
+	}
+}
+
+// drawTextCentered draw a single line of text from left to right, starting at a
+// position so that the center of the line ends up at x and y.
+func (c *Console) drawTextCentered(x, y int, text string, style tcell.Style) {
+	g := uniseg.NewGraphemes(text)
+	var runes [][]rune
+	for g.Next() {
+		runes = append(runes, g.Runes())
+	}
+
+	x -= len(runes) / 2
+
+	for _, r := range runes {
+		c.screen.SetContent(x, y, r[0], r[1:], style)
+		x += 1
+	}
+}
+
+// fillRectangle fills a given rectangle [x0,x1) [y0,y1) with empty space of a
+// given style.
+func (c *Console) fillRectangle(x0, x1, y0, y1 int, style tcell.Style) {
+	for x := x0; x < x1; x++ {
+		for y := y0; y < y1; y++ {
+			c.screen.SetContent(x, y, ' ', nil, style)
+		}
+	}
+}
+
+const logo = `
+             _g@@@@g_         _g@@@@g_             
+           _@@@@@@@@@@a     g@@@@@@@@@@b           
+          @@@@@@@@@@@@@@___@@@@@@@@@@@@@@          
+         @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@         
+        @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@        
+       g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g       
+      g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g      
+     g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g     
+    ;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@,    
+    |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"    
+     @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@     
+      %@@@@@@@@@@P<@@@@@@@@@@@@@>%@@@@@@@@@@P      
+        "+B@@B>'    "@@@@@@@@B"    '<B@@BP"        
+                        '"                         
+`
+
+// drawLogo draws the Monogon logo so that its top left corner is at x, y.
+func (c *Console) drawLogo(x, y int, style tcell.Style) {
+	for i, line := range strings.Split(logo, "\n") {
+		c.drawText(x, y+i, line, style)
+	}
+}
+
+// split calculates a mid-point in the [0, capacity) domain so that it splits it
+// into two parts fairly with minA and minB used as minimum size hints for each
+// section.
+func split(capacity, minA, minB int) int {
+	slack := capacity - (minA + minB)
+	propA := float64(minA) / float64(minA+minB)
+	slackA := int(propA * float64(slack))
+	return minA + slackA
+}
+
+// center calculates a point at which to start drawing a 'size'-sized element in
+// a 'capacity'-sized container so that it ends in the middle of said container.
+func center(capacity, size int) int {
+	return (capacity - size) / 2
+}
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
new file mode 100644
index 0000000..13c4b63
--- /dev/null
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -0,0 +1,78 @@
+package tconsole
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+
+	mversion "source.monogon.dev/metropolis/version"
+	"source.monogon.dev/version"
+)
+
+// pageStatusData encompasses all data to be shown within the status page.
+type pageStatusData struct {
+	netAddr     string
+	roles       string
+	id          string
+	fingerprint string
+}
+
+// pageStatus renders the status page to the user given pageStatusData.
+func (c *Console) pageStatus(d *pageStatusData) {
+	c.screen.Clear()
+	sty1 := tcell.StyleDefault.Background(c.color(colorPink)).Foreground(c.color(colorBlack))
+	sty2 := tcell.StyleDefault.Background(c.color(colorBlue)).Foreground(c.color(colorBlack))
+
+	logoWidth := len(strings.Split(logo, "\n")[1])
+	logoHeight := len(strings.Split(logo, "\n"))
+
+	// Vertical split between top copyright string and main display part.
+	splitV := split(c.height, 4, logoHeight)
+
+	// Colour the split.
+	c.fillRectangle(0, c.width, 0, splitV, sty1)
+	c.fillRectangle(0, c.width, splitV, c.height, sty2)
+
+	// Draw the top part.
+	c.drawTextCentered(c.width/2, splitV/2, "Monogon Cluster Operating System", sty1)
+	c.drawTextCentered(c.width/2, splitV/2+1, "Copyright 2022-2024 The Monogon Project Authors", sty1)
+
+	// Horizontal split between left logo and right status lines, a la 'fetch'.
+	splitH := split(c.width, logoWidth, 60)
+
+	// Status lines.
+	lines := []string{
+		fmt.Sprintf("Version: %s", version.Semver(mversion.Version)),
+		fmt.Sprintf("Node ID: %s", d.id),
+		fmt.Sprintf("CA fingerprint: %s", d.fingerprint),
+		fmt.Sprintf("Management address: %s", d.netAddr),
+		fmt.Sprintf("Roles: %s", d.roles),
+	}
+	// Calculate longest line.
+	maxLine := 0
+	for _, l := range lines {
+		if len(l) > maxLine {
+			maxLine = len(l)
+		}
+	}
+
+	// If logo wouldn't fit, don't bother, save space for important data.
+	drawLogo := true
+	if splitH < logoWidth {
+		drawLogo = false
+		splitH = center(c.width, maxLine)
+	}
+
+	// Draw lines.
+	for i, line := range lines {
+		c.drawText(splitH, splitV+center(c.height-splitV, len(lines))+i, line, sty2)
+	}
+
+	// Draw logo.
+	if drawLogo {
+		c.drawLogo(splitH-logoWidth, splitV+center(c.height-splitV, logoHeight), sty2)
+	}
+
+	c.screen.Show()
+}
diff --git a/metropolis/node/core/tconsole/standalone/BUILD.bazel b/metropolis/node/core/tconsole/standalone/BUILD.bazel
new file mode 100644
index 0000000..cd7a653
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "standalone_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/metropolis/node/core/tconsole/standalone",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//metropolis/node/core/network",
+        "//metropolis/node/core/roleserve",
+        "//metropolis/node/core/tconsole",
+        "//metropolis/proto/common",
+        "//osbase/event/memory",
+        "//osbase/supervisor",
+    ],
+)
+
+go_binary(
+    name = "standalone",
+    embed = [":standalone_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/tconsole/standalone/main.go b/metropolis/node/core/tconsole/standalone/main.go
new file mode 100644
index 0000000..65b3976
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/main.go
@@ -0,0 +1,90 @@
+package main
+
+// This is a standalone tconsole test application which works independently from
+// a Metropolis instance. It's intended to be used during tconsole development to
+// make the iteration cycle faster (not needing to boot up a whole node just to
+// test the console).
+
+import (
+	"context"
+	"fmt"
+	"log"
+	mrand "math/rand"
+	"net"
+	"os"
+	"os/signal"
+	"time"
+
+	"source.monogon.dev/metropolis/node/core/network"
+	"source.monogon.dev/metropolis/node/core/roleserve"
+	"source.monogon.dev/metropolis/node/core/tconsole"
+	cpb "source.monogon.dev/metropolis/proto/common"
+	"source.monogon.dev/osbase/event/memory"
+	"source.monogon.dev/osbase/supervisor"
+)
+
+func main() {
+	var netV memory.Value[*network.Status]
+	var rolesV memory.Value[*cpb.NodeRoles]
+	var curV memory.Value[*roleserve.CuratorConnection]
+
+	tc, err := tconsole.New(tconsole.TerminalGeneric, "/proc/self/fd/0", &netV, &rolesV, &curV)
+	if err != nil {
+		log.Fatalf("tconsole.New: %v", err)
+	}
+
+	ctx, ctxC := context.WithCancel(context.Background())
+	go func() {
+		<-tc.Quit
+		ctxC()
+	}()
+
+	delay := func(ctx context.Context, d time.Duration) error {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(d):
+			return nil
+		}
+	}
+
+	signal.Ignore(os.Interrupt)
+	supervisor.New(ctx, func(ctx context.Context) error {
+		supervisor.Run(ctx, "tconsole", tc.Run)
+		supervisor.Run(ctx, "net-dawdle", func(ctx context.Context) error {
+			supervisor.Signal(ctx, supervisor.SignalHealthy)
+			for {
+				if err := delay(ctx, time.Millisecond*1000); err != nil {
+					return err
+				}
+				netV.Set(&network.Status{
+					ExternalAddress: net.ParseIP(fmt.Sprintf("203.0.113.%d", mrand.Intn(256))),
+				})
+			}
+		})
+		supervisor.Run(ctx, "roles-dawdle", func(ctx context.Context) error {
+			supervisor.Signal(ctx, supervisor.SignalHealthy)
+			for {
+				if err := delay(ctx, time.Millisecond*1200); err != nil {
+					return err
+				}
+				nr := &cpb.NodeRoles{}
+				if mrand.Intn(2) == 0 {
+					nr.KubernetesWorker = &cpb.NodeRoles_KubernetesWorker{}
+				}
+				if mrand.Intn(2) == 0 {
+					nr.ConsensusMember = &cpb.NodeRoles_ConsensusMember{}
+				}
+				if mrand.Intn(2) == 0 {
+					nr.KubernetesController = &cpb.NodeRoles_KubernetesController{}
+				}
+				rolesV.Set(nr)
+			}
+		})
+		supervisor.Signal(ctx, supervisor.SignalHealthy)
+		<-ctx.Done()
+		return ctx.Err()
+	})
+	<-ctx.Done()
+	tc.Cleanup()
+}
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
new file mode 100644
index 0000000..52885bd
--- /dev/null
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -0,0 +1,163 @@
+package tconsole
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+
+	"source.monogon.dev/metropolis/node/core/network"
+	"source.monogon.dev/metropolis/node/core/roleserve"
+	cpb "source.monogon.dev/metropolis/proto/common"
+	"source.monogon.dev/osbase/event"
+	"source.monogon.dev/osbase/supervisor"
+)
+
+// Console is a Terminal Console (TConsole), a user-interactive informational
+// display visible on the TTY of a running Metropolis instance.
+type Console struct {
+	// Quit will be closed when the user press CTRL-C. A new channel will be created
+	// on each New call.
+
+	Quit    chan struct{}
+	ttyPath string
+	tty     tcell.Tty
+	screen  tcell.Screen
+	width   int
+	height  int
+	// palette chosen for the given terminal.
+	palette palette
+
+	network     event.Value[*network.Status]
+	roles       event.Value[*cpb.NodeRoles]
+	curatorConn event.Value[*roleserve.CuratorConnection]
+}
+
+// New creates a new Console, taking over the TTY at the given path. The given
+// Terminal type selects between a Linux terminal (VTY) and a generic terminal
+// for testing.
+//
+// network, roles, curatorConn point to various Metropolis subsystems that are
+// used to populate the console data.
+func New(terminal Terminal, ttyPath string, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) {
+	tty, err := tcell.NewDevTtyFromDev(ttyPath)
+	if err != nil {
+		return nil, err
+	}
+	screen, err := tcell.NewTerminfoScreenFromTty(tty)
+	if err != nil {
+		return nil, err
+	}
+	if err := screen.Init(); err != nil {
+		return nil, err
+	}
+	screen.SetStyle(tcell.StyleDefault)
+
+	var pal palette
+	switch terminal {
+	case TerminalLinux:
+		pal = paletteLinux
+		tty.Write([]byte(pal.setupLinuxConsole()))
+	case TerminalGeneric:
+		pal = paletteGeneric
+	}
+
+	width, height := screen.Size()
+	return &Console{
+		ttyPath: ttyPath,
+		tty:     tty,
+		screen:  screen,
+		width:   width,
+		height:  height,
+		network: network,
+		palette: pal,
+		Quit:    make(chan struct{}),
+
+		roles:       roles,
+		curatorConn: curatorConn,
+	}, nil
+}
+
+// Cleanup should be called when the console exits. This is only used in testing,
+// the Metropolis console always runs.
+func (c *Console) Cleanup() {
+	c.screen.Fini()
+}
+
+func (c *Console) processEvent(ev tcell.Event) {
+	switch ev := ev.(type) {
+	case *tcell.EventKey:
+		if ev.Key() == tcell.KeyCtrlC {
+			close(c.Quit)
+		}
+	case *tcell.EventResize:
+		c.width, c.height = ev.Size()
+	}
+}
+
+// Run blocks while displaying the Console to the user.
+func (c *Console) Run(ctx context.Context) error {
+	// Build channel for console event processing.
+	evC := make(chan tcell.Event)
+	evQuitC := make(chan struct{})
+	defer close(evQuitC)
+	go c.screen.ChannelEvents(evC, evQuitC)
+
+	// Pipe event values into channels.
+	netAddrC := make(chan *network.Status)
+	rolesC := make(chan *cpb.NodeRoles)
+	curatorConnC := make(chan *roleserve.CuratorConnection)
+	if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
+		return err
+	}
+	if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
+		return err
+	}
+	if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
+		return err
+	}
+	supervisor.Signal(ctx, supervisor.SignalHealthy)
+
+	pageStatus := pageStatusData{
+		netAddr:     "Waiting...",
+		roles:       "Waiting...",
+		id:          "Waiting...",
+		fingerprint: "Waiting...",
+	}
+
+	for {
+		c.pageStatus(&pageStatus)
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case ev := <-evC:
+			c.processEvent(ev)
+		case t := <-netAddrC:
+			pageStatus.netAddr = t.ExternalAddress.String()
+		case t := <-rolesC:
+			var rlist []string
+			if t.ConsensusMember != nil {
+				rlist = append(rlist, "ConsensusMember")
+			}
+			if t.KubernetesController != nil {
+				rlist = append(rlist, "KubernetesController")
+			}
+			if t.KubernetesWorker != nil {
+				rlist = append(rlist, "KubernetesWorker")
+			}
+			pageStatus.roles = strings.Join(rlist, ", ")
+			if pageStatus.roles == "" {
+				pageStatus.roles = "none"
+			}
+		case t := <-curatorConnC:
+			pageStatus.id = t.Credentials.ID()
+			cert := t.Credentials.ClusterCA()
+			sum := sha256.New()
+			sum.Write(cert.Raw)
+			pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
+		}
+	}
+}