
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.

| 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(¶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 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
- Registre la versión de VirtualBox, el SDK utilizado como contrato, la configuración del servicio y la cuenta con la que se ejecuta.
- Use un endpoint protegido del laboratorio, con autenticación activa. No reutilice credenciales de producción en fixtures.
- Compare los UUID con
VBoxManage list vmsejecutado en el mismo contexto de usuario del servicio. Consulte estados porVBoxManage showvminfo UUID --machinereadable, sustituyendo UUID por el identificador real. - Pruebe con lista vacía, nombres con acentos y caracteres XML, credencial no válida, cancelación e indisponibilidad.
- Confirme que el programa solo consulta atributos y que no se ha añadido ningún comando de escritura al adaptador.
- 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.