/* I2PSOCKSTunnel is released under the terms of the GNU GPL,
 * with an additional exception.  For further details, see the
 * licensing terms in I2PTunnel.java.
 *
 * Copyright (c) 2004 by human
 */
package net.i2p.i2ptunnel.socks;

import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.List;
import java.util.Locale;
import java.util.Properties;

import net.i2p.I2PAppContext;
import net.i2p.I2PException;
import net.i2p.app.ClientApp;
import net.i2p.app.ClientAppManager;
import net.i2p.app.Outproxy;
import net.i2p.client.streaming.I2PSocket;
import net.i2p.client.streaming.I2PSocketOptions;
import net.i2p.data.DataFormatException;
import net.i2p.data.Destination;
import net.i2p.util.HexDump;
import net.i2p.util.Log;

/*
 * Class that manages SOCKS 4/4a connections, and forwards them to
 * destination hosts or (eventually) some outproxy.
 *
 * @author zzz modded from SOCKS5Server
 */
class SOCKS4aServer extends SOCKSServer {

    private boolean setupCompleted;

    /**
     * Create a SOCKS4a server that communicates with the client using
     * the specified socket.  This method should not be invoked
     * directly: new SOCKS4aServer objects should be created by using
     * SOCKSServerFactory.createSOCSKServer().  It is assumed that the
     * SOCKS VER field has been stripped from the input stream of the
     * client socket.
     *
     * @param clientSock client socket
     * @param props non-null
     */
    public SOCKS4aServer(I2PAppContext ctx, Socket clientSock, Properties props) {
        super(ctx, clientSock, props);
    }

    public Socket getClientSocket() throws SOCKSException {
        setupServer();
        return clientSock;
    }

    protected void setupServer() throws SOCKSException {
        if (setupCompleted) { return; }

        DataInputStream in;
        DataOutputStream out;
        try {
            in = new DataInputStream(clientSock.getInputStream());
            out = new DataOutputStream(clientSock.getOutputStream());

            manageRequest(in, out);
        } catch (IOException e) {
            throw new SOCKSException("Connection error (" + e.getMessage() + ")");
        }

        setupCompleted = true;
    }

    /**
     * SOCKS4a request management.  This method assumes that all the
     * stuff preceding or enveloping the actual request
     * has been stripped out of the input/output streams.
     */
    private void manageRequest(DataInputStream in, DataOutputStream out) throws IOException, SOCKSException {

        int command = in.readByte() & 0xff;
        switch (command) {
        case Command.CONNECT:
            break;
        case Command.BIND:
            _log.debug("BIND command is not supported!");
            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            throw new SOCKSException("BIND command not supported");
        default:
            _log.debug("unknown command in request (" + Integer.toHexString(command) + ")");
            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            throw new SOCKSException("Invalid command in request");
        }

        connPort = in.readUnsignedShort();
        if (connPort == 0) {
            _log.debug("trying to connect to TCP port 0?  Dropping!");
            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            throw new SOCKSException("Invalid port number in request");
        }

        StringBuilder builder = new StringBuilder();
        boolean alreadyWarned = false;
        for (int i = 0; i < 4; ++i) {
            int octet = in.readByte() & 0xff;
            builder.append(Integer.toString(octet));
            if (i != 3) {
                builder.append(".");
                if (octet != 0 && !alreadyWarned) {
                    _log.warn("IPV4 address type in request: " + connHostName + ". Is your client secure?");
                    alreadyWarned = true;
                }
            }
        }
        connHostName = builder.toString();

        // Check if the requested IP should be mapped to a domain name
        String mappedDomainName = getMappedDomainNameForIP(connHostName);
        if (mappedDomainName != null) {
            _log.debug("IPV4 address " + connHostName + " was mapped to domain name " + mappedDomainName);
            connHostName = mappedDomainName;
        }

        // discard user name
        readString(in);

        // SOCKS 4a
        if (connHostName.startsWith("0.0.0.") && !connHostName.equals("0.0.0.0"))
            connHostName = readString(in);
    }

    private String readString(DataInputStream in) throws IOException {
        StringBuilder sb = new StringBuilder(16);
        char c;
        while ((c = (char) (in.readByte() & 0xff)) != 0)
            sb.append(c);
        return sb.toString();
    }

    protected void confirmConnection() throws SOCKSException {
        DataOutputStream out;
        try {
            out = new DataOutputStream(clientSock.getOutputStream());

            sendRequestReply(Reply.SUCCEEDED, InetAddress.getByName("127.0.0.1"), 1, out);
        } catch (IOException e) {
            throw new SOCKSException("Connection error (" + e.getMessage() + ")");
        }
    }

    /**
     * Send the specified reply to a request of the client.  Either
     * one of inetAddr or domainName can be null, depending on
     * addressType.
     */
    private void sendRequestReply(int replyCode, InetAddress inetAddr,
                                  int bindPort, DataOutputStream out) throws IOException {
        ByteArrayOutputStream reps = new ByteArrayOutputStream();
        DataOutputStream dreps = new DataOutputStream(reps);

        // Reserved byte, should be 0x00
        dreps.write(0x00);
        dreps.write(replyCode);
        dreps.writeShort(bindPort);
        dreps.write(inetAddr.getAddress());

        byte[] reply = reps.toByteArray();

        if (_log.shouldLog(Log.DEBUG)) {
            _log.debug("Sending request reply:\n" + HexDump.dump(reply));
        }

        out.write(reply);
    }

