/* dCache - http://www.dcache.org/
*
* Copyright (C) 2015 Deutsches Elektronen-Synchrotron
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.dcache.ftp.client.extended;
import com.google.common.base.Splitter;
import com.google.common.io.BaseEncoding;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSession;
import javax.security.auth.x500.X500Principal;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.StringReader;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.List;
import org.dcache.dss.DssContext;
import org.dcache.dss.DssContextFactory;
import org.dcache.dss.SslEngineDssContext;
import org.dcache.ftp.client.exception.FTPReplyParseException;
import org.dcache.ftp.client.exception.ServerException;
import org.dcache.ftp.client.exception.UnexpectedReplyCodeException;
import org.dcache.ftp.client.vanilla.Command;
import org.dcache.ftp.client.vanilla.FTPControlChannel;
import org.dcache.ftp.client.vanilla.Flag;
import org.dcache.ftp.client.vanilla.Reply;
import static com.google.common.io.BaseEncoding.base64;
/**
* GridFTP control channel wraps a vanilla control channel and
* adds GSI encryption.
*/
public class GridFTPControlChannel extends FTPControlChannel
{
protected final FTPControlChannel inner;
protected final DssContext context;
protected final HostnameVerifier hostnameVerifier = SSLConnectionSocketFactory.getDefaultHostnameVerifier();
protected Reply lastReply;
/**
* Creates an encrypted control channel wrapping an unencrypted control channel.
* The constructor will establish a common security context with the server.
*/
public GridFTPControlChannel(FTPControlChannel inner, DssContextFactory factory, String expectedHostName)
throws IOException, ServerException
{
super(inner.getHost(), inner.getPort());
this.inner = inner;
this.context = authenticate(factory, expectedHostName);
}
/**
* Performs authentication with specified user credentials and
* a specific username (assuming the user dn maps to the passed username).
*
* @throws IOException on i/o error
* @throws ServerException on server refusal or faulty server behavior
*/
private DssContext authenticate(DssContextFactory factory, String expectedHostName)
throws IOException, ServerException
{
DssContext context;
try {
try {
Reply reply = inner.exchange(new Command("AUTH", "GSSAPI"));
if (!Reply.isPositiveIntermediate(reply)) {
throw ServerException.embedUnexpectedReplyCodeException(
new UnexpectedReplyCodeException(reply),
"Server refused GSSAPI authentication.");
}
} catch (FTPReplyParseException rpe) {
throw ServerException.embedFTPReplyParseException(
rpe, "Received faulty reply to AUTH GSSAPI.");
}
context = factory.create(inner.getRemoteAddress(), inner.getLocalAddress());
Reply reply;
byte[] inToken = new byte[0];
do {
byte[] outToken = context.init(inToken);
reply = inner.exchange(new Command("ADAT", BaseEncoding.base64().encode(outToken != null ? outToken : new byte[0])));
if (reply.getMessage().startsWith("ADAT=")) {
inToken = BaseEncoding.base64().decode(reply.getMessage().substring(5));
} else {
inToken = new byte[0];
}
} while (Reply.isPositiveIntermediate(reply) && !context.isEstablished());
if (!Reply.isPositiveCompletion(reply)) {
throw ServerException.embedUnexpectedReplyCodeException(
new UnexpectedReplyCodeException(reply), "Server failed GSI handshake.");
}
if (inToken.length > 0 || !context.isEstablished()) {
byte[] outToken = context.init(inToken);
if (outToken != null || !context.isEstablished()) {
throw new ServerException(ServerException.WRONG_PROTOCOL, "Unexpected GSI handshake completion.");
}
}
SSLSession session = ((SslEngineDssContext) context).getSSLSession();
if (!this.hostnameVerifier.verify(expectedHostName, session)) {
final Certificate[] certs = session.getPeerCertificates();
final X509Certificate x509 = (X509Certificate) certs[0];
final X500Principal x500Principal = x509.getSubjectX500Principal();
throw new SSLPeerUnverifiedException("Host name '" + expectedHostName + "' does not match " +
"the certificate subject provided by the peer (" + x500Principal.toString() + ")");
}
} catch (FTPReplyParseException e) {
throw ServerException.embedFTPReplyParseException(e, "Received faulty reply to ADAT.");
}
return context;
}
@Override
public String getHost()
{
return inner.getHost();
}
@Override
public int getPort()
{
return inner.getPort();
}
@Override
public InetSocketAddress getLocalAddress()
{
return inner.getLocalAddress();
}
@Override
public InetSocketAddress getRemoteAddress()
{
return inner.getRemoteAddress();
}
@Override
public boolean isIPv6()
{
return inner.isIPv6();
}
@Override
public void open() throws IOException, ServerException
{
throw new UnsupportedOperationException("GridFTPControlChannel wraps existing control channel and cannot be opened.");
}
@Override
public Reply getLastReply()
{
return lastReply;
}
@Override
public void close() throws IOException
{
inner.close();
}
@Override
public void waitFor(Flag aborted, int ioDelay,
int maxWait) throws ServerException, IOException, InterruptedException
{
inner.waitFor(aborted, ioDelay, maxWait);
}
@Override
public Reply read() throws ServerException, IOException, FTPReplyParseException, EOFException
{
Reply reply = inner.read();
if (reply.getCode() != 632 && reply.getCode() != 633) {
throw ServerException.embedUnexpectedReplyCodeException(
new UnexpectedReplyCodeException(reply), "Expected 632 or 633 reply.");
}
// FIXME: this is a work-around against problems in Reply to fix
// multi-line 63x responses in a way that can be back-ported.
ByteArrayOutputStream out = new ByteArrayOutputStream();
boolean isFirstLine = true;
for (String line : Splitter.on('\n').split(reply.getMessage())) {
byte[] token = base64().decode(isFirstLine ? line : line.substring(4));
out.write(token, 0, token.length);
isFirstLine = false;
}
String unwrapped = new String(context.unwrap(out.toByteArray()));
lastReply = new Reply(new BufferedReader(new StringReader(unwrapped)));
return lastReply;
}
@Override
public void abortTransfer()
{
inner.abortTransfer();
}
@Override
public void write(Command cmd) throws IOException, IllegalArgumentException
{
byte[] bytes = cmd.toString().getBytes(StandardCharsets.US_ASCII);
byte[] token = context.wrap(bytes, 0, bytes.length);
inner.write(new Command("ENC", BaseEncoding.base64().encode(token)));
}
}