2
Apr

SSHAPI is supplied with a number of examples to help get you started. The source for the examples is supplied with all distributions.

Preparing the examples

First off, you should download the binary distribution, and extract it somewhere. The following example assumes you have downloaded the archive to your home directory, and will be installing it there also.

On Unix/Linux/OSX
$ cd
$ tar xzf sshapi-0.9.5.tar.gz
On Windows

Use your preferred tool to extract the Zip file to where ever your %HOME% directory is.

Adding the providers

Now you need your provider libraries. We currently have providers for the following libraries:

Download your chosen libraries, In our example, we will use J2SSH.

On Unix/Linux/OSX
mkdir -p $HOME/.sshapi/providers
cp $HOME/j2ssh*.jar $HOME/sshapi-0.9.5
On Windows

We will assume you have downloaded and extracted the J2SSH archive to C:\TEMP directory (and so the Jars should be in C:\TEMP\j2ssh)

MD %HOME%\.sshapi
MD %HOME%\.sshapi\providers
COPY C:\TEMP\j2ssh\j2ssh*.jar %HOME%\sshapi-0.9.5

Running the examples

Now run one of the example applications.

Note that because all of the SSHAPI jars contain the ClassPath MANIFEST.MF, and they all exist in the same directory, you only need to add the examples jar itself. You do however have to add all jars for the provider.

For other environments, e.g. running the examples from an IDE, you may have to manually include the core, provider bridge and provider libraries yourself.

On Unix/Linux/OSX
cd sshapi-0.9.5
java -classpath sshapi-examples-0.9.5.jar:j2ssh-core-0.2.9.jar:j2ssh-common-0.2.9.jar E01Shell
On Windows

We will assume you have downloaded and extracted the J2SSH archive to C:\TEMP directory (and so the Jars should be in C:\TEMP\j2ssh)

CD sshapi-0.9.5
java -classpath sshapi-examples-0.9.5.jar;j2ssh-core-0.2.9.jar;j2ssh-common-0.2.9.jar E01Shell
Category : SSH API
2
Apr

Maverick SSHD uses commons logging, you can enable it using a tool such as log4j.

If you are using version 1.4.x ensure you are using maverick-sshd-debug.jar instead of maverick-sshd.jar. For 1.5.x versions you can continue to use maverick-sshd.jar.

Again, if you are using version 1.4.x place the following statement in your code before you use the API to ensure events are logged to commons logging. This is not required for 1.5.x versions.

DaemonContext.addEventListener(new SSHDLoggingListener());

To configure log4j we use

org.apache.log4j.PropertyConfigurator.configure("log4j.properties");

with log4j.properties file containing

# Set root category priority to INFO and its only appender to CONSOLE.
log4j.rootCategory=DEBUG,CONSOLE,LOGFILE

# CONSOLE is set to be a ConsoleAppender using a PatternLayout.
log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
log4j.appender.CONSOLE.Threshold=DEBUG
log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
log4j.appender.CONSOLE.layout.ConversionPattern=%d{dd MMM yyyy HH:mm:ss,SSS} [%t] %-5p %c{1} %x - %m%n

# LOGFILE is set to be a File appender using a PatternLayout.
log4j.appender.LOGFILE=org.apache.log4j.FileAppender
log4j.appender.LOGFILE.File=maverick.log
log4j.appender.LOGFILE.Append=true
log4j.appender.LOGFILE.Threshold=INFO
log4j.appender.LOGFILE.layout=org.apache.log4j.PatternLayout
log4j.appender.LOGFILE.layout.ConversionPattern=%d{dd MMM yyyy HH:mm:ss,SSS} [%t] %-5p %c{1} %x - %m%n
Category : Maverick SSHD
20
Jan

The Maverick SSHD API enables the development of robust, high performance SSH-enabled server applications. This feature-rich API can be used to custom-build a server to suit each user or organisations specific security-related needs.

This tutorial sets the foundation by creating an SSH Server with basic SFTP file transfer capabilities. At the end of this article the reader should have a fully working SSH Server running and should be able to use any SSH client to instantly connect and begin transferring files securely.

How It Works

