VirtualBox API — part 2: SOAP client in Go

Mascote LinuxPro e cachorro caramelo cyborg em laboratório com máquinas virtuais, Gopher do Go e logo VirtualBox.

Let's implement a client in Go that authenticates against the VirtualBox web service, lists the UUID, name, and state of the machines, and ends the session. The program uses only the standard library and produces a JSON inventory, without performing any write actions on the VMs.

This is part 2. If you are not yet familiar with the service, first read part 1: SOAP, sessions, and security in the VirtualBox API. It covers preparing the vboxwebsrv, the SSH tunnel, and the difference between references and UUIDs.

Prerequisites for the example

Have Go installed and a VirtualBox host with vboxwebsrv active, authentication configured and protected access. The commands below use http://127.0.0.1:18083/: run the client on the same host or use the tunnel explained in section 1. Do not disable authentication nor expose that port directly to the internet.

Which contract was used and what was tested

The operations and their parameters were verified against the WSDL included in the official SDK 7.2.0, used as documentary reference for the line 7.2. This is not a recommendation to install an old patch: use a maintained version of VirtualBox and check the SDK corresponding to your environment.

Original validation: the code was compiled, verified with go vet and tested with a simulated SOAP server and race detector. We also verified request structures against the SDK's XSD. There was no test of an authenticated session on a real VirtualBox host. Therefore, the integration must go through approval in your lab before operational use.

Additional tests covered SOAP error, invalid XML, oversized response, redirection, cancellation, character escape, and session cleanup after failure. The example is not intended to replace a full SDK.

Review of 7 of October 2026: we repeated compilation, go vet and tests with -race using the code extracted from the article. We added cancellation via Ctrl+C, an empty inventory test with logoff, and simulated checks of full cycle, escape, invalid response, size limit, and cleanup after cancellation. The original XSD check was not repeated; it still lacks authenticated acceptance testing on a real host.

Flow: login, inventory, cleanup, and logout

The client authenticates, receives a remote reference to IVirtualBox, requests the references of the machines and queries the UUID, name and state of each one. Only after attempting to end the session does it return the inventory to the JSON output. A SOAP reference identifies an object within the remote context; it is not the VM's persistent UUID.

Fluxo do cliente Go: login SOAP, leitura das máquinas, tentativa de logoff e saída JSON somente após sucesso.
The requests use a protected endpoint. The cleanup has its own timeout so it can still run after the collection is canceled.
Operation Main entry Expected result
IWebsessionManager_logon User and password. IVirtualBox object reference.
IVirtualBox_getMachines _this with the received reference. Machine references, not their UUIDs.
IMachine_getId/getName/getState _this with the machine reference. One value per query.
IWebsessionManager_logoff refIVirtualBox. Remote session shutdown.

For N machines, the success path of this example makes 3N + 3 SOAP calls: login, listing, three attributes per VM, and logoff. This is a consequence of the sequential code below, not a requirement for all VirtualBox clients. The larger the inventory, the more important it becomes to gauge latency and overall timeout.

1. Create the Go project

mkdir vbox-inventory
cd vbox-inventory
go mod init example.com/vbox-inventory

The example uses errors.Join, available from Go 1.20; prefer a currently maintained version. There are no external dependencies to download. Let's separate the SOAP transport into client.go and the inventory in main.go.

2. Implement the SOAP transport

Save as client.go. The client only accepts HTTP for loopback, does not follow redirects, limits the response, and leaves the default TLS validation enabled:

package main

import (
	"bytes"
	"context"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"time"
)

const apiNS = "http://www.virtualbox.org/"
const soapNS = "http://schemas.xmlsoap.org/soap/envelope/"
const maxResponse = 2 << 20

type client struct {
	endpoint string
	http     *http.Client
}

type field struct{ name, value string }

type reply struct {
	XMLName xml.Name `xml:"http://schemas.xmlsoap.org/soap/envelope/ Envelope"`
	Body    struct {
		Fault  *struct{} `xml:"http://schemas.xmlsoap.org/soap/envelope/ Fault"`
		Result struct {
			XMLName xml.Name
			Values  []string `xml:"returnval"`
		} `xml:",any"`
	} `xml:"http://schemas.xmlsoap.org/soap/envelope/ Body"`
}

func newClient(endpoint string) (*client, error) {
	u, err := url.Parse(endpoint)
	if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
		return nil, errors.New("endpoint inválido: use URL sem credenciais ou parâmetros")
	}
	ip := net.ParseIP(u.Hostname())
	loopback := u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback())
	if u.Scheme != "https" && !(u.Scheme == "http" && loopback) {
		return nil, errors.New("use HTTPS; HTTP só é permitido no loopback")
	}
	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.Proxy = nil // Não encaminhar credenciais por proxy do ambiente.
	return &client{
		endpoint: endpoint,
		http: &http.Client{
			Transport:     transport,
			Timeout:       10 * time.Second,
			CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
		},
	}, nil
}

