
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(¶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 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.