Reminder
In this series I demonstrate how to create on demand SSH tunnel to connect to service in another network directly inside your code. I will be referring to theory described in the previous post, so please make sure you read it first.
Preparation
We will be using SSH::LibSSH module that connects to system libssh
C library through built-in NativeCall mechanism. Install Raku module by invoking zef install SSH::LibSSH
. As for libssh
it is very likely you have it already installed if you use any major Linux distribution.
Boilerplate
Let's start by including required module and setting necessary variables.
use SSH::LibSSH;
my Str $service-host = 'service';
my Int $service-port = 7878;
my Str $jump-host = 'jump';
my Int $jump-port = 22;
my Str $jump-user = 'me';
my Str $jump-private-key-file = '/home/me/.ssh/jump';
my Str $jump-private-key-password = 's3cret!';
my Str $local-host = '127.0.0.1';
my Int $local-port = 8080;
Note is that $service-host
is not resolvable or reachable from local network but must be resolvable and reachable from jump host.
Raku is gradually typed, so you can skip type annotations if you like but they are super useful to detect errors in compile time.
As mentioned in previous post - $jump-private-key-password
should never be stored in code directly, this is for demonstration purposes only. You can have passwordless SSH key, in such case just skip all key-related variables from following code.
Connect to jump host
my $jump-connection = await SSH::LibSSH.connect(
host => $jump-host, port => $jump-port, timeout => 4,
user => $jump-user,
private-key-file => $jump-private-key-file,
private-key-file-password => $jump-private-key-password
);
To confirm jump host authenticity you can provide 3 additional parameters: on-server-unknown
, on-server-known-changed
and on-server-found-other
. Their value should be a subroutine accepting one $handler
parameter. Jump host hash will be available at $handler.hash
and you have $handler.accept-this-time()
or $handler.decline()
methods to decide if you want to proceed. Default implementations are provided.
Open local listener
There is one cool trick here - if you do not know free local port up front you can provide 0
and system will assign one for you.
my $local-server = IO::Socket::Async.listen($local-host, $local-port);
my $local-listener = $local-server.tap(
... # TODO
);
await $local-listener.socket-port;
First we should open asynchronous socket that acts as a Supply of incoming connections, then tap it. Tap is just a subscription to a Supply and we will implement its guts in the next step.
But beware! Having local port listener does not mean we can immediately connect to this port, because those actions are asynchronous. That is what await $local-listener.socket-port
part is for. It is a Promise that will be kept when port is actually opened. Promises are thread safe thingies that can be kept or broken once per their lifetime and can carry more info than just boolean state: if you used local port 0
that is how you will receive system assigned local port number - just check value kept by this Promise as $local-listener.socket-port.result
.
Open channel from jump to service
my $local-listener = $local-server.tap(
my $local-listener = $local-server.tap(
-> $local-connection {
my $jump-channel = await $jump-connection.forward(
$service-host, $service-port,
$local-host, $local-port
);
...
}
);
Back to tap implementation. It expects closure with one parameter, which will be incoming connection to local. When we receive such connection we must ask jump host to create SSH channel that will pass TCP packets to service host.
Create bidirectional data flow between local connection and SSH channel
my $local-listener = $local-server.tap(
my $local-listener = $local-server.tap(
-> $local-connection {
my $jump-channel = ...
react {
whenever $local-connection.Supply(:bin) {
$jump-channel.write($_);
LAST $jump-channel.close();
}
whenever $jump-channel.Supply(:bin) {
$local-connection.write($_);
LAST $local-connection.close();
}
}
}
);
To establish bidirectional data exchange we can use reactive programming. Both local connection and jump channel have Supplies that will give us incoming data. All we need to do it pass data from local connection to jump channel and from jump channel to local connection.
LAST phaser is used to gracefully close corresponding side when one of the Supplies closes. In happy path it is local connection that will be closing SSH channel.
To capture any errors you can use QUIT phasers.
Done
To recap here is whole code with marked spot where you can implement your own logic that requires connection to inaccessible service host but can now be achieved by connecting to local host that forwards it to service host through jump host.
use SSH::LibSSH;
my Str $service-host = 'service';
my Int $service-port = 7878;
my Str $jump-host = 'jump';
my Int $jump-port = 22;
my Str $jump-user = 'me';
my Str $jump-private-key-file = '/home/me/.ssh/jump';
my Str $jump-private-key-password = 's3cret!';
my Str $local-host = '127.0.0.1';
my Int $local-port = 8080;
my $jump-connection = await SSH::LibSSH.connect(
host => $jump-host, port => $jump-port, timeout => 4,
user => $jump-user,
private-key-file => $jump-private-key-file,
private-key-file-password => $jump-private-key-password
);
my $local-server = IO::Socket::Async.listen($local-host, $local-port);
my $local-listener = $local-server.tap(
-> $local-connection {
my $jump-channel = await $jump-connection.forward(
$service-host, $service-port,
$local-host, $local-port
);
react {
whenever $local-connection.Supply(:bin) {
$jump-channel.write($_);
LAST $jump-channel.close();
}
whenever $jump-channel.Supply(:bin) {
$local-connection.write($_);
LAST $local-connection.close();
}
}
}
);
await $local-listener.socket-port;
printf "Connect to %s:%d to actually connect to %s:%d.\n",
$local-host, $local-listener.socket-port.result,
$service-host, $service-port;
# your logic that requires service host goes here
# gracefully close jump SSH channel and connection when done
$local-listener.close();
$jump-connection.close();
When using this flow make sure you truly closed local connection first before closing local listener and jump connection. Common mistake is forgetting for example that HTTP modules with keep-alive capability will keep connection open in background.
Alternatives
- SSH::LibSSH::Tunnel module implements exactly the same solution but in different manner - it uses separate thread and single react block wrapping whole logic under the hood.
Top comments (0)