func (c *client) call(ctx context.Context, method string, fields ...field) (values []string, err error) {
	switch method {
	case "IWebsessionManager_logon", "IWebsessionManager_logoff", "IVirtualBox_getMachines",
		"IMachine_getId", "IMachine_getName", "IMachine_getState":
	default:
		return nil, errors.New("operação fora do escopo somente leitura")
	}
	var params bytes.Buffer
	enc := xml.NewEncoder(&params)
	for _, f := range fields {
		if err := enc.EncodeElement(f.value, xml.StartElement{Name: xml.Name{Local: f.name}}); err != nil {
			return nil, fmt.Errorf("codificar parâmetros: %w", err)
		}
	}
	if err := enc.Flush(); err != nil {
		return nil, fmt.Errorf("finalizar XML: %w", err)
	}
	body := fmt.Sprintf(
		`<s:Envelope xmlns:s="%s" xmlns:v="%s"><s:Body><v:%s>%s</v:%s></s:Body></s:Envelope>`,
		soapNS, apiNS, method, params.String(), method,
	)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewBufferString(body))
	if err != nil {
		return nil, fmt.Errorf("criar requisição: %w", err)
	}
	req.Header.Set("Content-Type", "text/xml; charset=utf-8")
	req.Header.Set("SOAPAction", `""`)
	res, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("transportar %s: %w", method, err)
	}
	defer func() { err = errors.Join(err, res.Body.Close()) }()
	data, err := io.ReadAll(io.LimitReader(res.Body, maxResponse+1))
	if err != nil {
		return nil, fmt.Errorf("ler resposta: %w", err)
	}
	if len(data) > maxResponse {
		return nil, errors.New("resposta excede 2 MiB")
	}
	var decoded reply
	decodeErr := xml.Unmarshal(data, &decoded)
	if decodeErr == nil && decoded.Body.Fault != nil {
		// Não imprimir faultstring/detail: podem revelar informações do host.
		return nil, fmt.Errorf("falha SOAP em %s (HTTP %d)", method, res.StatusCode)
	}
	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s: HTTP %d", method, res.StatusCode)
	}
	if decodeErr != nil {
		return nil, fmt.Errorf("decodificar resposta: %w", decodeErr)
	}
	result := decoded.Body.Result
	if result.XMLName.Space != apiNS || result.XMLName.Local != method+"Response" {
		return nil, errors.New("resposta SOAP inesperada")
	}
	return result.Values, nil
}

func (c *client) one(ctx context.Context, method string, fields ...field) (string, error) {
	values, err := c.call(ctx, method, fields...)
	if err != nil {
		return "", err
	}
	if len(values) != 1 || values[0] == "" {
		return "", errors.New("retorno escalar vazio ou inválido")
	}
	return values[0], nil
}

The xml.Encoder takes care of escaping values. We don't concatenate the password directly as XML. The namespace, operations and the SOAPAction empty follow the consulted contract; HTTP and SOAP errors are handled separately.

Full server failure details were omitted from the message to reduce information exposure. In a real product, you can classify the failure types internally, keeping secrets and sensitive details out of the logs. See the references for encoding/xml and net/http.

3. List the machines and attempt to end the session

Save as main.go:

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"time"
)

type machine struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	State string `json:"state"`
}

func inventory(ctx context.Context, c *client, username, password string) (vms []machine, err error) {
	ref, err := c.one(ctx, "IWebsessionManager_logon",
		field{name: "username", value: username},
		field{name: "password", value: password},
	)
	if err != nil {
		return nil, err
	}
	defer func() {
		// A limpeza precisa funcionar mesmo se o contexto principal expirar.
		cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_, closeErr := c.call(cleanup, "IWebsessionManager_logoff", field{name: "refIVirtualBox", value: ref})
		if closeErr != nil {
			err = errors.Join(err, fmt.Errorf("encerrar sessão: %w", closeErr))
		}
	}()
	refs, err := c.call(ctx, "IVirtualBox_getMachines", field{name: "_this", value: ref})
	if err != nil {
		return nil, err
	}
	vms = make([]machine, 0, len(refs))
	for _, machineRef := range refs {
		target := field{name: "_this", value: machineRef}
		id, err := c.one(ctx, "IMachine_getId", target)
		if err != nil {
			return nil, err
		}
		name, err := c.one(ctx, "IMachine_getName", target)
		if err != nil {
			return nil, err
		}
		state, err := c.one(ctx, "IMachine_getState", target)
		if err != nil {
			return nil, err
		}
		vms = append(vms, machine{ID: id, Name: name, State: state})
	}
	return vms, nil
}

