TCP forward сървър

Светлин Наков

www.nakov.com

 

Вече знаем как да разработваме многопотребителски TCP сървъри. Сега ще си поставим малко по-сложна задача – разработка на сървър за препращане на трафика от един TCP порт към друг TCP порт на друга машина по прозрачен за потребителя начин. Такъв софтуер се нарича bridge на транспортно ниво.

Какво всъщност прави един TCP forward сървър

Представете си, че имаме локална мрежа с локални IP адреси 192.168.0.*, която е свързана с Интернет през една машина с реален IP адрес от Интернет (статичен IP адрес), да кажем 212.50.1.1. От Интернет се вижда само една машина от цялата мрежа – машината 212.50.1.1, а всички останали машини от мрежата не са достъпни, защото нямат реален IP адрес в Интернет. Искаме да пуснем някакъв TCP сървър (някаква услуга), да кажем на порт 80 на някоя машина от локалната мрежа, да кажем 192.168.0.12 и искаме тази услуга да е достъпна от Интернет. Ако просто стартираме TCP сървъра, услугата ще е достъпна само за потребителите на локалната мрежа.

Има няколко варианта да накараме услугата да е достъпна и от Интернет. Най-лесният от тях е да си осигурим реален IP адрес за машината, на която работи сървъра, но това не винаги е възможно и може да изисква допълнителни разходи.

Друг вариант е да се направи т. нар. port forwarding (препращане на порт) на някой порт от машината 212.50.1.1 към някой порт на машината 192.168.0.12. Целта е всеки, който се свърже към 212.50.1.1 на даден порт за препращане да получава на практика връзка към 192.168.0.12 на порт 80. Има различни програми, които извършват препращане на порт, някои от които се разпространяват стандартно с мрежовия софтуер на операционната система.

Нашата цел е да напишем програма на Java, която извършва TCP port forwarding.

Примерен TCP forward сървър

Нашият сървър трябва да слуша на даден TCP порт и при свързване на клиент да отваря сокет към дадена машина на даден порт (сървъра) и да осигурява препращане на всичко идващо от клиента към сървъра, а всичко, идващо от сървъра към клиента. При прекъсване на връзката с клиента трябва да се прекъсне и връзката със сървъра и обратното – при прекъсване на връзката със сървъра трябва да се прекъсне и връзката с клиента. Трябва да се поддържа обслужване на много потребители едновременно и независимо един от друг. Ето една примерна реализация на такъв TCP forward сървър:

TCPForwardServer.java
import java.io.*; 
import java.net.*; 
 
/** 
 * TCPForwardServer is a simple TCP bridging software that 
 * allows a TCP port on some host to be transparently forwarded 
 * to some other TCP port on some other host. TCPForwardServer 
 * continuously accepts client connections on the listening TCP 
 * port (source port) and starts a thread (ClientThread) that 
 * connects to the destination host and starts forwarding the 
 * data between the client socket and destination socket. 
 */ 
public class TCPForwardServer { 
    public static final int SOURCE_PORT = 2525; 
    public static final String DESTINATION_HOST = "mail.abv.bg"; 
    public static final int DESTINATION_PORT = 25; 
 
    public static void main(String[] args) throws IOException { 
        ServerSocket serverSocket = 
            new ServerSocket(SOURCE_PORT); 
        while (true) { 
            Socket clientSocket = serverSocket.accept(); 
            ClientThread clientThread = 
                new ClientThread(clientSocket); 
            clientThread.start(); 
        } 
    } 
} 
 
/** 
 * ClientThread is responsible for starting forwarding between 
 * the client and the server. It keeps track of the client and 
 * servers sockets that are both closed on input/output error 
 * durinf the forwarding. The forwarding is bidirectional and 
 * is performed by two ForwardThread instances. 
 */ 
class ClientThread extends Thread { 
    private Socket mClientSocket; 
    private Socket mServerSocket; 
    private boolean mForwardingActive = false; 
 
    public ClientThread(Socket aClientSocket) { 
        mClientSocket = aClientSocket; 
    } 
 
