SPA nueva en /orchestrator (Vue 3 + Vite, servida por el mismo binario Go bajo /orchestrator para que la cookie de sesión funcione sin tocar CORS), reemplaza al panel Alpine.js como punto de entrada del menú. Backend, todo aditivo sobre el motor de uMind ya existente: - UmindHerramienta: tools custom por tenant que llaman un webhook HTTP, integradas al loop de function-calling existente. Cliente HTTP con guardas SSRF (bloqueo de IPs privadas/loopback/link-local resuelto en el momento de conectar, no antes, para cerrar la ventana de DNS rebinding) que no existían en el proyecto. - UmindCanal: Telegram y WhatsApp Business Cloud API como canales adicionales del mismo agente que ya atiende el widget web, ambos reusando ProcessWidgetMessage. WhatsApp valida X-Hub-Signature-256. Credenciales cifradas en reposo con el mismo AES-GCM+APP_KEY que ya usa el proyecto para la contraseña SMTP (primer uso para secretos de uMind). - Se conecta middlewares.Limit() (rate limiter que existía pero no se usaba en ningún lado) al widget público y a los webhooks nuevos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
127 lines
3.8 KiB
Go
127 lines
3.8 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
umindWebhookTimeout = 10 * time.Second
|
|
umindWebhookRespuestaLimit = 256 * 1024 // 256KB
|
|
)
|
|
|
|
// validarURLTool solo chequea forma (https + host presente) antes de
|
|
// intentar la llamada — la validación real de destino pasa por
|
|
// dialContextSeguro en cada conexión, no acá, para no dejar una ventana
|
|
// entre "resolver y validar" y "conectar" (DNS rebinding: el mismo hostname
|
|
// podría resolver a una IP pública en el primer lookup y a una interna
|
|
// milisegundos después, en el connect real).
|
|
func validarURLTool(rawURL string) (*url.URL, error) {
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("URL inválida: %w", err)
|
|
}
|
|
if u.Scheme != "https" {
|
|
return nil, fmt.Errorf("la URL de la tool debe ser https")
|
|
}
|
|
if u.Hostname() == "" {
|
|
return nil, fmt.Errorf("URL sin host")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// dialContextSeguro resuelve el host en el momento de conectar (no antes) y
|
|
// rechaza cualquier IP interna justo antes de abrir la conexión TCP — cierra
|
|
// la ventana de DNS rebinding que tendría validar la URL una vez y confiar
|
|
// en que el cliente HTTP resuelva "lo mismo" después.
|
|
func dialContextSeguro(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo resolver %s: %w", host, err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, fmt.Errorf("%s no resolvió a ninguna IP", host)
|
|
}
|
|
for _, ip := range ips {
|
|
if ipEsInterna(ip) {
|
|
return nil, fmt.Errorf("%s resuelve a una IP interna (%s), no permitido", host, ip)
|
|
}
|
|
}
|
|
dialer := &net.Dialer{Timeout: umindWebhookTimeout}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
|
|
}
|
|
|
|
// ipEsInterna centraliza qué se considera "red interna" — separado para
|
|
// poder testearlo sin red real.
|
|
func ipEsInterna(ip net.IP) bool {
|
|
return ip.IsPrivate() ||
|
|
ip.IsLoopback() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() ||
|
|
ip.IsUnspecified() ||
|
|
ip.IsMulticast()
|
|
}
|
|
|
|
var umindWebhookHTTPClient = &http.Client{
|
|
Timeout: umindWebhookTimeout,
|
|
Transport: &http.Transport{
|
|
DialContext: dialContextSeguro,
|
|
},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return fmt.Errorf("redirects no permitidos en tools custom")
|
|
},
|
|
}
|
|
|
|
// LlamarHerramientaWebhook ejecuta una tool custom: POST a la URL configurada
|
|
// con los argumentos que decidió el modelo, con guardas SSRF y límites de
|
|
// tiempo/tamaño de respuesta. Devuelve el body de la respuesta tal cual (el
|
|
// modelo lo interpreta como resultado de la tool).
|
|
func LlamarHerramientaWebhook(rawURL string, headerNombre, headerValor string, argumentos map[string]interface{}) (string, error) {
|
|
u, err := validarURLTool(rawURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
body, err := json.Marshal(argumentos)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no se pudieron serializar los argumentos: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if strings.TrimSpace(headerNombre) != "" {
|
|
req.Header.Set(headerNombre, headerValor)
|
|
}
|
|
|
|
resp, err := umindWebhookHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("no se pudo contactar la tool: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, umindWebhookRespuestaLimit))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
detalle := strings.TrimSpace(string(raw))
|
|
if len(detalle) > 500 {
|
|
detalle = detalle[:500]
|
|
}
|
|
return "", fmt.Errorf("la tool respondió %d: %s", resp.StatusCode, detalle)
|
|
}
|
|
return string(raw), nil
|
|
}
|