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.

Validation of this article: 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.

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 ensure logoff

Save as main.go:

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"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()
	ctx, cancel := context.WithTimeout(context.Background(), 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 logged 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 an attempt to end the session. A logout failure isn't silently discarded.

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.

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.

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.