API do VirtualBox — parte 2: cliente SOAP em Go

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

Vamos implementar um cliente em Go que autentica no web service do VirtualBox, lista UUID, nome e estado das máquinas e encerra a sessão. O programa utiliza somente a biblioteca padrão e produz um inventário JSON, sem executar ações de escrita sobre as VMs.

Esta é a parte 2. Se você ainda não conhece o serviço, leia primeiro a parte 1: SOAP, sessões e segurança na API do VirtualBox. Lá estão a preparação do vboxwebsrv, o túnel SSH e a diferença entre referências e UUIDs.

Pré-requisitos do exemplo

Tenha Go instalado e um host VirtualBox com vboxwebsrv ativo, autenticação configurada e acesso protegido. Os comandos abaixo usam http://127.0.0.1:18083/: execute o cliente no mesmo host ou use o túnel explicado na parte 1. Não desabilite a autenticação nem exponha essa porta diretamente à internet.

Qual contrato foi usado e o que foi testado

As operações e seus parâmetros foram conferidos no WSDL incluído no SDK oficial 7.2.0, usado como referência documental da linha 7.2. Isso não é recomendação para instalar um patch antigo: use uma versão mantida do VirtualBox e confira o SDK correspondente ao seu ambiente.

Validação deste artigo: o código foi compilado, verificado com go vet e testado com servidor SOAP simulado e detector de corridas. Também verificamos estruturas de requisição contra o XSD do SDK. Não houve teste de uma sessão autenticada em um host VirtualBox real. Portanto, a integração precisa passar por homologação no seu laboratório antes de uso operacional.

Os testes adicionais cobriram erro SOAP, XML inválido, resposta excessiva, redirecionamento, cancelamento, escape de caracteres e limpeza de sessão após falha. O exemplo não pretende substituir um SDK completo.

1. Criar o projeto Go

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

O exemplo utiliza errors.Join, disponível a partir do Go 1.20; prefira uma versão atualmente mantida. Não há dependências externas para baixar. Vamos separar o transporte SOAP em client.go e o inventário em main.go.

2. Implementar o transporte SOAP

Salve como client.go. O cliente aceita HTTP apenas para loopback, não segue redirecionamentos, limita a resposta e deixa a validação TLS padrão ativa:

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
}

O xml.Encoder cuida do escape dos valores. Não concatenamos a senha diretamente como XML. O namespace, as operações e o SOAPAction vazio seguem o contrato consultado; erros HTTP e SOAP são tratados separadamente.

Detalhes completos de falhas do servidor foram omitidos da mensagem para reduzir exposição de informações. Em um produto real, você pode classificar os tipos de falha internamente, mantendo segredos e detalhes sensíveis fora dos logs. Veja as referências de encoding/xml e net/http.

3. Listar as máquinas e garantir o logoff

Salve como 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)
	}
}

O defer para limpeza é registrado assim que o login retorna uma referência válida. Ele usa um contexto independente e curto: se a consulta principal expirar, ainda haverá uma tentativa de encerrar a sessão. Uma falha no logoff não é silenciosamente descartada.

A execução é sequencial por clareza. O nome, estado e UUID exigem consultas separadas; não é um coletor otimizado para inventários enormes. O programa interrompe a coleta no primeiro erro e não apresenta um inventário parcial como se fosse completo. A restrição de operações é do cliente didático, não uma política de autorização aplicada pelo servidor.

4. Compilar e executar sem colocar a senha no histórico

No Bash, compile e leia a senha sem escrevê-la na linha de comando:

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

Variáveis de ambiente não são um cofre: processos privilegiados e ferramentas de diagnóstico podem expô-las. Em automação, integre um mecanismo de segredos apropriado e não imprima ambiente, XML de login ou referências de sessão.

Formato ilustrativo de saída, não capturado de um host real:

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

Se a conta não tiver máquinas registradas, a saída será []. Um erro faz o programa terminar com código diferente de zero. Antes de concluir que o inventário está errado, confirme qual ambiente de usuário o serviço está expondo.

5. Um teste automatizado sem hipervisor

Salve este teste como client_test.go. Ele verifica que um SOAP Fault produz erro sem reproduzir os detalhes privados do servidor:

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 ./...

Esse teste exercita o transporte com um servidor simulado. Ele não comprova credenciais, permissões ou compatibilidade operacional com seu VirtualBox. Complete a homologação com uma máquina de laboratório e compare UUID, nome e estado com a administração local.

Falhas comuns e diagnóstico

Problema O que verificar
Conexão recusada Serviço ativo, endereço, porta e túnel SSH.
Falha SOAP no login Autenticação configurada, usuário e senha; não desligue a proteção como correção.
Lista vazia Usuário que executa o serviço e máquinas registradas naquele ambiente.
Referência inválida Sessão expirada ou referência reutilizada de outra execução.
Erro de certificado Nome do servidor e cadeia de confiança; não use InsecureSkipVerify.
Resposta inesperada Endpoint correto, namespace, versão e contrato WSDL.
Prazo excedido Latência, número de VMs e limites configurados.

O programa utiliza dez segundos por requisição, trinta para a coleta e cinco para a limpeza. São escolhas didáticas, não valores universais. Ajuste-os com medição. Não acrescente repetição automática indiscriminada quando evoluir para ações de escrita: um timeout não prova que o servidor deixou de executar a operação.

Quer evoluir de consultas para ações sobre as VMs? Revise na parte 1 os cuidados com sessões de máquina, bloqueios, autorização e operações de escrita.

Como transformar o exemplo em um projeto maior

  • Fixar uma matriz de versões e validar o WSDL correspondente.
  • Separar transporte, autenticação, inventário e operações de escrita.
  • Adicionar testes com respostas reais sanitizadas, além dos simulados.
  • Tratar paginação ou limites da aplicação, cache e prazo total de coleta.
  • Para agentes persistentes, estudar liberação de referências e reconexão, sem acumular objetos indefinidamente.
  • Versionar o contrato público sem expor os detalhes SOAP aos consumidores.

Se o escopo crescer, um cliente gerado a partir do WSDL pode reduzir trabalho repetitivo, mas ainda exige validação de tipos, namespaces e falhas. O pequeno cliente manual deste artigo serve para entender o caminho, não para reimplementar toda a API à mão.

Esse trabalho se conecta à nossa série: arquitetura em Go, Echo e Vue, planejamento com IA e OpenSpec e implementação incremental e testes. Aqui, a base é prática: primeiro consultar corretamente; depois ampliar com evidências.