feat: funciones NordVPN bash y Go — CLI, contenedor Docker y parser de estado

Funciones bash para instalar, conectar, desconectar, estado, IP, ciudades, países y protocolo.
Funciones Go para gestionar contenedor NordVPN (run/start/stop) y parsear estado.
Incluye tipo NordVPNStatus y tests para el parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 20:55:08 +02:00
parent bf1efb2099
commit 2f119478af
26 changed files with 1076 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
package infra
import (
"fmt"
)
// NordVPNContainerRunOpts opciones para ejecutar un container a traves del gateway NordVPN.
type NordVPNContainerRunOpts struct {
Image string // Imagen Docker a ejecutar (obligatorio)
Cmd []string // Comando a ejecutar en el container
Env map[string]string // Variables de entorno
Volumes []string // Bind mounts
Name string // Nombre del container (opcional)
Gateway string // Nombre del container NordVPN gateway (default: "nordvpn")
Detach bool // Ejecutar en background
Remove bool // Eliminar al terminar (--rm)
}
// NordVPNContainerRun ejecuta un container Docker cuyo trafico de red
// pasa por el container gateway NordVPN usando --network=container:<gateway>.
// Devuelve el ID del container creado.
func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error) {
if opts.Image == "" {
return "", fmt.Errorf("image required")
}
if opts.Gateway == "" {
opts.Gateway = "nordvpn"
}
id, err := DockerRunContainer(opts.Image, DockerRunOpts{
Name: opts.Name,
Env: opts.Env,
Volumes: opts.Volumes,
Detach: opts.Detach,
Remove: opts.Remove,
Network: "container:" + opts.Gateway,
Cmd: opts.Cmd,
})
if err != nil {
return "", fmt.Errorf("nordvpn container run %s: %w", opts.Image, err)
}
return id, nil
}
+53
View File
@@ -0,0 +1,53 @@
---
name: nordvpn_container_run
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NordVPNContainerRun(opts NordVPNContainerRunOpts) (string, error)"
description: "Ejecuta un container Docker cuyo trafico pasa por el gateway NordVPN usando --network=container:<gateway>. El container hereda la IP y tunel VPN del gateway."
tags: [vpn, nordvpn, docker, container, run, infra, network]
uses_functions: ["docker_run_container_go_infra"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/nordvpn_container_run.go"
---
## Ejemplo
```go
// Verificar IP desde VPN
id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
Image: "curlimages/curl",
Cmd: []string{"https://api.ipify.org"},
Remove: true,
Gateway: "nordvpn",
})
// Ejecutar scraper bajo VPN
id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
Image: "my-scraper:latest",
Env: map[string]string{"TARGET_URL": "https://example.com"},
Volumes: []string{"/tmp/output:/output"},
Detach: true,
Name: "scraper-vpn",
})
// Navegador headless bajo VPN
id, err := NordVPNContainerRun(NordVPNContainerRunOpts{
Image: "chromedp/headless-shell",
Detach: true,
Name: "chrome-vpn",
})
```
## Notas
Requiere que el container gateway NordVPN este corriendo (usar `NordVPNContainerStart` primero). El container cliente no necesita capabilities especiales — hereda la red del gateway. Con `--network=container:X` el container no puede exponer puertos propios; los puertos deben mapearse en el gateway.
@@ -0,0 +1,73 @@
package infra
import (
"fmt"
"strings"
"time"
)
// NordVPNContainerOpts opciones para el container gateway NordVPN.
type NordVPNContainerOpts struct {
Token string // Token de acceso NordVPN (obligatorio)
Country string // Pais al que conectar (opcional, ej: "Spain")
City string // Ciudad (opcional, ej: "Madrid")
Protocol string // "NordLynx" o "OpenVPN" (default: NordLynx)
Name string // Nombre del container (default: "nordvpn")
}
// NordVPNContainerStart levanta un container Docker con NordVPN como gateway.
// Otros containers pueden usar su red con --network=container:<name>.
// Espera hasta que el tunel este activo o timeout de 30s.
func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) {
if opts.Token == "" {
return "", fmt.Errorf("nordvpn token required")
}
if opts.Name == "" {
opts.Name = "nordvpn"
}
if opts.Protocol == "" {
opts.Protocol = "NordLynx"
}
env := map[string]string{
"TOKEN": opts.Token,
"TECHNOLOGY": opts.Protocol,
}
if opts.Country != "" {
connect := opts.Country
if opts.City != "" {
connect += " " + opts.City
}
env["CONNECT"] = connect
}
// Limpiar container previo con el mismo nombre si existe
_ = DockerRemoveContainer(opts.Name, true)
id, err := DockerRunContainer("ghcr.io/bubuntux/nordvpn", DockerRunOpts{
Name: opts.Name,
Env: env,
Detach: true,
CapAdd: []string{"NET_ADMIN", "NET_RAW"},
})
if err != nil {
return "", fmt.Errorf("nordvpn container start: %w", err)
}
// Esperar a que el tunel este activo
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
logs, logErr := DockerContainerLogs(opts.Name, 20)
if logErr != nil {
continue
}
if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") {
return id, nil
}
if strings.Contains(logs, "error") || strings.Contains(logs, "failed") {
return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name)
}
}
return id, fmt.Errorf("nordvpn connection timeout after 30s, check logs: docker logs %s", opts.Name)
}
@@ -0,0 +1,42 @@
---
name: nordvpn_container_start
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error)"
description: "Levanta un container Docker con NordVPN como gateway de red. Otros containers pueden rutear su trafico a traves de este con --network=container:<name>. Espera hasta 30s a que el tunel este activo."
tags: [vpn, nordvpn, docker, container, gateway, infra, network]
uses_functions: ["docker_run_container_go_infra", "docker_remove_container_go_infra", "docker_container_logs_go_infra"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt, strings, time]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/nordvpn_container_start.go"
---
## Ejemplo
```go
id, err := NordVPNContainerStart(NordVPNContainerOpts{
Token: os.Getenv("NORDVPN_TOKEN"),
Country: "Spain",
City: "Madrid",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("VPN gateway:", id)
// Ahora otros containers pueden usar la VPN:
// docker run --network=container:nordvpn curlimages/curl https://api.ipify.org
```
## Notas
Usa la imagen `ghcr.io/bubuntux/nordvpn`. Requiere un token de acceso NordVPN (obtener con `nordvpn token` desde CLI o desde la web de NordVPN). Limpia containers previos con el mismo nombre automaticamente. El protocolo por defecto es NordLynx (WireGuard).
+29
View File
@@ -0,0 +1,29 @@
package infra
import (
"fmt"
)
// NordVPNContainerStop detiene y elimina el container gateway NordVPN.
// Tambien detiene containers que usen su red si se proporcionan.
func NordVPNContainerStop(gateway string, clientNames ...string) error {
if gateway == "" {
gateway = "nordvpn"
}
// Primero parar los clientes que usan la red del gateway
for _, name := range clientNames {
_ = DockerStopContainer(name, 5)
_ = DockerRemoveContainer(name, true)
}
// Parar y eliminar el gateway
if err := DockerStopContainer(gateway, 10); err != nil {
return fmt.Errorf("nordvpn container stop: %w", err)
}
if err := DockerRemoveContainer(gateway, true); err != nil {
return fmt.Errorf("nordvpn container remove: %w", err)
}
return nil
}
+35
View File
@@ -0,0 +1,35 @@
---
name: nordvpn_container_stop
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func NordVPNContainerStop(gateway string, clientNames ...string) error"
description: "Detiene y elimina el container gateway NordVPN y opcionalmente los containers cliente que usan su red."
tags: [vpn, nordvpn, docker, container, stop, cleanup, infra]
uses_functions: ["docker_stop_container_go_infra", "docker_remove_container_go_infra"]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [fmt]
tested: false
tests: []
test_file_path: ""
file_path: "functions/infra/nordvpn_container_stop.go"
---
## Ejemplo
```go
// Parar solo el gateway
err := NordVPNContainerStop("nordvpn")
// Parar gateway y clientes asociados
err := NordVPNContainerStop("nordvpn", "chrome-vpn", "scraper-vpn")
```
## Notas
Detiene primero los containers cliente (si se proporcionan) y luego el gateway. Importante: si los clientes usan `--network=container:nordvpn`, deben pararse antes que el gateway para evitar errores de red. Los clientes se paran con timeout de 5s, el gateway con 10s.
+68
View File
@@ -0,0 +1,68 @@
package infra
import (
"regexp"
"strings"
)
// NordVPNStatus representa el estado parseado de nordvpn status.
type NordVPNStatus struct {
Connected bool // true si hay conexion activa
Status string // "Connected" o "Disconnected"
Hostname string // ej: "es42.nordvpn.com"
IP string // IP del servidor VPN
Country string // ej: "Spain"
City string // ej: "Madrid"
Technology string // ej: "NordLynx"
Protocol string // ej: "nordlynx"
Transfer string // ej: "1.2 MiB received, 500 KiB sent"
Uptime string // ej: "5 minutes 32 seconds"
}
// ansiRegexp elimina codigos de escape ANSI.
var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// ParseNordVPNStatus parsea la salida de texto de `nordvpn status`
// a un struct tipado. Funcion pura — no ejecuta comandos.
func ParseNordVPNStatus(output string) NordVPNStatus {
var s NordVPNStatus
lines := strings.Split(output, "\n")
for _, line := range lines {
line = ansiRegexp.ReplaceAllString(line, "")
line = strings.TrimSpace(line)
line = strings.TrimLeft(line, "- ")
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
val := strings.TrimSpace(line[idx+1:])
switch key {
case "status":
s.Status = val
s.Connected = strings.EqualFold(val, "connected")
case "hostname", "server":
s.Hostname = val
case "ip":
s.IP = val
case "country":
s.Country = val
case "city":
s.City = val
case "current technology":
s.Technology = val
case "current protocol":
s.Protocol = val
case "transfer":
s.Transfer = val
case "uptime":
s.Uptime = val
}
}
return s
}
+41
View File
@@ -0,0 +1,41 @@
---
name: parse_nordvpn_status
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: pure
signature: "func ParseNordVPNStatus(output string) NordVPNStatus"
description: "Parsea la salida de texto de nordvpn status a un struct tipado. Elimina codigos ANSI y normaliza claves."
tags: [vpn, nordvpn, parser, pure, infra]
uses_functions: []
uses_types: ["NordVPNStatus_go_infra"]
returns: []
returns_optional: false
error_type: ""
imports: ["regexp", "strings"]
tested: true
tests: ["TestParseNordVPNStatus_Connected", "TestParseNordVPNStatus_Disconnected", "TestParseNordVPNStatus_WithANSI"]
test_file_path: "functions/infra/parse_nordvpn_status_test.go"
file_path: "functions/infra/parse_nordvpn_status.go"
---
## Ejemplo
```go
output := `Status: Connected
Hostname: es42.nordvpn.com
IP: 185.230.124.42
Country: Spain
City: Madrid
Current Technology: NordLynx`
s := ParseNordVPNStatus(output)
// s.Connected == true
// s.Hostname == "es42.nordvpn.com"
// s.Country == "Spain"
```
## Notas
Funcion pura — no ejecuta comandos ni accede a red. Maneja codigos ANSI que NordVPN CLI emite en terminal. Campos no presentes en la salida quedan como zero value.
@@ -0,0 +1,62 @@
package infra
import "testing"
func TestParseNordVPNStatus_Connected(t *testing.T) {
input := `Status: Connected
Hostname: es42.nordvpn.com
IP: 185.230.124.42
Country: Spain
City: Madrid
Current Technology: NordLynx
Current Protocol: nordlynx
Transfer: 1.2 MiB received, 500 KiB sent
Uptime: 5 minutes 32 seconds`
got := ParseNordVPNStatus(input)
if !got.Connected {
t.Error("expected Connected=true")
}
if got.Hostname != "es42.nordvpn.com" {
t.Errorf("Hostname = %q, want es42.nordvpn.com", got.Hostname)
}
if got.IP != "185.230.124.42" {
t.Errorf("IP = %q, want 185.230.124.42", got.IP)
}
if got.Country != "Spain" {
t.Errorf("Country = %q, want Spain", got.Country)
}
if got.City != "Madrid" {
t.Errorf("City = %q, want Madrid", got.City)
}
if got.Technology != "NordLynx" {
t.Errorf("Technology = %q, want NordLynx", got.Technology)
}
}
func TestParseNordVPNStatus_Disconnected(t *testing.T) {
input := `Status: Disconnected`
got := ParseNordVPNStatus(input)
if got.Connected {
t.Error("expected Connected=false")
}
if got.Status != "Disconnected" {
t.Errorf("Status = %q, want Disconnected", got.Status)
}
}
func TestParseNordVPNStatus_WithANSI(t *testing.T) {
input := "\x1b[32mStatus: Connected\x1b[0m\n\x1b[32m- Hostname: us1234.nordvpn.com\x1b[0m"
got := ParseNordVPNStatus(input)
if !got.Connected {
t.Error("expected Connected=true with ANSI codes")
}
if got.Hostname != "us1234.nordvpn.com" {
t.Errorf("Hostname = %q, want us1234.nordvpn.com", got.Hostname)
}
}