    /** 
     * Establishes connection to the destination server and 
     * starts bidirectional forwarding ot data between the 
     * client and the server. 
     */ 
    public void run() { 
        InputStream clientIn; 
        OutputStream clientOut; 
        InputStream serverIn; 
        OutputStream serverOut; 
        try { 
            // Connect to the destination server 
            mServerSocket = new Socket( 
                TCPForwardServer.DESTINATION_HOST, 
                TCPForwardServer.DESTINATION_PORT); 
 
            // Obtain client & server input & output streams 
            clientIn = mClientSocket.getInputStream(); 
            clientOut = mClientSocket.getOutputStream(); 
            serverIn = mServerSocket.getInputStream(); 
            serverOut = mServerSocket.getOutputStream(); 
        } catch (IOException ioe) { 
            System.err.println("Can not connect to " + 
                TCPForwardServer.DESTINATION_HOST + ":" + 
                TCPForwardServer.DESTINATION_PORT); 
            connectionBroken(); 
            return; 
        } 
 
        // Start forwarding data between server and client 
        mForwardingActive = true; 
        ForwardThread clientForward = 
            new ForwardThread(this, clientIn, serverOut); 
        clientForward.start(); 
        ForwardThread serverForward = 
            new ForwardThread(this, serverIn, clientOut); 
        serverForward.start(); 
 
        System.out.println("TCP Forwarding " + 
            mClientSocket.getInetAddress().getHostAddress() + 
            ":" + mClientSocket.getPort() + " <--> " + 
            mServerSocket.getInetAddress().getHostAddress() + 
            ":" + mServerSocket.getPort() + " started."); 
    } 
 
    /** 
     * Called by some of the forwarding threads to indicate 
     * that its socket connection is brokean and both client 
     * and server sockets should be closed. Closing the client 
     * and server sockets causes all threads blocked on reading 
     * or writing to these sockets to get an exception and to 
     * finish their execution. 
     */ 
    public synchronized void connectionBroken() { 
        try { 
            mServerSocket.close(); 
        } catch (Exception e) {} 
        try { 
            mClientSocket.close(); } 
        catch (Exception e) {} 
 
 
        if (mForwardingActive) { 
            System.out.println("TCP Forwarding " + 
                mClientSocket.getInetAddress().getHostAddress() 
                + ":" + mClientSocket.getPort() + " <--> " + 
                mServerSocket.getInetAddress().getHostAddress() 
                + ":" + mServerSocket.getPort() + " stopped."); 
            mForwardingActive = false; 
        } 
    } 
} 
 
/** 
 * ForwardThread handles the TCP forwarding between a socket 
 * input stream (source) and a socket output stream (dest). 
 * It reads the input stream and forwards everything to the 
 * output stream. If some of the streams fails, the forwarding 
 * stops and the parent is notified to close all its sockets. 
 */ 
class ForwardThread extends Thread { 
    private static final int BUFFER_SIZE = 8192; 
 
    InputStream mInputStream = null; 
    OutputStream mOutputStream = null; 
    ClientThread mParent = null; 
 
    /** 
     * Creates a new traffic redirection thread specifying 
     * its parent, input stream and output stream. 
     */ 
    public ForwardThread(ClientThread aParent, InputStream 
            aInputStream, OutputStream aOutputStream) { 
        mParent = aParent; 
        mInputStream = aInputStream; 
        mOutputStream = aOutputStream; 
    } 
 
    /** 
     * Runs the thread. Continuously reads the input stream and 
     * writes the read data to the output stream. If reading or 
     * writing fail, exits the thread and notifies the parent 
     * about the failure. 
     */ 
    public void run() { 
        byte[] buffer = new byte[BUFFER_SIZE]; 
        try { 
            while (true) { 
                int bytesRead = mInputStream.read(buffer); 
                if (bytesRead == -1) 
                    break; // End of stream is reached --> exit 
                mOutputStream.write(buffer, 0, bytesRead); 
                mOutputStream.flush(); 
            } 
        } catch (IOException e) { 
            // Read/write failed --> connection is broken 
        } 
 
        // Notify parent thread that the connection is broken 
        mParent.connectionBroken(); 
    } 
}

