
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 original: 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.
Revisão de 7 de outubro de 2026: repetimos compilação, go vet e testes com -race usando o código extraído do artigo. Acrescentamos cancelamento por Ctrl+C, teste de inventário vazio com logoff e verificações simuladas de ciclo completo, escape, resposta inválida, limite de tamanho e limpeza após cancelamento. A verificação original do XSD não foi repetida; continua sem homologação autenticada em host real.
Fluxo: login, inventário, limpeza e saída
O cliente autentica, recebe uma referência remota de IVirtualBox, solicita as referências das máquinas e consulta UUID, nome e estado de cada uma. Só depois de tentar encerrar a sessão ele devolve o inventário para a saída JSON. Uma referência SOAP identifica um objeto dentro do contexto remoto; não é o UUID persistente da VM.

| Operação | Entrada principal | Resultado esperado |
|---|---|---|
IWebsessionManager_logon |
Usuário e senha. | Referência do objeto IVirtualBox. |
IVirtualBox_getMachines |
_this com a referência recebida. |
Referências das máquinas, não seus UUIDs. |
IMachine_getId/getName/getState |
_this com a referência da máquina. |
Um valor por consulta. |
IWebsessionManager_logoff |
refIVirtualBox. |
Encerramento da sessão remota. |
Para N máquinas, o caminho de sucesso deste exemplo faz 3N + 3 chamadas SOAP: login, listagem, três atributos por VM e logoff. É uma consequência do código sequencial abaixo, não um requisito de todos os clientes VirtualBox. Quanto maior o inventário, mais importante medir latência e prazo total.
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(¶ms)
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 tentar encerrar a sessão
Salve como 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)
}
}
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. O tratamento de interrupção usa signal.NotifyContext, da biblioteca padrão.
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.
O que a limpeza garante — e o que ela não garante
O contexto da coleta agora observa os.Interrupt: Ctrl+C pede cancelamento e permite que a função retorne passando pelo defer. A limpeza ainda pode consumir até cinco segundos adicionais. Somente Ctrl+C/SIGINT foi registrado: SIGTERM não recebe esse tratamento cooperativo no exemplo. Isso também não cobre encerramento forçado do processo, queda da máquina ou perda de comunicação com o serviço.
Se o servidor aceitar o login, mas a resposta se perder antes de o cliente receber a referência, o programa não terá como executar esse logoff. A expiração de sessões do serviço continua importante. Por outro lado, se o logoff falhar depois de uma coleta completa, esta implementação retorna erro e não imprime JSON de sucesso: uma decisão conservadora, documentada, não prova de que a sessão remota permaneceu aberta.
“Somente leitura” descreve as ações sobre VMs: autenticar e encerrar a sessão ainda alteram o estado administrativo do web service. O programa não solicita bloqueio de máquina, não inicia VMs e não modifica discos. A conta de serviço, porém, pode possuir mais permissões que o cliente utiliza; a lista permitida de métodos não substitui isolamento e autorização no ambiente.
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.
Teste também inventário vazio e encerramento
Salve o teste abaixo como empty_test.go, ao lado dos demais arquivos, e repita go test -race -count=1 ./.... Ele exige saída serializada como lista vazia e confirmação de que o cliente tentou o 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())
}
}
Homologação em host real: roteiro de aceitação
- Registre versão do VirtualBox, SDK usado como contrato, configuração do serviço e conta sob a qual ele roda.
- Use um endpoint protegido do laboratório, com autenticação ativa. Não reutilize credenciais de produção em fixtures.
- Compare os UUIDs com
VBoxManage list vmsexecutado no mesmo contexto de usuário do serviço. Consulte estados porVBoxManage showvminfo UUID --machinereadable, substituindo UUID pelo identificador real. - Teste lista vazia, nomes com acentos e caracteres XML, credencial inválida, cancelamento e indisponibilidade.
- Confirme que o programa só consulta atributos e que nenhum comando de escrita foi acrescentado ao adaptador.
- Documente as falhas e os limites encontrados antes de usar o inventário em automação.
O inventário não é uma fotografia atômica: uma VM pode mudar de estado entre consultas. A ordem da lista também não deve ser tratada como contrato. Para comparação automatizada, use UUID como chave e aceite que estados podem mudar durante a coleta.
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.