Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/concepts/access-and-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ the detail screen:

```
ssh stoat@127.0.0.1:2202
console stoat / stoat (qemu window only)
console stoat / stoat (over the qemu window, or vnc on a headless host)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document both VNC routes for console access.

The text limits VNC to the headless fallback, but display = "vnc" also selects VNC on graphical hosts. Keep both copies aligned.

  • docs/concepts/access-and-auth.md#L133-L133: mention VNC when the host is headless or display = "vnc".
  • internal/core/core.go#L250-L251: apply the same condition to the cloud-console comment.
📍 Affects 2 files
  • docs/concepts/access-and-auth.md#L133-L133 (this comment)
  • internal/core/core.go#L250-L251
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/concepts/access-and-auth.md` at line 133, Update the console-access
documentation in docs/concepts/access-and-auth.md at lines 133-133 to state that
VNC is used when the host is headless or display = "vnc"; apply the same wording
and condition to the cloud-console comment in internal/core/core.go at lines
250-251 so both copies remain aligned.

```

and in the log line written when the VM starts, which you can read with
Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ sudo usermod -aG kvm "$USER" # then log out and back in

### GPU/display

One kind of VM opens a real QEMU window: a disk-mode VM that has not been installed yet, whose OS installer has to be driven by a human. That window is `-display gtk,gl=on`, so your QEMU build needs GTK and OpenGL support (the `qemu-full`/`qemu-desktop` Arch packages and the Debian/Ubuntu packages above provide this). Every other VM is headless with its screen on a VNC socket, and needs none of it.
A VM opens a real QEMU window by default. That window is `-display gtk,gl=on`, so your QEMU build needs GTK and OpenGL support (the `qemu-full`/`qemu-desktop` Arch packages and the Debian/Ubuntu packages above provide this). Set `display = "vnc"` on a VM to keep it headless with its screen on a VNC socket instead.

**On a host with no graphical session** (a server, an ssh session with no forwarding) stoat does not ask for that window at all: it puts the install console on the VNC socket too and prints how to attach, so a disk VM can be installed from another machine. It detects this from `DISPLAY`, `WAYLAND_DISPLAY` and `$XDG_RUNTIME_DIR/wayland-0`.
**On a host with no graphical session** (a server, an ssh session with no forwarding) stoat does not ask for a window at all: every VM's screen goes to a VNC socket and stoat prints how to attach, so a disk VM can still be installed from another machine. It detects this from `DISPLAY`, `WAYLAND_DISPLAY` and `$XDG_RUNTIME_DIR/wayland-0`.

If a VM still fails to start with a display or GL error, your host has a session QEMU cannot draw on. Set `STOAT_GRAPHICAL=0` to take the window out of play; see [troubleshooting](../troubleshooting.md). No source edit and no rebuild.

Expand Down
23 changes: 10 additions & 13 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ ssh port: 2222
ssh user: root
recipes: xfce
forwards: 8080:80
display: no qemu window; the screen is on /home/user/.stoat/work/vnc.sock
attach with: gvncviewer /home/user/.stoat/work/vnc.sock
display: a qemu window
```