func run() error {
	endpoint := os.Getenv("VBOX_URL")
	if endpoint == "" {
		endpoint = "http://127.0.0.1:18083/"
	}
	username, password := os.Getenv("VBOX_USER"), os.Getenv("VBOX_PASSWORD")
	if username == "" || password == "" {
		return errors.New("defina VBOX_USER e VBOX_PASSWORD")
	}
	c, err := newClient(endpoint)
	if err != nil {
		return err
	}
	defer c.http.CloseIdleConnections()
	shutdown, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()
	ctx, cancel := context.WithTimeout(shutdown, 30*time.Second)
	defer cancel()
	vms, err := inventory(ctx, c, username, password)
	if err != nil {
		return err
	}
	out := json.NewEncoder(os.Stdout)
	out.SetIndent("", "  ")
	if err := out.Encode(vms); err != nil {
		return fmt.Errorf("escrever inventário: %w", err)
	}
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "erro:", err)
		os.Exit(1)
	}
}

The defer for cleanup is registered as soon as login returns a valid reference. It uses an independent, short-lived context: if the main query times out, there will still be one attempt to end the session. A logoff failure is not silently dropped. The interrupt handling uses signal.NotifyContext, from the standard library.

Execution is sequential for clarity. The name, state, and UUID require separate queries; it's not an optimized collector for huge inventories. The program stops the collection at the first error and does not present a partial inventory as if it were complete. The operations restriction is from the didactic client, not an authorization policy enforced by the server.

What cleanup guarantees — and what it does not guarantee

The collection context now observes os.Interrupt: Ctrl+C requests cancellation and allows the function to return going through defer. Cleanup may still consume up to five extra seconds. Only Ctrl+C/SIGINT was registered: SIGTERM does not receive this cooperative handling in the example. This also does not cover forced process termination, machine crash, or loss of communication with the service.

If the server accepts the login, but the response is lost before the client receives the reference, the program will have no way to perform that logoff. Session expiration on the service remains important. On the other hand, if the logoff fails after a completed collection, this implementation returns an error and does not print success JSON: a conservative, documented decision, not proof that the remote session remained open.

“Read-only” describes the actions on VMs: authenticating and ending the session still change the administrative state of the web service. The program does not request machine lock, does not start VMs, and does not modify disks. The service account, however, may have more permissions than the client uses; the allowed list of methods does not replace isolation and authorization in the environment.

4. Compile and run without putting the password in the shell history

In Bash, compile and read the password without writing it on the command line:

gofmt -w client.go main.go
go vet ./...
go build -o vbox-inventory .

export VBOX_URL='http://127.0.0.1:18083/'
IFS= read -r -p 'Usuário do serviço: ' VBOX_USER
export VBOX_USER
IFS= read -r -s -p 'Senha do serviço: ' VBOX_PASSWORD
printf '\n'
export VBOX_PASSWORD
./vbox-inventory
unset VBOX_PASSWORD

Environment variables aren't a vault: privileged processes and diagnostic tools can expose them. In automation, integrate an appropriate secrets mechanism and don't print environment, login XML, or session references.

Illustrative output format, not captured from a real host:

[
  {
    "id": "11111111-2222-3333-4444-555555555555",
    "name": "Linux lab",
    "state": "PoweredOff"
  }
]

If the account has no registered machines, the output will be []. An error causes the program to exit with a non-zero code. Before concluding that the inventory is wrong, confirm which user environment the service is exposing.

5. An automated test without a hypervisor

Save this test as client_test.go. It checks that a SOAP Fault produces an error without reproducing the server's private details:

package main

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestSOAPFaultIsNotSuccess(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusInternalServerError)
		body := `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">` +
			`<s:Body><s:Fault><faultstring>private-host-detail</faultstring></s:Fault></s:Body></s:Envelope>`
		if _, err := io.WriteString(w, body); err != nil {
			t.Error(err)
		}
	}))
	defer server.Close()
	c, err := newClient(server.URL)
	if err != nil {
		t.Fatal(err)
	}
	defer c.http.CloseIdleConnections()
	_, err = c.call(context.Background(), "IVirtualBox_getMachines", field{name: "_this", value: "test-ref"})
	if err == nil {
		t.Fatal("SOAP Fault deveria produzir erro")
	}
	if strings.Contains(err.Error(), "private-host-detail") {
		t.Fatal("erro expôs detalhes do host")
	}
}
go test -race -count=1 ./...

