API de VirtualBox — parte 2: cliente SOAP en Go

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

Vamos a implementar un cliente en Go que se autentica en el servicio web de VirtualBox, lista el UUID, el nombre y el estado de las máquinas y cierra la sesión. El programa utiliza solo la biblioteca estándar y produce un inventario JSON, sin ejecutar acciones de escritura sobre las VMs.

Esta es la parte 2. Si aún no conoces el servicio, lee primero la parte 1: SOAP, sesiones y seguridad en la API de VirtualBox. Allí se explican la preparación del vboxwebsrv, el túnel SSH y la diferencia entre referencias y UUIDs.

Requisitos previos del ejemplo

Ten Go instalado y un host VirtualBox con vboxwebsrv activo, autenticación configurada y acceso protegido. Los comandos siguientes usan http://127.0.0.1:18083/: ejecuta el cliente en el mismo host o utiliza el túnel explicado en la parte 1. No deshabilites la autenticación ni expongas este puerto directamente a internet.

Qué contrato se ha utilizado y qué se ha probado

Las operaciones y sus parámetros se han revisado en el WSDL incluido en el SDK oficial 7.2.0, utilizado como referencia documental de la línea 7.2. Esto no es una recomendación para instalar un parche antiguo: usa una versión mantenida de VirtualBox y consulta el SDK correspondiente a tu entorno.

Validación de este artículo: el código se ha compilado, verificado con go vet e probado con servidor SOAP simulado y detector de carreras. También verificamos estructuras de solicitud contra el XSD del SDK. No se realizó ninguna prueba de una sesión autenticada en un host VirtualBox real. Por lo tanto, la integración debe pasar por homologación en su laboratorio antes del uso operativo.

Las pruebas adicionales cubrieron error SOAP, XML no válido, respuesta excesiva, redirección, cancelación, escape de caracteres y limpieza de sesión tras fallo. El ejemplo no pretende sustituir un SDK completo.

1. Crear el proyecto Go

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

El ejemplo utiliza errors.Join, disponible a partir de Go 1.20; prefiera una versión actualmente mantenida. No hay dependencias externas que descargar. Vamos a separar el transporte SOAP en client.go y el inventario en main.go.

2. Implementar el transporte SOAP

Guárdelo como client.go. El cliente acepta HTTP solo para loopback, no sigue redirecciones, limita la respuesta y deja activa la validación TLS por defecto:

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 se encarga del escape de los valores. No concatenamos la contraseña directamente como XML. El espacio de nombres, las operaciones y el SOAPAction vacío siguen el contrato consultado; los errores HTTP y SOAP se gestionan por separado.

Los detalles completos de fallos del servidor se omitieron del mensaje para reducir la exposición de información. En un producto real, puede clasificar los tipos de fallo internamente, manteniendo secretos y datos sensibles fuera de los registros. Consulte las referencias de encoding/xml e net/http.

3. Listar las máquinas y garantizar el cierre de sesión

Guárdelo 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 la limpieza se registra en cuanto el login devuelve una referencia válida. Utiliza un contexto independiente y breve: si la consulta principal expira, aún habrá un intento de cerrar la sesión. Un fallo en el cierre de sesión no se descarta silenciosamente.

La ejecución es secuencial por claridad. El nombre, estado y UUID requieren consultas separadas; no es un recopilador optimizado para inventarios enormes. El programa interrumpe la recolección en el primer error y no presenta un inventario parcial como si fuera completo. La restricción de operaciones es del cliente didáctico, no una política de autorización aplicada por el servidor.

4. Compilar y ejecutar sin guardar la contraseña en el historial

En Bash, compile y lea la contraseña sin escribirla en la línea de comandos:

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

Las variables de entorno no son una caja fuerte: los procesos privilegiados y las herramientas de diagnóstico pueden exponerlas. En automatización, integre un mecanismo de secretos apropiado y no imprima el entorno, XML de inicio de sesión ni referencias de sesión.

Formato ilustrativo de salida, no capturado de un host real:

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

Si la cuenta no tiene máquinas registradas, la salida será []. Un error hace que el programa termine con un código distinto de cero. Antes de concluir que el inventario está mal, confirme qué entorno de usuario está exponiendo el servicio.

5. Una prueba automatizada sin hipervisor

Guarde esta prueba como client_test.go. Verifica que un SOAP Fault produce error sin reproducir los detalles privados del 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 ./...

Esta prueba ejercita el transporte con un servidor simulado. No comprueba credenciales, permisos ni compatibilidad operativa con su VirtualBox. Complete la homologación con una máquina de laboratorio y compare el UUID, el nombre y el estado con la administración local.

Fallos comunes y diagnóstico

Problema Qué comprobar
Conexión rechazada Servicio activo, dirección, puerto y túnel SSH.
Fallo SOAP al iniciar sesión Autenticación configurada, usuario y contraseña; no desactive la protección como corrección.
Lista vacía Usuario que ejecuta el servicio y máquinas registradas en ese entorno.
Referencia no válida Sesión caducada o referencia reutilizada de otra ejecución.
Error de certificado Nombre del servidor y cadena de confianza; no use InsecureSkipVerify.
Respuesta inesperada Endpoint correcto, espacio de nombres, versión y contrato WSDL.
Plazo agotado Latencia, número de máquinas virtuales y límites configurados.

El programa utiliza diez segundos por petición, treinta para la recolección y cinco para la limpieza. Son elecciones didácticas, no valores universales. Ajústelos con medición. No añada repetición automática indiscriminada al evolucionar hacia acciones de escritura: un timeout no demuestra que el servidor haya dejado de ejecutar la operación.

¿Quiere evolucionar de consultas a acciones sobre las máquinas virtuales? Revise en la parte 1 las precauciones con sesiones de máquina, bloqueos, autorización y operaciones de escritura.

Cómo convertir el ejemplo en un proyecto más grande

  • Fijar una matriz de versiones y validar el WSDL correspondiente.
  • Separar transporte, autenticación, inventario y operaciones de escritura.
  • Añadir pruebas con respuestas reales saneadas, además de las simuladas.
  • Tratar paginación o límites de la aplicación, caché y plazo total de recolección.
  • Para agentes persistentes, estudiar la liberación de referencias y reconexión, sin acumular objetos indefinidamente.
  • Versionar el contrato público sin exponer los detalles SOAP a los consumidores.

Si el alcance crece, un cliente generado a partir del WSDL puede reducir trabajo repetitivo, pero aun así exige validación de tipos, namespaces y fallos. El pequeño cliente manual de este artículo sirve para entender el camino, no para reimplementar toda la API a mano.

Este trabajo se conecta con nuestra serie: arquitectura en Go, Echo y Vue, planificación con IA y OpenSpec e implementación incremental y pruebas. Aquí, la base es práctica: primero consultar correctamente; luego ampliar con evidencias.