`display` is the only line here that is not a `vm.toml` field. See [`stoat up`](#stoat-up-name) for what it means and why the answer changes. It is omitted entirely for a broken VM, whose `vm.toml` supplies neither of the facts the answer depends on.
Expand Down Expand Up @@ -153,34 +152,32 @@ display: no qemu window; the screen is on /home/user/.stoat/work/vnc.sock

### Where the screen is

Exactly one kind of VM gets a real QEMU window: a **disk-mode VM that is not yet installed**, on a host with a graphical session. Its OS installer draws to VGA and a human has to drive it, so `up` says so:
A VM gets a real QEMU window by default, on a host with a graphical session. Set `display = "vnc"` in `vm.toml` (or cycle it with the `d` key in the TUI) to keep a VM headless instead. QEMU then starts with `-display none` and a VNC server bound to a unix socket in the VM's directory; `-display none` cannot be undone on a running QEMU, so binding VNC at launch keeps a misbehaving guest recoverable.

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add language identifiers to the shell examples.

markdownlint reports MD040 at Line 157 and Line 175. Add sh to both opening fences so the documentation lint passes.

Also applies to: 175-175

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 157-157: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reference/cli.md` at line 157, Update the shell example code fences at
the referenced documentation locations to include the sh language identifier on
both opening fences, resolving markdownlint MD040 without changing the example
contents.

Source: Linters/SAST tools

display: a qemu window, for the OS installer's console
$ stoat up work
starting work...
work started (ssh :2222)
display: no qemu window; the screen is on /home/user/.stoat/work/vnc.sock
attach with: gvncviewer /home/user/.stoat/work/vnc.sock
```

Every other VM is headless. QEMU is started with `-display none` and a VNC server bound to a unix socket in the VM's directory, because `-display none` cannot be undone on a running QEMU and binding VNC at launch keeps a misbehaving guest recoverable.

**This includes a disk VM the moment its install finishes.** `setup-alpine` completes, stoat records `installed = true`, and the next start has no window. That is not a failure; the screen moved to the socket. It surprises people who provisioned a desktop onto a disk VM and expected the window to keep coming back.

The attach command names a viewer that is actually installed on your machine:

- `gvncviewer <socket>` opens the socket directly, when `gvncviewer` is present.
- Otherwise `socat TCP-LISTEN:5900,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:<socket>` republishes it on loopback, and any VNC client connects to `127.0.0.1:5900`.
- If neither is installed, `up` says so and names them rather than printing a command that would fail.

There is currently no way to ask for a QEMU window on an installed disk VM. `-display gtk` needs a graphical session on the host, so granting one by default would make `stoat up` fail outright over SSH or from a script rather than merely come up headless.

### On a host with no graphical session

`-display gtk` does not degrade when there is no display server: QEMU exits 1. So the install console goes to VNC there too, and `up` says why before it says where:
`-display gtk` does not degrade when there is no display server: QEMU exits 1. So every VM's screen goes to VNC there instead, and `up` says why before it says where:

```
$ stoat up alpinedisk
starting alpinedisk...
alpinedisk started (ssh :2200)
display: no usable graphical session on this host, so the OS installer's
console is on VNC instead; drive it from a machine with a screen
display: no usable graphical session on this host, so the screen
is on VNC instead; attach to watch it
display: no qemu window; the screen is on /home/user/.stoat/alpinedisk/vnc.sock
attach with: gvncviewer /home/user/.stoat/alpinedisk/vnc.sock
```
Expand Down
34 changes: 13 additions & 21 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,12 @@

Symptom-first. Find the error text you're seeing and jump to it.

## No QEMU window appears any more
## No QEMU window appears

You created a disk VM, let it install itself, maybe provisioned a desktop onto
it, and now no window opens on start. There is no error because nothing failed.

Exactly one kind of VM gets a real QEMU window: a disk-mode VM that is **not
yet installed**, on a host with a graphical session. That window shows the
unattended `setup-alpine` install as it runs, which draws to VGA. The moment
stoat records `installed = true`,
the next start uses `-display none` with a VNC server bound to a unix socket in
the VM's directory (`internal/qemu/args.go`). `-display none` cannot be undone
on a running QEMU, so binding VNC at launch is what keeps a guest that has
locked up or lost its network still reachable.
A VM with `display = "vnc"` in its `vm.toml`, or one started on a host with no
graphical session, puts its screen on a VNC socket instead of a window. Every
other VM opens a real QEMU window by default (`internal/qemu/args.go`), whether
it is live, cloud, or an installed or uninstalled disk VM.

**Fix:** ask stoat where the screen went. Both `stoat up` and `stoat get` print
it, with a command for a VNC viewer that is actually installed on your machine:
Expand All @@ -31,10 +24,9 @@ installed, stoat names what to install rather than printing a command that
would fail. With only `socat`, the command bridges the socket to loopback and
any VNC client connects to `127.0.0.1:5900`.

There is no way to ask for a QEMU window on an installed disk VM. `-display
gtk` needs a graphical session on the host, so granting one by default would
make `stoat up` fail outright over SSH or from a script instead of merely
coming up headless.
Set `display = "vnc"` (or cycle it with the `d` key in the TUI) to keep a VM
headless on purpose: `-display none` binds a VNC server at launch instead of a
window, which stays reachable even if the guest locks up or loses its network.

## `OpenGL is not supported by display backend 'gtk'`

Expand All @@ -43,10 +35,10 @@ stoat: up: qemu failed to start: qemu-system-x86_64: OpenGL is not supported by
```

This one is not about OpenGL, and mesa and your GPU drivers are not the place
to look. stoat starts an uninstalled disk VM with `-display gtk,gl=on`, and
`gl=on` is simply the first option QEMU rejects when it cannot open a window at
all. With the same window and no `gl=on`, the same host says `gtk
initialization failed` instead.
to look. stoat starts a VM with `-display gtk,gl=on` by default, and `gl=on`
is simply the first option QEMU rejects when it cannot open a window at all.
With the same window and no `gl=on`, the same host says `gtk initialization
failed` instead.

If stoat prints this, it found a graphical session on the host and QEMU still
could not use it: usually a QEMU or GTK build without working GL. The message
Expand Down Expand Up @@ -92,7 +84,7 @@ directions, for every command and for the TUI:

| value | effect |
| --- | --- |
| `STOAT_GRAPHICAL=0` | never open a window; the install console goes to VNC |
| `STOAT_GRAPHICAL=0` | never open a window; every VM's screen goes to VNC |
| `STOAT_GRAPHICAL=1` | open the window; use this if stoat did not recognize your session |
| unset | detect (the default) |

Expand Down
33 changes: 17 additions & 16 deletions internal/cli/display_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ func getText(t *testing.T, name string) string {
return out.String()
}

// The reported bug: a disk VM whose window vanishes once setup-alpine marks
// it installed, with nothing anywhere saying where the screen went.
// A VM pinned to display="vnc" always has a socket to attach to, whether or
// not it is installed.
func TestGetTellsAnInstalledDiskVMWhereItsScreenWent(t *testing.T) {
root := cliRoot(t)
saveVM(t, &config.VM{Name: "alpinedisk", OS: "alpine", Mode: "disk", Disk: "8G",
Installed: true, RAM: 2048, CPUs: 2, SSHPort: 2200})
Installed: true, Display: "vnc", RAM: 2048, CPUs: 2, SSHPort: 2200})
fakeViewers(t, "gvncviewer")

out := getText(t, "alpinedisk")
Expand All @@ -62,8 +62,8 @@ func TestGetTellsAnInstalledDiskVMWhereItsScreenWent(t *testing.T) {
}
}

// Before the install finishes there IS a window, and saying "no qemu window"
// then would send a user hunting for a socket qemu never bound.
// A disk VM on a graphical host gets a window whether or not the install has
// finished.
//
// The override is pinned rather than left to detection: this assertion is
// about a host with a session, and the machine running the test may not be
Expand All @@ -83,11 +83,11 @@ func TestGetSaysAFreshDiskVMHasAWindow(t *testing.T) {
}
}

// The same VM on a host with no graphical session. qemu cannot open a window
// there, so the install console is on VNC, and the output has to say that
// before it says "no qemu window": mid-install, "no qemu window" on its own
// reads as the thing that went wrong.
func TestGetExplainsTheInstallConsoleOnAHeadlessHost(t *testing.T) {
// A host with no graphical session. qemu cannot open a window there, so
// every VM's console is on VNC, and the output has to say that before it
// says "no qemu window": on its own, "no qemu window" reads as the thing
// that went wrong.
func TestGetExplainsTheVNCFallbackOnAHeadlessHost(t *testing.T) {
root := cliRoot(t)
saveVM(t, &config.VM{Name: "alpinedisk", OS: "alpine", Mode: "disk", Disk: "8G",
Installed: false, RAM: 2048, CPUs: 2, SSHPort: 2200})
Expand All @@ -98,26 +98,26 @@ func TestGetExplainsTheInstallConsoleOnAHeadlessHost(t *testing.T) {
sock := filepath.Join(root, "alpinedisk", "vnc.sock")
for _, want := range []string{
"no usable graphical session on this host",
"console is on VNC instead",
"attach with: gvncviewer " + sock,
} {
if !strings.Contains(out, want) {
t.Errorf("get output missing %q:\n%s", want, out)
}
}
// An installed VM on the same host is on VNC for its own reasons and must
// not be given the host's excuse.
// A VM pinned to display="vnc" was already on VNC for its own reasons and
// must not be given the host's excuse.
saveVM(t, &config.VM{Name: "done", OS: "alpine", Mode: "disk", Disk: "8G",
Installed: true, RAM: 2048, CPUs: 2, SSHPort: 2201})
Installed: true, Display: "vnc", RAM: 2048, CPUs: 2, SSHPort: 2201})
if out := getText(t, "done"); strings.Contains(out, "graphical session") {
t.Errorf("an installed VM was never getting a window:\n%s", out)
t.Errorf("a VM pinned to vnc was never getting a window:\n%s", out)
}
}

// Printing a command for a binary that is not installed is worse than saying
// nothing: it reads as an instruction and fails as one.
func TestGetNamesWhatToInstallWhenNoViewerExists(t *testing.T) {
cliRoot(t)
t.Setenv(core.GraphicalEnv, "0")
saveVM(t, &config.VM{Name: "cloudy", OS: "alpine", Mode: "cloud",
RAM: 2048, CPUs: 2, SSHPort: 2201})
fakeViewers(t)
Expand All @@ -135,6 +135,7 @@ func TestGetNamesWhatToInstallWhenNoViewerExists(t *testing.T) {
// anybody anything.
func TestGetPrintsTheBridgeFollowUpStep(t *testing.T) {
cliRoot(t)
t.Setenv(core.GraphicalEnv, "0")
saveVM(t, &config.VM{Name: "cloudy", OS: "alpine", Mode: "cloud",
RAM: 2048, CPUs: 2, SSHPort: 2201})
fakeViewers(t, "socat")
Expand Down Expand Up @@ -162,7 +163,7 @@ func TestGetOnABrokenVMSaysNothingAboutTheDisplay(t *testing.T) {
func TestJSONCarriesTheDisplayKindButNeverTheSocket(t *testing.T) {
root := cliRoot(t)
saveVM(t, &config.VM{Name: "alpinedisk", OS: "alpine", Mode: "disk", Disk: "8G",
Installed: true, RAM: 2048, CPUs: 2, SSHPort: 2200})
Installed: true, Display: "vnc", RAM: 2048, CPUs: 2, SSHPort: 2200})
fakeViewers(t, "gvncviewer")

code, objs := runJSON(t, "get", "alpinedisk")
Expand Down
19 changes: 9 additions & 10 deletions internal/cli/run_vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,24 +94,23 @@ func runUp(a *Args, stdout, stderr io.Writer) int {
// printDisplay says where a VM's screen is and how to reach it.
//
// This prints on every start, not only the surprising one, because the
// surprising one is not detectable from here: a disk VM shows a window until
// setup-alpine marks it installed, and the start after that silently moves
// the screen to a VNC socket. A user who was never told the socket exists has
// no error to search for and no path to guess.
// surprising one is not detectable from here: a VM with display="vnc", or one
// on a host with no graphical session, has its screen on a socket instead of
// the window a user expects by default. A user who was never told the socket
// exists has no error to search for and no path to guess.
func printDisplay(w io.Writer, d core.Display) {
switch d.Kind {
case core.DisplayWindow:
fmt.Fprintln(w, "display: a qemu window, for the OS installer's console")
fmt.Fprintln(w, "display: a qemu window")
case core.DisplayVNC:
if d.NoSession {
// The install console, on a host that cannot open a window. Said
// before the socket line, because without it "no qemu window" for a
// VM that is mid-install reads as the thing that went wrong.
// Said before the socket line, because without it "no qemu window"
// on its own reads as the thing that went wrong.
// "no usable session" rather than "no session": the same line
// prints when the user set STOAT_GRAPHICAL=0 on a host that plainly
// has one, because its GTK cannot draw on it.
fmt.Fprintln(w, "display: no usable graphical session on this host, so the OS installer's")
fmt.Fprintln(w, " console is on VNC instead; drive it from a machine with a screen")
fmt.Fprintln(w, "display: no usable graphical session on this host, so the screen")
fmt.Fprintln(w, " is on VNC instead; attach to watch it")
}
fmt.Fprintf(w, "display: no qemu window; the screen is on %s\n", d.Socket)
if d.Attach.Command == "" {
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/wire/dto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

func TestVMGolden(t *testing.T) {
got := marshal(t, FromVM(sampleVM(), true))
want := `{"name":"work","os":"alpine","mode":"live","backend":"apkovl","state":"running","cpus":4,"ram_mb":4096,"disk":"8G","share":"/home/u/src","recipes":["xfce"],"ssh_port":2222,"ssh_user":"root","installed":false,"forwards":[{"host_port":8080,"guest_port":80}],"allow_exec":true,"display":"vnc"}`
want := `{"name":"work","os":"alpine","mode":"live","backend":"apkovl","state":"running","cpus":4,"ram_mb":4096,"disk":"8G","share":"/home/u/src","recipes":["xfce"],"ssh_port":2222,"ssh_user":"root","installed":false,"forwards":[{"host_port":8080,"guest_port":80}],"allow_exec":true,"display":"window"}`
if got != want {
t.Errorf("got %s\nwant %s", got, want)
}
Expand All @@ -28,11 +28,13 @@ func TestVMDisplayNamesTheSurfaceNotTheSocket(t *testing.T) {
if !strings.Contains(got, `"display":"window"`) {
t.Errorf("a VM mid-install has a real window: %s", got)
}
installed := fresh
installed.Installed = true
got = marshal(t, FromVM(installed, true))

pinned := fresh
pinned.Installed = true
pinned.Display = "vnc"
got = marshal(t, FromVM(pinned, true))
if !strings.Contains(got, `"display":"vnc"`) {
t.Errorf("an installed disk VM is headless: %s", got)
t.Errorf("a VM pinned to vnc is headless: %s", got)
}
if strings.Contains(got, "vnc.sock") || strings.Contains(got, "/home/u") {
t.Errorf("the socket path reached the wire: %s", got)
Expand Down
6 changes: 3 additions & 3 deletions internal/cloudinit/cloudinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ const User = "stoat"
// does not exist in the image. The shell must match the guest; see
// guestShell. consolePasswordBlock below fills in the password block.
//
// ssh_pwauth stays false on purpose. The password exists only for the VNC
// console: a cloud VM never gets a qemu window (qemu.NeedsWindow), so the
// console socket is the only place a password login happens. The network
// ssh_pwauth stays false on purpose. The password exists only for the
// console, a qemu window on a graphical host or the VNC socket otherwise
// (qemu.NeedsWindow), the only place a password login happens. The network
// stays key-only.
const userDataTemplate = `#cloud-config
users:
Expand Down
11 changes: 7 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,13 @@ type VM struct {
Recipes []string `toml:"recipes"`

// Display is the user's screen preference: "" or "auto" (default),
// "window", or "vnc". core.validateDisplay is the single place that
// checks the value; empty means an old vm.toml predates this field, so
// it must read the same as "auto". qemu.DisplayKind is the rule that
// turns this into DisplayWindow or DisplayVNC.
// "window", or "vnc". "auto" opens a real qemu window on a graphical
// host, and falls back to VNC only when the host has no display server;
// set "vnc" to opt out of a window on a graphical host. core.validateDisplay
// is the single place that checks the value; empty means an old vm.toml
// predates this field, so it must read the same as "auto".
Comment on lines 48 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Include the host override in the Display contract.

The comment says "auto" falls back only when the host has no display server. STOAT_GRAPHICAL=0 also forces VNC on graphical hosts. Describe both conditions, such as no usable graphical session or an override that disables windows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/config.go` around lines 48 - 53, Update the Display field
comment to state that "auto" uses a QEMU window unless there is no usable
graphical session or STOAT_GRAPHICAL=0 disables windows, in which cases it falls
back to VNC. Keep the existing value descriptions and legacy empty-value
behavior unchanged.

// qemu.DisplayKind is the rule that turns this into DisplayWindow or
// DisplayVNC.
Display string `toml:"display"`

// Forwards are user-declared TCP ports forwarded from host to guest, in
Expand Down
8 changes: 4 additions & 4 deletions internal/config/password.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import (

// DefaultConsolePassword is what a new cloud VM gets unless a random one is
// asked for. The value is fixed and documented on purpose. You see this
// login prompt at the VM's VNC console on your own machine (a cloud VM
// never gets a qemu window; see qemu.NeedsWindow), usually because ssh
// isn't working. Looking the password up at that moment is the failure
// this prevents.
// login prompt at the VM's console (a qemu window, or the VNC socket on a
// headless host or with display="vnc"; see qemu.NeedsWindow), usually
// because ssh isn't working. Looking the password up at that moment is the
// failure this prevents.
//
// This value is safe here in a way it would not be on a server. The seed
// sets ssh_pwauth: false, so this password is refused over the forwarded
Expand Down
4 changes: 2 additions & 2 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ func plan(s Spec) (*config.VM, error) {
if img.backend == "cloudinit" {
v.Base = img.abs
// Only a cloud image needs a console password. cloud-init locks
// every account by default, so its VNC console (a cloud VM never
// gets a qemu window; see qemu.NeedsWindow) shows a login prompt
// every account by default, so its console (a qemu window, or VNC
// on a headless host; see qemu.NeedsWindow) shows a login prompt
// with no valid answer otherwise. A live Alpine VM logs root in at
// the console with no password. A disk VM's password is whatever
// the user set in the guest's own installer.
Expand Down
Loading
Loading