Author SHA1 Message Date
Michael WesemannandClaude Opus 5 fba15b7897 [mike@mwxm4]
The four read-only actions move out of the action menu onto the sheet's own
letters — e, h, y, w — and the menu keeps only what changes a machine. h logs
in as root by default, survives a ^C during the login, and y actually reaches
the clipboard and says what it put there.

* e h y w are keys of the detail sheet; the menu loses its last group, its
  separator and the guestItem/vsphereItem helpers. The "no address" reason the
  greyed-out entries carried is now hasAddress, said on the status line.
* defaultSSH is "ssh root@%h" — a template in ~/.gvmrc replaces it whole.
* holdTerminalSignals catches SIGINT and SIGQUIT while a child has the screen:
  in cooked mode the keystroke went to the whole foreground group and took gvm
  with it. Caught, not ignored — exec resets a caught signal to default in the
  child, while an ignored one is inherited and the ssh could not be aborted.
  interrupted() tells that keystroke from a fault, so the screen is no longer
  held for something somebody meant to do.
* toClipboard uses pbcopy/wl-copy/xclip/xsel where there is one and always
  sends OSC 52 as well; an ssh login uses the sequence alone. iTerm2 keeps
  OSC 52 behind a setting, which is why y appeared to do nothing. The status
  line now names what was copied — hostname or address — and which clipboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 08:51:18 +02:00
