package org.dcache.srm.shell; import com.google.common.base.Throwables; import com.google.common.collect.ImmutableMap; import com.google.common.hash.HashCode; import com.google.common.hash.Hasher; import com.google.common.hash.Hashing; import com.google.common.io.BaseEncoding; import eu.emi.security.authn.x509.CrlCheckingMode; import eu.emi.security.authn.x509.NamespaceCheckingMode; import eu.emi.security.authn.x509.OCSPCheckingMode; import eu.emi.security.authn.x509.X509Credential; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; import org.apache.axis.types.URI; import org.dcache.ftp.client.Buffer; import org.dcache.ftp.client.ChecksumAlgorithm; import org.dcache.ftp.client.DataChannelAuthentication; import org.dcache.ftp.client.DataSinkStream; import org.dcache.ftp.client.DataSourceStream; import org.dcache.ftp.client.GridFTPClient; import org.dcache.ftp.client.GridFTPSession; import org.dcache.ftp.client.RetrieveOptions; import org.dcache.ftp.client.exception.ClientException; import org.dcache.ftp.client.exception.ServerException; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.text.NumberFormat; import java.util.Collections; import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import org.dcache.dss.ClientGsiEngineDssContextFactory; import org.dcache.ssl.CanlContextFactory; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; /** * A FileTransferAgent that supports the {@literal gsiftp} protocol. */ public class GridFTPTransferAgent extends AbstractFileTransferAgent implements CredentialAware { private static final int MAX_CONCURRENT_TRANSFERS = 10; private static final ChecksumAlgorithm ADLER32 = new ChecksumAlgorithm("ADLER32"); private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray(); private final ExecutorService _executor = Executors.newFixedThreadPool(MAX_CONCURRENT_TRANSFERS); private Entity _dataInitiator = Entity.CLIENT; private TransferMode _transferMode = TransferMode.STREAM; private ChecksumMode _checksumHandling = ChecksumMode.IF_AVAILABLE; private X509Credential _credential; private String _caPath = "/etc/grid-security/certificates"; private CrlCheckingMode _crlChecking = CrlCheckingMode.IF_VALID; private NamespaceCheckingMode _namespace = NamespaceCheckingMode.EUGRIDPMA_GLOBUS; private OCSPCheckingMode _ocsp = OCSPCheckingMode.IF_AVAILABLE; private CanlContextFactory _sslContextFactory; private ClientGsiEngineDssContextFactory _dssContextFactory; enum TransferMode { EMODE, STREAM; } enum Entity { CLIENT, SERVER; } enum ChecksumMode { IF_AVAILABLE, REQUIRE, IGNORE; } private void updateCanlContextFactory() { _sslContextFactory = CanlContextFactory.custom() .withCertificateAuthorityPath(_caPath) .withCrlCheckingMode(_crlChecking) .withNamespaceMode(_namespace) .withOcspCheckingMode(_ocsp) .withLazy(true) .build(); updateDssContextFactory(); } private void updateDssContextFactory() { _dssContextFactory = new ClientGsiEngineDssContextFactory(_sslContextFactory, _credential, new String[0], true, true); } @Override public void setCredential(X509Credential credential) { _credential = credential; } @Override public String getTransportName() { return "gridftp"; } @Override public Map<String,String> getOptions() { ImmutableMap.Builder<String,String> builder = ImmutableMap.builder(); builder.put("data.connection-initiator", _dataInitiator.name()); builder.put("data.mode", _transferMode.name()); builder.put("checksum-verification", _checksumHandling.name()); builder.put("security.ca-path", _caPath); builder.put("security.crl-checking", _crlChecking.name()); builder.put("security.OCSP", _ocsp.name()); builder.put("security.ca-namespace", _namespace.name()); return builder.build(); } @Override public void setOption(String key, String value) { switch (key) { case "data.connection-initiator": _dataInitiator = Entity.valueOf(value); break; case "data.mode": _transferMode = TransferMode.valueOf(value); break; case "checksum-verification": _checksumHandling = ChecksumMode.valueOf(value); break; case "security.ca-path": File path = new File(value); checkArgument(path.isAbsolute(), "Absolute path required"); checkArgument(path.isDirectory(), "Path is not a directory"); try { _caPath = path.getCanonicalPath(); } catch (IOException e) { throw new IllegalArgumentException("Unable to set path: " + e.getMessage()); } updateCanlContextFactory(); break; case "security.crl-checking": _crlChecking = CrlCheckingMode.valueOf(value); updateCanlContextFactory(); break; case "security.OCSP": _ocsp = OCSPCheckingMode.valueOf(value); updateCanlContextFactory(); break; case "security.ca-namespace": _namespace = NamespaceCheckingMode.valueOf(value); updateCanlContextFactory(); break; default: throw new IllegalArgumentException("No such option \"" + key + "\""); } } @Override public void start() { updateCanlContextFactory(); } @Override public void close() { MoreExecutors.shutdownAndAwaitTermination(_executor, 500, TimeUnit.MILLISECONDS); } @Override public Map<String,Integer> getSupportedProtocols() { return Collections.singletonMap("gsiftp", 100); } @Override public FileTransfer download(URI source, File destination) { if (source.getScheme().equals("gsiftp")) { GridFTPDownload transfer = new GridFTPDownload(source, destination); transfer.start(); return transfer; } return null; } @Override public FileTransfer upload(File source, URI destination) { if (destination.getScheme().equals("gsiftp")) { GridFTPUpload transfer = new GridFTPUpload(source, destination); transfer.start(); return transfer; } return null; } private abstract class GridFTPTransfer extends AbstractFileTransfer { private final Entity dataInitiator = GridFTPTransferAgent.this._dataInitiator; private final TransferMode transferMode = GridFTPTransferAgent.this._transferMode; private final ChecksumMode checksumHandling = GridFTPTransferAgent.this._checksumHandling; private final java.net.URI _remote; private final File _localFile; private long _bytesTransferred; private long _size; protected GridFTPClient _client; protected volatile String _status; GridFTPTransfer(URI remote, File localFile) { _remote = java.net.URI.create(remote.toString()); _localFile = localFile; Futures.addCallback(this, new FutureCallback(){ @Override public void onSuccess(Object result) { } @Override public void onFailure(Throwable t) { if (t instanceof CancellationException) { onCancel(); } } }); } protected void onCancel() { try { if (_client != null) { _client.abort(); } _status = "cancelled"; } catch (IOException | ServerException e) { _status = "cancel failed [" + e.toString() + "]"; } } @Override public String getStatus() { return _status; } protected void incrementBytesTransferred(int increment) { _bytesTransferred += increment; } protected long getBytesTransferred() { return _bytesTransferred; } protected ChecksumMode getChecksumHandling() { return checksumHandling; } private int getPort() { int port = _remote.getPort(); return port == -1 ? 2811 : port; } protected String getRemotePath() { return _remote.getPath(); } protected File getLocalFile() { return _localFile; } protected void setTargetSize(long size) { _size = size; } protected long getTargetSize() { return _size; } protected String percent() { return NumberFormat.getPercentInstance().format(((double)_bytesTransferred)/_size); } protected boolean isPassive() { return transferMode == TransferMode.STREAM && dataInitiator == Entity.CLIENT; } protected GridFTPClient buildClient() throws IOException, ServerException, ClientException { GridFTPClient client = new GridFTPClient(_remote.getHost(), getPort()); client.setUsageInformation("srmfs", "0.0.1"); client.authenticate(_dssContextFactory); client.setType(GridFTPSession.TYPE_IMAGE); if (client.isFeatureSupported("DCAU")) { client.setDataChannelAuthentication(DataChannelAuthentication.NONE); } client.setLocalNoDataChannelAuthentication(); if (transferMode == TransferMode.EMODE) { client.setMode(GridFTPSession.MODE_EBLOCK); client.setOptions(new RetrieveOptions(1)); } else { client.setMode(GridFTPSession.MODE_STREAM); if (!client.isFeatureSupported("GETPUT")) { switch (dataInitiator) { case CLIENT: client.setPassive(); client.setLocalActive(); break; case SERVER: client.setLocalPassive(); client.setActive(); break; } } } return client; } protected void start() { _executor.submit(new Runnable(){ @Override public void run() { try { _status = "Connecting to FTP server."; _client = buildClient(); doTransfer(); _status = "Succeeded."; succeeded(); } catch (IOException e) { Throwable cause = Throwables.getRootCause(e); _status = "Transfer failed: " + cause.getMessage(); failed(cause); } catch (ServerException e) { Throwable cause = Throwables.getRootCause(e); _status = "Transfer failed (server exception): " + cause.getMessage(); failed(cause); } catch (ClientException e) { Throwable cause = Throwables.getRootCause(e); _status = "Transfer failed (client exception): " + cause.getMessage(); failed(cause); } catch (RuntimeException e) { e.printStackTrace(); Throwable cause = Throwables.getRootCause(e); _status = "Failed due to bug: " + cause; failed(cause); } finally { try { _client.close(); } catch (IOException|ServerException e) { //FIXME: we ignore errors sent back when saying BYE. } } } }); } protected HashCode getRemoteChecksum() throws IOException { String checksum; switch (getChecksumHandling()) { case REQUIRE: try { checksum = _client.checksum(ADLER32, 0, -1, getRemotePath()); } catch (IOException | ServerException e) { throw new IOException("Unable to fetch remote checksum: " + e.getMessage()); } break; case IGNORE: return null; case IF_AVAILABLE: try { checksum = _client.checksum(ADLER32, 0, -1, getRemotePath()); } catch (IOException | ServerException e) { return null; } break; default: throw new RuntimeException("No further options"); } try { byte[] raw = BaseEncoding.base16().lowerCase().decode(checksum.toLowerCase()); // NB HashCode#fromBytes requires the bytes in little-endian, but // returned value is big-endian so we must turn our egg around! int value = ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN).getInt(); return HashCode.fromInt(value); } catch (IllegalArgumentException e) { throw new IOException("Badly formatted checksum \"" + checksum + "\": " + Throwables.getRootCause(e).getMessage()); } } protected abstract void doTransfer() throws IOException, ClientException, ServerException; } /** * Represents a file being uploaded via GridFTP. */ private class GridFTPUpload extends GridFTPTransfer { public GridFTPUpload(File source, URI destination) { super(destination, source); setTargetSize(getLocalFile().length()); } @Override protected void doTransfer() throws IOException, ClientException, ServerException { _status = "Preparing for upload."; MonitoringFileDataSource source = new MonitoringFileDataSource(); if (_client.isFeatureSupported("GETPUT")) { _client.put2(getRemotePath(), isPassive(), source, null); } else { _client.put(getRemotePath(), source, null); } HashCode localChecksum = source.getHash(); HashCode remoteChecksum = getRemoteChecksum(); if (localChecksum != null && remoteChecksum != null && !remoteChecksum.equals(localChecksum)) { throw new IOException("checksum mismatch: " + bigEndian(remoteChecksum) + " != " + bigEndian(localChecksum)); } } @Override protected void incrementBytesTransferred(int count) { super.incrementBytesTransferred(count); _status = "Sent " + percent() + " of " + getTargetSize() + " bytes."; } private class MonitoringFileDataSource extends DataSourceStream { private final Hasher hasher; private HashCode hashcode; MonitoringFileDataSource() throws FileNotFoundException { super(new FileInputStream(getLocalFile())); switch (GridFTPUpload.this.getChecksumHandling()) { case IF_AVAILABLE: case REQUIRE: hasher = Hashing.adler32().newHasher(); break; case IGNORE: hasher = null; break; default: throw new RuntimeException("Unknown ChecksumHandling"); } } @Override public Buffer read() throws IOException { Buffer buffer = super.read(); if (buffer != null) { incrementBytesTransferred(buffer.getLength()); if (hasher != null) { hasher.putBytes(buffer.getBuffer(), 0, buffer.getLength()); } } return buffer; } @Override public void close() throws IOException { if (hashcode != null) { throw new IllegalStateException("Attempt to close already closed DataSource"); } if (hasher != null) { hashcode = hasher.hash(); } super.close(); } public HashCode getHash() { checkState(hasher == null || hashcode != null, "Attempt to call getHash before close"); return hashcode; } } } /** * Represents downloading a file via GridFTP. */ private class GridFTPDownload extends GridFTPTransfer { public GridFTPDownload(URI source, File destination) { super(source, destination); } @Override protected void doTransfer() throws IOException, ServerException, ClientException { _status = "Querying file size."; setTargetSize(_client.getSize(getRemotePath())); _status = "Preparing for download."; MonitoringFileDataSink sink = new MonitoringFileDataSink(); if (_client.isFeatureSupported("GETPUT")) { _client.get2(getRemotePath(), isPassive(), sink, null); } else { _client.get(getRemotePath(), sink, null); } HashCode localChecksum = sink.getHash(); HashCode remoteChecksum = getRemoteChecksum(); if (remoteChecksum != null && localChecksum != null && !remoteChecksum.equals(localChecksum)) { throw new IOException("checksum mismatch: " + bigEndian(remoteChecksum) + " != " + bigEndian(localChecksum)); } } @Override protected void incrementBytesTransferred(int count) { super.incrementBytesTransferred(count); _status = "Recieved " + percent() + " of " + getTargetSize() + " bytes."; } private class MonitoringFileDataSink extends DataSinkStream { private final Hasher hasher; private HashCode hashcode; MonitoringFileDataSink() throws FileNotFoundException { super(new FileOutputStream(getLocalFile())); switch (GridFTPDownload.this.getChecksumHandling()) { case IF_AVAILABLE: case REQUIRE: hasher = Hashing.adler32().newHasher(); break; case IGNORE: hasher = null; break; default: throw new RuntimeException("Unknown ChecksumHandling"); } } @Override public void write(Buffer out) throws IOException { if (hasher != null) { hasher.putBytes(out.getBuffer(), 0, out.getLength()); } incrementBytesTransferred(out.getLength()); super.write(out); } @Override public void close() throws IOException { if (hashcode != null) { throw new IllegalStateException("Attempt to close already closed DataSink"); } if (hasher != null) { hashcode = hasher.hash(); } super.close(); } public HashCode getHash() { checkState(hasher == null || hashcode != null, "Attempt to call getHash before close"); return hashcode; } } } /** * Similar to HashCode#toString but provides the HashCode value in * the more common big-endian format. */ String bigEndian(HashCode hash) { byte[] bytes = hash.asBytes(); StringBuilder sb = new StringBuilder(2 * bytes.length); for (int i = bytes.length-1; i >= 0; i--) { byte b = bytes[i]; sb.append(HEX_DIGITS[(b >> 4) & 0xf]).append(HEX_DIGITS[b & 0xf]); } return sb.toString(); } }