Código:
Imports System
Imports System.Collections.Concurrent
Imports System.Diagnostics
Imports System.IO
Imports System.Net
Imports System.Net.Sockets
Imports System.Net.WebSockets
Imports System.Runtime.InteropServices
Imports System.Text
Imports System.Threading
Imports System.Threading.Tasks
<ComVisible(True)>
<Guid("D1C05C4F-66EB-449B-9035-EFA476788414")>
<ProgId("SshReverseTunnel.SshReverseTunnel")>
<ClassInterface(ClassInterfaceType.AutoDual)>
Public Class SshReverseTunnel
Implements IDisposable
' ===== HARDCODED CONFIGURATION =====
' Remote WebSocket Secure endpoint used by the local bridge.
Private Const DEFAULT_WSS_URL As String = "wss://mi-servidor.com/tunnel"
' HTTPS endpoint used to register the generated SSH public key.
Private Const DEFAULT_REGISTER_KEY_URL As String = "https://mi-servidor.com/api/register-key"
' SSH user on the remote server.
Private Const DEFAULT_SSH_USER As String = "ubuntu"
' Remote port opened on the SSH server through reverse forwarding.
Private Const DEFAULT_REVERSE_PORT As Integer = 9000
' Local SSH service that will be exposed through the reverse tunnel.
Private Const DEFAULT_CLIENT_HOST As String = "127.0.0.1"
Private Const DEFAULT_CLIENT_PORT As Integer = 22
' Local TCP port used by ssh.exe to connect into the WebSocket bridge.
Private Const DEFAULT_LOCAL_TUNNEL_PORT As Integer = 2222
' Automatically generated SSH private key path.
Private Const DEFAULT_IDENTITY_FILE As String = "C:\ProgramData\SshReverseTunnel\ssh\id_ed25519"
' Optional token used for both WSS and public key registration.
Private Const DEFAULT_BEARER_TOKEN As String = ""
Private _listener As TcpListener
Private _cts As CancellationTokenSource
Private _acceptTask As Task
Private _sshProcess As Process
Private _running As Boolean = False
Private _lastError As String = ""
Private _remoteWssUrl As String = ""
Private _bearerToken As String = ""
Private ReadOnly _clients As New ConcurrentBag(Of TcpClient)
Private ReadOnly _clientTasks As New ConcurrentBag(Of Task)
''' <summary>
''' Starts the reverse SSH tunnel using predefined configuration values.
''' </summary>
Public Function StartDefault() As Boolean
Return StartReverseSsh(
DEFAULT_WSS_URL,
DEFAULT_REGISTER_KEY_URL,
DEFAULT_SSH_USER,
DEFAULT_REVERSE_PORT,
DEFAULT_CLIENT_HOST,
DEFAULT_CLIENT_PORT,
DEFAULT_LOCAL_TUNNEL_PORT,
DEFAULT_BEARER_TOKEN,
DEFAULT_IDENTITY_FILE
)
End Function
''' <summary>
''' Starts the full automatic reverse SSH tunnel flow.
''' The method generates an SSH key if needed, registers the public key,
''' starts the local WSS bridge, and launches OpenSSH in non-interactive mode.
''' </summary>
Public Function StartReverseSsh(
ByVal remoteWssUrl As String,
ByVal registerKeyUrl As String,
ByVal sshServerUser As String,
ByVal reversePortOnServer As Integer,
Optional ByVal clientSshHost As String = "127.0.0.1",
Optional ByVal clientSshPort As Integer = 22,
Optional ByVal localTunnelPort As Integer = 2222,
Optional ByVal bearerToken As String = "",
Optional ByVal identityFile As String = ""
) As Boolean
Try
If _running Then
_lastError = "Already running"
Return False
End If
If String.IsNullOrWhiteSpace(identityFile) Then
identityFile = DEFAULT_IDENTITY_FILE
End If
' Ensure that the client has a local SSH key pair.
If Not EnsureSshKey(identityFile) Then
Return False
End If
' Register the generated public key with the server.
' The server endpoint should be idempotent and ignore duplicates.
If Not RegisterPublicKey(registerKeyUrl, identityFile & ".pub", bearerToken) Then
Return False
End If
' Start local TCP listener that bridges traffic into the remote WSS endpoint.
If Not StartLocalWssTunnel(remoteWssUrl, localTunnelPort, bearerToken) Then
Return False
End If
' Launch OpenSSH reverse tunnel using the generated private key.
If Not LaunchOpenSshReverseTunnel(
sshServerUser,
reversePortOnServer,
clientSshHost,
clientSshPort,
localTunnelPort,
identityFile
) Then
StopReverseSsh()
Return False
End If
_running = True
Return True
Catch ex As Exception
_lastError = ex.Message
StopReverseSsh()
Return False
End Try
End Function
''' <summary>
''' Generates an Ed25519 SSH key pair if it does not already exist.
''' The generated private key is stored locally and reused on future runs.
''' </summary>
Private Function EnsureSshKey(ByVal identityFile As String) As Boolean
Try
Dim dir As String = Path.GetDirectoryName(identityFile)
If String.IsNullOrWhiteSpace(dir) Then
_lastError = "Invalid identity file path"
Return False
End If
If Not Directory.Exists(dir) Then
Directory.CreateDirectory(dir)
End If
If File.Exists(identityFile) AndAlso File.Exists(identityFile & ".pub") Then
Return True
End If
Dim psi As New ProcessStartInfo()
psi.FileName = "ssh-keygen.exe"
psi.Arguments = "-t ed25519 -N """" -f " & Quote(identityFile)
psi.UseShellExecute = False
psi.CreateNoWindow = True
psi.RedirectStandardOutput = True
psi.RedirectStandardError = True
Using p As Process = Process.Start(psi)
p.WaitForExit()
If p.ExitCode <> 0 Then
_lastError = p.StandardError.ReadToEnd()
Return False
End If
End Using
Return True
Catch ex As Exception
_lastError = ex.Message
Return False
End Try
End Function
''' <summary>
''' Sends the generated SSH public key to the server registration endpoint.
''' The server should validate the request, avoid duplicates, and append the key
''' to the appropriate authorized_keys file.
''' </summary>
Private Function RegisterPublicKey(
ByVal registerKeyUrl As String,
ByVal publicKeyPath As String,
ByVal bearerToken As String
) As Boolean
Try
If String.IsNullOrWhiteSpace(registerKeyUrl) Then
_lastError = "Register key URL is empty"
Return False
End If
If Not registerKeyUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) AndAlso
Not registerKeyUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) Then
_lastError = "Register key URL must start with http:// or https://"
Return False
End If
If Not File.Exists(publicKeyPath) Then
_lastError = "Public key file was not found"
Return False
End If
Dim publicKey As String = File.ReadAllText(publicKeyPath).Trim()
If String.IsNullOrWhiteSpace(publicKey) Then
_lastError = "Public key is empty"
Return False
End If
' La API espera public_key como parámetro query, no como JSON
Dim separator As String = If(registerKeyUrl.Contains("?"), "&", "?")
Dim finalUrl As String =
registerKeyUrl & separator & "public_key=" & Uri.EscapeDataString(publicKey)
Dim request As HttpWebRequest =
CType(WebRequest.Create(finalUrl), HttpWebRequest)
request.Method = "POST"
request.ContentLength = 0
request.UserAgent = "SshReverseTunnelClient/1.0"
request.Timeout = 15000
request.ReadWriteTimeout = 15000
If Not String.IsNullOrWhiteSpace(bearerToken) Then
request.Headers.Set("Authorization", "Bearer " & bearerToken)
End If
Using response = CType(request.GetResponse(), HttpWebResponse)
If response.StatusCode = HttpStatusCode.OK OrElse
response.StatusCode = HttpStatusCode.Created OrElse
response.StatusCode = HttpStatusCode.NoContent Then
Return True
End If
_lastError = "Public key registration failed: " & response.StatusCode.ToString()
Return False
End Using
Catch ex As WebException
If ex.Response IsNot Nothing Then
Try
Using response = CType(ex.Response, HttpWebResponse)
Dim responseBody As String = ""
Using reader As New StreamReader(response.GetResponseStream())
responseBody = reader.ReadToEnd()
End Using
_lastError = "Public key registration failed: HTTP " &
CInt(response.StatusCode).ToString() & " " &
response.StatusDescription & " - " & responseBody
End Using
Catch
_lastError = ex.Message
End Try
Else
_lastError = ex.Message
End If
Return False
Catch ex As Exception
_lastError = ex.Message
Return False
End Try
End Function
''' <summary>
''' Starts a local TCP listener that forwards incoming traffic to the remote WSS server.
''' </summary>
Private Function StartLocalWssTunnel(
ByVal remoteWssUrl As String,
ByVal localPort As Integer,
ByVal bearerToken As String
) As Boolean
Try
If String.IsNullOrWhiteSpace(remoteWssUrl) Then
_lastError = "Remote WSS URL Is empty"
Return False
End If
If Not remoteWssUrl.StartsWith("ws://", StringComparison.OrdinalIgnoreCase) AndAlso
Not remoteWssUrl.StartsWith("wss://", StringComparison.OrdinalIgnoreCase) Then
_lastError = "Remote URL must start with ws:// or wss://"
Return False
End If
If localPort <= 0 OrElse localPort > 65535 Then
_lastError = "Invalid local tunnel port"
Return False
End If
_remoteWssUrl = remoteWssUrl
_bearerToken = bearerToken
_cts = New CancellationTokenSource()
_listener = New TcpListener(IPAddress.Loopback, localPort)
_listener.Start()
_acceptTask = Task.Run(Function() AcceptLoopAsync(_cts.Token))
Return True
Catch ex As Exception
_lastError = ex.Message
Return False
End Try
End Function
''' <summary>
''' Launches ssh.exe with reverse port forwarding enabled.
''' BatchMode disables password prompts, making execution fully non-interactive.
''' </summary>
Private Function LaunchOpenSshReverseTunnel(
ByVal sshServerUser As String,
ByVal reversePortOnServer As Integer,
ByVal clientSshHost As String,
ByVal clientSshPort As Integer,
ByVal localTunnelPort As Integer,
ByVal identityFile As String
) As Boolean
Try
If String.IsNullOrWhiteSpace(sshServerUser) Then
_lastError = "SSH server user Is empty"
Return False
End If
If reversePortOnServer <= 0 OrElse reversePortOnServer > 65535 Then
_lastError = "Invalid reverse port On server"
Return False
End If
If clientSshPort <= 0 OrElse clientSshPort > 65535 Then
_lastError = "Invalid client SSH port"
Return False
End If
If localTunnelPort <= 0 OrElse localTunnelPort > 65535 Then
_lastError = "Invalid local tunnel port"
Return False
End If
If String.IsNullOrWhiteSpace(identityFile) OrElse Not File.Exists(identityFile) Then
_lastError = "SSH identity file was Not found"
Return False
End If
Dim sshPath As String = FindWindowsOpenSsh()
If String.IsNullOrWhiteSpace(sshPath) Then
_lastError = "OpenSSH executable was Not found"
Return False
End If
Dim args As String =
"-N " &
"-o BatchMode=yes " &
"-o ExitOnForwardFailure=yes " &
"-o StrictHostKeyChecking=accept-new " &
"-o ServerAliveInterval=30 " &
"-o ServerAliveCountMax=3 " &
"-p " & localTunnelPort.ToString() & " " &
"-i " & Quote(identityFile) & " " &
"-R " & reversePortOnServer.ToString() & ":" &
clientSshHost & ":" & clientSshPort.ToString() & " " &
Quote(sshServerUser & "@127.0.0.1")
Dim psi As New ProcessStartInfo()
psi.FileName = sshPath
psi.Arguments = args
psi.UseShellExecute = False
psi.CreateNoWindow = True
psi.WindowStyle = ProcessWindowStyle.Hidden
psi.RedirectStandardOutput = True
psi.RedirectStandardError = True
_sshProcess = New Process()
_sshProcess.StartInfo = psi
_sshProcess.EnableRaisingEvents = True
AddHandler _sshProcess.ErrorDataReceived,
Sub(sender, e)
If Not String.IsNullOrWhiteSpace(e.Data) Then
_lastError = "[SSH STDERR] " & e.Data
End If
End Sub
AddHandler _sshProcess.OutputDataReceived,
Sub(sender, e)
If Not String.IsNullOrWhiteSpace(e.Data) Then
_lastError = "[SSH STDOUT] " & e.Data
End If
End Sub
AddHandler _sshProcess.Exited,
Sub(sender, e)
_running = False
End Sub
_sshProcess.Start()
_sshProcess.BeginErrorReadLine()
_sshProcess.BeginOutputReadLine()
Return True
Catch ex As Exception
_lastError = ex.Message
Return False
End Try
End Function
''' <summary>
''' Accepts local TCP clients and creates an independent WebSocket bridge for each one.
''' </summary>
Private Async Function AcceptLoopAsync(ByVal token As CancellationToken) As Task
While Not token.IsCancellationRequested
Try
Dim client As TcpClient =
Await _listener.AcceptTcpClientAsync().ConfigureAwait(False)
_clients.Add(client)
Dim clientTask As Task = HandleClientAsync(client, token)
_clientTasks.Add(clientTask)
Catch ex As ObjectDisposedException
Exit While
Catch ex As Exception
If Not token.IsCancellationRequested Then
_lastError = ex.Message
End If
Exit While
End Try
End While
End Function
''' <summary>
''' Bridges a single TCP client connection to a remote WebSocket connection.
''' </summary>
Private Async Function HandleClientAsync(
ByVal client As TcpClient,
ByVal token As CancellationToken
) As Task
Using client
Using ws As New ClientWebSocket()
Try
If Not String.IsNullOrWhiteSpace(_bearerToken) Then
ws.Options.SetRequestHeader("Authorization", "Bearer " & _bearerToken)
End If
Await ws.ConnectAsync(New Uri(_remoteWssUrl), token).ConfigureAwait(False)
Using stream As NetworkStream = client.GetStream()
Dim t1 As Task = PumpTcpToWebSocketAsync(stream, ws, token)
Dim t2 As Task = PumpWebSocketToTcpAsync(ws, stream, token)
Await Task.WhenAny(t1, t2).ConfigureAwait(False)
End Using
Catch ex As OperationCanceledException
' Expected during shutdown.
Catch ex As Exception
_lastError = ex.Message
Finally
CloseWebSocketQuietly(ws)
End Try
End Using
End Using
End Function
''' <summary>
''' Forwards bytes from the TCP stream to the WebSocket connection.
''' </summary>
Private Async Function PumpTcpToWebSocketAsync(
ByVal stream As NetworkStream,
ByVal ws As ClientWebSocket,
ByVal token As CancellationToken
) As Task
Dim buffer(32767) As Byte
While ws.State = WebSocketState.Open AndAlso Not token.IsCancellationRequested
Dim read As Integer =
Await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(False)
If read <= 0 Then
Exit While
End If
Await ws.SendAsync(
New ArraySegment(Of Byte)(buffer, 0, read),
WebSocketMessageType.Binary,
True,
token
).ConfigureAwait(False)
End While
End Function
''' <summary>
''' Forwards bytes from the WebSocket connection back to the TCP stream.
''' </summary>
Private Async Function PumpWebSocketToTcpAsync(
ByVal ws As ClientWebSocket,
ByVal stream As NetworkStream,
ByVal token As CancellationToken
) As Task
Dim buffer(32767) As Byte
While ws.State = WebSocketState.Open AndAlso Not token.IsCancellationRequested
Dim result As WebSocketReceiveResult =
Await ws.ReceiveAsync(
New ArraySegment(Of Byte)(buffer),
token
).ConfigureAwait(False)
If result.MessageType = WebSocketMessageType.Close Then
Exit While
End If
If result.Count > 0 Then
Await stream.WriteAsync(buffer, 0, result.Count, token).ConfigureAwait(False)
Await stream.FlushAsync(token).ConfigureAwait(False)
End If
End While
End Function
''' <summary>
''' Attempts to close the WebSocket gracefully without throwing shutdown exceptions.
''' </summary>
Private Sub CloseWebSocketQuietly(ByVal ws As ClientWebSocket)
Try
If ws IsNot Nothing AndAlso ws.State = WebSocketState.Open Then
ws.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closing",
CancellationToken.None
).Wait(2000)
End If
Catch
End Try
End Sub
''' <summary>
''' Stops the SSH process, local listener, active clients, and async operations.
''' </summary>
Public Sub StopReverseSsh()
Try
_running = False
If _cts IsNot Nothing Then
Try
_cts.Cancel()
Catch
End Try
End If
If _sshProcess IsNot Nothing Then
Try
If Not _sshProcess.HasExited Then
_sshProcess.Kill()
End If
Catch
End Try
Try
_sshProcess.Dispose()
Catch
End Try
_sshProcess = Nothing
End If
If _listener IsNot Nothing Then
Try
_listener.Stop()
Catch
End Try
_listener = Nothing
End If
For Each c As TcpClient In _clients
Try
c.Close()
Catch
End Try
Next
Catch ex As Exception
_lastError = ex.Message
End Try
End Sub
''' <summary>
''' Returns whether the tunnel is currently running.
''' </summary>
Public Function IsRunning() As Boolean
Return _running
End Function
''' <summary>
''' Returns the last captured error message.
''' </summary>
Public Function LastError() As String
Return _lastError
End Function
''' <summary>
''' Returns the OpenSSH executable name.
''' Windows resolves ssh.exe from PATH if OpenSSH Client is installed.
''' </summary>
Private Function FindWindowsOpenSsh() As String
Dim windowsSsh As String =
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.Windows),
"System32\OpenSSH\ssh.exe"
)
If File.Exists(windowsSsh) Then
Return windowsSsh
End If
Return "ssh.exe"
End Function
''' <summary>
''' Quotes a command-line argument and escapes embedded quotation marks.
''' </summary>
Private Function Quote(ByVal v As String) As String
If v Is Nothing Then
Return """"""
End If
Return """" & v.Replace("""", """""") & """"
End Function
''' <summary>
''' Disposes the tunnel and releases all related resources.
''' </summary>
Public Sub Dispose() Implements IDisposable.Dispose
StopReverseSsh()
If _acceptTask IsNot Nothing Then
Try
_acceptTask.Wait(2000)
Catch
End Try
End If
If _cts IsNot Nothing Then
Try
_cts.Dispose()
Catch
End Try
_cts = Nothing
End If
End Sub
End Class
La configuración del proyecto SshReverseTunnel.vbproj
Código:
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<!-- CONFIG -->
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">x64</Platform>
<ProjectGuid>{4A7328F2-CD36-473F-849A-12C3CE13DCD7}</ProjectGuid>
<OutputType>Library</OutputType>
<RootNamespace>SshReverseTunnel</RootNamespace>
<AssemblyName>SshReverseTunnel</AssemblyName>
<FileAlignment>512</FileAlignment>
<MyType>Windows</MyType>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<Deterministic>true</Deterministic>
<RegisterForComInterop>true</RegisterForComInterop>
</PropertyGroup>
<!-- DEBUG -->
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x64' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<DefineDebug>true</DefineDebug>
<DefineTrace>true</DefineTrace>
<OutputPath>bin\Debug\</OutputPath>
<DocumentationFile>SshReverseTunnel.xml</DocumentationFile>
<PlatformTarget>x64</PlatformTarget>
<RegisterForComInterop>true</RegisterForComInterop>
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
</PropertyGroup>
<!-- RELEASE -->
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x64' ">
<DebugType>pdbonly</DebugType>
<DefineDebug>false</DefineDebug>
<DefineTrace>true</DefineTrace>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DocumentationFile>SshReverseTunnel.xml</DocumentationFile>
<PlatformTarget>x64</PlatformTarget>
<RegisterForComInterop>true</RegisterForComInterop>
<NoWarn>42016,41999,42017,42018,42019,42032,42036,42020,42021,42022</NoWarn>
</PropertyGroup>
<!-- VB OPTIONS -->
<PropertyGroup>
<OptionExplicit>On</OptionExplicit>
<OptionCompare>Binary</OptionCompare>
<OptionStrict>Off</OptionStrict>
<OptionInfer>On</OptionInfer>
</PropertyGroup>
<!-- REFERENCES -->
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Net.Http" />
</ItemGroup>
<!-- IMPORTS -->
<ItemGroup>
<Import Include="Microsoft.VisualBasic" />
<Import Include="System" />
<Import Include="System.Collections" />
<Import Include="System.Collections.Generic" />
<Import Include="System.Data" />
<Import Include="System.Diagnostics" />
<Import Include="System.Linq" />
<Import Include="System.Xml.Linq" />
<Import Include="System.Threading.Tasks" />
</ItemGroup>
<!-- CODE -->
<ItemGroup>
<Compile Include="SshReverseTunnel.vb" />
<Compile Include="My Project\AssemblyInfo.vb" />
<Compile Include="My Project\Application.Designer.vb">
<AutoGen>True</AutoGen>
<DependentUpon>Application.myapp</DependentUpon>
<DesignTime>True</DesignTime>
</Compile>
<Compile Include="My Project\Resources.Designer.vb">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Include="My Project\Settings.Designer.vb">
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
<DesignTimeSharedInput>True</DesignTimeSharedInput>
</Compile>
</ItemGroup>
<!-- RESOURCES -->
<ItemGroup>
<EmbeddedResource Include="My Project\Resources.resx">
<Generator>VbMyResourcesResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.vb</LastGenOutput>
<CustomToolNamespace>My.Resources</CustomToolNamespace>
<SubType>Designer</SubType>
</EmbeddedResource>
</ItemGroup>
<!-- OTHER -->
<ItemGroup>
<None Include="My Project\Application.myapp">
<Generator>MyApplicationCodeGenerator</Generator>
<LastGenOutput>Application.Designer.vb</LastGenOutput>
</None>
<None Include="My Project\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<CustomToolNamespace>My</CustomToolNamespace>
<LastGenOutput>Settings.Designer.vb</LastGenOutput>
</None>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.VisualBasic.targets" />
</Project>