11 changed files with 534 additions and 154 deletions
+73 -40
View File
@@ -121,6 +121,8 @@ help, as does anything gvm does not recognise:
memory in use, uptime, storage, guest filesystems, network
adapters, snapshots, uuid and moref
↑ ↓ in there scroll the sheet, esc/enter back to the list
e h y w in the sheet: recent events, ssh to the guest, copy what
ssh would connect to, open it in the vSphere client
The sheet is one line per thing worth knowing, values that belong together
joined with a middle dot and no section headings — an ordinary machine fits a
@@ -285,8 +287,12 @@ sheet:
^a the action menu (see below)
^s take a snapshot: a name, then a confirmation
e recent events
h ssh to the guest, as root
y copy that name or address to the clipboard
w open in the vSphere client
Pressing either in the table says so rather than doing nothing visible.
Pressing `^a` or `^s` in the table says so rather than doing nothing visible.
`^s` takes two steps.
@@ -301,57 +307,78 @@ nothing but `y`: Enter finishes a name, it never takes a snapshot. Afterwards th
name is on the status line, and a sheet that is open jumps to its snapshot
section so the new one is there to see.
### The action menu
### The sheet's four letters
In a machine's sheet, `^a` opens the menu for it. Above the choices it repeats
the few lines of the sheet the choice depends on — state, guest, hostname,
address — taken from the sheet itself, so the two cannot word the same fact
differently. On a terminal too short for both, those lines go one at a time,
least useful first: the state stays longest because every choice depends on it,
then the address and the hostname, because two of the choices are about them.
Everything that changes a
machine lives there and nowhere else — the list is arrowed through and its filter
swallows every ordinary letter, so a hotkey that powered a machine off would sit
one fumbled control key away from an outage, and the sheet has to be opened first
anyway.
n take a snapshot o power on
r revert to a snapshot ... s shut down the guest
d remove a snapshot ... b reboot the guest
D remove ALL snapshots S power off (hard)
B reset (hard)
────────────────────────────────────────────────────────────
e recent events h ssh to the guest
y copy the address w open in the vSphere client
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
always needs the shift key. What cannot be done right now is greyed out with the
reason next to it — "no VMware Tools", "already running", "no address" — rather
than left out, and picking it anyway spells the reason out instead of running it.
The group under the rule changes nothing, on the machine or on the vCenter. It
is here rather than on four more control keys of its own because this is where
one already looks for "what can I do with this machine", and because the sheet's
help line is not the place to learn them:
The four letters are the things that change nothing, on the machine or on the
vCenter: they read its history, copy its address, open it somewhere else. They
are letters of the sheet rather than entries in the menu because nothing they do
needs thinking about first, and — unlike the table, whose filter swallows every
ordinary letter — the sheet has nothing else to do with them:
* **`e`** puts the machine's own recent events at the foot of its sheet and
scrolls down to them — why is this thing off, who rebooted it, what happened
at four this morning. `gvm log` is the whole vCenter over the last hour, which
is the right shape for a mail and the wrong one for that question. They are
fetched when they are asked for: opening a machine stays one call.
* **`h`** logs in to the guest, by its own hostname where it reports one and by
its address otherwise. The terminal goes back to what it was for as long as
that lasts. `ssh = ssh -l root %h` in `~/.gvmrc` says how; the target is
always one argument and never goes through a shell, because it is a name the
guest chose for itself.
* **`y`** copies that address to the clipboard — the terminal's own, asked for
with an escape sequence rather than through `pbcopy`, so it works over ssh and
lands where the person actually is.
* **`h`** logs in to the guest as root — `ssh root@<name>` — by its own hostname
where it reports one and by its address otherwise. The terminal goes back to
what it was for as long as that lasts. `ssh = ssh -l someone %h` in `~/.gvmrc`
replaces that command whole, root and all; the target is always one argument
and never goes through a shell, because it is a name the guest chose for
itself. `^C` while it hangs on a machine that is not answering kills the login
and no more than that: gvm catches the signal for as long as the child has the
screen — the terminal is in its ordinary mode there, where the keystroke goes
to every process in the foreground group — and comes back to the sheet saying
the login was interrupted.
* **`y`** copies exactly what `h` would connect to — the hostname where the
guest reports one, the address otherwise — and the status line names which of
the two it was and which clipboard it went into, because a clipboard is
invisible and "copied" on its own is something one has to go and check.
Two routes, because neither alone is enough. `pbcopy` (or `wl-copy`, `xclip`,
`xsel`) is the one that always works where there is one, and the terminal's own
OSC 52 escape sequence is the only one that reaches the right machine from the
far end of an ssh login — where a local `pbcopy` would copy into the clipboard
of a machine nobody is sitting at. So the sequence is always sent and the
command is used as well where there is one; a login is recognised by
`SSH_CONNECTION`, and there the sequence is the whole story. It is also the
route a terminal is free to ignore — iTerm2 keeps it behind *Applications in
terminal may access clipboard*, tmux behind `set-clipboard` — which is why the
line says when it was the only one used.
* **`w`** opens the machine's page in the vSphere client. The link needs the
vCenter's instance UUID, which is the serverGuid that client puts in its URLs
and the one thing gvm cannot work out from the configuration; where there is no
browser to hand off to, the URL is said and copied instead.
`h` and `y` need somewhere to connect to: on a machine whose guest is not
reporting an address they say so on the status line rather than doing nothing.
### The action menu
In a machine's sheet, `^a` opens the menu for it. It is everything that changes a
machine, and it lives there and nowhere else — the list is arrowed through and its
filter swallows every ordinary letter, so a hotkey that powered a machine off
would sit one fumbled control key away from an outage, and the sheet has to be
opened first anyway.
Above the choices the menu repeats the few lines of the sheet the choice depends
on — state, guest, hostname, address — taken from the sheet itself, so the two
cannot word the same fact differently. On a terminal too short for both, those
lines go one at a time, least useful first: the state stays longest because every
choice depends on it, then the address and the hostname, which say which machine
this is about.
n take a snapshot o power on
r revert to a snapshot ... s shut down the guest
d remove a snapshot ... b reboot the guest
D remove ALL snapshots S power off (hard)
B reset (hard)
Lowercase asks the guest, uppercase acts at the hypervisor: the violent variant
always needs the shift key. What cannot be done right now is greyed out with the
reason next to it — "no VMware Tools", "already running", "no snapshots" — rather
than left out, and picking it anyway spells the reason out instead of running it.
Snapshots are drawn as the tree they are — which state descends from which is
the whole point of a snapshot list — and the one the machine is running from
says so:
@@ -686,6 +713,12 @@ wrong quietly:
snapshot whose file layout could not be read
* that the ssh target is one argument and never shell code — it is a name the
guest chose for itself
* that `y` names what it copied and that an ssh login uses no local clipboard
tool — the tests say they are a login, which also keeps them off the clipboard
of whoever is running them
* that a `^C` during a login does not take gvm with it, and that the login still
dies of it: the signal is caught for as long as the child has the screen, and
catching is not ignoring — an ignored one would be inherited by the ssh
* that the column ladder still only ever *drops* columns with the task column in
the table, and that a terminal of eighty still keeps the address
* that the completion cache survives a sweep of one server, forgets a machine
+49 -55
View File
@@ -59,15 +59,16 @@ var menuFacts = []string{"state", "guest", "hostname", "address"}
// most worth keeping first, the same idea as the table's expendable columns.
//
// The state comes first because every choice below depends on it. The address
// and the hostname come next because two of the choices are *about* them: ssh
// and copy-the-address. The guest's operating system decides nothing here.
// and the hostname come next: they say which machine is about to be powered off,
// which is worth having in front of one. The guest's operating system decides
// nothing here.
var factOrder = []string{"state", "address", "hostname", "guest"}
// keepFacts is as many of the facts as fit, still in sheet order.
//
// Giving them up one at a time rather than all at once is what a terminal of
// twenty-four rows gets out of this: the menu grew a group of its own, all four
// facts no longer fit above it there, and none is a worse answer than three.
// Giving them up one at a time rather than all at once is what a short terminal
// gets out of this: below twenty rows the menu and all four facts no longer fit
// on the screen together, and none of them is a worse answer than three.
func keepFacts(info []sheetLine, room int) []sheetLine {
if room >= len(info) {
return info
@@ -189,44 +190,9 @@ func (b *browser) buildMenu(r vmRow, snaps []snapEntry) []menuItem {
separator(),
pwr('S', "power off (hard, at the hypervisor)", opPowerOff),
pwr('B', "reset (hard, at the hypervisor)", opReset),
separator(),
// The last group changes nothing, on the machine or on the vCenter: it
// reads its history, copies its address, opens it somewhere else. It is
// here rather than on keys of its own because the sheet's help line is
// not the place to learn four more control keys, and because this is
// where one already looks for "what can I do with this machine".
{key: 'e', label: "recent events", run: func(b *browser, r vmRow) { b.showEvents(r) }},
guestItem('h', "ssh to the guest", r, func(b *browser, r vmRow) { b.sshTo(r) }),
guestItem('y', "copy the address to the clipboard", r,
func(b *browser, r vmRow) { b.copyAddress(r) }),
vsphereItem('w', "open in the vSphere client", r),
}
}
// guestItem is one of the entries that needs somewhere to connect to. A machine
// whose guest is not talking has no address, and saying so where the entry is
// beats a keystroke that does nothing.
func guestItem(key rune, label string, r vmRow, run func(*browser, vmRow)) menuItem {
if r.sshTarget() == "" {
return menuItem{key: key, label: label, hint: "no address",
why: SF("%s has no address or hostname — its guest is not reporting one", r.name),
run: run}
}
return menuItem{key: key, label: label, run: run}
}
// vsphereItem needs the same, plus a way to open a browser: on a machine with
// no desktop the URL is still worth having, so it is offered and printed rather
// than left out.
func vsphereItem(key rune, label string, r vmRow) menuItem {
if vsphereURL(r) == "" {
return menuItem{key: key, label: label, hint: "no connection",
why: "the vSphere client link needs the connection this machine was read over",
run: func(b *browser, r vmRow) { b.openVSphere(r) }}
}
return menuItem{key: key, label: label, run: func(b *browser, r vmRow) { b.openVSphere(r) }}
}
// menuKey drives the menu. Letters pick an item directly; the arrows and Enter
// do the same for anyone who would rather read than remember.
func (b *browser) menuKey(k key) {
@@ -637,7 +603,7 @@ func (b *browser) renderMenu() {
segLine(&sb, cols, seg{b.statusCol, b.status})
} else {
segLine(&sb, cols, seg{colDim,
"lowercase asks the guest, uppercase the hypervisor · the last group only reads"})
"lowercase asks the guest, uppercase the hypervisor"})
}
sb.WriteString(colDim + truncate("a letter or ↑/↓ and ⏎ to choose esc back", cols) + attrOff + scrEOL)
b.write(sb.String())
@@ -793,13 +759,17 @@ func wrap(s string, width int) []string {
return out
}
// ------------------------------------------------- the harmless half of the menu
// ------------------------------------------------------- the sheet's own keys
//
// The four below change nothing, on the machine or on the vCenter: they read its
// history, copy its address, open it somewhere else. That is why they are on
// letters of the sheet itself rather than in the action menu, which is for the
// things one has to be sure about before pressing.
// showEvents puts the machine's recent history at the foot of its sheet and
// scrolls down to it. Asked for rather than fetched with the sheet: opening a
// machine is one call, and this is another.
func (b *browser) showEvents(r vmRow) {
b.closeMenu()
b.working(SF("reading the events of %s ...", r.name))
lines, err := eventsOf(r)
@@ -818,41 +788,65 @@ func (b *browser) showEvents(r vmRow) {
b.scrollToSection("events")
}
// hasAddress reports whether there is somewhere to connect to, and says so where
// the key was pressed when there is not. A guest that is not talking reports no
// address, and ssh or copy doing nothing at all would read as gvm having hung —
// the greyed-out menu entries these two replaced said as much in their own line.
func (b *browser) hasAddress(r vmRow) bool {
if r.sshTarget() != "" {
return true
}
b.setStatus(colWarn, SF("%s has no address or hostname — its guest is not reporting one", r.name))
return false
}
// sshTo logs in to the guest. The terminal goes back to what it was for as long
// as that lasts (guest.go), and the list is redrawn afterwards.
func (b *browser) sshTo(r vmRow) {
b.closeMenu()
target := r.sshTarget()
argv := sshCommand(b.ssh, target)
if err := b.runInTerminal(argv); err != nil {
err := b.runInTerminal(argv)
switch {
case err == nil:
b.setStatus(colDim, "back from "+target)
case interrupted(err):
// Ctrl-C during a login is somebody changing their mind, not a fault:
// gvm now survives it (holdTerminalSignals) and says so in the colour
// of an ordinary remark.
b.setStatus(colDim, "the login to "+target+" was interrupted")
default:
b.setStatus(colWarn, SF("%s: %v", strings.Join(argv, " "), err))
return
}
b.setStatus(colDim, "back from "+target)
}
// copyAddress puts the address where the next paste will find it, by asking the
// terminal rather than the operating system — see osc52.
// copyAddress puts what `h` would connect to where the next paste will find it.
//
// What it says is longer than "copied web01.example" was, and deliberately: a
// clipboard is invisible, so the line has to name what went into it — the
// hostname or the address, since the sheet shows both — and which clipboard it
// is. Where the escape sequence was the only route it also says so, because that
// is the case where it may quietly not have arrived.
func (b *browser) copyAddress(r vmRow) {
b.closeMenu()
target := r.sshTarget()
b.write(osc52(target))
b.setStatus(colInfo, "copied "+target)
target, kind := r.sshTargetIs()
if where := b.toClipboard(target); where != "" {
b.setStatus(colInfo, SF("copied its %s %s to the clipboard (%s)", kind, target, where))
return
}
b.setStatus(colWarn, SF("sent its %s %s to the terminal's own clipboard — it has to allow that (OSC 52)", kind, target))
}
// openVSphere opens the machine's page in the vSphere client, and says the URL
// either way: a workstation with no browser to hand off to still gets the one
// thing that was wanted, and so does anyone running gvm over ssh.
func (b *browser) openVSphere(r vmRow) {
b.closeMenu()
url := vsphereURL(r)
if url == "" {
b.setStatus(colWarn, "no connection to "+r.vc.Name+" to build the link from")
return
}
if err := openURL(url); err != nil {
b.write(osc52(url))
b.toClipboard(url) // the same two routes as `y`
b.setStatus(colWarn, url+" (copied; "+err.Error()+")")
return
}
+3 -3
View File
@@ -606,9 +606,9 @@ func TestMenuDropsItsFactsBeforeItsChoices(t *testing.T) {
wantFacts bool
}{
{"30", true}, // room for both, all four facts
{"24", true}, // room for three of them, the hostname among them
{"23", false}, // room for two: the state and the address
{"18", false}, // no room at all, so the choices have the screen
{"19", true}, // room for three of them, the hostname among them
{"18", false}, // room for two: the state and the address
{"13", false}, // no room at all, so the choices have the screen
} {
t.Setenv("LINES", c.rows)
+30 -2
View File
@@ -83,7 +83,7 @@ var (
const (
listHelp = "type to filter ↑/↓ move ⏎ details ^o sort ^w issues ^r reload esc clear/quit"
detailHelp = "↑/↓ scroll ^a actions (events, ssh, vsphere) ^s snapshot esc/⏎ back ^c quit"
detailHelp = "↑/↓ scroll e events h ssh y copy w vsphere ^a actions ^s snapshot esc back ^c quit"
gutter = 2 // the pointer's two columns, in front of every row
colSep = 2
labelWidth = 12
@@ -759,7 +759,7 @@ type browser struct {
// browseVMs is the command: gather, then hand the terminal over to the loop.
//
// ssh is the command line the action menu's `h` runs, out of the configuration:
// ssh is the command line the sheet's `h` runs, out of the configuration:
// the browser is handed it rather than reading it, so nothing in the interactive
// half has to know where settings come from.
func browseVMs(targets []VCenter, filter, ssh string) error {
@@ -957,6 +957,8 @@ func (b *browser) detailKey(k key) {
b.openMenu()
case keyCtrlS:
b.snapshot()
case keyRune:
b.detailRune(k.r)
case keyUp:
b.dscroll = max(b.dscroll-1, 0)
case keyDown:
@@ -972,6 +974,32 @@ func (b *browser) detailKey(k key) {
}
}
// detailRune answers the sheet's own letters: the four things that only read the
// machine or point somewhere else at it. They are letters here rather than
// entries at the foot of the action menu because nothing they do needs thinking
// about first, and the sheet — unlike the list — has no filter to swallow them.
// A letter that means nothing here is ignored, not complained about.
func (b *browser) detailRune(r rune) {
row := b.current()
if row == nil {
return
}
switch r {
case 'e':
b.showEvents(*row)
case 'h':
if b.hasAddress(*row) {
b.sshTo(*row)
}
case 'y':
if b.hasAddress(*row) {
b.copyAddress(*row)
}
case 'w':
b.openVSphere(*row)
}
}
// refilter rebuilds the visible set. The selection stays on the machine it was
// on where that machine is still in the list, which is what makes typing a few
// letters and pressing enter feel like one motion.
+80 -2
View File
@@ -251,6 +251,71 @@ func TestListViewHasNoActionKeys(t *testing.T) {
}
}
// The four that only read a machine are the sheet's own letters now, not entries
// at the foot of the action menu. Each one has to reach its own action from
// there, and a letter that means nothing must be dropped rather than answered.
func TestSheetLettersReachTheirActions(t *testing.T) {
// Said to be an ssh login, which keeps the test off the clipboard of whoever
// is running it: with no local tool to hand, `y` uses the terminal's own
// escape sequence and nothing else.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
// That sequence goes to the terminal, so the sheet needs one to write to.
pipe := func(b *browser) func() string {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = w
return func() string {
w.Close()
out, _ := io.ReadAll(r)
r.Close()
return string(out)
}
}
// y copies. It is the one of the four that needs neither a session nor a
// browser, so it is checked all the way through.
b := testBrowser("web01")
read := pipe(b)
b.detailRune('y')
if got := read(); !strings.Contains(got, "\x1b]52;c;") {
t.Errorf("y sent no clipboard sequence: %q", got)
}
// What the line says about which clipboard is TestCopyAddressSaysWhatWentWhere's
// business; here it only has to name the thing that was copied.
for _, want := range []string{"clipboard", "web01.example"} {
if !strings.Contains(b.status, want) {
t.Errorf("y said %q, which does not mention %q", b.status, want)
}
}
// e and w need the connection the machine was read over, and these rows have
// none: what matters is that the letter arrived at the action, which says so.
for _, c := range []struct {
k rune
want string
}{{'e', "v308"}, {'w', "v308"}} {
b := testBrowser("web01")
read := pipe(b)
b.detailRune(c.k)
read()
if !strings.Contains(b.status, c.want) {
t.Errorf("%q on a machine with no connection said %q", string(c.k), b.status)
}
}
// A letter nothing is bound to is ignored — silently, because the sheet is
// not a filter and there is nothing to correct.
b = testBrowser("web01")
b.setStatus("", "")
b.detailRune('q')
if b.status != "" {
t.Errorf("an unbound letter said %q", b.status)
}
}
func TestDetailSheetHasTheParameters(t *testing.T) {
sheet := sheetText(vmDetail(testRow("web01", true, "10.0.0.5"), []string{"none"}, colOff))
@@ -1390,16 +1455,29 @@ func TestEveryScreenDrawsWhatItHasToSay(t *testing.T) {
t.Errorf("the sheet does not show %q:\n%s", want, sheet)
}
}
// The four that only read the machine are the sheet's own letters, so the
// sheet is where they have to be advertised.
for _, want := range []string{"e events", "h ssh", "y copy", "w vsphere", "^a actions"} {
if !strings.Contains(sheet, want) {
t.Errorf("the sheet's help does not offer %q:\n%s", want, sheet)
}
}
b.menu = b.buildMenu(*b.current(), b.current().snaps)
b.menuInfo = sheetPick(vmDetail(*b.current(), nil, ""), menuFacts)
menu := stripEscapes(renderToPipe(t, b, b.renderMenu))
for _, want := range []string{"take a snapshot", "power off", "recent events",
"ssh to the guest", "copy the address", "vSphere client"} {
for _, want := range []string{"take a snapshot", "power off", "reset"} {
if !strings.Contains(menu, want) {
t.Errorf("the menu does not show %q:\n%s", want, menu)
}
}
// And the menu is now only the things that change a machine.
for _, gone := range []string{"recent events", "ssh to the guest",
"copy the address", "vSphere client"} {
if strings.Contains(menu, gone) {
t.Errorf("the menu still holds %q:\n%s", gone, menu)
}
}
}
// Every line at the foot of the screen that wants an answer wears one colour.
+4 -4
View File
@@ -30,7 +30,7 @@ type Config struct {
SMTPHost string // relay to hand it to
SMTPPort string // its port (default 25)
Telemetry string // URL `host -t` posts to; unset turns the posting off
SSH string // the command the action menu's `h` runs; %h is the machine
SSH string // the command the sheet's `h` runs; %h is the machine
}
// VCenter is one server, configured as a `vcenter.<name>.<field>` block. Name
@@ -587,10 +587,10 @@ func writeConfigTemplate(path string) {
b.WriteString("# smtpport = 25\n\n")
b.WriteString("# --- where `gvm host -t` and `gvm ds -t` post their numbers ---\n")
b.WriteString("# telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php\n\n")
b.WriteString("# --- how the action menu's 'h' logs in to a guest ---\n")
b.WriteString("# --- how the sheet's 'h' logs in to a guest ---\n")
b.WriteString("# %h is where the machine's name or address goes; appended when it is\n")
b.WriteString("# not written anywhere. Unset means plain 'ssh <machine>'.\n")
b.WriteString("# ssh = ssh -l root %h\n")
b.WriteString("# not written anywhere. Unset means '" + defaultSSH + "'.\n")
b.WriteString("# ssh = ssh -l someone %h\n")
if err := os.WriteFile(path, []byte(b.String()), configMode); err != nil {
PE("could not create "+path, err.Error())
+129 -12
View File
@@ -4,33 +4,51 @@
// take its address away with you, open it in the vSphere client. They are the
// keystrokes that stop gvm being a viewer you then have to type an address out
// of by hand — and none of them touches the vCenter at all, which is why they
// sit in the harmless group at the foot of the action menu.
// are letters of the sheet itself rather than entries in the action menu.
package main
import (
"encoding/base64"
"errors"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
)
// sshTarget is what to connect to: the name the guest calls itself, or its
// address. The name is preferred where there is one — it is what is in the known
// hosts file, and an address that came out of VMware Tools may be one of several.
func (r vmRow) sshTarget() string {
func (r vmRow) sshTarget() string { t, _ := r.sshTargetIs(); return t }
// sshTargetIs is the same, plus which of the two it turned out to be. What was
// copied is worth naming — a sheet shows a hostname and an address, and
// "copied 10.0.0.5" leaves the person wondering why it was not the name — and
// working that out a second time somewhere else is how two answers drift apart.
func (r vmRow) sshTargetIs() (target, kind string) {
if g := r.vm.Guest; g != nil && strings.TrimSpace(g.HostName) != "" {
return strings.TrimSpace(g.HostName)
return strings.TrimSpace(g.HostName), "hostname"
}
if ip := r.ip(); ip != "-" {
return ip
return ip, "address"
}
return ""
return "", ""
}
// defaultSSH is what `h` runs when the configuration says nothing.
const defaultSSH = "ssh root@%h"
// sshCommand is the command line to run, as argv: the configured template with
// the target put where %h stands, or appended when it does not stand anywhere.
//
// Unset it is `ssh root@%h`. Root is what one logs in to these machines as —
// anything else is a second step once the session is up — and having it in the
// default means the common case needs no configuration file at all. A template
// of one's own overrides it entirely, root and all.
//
// The target is its own argument and never goes through a shell. It comes from
// the guest — a hostname the guest chose for itself, by way of VMware Tools —
// and a guest that called itself `; rm -rf ~` would otherwise be running that
@@ -39,7 +57,7 @@ func (r vmRow) sshTarget() string {
// quoting is not supported, which is a limit worth having here.
func sshCommand(template, target string) []string {
if strings.TrimSpace(template) == "" {
template = "ssh %h"
template = defaultSSH
}
fields := strings.Fields(template)
@@ -116,16 +134,74 @@ func openURL(url string) error {
}
// osc52 is the escape sequence that puts text in the clipboard of the terminal
// that is being looked at, wherever that terminal is running.
// that is being looked at, wherever that terminal is running. It is the only way
// that reaches the right machine when gvm is run over ssh: a pbcopy on the far
// end of a login copies into the clipboard of a machine nobody is sitting at.
//
// Deliberately not pbcopy or xclip: gvm is run over ssh as often as not, and a
// local pbcopy would then copy an address into the clipboard of a machine
// nobody is sitting at. This asks the terminal itself, which is the one program
// in the chain that knows where the person is.
// It is also the way a terminal is free to ignore, and several do until they are
// told not to — iTerm2 has it behind a setting, tmux behind set-clipboard — which
// is why it is not the only thing tried. See toClipboard.
func osc52(text string) string {
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}
// toClipboard puts text where the next paste will find it and reports the way it
// got there, named — "pbcopy" — or empty when the escape sequence was the only
// thing on offer. The caller says so on the status line: a copy nobody can see
// happen is one that has to be described, or the only way to find out whether it
// worked is to paste somewhere and look.
//
// Both routes are used, because either alone leaves somebody with nothing: the
// local command always works where there is one, and the sequence is what
// carries the text home from the far end of an ssh login.
func (b *browser) toClipboard(text string) string {
b.write(osc52(text))
argv := clipTool()
if argv == nil {
return ""
}
if err := runClipTool(argv, text); err != nil {
return ""
}
return filepath.Base(argv[0])
}
// clipTool is the command that puts something in this machine's clipboard, where
// this is the machine the person is sitting at. Over an ssh login it is not:
// there the terminal's own sequence is the only route that ends up where the
// person can paste it, and a local clipboard would be the wrong machine's.
func clipTool() []string {
if os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_TTY") != "" {
return nil
}
candidates := [][]string{{"wl-copy"}, {"xclip", "-selection", "clipboard"}, {"xsel", "--clipboard", "--input"}}
if runtime.GOOS == "darwin" {
candidates = [][]string{{"pbcopy"}}
} else if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" {
// A Linux console or a machine with no session to speak of: there is
// nothing for xclip to hand the text to, and it would sit there waiting.
return nil
}
for _, c := range candidates {
if path, err := exec.LookPath(c[0]); err == nil {
return append([]string{path}, c[1:]...)
}
}
return nil
}
// runClipTool feeds the text to it on standard input, which is how all of them
// take it. Nothing is added: a trailing newline in the clipboard turns a pasted
// hostname into a pasted hostname and a return.
func runClipTool(argv []string, text string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = strings.NewReader(text)
return cmd.Run()
}
// runInTerminal gives the terminal back, runs a command in it, and takes it
// again. For ssh, which wants the terminal in its ordinary mode, its own screen,
// and the keyboard.
@@ -139,11 +215,13 @@ func (b *browser) runInTerminal(argv []string) error {
}
b.close() // clears the screen, puts the cursor back, hands the tty back
release := holdTerminalSignals()
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
err := cmd.Run()
release()
if err != nil {
if err != nil && !interrupted(err) {
// Something to read: the message would be wiped by the next frame, so
// the screen is held until somebody has seen it.
PF("\n%s %v\n", Crb(argv[0]+":"), err)
@@ -161,3 +239,42 @@ func (b *browser) runInTerminal(argv []string) error {
}
return err
}
// holdTerminalSignals keeps the keystrokes the terminal turns into signals from
// reaching gvm while a child has the screen. In its ordinary mode Ctrl-C is not
// a byte gvm reads but a SIGINT to the whole foreground process group — which is
// gvm as much as the ssh it is waiting for. Killing the login was meant; killing
// the list one was going back to was not.
//
// They are caught rather than ignored, and the difference matters: exec resets a
// caught signal to its default in the child, while an ignored one is inherited.
// An ssh that cannot be interrupted while it hangs on a machine that is not
// answering would be worse than what this fixes.
//
// The returned func puts them back the way they were, which is gvm's own raw
// mode reading Ctrl-C as a key like any other.
func holdTerminalSignals() func() {
// Buffered and never read: the signal package sends without blocking and
// drops what does not fit, which is the whole intent — these are being
// swallowed, not handled.
ch := make(chan os.Signal, 4)
signal.Notify(ch, os.Interrupt, syscall.SIGQUIT)
return func() { signal.Stop(ch) }
}
// interrupted reports whether a child ended because somebody pressed Ctrl-C (or
// Ctrl-\) rather than because something went wrong. Nothing is held on the
// screen for it: the person who pressed it knows what happened and wants to be
// back in the list, not reading that ssh got a signal.
func interrupted(err error) bool {
var exit *exec.ExitError
if !errors.As(err, &exit) {
return false
}
if st, ok := exit.Sys().(syscall.WaitStatus); ok && st.Signaled() {
return st.Signal() == syscall.SIGINT || st.Signal() == syscall.SIGQUIT
}
// A shell between gvm and the signal reports it as its own exit status
// instead, in the shells' 128+signal spelling.
return exit.ExitCode() == 128+int(syscall.SIGINT) || exit.ExitCode() == 128+int(syscall.SIGQUIT)
}
+159 -31
View File
@@ -2,9 +2,15 @@ package main
import (
"encoding/base64"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"time"
"github.com/vmware/govmomi/vim25/types"
)
@@ -33,8 +39,8 @@ func TestSSHCommand(t *testing.T) {
template string
want []string
}{
{"", []string{"ssh", "web01"}},
{"ssh %h", []string{"ssh", "web01"}},
{"", []string{"ssh", "root@web01"}}, // the default: root, no configuration needed
{"ssh %h", []string{"ssh", "web01"}}, // a template of one's own overrides it, root and all
{"ssh -l root %h", []string{"ssh", "-l", "root", "web01"}},
{"ssh -o StrictHostKeyChecking=no", []string{"ssh", "-o", "StrictHostKeyChecking=no", "web01"}},
{"mosh %h", []string{"mosh", "web01"}},
@@ -46,6 +52,142 @@ func TestSSHCommand(t *testing.T) {
}
}
// A clipboard is invisible, so what the status line says about it has to be
// exact: which of the two the machine gave up — the hostname or the address —
// and which clipboard it went into.
func TestCopyAddressSaysWhatWentWhere(t *testing.T) {
// Pretending to be an ssh login does two things: it is the case where the
// escape sequence is the only route, and it keeps the tests off the
// clipboard of whoever is running them.
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
for _, c := range []struct {
what string
row func() vmRow
want []string
}{
{"a guest that reports its name", func() vmRow {
return testRow("web01", true, "10.0.0.5")
}, []string{"hostname", "web01.example"}},
{"a guest that reports only an address", func() vmRow {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest.HostName = ""
return r
}, []string{"address", "10.0.0.5"}},
} {
r := c.row()
b := &browser{rows: []vmRow{r}, view: []int{0}}
pr, pw, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
b.tty = pw
b.copyAddress(r)
pw.Close()
sent, _ := io.ReadAll(pr)
pr.Close()
for _, want := range c.want {
if !strings.Contains(b.status, want) {
t.Errorf("%s: the status line does not say %q: %q", c.what, want, b.status)
}
}
// And the sequence carried the same string, base64 and all.
payload := base64.StdEncoding.EncodeToString([]byte(c.want[1]))
if !strings.Contains(string(sent), payload) {
t.Errorf("%s: the terminal was not sent %q", c.what, c.want[1])
}
}
}
// Over an ssh login there is no local clipboard worth writing to: pbcopy on the
// far end of a login copies into the clipboard of a machine nobody is sitting
// at, and the terminal's own sequence is the only route home.
func TestClipToolStaysOutOfAnSSHSession(t *testing.T) {
t.Setenv("SSH_CONNECTION", "10.0.0.9 51000 10.0.0.1 22")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
t.Setenv("SSH_CONNECTION", "")
t.Setenv("SSH_TTY", "/dev/ttys004")
if got := clipTool(); got != nil {
t.Errorf("an ssh session offered %v as a clipboard", got)
}
}
// Whatever the tool is, it takes the text on standard input and gets it verbatim
// — no trailing newline, which in a clipboard turns a pasted hostname into a
// pasted hostname and a return.
func TestClipToolGetsTheTextVerbatim(t *testing.T) {
out := filepath.Join(t.TempDir(), "clipboard")
if err := runClipTool([]string{"tee", out}, "web01.example"); err != nil {
t.Fatalf("runClipTool: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(got) != "web01.example" {
t.Errorf("the clipboard would get %q", got)
}
}
// Ctrl-C during an ssh login used to take gvm with it. In the terminal's
// ordinary mode — which is what a child gets — the keystroke is not a byte gvm
// reads but a SIGINT to the whole foreground process group, and gvm is in that
// group. While a child has the screen the signal has to be caught and dropped:
// were it not, this test would kill the test binary rather than fail.
func TestCtrlCDoesNotTakeGvmWithIt(t *testing.T) {
release := holdTerminalSignals()
defer release()
for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGQUIT} {
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatalf("cannot send myself a %v: %v", sig, err)
}
}
// Delivery is asynchronous: a moment to be killed in, if it is going to be.
time.Sleep(50 * time.Millisecond)
// And the child must still die of it, which is why the signal is caught and
// not ignored: exec resets a caught signal to its default in the child,
// while an ignored one is inherited — signal.Ignore here would leave an ssh
// that cannot be interrupted while it hangs on a machine that is not
// answering.
if err := exec.Command("sh", "-c", "kill -INT $$").Run(); err == nil {
t.Error("the child shrugged the Ctrl-C off: the signal is being ignored, not caught")
}
}
// And a child that died of that keystroke is told apart from one that failed, so
// the screen is not held with "signal: interrupt" over something somebody meant
// to do.
func TestInterruptedTellsTheKeystrokeFromAFault(t *testing.T) {
for _, c := range []struct {
script string
want bool
}{
{"kill -INT $$", true}, // the signal itself, which is what ssh dies of
{"kill -QUIT $$", true}, // Ctrl-\, the same keystroke story
{"exit 130", true}, // a shell in between, reporting it as 128+SIGINT
{"exit 1", false}, // a remote command that failed
{"exit 255", false}, // ssh's own "could not connect"
{"exit 0", false}, // nothing wrong at all
} {
err := exec.Command("sh", "-c", c.script).Run()
if got := interrupted(err); got != c.want {
t.Errorf("sh -c %q gave %v: interrupted = %v, want %v", c.script, err, got, c.want)
}
}
// Something that never got as far as a child at all is not an interruption.
if interrupted(errf("nothing to run")) {
t.Error("a plain error was taken for a Ctrl-C")
}
}
// The target is one argument and never a piece of a shell command. It comes
// from the guest — a name the guest chose for itself — so a machine that called
// itself "; rm -rf ~" must end up as an ssh host that does not resolve, and not
@@ -110,47 +252,33 @@ func TestOsc52CarriesTheTextItself(t *testing.T) {
}
}
// The menu says why an entry cannot be used rather than leaving it out, and the
// two entries that need somewhere to connect to say exactly that.
func TestTheMenuGreysOutWhatItCannotDo(t *testing.T) {
// The sheet says why a key cannot do anything rather than swallowing it, and the
// two that need somewhere to connect to say exactly that.
func TestTheSheetSaysWhyItCannotConnect(t *testing.T) {
r := testRow("web01", true, "10.0.0.5")
r.vm.Guest = nil
r.vm.Summary.Guest = &types.VirtualMachineGuestSummary{}
b := &browser{rows: []vmRow{r}, view: []int{0}}
menu := b.buildMenu(r, nil)
for _, key := range []rune{'h', 'y'} {
found := false
for _, m := range menu {
if m.key != key {
continue
}
found = true
if m.available() {
t.Errorf("%q is offered on a machine with no address", string(key))
}
if m.hint == "" || m.why == "" {
t.Errorf("%q is greyed out without saying why", string(key))
}
}
if !found {
t.Errorf("the menu has no %q entry", string(key))
for _, k := range []rune{'h', 'y'} {
b.setStatus("", "")
b.detailRune(k)
if !strings.Contains(b.status, "no address") {
t.Errorf("%q on a machine with no address said %q", string(k), b.status)
}
}
// With an address they are there to be used.
r = testRow("web01", true, "10.0.0.5")
for _, m := range b.buildMenu(r, nil) {
if (m.key == 'h' || m.key == 'y') && !m.available() {
t.Errorf("%q is greyed out on a machine with an address: %s", string(m.key), m.why)
}
// With an address there is nothing to object to. Only the check is asked
// here — what follows it is an ssh session and a clipboard.
b.rows[0] = testRow("web01", true, "10.0.0.5")
if !b.hasAddress(b.rows[0]) {
t.Errorf("a machine with an address was refused: %s", b.status)
}
}
// Every letter in the menu reaches exactly one entry, or one of them is
// unreachable — and the harmless group must not have taken a letter the
// dangerous half already uses.
// unreachable — and the snapshot half must not have taken a letter the
// power half already uses.
func TestMenuLettersAreDistinct(t *testing.T) {
b := &browser{}
seen := map[rune]string{}
+5 -3
View File
@@ -69,14 +69,16 @@ smtpport = 25
# -t) nothing is sent. The lines are prefixed "vm," and "ds," respectively.
telemetry = http://monitor.rz-berlin.mpg.de/telemetry.php
# --- ssh, for the action menu's 'h' ---
# --- ssh, for the sheet's 'h' ---
# The command that logs in to a machine's guest from its sheet. "%h" is where
# the guest's own hostname — or its address, when it reports no name — is put;
# it is appended when %h is not written anywhere. Unset means plain "ssh <machine>".
# it is appended when %h is not written anywhere. Unset means "ssh root@%h",
# which is what one logs in to these machines as; a line here replaces it whole,
# root and all.
#
# The target is always one argument and never goes through a shell: it is a name
# the guest chose for itself, and gvm does not run it as a command.
# ssh = ssh -l root %h
# ssh = ssh -l someone %h
# --- the same settings from the environment ---
# Every setting above has an environment spelling that wins over the file:
+1 -1
View File
@@ -1170,7 +1170,7 @@ func TestSimIssuesListing(t *testing.T) {
}
}
// The machine's own events, which is what the action menu's 'e' fetches.
// The machine's own events, which is what 'e' on a machine's sheet fetches.
func TestSimEventsOfOneMachine(t *testing.T) {
quiet(t)
vc := simVCenter(t)
+1 -1
View File
@@ -1 +1 @@
1.1.3
1.1.7