/* * 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.javastack.sftpserver; import java.io.BufferedReader; import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.math.BigInteger; import java.nio.charset.Charset; import java.nio.file.FileSystem; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; import java.security.PublicKey; import java.security.interfaces.DSAPublicKey; import java.security.interfaces.RSAPublicKey; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Properties; import javax.xml.bind.DatatypeConverter; import org.apache.mina.core.buffer.IoBuffer; import org.apache.sshd.common.Factory; import org.apache.sshd.common.NamedFactory; import org.apache.sshd.common.PropertyResolverUtils; import org.apache.sshd.common.channel.Channel; import org.apache.sshd.common.compression.BuiltinCompressions; import org.apache.sshd.common.compression.Compression; import org.apache.sshd.common.file.FileSystemFactory; import org.apache.sshd.common.file.root.RootedFileSystemProvider; import org.apache.sshd.common.mac.BuiltinMacs; import org.apache.sshd.common.mac.Mac; import org.apache.sshd.common.session.Session; import org.apache.sshd.common.util.GenericUtils; import org.apache.sshd.common.util.SecurityUtils; import org.apache.sshd.server.Command; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; import org.apache.sshd.server.ServerFactoryManager; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.auth.password.PasswordAuthenticator; import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; import org.apache.sshd.server.channel.ChannelSessionFactory; import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider; import org.apache.sshd.server.scp.ScpCommandFactory; import org.apache.sshd.server.session.ServerSession; import org.apache.sshd.server.subsystem.sftp.SftpEventListener; import org.apache.sshd.server.subsystem.sftp.SftpSubsystem; import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory; import org.javastack.sftpserver.readonly.ReadOnlyRootedFileSystemProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * SFTP Server * * @author Guillermo Grandes / guillermo.grandes[at]gmail.com */ public class Server implements PasswordAuthenticator, PublickeyAuthenticator { public static final String CONFIG_FILE = "/sftpd.properties"; public static final String HTPASSWD_FILE = "/htpasswd"; public static final String HOSTKEY_FILE_PEM = "keys/hostkey.pem"; public static final String HOSTKEY_FILE_SER = "keys/hostkey.ser"; private static final Logger LOG = LoggerFactory.getLogger(Server.class); private Config db; private SshServer sshd; private volatile boolean running = true; public static void main(final String[] args) { new Server().start(); } @SuppressWarnings("unchecked") protected void setupFactories() { sshd.setSubsystemFactories(Arrays.<NamedFactory<Command>> asList(new CustomSftpSubsystemFactory())); sshd.setMacFactories(Arrays.<NamedFactory<Mac>> asList( // BuiltinMacs.hmacsha512, // BuiltinMacs.hmacsha256, // BuiltinMacs.hmacsha1)); sshd.setChannelFactories(Arrays.<NamedFactory<Channel>> asList(ChannelSessionFactory.INSTANCE)); } protected void setupDummyShell() { sshd.setShellFactory(new SecureShellFactory()); } protected void setupKeyPair() { if (SecurityUtils.isBouncyCastleRegistered()) { sshd.setKeyPairProvider(SecurityUtils.createGeneratorHostKeyProvider(new File(HOSTKEY_FILE_PEM) .toPath())); } else { sshd.setKeyPairProvider(new SimpleGeneratorHostKeyProvider(new File(HOSTKEY_FILE_SER))); } } protected void setupScp() { sshd.setCommandFactory(new ScpCommandFactory()); sshd.setFileSystemFactory(new SecureFileSystemFactory(db)); sshd.setTcpipForwardingFilter(null); sshd.setAgentFactory(null); } protected void setupAuth() { sshd.setPasswordAuthenticator(this); sshd.setPublickeyAuthenticator(this); sshd.setGSSAuthenticator(null); } protected void loadHtPasswd() throws IOException { InputStream is = null; BufferedReader r = null; try { final boolean htEnabled = Boolean.parseBoolean(db.getHtValue(Config.PROP_HT_ENABLED)); if (!htEnabled) { return; } final String htHome = db.getHtValue(Config.PROP_HT_HOME); final boolean htEnableWrite = Boolean.parseBoolean(db.getHtValue(Config.PROP_HT_ENABLE_WRITE)); is = getClass().getResourceAsStream(HTPASSWD_FILE); r = new BufferedReader(new InputStreamReader(is)); if (is == null) { LOG.error("htpasswd file " + HTPASSWD_FILE + " not found in classpath"); return; } String line = null; int c = 0; while ((line = r.readLine()) != null) { if (line.startsWith("#")) continue; final String[] tok = line.split(":", 2); if (tok.length != 2) continue; final String user = tok[0]; final String auth = tok[1]; db.setValue(user, Config.PROP_PWD, auth); db.setValue(user, Config.PROP_HOME, htHome); db.setValue(user, Config.PROP_ENABLED, htEnabled); db.setValue(user, Config.PROP_ENABLE_WRITE, htEnableWrite); c++; } LOG.info("htpasswd file loaded " + c + " lines"); } finally { closeQuietly(r); closeQuietly(is); } } @SuppressWarnings("unchecked") protected void setupCompress() { // Compression is not enabled by default // You need download and compile: // http://www.jcraft.com/jzlib/ sshd.setCompressionFactories(Arrays.<NamedFactory<Compression>> asList( // BuiltinCompressions.none, // BuiltinCompressions.zlib, // BuiltinCompressions.delayedZlib)); } protected Config loadConfig() { final Properties db = new Properties(); InputStream is = null; try { is = getClass().getResourceAsStream(CONFIG_FILE); if (is == null) { LOG.error("Config file " + CONFIG_FILE + " not found in classpath"); } else { db.load(is); LOG.info("Config file loaded " + db.size() + " lines"); } } catch (IOException e) { LOG.error("IOException " + e.toString(), e); } finally { closeQuietly(is); } return new Config(db); } private void closeQuietly(final Closeable c) { if (c != null) { try { c.close(); } catch (Exception ign) { } } } private void hackVersion() { PropertyResolverUtils.updateProperty(sshd, ServerFactoryManager.SERVER_IDENTIFICATION, "SSHD"); } public void start() { LOG.info("Starting"); db = loadConfig(); sshd = SshServer.setUpDefaultServer(); LOG.info("SSHD " + sshd.getVersion()); hackVersion(); setupFactories(); setupKeyPair(); setupScp(); setupAuth(); try { final int port = db.getPort(); final boolean enableCompress = db.enableCompress(); final boolean enableDummyShell = db.enableDummyShell(); if (enableCompress) setupCompress(); if (enableDummyShell) setupDummyShell(); loadHtPasswd(); sshd.setPort(port); LOG.info("Listen on port=" + port); final Server thisServer = this; Runtime.getRuntime().addShutdownHook(new Thread() { public void run() { thisServer.stop(); } }); sshd.start(); } catch (Exception e) { LOG.error("Exception " + e.toString(), e); } while (running) { try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } public void stop() { LOG.info("Stopping"); running = false; try { sshd.stop(); } catch (IOException e) { try { sshd.stop(true); } catch (IOException ee) { LOG.error("Failed to stop", ee); } } } @Override public boolean authenticate(final String username, final String password, final ServerSession session) { LOG.info("Request auth (Password) for username=" + username); if ((username != null) && (password != null)) { return db.checkUserPassword(username, password); } return false; } @Override public boolean authenticate(final String username, final PublicKey key, final ServerSession session) { LOG.info("Request auth (PublicKey) for username=" + username); // File f = new File("/home/" + username + "/.ssh/authorized_keys"); if ((username != null) && (key != null)) { return db.checkUserPublicKey(username, key); } return false; } // =================== Helper Classes static class Config { // Global config public static final String BASE = "sftpserver"; public static final String PROP_GLOBAL = BASE + "." + "global"; public static final String PROP_PORT = "port"; public static final String PROP_COMPRESS = "compress"; public static final String PROP_DUMMY_SHELL = "dummyshell"; // HtPasswd config public static final String PROP_HTPASSWD = BASE + "." + "htpasswd"; public static final String PROP_HT_HOME = "homedirectory"; public static final String PROP_HT_ENABLED = "enableflag"; public static final String PROP_HT_ENABLE_WRITE = "writepermission"; // true / false // User config public static final String PROP_BASE_USERS = BASE + "." + "user"; public static final String PROP_PWD = "userpassword"; public static final String PROP_KEY = "userkey" + "."; public static final String PROP_HOME = "homedirectory"; public static final String PROP_ENABLED = "enableflag"; // true / false public static final String PROP_ENABLE_WRITE = "writepermission"; // true / false private final Properties db; public Config(final Properties db) { this.db = db; } // Global config public boolean enableCompress() { return Boolean.parseBoolean(getValue(PROP_COMPRESS)); } public boolean enableDummyShell() { return Boolean.parseBoolean(getValue(PROP_DUMMY_SHELL)); } public int getPort() { return Integer.parseInt(getValue(PROP_PORT)); } private final String getValue(final String key) { if (key == null) return null; return db.getProperty(PROP_GLOBAL + "." + key); } private final String getHtValue(final String key) { if (key == null) return null; return db.getProperty(PROP_HTPASSWD + "." + key); } // User config private final String getValue(final String user, final String key) { if ((user == null) || (key == null)) return null; final String value = db.getProperty(PROP_BASE_USERS + "." + user + "." + key); return ((value == null) ? null : value.trim()); } private final void setValue(final String user, final String key, final Object value) { if ((user == null) || (key == null) || (value == null)) return; db.setProperty(PROP_BASE_USERS + "." + user + "." + key, String.valueOf(value)); } public boolean isEnabledUser(final String user) { final String value = getValue(user, PROP_ENABLED); if (value == null) return false; return Boolean.parseBoolean(value); } public boolean checkUserPassword(final String user, final String pwd) { final StringBuilder sb = new StringBuilder(96); boolean traceInfo = false; boolean authOk = false; sb.append("Request auth (Password) for username=").append(user).append(" "); try { if (!isEnabledUser(user)) { sb.append("(user disabled)"); return authOk; } final String value = getValue(user, PROP_PWD); if (value == null) { sb.append("(no password)"); return authOk; } final boolean isCrypted = PasswordEncrypt.isCrypted(value); authOk = isCrypted ? PasswordEncrypt.checkPassword(value, pwd) : value.equals(pwd); sb.append(isCrypted ? "(encrypted)" : "(unencrypted)"); traceInfo = isCrypted; } finally { sb.append(": ").append(authOk ? "OK" : "FAIL"); if (authOk) { if (traceInfo) { LOG.info(sb.toString()); } else { LOG.warn(sb.toString()); } } else { LOG.error(sb.toString()); } } return authOk; } public boolean checkUserPublicKey(final String user, final PublicKey key) { final String encodedKey = PublicKeyHelper.getEncodedPublicKey(key); final StringBuilder sb = new StringBuilder(96); boolean authOk = false; sb.append("Request auth (PublicKey) for username=").append(user); sb.append(" (").append(key.getAlgorithm()).append(")"); try { if (!isEnabledUser(user)) { sb.append(" (user disabled)"); return authOk; } for (int i = 1; i < 1024; i++) { final String value = getValue(user, PROP_KEY + i); if (value == null) { if (i == 1) sb.append(" (no publickey)"); break; } else if (value.equals(encodedKey)) { authOk = true; break; } } } finally { sb.append(": ").append(authOk ? "OK" : "FAIL"); if (authOk) { LOG.info(sb.toString()); } else { LOG.error(sb.toString()); } } return authOk; } public String getHome(final String user) { try { final File home = new File(getValue(user, PROP_HOME)); if (home.isDirectory() && home.canRead()) { return home.getCanonicalPath(); } } catch (IOException e) { } return null; } public boolean hasWritePerm(final String user) { final String value = getValue(user, PROP_ENABLE_WRITE); return Boolean.parseBoolean(value); } } static class SecureShellFactory implements Factory<Command> { @Override public Command create() { return new SecureShellCommand(); } } static class SecureShellCommand implements Command { private OutputStream err = null; private ExitCallback callback = null; @Override public void setInputStream(final InputStream in) { } @Override public void setOutputStream(final OutputStream out) { } @Override public void setErrorStream(final OutputStream err) { this.err = err; } @Override public void setExitCallback(final ExitCallback callback) { this.callback = callback; } @Override public void start(final Environment env) throws IOException { if (err != null) { err.write("shell not allowed\r\n".getBytes("ISO-8859-1")); err.flush(); } if (callback != null) callback.onExit(-1, "shell not allowed"); } @Override public void destroy() { } } static class CustomSftpSubsystemFactory extends SftpSubsystemFactory { @Override public Command create() { final SftpSubsystem subsystem = new SftpSubsystem(getExecutorService(), isShutdownOnExit(), getUnsupportedAttributePolicy()) { @Override protected void setFileAttribute(final Path file, final String view, final String attribute, final Object value, final LinkOption... options) throws IOException { throw new UnsupportedOperationException("setFileAttribute Disabled"); } @Override protected void createLink(final int id, final String targetPath, final String linkPath, final boolean symLink) throws IOException { throw new UnsupportedOperationException("createLink Disabled"); } }; final Collection<? extends SftpEventListener> listeners = getRegisteredListeners(); if (GenericUtils.size(listeners) > 0) { for (final SftpEventListener l : listeners) { subsystem.addSftpEventListener(l); } } return subsystem; } } static class SecureFileSystemFactory implements FileSystemFactory { private final Config db; public SecureFileSystemFactory(final Config db) { this.db = db; } @Override public FileSystem createFileSystem(final Session session) throws IOException { final String userName = session.getUsername(); final String home = db.getHome(userName); if (home == null) { throw new IOException("user home error"); } final RootedFileSystemProvider rfsp = db.hasWritePerm(userName) ? new RootedFileSystemProvider() : new ReadOnlyRootedFileSystemProvider(); return rfsp.newFileSystem(Paths.get(home), Collections.<String, Object> emptyMap()); } } // =================== PublicKeyHelper static class PublicKeyHelper { private static final Charset US_ASCII = Charset.forName("US-ASCII"); public static String getEncodedPublicKey(final PublicKey pub) { if (pub instanceof RSAPublicKey) { return encodeRSAPublicKey((RSAPublicKey) pub); } if (pub instanceof DSAPublicKey) { return encodeDSAPublicKey((DSAPublicKey) pub); } return null; } public static String encodeRSAPublicKey(final RSAPublicKey key) { final BigInteger[] params = new BigInteger[] { key.getPublicExponent(), key.getModulus() }; return encodePublicKey(params, "ssh-rsa"); } public static String encodeDSAPublicKey(final DSAPublicKey key) { final BigInteger[] params = new BigInteger[] { key.getParams().getP(), key.getParams().getQ(), key.getParams().getG(), key.getY() }; return encodePublicKey(params, "ssh-dss"); } private static final void encodeUInt32(final IoBuffer bab, final int value) { bab.put((byte) ((value >> 24) & 0xFF)); bab.put((byte) ((value >> 16) & 0xFF)); bab.put((byte) ((value >> 8) & 0xFF)); bab.put((byte) (value & 0xFF)); } private static String encodePublicKey(final BigInteger[] params, final String keyType) { final IoBuffer bab = IoBuffer.allocate(256); bab.setAutoExpand(true); byte[] buf = null; // encode the header "ssh-dss" / "ssh-rsa" buf = keyType.getBytes(US_ASCII); // RFC-4253, pag.13 encodeUInt32(bab, buf.length); // RFC-4251, pag.8 (string encoding) for (final byte b : buf) { bab.put(b); } // encode params for (final BigInteger param : params) { buf = param.toByteArray(); encodeUInt32(bab, buf.length); for (final byte b : buf) { bab.put(b); } } bab.flip(); buf = new byte[bab.limit()]; System.arraycopy(bab.array(), 0, buf, 0, buf.length); bab.free(); return keyType + " " + DatatypeConverter.printBase64Binary(buf); } } }