diff --git a/cmd/toolbox/main.go b/cmd/toolbox/main.go index b730fc853a..a9817f85bd 100644 --- a/cmd/toolbox/main.go +++ b/cmd/toolbox/main.go @@ -15,15 +15,20 @@ package main import ( + "flag" "fmt" "log" "os" + "os/signal" + "syscall" "github.com/vmware/vic/pkg/vsphere/toolbox" ) // This example can be run on a VM hosted by ESX, Fusion or Workstation func main() { + flag.Parse() + in := toolbox.NewBackdoorChannelIn() out := toolbox.NewBackdoorChannelOut() @@ -38,12 +43,26 @@ func main() { return -1, nil } + power := toolbox.RegisterPowerCommandHandler(service) + + if os.Getuid() == 0 { + power.Halt.Handler = toolbox.Halt + power.Reboot.Handler = toolbox.Reboot + } + err := service.Start() if err != nil { log.Fatal(err) } - defer service.Stop() + // handle the signals and gracefully shutdown the service + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("signal %s received", <-sig) + service.Stop() + }() service.Wait() } diff --git a/pkg/vsphere/toolbox/backdoor.go b/pkg/vsphere/toolbox/backdoor.go index 0aa8cae299..c69a25272b 100644 --- a/pkg/vsphere/toolbox/backdoor.go +++ b/pkg/vsphere/toolbox/backdoor.go @@ -65,7 +65,7 @@ func NewBackdoorChannelOut() Channel { } } -// NewBackdoorChannelOut creates a Channel for use with the TCLO protocol +// NewBackdoorChannelIn creates a Channel for use with the TCLO protocol func NewBackdoorChannelIn() Channel { return &backdoorChannel{ protocol: tcloProtocol, diff --git a/pkg/vsphere/toolbox/backdoor_test.go b/pkg/vsphere/toolbox/backdoor_test.go index 637bd11229..1bc8040d35 100644 --- a/pkg/vsphere/toolbox/backdoor_test.go +++ b/pkg/vsphere/toolbox/backdoor_test.go @@ -14,4 +14,35 @@ package toolbox +import "testing" + var _ Channel = new(backdoorChannel) + +func TestBackdoorChannel(t *testing.T) { + in := NewBackdoorChannelIn() + out := NewBackdoorChannelOut() + + funcs := []func() error{ + in.Start, + out.Start, + in.Stop, + out.Stop, + } + + for _, f := range funcs { + err := f() + + if err != nil { + if err == ErrNotVirtualWorld { + t.SkipNow() + } + t.Fatal(err) + } + } + + // expect an error if we don't specify the protocol + err := new(backdoorChannel).Start() + if err == nil { + t.Error("expected error") + } +} diff --git a/pkg/vsphere/toolbox/channel.go b/pkg/vsphere/toolbox/channel.go index 5e3356a83a..e8d3693e1b 100644 --- a/pkg/vsphere/toolbox/channel.go +++ b/pkg/vsphere/toolbox/channel.go @@ -14,6 +14,11 @@ package toolbox +import ( + "bytes" + "fmt" +) + // Channel abstracts the guest<->vmx RPC transport type Channel interface { Start() error @@ -21,3 +26,31 @@ type Channel interface { Send([]byte) error Receive() ([]byte, error) } + +var ( + rpciOK = []byte{'1', ' '} + rpciERR = []byte{'0', ' '} +) + +// ChannelOut extends Channel to provide RPCI protocol helpers +type ChannelOut struct { + Channel +} + +// Request sends an RPC command to the vmx and checks the return code for success or error +func (c *ChannelOut) Request(request []byte) ([]byte, error) { + if err := c.Send(request); err != nil { + return nil, err + } + + reply, err := c.Receive() + if err != nil { + return nil, err + } + + if bytes.HasPrefix(reply, rpciOK) { + return reply[2:], nil + } + + return nil, fmt.Errorf("request %q: %q", string(request), string(reply)) +} diff --git a/pkg/vsphere/toolbox/power.go b/pkg/vsphere/toolbox/power.go new file mode 100644 index 0000000000..ef90202e8b --- /dev/null +++ b/pkg/vsphere/toolbox/power.go @@ -0,0 +1,114 @@ +// Copyright 2016 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toolbox + +import ( + "fmt" + "log" + "os/exec" +) + +// GuestOsState enum as defined in open-vm-tools/lib/include/vmware/guestrpc/powerops.h +const ( + _ = iota + powerStateHalt + powerStateReboot + powerStatePowerOn + powerStateResume + powerStateSuspend +) + +var ( + shutdown = "/sbin/shutdown" +) + +type PowerCommand struct { + Handler func() error + + out *ChannelOut + state int + name string +} + +type PowerCommandHandler struct { + Halt PowerCommand + Reboot PowerCommand + PowerOn PowerCommand + Resume PowerCommand + Suspend PowerCommand +} + +func RegisterPowerCommandHandler(service *Service) *PowerCommandHandler { + handler := new(PowerCommandHandler) + + handlers := map[string]struct { + cmd *PowerCommand + state int + }{ + "OS_Halt": {&handler.Halt, powerStateHalt}, + "OS_Reboot": {&handler.Reboot, powerStateReboot}, + "OS_PowerOn": {&handler.PowerOn, powerStatePowerOn}, + "OS_Resume": {&handler.Resume, powerStateResume}, + "OS_Suspend": {&handler.Suspend, powerStateSuspend}, + } + + for name, h := range handlers { + *h.cmd = PowerCommand{ + name: name, + state: h.state, + out: service.out, + } + + service.RegisterHandler(name, h.cmd.Dispatch) + } + + return handler +} + +func (c *PowerCommand) Dispatch([]byte) ([]byte, error) { + rc := rpciOK + + log.Printf("dispatching power op %q", c.name) + + if c.Handler == nil { + if c.state == powerStateHalt || c.state == powerStateReboot { + rc = rpciERR + } + } + + msg := fmt.Sprintf("tools.os.statechange.status %s%d\x00", rc, c.state) + + if _, err := c.out.Request([]byte(msg)); err != nil { + log.Printf("unable to send %q: %q", msg, err) + } + + if c.Handler != nil { + if err := c.Handler(); err != nil { + log.Printf("%s: %s", c.name, err) + } + } + + return nil, nil +} + +func Halt() error { + log.Printf("Halting system...") + return exec.Command(shutdown, "-h", "now").Run() +} + +func Reboot() error { + log.Printf("Rebooting system...") + return exec.Command(shutdown, "-r", "now").Run() +} diff --git a/pkg/vsphere/toolbox/power_test.go b/pkg/vsphere/toolbox/power_test.go new file mode 100644 index 0000000000..124875492b --- /dev/null +++ b/pkg/vsphere/toolbox/power_test.go @@ -0,0 +1,51 @@ +// Copyright 2016 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package toolbox + +import ( + "errors" + "testing" +) + +func TestPowerCommandHandler(t *testing.T) { + shutdown = "/bin/echo" + + in := new(mockChannelIn) + out := new(mockChannelOut) + + service := NewService(in, out) + power := RegisterPowerCommandHandler(service) + + // cover nil Handler and out.Receive paths + _, _ = power.Halt.Dispatch(nil) + + out.reply = append(out.reply, rpciOK, rpciOK) + + power.Halt.Handler = Halt + power.Reboot.Handler = Reboot + power.Suspend.Handler = func() error { + return errors.New("an error") + } + + commands := []PowerCommand{ + power.Halt, + power.Reboot, + power.Suspend, + } + + for _, cmd := range commands { + _, _ = cmd.Dispatch(nil) + } +} diff --git a/pkg/vsphere/toolbox/service.go b/pkg/vsphere/toolbox/service.go index 20e9f40962..700d8709d7 100644 --- a/pkg/vsphere/toolbox/service.go +++ b/pkg/vsphere/toolbox/service.go @@ -24,16 +24,31 @@ import ( "time" ) +const ( + // TOOLS_VERSION_UNMANAGED as defined in open-vm-tools/lib/include/vm_tools_version.h + toolsVersionUnmanaged = 0x7fffffff +) + +var ( + capabilities = []string{ + // Without tools.set.version, the UI reports Tools are "running", but "not installed" + fmt.Sprintf("tools.set.version %d", toolsVersionUnmanaged), + + // Required to invoke guest power operations (shutdown, reboot) + "tools.capability.statechange", + } +) + // Service receives and dispatches incoming RPC requests from the vmx type Service struct { name string in Channel - out Channel + out *ChannelOut handlers map[string]Handler stop chan struct{} wg *sync.WaitGroup + delay time.Duration - Interval time.Duration PrimaryIP func() string } @@ -42,12 +57,11 @@ func NewService(rpcIn Channel, rpcOut Channel) *Service { s := &Service{ name: "toolbox", // Same name used by vmtoolsd in: NewTraceChannel(rpcIn), - out: NewTraceChannel(rpcOut), + out: &ChannelOut{NewTraceChannel(rpcOut)}, handlers: make(map[string]Handler), wg: new(sync.WaitGroup), stop: make(chan struct{}, 1), - Interval: time.Second, PrimaryIP: DefaultIP, } @@ -59,6 +73,24 @@ func NewService(rpcIn Channel, rpcOut Channel) *Service { return s } +// backoff exponentially increases the RPC poll delay up to maxDelay +func (s *Service) backoff() { + const maxDelay = 10 // rpcChannelInt.h:RPCIN_MAX_DELAY + + if s.delay < maxDelay { + if s.delay > 0 { + d := s.delay * 2 + if d > s.delay && d < maxDelay { + s.delay = d + } else { + s.delay = maxDelay + } + } else { + s.delay = 1 + } + } +} + // Start initializes the RPC channels and starts a goroutine to listen for incoming RPC requests func (s *Service) Start() error { err := s.in.Start() @@ -71,26 +103,33 @@ func (s *Service) Start() error { return err } - ticker := time.NewTicker(s.Interval) - s.wg.Add(1) go func() { defer s.wg.Done() + // Same polling interval and backoff logic as vmtoolsd. + // Required in our case at startup at least, otherwise it is possible + // we miss the 1 Capabilities_Register call for example. + + // Note we Send(response) even when nil, to let the VMX know we are here + var response []byte + for { select { - case <-ticker.C: - _ = s.in.Send(nil) // POKE + case <-time.After(time.Millisecond * 10 * s.delay): + _ = s.in.Send(response) + response = nil request, _ := s.in.Receive() if len(request) > 0 { - response := s.Dispatch(request) + response = s.Dispatch(request) - _ = s.in.Send(response) + s.delay = 0 + } else { + s.backoff() } case <-s.stop: - ticker.Stop() return } } @@ -131,7 +170,7 @@ func (s *Service) Dispatch(request []byte) []byte { handler, ok := s.handlers[string(name)] if !ok { - log.Printf("unknown command: '%s'\n", name) + log.Printf("unknown command: %q\n", name) return []byte("Unknown Command") } @@ -168,7 +207,7 @@ func (s *Service) SetOption(args []byte) ([]byte, error) { val := string(opts[1]) if Trace { - fmt.Fprintf(os.Stderr, "set option '%s'='%s'\n", key, val) + fmt.Fprintf(os.Stderr, "set option %q=%q\n", key, val) } switch key { @@ -176,7 +215,10 @@ func (s *Service) SetOption(args []byte) ([]byte, error) { if val == "1" { ip := s.PrimaryIP() msg := fmt.Sprintf("info-set guestinfo.ip %s", ip) - return nil, s.out.Send([]byte(msg)) + _, err := s.out.Request([]byte(msg)) + if err != nil { + return nil, err + } } default: // TODO: handle other options... @@ -203,6 +245,12 @@ func DefaultIP() string { } func (s *Service) CapabilitiesRegister([]byte) ([]byte, error) { - // TODO: this is here just to make Fusion happy. ESX doesn't seem to mind if we don't support this RPC + for _, cap := range capabilities { + _, err := s.out.Request([]byte(cap)) + if err != nil { + log.Printf("send %q: %s", cap, err) + } + } + return nil, nil } diff --git a/pkg/vsphere/toolbox/service_test.go b/pkg/vsphere/toolbox/service_test.go index b477bb7819..16d27b2655 100644 --- a/pkg/vsphere/toolbox/service_test.go +++ b/pkg/vsphere/toolbox/service_test.go @@ -15,9 +15,14 @@ package toolbox import ( + "bytes" + "errors" + "flag" "io" + "io/ioutil" + "log" + "sync" "testing" - "time" ) func TestDefaultIP(t *testing.T) { @@ -25,7 +30,6 @@ func TestDefaultIP(t *testing.T) { if ip == "" { t.Error("failed to get a default IP address") } - t.Logf("DefaultIP=%s", ip) } type testRPC struct { @@ -37,6 +41,7 @@ type mockChannelIn struct { t *testing.T service *Service rpc []*testRPC + wg sync.WaitGroup } func (c *mockChannelIn) Start() error { @@ -49,8 +54,11 @@ func (c *mockChannelIn) Stop() error { func (c *mockChannelIn) Receive() ([]byte, error) { if len(c.rpc) == 0 { - // Stop the service after all test RPC requests have been consumed - defer c.service.Stop() + if c.rpc != nil { + // All test RPC requests have been consumed + c.wg.Done() + c.rpc = nil + } return nil, io.EOF } @@ -64,7 +72,7 @@ func (c *mockChannelIn) Send(buf []byte) error { expect := c.rpc[0].expect if string(buf) != expect { - c.t.Errorf("expected '%s' reply for request '%s', got: '%s'", expect, c.rpc[0].cmd, string(buf)) + c.t.Errorf("expected %q reply for request %q, got: %q", expect, c.rpc[0].cmd, string(buf)) } c.rpc = c.rpc[1:] @@ -73,7 +81,9 @@ func (c *mockChannelIn) Send(buf []byte) error { } // discard rpc out for now -type mockChannelOut struct{} +type mockChannelOut struct { + reply [][]byte +} func (c *mockChannelOut) Start() error { return nil @@ -84,21 +94,36 @@ func (c *mockChannelOut) Stop() error { } func (c *mockChannelOut) Receive() ([]byte, error) { - panic("receive on out channel") + if len(c.reply) == 0 { + return nil, io.EOF + } + reply := c.reply[0] + c.reply = c.reply[1:] + return reply, nil } func (c *mockChannelOut) Send(buf []byte) error { + if len(buf) == 0 { + return io.ErrShortBuffer + } return nil } func TestServiceRun(t *testing.T) { - Trace = testing.Verbose() + Trace = true + if !testing.Verbose() { + // cover TraceChannel but discard output + traceLog = ioutil.Discard + } in := new(mockChannelIn) out := new(mockChannelOut) service := NewService(in, out) - service.Interval = time.Millisecond + + service.RegisterHandler("Sorry", func([]byte) ([]byte, error) { + return nil, errors.New("i am so sorry") + }) in.rpc = []*testRPC{ {"reset", "OK ATR toolbox"}, @@ -106,6 +131,22 @@ func TestServiceRun(t *testing.T) { {"Set_Option synctime 0", "OK "}, {"NOPE", "Unknown Command"}, {"Set_Option broadcastIP 1", "OK "}, + {"Capabilities_Register", "OK "}, + {"Sorry", "ERR "}, + } + + in.wg.Add(1) + + foo := []byte("foo") + out.reply = [][]byte{ + rpciOK, + append(rpciOK, foo...), + rpciERR, + } + + for i := 0; i < len(capabilities); i++ { + // prepend an OK for each capability + out.reply = append([][]byte{rpciOK}, out.reply...) } in.service = service @@ -117,5 +158,95 @@ func TestServiceRun(t *testing.T) { t.Fatal(err) } + in.wg.Wait() + + // Done serving RPCs, test ChannelOut errors + reply, err := service.out.Request(rpciOK) + if err != nil { + t.Error(err) + } + + if !bytes.Equal(reply, foo) { + t.Errorf("reply=%s", string(foo)) + } + + _, err = service.out.Request(rpciOK) + if err == nil { + t.Error("expected error") + } + + _, err = service.out.Request(nil) + if err == nil { + t.Error("expected error") + } + + service.Stop() + service.Wait() +} + +var ( + testESX = flag.Bool("toolbox.testesx", false, "Test toolbox service against ESX (vmtoolsd must not be running)") + testPID = flag.Int("toolbox.testpid", 0, "PID to return from toolbox start command") +) + +func TestServiceRunESX(t *testing.T) { + if *testESX == false { + t.SkipNow() + } + + Trace = testing.Verbose() + + var wg sync.WaitGroup + + in := NewBackdoorChannelIn() + out := NewBackdoorChannelOut() + + service := NewService(in, out) + + // assert that reset, ping, Set_Option and Capabilities_Register are called at least once + for name, handler := range service.handlers { + n := name + h := handler + wg.Add(1) + + service.handlers[name] = func(b []byte) ([]byte, error) { + defer wg.Done() + + service.handlers[n] = h // reset + + return h(b) + } + } + + vix := RegisterVixRelayedCommandHandler(service) + + if *testPID != 0 { + wg.Add(1) + vix.ProcessStartCommand = func(r *VixMsgStartProgramRequest) (int, error) { + defer wg.Done() + + if r.ProgramPath != "/bin/date" { + t.Errorf("ProgramPath=%q", r.ProgramPath) + } + + return *testPID, nil + } + } + + wg.Add(1) + service.PrimaryIP = func() string { + defer wg.Done() + log.Print("broadcasting IP") + return DefaultIP() + } + + err := service.Start() + if err != nil { + log.Fatal(err) + } + + wg.Wait() + + service.Stop() service.Wait() } diff --git a/pkg/vsphere/toolbox/toolbox-test.sh b/pkg/vsphere/toolbox/toolbox-test.sh new file mode 100755 index 0000000000..8bda9b9d33 --- /dev/null +++ b/pkg/vsphere/toolbox/toolbox-test.sh @@ -0,0 +1,145 @@ +#!/bin/bash -e +# Copyright 2016 VMware, Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Create (or reuse) a VM to run toolbox and/or toolbox.test +# Requires ESX to be configured with: +# govc host.esxcli system settings advanced set -o /Net/GuestIPHack -i 1 + +set -o pipefail + +vm="toolbox-test-$(uuidgen)" +destroy=true +verbose=false + +while getopts n:stv flag +do + case $flag in + n) + vm=$OPTARG + unset destroy + ;; + s) + start=true + ;; + t) + test=true + ;; + v) + verbose=true + ;; + *) + echo "unknown option" 1>&2 + exit 1 + ;; + esac +done + +echo "Building toolbox binaries..." +pushd "$(git rev-parse --show-toplevel)" >/dev/null +go install -v ./cmd/toolbox +go test -i -c ./pkg/vsphere/toolbox -o "$GOPATH/bin/toolbox.test" +popd >/dev/null + +iso=coreos_production_iso_image.iso + +if ! govc datastore.ls $iso 1>/dev/null 2>&1 ; then + echo "Downloading ${iso}..." + if [ ! -e $iso ] ; then + wget http://beta.release.core-os.net/amd64-usr/current/$iso + fi + + echo "Uploading ${iso}..." + govc datastore.upload $iso $iso +fi + +if [ ! -e config.iso ] ; then + echo "Generating config.iso..." + keys=$(cat ~/.ssh/id_[rd]sa.pub) + + dir=$(mktemp -d toolbox.XXXXXX) + pushd "${dir}" >/dev/null + + mkdir -p drive/openstack/latest + + cat > drive/openstack/latest/user_data </dev/null + + mv -f "$dir/config.iso" . + rm -rf "$dir" +fi + +destroy() { + echo "Destroying VM ${vm}..." + govc vm.destroy "$vm" + govc datastore.rm -f "$vm" +} + +if ! govc datastore.ls "$vm/${vm}.vmx" 1>/dev/null 2>&1 ; then + echo "Creating VM ${vm}..." + govc vm.create -g otherGuest64 -m 1024 -on=false "$vm" + + if [ -n "$destroy" ] ; then + trap destroy EXIT + fi + + device=$(govc device.cdrom.add -vm "$vm") + govc device.cdrom.insert -vm "$vm" -device "$device" $iso + + govc datastore.upload config.iso "$vm/config.iso" >/dev/null + device=$(govc device.cdrom.add -vm "$vm") + govc device.cdrom.insert -vm "$vm" -device "$device" "$vm/config.iso" + + govc vm.power -on "$vm" +fi + +echo -n "Waiting for ${vm} ip..." +ip=$(govc vm.ip -esxcli "$vm") + +opts=(-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=error -o BatchMode=yes) + +scp "${opts[@]}" "$GOPATH"/bin/toolbox{,.test} "core@${ip}:" + +if [ -n "$test" ] ; then + pid=$RANDOM + echo "Running toolbox tests..." + ssh "${opts[@]}" "core@${ip}" ./toolbox.test -test.v=$verbose -test.run TestServiceRunESX -toolbox.testesx -toolbox.testpid=$pid & + + echo "Waiting for VM ip from toolbox..." + ip=$(govc vm.ip "$vm") + echo "toolbox vm.ip=$ip" + + echo "Testing guest operations via govc..." + out=$(govc guest.start -vm "$vm" -l user:pass /bin/date) + + if [ "$out" != "$pid" ] ; then + echo "'$out' != '$pid'" 1>&2 + fi + + echo "Waiting for tests to complete..." + wait +fi + +if [ -n "$start" ] ; then + echo "Starting toolbox..." + ssh "${opts[@]}" "core@${ip}" ./toolbox -toolbox.trace=$verbose +fi diff --git a/pkg/vsphere/toolbox/trace_channel.go b/pkg/vsphere/toolbox/trace_channel.go index 9d42650ff0..8550823fc2 100644 --- a/pkg/vsphere/toolbox/trace_channel.go +++ b/pkg/vsphere/toolbox/trace_channel.go @@ -16,15 +16,22 @@ package toolbox import ( "encoding/hex" + "flag" "fmt" "io" "os" ) var ( - Trace = true // TODO: make optional + Trace = false + + traceLog io.Writer = os.Stderr ) +func init() { + flag.BoolVar(&Trace, "toolbox.trace", Trace, "Enable toolbox trace") +} + type TraceChannel struct { Channel log io.Writer @@ -37,7 +44,7 @@ func NewTraceChannel(c Channel) Channel { return &TraceChannel{ Channel: c, - log: os.Stderr, + log: traceLog, } } diff --git a/pkg/vsphere/toolbox/vix_command.go b/pkg/vsphere/toolbox/vix_command.go index 165b21fa9b..ed1dae2fec 100644 --- a/pkg/vsphere/toolbox/vix_command.go +++ b/pkg/vsphere/toolbox/vix_command.go @@ -79,7 +79,7 @@ type VixMsgStartProgramRequest struct { type VixCommandHandler func(string, VixCommandRequestHeader, []byte) ([]byte, error) type VixRelayedCommandHandler struct { - Out Channel + Out *ChannelOut ProcessStartCommand func(*VixMsgStartProgramRequest) (int, error) @@ -122,7 +122,7 @@ func (c *VixRelayedCommandHandler) Dispatch(data []byte) ([]byte, error) { } if Trace { - fmt.Fprintf(os.Stderr, "vix dispatch '%s'...\n%s\n", name, hex.Dump(data)) + fmt.Fprintf(os.Stderr, "vix dispatch %q...\n%s\n", name, hex.Dump(data)) } var header VixCommandRequestHeader