This test exercises the transport with a simulated server. It does not verify credentials, permissions, or operational compatibility with your VirtualBox. Complete the homologation with a lab machine and compare UUID, name, and state with the local administration.

Also test empty inventory and termination

Save the test below as empty_test.go, alongside the other files, and repeat go test -race -count=1 ./.... It requires serialized output as an empty list and confirmation that the client attempted the logoff:

package main

import (
	"context"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
)

func TestEmptyInventoryLogsOff(t *testing.T) {
	var loggedOff atomic.Bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Body struct {
				Method struct{ XMLName xml.Name } `xml:",any"`
			} `xml:"Body"`
		}
		if err := xml.NewDecoder(r.Body).Decode(&req); err != nil {
			t.Error(err)
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		method := req.Body.Method.XMLName.Local
		value := ""
		switch method {
		case "IWebsessionManager_logon":
			value = "<returnval>test-session</returnval>"
		case "IVirtualBox_getMachines":
		case "IWebsessionManager_logoff":
			loggedOff.Store(true)
		default:
			t.Errorf("operação inesperada: %s", method)
		}
		body := fmt.Sprintf(
			`<s:Envelope xmlns:s="%s"><s:Body><v:%sResponse xmlns:v="%s">%s</v:%sResponse></s:Body></s:Envelope>`,
			soapNS, method, apiNS, value, method,
		)
		if _, err := io.WriteString(w, body); err != nil {
			t.Error(err)
		}
	}))
	defer server.Close()
	c, err := newClient(server.URL)
	if err != nil {
		t.Fatal(err)
	}
	defer c.http.CloseIdleConnections()
	vms, err := inventory(context.Background(), c, "lab-user", "lab-password")
	if err != nil {
		t.Fatal(err)
	}
	data, err := json.Marshal(vms)
	if err != nil {
		t.Fatal(err)
	}
	if string(data) != "[]" || !loggedOff.Load() {
		t.Fatalf("inventário=%s; logoff=%v", data, loggedOff.Load())
	}
}

Acceptance testing on a real host: acceptance script

  1. Record the VirtualBox version, SDK used as contract, service configuration, and the account under which it runs.
  2. Use a protected lab endpoint, with authentication enabled. Do not reuse production credentials in fixtures.
  3. Compare UUIDs with VBoxManage list vms run in the same user context as the service. Query states by VBoxManage showvminfo UUID --machinereadable, replacing UUID with the actual identifier.
  4. Test empty list, names with accents and XML characters, invalid credential, cancellation, and unavailability.
  5. Confirm that the program only queries attributes and that no write command was added to the adapter.
  6. Document the failures and limits found before using it in inventory. The output and.

The inventory is not an atomic snapshot: a VM can change state between queries. The order of the list should also not be treated as a contract. For automated comparison, use UUID as the key and accept that states may change during collection.

Common failures and diagnosis

Problem What to check
Connection refused Active service, address, port, and SSH tunnel.
SOAP failure on login Authentication configured, username and password; do not disable protection as a fix.
Empty list User running the service and machines registered in that environment.
Invalid reference Session expired or reference reused from another run.
Certificate error Server name and trust chain; do not use InsecureSkipVerify.
Unexpected response Correct endpoint, namespace, version, and WSDL contract.
Timeout exceeded Latency, number of VMs, and configured limits.

The program uses ten seconds per request, thirty for collection, and five for cleanup. These are didactic choices, not universal values. Adjust them with measurement. Do not add indiscriminate automatic retry when evolving into write actions: a timeout does not prove that the server stopped executing the operation.

Want to evolve from queries to actions on VMs? Review in part 1 the care with machine sessions, locks, authorization, and write operations.

How to turn the example into a larger project

  • Fix a matrix of versions and validate the corresponding WSDL.
  • Separate transport, authentication, inventory, and write operations.
  • Add tests with sanitized real responses, in addition to the simulated ones.
  • Handle pagination or application limits, caching, and total collection time.
  • For persistent agents, study reference release and reconnection, without accumulating objects indefinitely.
  • Version the public contract without exposing SOAP details to consumers.

If the scope grows, a client generated from the WSDL can reduce repetitive work, but still requires validation of types, namespaces, and failures. The small manual client in this article serves to understand the path, not to reimplement the entire API by hand.

This work connects to our series: architecture in Go, Echo, and Vue, planning with AI and OpenSpec and incremental implementation and testing. Here, the base is practical: first query correctly; then expand with evidence.