Как работи примерният TCP forward сървър

Сървърът се състои от няколко класа, които видими от диаграмата:

Главната програма е доста проста. Тя слуша постоянно за идващи заявки на TCP порт 2525 и при свързване на нов клиент създава нишка от класа ClientThread, подава й сокета, създаден за този клиент и стартира нишката.

Класът ClientThread се опитва да се свържи към сървъра (в случая това е хоста mail.abv.bg на порт 25, където върви стандартната услуга за изпращане на поща по протокол SMTP). При успешно свързване към сървъра се създават още две нишки ForwardThread. Едната нишка транспортира всичко получено от сокета на клиента към сокета на сървъра, а другата нишка транспортира всичко получено от сокета на сървъра към клиента. При неуспешно свързване към сървъра сокетът на клиента се затваря.

Нишката ForwardThread не е сложна. тя се създава по два потока – един входен и един изходен. Всичко, което тя прави, е да чете от входния поток и да пише в изходния поток. При достигане на края на входния поток или при възникване на входно-изходна грешка се извиква специален метод на ClientThread класа, с който се спира препращането на трафика между клиента и сървъра и нишката завършва изпълнението си.

Транспортирането на информация се извършва на бинарно ниво, защото това е единственият правилен начин. Ако се използваха текстови потоци, щеше да има проблеми ако клиентът и сървърът използват бинарен протокол.

ClientThread нишката съществува само докато се свърже към сървъра и стартира препращащите нишки (ForwardThread), след което завършва изпълнението си.

Ако в някоя от препращащите нишки се установи проблем с препращането, това означава, че се е прекъснала връзката съответно или с клиента или със сървъра. В такъв случай и двете нишки за препращане на данни между клиента и сървъра трябва да завършат. Това се осигурява чрез затваряне на двата сокета, по които върви комуникацията. Затварянето на активен сокет предизвиква изключение в нишката, която е блокирала по операцията четене или писане в този сокет, което осигурява прекъсване на изпълнението и на другата препращащата нишка, която все още е активна.

На практика ако сървърът затвори сокета, се затваря и сокета на клиента и всички нишки, свързани с обслужването на този клиент прекратяват изпълнението си. Ако клиентът затвори сокета, се затваря и сокета към сървъра и също всички нишки, свързани с този клиент приключват.

TCP forward сървърът в действие

Ето какъв изход би могъл да се получи ако при активен TCPForwardServer се свържем към него на порт 2525 и напишем няколко команди към SMTP сървъра:

telnet localhost 2525
220 abv.bg ESMTP
HELO
250 abv.bg
HELP
214 netqmail home page: http://qmail.org/netqmail
QUIT
221 abv.bg
 
Connection to host lost.

Ето и изходът на конзолата на сървъра след изпълнението на горните команди:

TCP Forwarding 127.0.0.1:4184 <--> 194.153.145.80:25 started.
TCP Forwarding 127.0.0.1:4184 <--> 194.153.145.80:25 stopped.

Няма никаква съществена разлика дали се свързваме директно към mail.abv.bg на порт 25 или към localhost на порт 2525. Това беше и целта на TCP forward сървъра – да осигури прозрачно препращане на някой TCP порт.

Има само един малък проблем. Ако mail.abv.bg по някаква причина не работи вместо да се получи съобщение за отказана връзка:

telnet mail.abv.bg 25
Connecting To mail.abv.bg...Could not open connection to the host, on port 25: Connect failed

се осъществява успешно свързване към localhost:2525, след което сокетът се затваря. Правилното поведение би било въобще да се откаже свързване към TCP forward сървъра.

Проблемът идва от това, че нашият сървър винаги приема клиентски заявки независимо дали сървърът е готов и може също да приема клиентски заявки. При по-добрите port forward сървъри нямат такъв дефект, но те обикновено работят на по-ниско ниво. Този дефект може да се преодолее чрез използване на асинхронни сокети, които се поддържат в Java от версия 1.4, но ние няма да се занимаваме с това.