2015-11-25 03:13:59 -05:00
|
|
|
import org.elasticsearch.gradle.LoggedExec
|
2015-12-04 02:36:35 -05:00
|
|
|
import org.elasticsearch.gradle.MavenFilteringHack
|
2016-08-30 12:57:42 -04:00
|
|
|
import org.elasticsearch.gradle.test.NodeInfo
|
|
|
|
|
|
|
|
import javax.net.ssl.HttpsURLConnection
|
|
|
|
import javax.net.ssl.KeyManagerFactory
|
|
|
|
import javax.net.ssl.SSLContext
|
|
|
|
import javax.net.ssl.TrustManagerFactory
|
|
|
|
import java.nio.charset.StandardCharsets
|
|
|
|
import java.security.KeyStore
|
|
|
|
import java.security.SecureRandom
|
2015-11-25 03:13:59 -05:00
|
|
|
|
2015-11-24 23:19:04 -05:00
|
|
|
apply plugin: 'elasticsearch.rest-test'
|
|
|
|
|
|
|
|
dependencies {
|
2015-11-25 18:28:45 -05:00
|
|
|
testCompile project(path: ':x-plugins:elasticsearch:x-pack', configuration: 'runtime')
|
2015-11-24 23:19:04 -05:00
|
|
|
}
|
|
|
|
|
2016-04-12 09:19:07 -04:00
|
|
|
String outputDir = "generated-resources/${project.name}"
|
|
|
|
task copyXPackPluginProps(type: Copy) {
|
|
|
|
from project(':x-plugins:elasticsearch:x-pack').file('src/main/plugin-metadata')
|
|
|
|
from project(':x-plugins:elasticsearch:x-pack').tasks.pluginProperties
|
|
|
|
into outputDir
|
|
|
|
}
|
|
|
|
project.sourceSets.test.output.dir(outputDir, builtBy: copyXPackPluginProps)
|
|
|
|
|
2016-02-08 08:33:27 -05:00
|
|
|
// needed to be consistent with ssl host checking
|
2016-03-14 20:22:05 -04:00
|
|
|
Object san = new SanEvaluator()
|
2015-11-24 23:19:04 -05:00
|
|
|
|
2016-02-08 08:33:27 -05:00
|
|
|
// location of generated keystores and certificates
|
|
|
|
File keystoreDir = new File(project.buildDir, 'keystore')
|
|
|
|
|
|
|
|
// Generate the node's keystore
|
|
|
|
File nodeKeystore = new File(keystoreDir, 'test-node.jks')
|
|
|
|
task createNodeKeyStore(type: LoggedExec) {
|
|
|
|
doFirst {
|
|
|
|
if (nodeKeystore.parentFile.exists() == false) {
|
|
|
|
nodeKeystore.parentFile.mkdirs()
|
|
|
|
}
|
|
|
|
if (nodeKeystore.exists()) {
|
|
|
|
delete nodeKeystore
|
|
|
|
}
|
|
|
|
}
|
|
|
|
executable = 'keytool'
|
|
|
|
standardInput = new ByteArrayInputStream('FirstName LastName\nUnit\nOrganization\nCity\nState\nNL\nyes\n\n'.getBytes('UTF-8'))
|
|
|
|
args '-genkey',
|
|
|
|
'-alias', 'test-node',
|
|
|
|
'-keystore', nodeKeystore,
|
|
|
|
'-keyalg', 'RSA',
|
|
|
|
'-keysize', '2048',
|
|
|
|
'-validity', '712',
|
|
|
|
'-dname', 'CN=smoke-test-plugins-ssl',
|
|
|
|
'-keypass', 'keypass',
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-ext', san
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate the client's keystore
|
|
|
|
File clientKeyStore = new File(keystoreDir, 'test-client.jks')
|
|
|
|
task createClientKeyStore(type: LoggedExec) {
|
2015-11-25 03:13:59 -05:00
|
|
|
doFirst {
|
2016-02-08 08:33:27 -05:00
|
|
|
if (clientKeyStore.parentFile.exists() == false) {
|
|
|
|
clientKeyStore.parentFile.mkdirs()
|
|
|
|
}
|
|
|
|
if (clientKeyStore.exists()) {
|
|
|
|
delete clientKeyStore
|
|
|
|
}
|
2015-11-24 23:19:04 -05:00
|
|
|
}
|
2015-11-25 03:13:59 -05:00
|
|
|
executable = 'keytool'
|
|
|
|
standardInput = new ByteArrayInputStream('FirstName LastName\nUnit\nOrganization\nCity\nState\nNL\nyes\n\n'.getBytes('UTF-8'))
|
|
|
|
args '-genkey',
|
2016-02-08 08:33:27 -05:00
|
|
|
'-alias', 'test-client',
|
|
|
|
'-keystore', clientKeyStore,
|
|
|
|
'-keyalg', 'RSA',
|
|
|
|
'-keysize', '2048',
|
|
|
|
'-validity', '712',
|
|
|
|
'-dname', 'CN=smoke-test-plugins-ssl',
|
|
|
|
'-keypass', 'keypass',
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-ext', san
|
2015-11-24 23:19:04 -05:00
|
|
|
}
|
|
|
|
|
2016-02-08 08:33:27 -05:00
|
|
|
// Export the node's certificate
|
|
|
|
File nodeCertificate = new File(keystoreDir, 'test-node.cert')
|
|
|
|
task exportNodeCertificate(type: LoggedExec) {
|
|
|
|
doFirst {
|
|
|
|
if (nodeCertificate.parentFile.exists() == false) {
|
|
|
|
nodeCertificate.parentFile.mkdirs()
|
|
|
|
}
|
|
|
|
if (nodeCertificate.exists()) {
|
|
|
|
delete nodeCertificate
|
|
|
|
}
|
|
|
|
}
|
|
|
|
executable = 'keytool'
|
|
|
|
args '-export',
|
|
|
|
'-alias', 'test-node',
|
|
|
|
'-keystore', nodeKeystore,
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-file', nodeCertificate
|
|
|
|
}
|
|
|
|
|
|
|
|
// Import the node certificate in the client's keystore
|
|
|
|
task importNodeCertificateInClientKeyStore(type: LoggedExec) {
|
|
|
|
dependsOn exportNodeCertificate
|
|
|
|
executable = 'keytool'
|
|
|
|
args '-import',
|
|
|
|
'-alias', 'test-node',
|
|
|
|
'-keystore', clientKeyStore,
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-file', nodeCertificate,
|
|
|
|
'-noprompt'
|
|
|
|
}
|
|
|
|
|
|
|
|
// Export the client's certificate
|
|
|
|
File clientCertificate = new File(keystoreDir, 'test-client.cert')
|
|
|
|
task exportClientCertificate(type: LoggedExec) {
|
|
|
|
doFirst {
|
|
|
|
if (clientCertificate.parentFile.exists() == false) {
|
|
|
|
clientCertificate.parentFile.mkdirs()
|
|
|
|
}
|
|
|
|
if (clientCertificate.exists()) {
|
|
|
|
delete clientCertificate
|
|
|
|
}
|
|
|
|
}
|
|
|
|
executable = 'keytool'
|
|
|
|
args '-export',
|
|
|
|
'-alias', 'test-client',
|
|
|
|
'-keystore', clientKeyStore,
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-file', clientCertificate
|
|
|
|
}
|
|
|
|
|
|
|
|
// Import the client certificate in the node's keystore
|
|
|
|
task importClientCertificateInNodeKeyStore(type: LoggedExec) {
|
|
|
|
dependsOn exportClientCertificate
|
|
|
|
executable = 'keytool'
|
|
|
|
args '-import',
|
|
|
|
'-alias', 'test-client',
|
|
|
|
'-keystore', nodeKeystore,
|
|
|
|
'-storepass', 'keypass',
|
|
|
|
'-file', clientCertificate,
|
|
|
|
'-noprompt'
|
|
|
|
}
|
|
|
|
|
|
|
|
forbiddenPatterns {
|
|
|
|
exclude '**/*.cert'
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add keystores to test classpath: it expects it there
|
|
|
|
sourceSets.test.resources.srcDir(keystoreDir)
|
|
|
|
processTestResources.dependsOn(
|
|
|
|
createNodeKeyStore, createClientKeyStore,
|
|
|
|
importNodeCertificateInClientKeyStore, importClientCertificateInNodeKeyStore
|
|
|
|
)
|
2015-11-25 03:13:59 -05:00
|
|
|
|
2015-12-04 14:52:31 -05:00
|
|
|
ext.pluginsCount = 1 // we install xpack explicitly
|
2015-12-04 02:36:35 -05:00
|
|
|
project.rootProject.subprojects.findAll { it.path.startsWith(':plugins:') }.each { subproj ->
|
|
|
|
// need to get a non-decorated project object, so must re-lookup the project by path
|
2016-07-15 17:36:13 -04:00
|
|
|
integTest.cluster.plugin(subproj.path)
|
2016-02-21 13:10:21 -05:00
|
|
|
pluginsCount += 1
|
2015-11-25 03:13:59 -05:00
|
|
|
}
|
2015-11-24 23:19:04 -05:00
|
|
|
|
|
|
|
integTest {
|
|
|
|
cluster {
|
2016-07-07 17:59:39 -04:00
|
|
|
setting 'xpack.monitoring.collection.interval', '3s'
|
2016-07-26 01:34:45 -04:00
|
|
|
setting 'xpack.monitoring.exporters._http.type', 'http'
|
|
|
|
setting 'xpack.monitoring.exporters._http.enabled', 'false'
|
|
|
|
setting 'xpack.monitoring.exporters._http.ssl.truststore.path', clientKeyStore.name
|
|
|
|
setting 'xpack.monitoring.exporters._http.ssl.truststore.password', 'keypass'
|
|
|
|
setting 'xpack.monitoring.exporters._http.auth.username', 'monitoring_agent'
|
|
|
|
setting 'xpack.monitoring.exporters._http.auth.password', 'changeme'
|
2016-03-15 20:01:01 -04:00
|
|
|
|
2016-03-22 17:40:45 -04:00
|
|
|
setting 'xpack.security.transport.ssl.enabled', 'true'
|
|
|
|
setting 'xpack.security.http.ssl.enabled', 'true'
|
2016-09-01 10:51:41 -04:00
|
|
|
setting 'xpack.ssl.keystore.path', nodeKeystore.name
|
|
|
|
setting 'xpack.ssl.keystore.password', 'keypass'
|
2016-02-08 08:33:27 -05:00
|
|
|
|
2016-07-15 17:36:13 -04:00
|
|
|
plugin ':x-plugins:elasticsearch:x-pack'
|
2015-11-24 23:19:04 -05:00
|
|
|
|
2016-02-08 08:33:27 -05:00
|
|
|
// copy keystores into config/
|
|
|
|
extraConfigFile nodeKeystore.name, nodeKeystore
|
|
|
|
extraConfigFile clientKeyStore.name, clientKeyStore
|
|
|
|
|
2015-11-24 23:19:04 -05:00
|
|
|
setupCommand 'setupTestUser',
|
2016-02-10 09:42:59 -05:00
|
|
|
'bin/x-pack/users', 'useradd', 'test_user', '-p', 'changeme', '-r', 'superuser'
|
2016-06-28 04:57:24 -04:00
|
|
|
setupCommand 'setupMonitoringUser',
|
2016-03-30 03:07:57 -04:00
|
|
|
'bin/x-pack/users', 'useradd', 'monitoring_agent', '-p', 'changeme', '-r', 'remote_monitoring_agent'
|
2016-02-08 08:33:27 -05:00
|
|
|
|
2016-08-30 12:57:42 -04:00
|
|
|
waitCondition = { NodeInfo node, AntBuilder ant ->
|
|
|
|
File tmpFile = new File(node.cwd, 'wait.success')
|
|
|
|
KeyStore keyStore = KeyStore.getInstance("JKS");
|
|
|
|
keyStore.load(clientKeyStore.newInputStream(), 'keypass'.toCharArray());
|
|
|
|
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
|
|
|
kmf.init(keyStore, 'keypass'.toCharArray());
|
|
|
|
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
|
|
|
tmf.init(keyStore);
|
|
|
|
SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
|
|
|
|
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
|
|
|
|
for (int i = 0; i < 10; i++) {
|
|
|
|
// we use custom wait logic here for HTTPS
|
|
|
|
HttpsURLConnection httpURLConnection = null;
|
|
|
|
try {
|
|
|
|
httpURLConnection = (HttpsURLConnection) new URL("https://${node.httpUri()}/_cluster/health?wait_for_nodes=${numNodes}").openConnection();
|
|
|
|
httpURLConnection.setSSLSocketFactory(sslContext.getSocketFactory());
|
|
|
|
httpURLConnection.setRequestProperty("Authorization", "Basic " +
|
|
|
|
Base64.getEncoder().encodeToString("test_user:changeme".getBytes(StandardCharsets.UTF_8)));
|
|
|
|
httpURLConnection.setRequestMethod("GET");
|
|
|
|
httpURLConnection.connect();
|
|
|
|
if (httpURLConnection.getResponseCode() == 200) {
|
|
|
|
tmpFile.withWriter StandardCharsets.UTF_8.name(), {
|
|
|
|
it.write(httpURLConnection.getInputStream().getText(StandardCharsets.UTF_8.name()))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (IOException e) {
|
|
|
|
if (i == 9) {
|
|
|
|
logger.error("final attempt of calling cluster health failed", e)
|
|
|
|
} else {
|
|
|
|
logger.debug("failed to call cluster health", e)
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
if (httpURLConnection != null) {
|
|
|
|
httpURLConnection.disconnect();
|
|
|
|
}
|
2016-02-08 08:33:27 -05:00
|
|
|
}
|
|
|
|
|
2016-08-30 12:57:42 -04:00
|
|
|
// did not start, so wait a bit before trying again
|
|
|
|
Thread.sleep(500L);
|
2016-02-08 08:33:27 -05:00
|
|
|
}
|
2016-08-30 12:57:42 -04:00
|
|
|
return tmpFile.exists()
|
2015-11-24 23:19:04 -05:00
|
|
|
}
|
|
|
|
}
|
2015-11-28 21:07:37 -05:00
|
|
|
}
|
2015-12-04 02:36:35 -05:00
|
|
|
|
|
|
|
ext.expansions = [
|
|
|
|
'expected.plugins.count': pluginsCount
|
|
|
|
]
|
|
|
|
|
|
|
|
processTestResources {
|
|
|
|
from(sourceSets.test.resources.srcDirs) {
|
|
|
|
include '**/*.yaml'
|
|
|
|
inputs.properties(expansions)
|
|
|
|
MavenFilteringHack.filter(it, expansions)
|
|
|
|
}
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
/** A lazy evaluator to find the san to use for certificate generation. */
|
|
|
|
class SanEvaluator {
|
2016-02-23 20:12:09 -05:00
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
private static String san = null
|
2016-02-23 20:12:09 -05:00
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
String toString() {
|
|
|
|
synchronized (SanEvaluator.class) {
|
|
|
|
if (san == null) {
|
|
|
|
san = getSubjectAlternativeNameString()
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
return san
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
// Code stolen from NetworkUtils/InetAddresses/NetworkAddress to support SAN
|
|
|
|
/** Return all interfaces (and subinterfaces) on the system */
|
|
|
|
private static List<NetworkInterface> getInterfaces() throws SocketException {
|
|
|
|
List<NetworkInterface> all = new ArrayList<>();
|
|
|
|
addAllInterfaces(all, Collections.list(NetworkInterface.getNetworkInterfaces()));
|
|
|
|
Collections.sort(all, new Comparator<NetworkInterface>() {
|
|
|
|
@Override
|
|
|
|
public int compare(NetworkInterface left, NetworkInterface right) {
|
|
|
|
return Integer.compare(left.getIndex(), right.getIndex());
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
});
|
|
|
|
return all;
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
|
|
|
|
/** Helper for getInterfaces, recursively adds subinterfaces to {@code target} */
|
|
|
|
private static void addAllInterfaces(List<NetworkInterface> target, List<NetworkInterface> level) {
|
|
|
|
if (!level.isEmpty()) {
|
|
|
|
target.addAll(level);
|
|
|
|
for (NetworkInterface intf : level) {
|
|
|
|
addAllInterfaces(target, Collections.list(intf.getSubInterfaces()));
|
|
|
|
}
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
private static String getSubjectAlternativeNameString() {
|
|
|
|
List<InetAddress> list = new ArrayList<>();
|
|
|
|
for (NetworkInterface intf : getInterfaces()) {
|
|
|
|
if (intf.isUp()) {
|
|
|
|
// NOTE: some operating systems (e.g. BSD stack) assign a link local address to the loopback interface
|
|
|
|
// while technically not a loopback address, some of these treat them as one (e.g. OS X "localhost") so we must too,
|
|
|
|
// otherwise things just won't work out of box. So we include all addresses from loopback interfaces.
|
|
|
|
for (InetAddress address : Collections.list(intf.getInetAddresses())) {
|
|
|
|
if (intf.isLoopback() || address.isLoopbackAddress()) {
|
|
|
|
list.add(address);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
if (list.isEmpty()) {
|
|
|
|
throw new IllegalArgumentException("no up-and-running loopback addresses found, got " + getInterfaces());
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
StringBuilder builder = new StringBuilder("san=");
|
|
|
|
for (int i = 0; i < list.size(); i++) {
|
|
|
|
InetAddress address = list.get(i);
|
|
|
|
String hostAddress;
|
|
|
|
if (address instanceof Inet6Address) {
|
|
|
|
hostAddress = compressedIPV6Address((Inet6Address)address);
|
|
|
|
} else {
|
|
|
|
hostAddress = address.getHostAddress();
|
|
|
|
}
|
|
|
|
builder.append("ip:").append(hostAddress);
|
|
|
|
String hostname = address.getHostName();
|
|
|
|
if (hostname.equals(address.getHostAddress()) == false) {
|
|
|
|
builder.append(",dns:").append(hostname);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (i != (list.size() - 1)) {
|
|
|
|
builder.append(",");
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
return builder.toString();
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
private static String compressedIPV6Address(Inet6Address inet6Address) {
|
|
|
|
byte[] bytes = inet6Address.getAddress();
|
|
|
|
int[] hextets = new int[8];
|
|
|
|
for (int i = 0; i < hextets.length; i++) {
|
|
|
|
hextets[i] = (bytes[2 * i] & 255) << 8 | bytes[2 * i + 1] & 255;
|
|
|
|
}
|
|
|
|
compressLongestRunOfZeroes(hextets);
|
|
|
|
return hextetsToIPv6String(hextets);
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
/**
|
|
|
|
* Identify and mark the longest run of zeroes in an IPv6 address.
|
|
|
|
*
|
|
|
|
* <p>Only runs of two or more hextets are considered. In case of a tie, the
|
|
|
|
* leftmost run wins. If a qualifying run is found, its hextets are replaced
|
|
|
|
* by the sentinel value -1.
|
|
|
|
*
|
|
|
|
* @param hextets {@code int[]} mutable array of eight 16-bit hextets
|
|
|
|
*/
|
|
|
|
private static void compressLongestRunOfZeroes(int[] hextets) {
|
|
|
|
int bestRunStart = -1;
|
|
|
|
int bestRunLength = -1;
|
|
|
|
int runStart = -1;
|
|
|
|
for (int i = 0; i < hextets.length + 1; i++) {
|
|
|
|
if (i < hextets.length && hextets[i] == 0) {
|
|
|
|
if (runStart < 0) {
|
|
|
|
runStart = i;
|
|
|
|
}
|
|
|
|
} else if (runStart >= 0) {
|
|
|
|
int runLength = i - runStart;
|
|
|
|
if (runLength > bestRunLength) {
|
|
|
|
bestRunStart = runStart;
|
|
|
|
bestRunLength = runLength;
|
|
|
|
}
|
|
|
|
runStart = -1;
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
}
|
|
|
|
if (bestRunLength >= 2) {
|
|
|
|
Arrays.fill(hextets, bestRunStart, bestRunStart + bestRunLength, -1);
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-03-14 20:22:05 -04:00
|
|
|
/**
|
|
|
|
* Convert a list of hextets into a human-readable IPv6 address.
|
|
|
|
*
|
|
|
|
* <p>In order for "::" compression to work, the input should contain negative
|
|
|
|
* sentinel values in place of the elided zeroes.
|
|
|
|
*
|
|
|
|
* @param hextets {@code int[]} array of eight 16-bit hextets, or -1s
|
2016-02-23 20:12:09 -05:00
|
|
|
*/
|
2016-03-14 20:22:05 -04:00
|
|
|
private static String hextetsToIPv6String(int[] hextets) {
|
|
|
|
/*
|
|
|
|
* While scanning the array, handle these state transitions:
|
|
|
|
* start->num => "num" start->gap => "::"
|
|
|
|
* num->num => ":num" num->gap => "::"
|
|
|
|
* gap->num => "num" gap->gap => ""
|
|
|
|
*/
|
|
|
|
StringBuilder buf = new StringBuilder(39);
|
|
|
|
boolean lastWasNumber = false;
|
|
|
|
for (int i = 0; i < hextets.length; i++) {
|
|
|
|
boolean thisIsNumber = hextets[i] >= 0;
|
|
|
|
if (thisIsNumber) {
|
|
|
|
if (lastWasNumber) {
|
|
|
|
buf.append(':');
|
|
|
|
}
|
|
|
|
buf.append(Integer.toHexString(hextets[i]));
|
|
|
|
} else {
|
|
|
|
if (i == 0 || lastWasNumber) {
|
|
|
|
buf.append("::");
|
|
|
|
}
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
lastWasNumber = thisIsNumber;
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
return buf.toString();
|
2016-02-23 20:12:09 -05:00
|
|
|
}
|
|
|
|
}
|
2016-03-14 20:22:05 -04:00
|
|
|
|