/* | |
* Copyright 2007 the original author or authors. | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
package org.mockftpserver.core.server; | |
import org.slf4j.Logger; | |
import org.slf4j.LoggerFactory; | |
import org.mockftpserver.core.MockFtpServerException; | |
import org.mockftpserver.core.command.Command; | |
import org.mockftpserver.core.command.CommandHandler; | |
import org.mockftpserver.core.session.DefaultSession; | |
import org.mockftpserver.core.session.Session; | |
import org.mockftpserver.core.socket.DefaultServerSocketFactory; | |
import org.mockftpserver.core.socket.ServerSocketFactory; | |
import org.mockftpserver.core.util.Assert; | |
import java.io.IOException; | |
import java.net.*; | |
import java.util.HashMap; | |
import java.util.Iterator; | |
import java.util.Map; | |
import java.util.ResourceBundle; | |
/** | |
* This is the abstract superclass for "mock" implementations of an FTP Server, | |
* suitable for testing FTP client code or standing in for a live FTP server. It supports | |
* the main FTP commands by defining handlers for each of the corresponding low-level FTP | |
* server commands (e.g. RETR, DELE, LIST). These handlers implement the {@link org.mockftpserver.core.command.CommandHandler} | |
* interface. | |
* <p/> | |
* By default, mock FTP Servers bind to the server control port of 21. You can use a different server control | |
* port by setting the <code>serverControlPort</code> property. If you specify a value of <code>0</code>, | |
* then a free port number will be chosen automatically; call <code>getServerControlPort()</code> AFTER | |
* <code>start()</code> has been called to determine the actual port number being used. Using a non-default | |
* port number is usually necessary when running on Unix or some other system where that port number is | |
* already in use or cannot be bound from a user process. | |
* <p/> | |
* <h4>Command Handlers</h4> | |
* You can set the existing {@link CommandHandler} defined for an FTP server command | |
* by calling the {@link #setCommandHandler(String, CommandHandler)} method, passing | |
* in the FTP server command name and {@link CommandHandler} instance. | |
* You can also replace multiple command handlers at once by using the {@link #setCommandHandlers(Map)} | |
* method. That is especially useful when configuring the server through the <b>Spring Framework</b>. | |
* <p/> | |
* You can retrieve the existing {@link CommandHandler} defined for an FTP server command by | |
* calling the {@link #getCommandHandler(String)} method, passing in the FTP server command name. | |
* <p/> | |
* <h4>FTP Command Reply Text ResourceBundle</h4> | |
* The default text asociated with each FTP command reply code is contained within the | |
* "ReplyText.properties" ResourceBundle file. You can customize these messages by providing a | |
* locale-specific ResourceBundle file on the CLASSPATH, according to the normal lookup rules of | |
* the ResourceBundle class (e.g., "ReplyText_de.properties"). Alternatively, you can | |
* completely replace the ResourceBundle file by calling the calling the | |
* {@link #setReplyTextBaseName(String)} method. | |
* | |
* @author Chris Mair | |
* @version $Revision$ - $Date$ | |
* @see org.mockftpserver.fake.FakeFtpServer | |
* @see org.mockftpserver.stub.StubFtpServer | |
*/ | |
public abstract class AbstractFtpServer implements Runnable { | |
/** | |
* Default basename for reply text ResourceBundle | |
*/ | |
public static final String REPLY_TEXT_BASENAME = "ReplyText"; | |
private static final int DEFAULT_SERVER_CONTROL_PORT = 21; | |
protected Logger LOG = LoggerFactory.getLogger(getClass()); | |
// Simple value object that holds the socket and thread for a single session | |
private static class SessionInfo { | |
Socket socket; | |
Thread thread; | |
} | |
protected ServerSocketFactory serverSocketFactory = new DefaultServerSocketFactory(); | |
private ServerSocket serverSocket = null; | |
private ResourceBundle replyTextBundle; | |
private volatile boolean terminate = false; | |
private Map commandHandlers; | |
private Thread serverThread; | |
private int serverControlPort = DEFAULT_SERVER_CONTROL_PORT; | |
private final Object startLock = new Object(); | |
// Map of Session -> SessionInfo | |
private Map sessions = new HashMap(); | |
/** | |
* Create a new instance. Initialize the default command handlers and | |
* reply text ResourceBundle. | |
*/ | |
public AbstractFtpServer() { | |
replyTextBundle = ResourceBundle.getBundle(REPLY_TEXT_BASENAME); | |
commandHandlers = new HashMap(); | |
} | |
/** | |
* Start a new Thread for this server instance | |
*/ | |
public void start() { | |
serverThread = new Thread(this); | |
synchronized (startLock) { | |
try { | |
// Start here in case server thread runs faster than main thread. | |
// See https://sourceforge.net/tracker/?func=detail&atid=1006533&aid=1925590&group_id=208647 | |
serverThread.start(); | |
// Wait until the server thread is initialized | |
startLock.wait(); | |
} | |
catch (InterruptedException e) { | |
e.printStackTrace(); | |
throw new MockFtpServerException(e); | |
} | |
} | |
} | |
/** | |
* The logic for the server thread | |
* | |
* @see Runnable#run() | |
*/ | |
public void run() { | |
try { | |
LOG.info("Starting the server on port " + serverControlPort); | |
serverSocket = serverSocketFactory.createServerSocket(serverControlPort); | |
if (serverControlPort == 0) { | |
this.serverControlPort = serverSocket.getLocalPort(); | |
LOG.info("Actual server port is " + this.serverControlPort); | |
} | |
// Notify to allow the start() method to finish and return | |
synchronized (startLock) { | |
startLock.notify(); | |
} | |
while (!terminate) { | |
try { | |
Socket clientSocket = serverSocket.accept(); | |
LOG.info("Connection accepted from host " + clientSocket.getInetAddress()); | |
Session session = createSession(clientSocket); | |
Thread sessionThread = new Thread(session); | |
sessionThread.start(); | |
SessionInfo sessionInfo = new SessionInfo(); | |
sessionInfo.socket = clientSocket; | |
sessionInfo.thread = sessionThread; | |
sessions.put(session, sessionInfo); | |
} | |
catch (SocketException e) { | |
LOG.trace("Socket exception: " + e.toString()); | |
} | |
} | |
} | |
catch (IOException e) { | |
LOG.error("Error", e); | |
} | |
finally { | |
LOG.debug("Cleaning up server..."); | |
// Ensure that the start() method is not still blocked | |
synchronized (startLock) { | |
startLock.notifyAll(); | |
} | |
try { | |
if (serverSocket != null) { | |
serverSocket.close(); | |
} | |
closeSessions(); | |
} | |
catch (IOException e) { | |
LOG.error("Error cleaning up server", e); | |
} | |
catch (InterruptedException e) { | |
LOG.error("Error cleaning up server", e); | |
} | |
LOG.info("Server stopped."); | |
terminate = false; | |
} | |
} | |
/** | |
* Stop this server instance and wait for it to terminate. | |
*/ | |
public void stop() { | |
LOG.trace("Stopping the server..."); | |
terminate = true; | |
if (serverSocket != null) { | |
try { | |
serverSocket.close(); | |
} catch (IOException e) { | |
throw new MockFtpServerException(e); | |
} | |
} | |
try { | |
if (serverThread != null) { | |
serverThread.join(); | |
} | |
} | |
catch (InterruptedException e) { | |
e.printStackTrace(); | |
throw new MockFtpServerException(e); | |
} | |
} | |
/** | |
* Return the CommandHandler defined for the specified command name | |
* | |
* @param name - the command name | |
* @return the CommandHandler defined for name | |
*/ | |
public CommandHandler getCommandHandler(String name) { | |
return (CommandHandler) commandHandlers.get(Command.normalizeName(name)); | |
} | |
/** | |
* Override the default CommandHandlers with those in the specified Map of | |
* commandName>>CommandHandler. This will only override the default CommandHandlers | |
* for the keys in <code>commandHandlerMapping</code>. All other default CommandHandler | |
* mappings remain unchanged. | |
* | |
* @param commandHandlerMapping - the Map of commandName->CommandHandler; these override the defaults | |
* @throws org.mockftpserver.core.util.AssertFailedException | |
* - if the commandHandlerMapping is null | |
*/ | |
public void setCommandHandlers(Map commandHandlerMapping) { | |
Assert.notNull(commandHandlerMapping, "commandHandlers"); | |
for (Iterator iter = commandHandlerMapping.keySet().iterator(); iter.hasNext();) { | |
String commandName = (String) iter.next(); | |
setCommandHandler(commandName, (CommandHandler) commandHandlerMapping.get(commandName)); | |
} | |
} | |
/** | |
* Set the CommandHandler for the specified command name. If the CommandHandler implements | |
* the {@link org.mockftpserver.core.command.ReplyTextBundleAware} interface and its <code>replyTextBundle</code> attribute | |
* is null, then set its <code>replyTextBundle</code> to the <code>replyTextBundle</code> of | |
* this StubFtpServer. | |
* | |
* @param commandName - the command name to which the CommandHandler will be associated | |
* @param commandHandler - the CommandHandler | |
* @throws org.mockftpserver.core.util.AssertFailedException | |
* - if the commandName or commandHandler is null | |
*/ | |
public void setCommandHandler(String commandName, CommandHandler commandHandler) { | |
Assert.notNull(commandName, "commandName"); | |
Assert.notNull(commandHandler, "commandHandler"); | |
commandHandlers.put(Command.normalizeName(commandName), commandHandler); | |
initializeCommandHandler(commandHandler); | |
} | |
/** | |
* Set the reply text ResourceBundle to a new ResourceBundle with the specified base name, | |
* accessible on the CLASSPATH. See {@link java.util.ResourceBundle#getBundle(String)}. | |
* | |
* @param baseName - the base name of the resource bundle, a fully qualified class name | |
*/ | |
public void setReplyTextBaseName(String baseName) { | |
replyTextBundle = ResourceBundle.getBundle(baseName); | |
} | |
/** | |
* Return the ReplyText ResourceBundle. Set the bundle through the {@link #setReplyTextBaseName(String)} method. | |
* | |
* @return the reply text ResourceBundle | |
*/ | |
public ResourceBundle getReplyTextBundle() { | |
return replyTextBundle; | |
} | |
/** | |
* Set the port number to which the server control connection socket will bind. The default value is 21. | |
* | |
* @param serverControlPort - the port number for the server control connection ServerSocket | |
*/ | |
public void setServerControlPort(int serverControlPort) { | |
this.serverControlPort = serverControlPort; | |
} | |
/** | |
* Return the port number to which the server control connection socket will bind. The default value is 21. | |
* | |
* @return the port number for the server control connection ServerSocket | |
*/ | |
public int getServerControlPort() { | |
return serverControlPort; | |
} | |
/** | |
* Return true if this server is fully shutdown -- i.e., there is no active (alive) threads and | |
* all sockets are closed. This method is intended for testing only. | |
* | |
* @return true if this server is fully shutdown | |
*/ | |
public boolean isShutdown() { | |
boolean shutdown = !serverThread.isAlive() && serverSocket.isClosed(); | |
for (Iterator iter = sessions.values().iterator(); iter.hasNext();) { | |
SessionInfo sessionInfo = (SessionInfo) iter.next(); | |
shutdown = shutdown && sessionInfo.socket.isClosed() && !sessionInfo.thread.isAlive(); | |
} | |
return shutdown; | |
} | |
/** | |
* Return true if this server has started -- i.e., there is an active (alive) server threads | |
* and non-null server socket. This method is intended for testing only. | |
* | |
* @return true if this server has started | |
*/ | |
public boolean isStarted() { | |
return serverThread != null && serverThread.isAlive() && serverSocket != null; | |
} | |
//------------------------------------------------------------------------- | |
// Internal Helper Methods | |
//------------------------------------------------------------------------- | |
/** | |
* Create a new Session instance for the specified client Socket | |
* | |
* @param clientSocket - the Socket associated with the client | |
* @return a Session | |
*/ | |
protected Session createSession(Socket clientSocket) { | |
return new DefaultSession(clientSocket, commandHandlers); | |
} | |
private void closeSessions() throws InterruptedException, IOException { | |
for (Iterator iter = sessions.entrySet().iterator(); iter.hasNext();) { | |
Map.Entry entry = (Map.Entry) iter.next(); | |
Session session = (Session) entry.getKey(); | |
SessionInfo sessionInfo = (SessionInfo) entry.getValue(); | |
session.close(); | |
sessionInfo.thread.join(500L); | |
Socket sessionSocket = sessionInfo.socket; | |
if (sessionSocket != null) { | |
sessionSocket.close(); | |
} | |
} | |
} | |
//------------------------------------------------------------------------------------ | |
// Abstract method declarations | |
//------------------------------------------------------------------------------------ | |
/** | |
* Initialize a CommandHandler that has been registered to this server. What "initialization" | |
* means is dependent on the subclass implementation. | |
* | |
* @param commandHandler - the CommandHandler to initialize | |
*/ | |
protected abstract void initializeCommandHandler(CommandHandler commandHandler); | |
} |