    /**
     * Get an I2PSocket that can be used to send/receive 8-bit clean data
     * to/from the destination of the SOCKS connection.
     *
     * @return an I2PSocket connected with the destination
     */
    public I2PSocket getDestinationI2PSocket(I2PSOCKSTunnel t) throws SOCKSException {
        setupServer();

        if (connHostName == null) {
            _log.error("BUG: destination host name has not been initialized!");
            throw new SOCKSException("BUG! See the logs!");
        }
        if (connPort == 0) {
            _log.error("BUG: destination port has not been initialized!");
            throw new SOCKSException("BUG! See the logs!");
        }

        DataOutputStream out; // for errors
        try {
            out = new DataOutputStream(clientSock.getOutputStream());
        } catch (IOException e) {
            throw new SOCKSException("Connection error (" + e.getMessage() + ")");
        }

        // FIXME: here we should read our config file, select an
        // outproxy, and instantiate the proper socket class that
        // handles the outproxy itself (SOCKS4a, SOCKS4a, HTTP CONNECT...).
        I2PSocket destSock;

        try {
            if (connHostName.toLowerCase(Locale.US).endsWith(".i2p")) {
                Destination dest = _context.namingService().lookup(connHostName);
                if (dest == null) {
                    try {
                        sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                    } catch (IOException ioe) {}
                    throw new SOCKSException("Host not found");
                }
                if (_log.shouldDebug())
                    _log.debug("connecting to " + connHostName + "...");
                Properties overrides = new Properties();
                I2PSocketOptions sktOpts = t.buildOptions(overrides);
                sktOpts.setPort(connPort);
                destSock = t.createI2PSocket(dest, sktOpts);
            } else if ("localhost".equals(connHostName) || "127.0.0.1".equals(connHostName)) {
                String err = "No localhost accesses allowed through the Socks Proxy";
                _log.error(err);
                try {
                    sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                } catch (IOException ioe) {}
                throw new SOCKSException(err);
          /****
            } else if (connPort == 80) {
                // rewrite GET line to include hostname??? or add Host: line???
                // or forward to local eepProxy (but that's a Socket not an I2PSocket)
                // use eepProxy configured outproxies?
                String err = "No handler for HTTP outproxy implemented - to: " + connHostName;
                _log.error(err);
                try {
                    sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                } catch (IOException ioe) {}
                throw new SOCKSException(err);
           ****/
            } else {
                Outproxy outproxy = getOutproxyPlugin();
                if (outproxy != null) {
                    try {
                        destSock = new SocketWrapper(outproxy.connect(connHostName, connPort));
                    } catch (IOException ioe) {
                        try {
                            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                        } catch (IOException ioe2) {}
                        throw new SOCKSException("connect failed via outproxy plugin", ioe);
                    }
                } else {
                    List<String> proxies = t.getProxies(connPort);
                    if (proxies == null || proxies.isEmpty()) {
                        String err = "No outproxy configured for port " + connPort + " and no default configured either - host: " + connHostName;
                        _log.error(err);
                        try {
                            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                        } catch (IOException ioe) {}
                        throw new SOCKSException(err);
                    }
                    int p = _context.random().nextInt(proxies.size());
                    String proxy = proxies.get(p);
                    Destination dest = _context.namingService().lookup(proxy);
                    if (dest == null) {
                        try {
                            sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
                        } catch (IOException ioe) {}
                        throw new SOCKSException("Outproxy not found");
                    }
                    if (_log.shouldDebug())
                        _log.debug("connecting to port " + connPort + " proxy " + proxy + " for " + connHostName + "...");
                    // this isn't going to work, these need to be socks outproxies so we need
                    // to do a socks session to them?
                    destSock = t.createI2PSocket(dest);
                }
            }
            confirmConnection();
            _log.debug("connection confirmed - exchanging data...");
        } catch (DataFormatException e) {
            try {
                sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            } catch (IOException ioe) {}
            throw new SOCKSException("Error in destination format");
        } catch (SocketException e) {
            try {
                sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            } catch (IOException ioe) {}
            throw new SOCKSException("Error connecting ("
                                     + e.getMessage() + ")");
        } catch (IOException e) {
            try {
                sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            } catch (IOException ioe) {}
            throw new SOCKSException("Error connecting ("
                                     + e.getMessage() + ")");
        } catch (I2PException e) {
            try {
                sendRequestReply(Reply.CONNECTION_REFUSED, InetAddress.getByName("127.0.0.1"), 0, out);
            } catch (IOException ioe) {}
            throw new SOCKSException("Error connecting ("
                                     + e.getMessage() + ")");
        }

        return destSock;
    }

    /*
     * Some namespaces to enclose SOCKS protocol codes
     */
    private static class Command {
        private static final int CONNECT = 0x01;
        private static final int BIND = 0x02;
    }

    private static class Reply {
        private static final int SUCCEEDED = 0x5a;
        private static final int CONNECTION_REFUSED = 0x5b;
    }
}
