Si tu script de PowerShell que llama a Microsoft Graph funciona en la red interna pero falla en la DMZ con el mensaje “The underlying connection was closed: An unexpected error occurred on a send”, casi siempre se trata de un problema de negociación TLS. Aquí tienes el diagnóstico y la solución, con enfoque seguro en TLS 1.2.
Resumen del caso
Un script de PowerShell que envía correos mediante Microsoft Graph funciona en equipos internos pero falla en un servidor ubicado en la DMZ. Las pruebas de conectividad básicas “pasan” (se ve el puerto 443 abierto), pero al ejecutar la llamada real a Graph aparece el error:
The underlying connection was closed: An unexpected error occurred on a send.
En los registros del firewall perimetral se observa tcp-rst-from-client
, lo que sugiere que el handshake TLS no concluye correctamente. En pocas palabras: hay sesión TCP pero no se establece el canal seguro.
Solución inmediata y segura
Habilita explícitamente TLS desde el proceso de PowerShell antes de invocar Invoke-RestMethod
/ Invoke-WebRequest
(o cualquier cliente HTTP que uses). Coloca una de estas líneas al inicio del script o justo antes de la llamada a Graph:
Opción recomendada
Fuerza solo TLS 1.2 (y 1.3 si tu runtime lo soporta). Es la práctica moderna y segura.
# TLS 1.2 recomendado (Windows PowerShell 5.1 / .NET Framework)
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Opción amplia de compatibilidad
Úsala solo si necesitas convivir con endpoints antiguos durante una migración controlada:
# Permite TLS 1.0/1.1/1.2 en el proceso
[Net.ServicePointManager]::SecurityProtocol =
[Net.SecurityProtocolType]::Tls -bor
[Net.SecurityProtocolType]::Tls11 -bor
[Net.SecurityProtocolType]::Tls12
Tras aplicar el ajuste anterior, el envío vía Microsoft Graph suele volver a funcionar en el servidor de la DMZ.
Nota: PowerShell 7+ (Core) usa por defecto TLS 1.2+ y normalmente no requiere esta línea. En Windows PowerShell 5.1 sí puede ser necesario. Si usas PS 7+ y un
HttpClient
propio, asegúrate de no forzar protocolos más antiguos en el handler.
Por qué ocurre
Los servidores perimetrales y equipos en DMZ a menudo tienen configuraciones de seguridad endurecidas o, por el contrario, heredadas. En Windows PowerShell 5.1 (sobre .NET Framework), el valor por defecto de SecurityProtocol
puede permitir negociar versiones obsoletas (TLS 1.0/1.1) o no incluir TLS 1.2 según el nivel de .NET y las claves de registro. Microsoft Graph exige TLS moderno; cuando el cliente intenta negociar con una versión o conjunto de cifrados no aceptados, el servidor cierra la conexión durante el handshake. En los registros de la infraestructura de red esto suele aparecer como tcp-rst-from-client
y en .NET como “underlying connection was closed”.
Diagnóstico rápido
Antes de abrir tickets, confirma con pruebas sencillas si el problema está en la capa TLS.
Comprobación dentro del proceso
# Observa qué protocolos admite actualmente el proceso
[Net.ServicePointManager]::SecurityProtocol
Si Tls12
no aparece, debes forzarlo (ver solución).
Conectividad TCP vs. HTTP/TLS
# Alcance TCP al 443 (no valida TLS)
Test-NetConnection graph.microsoft.com -Port 443
Validación HTTP/TLS:
En Windows PowerShell 5.1
Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata) -UseBasicParsing
En PowerShell 7+ (sin UseBasicParsing)
Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata)
- Si
Test-NetConnection
pasa peroInvoke-WebRequest
falla, el problema es el handshake TLS/cifrados, no el firewall de capa 3/4. - Si ambos fallan, podría existir bloqueo de red o resolución DNS incorrecta.
Versiones de PowerShell y .NET
$PSVersionTable
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' |
Select-Object Release, Version
Actualiza a .NET 4.8+ y considera PowerShell 7.x en servidores nuevos.
Política TLS del sistema (Schannel)
Asegura que TLS 1.2 esté habilitado a nivel del sistema operativo (útil para que todos los procesos negocien de forma segura sin tocar cada script). Comprueba estas claves:
# TLS 1.2 habilitado para cliente
Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client'
.NET: uso de cifrados fuertes / versiones por defecto del sistema
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft.NETFramework\v4.0.30319' |
Select-Object SchUseStrongCrypto, SystemDefaultTlsVersions
Dispositivos intermedios
Si existe proxy o inspección TLS, excluye los dominios de Microsoft Graph y de autenticación de Azure AD para evitar rupturas de handshake. Asegúrate de que el dispositivo soporte TLS 1.2 y los cipher suites modernos. Dominios típicos a revisar: graph.microsoft.com
, login.microsoftonline.com
, login.microsoft.com
, sts.windows.net
, aadcdn.msftauth.net
, aadcdn.msauth.net
.
Prueba dirigida en la DMZ
Ejecuta el script con logging detallado justo después de establecer SecurityProtocol
. Si el error cambia de “connection was closed” a un código HTTP de Graph (por ejemplo 401/403/400), ya superaste la barrera TLS y puedes seguir depurando autenticación o permisos.
Plantilla de script robusto
Este ejemplo contempla buenas prácticas: establece TLS 1.2 en Windows PowerShell 5.1, obtiene un token con client credentials y envía un correo con Graph usando /sendMail
. Ajusta variables y permisos de la app en Azure AD según tu entorno.
#requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
--- TLS seguro (necesario en Windows PowerShell 5.1 / .NET Framework) ---
try {
\[void]\[Net.ServicePointManager]::SecurityProtocol
\[Net.ServicePointManager]::SecurityProtocol = \[Net.SecurityProtocolType]::Tls12
} catch { }
--- Parámetros (ajusta a tu entorno) ---
\$TenantId = '\'
\$ClientId = '\'
\$ClientSecret = '\' # Usa KeyVault si es posible
\$GraphScope = '[https://graph.microsoft.com/.default](https://graph.microsoft.com/.default)'
\$FromUser = '[remitente@tu-dominio.com](mailto:remitente@tu-dominio.com)' # cuenta habilitada para enviar
\$ToUser = '[destinatario@tu-dominio.com](mailto:destinatario@tu-dominio.com)'
--- Función: Obtener token app-only (client credentials) ---
function Get-GraphToken {
param(
\[Parameter(Mandatory)] \[string]\$TenantId,
\[Parameter(Mandatory)] \[string]\$ClientId,
\[Parameter(Mandatory)] \[string]\$ClientSecret,
\[Parameter(Mandatory)] \[string]\$Scope
)
\$tokenEndpoint = "[https://login.microsoftonline.com/\$TenantId/oauth2/v2.0/token](https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token)"
\$body = @{
client\_id = \$ClientId
scope = \$Scope
client\_secret = \$ClientSecret
grant\type = 'client\credentials'
}
\$resp = Invoke-RestMethod -Method Post -Uri \$tokenEndpoint -Body \$body -ContentType 'application/x-www-form-urlencoded'
return \$resp.access\_token
}
--- Función: Enviar correo con Graph /sendMail ---
function Send-GraphMail {
param(
\[Parameter(Mandatory)] \[string]\$AccessToken,
\[Parameter(Mandatory)] \[string]\$FromUser,
\[Parameter(Mandatory)] \[string]\$ToUser
)
\$uri = "[https://graph.microsoft.com/v1.0/users/\$FromUser/sendMail](https://graph.microsoft.com/v1.0/users/$FromUser/sendMail)"
\$payload = @{
message = @{
subject = 'Prueba desde DMZ con TLS 1.2'
body = @{
contentType = 'HTML'
content = 'Hola, este es un correo enviado desde un servidor en la DMZ usando Graph.'
}
toRecipients = @(
@{ emailAddress = @{ address = \$ToUser } }
)
}
saveToSentItems = \$true
}
\$headers = @{ Authorization = "Bearer \$AccessToken" }
Invoke-RestMethod -Method Post -Uri \$uri -Headers \$headers -Body (\$payload | ConvertTo-Json -Depth 10) -ContentType 'application/json'
}
--- Ejecución ---
try {
Write-Host 'Obteniendo token...'
\$token = Get-GraphToken -TenantId \$TenantId -ClientId \$ClientId -ClientSecret \$ClientSecret -Scope \$GraphScope
Write-Host 'Enviando correo con Graph...'
Send-GraphMail -AccessToken \$token -FromUser \$FromUser -ToUser \$ToUser
Write-Host 'Envío completado.'
}
catch \[System.Net.WebException] {
Write-Warning "WebException: \$(\$*.Exception.Message)"
if (\$*.Exception.Response) {
\$stream = \$*.Exception.Response.GetResponseStream()
\$reader = New-Object System.IO.StreamReader(\$stream)
\$body = \$reader.ReadToEnd()
Write-Host "Respuesta HTTP: \$body"
}
if (\$*.Exception.InnerException) {
Write-Host "Detalle interno: \$(\$*.Exception.InnerException.Message)"
}
throw
}
catch {
Write-Error \$*
throw
}
Si ves respuesta HTTP de Graph (por ejemplo 202/204) en lugar del error de transporte, tu capa TLS está correcta. Si aparece 401/403, revisa permisos de la app, consentimiento, roles y ámbito.
Tabla rápida de síntomas y acciones
Síntoma | Interpretación probable | Acción recomendada |
---|---|---|
Test-NetConnection OK, Invoke-WebRequest falla | Handshake TLS o cifrados incompatibles | Forzar TLS 1.2 en el proceso; validar Schannel y ciphers; actualizar .NET/PS |
Firewall muestra tcp-rst-from-client | Endpoint o dispositivo intermedio aborta handshake | Excluir inspección TLS; verificar compatibilidad TLS 1.2 en proxy; revisar cadena de certificados |
Error “Could not create SSL/TLS secure channel” | Cliente no logra negociar protocolo/cipher suit | Habilitar TLS 1.2 y cifrados modernos; comprobar GPO/registro |
Funciona en LAN, falla en DMZ | Políticas diferentes (GPO/Schannel/proxy) por segmento | Alinear políticas; aplicar SystemDefaultTlsVersions y SchUseStrongCrypto |
PS 7+ OK; Windows PowerShell 5.1 falla | Diferencia de defaults TLS entre runtimes | Agregar línea de ServicePointManager en 5.1 o migrar a PS 7+ |
Endurecimiento a nivel sistema
Para evitar depender de cada script, establece TLS 1.2+ como predeterminado con GPO o automatización. Ejemplos de claves:
Windows Registry Editor Version 5.00
; Habilitar TLS 1.2 para cliente (Schannel)
\[HKEY\LOCAL\MACHINE\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Client]
"DisabledByDefault"=dword:00000000
"Enabled"=dword:00000001
; Forzar .NET a usar cifrados fuertes y versiones del sistema
\[HKEY\LOCAL\MACHINE\SOFTWARE\Microsoft.NETFramework\v4.0.30319]
"SchUseStrongCrypto"=dword:00000001
"SystemDefaultTlsVersions"=dword:00000001
\[HKEY\LOCAL\MACHINE\SOFTWARE\WOW6432Node\Microsoft.NETFramework\v4.0.30319]
"SchUseStrongCrypto"=dword:00000001
"SystemDefaultTlsVersions"=dword:00000001
Tras aplicarlo, reinicia los servicios o el servidor para que Schannel tome la configuración. Idealmente deshabilita TLS 1.0/1.1 en todo el entorno salvo que una dependencia legacy lo impida.
Revisión de cipher suites
En Windows Server 2016+ puedes listar los cipher suites locales y confirmar que incluyes suites modernas (por ejemplo ECDHE con AES-GCM):
Get-TlsCipherSuite | Sort-Object -Property Name | Format-Table Name, Protocols
Si solo ves suites antiguas o CBC, alinea el orden y el set con estándares actuales. En entornos inspeccionados, asegúrate de que el proxy negocie también suites modernas hacia Internet.
Pruebas adicionales útiles
- curl nativo (si está disponible):
curl.exe -v https://graph.microsoft.com/v1.0/$metadata
. Verás detalles de TLS negociado. - Eventos Schannel: en el Visor de eventos → Registros de Windows → Sistema → origen Schannel, busca 36874/36888/36886 para pistas de fallas en handshake.
- Captura controlada:
netsh trace start capture=yes persistent=no
ynetsh trace stop
para analizar a posteriori.
Buenas prácticas operativas
- Mantén actualizado el runtime: PowerShell 7.x y .NET 6/8 ofrecen defaults seguros y mejor diagnóstico.
- Centraliza políticas TLS con GPO para evitar “parches” por script.
- Evita reabrir TLS 1.0/1.1. Si necesitas compatibilidad temporal, delimítala al proceso y documenta fecha de retiro.
- Si usas inspección TLS, documenta exclusiones para dominios críticos de identidad y Graph.
- Revisa periódicamente los cipher suites permitidos y el orden de preferencia.
Checklist de resolución y verificación
- En Windows PowerShell 5.1, añade
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
y repite la llamada. - Si sigue fallando, valida que el sistema tenga TLS 1.2 habilitado en Schannel y .NET use cifrados fuertes (SchUseStrongCrypto).
- Prueba
Invoke-WebRequest https://graph.microsoft.com/v1.0/$metadata
: si responde 200, TLS está bien. - Comprueba permisos de la app en Graph si obtienes 401/403.
- Si hay proxy/inspección, excluye dominios de Graph y autenticación, y confirma soporte de TLS 1.2.
- Repite la prueba de envío con
/sendMail
y registra el código HTTP devuelto.
Preguntas frecuentes
¿Por qué en la LAN funciona y en la DMZ no? Políticas distintas: Schannel, GPO, proxies o inspección TLS. En LAN puedes tener TLS 1.2 habilitado por defecto y en DMZ no.
¿Es seguro permitir TLS 1.0/1.1 temporalmente? No se recomienda. Úsalo solo como último recurso y restringido al proceso mientras migra la dependencia afectada.
¿PowerShell 7+ soluciona esto solo? En la mayoría de casos sí, porque adopta TLS 1.2+ por defecto. Aun así, si el proxy o el sistema bloquea TLS 1.2, seguirá fallando.
¿Puedo establecer el protocolo desde Invoke-WebRequest
? No directamente en Windows PowerShell 5.1. La vía habitual es ServicePointManager
. En PS 7+ el stack usa la configuración del sistema.
¿Qué significa exactamente tcp-rst-from-client
? Que el extremo “cliente” (o un dispositivo intermedio que actúa como tal) envió un RST para terminar la conexión, a menudo por fallo en negociación TLS o política del proxy.
Caso cerrado
El incidente se resolvió habilitando explícitamente TLS 1.2 en el proceso de PowerShell antes de realizar las llamadas HTTP a Graph. Con ese ajuste, el servidor en la DMZ completó el handshake, desapareció el “underlying connection was closed” y el envío de correos mediante Graph API quedó operativo. El siguiente paso recomendable es aplicar la política a nivel de sistema/GPO para que todos los procesos negocien de forma segura sin depender de cada script.
Anexo de comandos útiles
# Ver protocolos activos en el proceso
[Net.ServicePointManager]::SecurityProtocol
Conectividad TCP
Test-NetConnection graph.microsoft.com -Port 443
Petición simple a Graph para validar TLS/HTTP
(en Windows PowerShell 5.1)
Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata) -UseBasicParsing
(en PowerShell 7+)
Invoke-WebRequest [https://graph.microsoft.com/v1.0/\$metadata](https://graph.microsoft.com/v1.0/$metadata)
Listar cipher suites (Server 2016+)
Get-TlsCipherSuite | Sort-Object Name | Format-Table Name, Protocols
Revisar .NET Framework instalado
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' |
Select-Object Release, Version
Resumen para llevar
- El error no es “de Graph”, es de transporte TLS en el cliente o en el camino.
- Forzar TLS 1.2 en Windows PowerShell 5.1 suele resolver de inmediato.
- Mejor práctica: habilitar TLS 1.2+ por defecto a nivel de sistema y migrar a PowerShell 7+.
- Si hay proxy/inspección TLS, aplica exclusiones para Graph y dominios de identidad de Microsoft.
Resultado en este caso: habilitar TLS 1.2 desde el script eliminó el error de conexión y permitió enviar correos vía Graph API desde el servidor en la DMZ.