feat(infra): auto-commit con 86 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:38:15 +02:00
parent de9bfec498
commit fe65c5e527
85 changed files with 11840 additions and 92 deletions
+247
View File
@@ -0,0 +1,247 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// DockerExecOpts configura la ejecucion de un comando dentro de un container Docker.
type DockerExecOpts struct {
ContainerID string // ID o nombre del container destino.
Cmd []string // argv. Cmd[0] = binario, debe estar en BinariesAllowed.
BinariesAllowed []string // Whitelist de binarios permitidos. EMPTY = rechaza todo.
User string // Usuario/grupo "UID:GID"; vacio = default del container.
WorkingDir string // Directorio de trabajo dentro del container.
Env []string // Variables de entorno en formato "KEY=VAL".
TimeoutSeconds int // Timeout de ejecucion; default 30 si es 0.
DockerHost string // Socket Docker; default "unix:///var/run/docker.sock".
}
// DockerExecResult contiene el resultado de ejecutar un comando en un container.
type DockerExecResult struct {
ExitCode int // Codigo de salida del proceso.
Stdout string // Salida estandar capturada.
Stderr string // Salida de error capturada.
Duration int64 // Duracion real de ejecucion en milisegundos.
}
// DockerContainerExec ejecuta un comando dentro de un container Docker via Engine API.
//
// Flujo: POST /containers/<id>/exec → POST /exec/<id>/start → demux stream → GET /exec/<id>/json.
// El comando se pasa como argv directo (sin shell). Aplica whitelist obligatoria de binarios.
// Timeout gestionado via context.WithTimeout.
func DockerContainerExec(opts DockerExecOpts) (DockerExecResult, error) {
// --- Validacion de seguridad (prioritaria, antes de contactar el engine) ---
if len(opts.Cmd) == 0 {
return DockerExecResult{}, fmt.Errorf("docker exec: Cmd must not be empty")
}
if len(opts.BinariesAllowed) == 0 {
return DockerExecResult{}, fmt.Errorf("docker exec: no binaries whitelisted: refusing")
}
bin := opts.Cmd[0]
inWhitelist := false
for _, b := range opts.BinariesAllowed {
if b == bin {
inWhitelist = true
break
}
}
if !inWhitelist {
return DockerExecResult{}, fmt.Errorf("docker exec: binary %q not in whitelist %v", bin, opts.BinariesAllowed)
}
if opts.ContainerID == "" {
return DockerExecResult{}, fmt.Errorf("docker exec: ContainerID must not be empty")
}
// --- Defaults ---
timeout := opts.TimeoutSeconds
if timeout <= 0 {
timeout = 30
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// Reuse dockerHTTPClient from docker_container_logs.go (same package).
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec: building client: %w", err)
}
start := time.Now()
// --- Step 1: Create exec instance ---
execID, err := dockerExecCreate(ctx, client, baseURL, opts)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec create: %w", err)
}
// --- Step 2: Start exec and capture stream ---
stdout, stderr, err := dockerExecStart(ctx, client, baseURL, execID)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec start: %w", err)
}
// --- Step 3: Inspect to get exit code ---
exitCode, err := dockerExecInspect(ctx, client, baseURL, execID)
if err != nil {
return DockerExecResult{}, fmt.Errorf("docker exec inspect: %w", err)
}
duration := time.Since(start).Milliseconds()
return DockerExecResult{
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
Duration: duration,
}, nil
}
// dockerExecCreate llama POST /containers/<id>/exec y devuelve el ExecID.
func dockerExecCreate(ctx context.Context, client *http.Client, baseURL string, opts DockerExecOpts) (string, error) {
type createBody struct {
AttachStdout bool `json:"AttachStdout"`
AttachStderr bool `json:"AttachStderr"`
Tty bool `json:"Tty"`
Cmd []string `json:"Cmd"`
User string `json:"User,omitempty"`
WorkingDir string `json:"WorkingDir,omitempty"`
Env []string `json:"Env,omitempty"`
}
body := createBody{
AttachStdout: true,
AttachStderr: true,
Tty: false,
Cmd: opts.Cmd,
User: opts.User,
WorkingDir: opts.WorkingDir,
Env: opts.Env,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/containers/%s/exec", baseURL, opts.ContainerID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("engine returned %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var result struct {
ID string `json:"Id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.ID == "" {
return "", fmt.Errorf("engine returned empty exec ID")
}
return result.ID, nil
}
// dockerExecStart llama POST /exec/<id>/start y demux el stream multiplexado de Docker.
// Reusa dockerDemuxFrame de docker_container_logs.go (mismo paquete).
// Retorna (stdout, stderr, error).
func dockerExecStart(ctx context.Context, client *http.Client, baseURL, execID string) (string, string, error) {
type startBody struct {
Detach bool `json:"Detach"`
Tty bool `json:"Tty"`
}
bodyBytes, _ := json.Marshal(startBody{Detach: false, Tty: false})
url := fmt.Sprintf("%s/exec/%s/start", baseURL, execID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return "", "", fmt.Errorf("docker exec timed out")
}
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("engine returned %d on start: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
// Demux usando dockerDemuxFrame de docker_container_logs.go.
var stdoutBuf, stderrBuf strings.Builder
for {
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
break
}
if err != nil {
if ctx.Err() != nil {
return "", "", fmt.Errorf("docker exec timed out")
}
return "", "", fmt.Errorf("reading exec stream: %w", err)
}
switch streamType {
case 1: // stdout
stdoutBuf.Write(payload)
case 2: // stderr
stderrBuf.Write(payload)
}
}
return stdoutBuf.String(), stderrBuf.String(), nil
}
// dockerExecInspect llama GET /exec/<id>/json y devuelve el ExitCode.
func dockerExecInspect(ctx context.Context, client *http.Client, baseURL, execID string) (int, error) {
url := fmt.Sprintf("%s/exec/%s/json", baseURL, execID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return -1, err
}
resp, err := client.Do(req)
if err != nil {
return -1, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return -1, fmt.Errorf("engine returned %d on inspect: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var result struct {
ExitCode int `json:"ExitCode"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return -1, err
}
return result.ExitCode, nil
}
+73
View File
@@ -0,0 +1,73 @@
---
name: docker_container_exec
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerContainerExec(opts DockerExecOpts) (DockerExecResult, error)"
description: "Exec comando dentro de container Docker con whitelist obligatoria de binarios. SIN shell expansion. Stream demuxado stdout/stderr. Timeout context-cancellable. Capability docker.container.exec del device_agent."
tags: [docker, docker-agent, exec, security, infra]
uses_functions: []
uses_types: [docker_exec_result_go_infra, error_go_core]
returns: [docker_exec_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [bytes, context, encoding/json, fmt, io, net/http, strings, time]
params:
- name: opts.ContainerID
desc: "ID o nombre del container destino. Obligatorio."
- name: opts.Cmd
desc: "argv del comando. Cmd[0] es el binario a ejecutar; debe estar en BinariesAllowed. Sin shell expansion."
- name: opts.BinariesAllowed
desc: "Whitelist exacta de binarios permitidos. EMPTY = rechaza todo sin contactar el engine. Security-critical."
- name: opts.User
desc: "Usuario/grupo en formato UID:GID (ej: '1000:1000'). Vacio = default del container."
- name: opts.WorkingDir
desc: "Directorio de trabajo dentro del container. Vacio = default del container."
- name: opts.Env
desc: "Variables de entorno adicionales en formato KEY=VAL. Combinadas con las del container."
- name: opts.TimeoutSeconds
desc: "Timeout de la operacion completa en segundos. Default 30 si es 0 o negativo."
- name: opts.DockerHost
desc: "Socket Docker. Default 'unix:///var/run/docker.sock'. Soporta 'unix://', 'tcp://', 'http://'."
output: "DockerExecResult{ExitCode, Stdout, Stderr, Duration} con el resultado completo del comando ejecutado."
tested: true
tests:
- "binario en whitelist exitcode 0 stdout stderr capturados"
- "binario NO en whitelist error sin contactar engine"
- "whitelist vacia rechaza todo"
- "timeout simulado"
test_file_path: "functions/infra/docker_container_exec_test.go"
file_path: "functions/infra/docker_container_exec.go"
---
## Ejemplo
```go
result, err := infra.DockerContainerExec(infra.DockerExecOpts{
ContainerID: "my-app-container",
Cmd: []string{"cat", "/etc/hostname"},
BinariesAllowed: []string{"cat", "ls", "id", "env"},
User: "1000:1000",
TimeoutSeconds: 10,
})
if err != nil {
log.Fatalf("exec failed: %v", err)
}
fmt.Printf("exit=%d stdout=%q stderr=%q duration=%dms\n",
result.ExitCode, result.Stdout, result.Stderr, result.Duration)
```
## Cuando usarla
Cuando necesitas ejecutar un comando dentro de un container en ejecucion desde Go, con control de seguridad sobre que binarios pueden invocarse. Indispensable para el capability group `docker-agent` (flow 0009 A2): health-checks, introspection, file reads, reconfigurations controladas. Usar antes de cualquier operacion que requiera acceso al filesystem o procesos del container sin montar volumenes adicionales.
## Gotchas
- **NUNCA usar `BinariesAllowed` vacio en produccion**: la funcion rechaza por diseno. Cualquier lista vacia es un error de configuracion, no un "permitir todo".
- **Sin shell expansion**: no puedes hacer pipes, redirects ni `$VAR` desde `Cmd`. Para eso el manifest del agent debe usar un binario que implemente esa logica (ej. `python3 -c "..."` si python3 esta en la whitelist).
- **Stream demux 8-byte header**: el protocolo Docker multiplexado (Tty=false) prefixa cada frame con 8 bytes. Esta funcion lo demux correctamente; si cambias a Tty=true el stream es raw y el demux falla.
- **Timeout incluye overhead de red**: el `TimeoutSeconds` aplica al flujo completo (create + start + stream + inspect). En containers locales el overhead es <10ms; en TCP remoto puede ser mas alto.
- **ExitCode -1**: solo aparece si falla la llamada a `/exec/{id}/json` (error de red/timeout), no como exit code real del proceso.
- **DockerHost en TCP**: usar `tcp://host:2375` para daemons remotos sin TLS. Para TLS, el cliente HTTP necesitaria cert/key — no soportado en esta version (ver Gotchas de produccion).
@@ -0,0 +1,190 @@
package infra
import (
"encoding/binary"
"encoding/json"
"net"
"net/http"
"strings"
"testing"
"time"
)
// newUnixDockerServer levanta un httptest-style server en un socket Unix temporal.
// Retorna el server (ya iniciado) y el path al socket.
func newUnixDockerServer(t *testing.T, handler http.Handler) (socketPath string) {
t.Helper()
socketPath = t.TempDir() + "/docker_exec_test.sock"
ln, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("listen unix %s: %v", socketPath, err)
}
srv := &http.Server{Handler: handler}
go srv.Serve(ln) //nolint:errcheck
t.Cleanup(func() {
srv.Close()
ln.Close()
})
return socketPath
}
// buildDockerExecHandler construye un http.Handler que simula el flujo completo del
// Docker Engine API para exec: create → start (stream multiplexado) → inspect.
func buildDockerExecHandler(t *testing.T, exitCode int, stdoutPayload, stderrPayload string, delayStart time.Duration) http.Handler {
t.Helper()
const fakeExecID = "deadbeefcafe"
// Construir stream multiplexado de Docker (Tty=false).
// Frame: [type(1)] [0 0 0(3)] [size big-endian uint32(4)] [payload]
buildFrame := func(streamType byte, payload string) []byte {
if payload == "" {
return nil
}
b := make([]byte, 8+len(payload))
b[0] = streamType
binary.BigEndian.PutUint32(b[4:8], uint32(len(payload)))
copy(b[8:], payload)
return b
}
var streamBody []byte
streamBody = append(streamBody, buildFrame(1, stdoutPayload)...)
streamBody = append(streamBody, buildFrame(2, stderrPayload)...)
mux := http.NewServeMux()
// POST /containers/{id}/exec — devuelve ExecID.
mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/exec") {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"Id": fakeExecID}) //nolint:errcheck
})
// POST /exec/{id}/start — transmite stream multiplexado.
// GET /exec/{id}/json — devuelve ExitCode.
mux.HandleFunc("/exec/", func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/start") && r.Method == http.MethodPost:
if delayStart > 0 {
select {
case <-r.Context().Done():
http.Error(w, "cancelled", http.StatusGatewayTimeout)
return
case <-time.After(delayStart):
}
}
w.WriteHeader(http.StatusOK)
if len(streamBody) > 0 {
w.Write(streamBody) //nolint:errcheck
}
case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]int{"ExitCode": exitCode}) //nolint:errcheck
default:
http.NotFound(w, r)
}
})
return mux
}
func TestDockerContainerExec(t *testing.T) {
const containerID = "abc123container"
t.Run("binario en whitelist exitcode 0 stdout stderr capturados", func(t *testing.T) {
handler := buildDockerExecHandler(t, 0, "hello stdout\n", "hello stderr\n", 0)
socketPath := newUnixDockerServer(t, handler)
result, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"ls", "-la"},
BinariesAllowed: []string{"ls", "cat", "echo"},
DockerHost: "unix://" + socketPath,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("ExitCode: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hello stdout\n" {
t.Errorf("Stdout: got %q, want %q", result.Stdout, "hello stdout\n")
}
if result.Stderr != "hello stderr\n" {
t.Errorf("Stderr: got %q, want %q", result.Stderr, "hello stderr\n")
}
if result.Duration < 0 {
t.Errorf("Duration should be >= 0, got %d", result.Duration)
}
})
t.Run("binario NO en whitelist error sin contactar engine", func(t *testing.T) {
// No levantamos server: si se contacta el engine, falla con connection refused.
// La validacion debe fallar ANTES de intentar conectar.
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"bash", "-c", "rm -rf /"},
BinariesAllowed: []string{"ls", "cat"},
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
})
if err == nil {
t.Fatal("expected error for binary not in whitelist, got nil")
}
if !strings.Contains(err.Error(), "not in whitelist") {
t.Errorf("error should mention whitelist, got: %v", err)
}
})
t.Run("whitelist vacia rechaza todo", func(t *testing.T) {
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"ls"},
BinariesAllowed: []string{},
DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock",
})
if err == nil {
t.Fatal("expected error for empty whitelist, got nil")
}
if !strings.Contains(err.Error(), "no binaries whitelisted") {
t.Errorf("error should mention empty whitelist, got: %v", err)
}
})
t.Run("timeout simulado", func(t *testing.T) {
// El handler demora 2s en /start, el timeout de la funcion es 1s.
handler := buildDockerExecHandler(t, 0, "should not arrive", "", 2*time.Second)
socketPath := newUnixDockerServer(t, handler)
done := make(chan error, 1)
go func() {
_, err := DockerContainerExec(DockerExecOpts{
ContainerID: containerID,
Cmd: []string{"sleep"},
BinariesAllowed: []string{"sleep"},
DockerHost: "unix://" + socketPath,
TimeoutSeconds: 1,
})
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("expected timeout error, got nil")
}
// Cualquier error de contexto/red es valido (context deadline exceeded, connection reset, etc.)
case <-time.After(5 * time.Second):
t.Fatal("test itself timed out — DockerContainerExec did not respect TimeoutSeconds")
}
})
}
+196
View File
@@ -0,0 +1,196 @@
package infra
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// DockerContainerInfo holds the essential fields for a Docker container,
// mapped from the Engine API /containers/json response.
type DockerContainerInfo struct {
ID string // short id (12 chars)
Names []string // e.g. ["/my-container"]
Image string // image name
State string // running|exited|paused|...
Status string // human label, e.g. "Up 2 hours"
Ports []string // e.g. "0.0.0.0:8080->8080/tcp"
Networks []string // network names
Labels map[string]string // container labels
}
// DockerContainerListOpts controls which containers are returned and where
// the Docker daemon is reached.
type DockerContainerListOpts struct {
All bool // if true, include stopped/exited containers (default: false = only running)
Filters []string // Docker filter expressions, e.g. "label=app=agents_and_robots", "status=running"
DockerHost string // default "unix:///var/run/docker.sock". Use "tcp://host:port" for remote.
}
// DockerContainerList lists Docker containers on the local (or remote) host
// by calling the Docker Engine HTTP API directly. No docker CLI required.
//
// The DockerHost field selects the transport:
// - empty or "unix:///var/run/docker.sock" → unix socket
// - "tcp://host:port" → plain HTTP (no TLS)
func DockerContainerList(opts DockerContainerListOpts) ([]DockerContainerInfo, error) {
client, baseURL, err := dockerListHTTPClient(opts.DockerHost)
if err != nil {
return nil, fmt.Errorf("docker_container_list: build client: %w", err)
}
// Build query string
q := url.Values{}
if opts.All {
q.Set("all", "1")
}
if len(opts.Filters) > 0 {
filters := buildDockerFilters(opts.Filters)
b, err := json.Marshal(filters)
if err != nil {
return nil, fmt.Errorf("docker_container_list: marshal filters: %w", err)
}
q.Set("filters", string(b))
}
endpoint := baseURL + "/containers/json"
if len(q) > 0 {
endpoint += "?" + q.Encode()
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("docker_container_list: new request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker_container_list: do request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("docker_container_list: read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("docker_container_list: daemon returned %d: %s", resp.StatusCode, string(body))
}
return parseDockerContainerList(body)
}
// dockerListHTTPClient returns an *http.Client wired for unix socket or TCP,
// and a base URL ("http://localhost" for unix, "http://host:port" for TCP).
func dockerListHTTPClient(dockerHost string) (*http.Client, string, error) {
if dockerHost == "" {
dockerHost = "unix:///var/run/docker.sock"
}
if strings.HasPrefix(dockerHost, "unix://") {
sockPath := strings.TrimPrefix(dockerHost, "unix://")
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", sockPath)
},
}
return &http.Client{Transport: transport}, "http://localhost", nil
}
if strings.HasPrefix(dockerHost, "tcp://") {
host := strings.TrimPrefix(dockerHost, "tcp://")
return &http.Client{}, "http://" + host, nil
}
return nil, "", fmt.Errorf("unsupported docker host scheme: %q (use unix:// or tcp://)", dockerHost)
}
// buildDockerFilters converts []string{"label=k=v", "status=running"} into
// the map[string][]string format that the Docker Engine API expects.
func buildDockerFilters(filters []string) map[string][]string {
m := make(map[string][]string)
for _, f := range filters {
idx := strings.IndexByte(f, '=')
if idx < 0 {
continue
}
key := f[:idx]
val := f[idx+1:]
m[key] = append(m[key], val)
}
return m
}
// parseDockerContainerList decodes the raw JSON from /containers/json.
func parseDockerContainerList(body []byte) ([]DockerContainerInfo, error) {
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
NetworkSettings struct {
Networks map[string]json.RawMessage `json:"Networks"`
} `json:"NetworkSettings"`
Ports []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
} `json:"Ports"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("docker_container_list: parse response: %w", err)
}
result := make([]DockerContainerInfo, 0, len(raw))
for _, c := range raw {
id := c.ID
if len(id) > 12 {
id = id[:12]
}
ports := make([]string, 0, len(c.Ports))
for _, p := range c.Ports {
if p.IP != "" && p.PublicPort != 0 {
ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.IP, p.PublicPort, p.PrivatePort, p.Type))
} else if p.PrivatePort != 0 {
ports = append(ports, fmt.Sprintf("%d/%s", p.PrivatePort, p.Type))
}
}
networks := make([]string, 0, len(c.NetworkSettings.Networks))
for name := range c.NetworkSettings.Networks {
networks = append(networks, name)
}
labels := c.Labels
if labels == nil {
labels = map[string]string{}
}
result = append(result, DockerContainerInfo{
ID: id,
Names: c.Names,
Image: c.Image,
State: c.State,
Status: c.Status,
Ports: ports,
Networks: networks,
Labels: labels,
})
}
return result, nil
}
+76
View File
@@ -0,0 +1,76 @@
---
name: docker_container_list
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func DockerContainerList(opts DockerContainerListOpts) ([]DockerContainerInfo, error)"
description: "Lista containers Docker del host via engine API (unix socket o TCP). Sin SDK pesado — net/http directo. Soporta filtros, All=true para exited. Usado por device_agent como capability docker.container.list."
tags: [docker, docker-agent, container, list, infra]
uses_functions: []
uses_types: [error_go_core, docker_container_info_go_infra]
returns: [docker_container_info_go_infra]
returns_optional: false
error_type: "error_go_core"
imports:
- context
- encoding/json
- fmt
- io
- net
- net/http
- net/url
- strings
- time
tested: true
tests:
- "lista solo running"
- "All=true incluye exited"
- "filter label aplica"
test_file_path: "functions/infra/docker_container_list_test.go"
file_path: "functions/infra/docker_container_list.go"
params:
- name: opts
desc: "DockerContainerListOpts — All (incluir exited), Filters (expresiones label=k=v / status=running), DockerHost (unix socket o tcp://host:port)"
output: "Slice de DockerContainerInfo con id, names, image, state, ports, networks para cada container."
---
## Ejemplo
```go
// Listar solo containers corriendo con un label específico
containers, err := DockerContainerList(infra.DockerContainerListOpts{
Filters: []string{"label=app=agents_and_robots"},
DockerHost: "unix:///var/run/docker.sock",
})
if err != nil {
log.Fatal(err)
}
for _, c := range containers {
fmt.Printf("%s %-20s %s %s\n", c.ID, c.Names[0], c.State, c.Status)
}
// Todos los containers (incluye exited) en host remoto
all, err := DockerContainerList(infra.DockerContainerListOpts{
All: true,
DockerHost: "tcp://192.168.1.10:2375",
})
```
## Cuando usarla
Cuando necesites listar containers Docker desde un agente o servicio sin depender del CLI `docker` instalado en el host — por ejemplo, al implementar la capability `docker.container.list` en un `device_agent` que recibe comandos desde Element/Matrix. También útil en tests y en entornos donde el binario docker no está en el PATH pero el socket sí es accesible.
## Gotchas
- Requiere acceso al docker socket. El proceso debe correr como root o en el grupo `docker`. En WSL2, el socket está en `/var/run/docker.sock` si Docker Desktop está activo.
- `Ports` puede estar vacío para containers en host network mode (`--network host`) — el engine no reporta port bindings en ese caso.
- Para acceso remoto sin TLS (`tcp://`), el daemon Docker debe tener `-H tcp://0.0.0.0:2375` habilitado explícitamente (deshabilitado por defecto por seguridad). Usar SSH tunnel o TLS para producción.
- `DockerHost` acepta `unix://` y `tcp://` solamente. Esquemas `https://` o `ssh://` retornan error.
- El campo `Names` incluye el slash inicial: `["/my-container"]`. Al mostrar al usuario, usar `strings.TrimPrefix(name, "/")`.
- Filters del tipo `label=k=v` incluyen el segundo `=` en el valor (el split es en el primer `=`). Para filtrar por presencia de label sin valor: `"label=app"`.
## Notas
Implementa la misma semántica que `GET /containers/json` del Docker Engine API v1.41+. No requiere el SDK `github.com/docker/docker` (evita ~50 MB de dependencias transitivas). El helper `dockerListHTTPClient` maneja la dialección unix socket requerida por `net/http`.
@@ -0,0 +1,223 @@
package infra
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// dockerAPIResponse is the minimal shape that /containers/json returns.
type dockerAPIResponse struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
// Status intentionally omitted to test zero-value handling
Labels map[string]string `json:"Labels"`
Ports []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
} `json:"Ports"`
NetworkSettings struct {
Networks map[string]json.RawMessage `json:"Networks"`
} `json:"NetworkSettings"`
}
// startMockDockerUnix starts an HTTP server listening on a temporary unix
// socket and returns the DockerHost string and a cleanup func.
func startMockDockerUnix(t *testing.T, handler http.Handler) (dockerHost string, cleanup func()) {
t.Helper()
tmp := filepath.Join(t.TempDir(), "docker.sock")
ln, err := net.Listen("unix", tmp)
if err != nil {
t.Fatalf("listen unix %s: %v", tmp, err)
}
srv := httptest.NewUnstartedServer(handler)
srv.Listener = ln
srv.Start()
return "unix://" + tmp, func() {
srv.Close()
os.Remove(tmp)
}
}
func TestDockerContainerList(t *testing.T) {
running := dockerAPIResponse{
ID: "abc123def456gh",
Names: []string{"/my-app"},
Image: "nginx:latest",
State: "running",
Ports: []struct {
IP string `json:"IP"`
PrivatePort uint16 `json:"PrivatePort"`
PublicPort uint16 `json:"PublicPort"`
Type string `json:"Type"`
}{{IP: "0.0.0.0", PrivatePort: 80, PublicPort: 8080, Type: "tcp"}},
Labels: map[string]string{"app": "my-app"},
}
running.NetworkSettings.Networks = map[string]json.RawMessage{
"bridge": json.RawMessage(`{}`),
}
exited := dockerAPIResponse{
ID: "deadbeef1234ab",
Names: []string{"/old-app"},
Image: "redis:7",
State: "exited",
}
labeled := dockerAPIResponse{
ID: "cafe00112233ab",
Names: []string{"/agents-app"},
Image: "agents:latest",
State: "running",
Labels: map[string]string{
"app": "agents_and_robots",
"team": "device",
},
}
t.Run("lista solo running", func(t *testing.T) {
// Serve only running containers (All=false → daemon omits exited).
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
// Verify All param NOT set
if r.URL.Query().Get("all") != "" {
t.Errorf("expected no 'all' param, got %q", r.URL.Query().Get("all"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{running})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: false,
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(containers))
}
c := containers[0]
if c.ID != "abc123def456" {
t.Errorf("ID: got %q, want %q", c.ID, "abc123def456")
}
if len(c.Names) == 0 || c.Names[0] != "/my-app" {
t.Errorf("Names: got %v, want [\"/my-app\"]", c.Names)
}
if c.State != "running" {
t.Errorf("State: got %q, want \"running\"", c.State)
}
if len(c.Ports) == 0 {
t.Errorf("expected ports, got empty")
}
if len(c.Networks) == 0 || c.Networks[0] != "bridge" {
t.Errorf("Networks: got %v, want [\"bridge\"]", c.Networks)
}
})
t.Run("All=true incluye exited", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("all") != "1" {
t.Errorf("expected all=1, got %q", r.URL.Query().Get("all"))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{running, exited})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: true,
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
states := map[string]bool{}
for _, c := range containers {
states[c.State] = true
}
if !states["running"] {
t.Error("missing running container")
}
if !states["exited"] {
t.Error("missing exited container")
}
})
t.Run("filter label aplica", func(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/containers/json" {
http.NotFound(w, r)
return
}
filtersRaw := r.URL.Query().Get("filters")
if filtersRaw == "" {
t.Error("expected filters param, got empty")
}
// Verify the filter map includes label key
var fm map[string][]string
if err := json.Unmarshal([]byte(filtersRaw), &fm); err != nil {
t.Errorf("parse filters: %v", err)
}
labelFilters := fm["label"]
found := false
for _, lf := range labelFilters {
if lf == "app=agents_and_robots" {
found = true
}
}
if !found {
t.Errorf("filter label=app=agents_and_robots not found in %v", fm)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]dockerAPIResponse{labeled})
})
dockerHost, cleanup := startMockDockerUnix(t, handler)
defer cleanup()
containers, err := DockerContainerList(DockerContainerListOpts{
All: true,
Filters: []string{"label=app=agents_and_robots"},
DockerHost: dockerHost,
})
if err != nil {
t.Fatalf("DockerContainerList: %v", err)
}
if len(containers) != 1 {
t.Fatalf("expected 1 container, got %d", len(containers))
}
c := containers[0]
if c.Labels["app"] != "agents_and_robots" {
t.Errorf("Labels[app]: got %q, want %q", c.Labels["app"], "agents_and_robots")
}
})
}
+290 -11
View File
@@ -1,23 +1,302 @@
package infra
import (
"context"
"encoding/binary"
"fmt"
"os/exec"
"strconv"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// DockerContainerLogs obtiene los logs de un contenedor. tail limita las últimas N líneas (0 = todas).
func DockerContainerLogs(nameOrID string, tail int) (string, error) {
args := []string{"logs"}
if tail > 0 {
args = append(args, "--tail", strconv.Itoa(tail))
// dockerHTTPClient devuelve un http.Client configurado para hablar con el daemon Docker.
// host puede ser "" (unix socket por defecto), "unix:///ruta/al/socket" o "tcp://host:port".
func dockerHTTPClient(host string) (*http.Client, string, error) {
if host == "" {
host = "unix:///var/run/docker.sock"
}
args = append(args, nameOrID)
out, err := exec.Command("docker", args...).CombinedOutput()
u, err := url.Parse(host)
if err != nil {
return "", fmt.Errorf("docker logs %s: %w", nameOrID, err)
return nil, "", fmt.Errorf("docker host URL invalida %q: %w", host, err)
}
return string(out), nil
var transport http.RoundTripper
var baseURL string
switch u.Scheme {
case "unix":
socketPath := u.Path
if socketPath == "" {
socketPath = u.Host // algunos parsers meten el path en Host para unix://
}
transport = &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
}
baseURL = "http://localhost"
case "tcp", "http":
transport = http.DefaultTransport
baseURL = "http://" + u.Host
case "https":
transport = http.DefaultTransport
baseURL = "https://" + u.Host
default:
return nil, "", fmt.Errorf("docker host scheme no soportado: %q", u.Scheme)
}
return &http.Client{Transport: transport, Timeout: 0}, baseURL, nil
}
// dockerLogsURL construye la URL para GET /containers/<id>/logs con los parametros dados.
func dockerLogsURL(baseURL, containerID string, opts DockerLogsOpts, follow bool) string {
tail := opts.Tail
if tail == 0 {
tail = 100
}
tailStr := fmt.Sprintf("%d", tail)
if tail < 0 {
tailStr = "all"
}
stdout := opts.Stdout
stderr := opts.Stderr
if !stdout && !stderr {
stdout = true
stderr = true
}
params := url.Values{}
params.Set("stdout", boolParam(stdout))
params.Set("stderr", boolParam(stderr))
params.Set("tail", tailStr)
params.Set("timestamps", boolParam(opts.Timestamps))
if follow {
params.Set("follow", "1")
} else {
params.Set("follow", "0")
}
if opts.Since != "" {
params.Set("since", opts.Since)
}
return fmt.Sprintf("%s/containers/%s/logs?%s", baseURL, url.PathEscape(containerID), params.Encode())
}
func boolParam(b bool) string {
if b {
return "1"
}
return "0"
}
// dockerDemuxFrame lee un frame del protocolo de multiplexion de Docker.
// El frame header tiene 8 bytes: [stream_type(1), 0,0,0, size(4 big-endian)].
// Retorna (streamType, payload, error). streamType: 1=stdout, 2=stderr.
// Retorna io.EOF cuando no hay mas frames.
func dockerDemuxFrame(r io.Reader) (uint8, []byte, error) {
var header [8]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, err // io.EOF si el stream termino limpiamente
}
streamType := header[0]
size := binary.BigEndian.Uint32(header[4:8])
if size == 0 {
return streamType, nil, nil
}
payload := make([]byte, size)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, nil, fmt.Errorf("leyendo payload del frame docker: %w", err)
}
return streamType, payload, nil
}
// dockerStreamToString convierte el streamType del frame header al string "stdout"/"stderr".
func dockerStreamToString(t uint8) string {
if t == 2 {
return "stderr"
}
return "stdout"
}
// dockerParsePayload convierte el payload de un frame en DockerLogLines.
// Cada linea del payload se convierte en una DockerLogLine separada.
// Si timestamps esta habilitado, Docker prefija cada linea con "2006-01-02T15:04:05.000000000Z ".
func dockerParsePayload(streamType uint8, payload []byte, timestamps bool) []DockerLogLine {
raw := strings.TrimRight(string(payload), "\n")
rawLines := strings.Split(raw, "\n")
stream := dockerStreamToString(streamType)
lines := make([]DockerLogLine, 0, len(rawLines))
for _, l := range rawLines {
if l == "" {
continue
}
line := DockerLogLine{Stream: stream}
if timestamps {
// Docker antepone timestamp seguido de espacio: "2026-05-23T12:00:00.000Z texto"
idx := strings.Index(l, " ")
if idx > 0 {
line.Timestamp = l[:idx]
line.Line = l[idx+1:]
} else {
line.Line = l
}
} else {
line.Line = l
}
lines = append(lines, line)
}
return lines
}
// DockerContainerLogs obtiene los logs de un contenedor en modo snapshot (sin follow).
// Retorna hasta opts.Tail lineas (default 100, -1 = todas). Demuxea stdout/stderr.
func DockerContainerLogs(opts DockerLogsOpts) ([]DockerLogLine, error) {
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return nil, err
}
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, false)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("construyendo request logs: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker logs %s: %w", opts.ContainerID, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("container %q no encontrado", opts.ContainerID)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("docker logs HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var result []DockerLogLine
for {
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
break
}
if err != nil {
return result, fmt.Errorf("leyendo frame docker logs: %w", err)
}
if len(payload) == 0 {
continue
}
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
result = append(result, lines...)
}
return result, nil
}
// DockerContainerLogsStream hace follow de los logs de un contenedor en modo streaming.
// Por cada linea recibida llama cb. Si cb retorna error, el stream se cancela.
// ctx permite cancelacion externa. No hace reconexion automatica — el caller decide si reintentar.
func DockerContainerLogsStream(ctx context.Context, opts DockerLogsOpts, cb func(DockerLogLine) error) error {
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
if err != nil {
return err
}
// Para streaming necesitamos un cliente sin timeout de lectura global.
client.Timeout = 0
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, true)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("construyendo request logs stream: %w", err)
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("docker logs stream %s: %w", opts.ContainerID, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("container %q no encontrado", opts.ContainerID)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("docker logs stream HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
// Leer frames hasta ctx cancel, EOF o error de cb.
for {
// Verificar cancelacion antes de cada lectura.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
streamType, payload, err := dockerDemuxFrame(resp.Body)
if err == io.EOF {
return nil // contenedor termino o daemon cerro el stream
}
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("leyendo frame docker logs stream: %w", err)
}
if len(payload) == 0 {
continue
}
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
for _, line := range lines {
if err := cb(line); err != nil {
return err // caller cancela via error
}
}
}
}
// dockerSince convierte duracion tipo "10m" a unix timestamp string para la API de Docker.
// La API acepta directamente duraciones asi que esta funcion es solo documentacion del contrato.
// Docker Engine acepta: unix timestamp int, RFC3339 timestamp, o Go duration string ("10m", "1h30m").
func dockerSince(_ string) string {
// Documentacion: Docker acepta directamente la string. No necesitamos conversion.
return ""
}
// dockerClientWithTimeout crea un http.Client con timeout de conexion pero sin timeout de lectura.
// Util para detectar rapido si el daemon no responde.
func dockerClientWithTimeout(host string, connectTimeout time.Duration) (*http.Client, string, error) {
client, baseURL, err := dockerHTTPClient(host)
if err != nil {
return nil, "", err
}
if transport, ok := client.Transport.(*http.Transport); ok {
origDial := transport.DialContext
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
defer cancel()
return origDial(dialCtx, network, addr)
}
}
return client, baseURL, nil
}
+79 -18
View File
@@ -3,36 +3,97 @@ name: docker_container_logs
kind: function
lang: go
domain: infra
version: "1.0.0"
version: "2.0.0"
purity: impure
signature: "func DockerContainerLogs(nameOrID string, tail int) (string, error)"
description: "Obtiene los logs de un contenedor Docker. El parámetro tail limita a las últimas N líneas (0 devuelve todos los logs)."
tags: [docker, container, logs, infra]
signature: "func DockerContainerLogs(opts DockerLogsOpts) ([]DockerLogLine, error)"
description: "Tail/grep logs de container Docker via engine API. Snapshot (N lineas) o streaming (callback por linea con context cancel). Demux frame stdout/stderr. Capability docker.container.logs del device_agent."
tags: [docker, docker-agent, logs, streaming, infra]
uses_functions: []
uses_types: []
returns: []
uses_types:
- docker_logs_opts_go_infra
- docker_log_line_go_infra
- error_go_core
returns:
- docker_log_line_go_infra
returns_optional: false
error_type: "error_go_core"
imports: [fmt, os/exec, strconv]
imports:
- context
- encoding/binary
- fmt
- io
- net
- net/http
- net/url
- strings
- time
params:
- name: nameOrID
desc: "nombre o ID del contenedor Docker"
- name: tail
desc: "numero de ultimas lineas a devolver (0 devuelve todos los logs)"
output: "logs del contenedor como string"
tested: false
tests: []
test_file_path: ""
- name: opts
desc: "Parametros de la peticion: container ID, tail N, since, stdout/stderr, timestamps, docker host. Ver DockerLogsOpts."
output: "Slice de DockerLogLine con stream (stdout/stderr), timestamp RFC3339 opcional y texto de la linea."
tested: true
tests:
- "snapshot stdout y stderr demuxeados"
- "container no encontrado retorna error"
- "timestamps parseados del prefijo Docker"
- "tail y since se envian como query params"
- "streaming recibe lineas via callback"
- "ctx cancel detiene el stream"
- "callback error cancela el stream"
- "frame stdout decodificado correctamente"
- "frame stderr decodificado correctamente"
test_file_path: "functions/infra/docker_container_logs_test.go"
file_path: "functions/infra/docker_container_logs.go"
---
## Ejemplo
```go
// Últimas 100 líneas
logs, err := DockerContainerLogs("my-app", 100)
// Snapshot: ultimas 50 lineas de stdout+stderr
lines, err := DockerContainerLogs(infra.DockerLogsOpts{
ContainerID: "registry_api",
Tail: 50,
Since: "10m",
Stdout: true,
Stderr: true,
Timestamps: true,
})
if err != nil {
log.Fatal(err)
}
fmt.Println(logs)
for _, l := range lines {
fmt.Printf("[%s] %s %s\n", l.Stream, l.Timestamp, l.Line)
}
// Streaming: follow hasta cancelacion
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err = infra.DockerContainerLogsStream(ctx, infra.DockerLogsOpts{
ContainerID: "registry_api",
Stdout: true,
Stderr: true,
}, func(line infra.DockerLogLine) error {
fmt.Printf("[%s] %s\n", line.Stream, line.Line)
if strings.Contains(line.Line, "FATAL") {
return fmt.Errorf("fatal error detectado")
}
return nil
})
```
## Cuando usarla
Cuando el device_agent necesite leer o monitorizar logs de un container Docker en tiempo real. Usar modo snapshot para health checks puntuales (N ultimas lineas). Usar streaming para tail -f reactivo con procesamiento por linea.
## Gotchas
- Containers sin `--tty` usan el protocolo de multiplexion de 8 bytes — esta funcion lo demuxea correctamente. Containers con `--tty` mezclan stdout/stderr en un stream plano sin headers, lo que puede dar `Stream = "stdout"` para todo o parsear mal el header (byte 0 podria ser el primer caracter de texto).
- Streaming consume una goroutine/conexion hasta que `ctx` se cancele o `cb` retorne error. El caller es responsable del ciclo de vida del contexto.
- `Since` acepta unix timestamp en string, RFC3339 o duracion Go ("10m", "1h30m"). El daemon Docker acepta los 3 formatos directamente.
- Sin reconexion automatica en streaming. Si el daemon reinicia o la conexion se corta, el caller recibe error y decide si reintentar.
- `DockerHost` vacio conecta a `/var/run/docker.sock`. En sistemas donde el socket esta en otra ruta (Docker Desktop macOS, Podman), pasar la URL explicitamente.
## Capability growth log
v2.0.0 (2026-05-23) — reemplaza implementacion CLI (exec docker logs) por engine API HTTP con demux de frames. Anade DockerLogsOpts, DockerLogLine, modo streaming con callback y ctx cancel. Consumidor nordvpn_container_start actualizado.
@@ -0,0 +1,320 @@
package infra
import (
"context"
"encoding/binary"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// buildDockerFrame construye un frame del protocolo de multiplexion de Docker.
// streamType: 1=stdout, 2=stderr. payload: contenido (puede incluir newline).
func buildDockerFrame(streamType uint8, payload string) []byte {
data := []byte(payload)
frame := make([]byte, 8+len(data))
frame[0] = streamType
binary.BigEndian.PutUint32(frame[4:8], uint32(len(data)))
copy(frame[8:], data)
return frame
}
// buildMultiFrame concatena multiples frames en un unico slice de bytes.
func buildMultiFrame(frames ...[]byte) []byte {
var buf []byte
for _, f := range frames {
buf = append(buf, f...)
}
return buf
}
func TestDockerContainerLogs_Snapshot(t *testing.T) {
t.Run("snapshot stdout y stderr demuxeados", func(t *testing.T) {
body := buildMultiFrame(
buildDockerFrame(1, "linea stdout 1\n"),
buildDockerFrame(2, "linea stderr 1\n"),
buildDockerFrame(1, "linea stdout 2\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/containers/") {
t.Errorf("path inesperado: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
w.Write(body)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "test-container",
Tail: 10,
Stdout: true,
Stderr: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("DockerContainerLogs error: %v", err)
}
if len(lines) != 3 {
t.Fatalf("esperadas 3 lineas, got %d", len(lines))
}
if lines[0].Stream != "stdout" {
t.Errorf("linea[0].Stream = %q, want stdout", lines[0].Stream)
}
if lines[0].Line != "linea stdout 1" {
t.Errorf("linea[0].Line = %q, want 'linea stdout 1'", lines[0].Line)
}
if lines[1].Stream != "stderr" {
t.Errorf("linea[1].Stream = %q, want stderr", lines[1].Stream)
}
if lines[1].Line != "linea stderr 1" {
t.Errorf("linea[1].Line = %q, want 'linea stderr 1'", lines[1].Line)
}
if lines[2].Stream != "stdout" {
t.Errorf("linea[2].Stream = %q, want stdout", lines[2].Stream)
}
})
t.Run("container no encontrado retorna error", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message":"No such container: missing"}`))
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "missing",
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
_, err := DockerContainerLogs(opts)
if err == nil {
t.Fatal("esperaba error para container no encontrado")
}
if !strings.Contains(err.Error(), "missing") {
t.Errorf("error no menciona el container: %v", err)
}
})
t.Run("timestamps parseados del prefijo Docker", func(t *testing.T) {
// Docker prefija: "2026-05-23T12:00:00.000000000Z texto\n"
payload := "2026-05-23T12:00:00.000000000Z hello timestamps\n"
body := buildDockerFrame(1, payload)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("timestamps") != "1" {
t.Errorf("timestamps param no enviado, query: %s", r.URL.RawQuery)
}
w.WriteHeader(http.StatusOK)
w.Write(body)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "ts-container",
Stdout: true,
Timestamps: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(lines) != 1 {
t.Fatalf("esperada 1 linea, got %d", len(lines))
}
if lines[0].Timestamp != "2026-05-23T12:00:00.000000000Z" {
t.Errorf("Timestamp = %q, want RFC3339", lines[0].Timestamp)
}
if lines[0].Line != "hello timestamps" {
t.Errorf("Line = %q, want 'hello timestamps'", lines[0].Line)
}
})
t.Run("tail y since se envian como query params", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("tail") != "50" {
t.Errorf("tail = %q, want '50'", q.Get("tail"))
}
if q.Get("since") != "10m" {
t.Errorf("since = %q, want '10m'", q.Get("since"))
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "c1",
Tail: 50,
Since: "10m",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
lines, err := DockerContainerLogs(opts)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(lines) != 0 {
t.Errorf("esperadas 0 lineas de body vacio, got %d", len(lines))
}
})
}
func TestDockerContainerLogsStream(t *testing.T) {
t.Run("streaming recibe lineas via callback", func(t *testing.T) {
frames := buildMultiFrame(
buildDockerFrame(1, "stream line 1\n"),
buildDockerFrame(2, "stream line 2\n"),
buildDockerFrame(1, "stream line 3\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("follow") != "1" {
t.Errorf("follow param no enviado")
}
w.WriteHeader(http.StatusOK)
w.Write(frames)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "stream-container",
Stdout: true,
Stderr: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
var received []DockerLogLine
ctx := context.Background()
err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error {
received = append(received, line)
return nil
})
if err != nil {
t.Fatalf("DockerContainerLogsStream error: %v", err)
}
if len(received) != 3 {
t.Fatalf("esperadas 3 lineas, got %d", len(received))
}
if received[0].Line != "stream line 1" {
t.Errorf("received[0].Line = %q", received[0].Line)
}
if received[1].Stream != "stderr" {
t.Errorf("received[1].Stream = %q, want stderr", received[1].Stream)
}
})
t.Run("ctx cancel detiene el stream", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
flusher := w.(http.Flusher)
w.Write(buildDockerFrame(1, "antes del cancel\n"))
flusher.Flush()
// Bloquear hasta que el cliente cierre la conexion.
<-r.Context().Done()
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "cancel-container",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
var count int
err := DockerContainerLogsStream(ctx, opts, func(line DockerLogLine) error {
count++
return nil
})
if err == nil {
t.Error("esperaba error de cancelacion de contexto")
}
if count == 0 {
t.Error("esperaba recibir al menos 1 linea antes del cancel")
}
})
t.Run("callback error cancela el stream", func(t *testing.T) {
frames := buildMultiFrame(
buildDockerFrame(1, "linea 1\n"),
buildDockerFrame(1, "linea 2\n"),
buildDockerFrame(1, "linea 3\n"),
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(frames)
}))
defer srv.Close()
opts := DockerLogsOpts{
ContainerID: "cb-error-container",
Stdout: true,
DockerHost: "tcp://" + srv.Listener.Addr().String(),
}
stopErr := errors.New("stop processing")
var count int
err := DockerContainerLogsStream(context.Background(), opts, func(line DockerLogLine) error {
count++
if count >= 2 {
return stopErr
}
return nil
})
if !errors.Is(err, stopErr) {
t.Errorf("esperaba stopErr, got: %v", err)
}
if count < 2 {
t.Errorf("esperaba al menos 2 invocaciones del callback, got %d", count)
}
if count > 3 {
t.Errorf("callback invocado demasiadas veces tras error: %d", count)
}
})
}
func TestDockerDemuxFrame(t *testing.T) {
t.Run("frame stdout decodificado correctamente", func(t *testing.T) {
payload := "hello world"
frame := buildDockerFrame(1, payload)
r := strings.NewReader(string(frame))
streamType, data, err := dockerDemuxFrame(r)
if err != nil {
t.Fatalf("error: %v", err)
}
if streamType != 1 {
t.Errorf("streamType = %d, want 1", streamType)
}
if string(data) != payload {
t.Errorf("payload = %q, want %q", string(data), payload)
}
})
t.Run("frame stderr decodificado correctamente", func(t *testing.T) {
frame := buildDockerFrame(2, "error line")
r := strings.NewReader(string(frame))
streamType, _, err := dockerDemuxFrame(r)
if err != nil {
t.Fatalf("error: %v", err)
}
if streamType != 2 {
t.Errorf("streamType = %d, want 2 (stderr)", streamType)
}
})
}
+29
View File
@@ -0,0 +1,29 @@
package infra
// DockerLogsOpts parametriza la peticion de logs al engine API de Docker.
type DockerLogsOpts struct {
// ContainerID es el ID o nombre del contenedor.
ContainerID string
// Tail es el numero de ultimas lineas a devolver. -1 = todas. Default efectivo 100 si es 0.
Tail int
// Since filtra logs desde este instante. Acepta unix timestamp ("1716400000") o duracion ("10m", "1h").
Since string
// Stdout incluye el stream stdout (default true si ambos son false).
Stdout bool
// Stderr incluye el stream stderr (default true si ambos son false).
Stderr bool
// Timestamps incluye el timestamp RFC3339 de cada linea en el campo Line prefijado por Docker.
Timestamps bool
// DockerHost es la URL del socket/TCP del daemon Docker. Vacio = unix:///var/run/docker.sock.
DockerHost string
}
// DockerLogLine es una linea de log de un contenedor Docker con su stream de origen.
type DockerLogLine struct {
// Stream indica el origen: "stdout" o "stderr".
Stream string
// Timestamp es el timestamp RFC3339 de la linea. Vacio si DockerLogsOpts.Timestamps es false.
Timestamp string
// Line es el contenido de la linea de log (sin newline final).
Line string
}
+15
View File
@@ -0,0 +1,15 @@
package infra
// ErrorGoCore is the standard error type for impure functions in the infra package.
// It wraps a message string and an optional machine-readable Code, and satisfies the error interface.
type ErrorGoCore struct {
Message string
Code string // optional machine-readable error code (e.g. "FETCH_ERROR", "CONVERGENCE_FAILED")
}
func (e *ErrorGoCore) Error() string {
if e.Code != "" {
return e.Code + ": " + e.Message
}
return e.Message
}
+107
View File
@@ -0,0 +1,107 @@
//go:build goolm || libolm
package infra
import (
"context"
"fmt"
"os"
"path/filepath"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
)
// MatrixCryptoInitConfig parametriza la inicializacion del crypto store Olm/Megolm.
type MatrixCryptoInitConfig struct {
// Client es el *mautrix.Client ya inicializado via MatrixClientInit.
// Debe tener AccessToken, UserID y DeviceID poblados.
Client *mautrix.Client
// StorePath es la ruta absoluta al archivo SQLite del crypto store.
// Debe ser separado del state store. El SDK gestiona el schema internamente.
// Si el directorio padre no existe, se crea con permisos 0700.
// Ejemplo: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
StorePath string
// PickleKey son exactamente 32 bytes usados por cryptohelper para cifrar las
// sesiones Olm en disco at-rest. DEBE persistir entre arranques (guardar en keyring).
// Si se pierde, el store SQLite se vuelve inutilizable y hay que crear nuevo dispositivo.
PickleKey []byte
}
// MatrixCryptoInitResult contiene el helper listo para usar.
type MatrixCryptoInitResult struct {
// Helper es el *cryptohelper.CryptoHelper inicializado.
// Ya esta asignado a client.Crypto — el Sync loop cifra/descifra automaticamente.
Helper *cryptohelper.CryptoHelper
// StorePath es la ruta al archivo SQLite del crypto store (igual que cfg.StorePath).
StorePath string
}
// MatrixCryptoInit inicializa el crypto store Olm/Megolm para un cliente mautrix
// usando cryptohelper — el wrapper oficial que abstrae SQLite + Olm identity keys +
// one-time key upload + decrypt automatico via el Syncer.
//
// Pasos:
// 1. Valida inputs (Client no nil con AccessToken/UserID/DeviceID, StorePath
// absoluto, PickleKey exactamente 32 bytes).
// 2. Crea el directorio padre de StorePath con permisos 0700 si no existe.
// 3. Construye el helper via cryptohelper.NewCryptoHelper(client, pickleKey, storePath).
// 4. Llama helper.Init(ctx) — crea tablas SQLite, carga cuenta Olm, sube one-time keys.
// 5. Asigna client.Crypto = helper para que SendMessageEvent cifre automaticamente.
// 6. Devuelve MatrixCryptoInitResult con el helper listo.
func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error) {
// 1. Validar Client
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_crypto_init: Client no puede ser nil")
}
if cfg.Client.AccessToken == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.AccessToken no puede estar vacio")
}
if cfg.Client.UserID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.UserID no puede estar vacio")
}
if cfg.Client.DeviceID == "" {
return nil, fmt.Errorf("matrix_crypto_init: Client.DeviceID no puede estar vacio — descubrirlo via MatrixClientInit o Whoami antes de llamar MatrixCryptoInit")
}
// Validar StorePath
if cfg.StorePath == "" {
return nil, fmt.Errorf("matrix_crypto_init: StorePath no puede estar vacio")
}
if !filepath.IsAbs(cfg.StorePath) {
return nil, fmt.Errorf("matrix_crypto_init: StorePath debe ser una ruta absoluta (got %q)", cfg.StorePath)
}
// Validar PickleKey: exactamente 32 bytes
if len(cfg.PickleKey) != 32 {
return nil, fmt.Errorf("matrix_crypto_init: PickleKey debe tener exactamente 32 bytes (got %d)", len(cfg.PickleKey))
}
// 2. Crear directorio padre con permisos 0700 (datos sensibles)
storeDir := filepath.Dir(cfg.StorePath)
if err := os.MkdirAll(storeDir, 0700); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: no se pudo crear directorio del store %q: %w", storeDir, err)
}
// 3. Construir CryptoHelper — acepta string como path SQLite directamente (v0.28 API)
helper, err := cryptohelper.NewCryptoHelper(cfg.Client, cfg.PickleKey, cfg.StorePath)
if err != nil {
return nil, fmt.Errorf("matrix_crypto_init: NewCryptoHelper failed: %w", err)
}
// 4. Init: crea tablas SQLite, carga cuenta Olm, sube one-time keys al servidor
if err := helper.Init(ctx); err != nil {
return nil, fmt.Errorf("matrix_crypto_init: helper.Init failed (comprueba conectividad con Synapse y validez del token): %w", err)
}
// 5. Asignar client.Crypto para que SendMessageEvent cifre automaticamente
cfg.Client.Crypto = helper
return &MatrixCryptoInitResult{
Helper: helper,
StorePath: cfg.StorePath,
}, nil
}
+96
View File
@@ -0,0 +1,96 @@
---
name: matrix_crypto_init
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error)"
description: "Inicializa el crypto store Olm/Megolm para un *mautrix.Client usando cryptohelper v0.28+. Crea el SQLite store, carga la cuenta Olm, sube one-time keys al servidor y asigna client.Crypto para que SendMessageEvent cifre automaticamente en rooms E2EE."
tags: [matrix, mautrix, e2ee, olm, megolm, crypto, cryptohelper, infra, matrix-mas]
params:
- name: ctx
desc: "context.Context con deadline/cancel. Se propaga a helper.Init() que hace HTTP a Synapse. Usar timeout de al menos 5s (primera vez puede tardar ~500ms por /keys/upload)."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado via MatrixClientInit. Debe tener AccessToken, UserID y DeviceID poblados. DeviceID es obligatorio — descubrirlo via Whoami antes si no lo tienes."
- name: cfg.StorePath
desc: "Ruta absoluta al archivo SQLite del crypto store. Separado del state store. Si el directorio padre no existe, se crea con permisos 0700. Ejemplo: /home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
- name: cfg.PickleKey
desc: "Exactamente 32 bytes usados para cifrar las sesiones Olm at-rest en el SQLite. Generar con crypto/rand.Read(). DEBE persistir entre arranques — guardar en keyring del sistema. Si se pierde, el store se vuelve inutilizable."
output: "*MatrixCryptoInitResult con Helper (*cryptohelper.CryptoHelper ya asignado a client.Crypto y listo para Sync/SendMessageEvent) y StorePath (ruta al SQLite). Llamar helper.Close() en shutdown."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/crypto/cryptohelper"
tested: true
tests:
- "Client nil devuelve error"
- "AccessToken vacio devuelve error"
- "UserID vacio devuelve error"
- "DeviceID vacio devuelve error"
- "StorePath vacio devuelve error"
- "StorePath relativo devuelve error"
- "PickleKey != 32 bytes devuelve error"
- "directorio del store se crea con permisos 0700"
- "input valido Init exito helper no nil"
- "Synapse 401 en keys upload devuelve error"
test_file_path: "functions/infra/matrix_crypto_init_test.go"
file_path: "functions/infra/matrix_crypto_init.go"
---
## Ejemplo
```go
import (
"context"
"crypto/rand"
infra "fn-registry/functions/infra"
)
// Paso 1: cliente ya inicializado (ver matrix_client_init_go_infra)
clientRes, err := infra.MatrixClientInit(infra.MatrixClientInitConfig{
HomeserverURL: "https://matrix-af2f3d.organic-machine.com",
UserID: "@egutierrez:matrix-af2f3d.organic-machine.com",
AccessToken: "mxat_xyz...",
DeviceID: "MYDEVICEID",
StoreDir: "/home/lucas/.config/matrix_client_pc/egutierrez/",
})
if err != nil { panic(err) }
// Paso 2: generar PickleKey (guardar en keyring, NO en codigo)
pickleKey := make([]byte, 32)
if _, err := rand.Read(pickleKey); err != nil { panic(err) }
// Persistir: secret-tool store --label="matrix pickle" service matrix account @user:server
// Paso 3: activar E2EE
ctx := context.Background()
cryptoRes, err := infra.MatrixCryptoInit(ctx, infra.MatrixCryptoInitConfig{
Client: clientRes.Client,
StorePath: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db",
PickleKey: pickleKey,
})
if err != nil { panic(err) }
defer cryptoRes.Helper.Close()
// Ahora clientRes.Client.SendMessageEvent en rooms E2EE cifra automaticamente.
// El Syncer descifra mensajes recibidos tambien automaticamente.
```
## Cuando usarla
Llamar UNA vez por sesion, tras `MatrixClientInit` y ANTES de arrancar `client.Sync()`. El orden es critico: si Sync arranca antes, los primeros eventos cifrados llegan sin handler Olm y se pierden. Una vez asignado `client.Crypto`, el Sync loop gestiona cifrado y descifrado transparente sin codigo adicional.
## Gotchas
- **PickleKey DEBE sobrevivir entre arranques**: si pierdes los 32 bytes, el store SQLite no se puede abrir y debes hacer nuevo login con nuevo DeviceID. Guardar obligatoriamente en keyring: `secret-tool store --label="matrix pickle key" service matrix_client_pc account pickle_key_@egutierrez:servidor`.
- **DeviceID es obligatorio**: a diferencia de `MatrixClientInit` (que puede descubrirlo via Whoami), esta funcion falla si `Client.DeviceID` esta vacio para evitar crear un store huerfano vinculado a ningun dispositivo real.
- **StorePath debe ser persistente**: NO usar `/tmp/`. Si el store se pierde entre arranques, se pierden las sesiones Olm — los mensajes historicos en rooms E2EE NO se podran descifrar sin Key Backup (issue 0150 full).
- **Init() hace HTTP a Synapse**: primera vez ~500ms por `/keys/upload`. Usar context con timeout >= 5s. Si devuelve error con "M_UNKNOWN_TOKEN", el access token caducó — refrescar via OIDC.
- **Sin cross-signing/SAS**: otros dispositivos ven el tuyo como "unverified" (amber warning en Element). E2EE sigue funcionando — cifra y descifra OK via TOFU. Cross-signing e implementacion de verificacion quedan para issue 0150 completo.
- **Build tag obligatorio**: el archivo requiere `-tags goolm` (puro Go, sin CGO) o `-tags libolm` (CGO + libolm-dev instalado). Sin ninguno de los dos, el archivo no compila (build constraint).
- **client.Syncer debe ser ExtensibleSyncer**: `mautrix.DefaultSyncer` lo implementa. Si usas Syncer custom, verificar que implementa `mautrix.ExtensibleSyncer` o `NewCryptoHelper` fallara.
- **Cerrar el helper en shutdown**: `helper.Close()` cierra la conexion SQLite del store. Imprescindible para evitar WAL leak en el crypto.db.
+321
View File
@@ -0,0 +1,321 @@
//go:build goolm || libolm
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// makeTestClient construye un *mautrix.Client apuntando al servidor dado con
// credenciales validas para los tests.
func makeTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@user:localhost", "test-token")
if err != nil {
t.Fatalf("mautrix.NewClient: %v", err)
}
cli.AccessToken = "test-token"
cli.UserID = id.UserID("@user:localhost")
cli.DeviceID = id.DeviceID("TESTDEVICE")
return cli
}
// validPickleKey genera una clave de 32 bytes para tests.
func validPickleKey() []byte {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
return key
}
// newSynapseMock crea un httptest.Server que responde a los endpoints
// necesarios para Init(): /keys/upload y /keys/query.
// Acepta un statusCode para /keys/upload (200 = exito, 401 = token invalido).
func newSynapseMock(t *testing.T, uploadStatus int) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// POST /_matrix/client/v3/keys/upload -> one-time key counts
mux.HandleFunc("/_matrix/client/v3/keys/upload", func(w http.ResponseWriter, r *http.Request) {
if uploadStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(uploadStatus)
resp := map[string]any{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid access token",
}
_ = json.NewEncoder(w).Encode(resp)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"one_time_key_counts": map[string]int{
"signed_curve25519": 50,
},
}
_ = json.NewEncoder(w).Encode(resp)
})
// POST /_matrix/client/v3/keys/query -> empty device keys
mux.HandleFunc("/_matrix/client/v3/keys/query", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"device_keys": map[string]any{},
"failures": map[string]any{},
"master_keys": map[string]any{},
"user_signing_keys": map[string]any{},
"self_signing_keys": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// GET /_matrix/client/v3/sync -> minimal empty sync
mux.HandleFunc("/_matrix/client/v3/sync", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"next_batch": "s0_1",
"rooms": map[string]any{},
"to_device": map[string]any{"events": []any{}},
"device_one_time_keys_count": map[string]any{},
}
_ = json.NewEncoder(w).Encode(resp)
})
// Catchall para no dejar requests colgados
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
})
return httptest.NewServer(mux)
}
func TestMatrixCryptoInit(t *testing.T) {
t.Run("Client nil devuelve error", func(t *testing.T) {
ctx := context.Background()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: nil,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Client nil, got nil")
}
if !strings.Contains(err.Error(), "Client no puede ser nil") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
t.Run("AccessToken vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "")
cli.UserID = "@user:localhost"
cli.DeviceID = "DEVID"
cli.AccessToken = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con AccessToken vacio, got nil")
}
})
t.Run("UserID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "", "token_abc")
cli.DeviceID = "DEVID"
cli.AccessToken = "token_abc"
cli.UserID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con UserID vacio, got nil")
}
})
t.Run("DeviceID vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token_abc")
cli.AccessToken = "token_abc"
cli.UserID = "@user:localhost"
cli.DeviceID = ""
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con DeviceID vacio, got nil")
}
})
t.Run("StorePath vacio devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath vacio, got nil")
}
})
t.Run("StorePath relativo devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "relative/path/crypto.db",
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con StorePath relativo, got nil")
}
})
t.Run("PickleKey != 32 bytes devuelve error", func(t *testing.T) {
ctx := context.Background()
cli, _ := mautrix.NewClient("http://localhost:8008", "@user:localhost", "token")
cli.AccessToken = "token"
cli.UserID = "@user:localhost"
cli.DeviceID = id.DeviceID("DEVID")
// Clave de 16 bytes (demasiado corta)
shortKey := make([]byte, 16)
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: "/tmp/crypto_test.db",
PickleKey: shortKey,
})
if err == nil {
t.Fatal("esperaba error con PickleKey de 16 bytes, got nil")
}
if !strings.Contains(err.Error(), "32 bytes") {
t.Errorf("mensaje de error debe mencionar '32 bytes', got %q", err.Error())
}
})
t.Run("directorio del store se crea con permisos 0700", func(t *testing.T) {
tmpDir := t.TempDir()
storeDir := filepath.Join(tmpDir, "sub", "crypto_store")
storePath := filepath.Join(storeDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// El Init puede fallar (e.g. sync loop), pero el directorio debe crearse.
_, _ = MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if _, statErr := os.Stat(storeDir); os.IsNotExist(statErr) {
t.Fatalf("el directorio %q no fue creado", storeDir)
}
info, statErr := os.Stat(storeDir)
if statErr != nil {
t.Fatalf("no se pudo stat el directorio: %v", statErr)
}
perm := info.Mode().Perm()
if perm != 0700 {
t.Errorf("permisos del directorio: got %04o, want 0700", perm)
}
})
t.Run("input valido Init exito helper no nil", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusOK)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
res, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err != nil {
t.Fatalf("MatrixCryptoInit failed: %v", err)
}
if res == nil {
t.Fatal("resultado es nil")
}
if res.Helper == nil {
t.Fatal("Helper es nil")
}
if res.StorePath != storePath {
t.Errorf("StorePath: got %q, want %q", res.StorePath, storePath)
}
if cli.Crypto == nil {
t.Error("client.Crypto no fue asignado")
}
// Verificar que el archivo SQLite fue creado
if _, err := os.Stat(storePath); os.IsNotExist(err) {
t.Error("archivo crypto.db no fue creado")
}
if err := res.Helper.Close(); err != nil {
t.Errorf("Helper.Close() error: %v", err)
}
})
t.Run("Synapse 401 en keys upload devuelve error", func(t *testing.T) {
tmpDir := t.TempDir()
storePath := filepath.Join(tmpDir, "crypto.db")
srv := newSynapseMock(t, http.StatusUnauthorized)
defer srv.Close()
cli := makeTestClient(t, srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := MatrixCryptoInit(ctx, MatrixCryptoInitConfig{
Client: cli,
StorePath: storePath,
PickleKey: validPickleKey(),
})
if err == nil {
t.Fatal("esperaba error con Synapse 401, got nil")
}
if !strings.Contains(err.Error(), "helper.Init failed") {
t.Errorf("mensaje de error inesperado: %q", err.Error())
}
})
}
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"bytes"
"context"
"fmt"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// matrixMarkdownToHTML convierte Markdown a HTML sanitizado con goldmark + bluemonday.
// El HTML resultante es seguro para incluir en formatted_body de un evento Matrix.
// Allowlist: bluemonday UGCPolicy + <details>, <summary>, <code>, <pre>.
func matrixMarkdownToHTML(markdown string) (string, error) {
var buf bytes.Buffer
if err := goldmark.Convert([]byte(markdown), &buf); err != nil {
return "", fmt.Errorf("matrix_message_send: goldmark convert: %w", err)
}
p := bluemonday.UGCPolicy()
p.AllowElements("details", "summary", "code", "pre")
sanitized := p.SanitizeBytes(buf.Bytes())
return string(sanitized), nil
}
// matrixSendEvent es el helper interno que llama a client.SendMessageEvent
// y devuelve el id.EventID asignado por Synapse.
func matrixSendEvent(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventType event.Type, content interface{}) (id.EventID, error) {
resp, err := client.SendMessageEvent(ctx, roomID, eventType, content)
if err != nil {
return "", err
}
return resp.EventID, nil
}
// MatrixSendText envía un mensaje de texto plano (m.text) al room indicado.
// Si el room tiene E2EE activo y client.Crypto != nil, mautrix cifra automáticamente.
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendMarkdown convierte markdown a HTML con goldmark, lo sanitiza con bluemonday
// (UGCPolicy + <details>, <summary>, <code>, <pre>) y envía con format=org.matrix.custom.html.
// El campo Body contiene el markdown original como fallback para clientes sin HTML.
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
htmlBody, err := matrixMarkdownToHTML(markdown)
if err != nil {
return "", fmt.Errorf("matrix_message_send.MatrixSendMarkdown: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: markdown,
Format: event.FormatHTML,
FormattedBody: htmlBody,
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReply envía un mensaje con m.relates_to.m.in_reply_to apuntando a replyTo.
// El body es el texto de la respuesta. En v0.1.0 el caller construye la cita si la necesita.
// El cifrado E2EE es automático si client.Crypto está configurado.
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: body,
RelatesTo: (&event.RelatesTo{}).SetReplyTo(replyTo),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixEditMessage envía un replacement event (m.replace) compatible con Element y la spec Matrix.
// NewContent contiene el texto nuevo; Body es el fallback "* newBody" para clientes sin soporte de edición.
// eventID es el evento original a reemplazar.
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: "* " + newBody,
NewContent: &event.MessageEventContent{
MsgType: event.MsgText,
Body: newBody,
},
RelatesTo: (&event.RelatesTo{}).SetReplace(eventID),
}
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
}
// MatrixSendReaction envía un evento m.reaction con m.relates_to.rel_type=m.annotation.
// key debe ser el emoji unicode raw (ej. "👍"), no shortcode (:thumbsup:).
// Las reactions no se cifran aunque el room sea E2EE (comportamiento de mautrix-go).
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error) {
if client == nil {
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
}
content := &event.ReactionEventContent{
RelatesTo: event.RelatesTo{
Type: event.RelAnnotation,
EventID: targetEventID,
Key: key,
},
}
return matrixSendEvent(ctx, client, roomID, event.EventReaction, content)
}
+99
View File
@@ -0,0 +1,99 @@
---
name: matrix_message_send
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: |
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error)
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error)
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error)
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error)
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error)
description: "Envía mensajes Matrix con todas las variantes del compositor: texto plain, markdown con HTML sanitizado, reply con m.in_reply_to, edit (m.replace) y reaction (m.annotation). Si el room es E2EE y client.Crypto está configurado via matrix_crypto_init, mautrix cifra automáticamente."
tags: [matrix, mautrix, send, message, markdown, reply, edit, reaction, infra, matrix-mas]
params:
- name: ctx
desc: "Context para cancelación y timeout de la petición HTTP a Synapse."
- name: client
desc: "*mautrix.Client autenticado. Debe tener AccessToken, UserID y DeviceID. Si es nil, error inmediato."
- name: roomID
desc: "ID del room Matrix destino. Formato: !xxx:server."
- name: body / markdown / newBody
desc: "Contenido del mensaje. Para MatrixSendMarkdown se parsea con goldmark y se sanitiza con bluemonday UGCPolicy."
- name: replyTo / eventID / targetEventID
desc: "ID del evento referenciado (para reply, edit y reaction)."
- name: key
desc: "Emoji unicode raw para reaction (ej. '👍'). No shortcodes (:thumbsup:)."
output: "id.EventID del evento enviado por Synapse + error. El EventID permite referenciar el mensaje para edits, replies o reactions posteriores."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "context"
- "bytes"
- "fmt"
- "github.com/microcosm-cc/bluemonday"
- "github.com/yuin/goldmark"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "SendText body correcto y EventID parseado"
- "SendMarkdown bold convierte a HTML strong y sanitiza script"
- "SendReply m.relates_to m.in_reply_to presente"
- "EditMessage rel_type m.replace y m.new_content"
- "SendReaction tipo m.reaction con m.annotation y key"
- "SendText client nil devuelve error"
- "SendMarkdown client nil devuelve error"
- "SendReply client nil devuelve error"
- "EditMessage client nil devuelve error"
- "SendReaction client nil devuelve error"
test_file_path: "functions/infra/matrix_message_send_test.go"
file_path: "functions/infra/matrix_message_send.go"
---
## Ejemplo
```go
import (
"context"
infra "fn-registry/functions/infra"
"maunium.net/go/mautrix/id"
)
ctx := context.Background()
roomID := id.RoomID("!abc123:organic-machine.com")
// Texto plain
evID, err := infra.MatrixSendText(ctx, client, roomID, "Hola")
// Markdown: **bold**, `code`, > quote -> HTML sanitizado
evID, err = infra.MatrixSendMarkdown(ctx, client, roomID, "**bold** + `code`")
// Reply a un evento existente
evID, err = infra.MatrixSendReply(ctx, client, roomID, id.EventID("$orig:server"), "Si, totalmente")
// Edit de un mensaje ya enviado
evID, err = infra.MatrixEditMessage(ctx, client, roomID, id.EventID("$msg:server"), "texto corregido")
// Reaction emoji
evID, err = infra.MatrixSendReaction(ctx, client, roomID, id.EventID("$msg:server"), "👍")
```
## Cuando usarla
Llamar desde el compositor del cliente Matrix (`matrix_client_pc`) tras inicializar el cliente con `matrix_client_init`. Si el room es E2EE, llamar primero a `matrix_crypto_init` para que `client.Crypto` esté configurado — el cifrado es transparente, no requiere código extra en estas funciones.
## Gotchas
- **Markdown sanitization**: goldmark puede emitir tags HTML arbitrarios si el input los contiene. Esta función aplica `bluemonday.UGCPolicy()` + allowlist extra (`details`, `summary`, `code`, `pre`). Tags fuera de la allowlist como `<script>`, `<iframe>`, `<style>` son eliminados. El texto interno puede quedar como texto plano.
- **Edits sobre mensajes cifrados**: mautrix-go cifra el `m.new_content` también. Receivers que no tengan acceso a la session megolm no verán el edit — verán el mensaje original.
- **Reactions** son evento separado `m.reaction`, NO `m.room.message`. Algunos clientes Matrix viejos las ignoran. No se cifran aunque el room sea E2EE (limitación de mautrix-go).
- **Reply quote v0.1.0**: esta función NO inserta el texto del mensaje original en el body. Es responsabilidad del caller construir la cita si la necesita. v0.2.0 podría hacer fetch del original via state cache.
- **Edit racing**: si dos edits llegan al mismo tiempo al servidor, gana el de timestamp mayor (regla Matrix server-side). No hay protección contra races en esta función.
- **client nil**: todas las funciones validan `client != nil` y retornan error inmediato. No hacen validación del formato de `roomID` — Synapse responderá con error si es inválido.
+269
View File
@@ -0,0 +1,269 @@
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// newMXTestClient construye un *mautrix.Client apuntando al servidor httptest dado.
func newMXTestClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@testuser:example.com", "mxat_test_token")
if err != nil {
t.Fatalf("newMXTestClient: %v", err)
}
cli.DeviceID = id.DeviceID("TESTDEVICE01")
return cli
}
// mxSendHandler devuelve un http.Handler que:
// - Acepta PUT /…/rooms/{roomID}/send/{eventType}/{txnID}
// - Devuelve {"event_id": "$fakeEvent123:example.com"} con 200
// - Guarda el body JSON decodificado en bodyOut y la path en pathOut para assertions
func mxSendHandler(t *testing.T, bodyOut *map[string]interface{}, pathOut *string) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if pathOut != nil {
*pathOut = r.URL.Path
}
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if bodyOut != nil {
var parsed map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&parsed); err != nil {
t.Errorf("mxSendHandler: json decode: %v", err)
}
*bodyOut = parsed
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"event_id":"$fakeEvent123:example.com"}`))
})
}
func TestMatrixMessageSend(t *testing.T) {
ctx := context.Background()
const roomID = "!testroom:example.com"
const wantEventID = "$fakeEvent123:example.com"
t.Run("SendText body correcto y EventID parseado", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendText(ctx, cli, id.RoomID(roomID), "Hola mundo")
if err != nil {
t.Fatalf("MatrixSendText error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["msgtype"]; got != "m.text" {
t.Errorf("body['msgtype']: got %v, want 'm.text'", got)
}
if got := body["body"]; got != "Hola mundo" {
t.Errorf("body['body']: got %v, want 'Hola mundo'", got)
}
})
t.Run("SendMarkdown bold convierte a HTML strong y sanitiza script", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
evID, err := MatrixSendMarkdown(ctx, cli, id.RoomID(roomID), "**bold**")
if err != nil {
t.Fatalf("MatrixSendMarkdown error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// Body debe ser el markdown original como fallback
if got := body["body"]; got != "**bold**" {
t.Errorf("body['body'] fallback: got %v, want '**bold**'", got)
}
// formatted_body debe contener <strong>bold</strong>
fmtBody, ok := body["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string: %v", body["formatted_body"])
}
if !strings.Contains(fmtBody, "<strong>bold</strong>") {
t.Errorf("formatted_body no contiene <strong>bold</strong>, got: %q", fmtBody)
}
// format debe ser org.matrix.custom.html
if got := body["format"]; got != "org.matrix.custom.html" {
t.Errorf("format: got %v, want 'org.matrix.custom.html'", got)
}
// Sub-test: sanitizer elimina <script>
const xssPayload = `texto <script>alert(1)</script> seguro`
var body2 map[string]interface{}
srv2 := httptest.NewServer(mxSendHandler(t, &body2, nil))
defer srv2.Close()
cli2 := newMXTestClient(t, srv2.URL)
_, err = MatrixSendMarkdown(ctx, cli2, id.RoomID(roomID), xssPayload)
if err != nil {
t.Fatalf("MatrixSendMarkdown XSS error: %v", err)
}
fmtBody2, ok := body2["formatted_body"].(string)
if !ok {
t.Fatalf("formatted_body no es string (XSS test): %v", body2["formatted_body"])
}
// El sanitizer debe eliminar el tag <script>...</script> completo.
// goldmark convierte inline HTML a texto plano antes de sanitizar,
// por lo que el texto interior puede quedar como texto plano — eso es correcto.
if strings.Contains(fmtBody2, "<script>") {
t.Errorf("formatted_body contiene <script> — sanitizer no funciono: %q", fmtBody2)
}
if strings.Contains(fmtBody2, "</script>") {
t.Errorf("formatted_body contiene </script> — sanitizer no funciono: %q", fmtBody2)
}
})
t.Run("SendReply m.relates_to m.in_reply_to presente", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const parentID = "$parentEvent:example.com"
evID, err := MatrixSendReply(ctx, cli, id.RoomID(roomID), id.EventID(parentID), "ack")
if err != nil {
t.Fatalf("MatrixSendReply error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
if got := body["body"]; got != "ack" {
t.Errorf("body['body']: got %v, want 'ack'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
inReplyTo, ok := relatesTo["m.in_reply_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.in_reply_to no es object, got: %v", relatesTo["m.in_reply_to"])
}
if got := inReplyTo["event_id"]; got != parentID {
t.Errorf("m.in_reply_to.event_id: got %v, want %q", got, parentID)
}
})
t.Run("EditMessage rel_type m.replace y m.new_content", func(t *testing.T) {
var body map[string]interface{}
srv := httptest.NewServer(mxSendHandler(t, &body, nil))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const originalID = "$originalEvent:example.com"
evID, err := MatrixEditMessage(ctx, cli, id.RoomID(roomID), id.EventID(originalID), "texto editado")
if err != nil {
t.Fatalf("MatrixEditMessage error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// fallback body
if got := body["body"]; got != "* texto editado" {
t.Errorf("body['body'] fallback: got %v, want '* texto editado'", got)
}
newContent, ok := body["m.new_content"].(map[string]interface{})
if !ok {
t.Fatalf("m.new_content no es object, got: %v", body["m.new_content"])
}
if got := newContent["body"]; got != "texto editado" {
t.Errorf("m.new_content.body: got %v, want 'texto editado'", got)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.replace" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.replace'", got)
}
if got := relatesTo["event_id"]; got != originalID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, originalID)
}
})
t.Run("SendReaction tipo m.reaction con m.annotation y key", func(t *testing.T) {
var body map[string]interface{}
var capturedPath string
srv := httptest.NewServer(mxSendHandler(t, &body, &capturedPath))
defer srv.Close()
cli := newMXTestClient(t, srv.URL)
const targetID = "$targetEvent:example.com"
evID, err := MatrixSendReaction(ctx, cli, id.RoomID(roomID), id.EventID(targetID), "👍")
if err != nil {
t.Fatalf("MatrixSendReaction error: %v", err)
}
if string(evID) != wantEventID {
t.Errorf("EventID: got %q, want %q", evID, wantEventID)
}
// URL debe contener "m.reaction"
if !strings.Contains(capturedPath, "m.reaction") {
t.Errorf("URL path no contiene 'm.reaction': %q", capturedPath)
}
relatesTo, ok := body["m.relates_to"].(map[string]interface{})
if !ok {
t.Fatalf("m.relates_to no es object, got: %v", body["m.relates_to"])
}
if got := relatesTo["rel_type"]; got != "m.annotation" {
t.Errorf("m.relates_to.rel_type: got %v, want 'm.annotation'", got)
}
if got := relatesTo["key"]; got != "👍" {
t.Errorf("m.relates_to.key: got %v, want '👍'", got)
}
if got := relatesTo["event_id"]; got != targetID {
t.Errorf("m.relates_to.event_id: got %v, want %q", got, targetID)
}
})
t.Run("SendText client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendText(ctx, nil, id.RoomID(roomID), "texto")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendMarkdown client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendMarkdown(ctx, nil, id.RoomID(roomID), "**md**")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReply client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReply(ctx, nil, id.RoomID(roomID), "$evID:x", "reply")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("EditMessage client nil devuelve error", func(t *testing.T) {
_, err := MatrixEditMessage(ctx, nil, id.RoomID(roomID), "$evID:x", "new")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
t.Run("SendReaction client nil devuelve error", func(t *testing.T) {
_, err := MatrixSendReaction(ctx, nil, id.RoomID(roomID), "$evID:x", "👍")
if err == nil {
t.Fatal("esperaba error con client nil, got nil")
}
})
}
+300
View File
@@ -0,0 +1,300 @@
package infra
import (
"context"
"fmt"
"log"
"sort"
"strings"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// RoomSummary es el resumen de una room Matrix para renderizar en el sidebar de un cliente.
type RoomSummary struct {
RoomID string `json:"room_id"`
Name string `json:"name,omitempty"` // m.room.name o fallback
CanonicalAlias string `json:"canonical_alias,omitempty"` // #room:server
AvatarMxc string `json:"avatar_mxc,omitempty"` // mxc://...
Topic string `json:"topic,omitempty"`
IsDirect bool `json:"is_direct"` // m.direct account_data
IsSpace bool `json:"is_space"` // m.room.type == m.space
IsEncrypted bool `json:"is_encrypted"` // m.room.encryption state event presente
MemberCount int `json:"member_count"`
LastEventTs int64 `json:"last_event_ts"` // unix ms del ultimo evento conocido
UnreadCount int `json:"unread_count"` // notifications.unread + highlight
Tags []string `json:"tags,omitempty"` // m.tag account_data
}
// MatrixRoomListConfig agrupa los parametros de MatrixRoomList.
type MatrixRoomListConfig struct {
Client *mautrix.Client
}
// MatrixRoomList devuelve todos los rooms en los que el usuario esta unido,
// ordenados por LastEventTs DESC (recientes primero).
//
// Estrategia:
// 1. JoinedRooms() para la lista de room IDs.
// 2. m.direct account_data para detectar DMs.
// 3. Para cada room: State() -> nombre, alias, topic, avatar, encryption, space, members.
// 4. Messages(limit=1) -> LastEventTs (TODO: coste N*HTTP; cachear con TTL 30s).
// 5. GetRoomAccountData("m.tag") -> Tags.
//
// Sub-operaciones que fallan por room concreto no abortan el global.
// LastEventTs puede ser 0 si el store no lo cachea (ver ## Gotchas del .md).
func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_room_list: client no puede ser nil")
}
client := cfg.Client
// 1. Rooms unidos
respJoined, err := client.JoinedRooms(ctx)
if err != nil {
return nil, fmt.Errorf("matrix_room_list: JoinedRooms: %w", err)
}
if len(respJoined.JoinedRooms) == 0 {
return []RoomSummary{}, nil
}
// 2. m.direct -> set roomID -> true
directSet := loadDirectRooms(ctx, client)
// 3. Construir summaries (secuencial para v0.1.0)
results := make([]RoomSummary, 0, len(respJoined.JoinedRooms))
for _, roomID := range respJoined.JoinedRooms {
s := buildRoomSummaryFromState(ctx, client, roomID, directSet)
results = append(results, s)
}
// 4. Ordenar DESC por LastEventTs; si empatan (ej. todo 0) -> alfabetico por Name
sort.Slice(results, func(i, j int) bool {
if results[i].LastEventTs != results[j].LastEventTs {
return results[i].LastEventTs > results[j].LastEventTs
}
return results[i].Name < results[j].Name
})
return results, nil
}
// loadDirectRooms carga m.direct account_data y devuelve un set roomID -> true.
// Falla silenciosamente: si hay error devuelve mapa vacio (IsDirect quedara false).
func loadDirectRooms(ctx context.Context, client *mautrix.Client) map[id.RoomID]bool {
result := make(map[id.RoomID]bool)
var directContent event.DirectChatsEventContent
if err := client.GetAccountData(ctx, "m.direct", &directContent); err != nil {
log.Printf("matrix_room_list: GetAccountData(m.direct) warning: %v", err)
return result
}
for _, rooms := range directContent {
for _, rid := range rooms {
result[rid] = true
}
}
return result
}
// buildRoomSummaryFromState construye el RoomSummary para un room concreto.
// Si State() falla usa el roomID como Name de emergencia.
func buildRoomSummaryFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID, directSet map[id.RoomID]bool) RoomSummary {
s := RoomSummary{
RoomID: string(roomID),
IsDirect: directSet[roomID],
}
// State del room
stateMap, err := client.State(ctx, roomID)
if err != nil {
log.Printf("matrix_room_list: State(%s) warning: %v", roomID, err)
s.Name = deriveRoomName(&s, nil)
return s
}
fillStateFields(&s, stateMap)
s.Name = deriveRoomName(&s, stateMap)
// Tags: m.tag room account_data
s.Tags = loadRoomTags(ctx, client, roomID)
// LastEventTs: Messages(limit=1, dir=backward)
// TODO(0148): caro N*HTTP -> cachear en backend con TTL 30s.
msgs, err := client.Messages(ctx, roomID, "", "", mautrix.DirectionBackward, nil, 1)
if err != nil {
log.Printf("matrix_room_list: Messages(%s) warning: %v", roomID, err)
// No fatal: LastEventTs queda 0 y el room cae al fondo del orden
} else if msgs != nil && len(msgs.Chunk) > 0 {
s.LastEventTs = msgs.Chunk[0].Timestamp
}
return s
}
// ensureParsed llama ParseRaw si el contenido no esta aun parseado.
// ParseRaw devuelve ErrContentAlreadyParsed cuando ya fue parseado (p.ej.
// por parseRoomStateArray al deserializar el state); en ese caso ignoramos
// el error y usamos el Parsed existente.
func ensureParsed(c *event.Content, evtType event.Type) {
if c.Parsed == nil {
_ = c.ParseRaw(evtType)
}
}
// fillStateFields rellena los campos del RoomSummary a partir del state map.
// parseRoomStateArray ya llama ParseRaw al deserializar, por lo que es posible
// que Content.Parsed este ya populado. ensureParsed maneja ambos casos.
func fillStateFields(s *RoomSummary, stateMap mautrix.RoomStateMap) {
// m.room.name
if nameEvts, ok := stateMap[event.StateRoomName]; ok {
if nameEvt, ok := nameEvts[""]; ok {
ensureParsed(&nameEvt.Content, event.StateRoomName)
if c := nameEvt.Content.AsRoomName(); c != nil {
s.Name = c.Name
}
}
}
// m.room.canonical_alias
if aliasEvts, ok := stateMap[event.StateCanonicalAlias]; ok {
if aliasEvt, ok := aliasEvts[""]; ok {
ensureParsed(&aliasEvt.Content, event.StateCanonicalAlias)
if c := aliasEvt.Content.AsCanonicalAlias(); c != nil {
s.CanonicalAlias = string(c.Alias)
}
}
}
// m.room.avatar
if avatarEvts, ok := stateMap[event.StateRoomAvatar]; ok {
if avatarEvt, ok := avatarEvts[""]; ok {
ensureParsed(&avatarEvt.Content, event.StateRoomAvatar)
if c := avatarEvt.Content.AsRoomAvatar(); c != nil {
s.AvatarMxc = string(c.URL)
}
}
}
// m.room.topic
if topicEvts, ok := stateMap[event.StateTopic]; ok {
if topicEvt, ok := topicEvts[""]; ok {
ensureParsed(&topicEvt.Content, event.StateTopic)
if c := topicEvt.Content.AsTopic(); c != nil {
s.Topic = c.Topic
}
}
}
// m.room.encryption (existence = encrypted)
if encEvts, ok := stateMap[event.StateEncryption]; ok {
if _, ok := encEvts[""]; ok {
s.IsEncrypted = true
}
}
// m.room.create -> IsSpace si type == "m.space"
if createEvts, ok := stateMap[event.StateCreate]; ok {
if createEvt, ok := createEvts[""]; ok {
ensureParsed(&createEvt.Content, event.StateCreate)
if c := createEvt.Content.AsCreate(); c != nil {
s.IsSpace = c.Type == event.RoomTypeSpace
}
}
}
// m.room.member: contar membership == join
if memberEvts, ok := stateMap[event.StateMember]; ok {
count := 0
for _, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
count++
}
}
s.MemberCount = count
}
}
// deriveRoomName calcula el nombre display para el room siguiendo la jerarquia:
// 1. Name (ya seteado desde m.room.name).
// 2. CanonicalAlias.
// 3. "Direct Message" si IsDirect.
// 4. Lista de otros miembros si los hay (max 3).
// 5. "Empty room" si MemberCount <= 1.
func deriveRoomName(s *RoomSummary, stateMap mautrix.RoomStateMap) string {
if s.Name != "" {
return s.Name
}
if s.CanonicalAlias != "" {
return s.CanonicalAlias
}
if s.IsDirect {
// Intentar obtener displayname del otro miembro desde el state
if stateMap != nil {
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil &&
c.Membership == event.MembershipJoin &&
userKey != "" {
if c.Displayname != "" {
return c.Displayname
}
return userKey // user ID como fallback
}
}
}
}
return "Direct Message"
}
if stateMap != nil && s.MemberCount > 1 {
// Lista de displaynames de otros miembros (max 3)
names := collectMemberNames(stateMap, 3)
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
return "Empty room"
}
// collectMemberNames extrae hasta maxN displaynames de joined members del state.
func collectMemberNames(stateMap mautrix.RoomStateMap, maxN int) []string {
names := make([]string, 0, maxN)
if memberEvts, ok := stateMap[event.StateMember]; ok {
for userKey, memberEvt := range memberEvts {
if len(names) >= maxN {
break
}
ensureParsed(&memberEvt.Content, event.StateMember)
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
if c.Displayname != "" {
names = append(names, c.Displayname)
} else if userKey != "" {
names = append(names, userKey)
}
}
}
}
return names
}
// loadRoomTags carga m.tag room account_data y devuelve los tag names como []string.
// Falla silenciosamente devolviendo nil.
func loadRoomTags(ctx context.Context, client *mautrix.Client, roomID id.RoomID) []string {
var tagContent event.TagEventContent
if err := client.GetRoomAccountData(ctx, roomID, "m.tag", &tagContent); err != nil {
// No fatal: rooms sin tags dan 404, lo cual es normal
return nil
}
if len(tagContent.Tags) == 0 {
return nil
}
tags := make([]string, 0, len(tagContent.Tags))
for tag := range tagContent.Tags {
tags = append(tags, string(tag))
}
sort.Strings(tags) // orden determinista
return tags
}
+65
View File
@@ -0,0 +1,65 @@
---
name: matrix_room_list
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error)"
description: "Devuelve la lista de rooms Matrix en los que el usuario esta unido con metadata completa (nombre, alias, avatar, topic, encryption, space, DM, tags), ordenada por LastEventTs DESC."
tags: ["matrix", "mautrix", "rooms", "summary", "state", "infra", "matrix-mas"]
params:
- name: ctx
desc: "Context de la llamada. Cancela todas las HTTP requests en curso si se cancela."
- name: cfg.Client
desc: "Cliente mautrix autenticado. Debe haber completado al menos un Sync para que JoinedRooms devuelva datos frescos. No puede ser nil."
output: "[]RoomSummary ordenado por LastEventTs DESC (rooms mas recientes primero). Si LastEventTs es 0 para todos, ordena alfabeticamente por Name."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "3 rooms devueltos con metadata correcta"
- "1 room sin m.room.name usa fallback name"
- "IsDirect set correctamente segun m.direct"
- "IsEncrypted set segun presencia de m.room.encryption"
- "client nil devuelve error"
test_file_path: "functions/infra/matrix_room_list_test.go"
file_path: "functions/infra/matrix_room_list.go"
---
## Ejemplo
```go
rooms, err := MatrixRoomList(ctx, MatrixRoomListConfig{Client: client})
if err != nil {
log.Fatal(err)
}
for _, r := range rooms {
fmt.Printf("%s [%s] enc=%v dm=%v members=%d\n",
r.Name, r.RoomID, r.IsEncrypted, r.IsDirect, r.MemberCount)
}
// Output ejemplo:
// General [!abc:server] enc=true dm=false members=12
// Alice [!xyz:server] enc=true dm=true members=2
```
## Cuando usarla
Usar tras al menos un Sync completado, para poblar el sidebar de rooms en la UI. Llamar periodicamente con un TTL de 30s o tras recibir eventos `m.room.*` / `m.direct` en el sync stream. Ideal para el panel lateral de `matrix_client_pc` y `admin_panel`.
## Gotchas
- **Costoso si muchos rooms**: cada room genera 3+ HTTP calls (State, Messages, m.tag). Para N=50 rooms son ~150 HTTP calls. Cachear en el backend con TTL 30s antes de exponer al frontend.
- **Sin sync previo**: si se llama antes del primer Sync completado, `JoinedRooms` puede devolver lista vacia o stale. Siempre hacer Sync primero.
- **LastEventTs puede ser 0**: mautrix Store en memoria no persiste el timestamp del ultimo evento. Si el store es en memoria (default), `Messages(limit=1)` hace una HTTP call extra por room. Si `LastEventTs == 0`, el room cae al fondo del orden (orden alfabetico por Name como desempate).
- **UnreadCount siempre 0 en v0.1.0**: los notification counters vienen del Sync response, no de la API de state. TODO: obtenerlos del Syncer internamente.
- **Spaces planos**: esta funcion devuelve joined rooms planos. No enumera recursivamente los children de un Space. Para arbol de Space, implementar funcion separada.
- **Content.ParseRaw idempotente**: mautrix `parseRoomStateArray` llama `ParseRaw` al deserializar el state. La funcion usa `ensureParsed` que es no-op si ya esta parseado.
- **IsDirect puede ser false si m.direct no esta sincronizado**: algunas implementaciones de Synapse no sincronizan `m.direct` inmediatamente. Si IsDirect es incorrecto, hacer un Sync completo primero.
+339
View File
@@ -0,0 +1,339 @@
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// matrixTestServer simula las respuestas de Synapse para MatrixRoomList.
// Los room IDs contienen '!' que mautrix URL-codifica como %21 en el path;
// los handlers lo decodifican antes de hacer lookup.
type matrixTestServer struct {
*httptest.Server
joinedRooms []string // room IDs que devuelve /joined_rooms
roomNames map[string]string // roomID -> name (no seteado = sin m.room.name)
encryptedRooms map[string]bool // roomID -> tiene encryption event
directContent map[string][]string // userID -> []roomID
roomTags map[string][]string // roomID -> []tag names
}
func newMatrixTestServer(t *testing.T) *matrixTestServer {
t.Helper()
ts := &matrixTestServer{
joinedRooms: []string{},
roomNames: map[string]string{},
encryptedRooms: map[string]bool{},
directContent: map[string][]string{},
roomTags: map[string][]string{},
}
mux := http.NewServeMux()
// GET /_matrix/client/v3/joined_rooms
mux.HandleFunc("/_matrix/client/v3/joined_rooms", func(w http.ResponseWriter, r *http.Request) {
rooms := make([]string, len(ts.joinedRooms))
copy(rooms, ts.joinedRooms)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"joined_rooms": rooms})
})
// Prefix handler para /rooms/ y /user/
mux.HandleFunc("/_matrix/", func(w http.ResponseWriter, r *http.Request) {
// URL-decode el path completo para manejar %21 -> !
rawPath := r.URL.Path
decodedPath, err := url.PathUnescape(rawPath)
if err != nil {
decodedPath = rawPath
}
w.Header().Set("Content-Type", "application/json")
switch {
// /user/{uid}/account_data/m.direct
case strings.Contains(decodedPath, "/account_data/m.direct") && strings.Contains(decodedPath, "/user/"):
json.NewEncoder(w).Encode(ts.directContent)
// /rooms/{roomId}/state (full state array)
case strings.Contains(decodedPath, "/rooms/") && strings.HasSuffix(decodedPath, "/state"):
roomID := extractRoomIDFromPath(decodedPath, "/state")
ts.serveFullState(w, roomID)
// /rooms/{roomId}/messages
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/messages"):
// Devolver chunk vacio para simplificar (LastEventTs = 0)
json.NewEncoder(w).Encode(map[string]any{
"chunk": []any{},
"start": "",
})
// /rooms/{roomId}/account_data/m.tag
case strings.Contains(decodedPath, "/rooms/") && strings.Contains(decodedPath, "/account_data/m.tag"):
roomID := extractRoomIDFromPath(decodedPath, "/account_data")
tags, ok := ts.roomTags[roomID]
if !ok || len(tags) == 0 {
http.NotFound(w, r)
return
}
tagMap := make(map[string]any)
for _, tag := range tags {
tagMap[tag] = map[string]any{}
}
json.NewEncoder(w).Encode(map[string]any{"tags": tagMap})
default:
http.NotFound(w, r)
}
})
srv := httptest.NewServer(mux)
ts.Server = srv
t.Cleanup(srv.Close)
return ts
}
// extractRoomIDFromPath extrae el roomID de /...rooms/{roomId}/{suffix}.
// suffix debe empezar con "/" (ej. "/state", "/account_data").
func extractRoomIDFromPath(path, suffix string) string {
// Encontrar el segmento entre /rooms/ y suffix
roomsIdx := strings.Index(path, "/rooms/")
if roomsIdx < 0 {
return ""
}
after := path[roomsIdx+len("/rooms/"):]
suffixIdx := strings.Index(after, suffix)
if suffixIdx < 0 {
// suffix no encontrado -> el roomID es lo que queda
return after
}
return after[:suffixIdx]
}
// serveFullState construye y escribe el array de state events para el room.
func (ts *matrixTestServer) serveFullState(w http.ResponseWriter, roomID string) {
events := []map[string]any{}
// m.room.name (si existe)
if name, ok := ts.roomNames[roomID]; ok && name != "" {
events = append(events, map[string]any{
"type": "m.room.name",
"state_key": "",
"content": map[string]any{"name": name},
"event_id": "$name",
"sender": "@bot:test",
"room_id": roomID,
})
}
// m.room.create (sin space)
events = append(events, map[string]any{
"type": "m.room.create",
"state_key": "",
"content": map[string]any{"room_version": "9"},
"event_id": "$create",
"sender": "@user:test",
"room_id": roomID,
})
// m.room.member: dos joined members
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@alice:test",
"content": map[string]any{"membership": "join", "displayname": "Alice"},
"event_id": "$member1",
"sender": "@alice:test",
"room_id": roomID,
})
events = append(events, map[string]any{
"type": "m.room.member",
"state_key": "@bob:test",
"content": map[string]any{"membership": "join", "displayname": "Bob"},
"event_id": "$member2",
"sender": "@bob:test",
"room_id": roomID,
})
// m.room.encryption (si aplica)
if ts.encryptedRooms[roomID] {
events = append(events, map[string]any{
"type": "m.room.encryption",
"state_key": "",
"content": map[string]any{"algorithm": "m.megolm.v1.aes-sha2"},
"event_id": "$enc",
"sender": "@alice:test",
"room_id": roomID,
})
}
json.NewEncoder(w).Encode(events)
}
// newTestClient crea un cliente mautrix apuntando al servidor httptest.
func newTestClient(t *testing.T, srv *matrixTestServer) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(srv.URL, id.UserID("@user:test"), "test_token")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
return cli
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// Test 1: 3 rooms devueltos con metadata correcta.
func TestMatrixRoomList_ThreeRoomsMetadata(t *testing.T) {
t.Run("3 rooms devueltos con metadata correcta", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!room1:test", "!room2:test", "!room3:test"}
srv.roomNames = map[string]string{
"!room1:test": "General",
"!room2:test": "Engineering",
"!room3:test": "Random",
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 3 {
t.Fatalf("got %d rooms, want 3", len(rooms))
}
nameSet := map[string]bool{}
for _, r := range rooms {
nameSet[r.Name] = true
if r.RoomID == "" {
t.Error("RoomID vacio en algun room")
}
// State simulado tiene 2 joined members (alice + bob)
if r.MemberCount != 2 {
t.Errorf("room %s: got MemberCount=%d, want 2", r.RoomID, r.MemberCount)
}
}
for _, want := range []string{"General", "Engineering", "Random"} {
if !nameSet[want] {
t.Errorf("nombre %q no encontrado en rooms", want)
}
}
})
}
// Test 2: room sin m.room.name -> fallback name no vacio.
func TestMatrixRoomList_FallbackName(t *testing.T) {
t.Run("1 room sin m.room.name usa fallback name", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!noname:test"}
// No registramos nombre para !noname:test
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 1 {
t.Fatalf("got %d rooms, want 1", len(rooms))
}
r := rooms[0]
if r.Name == "" {
t.Error("Name no debe ser vacio tras fallback")
}
t.Logf("fallback name para room sin m.room.name: %q", r.Name)
})
}
// Test 3: IsDirect set correctamente segun m.direct.
func TestMatrixRoomList_IsDirect(t *testing.T) {
t.Run("IsDirect set correctamente segun m.direct", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!dm:test", "!group:test"}
srv.roomNames = map[string]string{
"!dm:test": "Alice DM",
"!group:test": "Team channel",
}
// m.direct: !dm:test es DM con @alice:test
srv.directContent = map[string][]string{
"@alice:test": {"!dm:test"},
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!dm:test":
if !r.IsDirect {
t.Errorf("!dm:test: IsDirect debe ser true")
}
case "!group:test":
if r.IsDirect {
t.Errorf("!group:test: IsDirect debe ser false")
}
}
}
})
}
// Test 4: IsEncrypted set segun presencia de m.room.encryption.
func TestMatrixRoomList_IsEncrypted(t *testing.T) {
t.Run("IsEncrypted set segun presencia de m.room.encryption", func(t *testing.T) {
srv := newMatrixTestServer(t)
srv.joinedRooms = []string{"!encrypted:test", "!plain:test"}
srv.roomNames = map[string]string{
"!encrypted:test": "Encrypted room",
"!plain:test": "Plain room",
}
srv.encryptedRooms = map[string]bool{
"!encrypted:test": true,
}
cli := newTestClient(t, srv)
rooms, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: cli})
if err != nil {
t.Fatalf("MatrixRoomList: %v", err)
}
if len(rooms) != 2 {
t.Fatalf("got %d rooms, want 2", len(rooms))
}
for _, r := range rooms {
switch r.RoomID {
case "!encrypted:test":
if !r.IsEncrypted {
t.Errorf("!encrypted:test: IsEncrypted debe ser true")
}
case "!plain:test":
if r.IsEncrypted {
t.Errorf("!plain:test: IsEncrypted debe ser false")
}
}
}
})
}
// Test 5: client nil -> error.
func TestMatrixRoomList_NilClient(t *testing.T) {
t.Run("client nil devuelve error", func(t *testing.T) {
_, err := MatrixRoomList(context.Background(), MatrixRoomListConfig{Client: nil})
if err == nil {
t.Fatal("se esperaba error para client nil, got nil")
}
if !strings.Contains(err.Error(), "nil") {
t.Errorf("el error deberia mencionar nil, got: %v", err)
}
})
}
+366
View File
@@ -0,0 +1,366 @@
package infra
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// MatrixSyncEvent es un evento normalizado emitido por MatrixSyncService.
// Cubre mensajes, pertenencia a sala, redacciones, reacciones, tipeo y estado.
type MatrixSyncEvent struct {
Type string `json:"type"` // "message" | "membership" | "redaction" | "reaction" | "edit" | "encrypted" | "presence" | "typing" | "read_receipt" | "room_state"
RoomID string `json:"room_id"` // ID de la sala (vacio para presencia global)
EventID string `json:"event_id"` // event_id unico Matrix (vacio para eventos efimeros)
Sender string `json:"sender"` // MXID del emisor (vacio para eventos efimeros)
Ts int64 `json:"ts"` // origin_server_ts en milisegundos
Body string `json:"body,omitempty"` // contenido de texto del evento (mensajes)
Raw interface{} `json:"raw,omitempty"` // *event.Event original para acceso completo
}
// MatrixSyncServiceConfig configura el servicio de sync loop de Matrix.
type MatrixSyncServiceConfig struct {
// Client es el *mautrix.Client ya inicializado con credenciales.
// Obligatorio.
Client *mautrix.Client
// InitialBackoffMS es el tiempo inicial de espera entre reintentos tras error (ms).
// Default: 1000 (1 segundo).
InitialBackoffMS int
// MaxBackoffMS es el techo del backoff exponencial (ms).
// Default: 60000 (60 segundos).
MaxBackoffMS int
// ChannelBuffer es la capacidad del canal Events.
// Si el consumer va lento y el buffer se llena, el sync se bloquea hasta
// que el consumer drene. Default: 256.
ChannelBuffer int
}
// MatrixSyncServiceHandle es el handle devuelto por MatrixSyncService.
type MatrixSyncServiceHandle struct {
// Events es el canal de eventos normalizados (cierra al Stop).
Events <-chan MatrixSyncEvent
// Errors recibe errores transitorios (red, 5xx, etc.).
// No fatal: el servicio reintenta con backoff. El caller decide si actuar.
// El canal cierra al Stop.
Errors <-chan error
// Stop cancela el sync loop de forma limpia e idempotente.
// Cierra Events y Errors. Seguro llamar varias veces.
Stop func()
}
// matrixSyncerWrapper envuelve DefaultSyncer para interceptar OnFailedSync
// e inyectar nuestro backoff exponencial y emision de errores al canal.
type matrixSyncerWrapper struct {
*mautrix.DefaultSyncer
errCh chan<- error
innerCtx context.Context
backoffMs *int
initialMS int
maxMS int
lastSyncOK *time.Time
}
// OnFailedSync implementa mautrix.Syncer. Emite el error al canal y devuelve
// el proximo backoff. Para errores fatales (401, M_FORBIDDEN) devuelve el
// backoff maximo y emite al canal — el caller decide via Stop().
func (w *matrixSyncerWrapper) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
if w.innerCtx.Err() != nil {
return 0, fmt.Errorf("matrix_sync_service: context cancelado")
}
// Emitir error al canal de forma no-bloqueante
select {
case w.errCh <- fmt.Errorf("matrix_sync_service: %w", err):
default:
}
// Reset backoff si el ultimo sync exitoso fue reciente
if time.Since(*w.lastSyncOK) < 30*time.Second {
*w.backoffMs = w.initialMS
}
// Calcular duracion de espera
wait := time.Duration(*w.backoffMs) * time.Millisecond
// Backoff exponencial con techo
*w.backoffMs *= 2
if *w.backoffMs > w.maxMS {
*w.backoffMs = w.maxMS
}
// Para errores fatales, esperar el maximo pero no retornar error
// (dejamos al caller decidir via Stop)
if isFatalMatrixError(err) {
return time.Duration(w.maxMS) * time.Millisecond, nil
}
return wait, nil
}
// GetFilterJSON delega al DefaultSyncer.
func (w *matrixSyncerWrapper) GetFilterJSON(userID id.UserID) *mautrix.Filter {
return w.DefaultSyncer.GetFilterJSON(userID)
}
// ProcessResponse delega al DefaultSyncer. Actualiza lastSyncOK en exito.
func (w *matrixSyncerWrapper) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
err := w.DefaultSyncer.ProcessResponse(ctx, resp, since)
if err == nil {
now := time.Now()
*w.lastSyncOK = now
}
return err
}
// MatrixSyncService arranca el sync loop de mautrix contra Synapse en background.
// Registra handlers para los tipos de evento mas comunes y los emite via canal.
// Implementa reconnect con backoff exponencial para errores transitorios.
//
// Requiere un *mautrix.Client ya inicializado (ver matrix_client_init).
// Opcionalmente combinar con matrix_crypto_init para descifrar m.room.encrypted.
//
// La goroutine interna vive hasta que ctx sea cancelado o se llame Stop.
// Ambas acciones cierran los canales Events y Errors.
func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error) {
if cfg.Client == nil {
return nil, fmt.Errorf("matrix_sync_service: Client no puede ser nil")
}
// Aplicar defaults
initialBackoff := cfg.InitialBackoffMS
if initialBackoff <= 0 {
initialBackoff = 1000
}
maxBackoff := cfg.MaxBackoffMS
if maxBackoff <= 0 {
maxBackoff = 60000
}
bufSize := cfg.ChannelBuffer
if bufSize <= 0 {
bufSize = 256
}
// Context cancelable derivado del pasado
innerCtx, cancel := context.WithCancel(ctx)
// Channels
evtCh := make(chan MatrixSyncEvent, bufSize)
errCh := make(chan error, 8)
// Stop idempotente via sync.Once
var once sync.Once
stopFn := func() {
once.Do(func() {
cancel()
})
}
// Estado de backoff compartido con el wrapper
backoffMs := initialBackoff
lastSyncOK := time.Now()
// Configurar el Syncer: usar DefaultSyncer base (existente o nuevo)
var baseSyncer *mautrix.DefaultSyncer
if ds, ok := cfg.Client.Syncer.(*mautrix.DefaultSyncer); ok {
baseSyncer = ds
} else {
baseSyncer = mautrix.NewDefaultSyncer()
}
// Crear wrapper que intercepta OnFailedSync
wrapper := &matrixSyncerWrapper{
DefaultSyncer: baseSyncer,
errCh: errCh,
innerCtx: innerCtx,
backoffMs: &backoffMs,
initialMS: initialBackoff,
maxMS: maxBackoff,
lastSyncOK: &lastSyncOK,
}
cfg.Client.Syncer = wrapper
// Helper: emitir evento de forma no-bloqueante respetando ctx
emit := func(ev MatrixSyncEvent) {
select {
case evtCh <- ev:
case <-innerCtx.Done():
}
}
// Helper: extraer body de texto de Content.VeryRaw
extractBody := func(evt *event.Event) string {
raw := evt.Content.VeryRaw
if raw == nil {
return ""
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return ""
}
if b, ok := m["body"].(string); ok {
return b
}
return ""
}
// Registrar event handlers sobre el DefaultSyncer base
// m.room.message — mensajes de texto, imagen, archivo
baseSyncer.OnEventType(event.EventMessage, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "message",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Body: extractBody(evt),
Raw: evt,
})
})
// m.room.encrypted — mensajes cifrados (crypto helper los descifra si esta init)
baseSyncer.OnEventType(event.EventEncrypted, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "encrypted",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.redaction — redacciones de mensajes
baseSyncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "redaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.reaction — reacciones emoji
baseSyncer.OnEventType(event.EventReaction, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "reaction",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.room.member — cambios de pertenencia a sala
baseSyncer.OnEventType(event.StateMember, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "membership",
RoomID: evt.RoomID.String(),
EventID: evt.ID.String(),
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.typing — efimero: quien esta escribiendo en una sala
baseSyncer.OnEventType(event.EphemeralEventTyping, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "typing",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.receipt — read receipts
baseSyncer.OnEventType(event.EphemeralEventReceipt, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "read_receipt",
RoomID: evt.RoomID.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// m.presence — presencia de usuarios
baseSyncer.OnEventType(event.EphemeralEventPresence, func(_ context.Context, evt *event.Event) {
emit(MatrixSyncEvent{
Type: "presence",
Sender: evt.Sender.String(),
Ts: evt.Timestamp,
Raw: evt,
})
})
// Goroutine principal
// SyncWithContext ya es un loop bloqueante que incluye retry via OnFailedSync.
// Esta goroutine solo reinicia si SyncWithContext retorna con error inesperado.
go func() {
defer func() {
cancel()
close(evtCh)
close(errCh)
}()
for {
select {
case <-innerCtx.Done():
return
default:
}
err := cfg.Client.SyncWithContext(innerCtx)
// ctx cancelado = salida limpia
if innerCtx.Err() != nil {
return
}
// SyncWithContext retorna nil si otro Sync() lo cancelo
if err == nil {
return
}
// Cualquier otro error: pequeno delay antes de reiniciar
select {
case <-innerCtx.Done():
return
case <-time.After(time.Duration(initialBackoff) * time.Millisecond):
}
}
}()
return &MatrixSyncServiceHandle{
Events: evtCh,
Errors: errCh,
Stop: stopFn,
}, nil
}
// isFatalMatrixError devuelve true si el error indica que no tiene sentido
// reintentar (token invalido, forbidden).
func isFatalMatrixError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "M_UNKNOWN_TOKEN") ||
strings.Contains(msg, "M_FORBIDDEN") ||
strings.Contains(msg, "401")
}
+79
View File
@@ -0,0 +1,79 @@
---
name: matrix_sync_service
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error)"
description: "Arranca el sync loop de mautrix contra Synapse en background con backoff exponencial, emite eventos Matrix normalizados via canal Go y expone funcion de stop idempotente."
tags: [matrix, mautrix, sync, longpoll, reconnect, goroutine, channels, infra, matrix-mas]
params:
- name: ctx
desc: "Context padre. Si se cancela, la goroutine sale limpiamente y cierra los channels."
- name: cfg.Client
desc: "*mautrix.Client ya inicializado con credenciales (HomeserverURL, AccessToken, UserID). Usar matrix_client_init para crearlo. Obligatorio."
- name: cfg.InitialBackoffMS
desc: "Milisegundos de espera inicial entre reintentos tras error de sync. Default: 1000 (1s)."
- name: cfg.MaxBackoffMS
desc: "Techo del backoff exponencial en ms. Default: 60000 (60s)."
- name: cfg.ChannelBuffer
desc: "Capacidad del buffer del canal Events. Si el consumer va lento y el buffer se llena, el sync se bloquea hasta que el consumer drene. Default: 256."
output: "*MatrixSyncServiceHandle con Events <-chan MatrixSyncEvent (canal de eventos normalizados), Errors <-chan error (errores transitorios no fatales), Stop func() (cancela y cierra todo, idempotente)."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
tested: true
tests:
- "RecibeMensajeYStop"
- "BackoffRecovery"
- "Error401NoExit"
- "StopIdempotente"
- "CtxCancelCierraChannels"
test_file_path: "functions/infra/matrix_sync_service_test.go"
file_path: "functions/infra/matrix_sync_service.go"
---
## Ejemplo
```go
ctx := context.Background()
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: client, // *mautrix.Client de matrix_client_init
})
if err != nil {
panic(err)
}
defer h.Stop()
// Consumir errores transitorios en goroutine separada
go func() {
for e := range h.Errors {
log.Println("matrix sync error:", e)
}
}()
// Loop de eventos (bloquea hasta que h.Stop() se llame o ctx sea cancelado)
for ev := range h.Events {
fmt.Printf("[%s] %s: %s\n", ev.Type, ev.Sender, ev.Body)
}
```
## Cuando usarla
Usar despues de `MatrixClientInit` (y opcionalmente `MatrixCryptoInit`) para recibir el stream de eventos de Matrix en tiempo real. Es el servicio long-running central de cualquier cliente Matrix: matrix_client_pc, admin_panel, bots, monitores. Un solo `MatrixSyncService` por client, durante toda la vida de la aplicacion.
## Gotchas
- **Solo UN Sync por client**: dos goroutines llamando `SyncWithContext` simultaneamente sobre el mismo client rompe el `since` token y produce duplicados o perdidas. Esta funcion garantiza una sola goroutine de sync si es llamada una sola vez. NO llamar `MatrixSyncService` dos veces sobre el mismo `*mautrix.Client`.
- **Crypto antes del Sync**: mensajes `m.room.encrypted` que llegan antes de inicializar `MatrixCryptoInit` quedan sin descifrar (emitidos con `Type:"encrypted"`, `Body:""`, `Raw:*event.Event`). Inicializar crypto siempre ANTES de llamar a esta funcion.
- **Buffer de channel**: si el consumer no drena `Events` con suficiente rapidez, el sync se bloquea en el punto de emision. Synapse puede acumular deltas. Mantener el consumer rapido o aumentar `ChannelBuffer`.
- **Errores fatales (401/M_UNKNOWN_TOKEN)**: no cierran el servicio automaticamente — se emiten a `Errors` y el servicio espera con backoff maximo. El caller decide llamar `Stop()` y re-autenticar.
- **Stop idempotente**: llamar `Stop()` multiples veces es seguro; no causa panic.
- **Build tag**: el paquete `infra` requiere `-tags goolm` para compilar tests sin libolm (dependencia C de la crypto de mautrix). Los tests usan `//go:build goolm`.
+313
View File
@@ -0,0 +1,313 @@
//go:build goolm
package infra
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// fakeSynapseServer crea un httptest.Server que simula Synapse para tests de sync.
// syncHandler recibe el numero de llamada /sync (1-indexed) y devuelve la respuesta.
// nil response significa bloquear hasta ctx cancelado.
func fakeSynapseServer(t *testing.T, syncFn func(call int, w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
var callCount int32
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/_matrix/client/v3/user/@alice:localhost/filter":
// mautrix necesita este endpoint para guardar el filtro; responder con un filter_id
_ = json.NewEncoder(w).Encode(map[string]interface{}{"filter_id": "f1"})
case r.URL.Path == "/_matrix/client/v3/sync" || r.URL.Path == "/_matrix/client/r0/sync":
n := int(atomic.AddInt32(&callCount, 1))
syncFn(n, w, r)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
// syncRespMessage construye una respuesta /sync con un m.room.message.
func syncRespMessage(nextBatch string) map[string]interface{} {
return map[string]interface{}{
"next_batch": nextBatch,
"rooms": map[string]interface{}{
"join": map[string]interface{}{
"!testroom:localhost": map[string]interface{}{
"timeline": map[string]interface{}{
"events": []interface{}{
map[string]interface{}{
"event_id": "$evt001:localhost",
"type": "m.room.message",
"sender": "@alice:localhost",
"origin_server_ts": int64(1700000000000),
"room_id": "!testroom:localhost",
"content": map[string]interface{}{
"msgtype": "m.text",
"body": "hola mundo",
},
},
},
"limited": false,
},
},
},
},
}
}
// newTestSyncClient crea un *mautrix.Client apuntando al servidor dado.
func newTestSyncClient(t *testing.T, serverURL string) *mautrix.Client {
t.Helper()
cli, err := mautrix.NewClient(serverURL, "@alice:localhost", "token-test")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
cli.UserID = id.UserID("@alice:localhost")
return cli
}
// TestMatrixSyncService_RecibeMensajeYStop arranca el servicio, recibe 1 evento y hace Stop limpio.
func TestMatrixSyncService_RecibeMensajeYStop(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_001"))
return
}
// Bloquear syncs subsiguientes hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_002"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Esperar el primer evento
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de recibir evento")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
if ev.Sender != "@alice:localhost" {
t.Errorf("sender esperado '@alice:localhost', got %q", ev.Sender)
}
if ev.RoomID != "!testroom:localhost" {
t.Errorf("roomID esperado '!testroom:localhost', got %q", ev.RoomID)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout esperando evento")
}
// Stop limpio
h.Stop()
// Verificar que Events cierra tras Stop
timeout := time.After(3 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-timeout:
t.Fatal("canal Events no cerro tras Stop")
}
}
}
// TestMatrixSyncService_BackoffRecovery verifica backoff con 2 errores 500 seguidos de exito.
func TestMatrixSyncService_BackoffRecovery(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n <= 2 {
// Primeras 2 llamadas: devolver 500
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN",
"error": "internal server error",
})
return
}
if n == 3 {
// Tercera llamada: respuesta correcta inmediata (no bloquear)
_ = json.NewEncoder(w).Encode(syncRespMessage("nb_recovery"))
return
}
// Cuarta en adelante: bloquear hasta cancelacion
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50, // backoff corto para tests
MaxBackoffMS: 200,
ChannelBuffer: 16,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
defer h.Stop()
// Tras los fallos, debe llegar el evento de recovery
select {
case ev, ok := <-h.Events:
if !ok {
t.Fatal("canal cerrado antes de evento de recovery")
}
if ev.Type != "message" {
t.Errorf("tipo esperado 'message', got %q", ev.Type)
}
if ev.Body != "hola mundo" {
t.Errorf("body esperado 'hola mundo', got %q", ev.Body)
}
case <-time.After(8 * time.Second):
t.Fatal("timeout esperando evento de recovery tras backoff")
}
}
// TestMatrixSyncService_Error401NoExit verifica que 401 emite error pero no cierra el servicio.
func TestMatrixSyncService_Error401NoExit(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
if n == 1 {
// Primera llamada: 401 M_UNKNOWN_TOKEN
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Invalid macaroon passed.",
})
return
}
// Bloquear: el servicio espera en backoff maximo
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_x"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
InitialBackoffMS: 50,
MaxBackoffMS: 200,
ChannelBuffer: 8,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Debe recibir al menos un error (fatal 401)
select {
case syncErr := <-h.Errors:
if syncErr == nil {
t.Error("error esperado no nil")
}
case <-time.After(4 * time.Second):
t.Fatal("timeout esperando error 401 en canal Errors")
}
// El canal Events NO debe estar cerrado — el servicio sigue activo
select {
case _, ok := <-h.Events:
if !ok {
t.Fatal("canal Events no debia cerrarse con error 401 (dejar al caller decidir via Stop)")
}
case <-time.After(300 * time.Millisecond):
// Correcto: canal sigue abierto
}
h.Stop()
// Tras Stop, Events debe cerrarse
select {
case _, ok := <-h.Events:
if !ok {
return // OK
}
case <-time.After(3 * time.Second):
t.Fatal("canal Events no cerro tras Stop despues de error 401")
}
}
// TestMatrixSyncService_StopIdempotente verifica que Stop() dos veces no causa panic.
func TestMatrixSyncService_StopIdempotente(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_1"})
})
defer srv.Close()
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(context.Background(), MatrixSyncServiceConfig{
Client: cli,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Llamar Stop dos veces — no debe panic
defer func() {
if r := recover(); r != nil {
t.Errorf("Stop() dos veces causó panic: %v", r)
}
}()
h.Stop()
h.Stop()
}
// TestMatrixSyncService_CtxCancelCierraChannels verifica que cancelar ctx cierra Events < 1s.
func TestMatrixSyncService_CtxCancelCierraChannels(t *testing.T) {
srv := fakeSynapseServer(t, func(n int, w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
_ = json.NewEncoder(w).Encode(map[string]interface{}{"next_batch": "nb_ctx"})
})
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cli := newTestSyncClient(t, srv.URL)
h, err := MatrixSyncService(ctx, MatrixSyncServiceConfig{
Client: cli,
ChannelBuffer: 4,
})
if err != nil {
t.Fatalf("MatrixSyncService: %v", err)
}
// Cancelar contexto padre
cancel()
// Events debe cerrarse en menos de 1 segundo
deadline := time.After(1 * time.Second)
for {
select {
case _, ok := <-h.Events:
if !ok {
return // canal cerrado correctamente
}
case <-deadline:
t.Fatal("canal Events no cerro en 1s tras cancelar ctx")
}
}
}
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS revoked_peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
revoked_at INTEGER NOT NULL,
revoked_by TEXT NOT NULL,
reason TEXT NOT NULL,
prev_hash TEXT NOT NULL DEFAULT '',
this_hash TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_revoked_peers_device_id ON revoked_peers (device_id);
CREATE INDEX IF NOT EXISTS idx_revoked_peers_revoked_at ON revoked_peers (revoked_at);
+14 -3
View File
@@ -57,14 +57,25 @@ func NordVPNContainerStart(opts NordVPNContainerOpts) (string, error) {
// Esperar a que el tunel este activo
for i := 0; i < 30; i++ {
time.Sleep(1 * time.Second)
logs, logErr := DockerContainerLogs(opts.Name, 20)
lines, logErr := DockerContainerLogs(DockerLogsOpts{
ContainerID: opts.Name,
Tail: 20,
Stdout: true,
Stderr: true,
})
if logErr != nil {
continue
}
if strings.Contains(logs, "Connected") || strings.Contains(logs, "connected") {
var logText strings.Builder
for _, l := range lines {
logText.WriteString(l.Line)
logText.WriteByte('\n')
}
logsStr := logText.String()
if strings.Contains(logsStr, "Connected") || strings.Contains(logsStr, "connected") {
return id, nil
}
if strings.Contains(logs, "error") || strings.Contains(logs, "failed") {
if strings.Contains(logsStr, "error") || strings.Contains(logsStr, "failed") {
return id, fmt.Errorf("nordvpn connection failed, check logs: docker logs %s", opts.Name)
}
}
+261
View File
@@ -0,0 +1,261 @@
package infra
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// ShellExecOpts configura la ejecucion de un comando shell con whitelist de binarios.
type ShellExecOpts struct {
// Cmd es el argv completo. Cmd[0] es el binario (absoluto o nombre en PATH).
Cmd []string
// BinariesAllowed es la whitelist de binarios permitidos.
// EMPTY = rechaza todo (defense in depth). Obligatorio.
BinariesAllowed []string
// Env son variables de entorno KEY=VAL adicionales.
// Si vacio, se usa un entorno minimo: PATH=/usr/bin:/bin, HOME, USER, LANG.
Env []string
// WorkingDir es el directorio de trabajo. Si vacio usa HOME del usuario actual.
WorkingDir string
// TimeoutSeconds es el timeout maximo. Default 30. Hard kill al cumplir.
TimeoutSeconds int
// StdinPayload es el contenido a pasar como stdin al proceso.
StdinPayload []byte
// MaxOutputBytes es el limite de stdout+stderr combinado (cada uno).
// Default 1 MB. Trunca la salida y activa Truncated=true.
MaxOutputBytes int
// User es el usuario con el que ejecutar el proceso (requiere uid=0).
// Vacio = usuario actual.
User string
}
// ShellExecResult contiene el resultado de la ejecucion shell.
type ShellExecResult struct {
ExitCode int // Codigo de salida del proceso.
Stdout string // Salida estandar capturada (puede estar truncada).
Stderr string // Salida de error capturada (puede estar truncada).
Duration int64 // Duracion real de ejecucion en milisegundos.
Truncated bool // true si stdout o stderr fue truncado por MaxOutputBytes.
TimedOut bool // true si el proceso fue matado por timeout.
}
const (
defaultTimeoutSeconds = 30
defaultMaxOutputBytes = 1 * 1024 * 1024 // 1 MB
sigkillWait = time.Second
)
// ShellExecWhitelist ejecuta un comando shell con whitelist obligatoria de binarios,
// sin shell expansion, timeout context-cancellable con SIGTERM+SIGKILL,
// stdout/stderr separados con truncate opcional.
//
// Validaciones previas al spawn (ninguna hace I/O):
// - Cmd vacio → error.
// - BinariesAllowed vacio → error (defense in depth; NUNCA pasar [] en prod).
// - Cmd[0] debe estar en la whitelist: entry absoluta (/usr/bin/ls) se compara
// con el path resolvido de Cmd[0] via exec.LookPath; entry bare name (ls)
// se compara con filepath.Base(resolvido). Basta con que una entry haga match.
// - User != "" con uid != 0 → error (se necesita root para cambiar usuario).
func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error) {
// --- Validacion de seguridad (sin I/O) ---
if len(opts.Cmd) == 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: Cmd must not be empty")
}
if len(opts.BinariesAllowed) == 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: no binaries whitelisted: refusing exec")
}
// Resolver el binario real (LookPath solo si no es path absoluto).
resolvedBin, err := exec.LookPath(opts.Cmd[0])
if err != nil {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q not found in PATH: %w", opts.Cmd[0], err)
}
baseName := filepath.Base(resolvedBin)
inWhitelist := false
for _, entry := range opts.BinariesAllowed {
if strings.HasPrefix(entry, "/") {
// Entry es path absoluto: comparar con el path resolvido.
if entry == resolvedBin {
inWhitelist = true
break
}
} else {
// Entry es bare name: comparar con el basename del resolvido.
if entry == baseName {
inWhitelist = true
break
}
}
}
if !inWhitelist {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: binary %q (resolved: %q) not in whitelist %v",
opts.Cmd[0], resolvedBin, opts.BinariesAllowed)
}
// --- Validacion de user switch ---
if opts.User != "" {
if os.Getuid() != 0 {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: need root to switch user to %q", opts.User)
}
}
// --- Defaults ---
timeout := opts.TimeoutSeconds
if timeout <= 0 {
timeout = defaultTimeoutSeconds
}
maxOut := opts.MaxOutputBytes
if maxOut <= 0 {
maxOut = defaultMaxOutputBytes
}
// Working dir
workDir := opts.WorkingDir
if workDir == "" {
if h := os.Getenv("HOME"); h != "" {
workDir = h
} else {
workDir = "/"
}
}
// Env
env := opts.Env
if len(env) == 0 {
lang := os.Getenv("LANG")
if lang == "" {
lang = "C.UTF-8"
}
home := os.Getenv("HOME")
if home == "" {
home = "/"
}
usr := os.Getenv("USER")
if usr == "" {
usr = "root"
}
env = []string{
"PATH=/usr/bin:/bin",
"HOME=" + home,
"USER=" + usr,
"LANG=" + lang,
}
}
// --- Contexto con timeout ---
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
// --- Construir comando ---
argv := append([]string{resolvedBin}, opts.Cmd[1:]...)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // whitelist validated above
cmd.Env = env
cmd.Dir = workDir
// SysProcAttr para user switching (solo si root y User != "").
if opts.User != "" {
cred, err := buildCredential(opts.User)
if err != nil {
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: resolving user %q: %w", opts.User, err)
}
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: cred}
} else {
// Asegurar que el proceso puede ser matado como grupo.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// Buffers de captura.
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
// Stdin opcional.
if len(opts.StdinPayload) > 0 {
cmd.Stdin = bytes.NewReader(opts.StdinPayload)
}
start := time.Now()
// --- Ejecucion ---
runErr := cmd.Run()
duration := time.Since(start).Milliseconds()
// Determinar timedOut y exitCode.
timedOut := false
exitCode := 0
if runErr != nil {
if ctx.Err() == context.DeadlineExceeded {
timedOut = true
// SIGTERM ya fue enviado por exec.CommandContext; esperar 1s y SIGKILL.
if cmd.Process != nil {
time.Sleep(sigkillWait)
_ = cmd.Process.Kill()
}
}
if exitErr, ok := runErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if !timedOut {
// Error de spawn u otro — no es de exit.
return ShellExecResult{}, fmt.Errorf("shell_exec_whitelist: running %q: %w", opts.Cmd[0], runErr)
}
}
// Truncar salida si supera el limite.
truncated := false
stdout := stdoutBuf.String()
stderr := stderrBuf.String()
if len(stdout) > maxOut {
stdout = stdout[:maxOut]
truncated = true
}
if len(stderr) > maxOut {
stderr = stderr[:maxOut]
truncated = true
}
return ShellExecResult{
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
Duration: duration,
Truncated: truncated,
TimedOut: timedOut,
}, nil
}
// buildCredential construye un syscall.Credential para el usuario dado.
// Acepta nombre de usuario ("www-data") o "uid:gid" ("1000:1000").
func buildCredential(userStr string) (*syscall.Credential, error) {
// Intentar formato "uid:gid".
if strings.Contains(userStr, ":") {
parts := strings.SplitN(userStr, ":", 2)
uid, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid uid %q: %w", parts[0], err)
}
gid, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid gid %q: %w", parts[1], err)
}
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
// Nombre de usuario.
u, err := user.Lookup(userStr)
if err != nil {
return nil, fmt.Errorf("user %q not found: %w", userStr, err)
}
uid, _ := strconv.ParseUint(u.Uid, 10, 32)
gid, _ := strconv.ParseUint(u.Gid, 10, 32)
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
+95
View File
@@ -0,0 +1,95 @@
---
name: shell_exec_whitelist
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func ShellExecWhitelist(opts ShellExecOpts) (ShellExecResult, error)"
description: "Ejecuta argv shell con whitelist obligatoria de binarios, SIN shell expansion, timeout context-cancellable con SIGTERM+SIGKILL, stdout/stderr separados con truncate opcional. Para device_agent y otros sandboxes que reciben requests externos."
tags: [shell, exec, security, sandbox, device-agent, infra, agents, docker]
uses_functions: []
uses_types: [shell_exec_result_go_infra, error_go_core]
returns: [shell_exec_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: [bytes, context, fmt, os, os/exec, os/user, path/filepath, strconv, strings, syscall, time]
tested: true
tests:
- "echo whitelisted returns stdout"
- "binary not in whitelist rejected without spawn"
- "timeout kills process and sets TimedOut"
- "empty whitelist returns error"
- "stdin payload passes to process"
- "output exceeding MaxOutputBytes is truncated"
- "absolute path in whitelist matches resolved binary"
test_file_path: "functions/infra/shell_exec_whitelist_test.go"
file_path: "functions/infra/shell_exec_whitelist.go"
params:
- name: opts.Cmd
desc: "argv completo. Cmd[0] es el binario (path absoluto o nombre en PATH). Obligatorio, no puede estar vacío."
- name: opts.BinariesAllowed
desc: "Whitelist de binarios permitidos. EMPTY = rechaza todo sin spawn (defense in depth). Entry con / se compara con path resolvido; entry bare name se compara con basename del resolvido."
- name: opts.Env
desc: "Variables de entorno KEY=VAL. Si vacío, se aplica entorno mínimo: PATH=/usr/bin:/bin, HOME, USER, LANG."
- name: opts.WorkingDir
desc: "Directorio de trabajo. Si vacío usa HOME del usuario actual."
- name: opts.TimeoutSeconds
desc: "Timeout máximo en segundos. Default 30. Al expirar: SIGTERM → espera 1s → SIGKILL."
- name: opts.StdinPayload
desc: "Bytes a pasar como stdin al proceso. Nil/vacío = sin stdin."
- name: opts.MaxOutputBytes
desc: "Límite de bytes por stream (stdout y stderr por separado). Default 1 MB. Activa Truncated=true si se supera."
- name: opts.User
desc: "Usuario para ejecutar el proceso (nombre o 'uid:gid'). Requiere uid=0. Vacío = usuario actual."
output: "ShellExecResult con ExitCode, Stdout, Stderr, Duration (ms), Truncated y TimedOut."
---
## Ejemplo
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"ls", "-la", "/tmp"},
BinariesAllowed: []string{"ls", "cat", "echo", "id"},
TimeoutSeconds: 10,
MaxOutputBytes: 64 * 1024,
})
if err != nil {
log.Fatalf("exec rejected: %v", err)
}
fmt.Printf("exit=%d duration=%dms truncated=%v timedOut=%v\n",
result.ExitCode, result.Duration, result.Truncated, result.TimedOut)
fmt.Println(result.Stdout)
```
Con stdin:
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"cat"},
BinariesAllowed: []string{"cat"},
StdinPayload: []byte("payload from device_agent"),
})
```
Con path absoluto en whitelist:
```go
result, err := infra.ShellExecWhitelist(infra.ShellExecOpts{
Cmd: []string{"/usr/bin/id"},
BinariesAllowed: []string{"/usr/bin/id"},
})
```
## Cuando usarla
Cuando recibes requests externos (Element Matrix, webhook, agente) que especifican un comando a ejecutar en el host, y necesitas garantizar que solo binarios pre-aprobados corren, sin posibilidad de shell injection. Reemplaza `exec.Command` directa en device_agent o cualquier sandbox que acepte comandos de fuentes no confiables.
## Gotchas
- **Empty whitelist rechaza por diseño**: `BinariesAllowed: []string{}` devuelve error inmediato. NUNCA construyas la whitelist dinámicamente desde input externo.
- **PATH default mínimo** (`/usr/bin:/bin`): si tu binario está en `/usr/local/bin` u otro directorio, añádelo explícitamente a `Env` o usa el path absoluto en `Cmd[0]` y en `BinariesAllowed`.
- **SIGTERM+1s+SIGKILL**: algunos procesos pueden ignorar SIGTERM. SIGKILL es forzoso pero puede dejar recursos abiertos (ficheros, sockets). Diseña el proceso objetivo para manejar SIGTERM limpiamente.
- **Truncate aplica POST-exec**: no es streaming. Si el proceso produce 10 GB de output, el buffer crece hasta ese tamaño en RAM antes de truncar. Para procesos con output gigante usa pipes propios o un wrapper de streaming.
- **User switch requiere uid=0**: en un entorno sin root (contenedor sin privilegios, proceso normal), pasar `User != ""` siempre devuelve error. Verificar con `os.Getuid() == 0` antes si el campo es opcional.
- **`Cmd[0]` es el nombre del binario en PATH** pero la whitelist puede tener paths absolutos o bare names. Precedencia: entry con `/` compara contra el path resolvido por `LookPath`; entry sin `/` compara contra `filepath.Base` del path resolvido. Ambas formas son válidas y pueden coexistir en la misma whitelist.
@@ -0,0 +1,136 @@
package infra
import (
"strings"
"testing"
)
func TestShellExecWhitelist(t *testing.T) {
t.Run("echo whitelisted returns stdout", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", "hola"},
BinariesAllowed: []string{"echo"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hola\n" {
t.Errorf("stdout: got %q, want %q", result.Stdout, "hola\n")
}
if result.TimedOut {
t.Error("should not be timed out")
}
})
t.Run("binary not in whitelist rejected without spawn", func(t *testing.T) {
_, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"evil"},
BinariesAllowed: []string{"echo"},
})
if err == nil {
t.Fatal("expected error for non-whitelisted binary, got nil")
}
// Debe fallar por whitelist, no por spawn (el binario "evil" ni siquiera existe).
if !strings.Contains(err.Error(), "not in whitelist") &&
!strings.Contains(err.Error(), "not found in PATH") {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("timeout kills process and sets TimedOut", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"sleep", "10"},
BinariesAllowed: []string{"sleep"},
TimeoutSeconds: 1,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.TimedOut {
t.Error("expected TimedOut=true")
}
if result.ExitCode == 0 {
t.Error("expected non-zero exit code on timeout")
}
})
t.Run("empty whitelist returns error", func(t *testing.T) {
_, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", "test"},
BinariesAllowed: []string{},
})
if err == nil {
t.Fatal("expected error for empty whitelist, got nil")
}
if !strings.Contains(err.Error(), "no binaries whitelisted") {
t.Errorf("unexpected error message: %v", err)
}
})
t.Run("stdin payload passes to process", func(t *testing.T) {
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"cat"},
BinariesAllowed: []string{"cat"},
StdinPayload: []byte("hello registry"),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
if result.Stdout != "hello registry" {
t.Errorf("stdout: got %q, want %q", result.Stdout, "hello registry")
}
})
t.Run("output exceeding MaxOutputBytes is truncated", func(t *testing.T) {
// Genera ~100 bytes, limite de 10 → debe truncar.
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{"echo", strings.Repeat("x", 100)},
BinariesAllowed: []string{"echo"},
MaxOutputBytes: 10,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Truncated {
t.Error("expected Truncated=true")
}
if len(result.Stdout) > 10 {
t.Errorf("stdout length %d exceeds MaxOutputBytes 10", len(result.Stdout))
}
})
t.Run("absolute path in whitelist matches resolved binary", func(t *testing.T) {
// Buscar el path absoluto de "true" para usarlo en la whitelist.
// /usr/bin/true o /bin/true según la distro.
candidates := []string{"/usr/bin/true", "/bin/true"}
truePath := ""
for _, c := range candidates {
if _, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{c},
BinariesAllowed: []string{c},
}); err == nil {
truePath = c
break
}
}
if truePath == "" {
t.Skip("could not find absolute path for 'true'; skipping absolute-path whitelist test")
}
result, err := ShellExecWhitelist(ShellExecOpts{
Cmd: []string{truePath},
BinariesAllowed: []string{truePath},
})
if err != nil {
t.Fatalf("unexpected error with absolute path whitelist: %v", err)
}
if result.ExitCode != 0 {
t.Errorf("exit code: got %d, want 0", result.ExitCode)
}
})
}
+323
View File
@@ -0,0 +1,323 @@
package infra
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// SynapseAdminClient wraps the Synapse Admin API (/_synapse/admin/...) for user and room management.
type SynapseAdminClient struct {
HomeserverURL string // e.g. https://matrix-af2f3d.organic-machine.com
AdminToken string // access_token of a user with admin:true in Synapse
HTTPClient *http.Client // optional; default 30s timeout
}
// NewSynapseAdminClient creates a client with sensible defaults.
func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient {
return &SynapseAdminClient{
HomeserverURL: homeserver,
AdminToken: adminToken,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// AdminUser represents a Synapse user as returned by the admin API.
type AdminUser struct {
UserID string `json:"name"`
DisplayName string `json:"displayname"`
AvatarURL string `json:"avatar_url"`
Admin bool `json:"admin"`
Deactivated bool `json:"deactivated"`
IsGuest bool `json:"is_guest"`
CreationTs int64 `json:"creation_ts"`
LastSeenTs int64 `json:"last_seen_ts"`
}
// ListUsersFilter controls pagination and filtering for ListUsers.
type ListUsersFilter struct {
From int // pagination offset
Limit int // default 100
SearchTerm string // filter by name / user_id
Deactivated *bool // nil = both, true/false to filter
Admins *bool // nil = both, true/false to filter
}
// ListUsersResult holds a page of users plus pagination metadata.
type ListUsersResult struct {
Users []AdminUser
TotalCount int
NextToken *int // nil if last page
}
// AdminRoom represents a Synapse room as returned by the admin API.
type AdminRoom struct {
RoomID string `json:"room_id"`
Name string `json:"name"`
CanonicalAlias string `json:"canonical_alias"`
JoinedMembers int `json:"joined_members"`
JoinedLocal int `json:"joined_local_members"`
Version string `json:"version"`
Encrypted bool `json:"encryption_enabled"`
Federatable bool `json:"federatable"`
Public bool `json:"public"`
}
// AdminDevice represents a device belonging to a Synapse user.
type AdminDevice struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
LastSeenIP string `json:"last_seen_ip"`
LastSeenTs int64 `json:"last_seen_ts"`
}
// synapseError is the error envelope returned by Synapse for 4xx/5xx responses.
type synapseError struct {
ErrCode string `json:"errcode"`
ErrMsg string `json:"error"`
}
// client returns the HTTPClient, falling back to a 30-second default.
func (c *SynapseAdminClient) client() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
return &http.Client{Timeout: 30 * time.Second}
}
// do executes an authenticated request and returns the raw response body.
// Returns an error for HTTP >= 400, including the Synapse errcode when present.
func (c *SynapseAdminClient) do(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: marshal request body: %w", err)
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.HomeserverURL+path, bodyReader)
if err != nil {
return nil, fmt.Errorf("synapse_admin: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.AdminToken)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client().Do(req)
if err != nil {
return nil, fmt.Errorf("synapse_admin: http %s %s: %w", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("synapse_admin: read response: %w", err)
}
if resp.StatusCode >= 500 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: synapse internal %d %s: %s", resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: synapse internal: %d", resp.StatusCode)
}
if resp.StatusCode >= 400 {
var se synapseError
if jsonErr := json.Unmarshal(data, &se); jsonErr == nil && se.ErrCode != "" {
return nil, fmt.Errorf("synapse_admin: %s %s → %d %s: %s", method, path, resp.StatusCode, se.ErrCode, se.ErrMsg)
}
return nil, fmt.Errorf("synapse_admin: %s %s → HTTP %d", method, path, resp.StatusCode)
}
return data, nil
}
// --- Users ---
// ListUsers returns a page of users matching the given filter.
// Use ListUsersResult.NextToken to paginate: set ListUsersFilter.From = *NextToken on the next call.
func (c *SynapseAdminClient) ListUsers(ctx context.Context, f ListUsersFilter) (*ListUsersResult, error) {
limit := f.Limit
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(f.From))
q.Set("limit", strconv.Itoa(limit))
if f.SearchTerm != "" {
q.Set("user_id", f.SearchTerm)
}
if f.Deactivated != nil {
q.Set("deactivated", strconv.FormatBool(*f.Deactivated))
}
if f.Admins != nil {
q.Set("admins", strconv.FormatBool(*f.Admins))
}
path := "/_synapse/admin/v2/users?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Users []AdminUser `json:"users"`
Total int `json:"total"`
NextToken *int `json:"next_token"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUsers decode: %w", err)
}
return &ListUsersResult{
Users: raw.Users,
TotalCount: raw.Total,
NextToken: raw.NextToken,
}, nil
}
// GetUser returns the admin view of a single user by their full Matrix ID (e.g. @user:server).
func (c *SynapseAdminClient) GetUser(ctx context.Context, userID string) (*AdminUser, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var u AdminUser
if err := json.Unmarshal(data, &u); err != nil {
return nil, fmt.Errorf("synapse_admin: GetUser decode: %w", err)
}
return &u, nil
}
// DeactivateUser deactivates a user account.
// If erase=true, Synapse purges all user data — IRREVERSIBLE.
func (c *SynapseAdminClient) DeactivateUser(ctx context.Context, userID string, erase bool) error {
path := "/_synapse/admin/v1/deactivate/" + url.PathEscape(userID)
_, err := c.do(ctx, http.MethodPost, path, map[string]bool{"erase": erase})
return err
}
// ResetPassword sets a new password for the given user.
// If logoutDevices=true, all existing sessions are invalidated.
func (c *SynapseAdminClient) ResetPassword(ctx context.Context, userID, newPassword string, logoutDevices bool) error {
path := "/_synapse/admin/v1/reset_password/" + url.PathEscape(userID)
body := map[string]interface{}{
"new_password": newPassword,
"logout_devices": logoutDevices,
}
_, err := c.do(ctx, http.MethodPost, path, body)
return err
}
// --- Rooms ---
// ListRooms returns a page of rooms.
// from and limit control pagination; searchTerm filters by room name/alias.
func (c *SynapseAdminClient) ListRooms(ctx context.Context, from, limit int, searchTerm string) (rooms []AdminRoom, total int, nextToken *int, err error) {
if limit <= 0 {
limit = 100
}
q := url.Values{}
q.Set("from", strconv.Itoa(from))
q.Set("limit", strconv.Itoa(limit))
q.Set("order_by", "name")
if searchTerm != "" {
q.Set("search_term", searchTerm)
}
path := "/_synapse/admin/v1/rooms?" + q.Encode()
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, 0, nil, err
}
var raw struct {
Rooms []AdminRoom `json:"rooms"`
TotalRooms int `json:"total_rooms"`
NextBatch *int `json:"next_batch"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, 0, nil, fmt.Errorf("synapse_admin: ListRooms decode: %w", err)
}
return raw.Rooms, raw.TotalRooms, raw.NextBatch, nil
}
// GetRoom returns the admin view of a single room by its room ID (e.g. !room:server).
func (c *SynapseAdminClient) GetRoom(ctx context.Context, roomID string) (*AdminRoom, error) {
path := "/_synapse/admin/v1/rooms/" + url.PathEscape(roomID)
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var r AdminRoom
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("synapse_admin: GetRoom decode: %w", err)
}
return &r, nil
}
// DeleteRoom schedules an async room deletion. Returns the delete_id for status polling.
// purge=true destroys all messages and state (IRREVERSIBLE).
// block=true prevents new users from joining after deletion.
func (c *SynapseAdminClient) DeleteRoom(ctx context.Context, roomID, reason string, purge, block bool) (deleteID string, err error) {
path := "/_synapse/admin/v2/rooms/" + url.PathEscape(roomID)
body := map[string]interface{}{
"new_room_user_id": nil,
"purge": purge,
"block": block,
"message": reason,
}
data, err := c.do(ctx, http.MethodDelete, path, body)
if err != nil {
return "", err
}
var raw struct {
DeleteID string `json:"delete_id"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return "", fmt.Errorf("synapse_admin: DeleteRoom decode: %w", err)
}
return raw.DeleteID, nil
}
// --- Devices ---
// ListUserDevices returns all devices registered for the given user.
func (c *SynapseAdminClient) ListUserDevices(ctx context.Context, userID string) ([]AdminDevice, error) {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices"
data, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw struct {
Devices []AdminDevice `json:"devices"`
Total int `json:"total"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("synapse_admin: ListUserDevices decode: %w", err)
}
return raw.Devices, nil
}
// DeleteUserDevice removes a specific device from a user's account.
func (c *SynapseAdminClient) DeleteUserDevice(ctx context.Context, userID, deviceID string) error {
path := "/_synapse/admin/v2/users/" + url.PathEscape(userID) + "/devices/" + url.PathEscape(deviceID)
_, err := c.do(ctx, http.MethodDelete, path, nil)
return err
}
+100
View File
@@ -0,0 +1,100 @@
---
name: synapse_admin_client
kind: function
lang: go
domain: infra
version: "0.1.0"
purity: impure
signature: "func NewSynapseAdminClient(homeserver, adminToken string) *SynapseAdminClient"
description: "REST client for the Synapse Admin API (/_synapse/admin/v1 and v2). Wraps user management (list/get/deactivate/reset-password), room management (list/get/delete with purge), and device management (list/delete) with Bearer auth and structured error wrapping."
tags: [matrix, synapse, admin, rest, client, infra, matrix-mas]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "time"
tested: true
tests:
- "ListUsers parses + counts"
- "GetUser inexistente -> error contiene M_NOT_FOUND"
- "DeactivateUser ok"
- "DeleteRoom devuelve delete_id"
- "ListUserDevices parses array"
- "HTTP 403 -> error con errcode M_FORBIDDEN"
test_file_path: "functions/infra/synapse_admin_client_test.go"
file_path: "functions/infra/synapse_admin_client.go"
params:
- name: homeserver
desc: "Base URL of the Synapse homeserver, e.g. https://matrix-af2f3d.organic-machine.com (no trailing slash)"
- name: adminToken
desc: "Access token of a Synapse user with admin:true. NOT a MAS/OIDC token — must be a legacy Synapse session token"
output: "*SynapseAdminClient ready to call ListUsers, DeactivateUser, ListRooms, DeleteRoom, ListUserDevices, etc."
---
## Ejemplo
```go
ctx := context.Background()
c := NewSynapseAdminClient(
"https://matrix-af2f3d.organic-machine.com",
"syt_admin_token_xxx",
)
// List first 100 users
res, err := c.ListUsers(ctx, ListUsersFilter{Limit: 100})
if err != nil {
log.Fatal(err)
}
for _, u := range res.Users {
fmt.Printf("%s admin=%v deactivated=%v\n", u.UserID, u.Admin, u.Deactivated)
}
// Paginate with NextToken
if res.NextToken != nil {
res2, _ := c.ListUsers(ctx, ListUsersFilter{From: *res.NextToken, Limit: 100})
_ = res2
}
// Deactivate + erase a user
err = c.DeactivateUser(ctx, "@badactor:server", true)
// Delete a room with purge
deleteID, err := c.DeleteRoom(ctx, "!spamroom:server", "spam cleanup", true, true)
fmt.Println("delete_id:", deleteID) // poll /_synapse/admin/v2/rooms/delete_status/{deleteID}
// List devices for a user
devices, err := c.ListUserDevices(ctx, "@alice:server")
for _, d := range devices {
fmt.Printf(" device %s last seen %s\n", d.DeviceID, d.LastSeenIP)
}
```
## Cuando usarla
Usar en `matrix_admin_panel` (issue 0163) para construir el panel de administración del homeserver: listar usuarios, desactivar cuentas, inspeccionar rooms, purgar rooms spam, ver y eliminar dispositivos. También válida para scripts de operación del homeserver (bulk deactivation, room cleanup) que necesiten la Admin API sin pasar por el cliente Matrix regular.
## Gotchas
- **Admin Bearer NO es OIDC token**: Synapse Admin API NO acepta tokens MAS/OIDC regulares — requiere `access_token` de un usuario con `admin: true` en la tabla `users` de Synapse. Obtenerlo via una sesión creada con password legacy (antes de MSC3861) o via `mas-cli manage create-session --admin`. Con MAS activo, el flow de obtención de admin tokens cambia — ver documentación de MAS.
- **DeleteRoom es async**: devuelve `delete_id` inmediatamente. El estado real se consulta via `GET /_synapse/admin/v2/rooms/delete_status/{deleteID}` — ese endpoint NO está implementado en v0.1.0. Suficiente para lanzar la operación; la comprobación de finalización es TODO.
- **Rate limiting**: la Admin API aplica rate limits. >10 calls/s puede recibir 429. No hay retry-with-backoff en v0.1.0 — implementar en el consumidor si se hacen operaciones bulk.
- **Pagination**: iterar hasta que `NextToken == nil`. El campo `next_token` puede estar ausente en la última página — el cliente lo mapea a `nil` correctamente.
- **DeactivateUser con erase=true**: borra perfil, inhabilita el MXID permanentemente y bloquea su reúso. Operación irreversible en Synapse.
- **userID format**: usar MXID completo `@user:server`. La función aplica `url.PathEscape` automáticamente — no hace falta pre-encodear.
- **HTTPClient custom**: para timeouts distintos al default de 30s, pasar un `*http.Client` al campo `HTTPClient` del struct directamente (no hay opción en el constructor).
## Notas
Sólo usa stdlib (net/http, encoding/json, net/url, context). Sin dependencias externas.
Endpoints base siempre `/_synapse/admin` (no `/_matrix/client`).
@@ -0,0 +1,277 @@
package infra
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newSynapseTestServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// GET /_synapse/admin/v2/users (list)
// Note: exact path match (no trailing slash) catches the list endpoint only.
mux.HandleFunc("/_synapse/admin/v2/users", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
if r.Header.Get("Authorization") == "Bearer bad" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"errcode":"M_FORBIDDEN","error":"not admin"}`))
return
}
w.Header().Set("Content-Type", "application/json")
nextToken := 2
json.NewEncoder(w).Encode(map[string]interface{}{
"users": []map[string]interface{}{
{"name": "@alice:server", "admin": true, "deactivated": false, "creation_ts": 1000},
{"name": "@bob:server", "admin": false, "deactivated": false, "creation_ts": 2000},
},
"total": 2,
"next_token": nextToken,
})
})
// GET /_synapse/admin/v2/users/{userID} (single user + devices)
mux.HandleFunc("/_synapse/admin/v2/users/", func(w http.ResponseWriter, r *http.Request) {
suffix := strings.TrimPrefix(r.URL.Path, "/_synapse/admin/v2/users/")
// devices sub-path
if strings.HasSuffix(suffix, "/devices") {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"devices": []map[string]interface{}{
{"device_id": "AABBCC", "display_name": "Alice's phone", "last_seen_ip": "1.2.3.4", "last_seen_ts": 9999},
{"device_id": "DDEEFF", "display_name": "Alice's laptop", "last_seen_ip": "5.6.7.8", "last_seen_ts": 8888},
},
"total": 2,
})
return
}
// single device delete sub-path: /{userID}/devices/{deviceID}
if strings.Contains(suffix, "/devices/") {
if r.Method != http.MethodDelete {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
return
}
// single user GET
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
// 404 for missing user
if strings.Contains(suffix, "missing") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"errcode":"M_NOT_FOUND","error":"User not found"}`))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminUser{
UserID: "@alice:server",
DisplayName: "Alice",
Admin: true,
})
})
// POST /_synapse/admin/v1/deactivate/{userID}
mux.HandleFunc("/_synapse/admin/v1/deactivate/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"errcode":"M_BAD_JSON","error":"bad json"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"})
})
// GET /_synapse/admin/v1/rooms (list)
mux.HandleFunc("/_synapse/admin/v1/rooms", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"rooms": []map[string]interface{}{
{"room_id": "!abc:server", "name": "general", "joined_members": 5},
{"room_id": "!xyz:server", "name": "off-topic", "joined_members": 3},
},
"total_rooms": 2,
})
})
// GET /_synapse/admin/v1/rooms/{roomID}
mux.HandleFunc("/_synapse/admin/v1/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminRoom{RoomID: "!abc:server", Name: "general", JoinedMembers: 5})
})
// DELETE /_synapse/admin/v2/rooms/{roomID} (async delete)
mux.HandleFunc("/_synapse/admin/v2/rooms/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"errcode":"M_UNKNOWN","error":"bad method"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_001"})
})
return httptest.NewServer(mux)
}
func TestSynapseAdminClient(t *testing.T) {
srv := newSynapseTestServer(t)
defer srv.Close()
cl := NewSynapseAdminClient(srv.URL, "mxat_test_token")
ctx := context.Background()
t.Run("ListUsers parses + counts", func(t *testing.T) {
res, err := cl.ListUsers(ctx, ListUsersFilter{From: 0, Limit: 50})
if err != nil {
t.Fatalf("ListUsers: %v", err)
}
if res.TotalCount != 2 {
t.Errorf("TotalCount: got %d, want 2", res.TotalCount)
}
if len(res.Users) != 2 {
t.Fatalf("len(Users): got %d, want 2", len(res.Users))
}
if res.Users[0].UserID != "@alice:server" {
t.Errorf("Users[0].UserID: got %q, want @alice:server", res.Users[0].UserID)
}
if !res.Users[0].Admin {
t.Error("Users[0].Admin should be true")
}
if res.NextToken == nil {
t.Error("NextToken should be non-nil (test server returns next_token=2)")
} else if *res.NextToken != 2 {
t.Errorf("NextToken: got %d, want 2", *res.NextToken)
}
})
t.Run("GetUser inexistente -> error contiene M_NOT_FOUND", func(t *testing.T) {
_, err := cl.GetUser(ctx, "@missing:server")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "M_NOT_FOUND") {
t.Errorf("error should contain M_NOT_FOUND, got: %v", err)
}
})
t.Run("DeactivateUser ok", func(t *testing.T) {
// Verify via a targeted server that erase=true reaches the body.
var gotErase bool
deactivateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
if v, ok := req["erase"].(bool); ok {
gotErase = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id_server_unbind_result": "success"})
}))
defer deactivateSrv.Close()
clDe := NewSynapseAdminClient(deactivateSrv.URL, "tok")
if err := clDe.DeactivateUser(ctx, "@user:server", true); err != nil {
t.Fatalf("DeactivateUser: %v", err)
}
if !gotErase {
t.Error("erase=true not forwarded in request body")
}
})
t.Run("DeleteRoom devuelve delete_id", func(t *testing.T) {
var gotPurge, gotBlock bool
deleteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{}`, 405)
return
}
body, _ := io.ReadAll(r.Body)
var req map[string]interface{}
json.Unmarshal(body, &req)
if v, ok := req["purge"].(bool); ok {
gotPurge = v
}
if v, ok := req["block"].(bool); ok {
gotBlock = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"delete_id": "del_007"})
}))
defer deleteSrv.Close()
clDel := NewSynapseAdminClient(deleteSrv.URL, "tok")
deleteID, err := clDel.DeleteRoom(ctx, "!room:server", "cleanup", true, true)
if err != nil {
t.Fatalf("DeleteRoom: %v", err)
}
if deleteID != "del_007" {
t.Errorf("deleteID: got %q, want del_007", deleteID)
}
if !gotPurge {
t.Error("purge=true not forwarded in request body")
}
if !gotBlock {
t.Error("block=true not forwarded in request body")
}
})
t.Run("ListUserDevices parses array", func(t *testing.T) {
devices, err := cl.ListUserDevices(ctx, "@alice:server")
if err != nil {
t.Fatalf("ListUserDevices: %v", err)
}
if len(devices) != 2 {
t.Fatalf("len(devices): got %d, want 2", len(devices))
}
if devices[0].DeviceID != "AABBCC" {
t.Errorf("devices[0].DeviceID: got %q, want AABBCC", devices[0].DeviceID)
}
if devices[0].LastSeenIP != "1.2.3.4" {
t.Errorf("devices[0].LastSeenIP: got %q, want 1.2.3.4", devices[0].LastSeenIP)
}
})
t.Run("HTTP 403 -> error con errcode M_FORBIDDEN", func(t *testing.T) {
badCl := NewSynapseAdminClient(srv.URL, "bad")
_, err := badCl.ListUsers(ctx, ListUsersFilter{})
if err == nil {
t.Fatal("expected error for 403, got nil")
}
if !strings.Contains(err.Error(), "M_FORBIDDEN") {
t.Errorf("error should contain M_FORBIDDEN, got: %v", err)
}
})
}
+134
View File
@@ -0,0 +1,134 @@
package infra
import (
"encoding/base64"
"fmt"
"net"
"regexp"
"strings"
qrcode "github.com/skip2/go-qrcode"
)
// wgEndpointRe matches "host:port" where host is a hostname or IP and port is 165535.
var wgEndpointRe = regexp.MustCompile(`^[a-zA-Z0-9._\-]+:\d{1,5}$`)
// WGClientConfigGen generates the wg0.conf content for a WireGuard peer (client)
// and a unicode-block QR string suitable for mobile enrollment via Element or a terminal.
//
// Pure: no I/O, fully deterministic given the inputs. Returns error on invalid inputs.
func WGClientConfigGen(in WGClientConfigInput) (WGClientConfig, error) {
if err := validateWGClientInput(in); err != nil {
return WGClientConfig{}, err
}
ka := in.PersistentKA
if ka == 0 {
ka = 25
}
var b strings.Builder
// [Interface] section
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", in.DevicePrivateKey)
fmt.Fprintf(&b, "Address = %s\n", in.DeviceAddress)
if in.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", in.DNS)
}
b.WriteString("\n")
// [Peer] section (hub)
b.WriteString("[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", in.HubPublicKey)
if in.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", in.PresharedKey)
}
fmt.Fprintf(&b, "Endpoint = %s\n", in.HubEndpoint)
fmt.Fprintf(&b, "AllowedIPs = %s\n", in.HubAllowedIPs)
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka)
ini := b.String()
qr, err := qrcode.New(ini, qrcode.Medium)
if err != nil {
return WGClientConfig{}, fmt.Errorf("wg_client_config: qr encode: %w", err)
}
return WGClientConfig{
INI: ini,
QR: qr.ToString(false),
Filename: "wg0.conf",
}, nil
}
// validateWGClientInput checks all required fields for correctness.
func validateWGClientInput(in WGClientConfigInput) error {
if err := validateWGBase64Key("DevicePrivateKey", in.DevicePrivateKey); err != nil {
return err
}
if err := validateWGBase64Key("HubPublicKey", in.HubPublicKey); err != nil {
return err
}
if in.PresharedKey != "" {
if err := validateWGBase64Key("PresharedKey", in.PresharedKey); err != nil {
return err
}
}
// Validate DeviceAddress (CIDR)
if _, _, err := net.ParseCIDR(in.DeviceAddress); err != nil {
return fmt.Errorf("wg_client_config: DeviceAddress %q is not a valid CIDR: %w", in.DeviceAddress, err)
}
// Validate HubAllowedIPs (comma-separated CIDRs)
for _, cidr := range strings.Split(in.HubAllowedIPs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return fmt.Errorf("wg_client_config: HubAllowedIPs entry %q is not a valid CIDR: %w", cidr, err)
}
}
// Validate HubEndpoint
if !wgEndpointRe.MatchString(in.HubEndpoint) {
return fmt.Errorf("wg_client_config: HubEndpoint %q must be host:port", in.HubEndpoint)
}
parts := strings.SplitN(in.HubEndpoint, ":", 2)
port := 0
if _, err := fmt.Sscanf(parts[1], "%d", &port); err != nil || port < 1 || port > 65535 {
return fmt.Errorf("wg_client_config: HubEndpoint port %q out of range 1-65535", parts[1])
}
if in.DevicePrivateKey == "" {
return fmt.Errorf("wg_client_config: DevicePrivateKey is required")
}
if in.HubPublicKey == "" {
return fmt.Errorf("wg_client_config: HubPublicKey is required")
}
if in.HubEndpoint == "" {
return fmt.Errorf("wg_client_config: HubEndpoint is required")
}
if in.HubAllowedIPs == "" {
return fmt.Errorf("wg_client_config: HubAllowedIPs is required")
}
return nil
}
// validateWGBase64Key checks that a WireGuard key is a valid 32-byte base64-encoded string (44 chars).
func validateWGBase64Key(field, key string) error {
if len(key) != 44 {
return fmt.Errorf("wg_client_config: %s must be 44 base64 chars, got %d", field, len(key))
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return fmt.Errorf("wg_client_config: %s is not valid base64: %w", field, err)
}
if len(decoded) != 32 {
return fmt.Errorf("wg_client_config: %s must decode to 32 bytes, got %d", field, len(decoded))
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package infra
// WGClientConfigInput holds all parameters needed to generate a WireGuard
// client-side wg0.conf and its QR representation.
type WGClientConfigInput struct {
DevicePrivateKey string // base64 Curve25519 private key of the peer device
DeviceAddress string // CIDR assigned to the peer, e.g. "10.42.0.10/32"
HubPublicKey string // base64 Curve25519 public key of the hub server
HubEndpoint string // "host:port" of the hub WireGuard listener, e.g. "organic-machine.com:51820"
HubAllowedIPs string // CIDRs routed through hub: "10.42.0.0/24" (mesh) or "0.0.0.0/0" (full tunnel)
PresharedKey string // base64 preshared key, optional — must match hub-side config; empty to omit
PersistentKA int // PersistentKeepalive seconds; 0 → defaults to 25 (recommended for NAT/4G)
DNS string // optional DNS server, e.g. "10.42.0.1"; empty to omit
}
// WGClientConfig is the output of WGClientConfigGen: the .conf file contents,
// a unicode-block QR string for mobile enrollment, and the suggested filename.
type WGClientConfig struct {
INI string // full contents of wg0.conf ready to write to /etc/wireguard/wg0.conf
QR string // unicode-block QR art (skip2/go-qrcode ToString) for terminal display or Element message
Filename string // suggested filename, always "wg0.conf"
}
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// WGKeys holds a WireGuard Curve25519 key pair and an optional preshared key,
// all encoded as base64 strings.
type WGKeys struct {
PrivateKey string // base64 Curve25519 private key
PublicKey string // base64 Curve25519 public key
PresharedKey string // base64 preshared key, empty if not requested
}
// WGKeygen generates a WireGuard key pair using `wg genkey` / `wg pubkey`.
// If withPSK is true it also runs `wg genpsk` to produce a preshared key.
// Requires the `wg` binary in PATH (install wireguard-tools).
// NEVER log PrivateKey or PresharedKey in plain text.
func WGKeygen(withPSK bool) (WGKeys, error) {
// Generate private key
privOut, err := runWG(nil, "genkey")
if err != nil {
return WGKeys{}, fmt.Errorf("wg genkey: %w", err)
}
privateKey := strings.TrimSpace(privOut)
// Derive public key from private key
pubOut, err := runWG(strings.NewReader(privateKey), "pubkey")
if err != nil {
return WGKeys{}, fmt.Errorf("wg pubkey: %w", err)
}
publicKey := strings.TrimSpace(pubOut)
keys := WGKeys{
PrivateKey: privateKey,
PublicKey: publicKey,
}
if withPSK {
pskOut, err := runWG(nil, "genpsk")
if err != nil {
return WGKeys{}, fmt.Errorf("wg genpsk: %w", err)
}
keys.PresharedKey = strings.TrimSpace(pskOut)
}
return keys, nil
}
// runWG executes `wg <subcommand>`, optionally piping stdin, and returns stdout.
// stderr is captured and included in the error when exit code != 0.
func runWG(stdin interface{ Read([]byte) (int, error) }, subcommand string) (string, error) {
cmd := exec.Command("wg", subcommand)
if stdin != nil {
cmd.Stdin = stdin
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.String(), nil
}
+56
View File
@@ -0,0 +1,56 @@
---
name: wg_keygen
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGKeygen(withPSK bool) (WGKeys, error)"
description: "Genera par de claves WireGuard (Curve25519 privada+publica) en base64 via `wg genkey`/`wg pubkey`. Opcional preshared key via `wg genpsk` para defensa adicional contra futuro quantum-break."
tags: [wireguard, crypto, infra, mesh]
params:
- name: withPSK
desc: "true para incluir preshared key adicional (recomendado en mesh production)"
output: "WGKeys{PrivateKey, PublicKey, PresharedKey} todas base64. PresharedKey vacia si withPSK=false."
uses_functions: []
uses_types: [error_go_core]
returns: [WGKeys_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: ["bytes", "fmt", "os/exec", "strings"]
tested: true
tests: ["genera par de claves sin PSK", "genera par de claves con PSK"]
test_file_path: "functions/infra/wg_keygen_test.go"
file_path: "functions/infra/wg_keygen.go"
---
## Ejemplo
```go
// Sin preshared key (peer-to-peer simple)
keys, err := WGKeygen(false)
if err != nil {
log.Fatal(err)
}
fmt.Println("PrivateKey:", keys.PrivateKey) // NUNCA loguear en prod
fmt.Println("PublicKey:", keys.PublicKey)
// Con preshared key (recomendado en mesh production)
keys, err = WGKeygen(true)
if err != nil {
log.Fatal(err)
}
// keys.PresharedKey listo para [Peer] PresharedKey = ...
```
## Cuando usarla
Antes de configurar un nuevo peer WireGuard: genera las claves localmente y usa `PublicKey` para el bloque `[Peer]` del otro extremo. Usar `withPSK=true` en mesh de produccion para proteccion adicional frente a ataques cuanticos futuros.
## Gotchas
- Requiere `wg` binario en PATH (`wg_install` lo instala). Sin el binario retorna error inmediatamente.
- Las claves base64 tienen exactamente 44 caracteres con padding (`=`).
- NUNCA loguear `PrivateKey` ni `PresharedKey` en claro. Guardar en secreto (vault, env var cifrada).
- `PresharedKey` no es lo mismo que `PrivateKey` — es un secreto simetrico compartido entre dos peers, ambos deben configurarlo bajo `[Peer] PresharedKey`.
- Los keys generados son efimeros: si se pierde el `PrivateKey` no hay recuperacion posible.
+67
View File
@@ -0,0 +1,67 @@
package infra
import (
"encoding/base64"
"os/exec"
"strings"
"testing"
)
func TestWGKeygen(t *testing.T) {
// Skip if wg binary is not present in PATH
if _, err := exec.LookPath("wg"); err != nil {
t.Skip("wg binary not found in PATH, skipping WireGuard keygen tests")
}
t.Run("genera par de claves sin PSK", func(t *testing.T) {
keys, err := WGKeygen(false)
if err != nil {
t.Fatalf("WGKeygen(false) error: %v", err)
}
if keys.PrivateKey == "" {
t.Error("PrivateKey vacia")
}
if keys.PublicKey == "" {
t.Error("PublicKey vacia")
}
if keys.PresharedKey != "" {
t.Errorf("PresharedKey debe estar vacia sin PSK, got %q", keys.PresharedKey)
}
// WireGuard keys are 32-byte Curve25519, base64-encoded → 44 chars with padding
if len(strings.TrimSpace(keys.PrivateKey)) != 44 {
t.Errorf("PrivateKey len esperado 44, got %d", len(keys.PrivateKey))
}
if len(strings.TrimSpace(keys.PublicKey)) != 44 {
t.Errorf("PublicKey len esperado 44, got %d", len(keys.PublicKey))
}
// Validate they are valid base64
if _, err := base64.StdEncoding.DecodeString(keys.PrivateKey); err != nil {
t.Errorf("PrivateKey no es base64 valido: %v", err)
}
if _, err := base64.StdEncoding.DecodeString(keys.PublicKey); err != nil {
t.Errorf("PublicKey no es base64 valido: %v", err)
}
})
t.Run("genera par de claves con PSK", func(t *testing.T) {
keys, err := WGKeygen(true)
if err != nil {
t.Fatalf("WGKeygen(true) error: %v", err)
}
if keys.PrivateKey == "" {
t.Error("PrivateKey vacia")
}
if keys.PublicKey == "" {
t.Error("PublicKey vacia")
}
if keys.PresharedKey == "" {
t.Error("PresharedKey debe estar presente con withPSK=true")
}
if len(strings.TrimSpace(keys.PresharedKey)) != 44 {
t.Errorf("PresharedKey len esperado 44, got %d", len(keys.PresharedKey))
}
if _, err := base64.StdEncoding.DecodeString(keys.PresharedKey); err != nil {
t.Errorf("PresharedKey no es base64 valido: %v", err)
}
})
}
+408
View File
@@ -0,0 +1,408 @@
package infra
import (
"bufio"
"fmt"
"net"
"os"
"strings"
)
// WGPeerAdd añade un peer WireGuard al wg0.conf del hub y aplica la config
// en caliente con `wg syncconf` sin reiniciar la interface.
//
// Idempotente:
// - Si PublicKey ya está presente con la misma config → "already-present".
// - Si DeviceID existe con otra PublicKey → reemplaza el bloque → "reconfigured".
// - Si AllowedIPs está vacío, asigna la primera IP libre de subnetCIDR (excluyendo .1).
//
// Escribe atómicamente (tmpfile + rename) y hace chmod 600 sobre configPath.
// Si syncconf falla, restaura el backup y devuelve error.
//
// Para tests CI sin WireGuard real, establecer WG_SKIP_SYNCCONF=1.
func WGPeerAdd(spec WGPeerSpec, configPath string, subnetCIDR string) (WGPeerResult, error) {
// --- leer config actual ---
existing, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: read config: %w", err)
}
content := string(existing)
// --- parsear peers existentes ---
peers, err := wgParsePeers(content)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: parse peers: %w", err)
}
// --- idempotencia: buscar peer existente ANTES de asignar IP ---
status := "added"
existingBlock, foundByKey := peers[spec.PublicKey]
_, foundByDevice := wgFindByDeviceID(peers, spec.DeviceID)
// --- determinar AllowedIPs ---
allowedIPs := spec.AllowedIPs
if allowedIPs == "" {
if foundByKey && existingBlock.allowedIPs != "" {
// reusar la IP del peer existente para idempotencia
allowedIPs = existingBlock.allowedIPs
} else {
ip, err := wgNextFreeIP(subnetCIDR, peers)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: assign ip: %w", err)
}
allowedIPs = ip + "/32"
}
}
// extraer IP pura para el resultado
assignedIP := allowedIPs
if idx := strings.Index(allowedIPs, "/"); idx >= 0 {
assignedIP = allowedIPs[:idx]
}
if foundByKey {
// misma PublicKey ya está — verificar si la config coincide
if existingBlock.allowedIPs == allowedIPs &&
existingBlock.presharedKey == spec.PresharedKey {
return WGPeerResult{
DeviceID: spec.DeviceID,
AssignedIP: assignedIP,
ConfigPath: configPath,
Status: "already-present",
}, nil
}
status = "reconfigured"
} else if foundByDevice {
// DeviceID existe con otra PublicKey → reconfigured (replace)
status = "reconfigured"
}
// --- construir nueva config ---
newContent, err := wgRebuildConfig(content, spec, allowedIPs, status)
if err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rebuild config: %w", err)
}
// --- backup ---
backupPath := configPath + ".bak"
if len(existing) > 0 {
if err := os.WriteFile(backupPath, existing, 0600); err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: backup: %w", err)
}
}
// --- escritura atómica ---
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, []byte(newContent), 0600); err != nil {
return WGPeerResult{}, fmt.Errorf("wg_peer_add: write tmp: %w", err)
}
if err := os.Rename(tmpPath, configPath); err != nil {
_ = os.Remove(tmpPath)
return WGPeerResult{}, fmt.Errorf("wg_peer_add: rename: %w", err)
}
_ = os.Chmod(configPath, 0600)
// --- syncconf ---
iface := wgIfaceFromPath(configPath)
if iface != "" {
if err := wgSyncConfFn(iface, configPath); err != nil {
// restaurar backup
if len(existing) > 0 {
_ = os.WriteFile(configPath, existing, 0600)
}
return WGPeerResult{}, fmt.Errorf("wg_peer_add: syncconf: %w", err)
}
}
return WGPeerResult{
DeviceID: spec.DeviceID,
AssignedIP: assignedIP,
ConfigPath: configPath,
Status: status,
}, nil
}
// --- helpers internos ---
type wgPeerBlock struct {
deviceID string
publicKey string
presharedKey string
allowedIPs string
rawLines []string // líneas originales del bloque incluyendo comentario # DeviceID
}
// wgParsePeers extrae todos los bloques [Peer] indexados por PublicKey.
func wgParsePeers(content string) (map[string]*wgPeerBlock, error) {
peers := map[string]*wgPeerBlock{}
scanner := bufio.NewScanner(strings.NewReader(content))
var cur *wgPeerBlock
var pendingDeviceID string
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// comentario de tracking: # DeviceID: <id>
if strings.HasPrefix(trimmed, "# DeviceID:") {
pendingDeviceID = strings.TrimSpace(strings.TrimPrefix(trimmed, "# DeviceID:"))
if cur != nil {
cur.rawLines = append(cur.rawLines, line)
}
continue
}
if trimmed == "[Peer]" {
cur = &wgPeerBlock{deviceID: pendingDeviceID}
cur.rawLines = append(cur.rawLines, line)
pendingDeviceID = ""
continue
}
if cur != nil {
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
// fin del bloque
if cur.publicKey != "" {
peers[cur.publicKey] = cur
}
cur = nil
if strings.HasPrefix(trimmed, "[") {
// nueva sección, no es [Peer]
}
continue
}
cur.rawLines = append(cur.rawLines, line)
if k, v, ok := wgKV(trimmed); ok {
switch k {
case "PublicKey":
cur.publicKey = v
case "PresharedKey":
cur.presharedKey = v
case "AllowedIPs":
cur.allowedIPs = v
}
}
}
}
if cur != nil && cur.publicKey != "" {
peers[cur.publicKey] = cur
}
return peers, scanner.Err()
}
// wgFindByDeviceID busca un peer por DeviceID.
func wgFindByDeviceID(peers map[string]*wgPeerBlock, deviceID string) (*wgPeerBlock, bool) {
for _, p := range peers {
if p.deviceID == deviceID {
return p, true
}
}
return nil, false
}
// wgNextFreeIP encuentra la primera IP libre en subnetCIDR (excluyendo .1 del hub).
func wgNextFreeIP(subnetCIDR string, peers map[string]*wgPeerBlock) (string, error) {
ip, ipNet, err := net.ParseCIDR(subnetCIDR)
if err != nil {
return "", fmt.Errorf("invalid subnetCIDR %q: %w", subnetCIDR, err)
}
_ = ip
// recopilar IPs ya usadas
used := map[string]bool{}
for _, p := range peers {
cidr := p.allowedIPs
if idx := strings.Index(cidr, "/"); idx >= 0 {
used[cidr[:idx]] = true
}
}
// iterar desde .2 (hub es .1)
for candidate := wgIncrIP(ipNet.IP); ipNet.Contains(candidate); candidate = wgIncrIP(candidate) {
s := candidate.String()
// saltar la dirección de red (.0) y broadcast
if s == ipNet.IP.String() {
continue
}
// saltar hub (.1)
hubIP := wgIncrIP(ipNet.IP)
if s == hubIP.String() {
// esta es .1 (hub), saltar
continue
}
if !used[s] {
return s, nil
}
}
return "", fmt.Errorf("no free IPs in %s", subnetCIDR)
}
// wgIncrIP incrementa una IP en 1.
func wgIncrIP(ip net.IP) net.IP {
ip = ip.To4()
if ip == nil {
return nil
}
result := make(net.IP, 4)
copy(result, ip)
for i := 3; i >= 0; i-- {
result[i]++
if result[i] != 0 {
break
}
}
return result
}
// wgRebuildConfig reconstruye el contenido del config con el peer añadido/reemplazado.
func wgRebuildConfig(content string, spec WGPeerSpec, allowedIPs, status string) (string, error) {
// construir nuevo bloque
var newBlock strings.Builder
newBlock.WriteString(fmt.Sprintf("# DeviceID: %s\n", spec.DeviceID))
newBlock.WriteString("[Peer]\n")
newBlock.WriteString(fmt.Sprintf("PublicKey = %s\n", spec.PublicKey))
if spec.PresharedKey != "" {
newBlock.WriteString(fmt.Sprintf("PresharedKey = %s\n", spec.PresharedKey))
}
newBlock.WriteString(fmt.Sprintf("AllowedIPs = %s\n", allowedIPs))
if status == "added" {
// simplemente añadir al final
result := content
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
result += "\n"
}
result += "\n" + newBlock.String()
return result, nil
}
// reconfigured: reemplazar el bloque existente (buscar por DeviceID o PublicKey)
result, err := wgReplaceBlock(content, spec, newBlock.String())
if err != nil {
return "", err
}
return result, nil
}
// wgReplaceBlock reemplaza el bloque del peer identificado por DeviceID o PublicKey.
// Estrategia: parsear el config en segmentos (pre-bloque / bloque-target / post-bloque)
// y reconstruir sustituyendo solo el bloque target.
func wgReplaceBlock(content string, spec WGPeerSpec, newBlock string) (string, error) {
type segment struct {
isPeer bool
lines []string // incluye comentario # DeviceID si lo había
pk string
did string
}
var segments []segment
scanner := bufio.NewScanner(strings.NewReader(content))
var cur *segment
var pendingComment string
flush := func() {
if cur != nil {
segments = append(segments, *cur)
cur = nil
}
}
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# DeviceID:") {
pendingComment = line
continue
}
if trimmed == "[Peer]" {
flush()
seg := segment{isPeer: true}
if pendingComment != "" {
seg.lines = append(seg.lines, pendingComment)
seg.did = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(pendingComment), "# DeviceID:"))
pendingComment = ""
}
seg.lines = append(seg.lines, line)
cur = &seg
continue
}
if cur != nil && cur.isPeer {
if trimmed == "" || strings.HasPrefix(trimmed, "[") {
flush()
if pendingComment != "" {
segments = append(segments, segment{lines: []string{pendingComment}})
pendingComment = ""
}
if trimmed != "" {
cur = &segment{lines: []string{line}}
}
continue
}
cur.lines = append(cur.lines, line)
if k, v, ok := wgKV(trimmed); ok && k == "PublicKey" {
cur.pk = v
}
continue
}
// línea fuera de bloque peer
if pendingComment != "" {
if cur == nil {
cur = &segment{}
}
cur.lines = append(cur.lines, pendingComment)
pendingComment = ""
}
if cur == nil {
cur = &segment{}
}
cur.lines = append(cur.lines, line)
}
flush()
if err := scanner.Err(); err != nil {
return "", err
}
// reconstruir sustituyendo el target
var out strings.Builder
replaced := false
for _, seg := range segments {
if seg.isPeer && !replaced {
isTarget := (seg.pk == spec.PublicKey) ||
(spec.DeviceID != "" && seg.did == spec.DeviceID)
if isTarget {
out.WriteString("\n" + newBlock)
replaced = true
continue
}
}
for _, l := range seg.lines {
out.WriteString(l + "\n")
}
}
if !replaced {
result := out.String()
if !strings.HasSuffix(result, "\n") && len(result) > 0 {
result += "\n"
}
result += "\n" + newBlock
return result, nil
}
return out.String(), nil
}
// wgKV parsea "Key = Value" devolviendo (key, value, true) o ("", "", false).
func wgKV(line string) (string, string, bool) {
idx := strings.IndexByte(line, '=')
if idx < 0 {
return "", "", false
}
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:]), true
}
+57
View File
@@ -0,0 +1,57 @@
---
name: wg_peer_add
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerAdd(spec WGPeerSpec, configPath, subnetCIDR string) (WGPeerResult, error)"
description: "Hub-side: anade peer WireGuard al wg0.conf con IP asignada del pool, syncconf en caliente sin reiniciar interface. Idempotente por PublicKey + DeviceID. Mantiene comentario # DeviceID:<id> sobre cada bloque [Peer] para tracking inverso."
tags: [wireguard, hub, peer, mesh, infra]
params:
- name: spec
desc: "WGPeerSpec con DeviceID (identificador logico), PublicKey (base64), PresharedKey (base64, opcional), AllowedIPs (CIDR; vacio = autoasignar del pool)"
- name: configPath
desc: "Ruta absoluta al wg0.conf del hub, ej /etc/wireguard/wg0.conf"
- name: subnetCIDR
desc: "Subnet del pool de IPs WireGuard, ej '10.42.0.0/24'. La .1 se reserva para el hub y se excluye del pool."
output: "WGPeerResult con DeviceID, AssignedIP (pura sin CIDR), ConfigPath y Status ('added'|'already-present'|'reconfigured')"
uses_functions: []
uses_types: [wg_peer_spec_go_infra, wg_peer_result_go_infra]
returns: [wg_peer_result_go_infra]
returns_optional: false
error_type: "error_go_core"
imports: []
tested: true
tests:
- "peer nuevo con AllowedIPs vacio asigna 10.42.0.2"
- "agregar segundo peer asigna 10.42.0.3"
- "agregar mismo PublicKey otra vez retorna already-present"
- "agregar DeviceID existente con clave distinta retorna reconfigured"
test_file_path: "functions/infra/wg_peer_add_test.go"
file_path: "functions/infra/wg_peer_add.go"
---
## Ejemplo
```go
spec := infra.WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
res, err := infra.WGPeerAdd(spec, "/etc/wireguard/wg0.conf", "10.42.0.0/24")
// res.AssignedIP == "10.42.0.2" (primera IP libre del pool)
// res.Status == "added"
```
## Cuando usarla
Cuando un dispositivo nuevo (PC, contenedor, mobile) se une al mesh WireGuard del hub. Llamar tras generar las claves con `wg_keygen_go_infra`. Idempotente: si el DeviceID ya existe con la misma clave, devuelve `already-present` sin tocar el config.
## Gotchas
- **Race condition**: si dos llamadas concurrentes añaden peers simultáneamente, la segunda puede asignar la misma IP libre. Usar file lock (`flock`) sobre `configPath` para serializar en produccion.
- **WG_SKIP_SYNCCONF=1**: en entornos CI sin WireGuard instalado, establecer esta variable para saltarse el exec de `wg syncconf`. Los tests ya la activan en el `init()`.
- **syncconf falla → rollback automático**: si el `wg syncconf` devuelve error, se restaura el backup `.bak` y se devuelve error. El config queda intacto.
- **chmod 600**: la función hace `chmod 600` sobre `configPath` tras cada escritura. Asegúrate de que el proceso tiene permisos sobre el archivo.
- **Hub IP .1**: la función excluye `<red>.1` del pool de autoasignación. El hub debe tener esa IP. Si el hub usa otra IP, ajustar la lógica de `wgNextFreeIP`.
+160
View File
@@ -0,0 +1,160 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
const wgTestSubnet = "10.42.0.0/24"
// baseConfig es un [Interface] mínimo para simular wg0.conf vacío de peers.
const wgBaseConfig = `[Interface]
Address = 10.42.0.1/24
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
ListenPort = 51820
`
func wgTempConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "wg0.conf")
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
t.Fatalf("wgTempConfig: %v", err)
}
return p
}
func init() {
// asegura que syncconf no se ejecuta en tests
os.Setenv("WG_SKIP_SYNCCONF", "1")
}
func TestWGPeerAdd(t *testing.T) {
t.Run("peer nuevo con AllowedIPs vacio asigna 10.42.0.2", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
res, err := WGPeerAdd(spec, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.AssignedIP != "10.42.0.2" {
t.Errorf("AssignedIP = %q, want 10.42.0.2", res.AssignedIP)
}
if res.Status != "added" {
t.Errorf("Status = %q, want added", res.Status)
}
if res.DeviceID != "pc-aurgi" {
t.Errorf("DeviceID = %q, want pc-aurgi", res.DeviceID)
}
// verificar que el bloque está en el archivo
raw, _ := os.ReadFile(cfg)
content := string(raw)
if !strings.Contains(content, "# DeviceID: pc-aurgi") {
t.Errorf("config missing DeviceID comment, got:\n%s", content)
}
if !strings.Contains(content, "AllowedIPs = 10.42.0.2/32") {
t.Errorf("config missing AllowedIPs, got:\n%s", content)
}
})
t.Run("agregar segundo peer asigna 10.42.0.3", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec1 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec1, cfg, wgTestSubnet); err != nil {
t.Fatalf("add peer1: %v", err)
}
spec2 := WGPeerSpec{
DeviceID: "home-wsl",
PublicKey: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
}
res, err := WGPeerAdd(spec2, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("add peer2: %v", err)
}
if res.AssignedIP != "10.42.0.3" {
t.Errorf("AssignedIP = %q, want 10.42.0.3", res.AssignedIP)
}
if res.Status != "added" {
t.Errorf("Status = %q, want added", res.Status)
}
raw, _ := os.ReadFile(cfg)
content := string(raw)
if !strings.Contains(content, "# DeviceID: home-wsl") {
t.Errorf("config missing second DeviceID comment, got:\n%s", content)
}
})
t.Run("agregar mismo PublicKey otra vez retorna already-present", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec, cfg, wgTestSubnet); err != nil {
t.Fatalf("first add: %v", err)
}
res, err := WGPeerAdd(spec, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("second add: %v", err)
}
if res.Status != "already-present" {
t.Errorf("Status = %q, want already-present", res.Status)
}
if res.AssignedIP != "10.42.0.2" {
t.Errorf("AssignedIP = %q, want 10.42.0.2", res.AssignedIP)
}
})
t.Run("agregar DeviceID existente con clave distinta retorna reconfigured", func(t *testing.T) {
cfg := wgTempConfig(t, wgBaseConfig)
spec1 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
}
if _, err := WGPeerAdd(spec1, cfg, wgTestSubnet); err != nil {
t.Fatalf("first add: %v", err)
}
// misma DeviceID, PublicKey diferente
spec2 := WGPeerSpec{
DeviceID: "pc-aurgi",
PublicKey: "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=",
AllowedIPs: "10.42.0.2/32",
}
res, err := WGPeerAdd(spec2, cfg, wgTestSubnet)
if err != nil {
t.Fatalf("reconfigure: %v", err)
}
if res.Status != "reconfigured" {
t.Errorf("Status = %q, want reconfigured", res.Status)
}
// la clave vieja no debe estar, la nueva sí
raw, _ := os.ReadFile(cfg)
content := string(raw)
if strings.Contains(content, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") {
t.Errorf("old PublicKey still present after reconfigure:\n%s", content)
}
if !strings.Contains(content, "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD=") {
t.Errorf("new PublicKey not found after reconfigure:\n%s", content)
}
// solo debe haber un bloque [Peer]
count := strings.Count(content, "[Peer]")
if count != 1 {
t.Errorf("[Peer] count = %d, want 1:\n%s", count, content)
}
})
}
+232
View File
@@ -0,0 +1,232 @@
package infra
import (
"fmt"
"os"
"os/exec"
"strings"
)
// WGPeerRemoveStatus indica el resultado de la operacion de borrado.
type WGPeerRemoveStatus string
const (
WGPeerRemoveStatusRemoved WGPeerRemoveStatus = "removed"
WGPeerRemoveStatusNotPresent WGPeerRemoveStatus = "not-present"
)
// WGPeerRemoveResult contiene el resultado de WGPeerRemove.
type WGPeerRemoveResult struct {
DeviceID string
Status WGPeerRemoveStatus
ConfigPath string
}
// WGPeerRemove elimina el bloque [Peer] asociado a deviceID del archivo configPath
// buscando el comentario "# DeviceID:<deviceID>" y aplica syncconf en caliente.
// Es idempotente: si el peer no existe devuelve status=not-present sin error.
//
// Formato esperado del config (el comentario puede preceder o estar dentro del bloque):
//
// # DeviceID:device-001
// [Peer]
// PublicKey = ...
// AllowedIPs = ...
func WGPeerRemove(deviceID string, configPath string) (WGPeerRemoveResult, error) {
result := WGPeerRemoveResult{
DeviceID: deviceID,
ConfigPath: configPath,
}
if strings.TrimSpace(deviceID) == "" {
return result, fmt.Errorf("wg_peer_remove: deviceID cannot be empty")
}
if strings.TrimSpace(configPath) == "" {
return result, fmt.Errorf("wg_peer_remove: configPath cannot be empty")
}
data, err := os.ReadFile(configPath)
if err != nil {
return result, fmt.Errorf("wg_peer_remove: read config %s: %w", configPath, err)
}
lines := strings.Split(string(data), "\n")
marker := fmt.Sprintf("# DeviceID:%s", deviceID)
// Localizar el marker.
markerIdx := -1
for i, line := range lines {
if strings.TrimSpace(line) == marker {
markerIdx = i
break
}
}
if markerIdx == -1 {
result.Status = WGPeerRemoveStatusNotPresent
return result, nil
}
// blockStart: la primera linea del bloque a eliminar.
// Si el marker precede al [Peer], el bloque empieza en el marker.
// Si el marker esta dentro del [Peer], retrocedemos hasta el [Peer] o su comentario previo.
blockStart := markerIdx
for j := markerIdx - 1; j >= 0; j-- {
t := strings.TrimSpace(lines[j])
if t == "[Peer]" {
blockStart = j
// Incluir tambien el comentario # DeviceID que preceda a ese [Peer].
if j > 0 && strings.HasPrefix(strings.TrimSpace(lines[j-1]), "# DeviceID:") {
blockStart = j - 1
}
break
}
if strings.HasPrefix(t, "[") && t != "" {
// Llegamos a otra seccion — el marker precede al [Peer].
break
}
}
// blockEnd: primera linea que abre el SIGUIENTE bloque tras el [Peer] actual.
// Saltamos hasta pasar el [Peer] propio antes de buscar otra seccion.
blockEnd := len(lines)
pastOwnPeer := false
for i := blockStart; i < len(lines); i++ {
t := strings.TrimSpace(lines[i])
if !pastOwnPeer {
if t == "[Peer]" {
pastOwnPeer = true
}
continue
}
if (strings.HasPrefix(t, "[") && t != "") || strings.HasPrefix(t, "# DeviceID:") {
blockEnd = i
break
}
}
// Reconstruir: segmento antes del bloque + segmento desde blockEnd.
before := lines[:blockStart]
after := lines[blockEnd:]
// Quitar lineas vacias finales del segmento anterior.
for len(before) > 0 && strings.TrimSpace(before[len(before)-1]) == "" {
before = before[:len(before)-1]
}
var newLines []string
newLines = append(newLines, before...)
if len(after) > 0 {
newLines = append(newLines, "")
}
newLines = append(newLines, after...)
newContent := strings.TrimRight(strings.Join(newLines, "\n"), "\n") + "\n"
if err := os.WriteFile(configPath, []byte(newContent), 0600); err != nil {
return result, fmt.Errorf("wg_peer_remove: write config %s: %w", configPath, err)
}
// Aplicar syncconf en caliente.
iface := wgIfaceFromPath(configPath)
if iface != "" {
if err := wgSyncConfFn(iface, configPath); err != nil {
_ = os.WriteFile(configPath, data, 0600)
return result, fmt.Errorf("wg_peer_remove: syncconf %s: %w", iface, err)
}
}
result.Status = WGPeerRemoveStatusRemoved
return result, nil
}
// wgIfaceFromPath extrae el nombre de interfaz del path del config (ej. wg0 de /etc/wireguard/wg0.conf).
func wgIfaceFromPath(configPath string) string {
parts := strings.Split(configPath, "/")
if len(parts) == 0 {
return ""
}
name := parts[len(parts)-1]
name = strings.TrimSuffix(name, ".conf")
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return ""
}
}
return name
}
// wgSyncConfFn aplica `wg syncconf <iface> <configPath>` en caliente. Variable
// para permitir override en tests (no requiere binario `wg`). WG_SKIP_SYNCCONF=1
// salta la ejecucion (CI sin wg-tools).
var wgSyncConfFn = func(iface, configPath string) error {
if os.Getenv("WG_SKIP_SYNCCONF") == "1" {
return nil
}
cmd := exec.Command("wg", "syncconf", iface, configPath)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// wgLookupPeerPublicKey busca la PublicKey del peer identificado por deviceID en configPath.
// El comentario "# DeviceID:<id>" puede preceder al [Peer] o estar dentro del bloque.
func wgLookupPeerPublicKey(deviceID, configPath string) (string, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return "", fmt.Errorf("wg_lookup_peer: read %s: %w", configPath, err)
}
lines := strings.Split(string(data), "\n")
marker := fmt.Sprintf("# DeviceID:%s", deviceID)
markerIdx := -1
for i, line := range lines {
if strings.TrimSpace(line) == marker {
markerIdx = i
break
}
}
if markerIdx == -1 {
return "", fmt.Errorf("wg_lookup_peer: peer %s not found in %s", deviceID, configPath)
}
// Buscar PublicKey hacia adelante desde el marker (saltando la linea [Peer]).
for i := markerIdx + 1; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "[Peer]" {
continue
}
if (strings.HasPrefix(line, "[") && line != "") || strings.HasPrefix(line, "# DeviceID:") {
break
}
if strings.HasPrefix(line, "PublicKey") {
if idx := strings.Index(line, " = "); idx != -1 {
return strings.TrimSpace(line[idx+3:]), nil
}
if idx := strings.Index(line, "="); idx != -1 {
return strings.TrimSpace(line[idx+1:]), nil
}
}
}
// Fallback: buscar hacia atras (marker dentro del bloque).
for i := markerIdx - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "[") && line != "" {
break
}
if strings.HasPrefix(line, "PublicKey") {
if idx := strings.Index(line, " = "); idx != -1 {
return strings.TrimSpace(line[idx+3:]), nil
}
if idx := strings.Index(line, "="); idx != -1 {
return strings.TrimSpace(line[idx+1:]), nil
}
}
}
return "", fmt.Errorf("wg_lookup_peer: PublicKey not found for peer %s in %s", deviceID, configPath)
}
+51
View File
@@ -0,0 +1,51 @@
---
name: wg_peer_remove
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerRemove(deviceID string, configPath string) (WGPeerRemoveResult, error)"
description: "Quita peer del hub wg0.conf por device_id, syncconf en caliente, idempotente. Para reconfigurar peer existente (no kill switch)."
tags: [wireguard, hub, peer, mesh, infra, audit]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: deviceID
desc: "Identificador unico del dispositivo. Debe coincidir con el comentario '# DeviceID:<id>' en el bloque [Peer] del config."
- name: configPath
desc: "Ruta absoluta al archivo de configuracion WireGuard (ej. /etc/wireguard/wg0.conf). Debe tener permisos de escritura."
output: "WGPeerRemoveResult con DeviceID, ConfigPath y Status (removed | not-present). not-present cuando el peer no existia — no es error."
tested: true
tests:
- "peer present → status=removed"
- "peer absent → status=not-present"
test_file_path: "functions/infra/wg_peer_remove_test.go"
file_path: "functions/infra/wg_peer_remove.go"
---
## Ejemplo
```go
result, err := infra.WGPeerRemove("device-laptop-alice", "/etc/wireguard/wg0.conf")
if err != nil {
log.Fatal(err)
}
// result.Status == "removed" o "not-present"
fmt.Printf("peer %s: %s\n", result.DeviceID, result.Status)
```
## Cuando usarla
Cuando necesites dar de baja temporalmente un peer del hub (dispositivo reemplazado, rotar claves, reconfigurar AllowedIPs). No deja rastro permanente — si el dispositivo vuelve a conectarse puedes añadirlo de nuevo. Para kill switch permanente (dispositivo perdido/comprometido) usa `wg_peer_revoke_go_infra`.
## Gotchas
- Requiere privilegios de root para `wg syncconf`. La funcion escribe el .conf y luego invoca `wg syncconf <iface> <path>`. Si el binario `wg` no esta disponible o el proceso no tiene permisos, el write se revierte automaticamente.
- El marker `# DeviceID:<id>` debe estar en el bloque [Peer] correcto. Si el comentario esta duplicado solo se elimina el primer bloque encontrado.
- El nombre de la interfaz se deduce del nombre del archivo (wg0.conf → wg0). Si configPath no sigue esa convencion, syncconf se omite (solo se modifica el archivo).
- La funcion NO toca la blacklist ni la audit DB — eso es exclusivo de `wg_peer_revoke`.
+87
View File
@@ -0,0 +1,87 @@
package infra
import (
"os"
"path/filepath"
"strings"
"testing"
)
const wgTestConfig = `[Interface]
Address = 10.0.0.1/24
PrivateKey = SERVERKEY==
# DeviceID:device-001
[Peer]
PublicKey = PUBKEY001==
AllowedIPs = 10.0.0.2/32
# DeviceID:device-002
[Peer]
PublicKey = PUBKEY002==
AllowedIPs = 10.0.0.3/32
`
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "wg0.conf")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write test config: %v", err)
}
return path
}
func TestWGPeerRemove(t *testing.T) {
t.Run("peer present → status=removed", func(t *testing.T) {
path := writeTestConfig(t, wgTestConfig)
// Patch syncconf to no-op for tests (wg binary not available in CI).
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
result, err := WGPeerRemove("device-001", path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != WGPeerRemoveStatusRemoved {
t.Errorf("got status=%q, want %q", result.Status, WGPeerRemoveStatusRemoved)
}
// Verify the peer block is gone from the file.
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "DeviceID:device-001") {
t.Error("DeviceID:device-001 marker still present after remove")
}
if strings.Contains(string(data), "PUBKEY001==") {
t.Error("PUBKEY001 still present after remove")
}
// Other peer must remain.
if !strings.Contains(string(data), "DeviceID:device-002") {
t.Error("DeviceID:device-002 was incorrectly removed")
}
})
t.Run("peer absent → status=not-present", func(t *testing.T) {
path := writeTestConfig(t, wgTestConfig)
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
result, err := WGPeerRemove("device-999", path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Status != WGPeerRemoveStatusNotPresent {
t.Errorf("got status=%q, want %q", result.Status, WGPeerRemoveStatusNotPresent)
}
// File must be unchanged.
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), "DeviceID:device-001") {
t.Error("existing peers were modified when removing absent peer")
}
})
}
+157
View File
@@ -0,0 +1,157 @@
package infra
import (
"crypto/sha256"
"database/sql"
_ "embed"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
)
//go:embed migrations/wg_revoked/001_revoked_peers.sql
var wgRevokedMigration string
// WGPeerRevokeAudit contiene el registro inmutable de una revocacion.
type WGPeerRevokeAudit struct {
DeviceID string
PublicKey string
RevokedAt int64
RevokedBy string
Reason string
PrevHash string
ThisHash string
}
// WGPeerRevoke revoca permanentemente un peer: lo elimina del config activo,
// lo registra en una audit DB con hash chain SHA256 inviolable y escribe en
// la blacklist persistente /etc/wireguard/wg_revoked.list.
//
// Reglas:
// - reason no puede estar vacio.
// - Revocar un peer ya revocado devuelve error "already revoked".
// - auditDBPath se crea con migracion embebida si no existe.
// - this_hash = SHA256(prev_hash || device_id || public_key || revoked_at || revoked_by || reason)
func WGPeerRevoke(deviceID, operator, reason string, configPath, auditDBPath string) (WGPeerRevokeAudit, error) {
audit := WGPeerRevokeAudit{
DeviceID: deviceID,
RevokedBy: operator,
Reason: reason,
}
if strings.TrimSpace(deviceID) == "" {
return audit, fmt.Errorf("wg_peer_revoke: deviceID cannot be empty")
}
if strings.TrimSpace(operator) == "" {
return audit, fmt.Errorf("wg_peer_revoke: operator cannot be empty")
}
if strings.TrimSpace(reason) == "" {
return audit, fmt.Errorf("wg_peer_revoke: reason cannot be empty")
}
if strings.TrimSpace(configPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: configPath cannot be empty")
}
if strings.TrimSpace(auditDBPath) == "" {
return audit, fmt.Errorf("wg_peer_revoke: auditDBPath cannot be empty")
}
// 1. Abrir/crear audit DB y aplicar migracion.
if err := os.MkdirAll(filepath.Dir(auditDBPath), 0700); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: mkdir audit db dir: %w", err)
}
db, err := sql.Open("sqlite3", auditDBPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: open audit db: %w", err)
}
defer db.Close()
if _, err := db.Exec(wgRevokedMigration); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: apply migration: %w", err)
}
// 2. Verificar que no este ya revocado ANTES del lookup (el peer puede
// haber sido eliminado del config por una revocacion previa).
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM revoked_peers WHERE device_id = ?", deviceID).Scan(&count); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: check existing: %w", err)
}
if count > 0 {
return audit, fmt.Errorf("wg_peer_revoke: device %s already revoked", deviceID)
}
// 3. Lookup PublicKey (el peer debe estar aun en el config en este punto).
pubKey, err := wgLookupPeerPublicKey(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: lookup peer: %w", err)
}
audit.PublicKey = pubKey
// 4. Obtener prev_hash (hash del ultimo registro, o string vacio si genesis).
var prevHash string
_ = db.QueryRow("SELECT this_hash FROM revoked_peers ORDER BY revoked_at DESC LIMIT 1").Scan(&prevHash)
// 5. Calcular this_hash = SHA256(prevHash || deviceID || publicKey || revokedAt || operator || reason).
revokedAt := time.Now().Unix()
audit.RevokedAt = revokedAt
audit.PrevHash = prevHash
hashInput := fmt.Sprintf("%s|%s|%s|%d|%s|%s", prevHash, deviceID, pubKey, revokedAt, operator, reason)
thisHash := fmt.Sprintf("%x", sha256.Sum256([]byte(hashInput)))
audit.ThisHash = thisHash
// 6. WGPeerRemove (eliminar del config + syncconf).
removeResult, err := WGPeerRemove(deviceID, configPath)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: remove peer: %w", err)
}
_ = removeResult // status puede ser removed o not-present (ya borrado, igual revocamos)
// 7. Insertar en audit DB dentro de una transaccion.
tx, err := db.Begin()
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.Exec(
`INSERT INTO revoked_peers (device_id, public_key, revoked_at, revoked_by, reason, prev_hash, this_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
deviceID, pubKey, revokedAt, operator, reason, prevHash, thisHash,
)
if err != nil {
return audit, fmt.Errorf("wg_peer_revoke: insert audit record: %w", err)
}
if err := tx.Commit(); err != nil {
return audit, fmt.Errorf("wg_peer_revoke: commit audit record: %w", err)
}
// 8. Append a blacklist persistente /etc/wireguard/wg_revoked.list.
blacklistLine := fmt.Sprintf("%s %d # DeviceID:%s operator:%s reason:%s\n",
pubKey, revokedAt, deviceID, operator, reason)
if err := wgAppendBlacklistFn(blacklistLine); err != nil {
// No revertir — el audit DB ya tiene el registro. Loguear como advertencia.
return audit, fmt.Errorf("wg_peer_revoke: append blacklist (audit DB committed): %w", err)
}
return audit, nil
}
// wgAppendBlacklistFn escribe una linea al final de /etc/wireguard/wg_revoked.list (append-only).
// Variable para permitir override en tests sin requerir permisos de /etc/wireguard/.
var wgAppendBlacklistFn = func(line string) error {
const blacklistPath = "/etc/wireguard/wg_revoked.list"
f, err := os.OpenFile(blacklistPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("open blacklist %s: %w", blacklistPath, err)
}
defer f.Close()
if _, err := f.WriteString(line); err != nil {
return fmt.Errorf("write blacklist: %w", err)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
---
name: wg_peer_revoke
kind: function
lang: go
domain: infra
version: "1.0.0"
purity: impure
signature: "func WGPeerRevoke(deviceID, operator, reason string, configPath, auditDBPath string) (WGPeerRevokeAudit, error)"
description: "Kill switch: revoca peer permanentemente. Anade a blacklist + audit log hash-chained inviolable (SHA256 chain). Para dispositivos perdidos/comprometidos."
tags: [wireguard, hub, revoke, kill-switch, audit, security]
uses_functions:
- wg_peer_remove_go_infra
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: deviceID
desc: "Identificador unico del dispositivo a revocar. Debe coincidir con '# DeviceID:<id>' en wg0.conf."
- name: operator
desc: "Nombre del operador que ejecuta la revocacion. Se guarda en audit log. No puede ser vacio."
- name: reason
desc: "Motivo de la revocacion (ej. 'dispositivo perdido', 'comprometido'). No puede ser vacio. No incluir PII."
- name: configPath
desc: "Ruta absoluta al wg0.conf del hub. El peer se elimina del config activo via syncconf."
- name: auditDBPath
desc: "Ruta al SQLite de audit. Se crea con schema si no existe. Contiene la cadena de hashes inviolable."
output: "WGPeerRevokeAudit con DeviceID, PublicKey, RevokedAt (unix), RevokedBy, Reason, PrevHash y ThisHash. ThisHash = SHA256(PrevHash|DeviceID|PublicKey|RevokedAt|Operator|Reason)."
tested: true
tests:
- "audit DB contiene registro con this_hash != prev_hash"
- "segunda revoke del mismo peer → error already revoked"
test_file_path: "functions/infra/wg_peer_revoke_test.go"
file_path: "functions/infra/wg_peer_revoke.go"
---
## Ejemplo
```go
audit, err := infra.WGPeerRevoke(
"device-laptop-alice",
"operator-bob",
"dispositivo perdido en viaje",
"/etc/wireguard/wg0.conf",
"/var/lib/wg-hub/revoked.db",
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("revoked: device=%s pubkey=%s hash=%s\n",
audit.DeviceID, audit.PublicKey, audit.ThisHash)
```
## Cuando usarla
Cuando un dispositivo esta perdido, robado o comprometido y NO debe poder volver a conectarse nunca. Deja registro inmutable en audit DB con hash chain SHA256 y escribe la PublicKey en `/etc/wireguard/wg_revoked.list`. Para baja temporal (rotar claves, reconfigurar) usa `wg_peer_remove_go_infra`.
## Gotchas
- **Reason obligatorio**: reason vacio devuelve error antes de tocar nada.
- **Idempotencia**: revocar el mismo deviceID dos veces devuelve `already revoked`. La comprobacion es sobre la audit DB, no sobre el config.
- **Audit DB borrada**: si auditDBPath se borra, la chain se reinicia desde genesis (prev_hash vacio). El break de integridad es visible (primer registro sin prev_hash). El sistema lo acepta pero queda evidencia del gap.
- **Blacklist `/etc/wireguard/wg_revoked.list`**: requiere permisos de escritura en `/etc/wireguard/`. Si falla, el audit DB ya tiene el registro (no se revierte) pero la funcion devuelve error indicando el gap.
- **Reason no debe contener PII**: se guarda en texto plano en SQLite y en la blacklist. Usar referencias internas en vez de nombres reales.
- **syncconf**: hereda el requisito de privilegios de WGPeerRemove. Ver gotchas de `wg_peer_remove_go_infra`.
+104
View File
@@ -0,0 +1,104 @@
package infra
import (
"database/sql"
"os"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
const wgRevokeTestConfig = `[Interface]
Address = 10.0.0.1/24
PrivateKey = SERVERKEY==
# DeviceID:device-revoke-001
[Peer]
PublicKey = PUBKEYREVOKE001==
AllowedIPs = 10.0.0.10/32
# DeviceID:device-revoke-002
[Peer]
PublicKey = PUBKEYREVOKE002==
AllowedIPs = 10.0.0.11/32
`
func TestWGPeerRevoke(t *testing.T) {
origSyncConf := wgSyncConfFn
wgSyncConfFn = func(iface, configPath string) error { return nil }
defer func() { wgSyncConfFn = origSyncConf }()
origBlacklist := wgAppendBlacklistFn
wgAppendBlacklistFn = func(line string) error { return nil }
defer func() { wgAppendBlacklistFn = origBlacklist }()
t.Run("audit DB contiene registro con this_hash != prev_hash", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "wg0.conf")
auditDBPath := filepath.Join(dir, "revoked.db")
if err := os.WriteFile(configPath, []byte(wgRevokeTestConfig), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
audit, err := WGPeerRevoke("device-revoke-001", "operator-alice", "dispositivo perdido", configPath, auditDBPath)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if audit.ThisHash == "" {
t.Error("this_hash is empty")
}
// En el primer registro prev_hash es vacio — this_hash debe diferir siempre.
if audit.ThisHash == audit.PrevHash {
t.Errorf("this_hash == prev_hash (%q), expected different values", audit.ThisHash)
}
if audit.PublicKey != "PUBKEYREVOKE001==" {
t.Errorf("public_key=%q, want PUBKEYREVOKE001==", audit.PublicKey)
}
// Verificar en la BD directamente.
db, err := sql.Open("sqlite3", auditDBPath)
if err != nil {
t.Fatalf("open audit db: %v", err)
}
defer db.Close()
var storedHash, storedPubKey string
if err := db.QueryRow("SELECT this_hash, public_key FROM revoked_peers WHERE device_id = ?",
"device-revoke-001").Scan(&storedHash, &storedPubKey); err != nil {
t.Fatalf("query audit record: %v", err)
}
if storedHash != audit.ThisHash {
t.Errorf("stored hash=%q, want %q", storedHash, audit.ThisHash)
}
if storedPubKey != "PUBKEYREVOKE001==" {
t.Errorf("stored public_key=%q, want PUBKEYREVOKE001==", storedPubKey)
}
})
t.Run("segunda revoke del mismo peer → error already revoked", func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "wg0.conf")
auditDBPath := filepath.Join(dir, "revoked.db")
if err := os.WriteFile(configPath, []byte(wgRevokeTestConfig), 0600); err != nil {
t.Fatalf("write config: %v", err)
}
// Primera revocacion.
if _, err := WGPeerRevoke("device-revoke-002", "operator-bob", "comprometido", configPath, auditDBPath); err != nil {
t.Fatalf("first revoke unexpected error: %v", err)
}
// Segunda revocacion del mismo deviceID → debe fallar por audit DB, no por config.
_, err := WGPeerRevoke("device-revoke-002", "operator-bob", "segundo intento", configPath, auditDBPath)
if err == nil {
t.Fatal("expected error on second revoke, got nil")
}
if !contains(err.Error(), "already revoked") {
t.Errorf("expected 'already revoked' error, got: %v", err)
}
})
}
+17
View File
@@ -0,0 +1,17 @@
package infra
// WGPeerSpec describe un peer WireGuard a añadir al hub.
type WGPeerSpec struct {
DeviceID string // identificador logico ("pc-aurgi", "home-wsl", "android-egu")
PublicKey string // base64 — clave publica del peer
PresharedKey string // base64 — opcional, "" para omitir
AllowedIPs string // CIDR a rutear a este peer, ej "10.42.0.10/32"; "" para autoasignar
}
// WGPeerResult es el resultado de añadir o verificar un peer en wg0.conf.
type WGPeerResult struct {
DeviceID string // identificador logico del peer
AssignedIP string // IP asignada, ej "10.42.0.10"
ConfigPath string // ruta absoluta al config aplicado
Status string // "added" | "already-present" | "reconfigured"
}