Establishing The Framework
The first step in creating a Maverick SSH Server is to establish the SSH framework; authentication mechanisms, communications mechanisms, protocol requirements and all neccsary resourcing control. This is a very complex and time consuming process with the developer required to have first-hand knowledge of the SSH protocol and additional knowlege in topics as cryptography. However, with the Maverick SSHD API all this hard work has been done for us, encapsulated within a single abstract Daemon class, the starting point of this article.

To begin taking advantage of all this functionality that sits under the hood of this class all we need to do is extend it.

public class ExampleSSHD extends Daemon {

The Damon class performs all the core processing of the server instance from servicing incoming requests, securing communication channels to authentication and data encryption. The complex communication framework of the SSH daemon is based on the Java NIO socket framework, a high performance non-blocking I/O architecture that enables the server to service communication requests asynchronously. This sets the foundation for the high performance, efficient communications framework incorporated within the server API.

Configuration
As part of extending the Daemon class one is expected to implement the configure method. During startup of the service the server looks at this configuration method for details on how the server is to be set up.

protected void configure(ConfigurationContext context) throws IOException {

This method receives a ConfigurationContext that stores all the settings for the server instance. The minimal requirements that need to be configured are: Server Host Key, Authentication Scheme and for this example a Filesystem. There are many parameters that can be set and adjusted which all allow for a very flexible architecture and enables a developer to build a server to suit his or her requirements.

Host Verification
The first thing we define in our server is the host key in this example we have used RSA and DSA host keys.

When a client connects to the server instance the instance returns its’ public key the connecting client verifies this by trying to identify it within its list of known hosts. If it is aware of this server then communication can proceed however, if the host is not recognised the user is prompted to whether he wishes to add this unknown host to its list of known hosts.

This loading and generating of keys is performed by the protected void loadOrGenerateHostKey(java.io.File key, java.lang.String type, int bitlength, ConfigurationContext context) method, the first time it is called the neccesary host key file is generated, but afterwards subsequent calls, for example when the server is restarted, this method will simply load the already generated key.

// Add an RSA key and preferably a DSA key as well to avoid connection
// errors with clients that only support one key type (F-Secure 4.3 for example)
try {
    loadOrGenerateHostKey(new File("ssh_host_rsa_key"),
           SshKeyPairGenerator.SSH2_RSA, 1024, context);
    loadOrGenerateHostKey(new File("ssh_host_dsa_key"),
           SshKeyPairGenerator.SSH2_DSA, 1024, context);
} catch(InvalidPassphraseException ex) { }

RSA and DSA are cryptographic algorithms used by the SSH Daemon in authenticating itself to the client.
RSA is a public key cipher which can be used both for encrypting messages and making digital signatures, the letters stand for the names of the inventors: Rivest, Shamir and Adleman and is patented by RSA technologies. Digital Signature Algorithm is a public key algorithm that is used as part of the Digital Signature Standard (DSS). DSA was developed by the US National Security Agency but unlike RSA it cannot be used for encryption, only for digital signature authentication.

Listening interfaces
Next we specify which interfaces and ports the server should listen on for any clients that wish to communicate. TheaddListeningInterface method allows interface and associated port number to be specified.

// Listen on multiple ports on all interfaces
context.addListeningInterface("0.0.0.0", 22);
context.addListeningInterface("0.0.0.0", 10022);

In the above snippet we have defined all interfaces be checked on ports 22 and 10022. If one only had one network interface then the above statement would be equivalent to writing:

context.addListeningInterface("127.0.0.1", 22); 
context.addListeningInterface("127.0.0.1", 10022);
context.addListeningInterface("192.168.1.123", 22);
context.addListeningInterface("192.168.1.123", 10022);

Authentication Banner
The setBannerMessage method allows the setting of a simply message that is broadcast to any client that connects to the server. In this example the server message that will be broadcast details how to connect to the server.

// Setup an authentication banner
context.setBannerMessage("Use the username 'lee' to login with any password.");

Authentication Provider
The next thing we need to do is to implement the authentication mechanism that will be used by the instance. Since we will be allowing any requesting client to connect the instance needs to make sure that the connecting user is a valid user and is allowed to use the server. An authentication provider must extend and implement the required methods as defined by theNativeAuthenticationProvider. The methods and their purpose are detailed within the javadocs. However one of the methods that is worth mention is public boolean logonUser(byte[] sessionid, String username, SocketAddress ipAddress).

All valid users that are allowed to log into the system are defined within this method and in addition any neccesary password authentication must also carried out in this method. The extract below is take from the ExampleAuthenticationProvider.java an example implementation authentication provider that can be found within the examples folder.

public boolean logonUser(byte[] sessionid, String username, String password, SocketAddress ipAddress) throws com.maverick.sshd.platform.PasswordChangeException {
   if(username.equals("lee")) return true;
   if(username.equals("admin")) throw new PasswordChangeException();
   return false;
}

As can be seen the method defines the user ‘lee’ as the valid user. When an authentication request is received from the client the server looks up the authentication method name, for example “password” from the ConfigurationContext. The logon method is then expected to validate the password by whichever means necessary either implementing an authentication class asPasswordAuthentication or relying on inbuilt mechanisms such as calling ActiveDirectory APIs etc. This code is specific to each users requirement and needs to be handled appropriately since in our example no password will be checked we have no password processing code.

The ConfigurationConext::setAuthenticationProvider(java.lang.Class authenticationProvider) takes as its argument a class object as oppose to the convential class instance so that the server can independently manage instances of this class.

// Setup the authentication provider
context.setAuthenticationProvider(ExampleAuthenticationProvider.class);

As the above code highlights for the sake of this example we have utilised the implemented Authentication Provider supplied as part of the API examples.

VirtualFileSystem
In order for our server to support SFTP the ConfigurationContext needs to be loaded with a file system. The Maverick SSHD API comes with an interface NativeFileSystemProvider that needs to be implemented to carry out necessary file system protocols. The interface requires implementation to a number of methods including closeFilesystem() getDefaultPath() makeDirectory() etc all the necessary commands required in a file system.

Unfortunately implementing one of these is beyond the scope of this article though the API comes with a ready to use file system,VirtualFileSystem.class, that implements the basic IO operations needed, via java.io.File, for any basic file system at a level to perform basic SFTP functionality an apt class for us to use in our example server.

// Tell the SSHD which file system were using
context.setFileSystemProvider(VirtualFileSystem.class);

As the code above shows once again we simply supply this class as a parameter to the ConfigurationContext instance. The only thing we need to do for this file system to work us define our mount points.

Mount Points
Mount points are mappings between a name and an associated filesystem location for example, on a windows machine when we typec: the operating system has a link that ties this drive letter to the correct disk drive. In the same way our VirtualFileSystem requires mappings to allow mappings of the root directory and any others we wish to implement. Mounts are created by setting system properties, as a minimum the root file system must be provided, com.maverick.sshd.vfs.VFSRoot defines this directory mount. As the exert below shows, our root directory is C:

// Configure some VirtualFileSystem mounts and the VFS root
System.setProperty("com.maverick.sshd.vfs.VFSRoot", "C:");
System.setProperty("com.maverick.sshd.vfs.VFSMount.1", "/public=P:");
System.setProperty("com.maverick.sshd.vfs.VFSMount.2", "/management=M:");

We also set two additional mounts com.maverick.sshd.vfs.VFSMount.1, com.maverick.sshd.vfs.VFSMount.2 which have been mapped to my personal filesystem; P: (my public drive) and M: (my management drive), these should be changed to your own directories or removed altogether.

NOTE:The setting of attributes is NOT supported within the virtual file system and by default users will have access to all files and folders. However this functionality can be achieved by implementing VFSPermissionHandler but this is beyond the scope of this article.

Thats all there is to it!

Main
Our server is now ready and configured all we need to do instantiate the instance and set it running.

public static void main(String[] args) throws Exception {
      //Add your evaluation license here
      ExampleSSHD sshd = new ExampleSSHD();
      sshd.startup();
}

As the exert shows that we must install a valid license where indicated, create an instance of the class and call Daemon::startup().

Communication
Once the server is running user ‘lee’ should be able to securely connect to our SSHD Server. Using any client that supports SFTP we can connect to our server and begin transferring files from the server’s filesystem securely.

Download

An example SSHD server that follows the steps in this article is included in our Maverick SSHD download distribution. Goto http://www.javassh.com/products/maverick-sshd and use the download section on the right hand side. You can also generate an evaluation license there also.

Category : Maverick SSHD
9
Jan

The first thing you will probably want to do is to connect to an SSH server using J2SSH Maverick. This is a fairly straightforward procedure using the SshConnector class. This class creates the initial connection, determines which protocol versions are supported by the server and depending upon which settings have been specified informs the server which protocol version to use for the newly created connection.

import com.maverick.ssh.*;
import com.sshtools.net.*;

public class SshExample {

    public static void main(String args[]) {

        try {
            // Create an SshConnector instance
            SshConnector con = SshConnector.getInstance();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

The next few sections will guide you through making the initial connection, authenticating the user and executing a command or starting the users shell for a simple console based SSH application. First lets look at the SshConnector and the basic connection options available.

The SshConnector class is designed to be configured and reused for all subsequent connections that require the same settings. To create a connection we use the following code

SshClient ssh =
         con.connect(new SocketTransport("cvs", 22), "lee");

 

First lets take a look at the parameters of the connect method. The first parameter provides the transport mechanism for the connection which in this case is a Socket. Maverick has been designed to operate over a transport interface called SshTransport which at its simplist requires just a pair of IO streams. This enables Maverick to operate over any type of current or future communication medium supported by the Java runtime. Notice that we have passed the hostname of the computer we wish to connect to and the port number to the SocketTransport constructor, which in our case is the default SSH port number 22. The second parameter identifies the username for the connection.

Once a connection has been established the method returns an instance of the SshClient interface. This interface provides a protocol independent contract for an SSH client allowing applications to support both versions of the SSH protocol without the need for seperate client libraries. It should be noted that there are many differences between the protocols and some restrictions may apply when using the interface, these will be identified as and when required but for general usage this interface provides a suitable means for SSH protocol independence.

When the client connects to the server, the server supplies its public key for the client to verify. It is good practice to check the supplied key to verify the identity of the server and with Maverick this is possible using the HostKeyVerification interface. You can provide your own implementation of this interface if you require custom handling of the server’s public key however most SSH implementations verify the key against a list of known hosts stored in a special file $HOME/.ssh/known_hosts. Maverick provides an abstract implementation called AbstractKnownHostsKeyVerification to handle the reading of keys from the known_hosts file should you want to prompt the user within your own user interface but for the purposes of this command line example we will use the ConsoleKnownHostsKeyVerification class to prompt the user through System.out.

To configure our SshConnector to use host key verification we need to import the following class:

import com.sshtools.publickey.ConsoleKnownHostsKeyVerification;

And add the following code before we call the SshConnector’s connect method:

con.setKnownHosts(new ConsoleKnownHostsKeyVerification());

Now when we run the example we should be promted in the console to confirm the identity of the server.

The host shell.sourceforge.net is currently unknown to the system
The host key (ssh-dss) fingerprint is: f2:fd:57:3f:b1:f4:90:2d:2d:e8:50:de:99:9c:dd:fe:13:1f:8c:8c
Do you want to allow this host key? [Yes|No|Always]:

Let now take a look at the SshClient class; the code so far has created a connection to the SSH server and returned an instance of SshClient. There are two concrete implementations of this interface, Ssh1Client and Ssh2Client which provide the SSH1 and SSH2 protocol implementations respectivley. The default behaviour for the SshConnector is to select an SSH2 connection where ever possible, this is because SSH1 has known vunerabilities and should only be used when no SSH2 connection is available. If you wish to force the use of a specific version you can configure the SshConnector using the following code before calling the connect method:

// Force the use of the SSH1 protocol

            con.setSupportedVersions(SshConnector.SSH1);

            // Force the use of SSH2 i.e. do not fallback to SSH1
            con.setSupportedVersions(SshConnector.SSH2);

            // Try SSH2 first and fallback to SSH1 if its not available
            con.setSupportedVersions(SshConnector.SSH1 | SshConnector.SSH2);

 

We now have a connection to the SSH server and the next task is to authenticate the user we declared earlier in our connect method. We will not be able to perform any other operation until the user has been authenticated. Password authentication is ideal for first time users as it requires no additional configuration within the SSH client or server. The user simply supplies his username and password to the client which is then transmitted over the encrypted connection to the server. The server then checks that the given password is acceptable to the native password-authentication mechanism of the host operating system and returns the result to the client.

Add the following lines to our example code:

// Create a password authentication object

            PasswordAuthentication pwd = new PasswordAuthentication();

This creates a PasswordAuthentication object and configures it with the users password. We are now ready to authenticate the user.

switch (ssh.authenticate(pwd)) {

            case SshAuthentication.COMPLETE:
                System.out.println("User authenticated"); // We're going to add more code here      break;
            case SshAuthentication.FAILED:
                System.out.println("User authentication failed!");
                break;
            case SshAuthentication.FURTHER_AUTHENTICATION_REQUIRED:
                System.out.println(

                        "Authentication succeeded but the server requires an additional authenication");
                break;
            }

The SshClient’s authenticate method can return a number of different values of which the most common are displayed above. Once the method returns the complete status we can proceed with establishing a session.

Once the user is authenticated you will probably want to do something such as execute a command or start the users shell. The SSH protocol provides multiplexed channels over a single connection and the session channel is one of the channels defined by the SSH protocol. The session channel allows the client to execute a single command on the remote host and to communicate with the process by sending and receiving data. The Maverick SshSession interface provides a common contract between SSH protocol versions and we will use this to execute a basic “ls” command.

The session channel provides a set of IO streams for reading/writing, but before we can do this we need to setup the session for our command. First we open the channel itself by calling the SshClient method:

SshSession session = ssh.openSessionChannel();

Now that we have a session instance we need to configure it for our command, there are several options that can be set before we invoke one of the methods that will start the session.

 

Requesting a Pseudo Terminal
A pseudo terminal is a device that imitates a terminal. Rather than being connected to an actual terminal, a pseudo-terminal (or pty) is connected to a process. If the command you are executing is expecting a terminal (such as a shell command) you can request that a pseudo terminal be attached to the process by calling the requestPseudoTerminal method.

session.requestPseudoTerminal("vt100", 80, 24, 0, 0);

 

Invoking a command
After the above operations have been performed you can then request that the session either start the user’s shell, execute a specific command or start an SSH subsystem (such as SFTP). You should not invoke a subsystem unless you are able to read/write the subsystem protocol, there are many additional utilities within Maverick that provide for the available subsystems.

To start the users default shell use:

session.startShell();

Or to execute a specific command use:

session.executeCommand("ls");

An important note to remember is that this does not execute a shell command. You cannot for instance issue the command executeCommand(“dir”)” on the Windows Operating system as this is a shell command, instead use “cmd.exe /C dir”. This method executes a binary executable and so should be used to execute any program other than the users shell.

Handling Session Data
Once the session has been configured and a command or shell has been started, you can begin to transfer data to and from the remote computer using the sessions IO streams. These streams provide you with a standardized interface for reading and writing the data.

The Session Channel’s OutputStream
The format of writing data varies according to how you configured the session, for example if you executed the users shell then the data should be written as if the user had entered the commands interactively.

// Writing to the session OutputStream

            OutputStream out = session.getOutputStream();
            String cmd = "lsr";
            out.write(cmd.getBytes());

The Session Channel’s InputStream

// Reading from the session InputStream
            InputStream in = session.getInputStream();
            byte buffer[] = new byte[255];
            int read;

            while ((read = in.read(buffer)) > 0) {

                System.out.write(buffer, 0, read);
            }

 

Reading from STDERR
The session also provides the stderr data provided by the remote session. Again an InputStream is provided.

public InputStream getStderrInputStream();

Closing the Session
When executing commands the session is normally closed by the server as soon as the command has completed. If you are reading from the session’s InputStream and the session closes, the stream will return EOF. When starting the users shell, the session will not end until the user quits the terminal by typing the “exit” command however you can close the session at any time by calling the session’s close method

session.close();

Disconnecting
The connection can be terminated by either side. To terminate the connection call the SshClient disconnect method:

ssh.disconnect();

 

So we can now execute a single command on the remote server, but what’s that I hear you say? I want to execute more than one command? Well if you cast your mind back I told you that the SSH protocol provides multiplexed channels over a single connection, so executing another command is as simple as executing the first, just create a new instance of the SessionChannelClient for every command you want to execute. You can execute them simultaneously or one after another, but always create a new session (since the session is closed when the command finishes and the protocol does not allow for re-using of a session to execute another command).

There is a drawback to this in that the process environment is not passed on from one session to another, so you cannot for example execute a command to change directory and then another to execute a script in that directory, since the change directory is lost when the session closes and the new command starts back in the default working directory. Of course you could always put the cd command into the script? Or use the shell to execute both commands. This subject is certainly a bit more advanced so I will leave it for another day and pencil in a new article to discuss all the alternatives for executing multiple commands.

This concludes our getting started tutorial, you should now have a basic working knowledge of how to connect, authenticate and execute commands using J2SSH Maverick.

Category : J2SSH Maverick
8
Jan

There are a couple of options that you can use to configure socket time-outs depending on the version of the API you are using. If you are using a 1.4.x version then a time-out will not be configured by default and its likely requests to the server will wait forever.

In our 1.5.x versions we have some new behaviour to manage time-outs. This behaviour is available in 1.4.28 and above but is not enabled by default.

To configure a time-out in 1.4.x without using new behaviour:

Set a socket timeout on the SshTransport object you create before passing it into the API (SocketTransport extends Socket directly so you can call setSoTimeout as with any other socket).

When you set a socket time-out using setSoTimeout you must be prepared to handle the exceptions that are thrown by the API when a timeout is detected. The current default behaviour of the API does not do anything on timeouts, it simply throws an SshException with SOCKET_TIMEOUT reason.

To configure a time-out in 1.5.x or in versions >= 1.4.28

Set the following options on the Ssh2Context object:

((Ssh2Context)con.getContext(2)).setSocketTimeout(30000);
((Ssh2Context)con.getContext(2)).setIdleConnectionTimeoutSeconds(120);
((Ssh2Context)con.getContext(2)).setSendIgnorePacketOnIdle(true);

Using this method time-outs are handled internally by the API. With the above settings a 30 second socket timeout will be placed on the socket, if a time-out is detected an ignore packet will be sent to the other side to determine if the connection is still alive. Optionally you can instruct the API to disconnect the client after a period of inactivity using setIdleConnectionTimeoutSeconds.

1.5.x has the following default values, thus non-responsive connections will be closed but will not be disconnected if idle and active.

((Ssh2Context)con.getContext(2)).setSocketTimeout(30000);
((Ssh2Context)con.getContext(2)).setIdleConnectionTimeoutSeconds(0);
((Ssh2Context)con.getContext(2)).setSendIgnorePacketOnIdle(true);
Category : J2SSH Maverick
7
Jan

Maverick uses commons logging, you can enable it using a tool such as log4j.

If you are using version 1.4.x ensure you are using maverick-all-debug.jar instead of maverick-all.jar. For 1.5.x versions you can continue to use maverick-all.jar.

Again, if you are using version 1.4.x place the following statement in your code before you use the API to ensure events are logged to commons logging. This is not required for 1.5.x versions.

SshConnector.addEventListener(new J2SSHLoggingListener(false));

To configure log4j we use

org.apache.log4j.PropertyConfigurator.configure("log4j.properties");

with log4j.properties file containing

# Set root category priority to INFO and its only appender to CONSOLE.
log4j.rootCategory=DEBUG,CONSOLE,LOGFILE

# CONSOLE is set to be a ConsoleAppender using a PatternLayout.
log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
log4j.appender.CONSOLE.Threshold=DEBUG
log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
log4j.appender.CONSOLE.layout.ConversionPattern=%d{dd MMM yyyy HH:mm:ss,SSS} [%t] %-5p %c{1} %x - %m%n

# LOGFILE is set to be a File appender using a PatternLayout.
log4j.appender.LOGFILE=org.apache.log4j.FileAppender
log4j.appender.LOGFILE.File=maverick.log
log4j.appender.LOGFILE.Append=true
log4j.appender.LOGFILE.Threshold=INFO
log4j.appender.LOGFILE.layout=org.apache.log4j.PatternLayout
log4j.appender.LOGFILE.layout.ConversionPattern=%d{dd MMM yyyy HH:mm:ss,SSS} [%t] %-5p %c{1} %x - %m%n
Category : J2SSH Maverick
23
Nov

Its been a long time coming but finally the new SSHTOOLS website is here.

I’m sure you really want to know what that “a coffee break for snails” tag line is all about eh? For those not in the know this is our little dedication to the classic O’Reilly book SSH, The Secure Shell: The Definitive Guide.

Category : Uncategorized