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 original: 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.

Revisión de 7 de octubre de 2026: repetimos compilación, go vet y pruebas con -race usando el código extraído del artículo. Añadimos cancelación con Ctrl+C, prueba de inventario vacío con cierre de sesión y comprobaciones simuladas de ciclo completo, escape, respuesta no válida, límite de tamaño y limpieza tras la cancelación. La comprobación original del XSD no se repitió; sigue sin homologación autenticada en host real.

Flujo: inicio de sesión, inventario, limpieza y salida

El cliente se autentica, recibe una referencia remota de IVirtualBox, solicita las referencias de las máquinas y consulta el UUID, nombre y estado de cada una. Solo después de intentar cerrar la sesión devuelve el inventario para la salida JSON. Una referencia SOAP identifica un objeto dentro del contexto remoto; no es el UUID persistente de la VM.

Fluxo do cliente Go: login SOAP, leitura das máquinas, tentativa de logoff e saída JSON somente após sucesso.
Las llamadas utilizan un endpoint protegido. La limpieza tiene su propio plazo para seguir siendo posible tras la cancelación de la recolección.
Operación Entrada principal Resultado esperado
IWebsessionManager_logon Usuario y contraseña. Referencia del objeto IVirtualBox.
IVirtualBox_getMachines _this con la referencia recibida. Referencias de las máquinas, no sus UUIDs.
IMachine_getId/getName/getState _this con la referencia de la máquina. Un valor por consulta.
IWebsessionManager_logoff refIVirtualBox. Cierre de la sesión remota.

Para N máquinas, la ruta de éxito de este ejemplo realiza 3N + 3 llamadas SOAP: inicio de sesión, listado, tres atributos por VM y cierre de sesión. Es una consecuencia del código secuencial que aparece a continuación, no un requisito de todos los clientes de VirtualBox. Cuanto mayor sea el inventario, más importante será medir la latencia y el plazo total.

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 e intentar cerrar la sesión

Guárdelo 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 limpieza se registra en cuanto el inicio de sesión devuelve una referencia válida. Utiliza un contexto independiente y breve: si la consulta principal expira, todavía habrá un intento de cerrar la sesión. Un error en el cierre de sesión no se descarta en silencio. El manejo de la interrupción utiliza signal.NotifyContext, de la biblioteca estándar.

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.

Qué garantiza la limpieza — y qué no

El contexto de la recopilación ahora observa os.Interrupt: Ctrl+C solicita la cancelación y permite que la función regrese pasando por defer. La limpieza todavía puede consumir hasta cinco segundos adicionales. Solo Ctrl+C/SIGINT ha sido registrado: SIGTERM no recibe ese tratamiento cooperativo en el ejemplo. Esto tampoco cubre el cierre forzado del proceso, la caída de la máquina o la pérdida de comunicación con el servicio.

Si el servidor acepta el inicio de sesión, pero la respuesta se pierde antes de que el cliente reciba la referencia, el programa no tendrá forma de ejecutar ese cierre de sesión. La expiración de sesiones del servicio sigue siendo importante. Por otro lado, si el cierre de sesión falla después de una recopilación completa, esta implementación devuelve un error y no imprime JSON de éxito: una decisión conservadora, documentada, no prueba de que la sesión remota haya permanecido abierta.

“Solo lectura” describe las acciones sobre las VMs: autenticar y cerrar la sesión siguen alterando el estado administrativo del servicio web. El programa no solicita el bloqueo de la máquina, no inicia VMs y no modifica discos. La cuenta de servicio, sin embargo, puede tener más permisos de los que utiliza el cliente; la lista permitida de métodos no sustituye al aislamiento y la autorización en el entorno.

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.

Pruebe también el inventario vacío y el cierre

Guarde la prueba a continuación como empty_test.go, junto a los demás archivos, y repita go test -race -count=1 ./.... Exige salida serializada como lista vacía y confirmación de que el cliente intentó el cierre de sesión:

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())
	}
}

Homologación en host real: guion de aceptación

  1. Registre la versión de VirtualBox, el SDK utilizado como contrato, la configuración del servicio y la cuenta con la que se ejecuta.
  2. Use un endpoint protegido del laboratorio, con autenticación activa. No reutilice credenciales de producción en fixtures.
  3. Compare los UUID con VBoxManage list vms ejecutado en el mismo contexto de usuario del servicio. Consulte estados por VBoxManage showvminfo UUID --machinereadable, sustituyendo UUID por el identificador real.
  4. Pruebe con lista vacía, nombres con acentos y caracteres XML, credencial no válida, cancelación e indisponibilidad.
  5. Confirme que el programa solo consulta atributos y que no se ha añadido ningún comando de escritura al adaptador.
  6. Documente los fallos y los límites encontrados antes de usar el inventario en automatización.

El inventario no es una fotografía atómica: una VM puede cambiar de estado entre consultas. El orden de la lista tampoco debe tratarse como un contrato. Para la comparación automatizada, use UUID como clave y acepte que los estados pueden cambiar durante la recopilación.

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.