commit 7eb783968bd6d9a5bde5d69409fe359df51f9a9f Author: Karsten Loesing karsten.loesing@gmx.net Date: Thu Apr 30 15:56:52 2020 +0200
Add parsing support for OnionPerf analysis files.
Implements #34070. --- CHANGELOG.md | 4 + .../org/torproject/descriptor/TorperfResult.java | 6 +- .../descriptor/impl/DescriptorParserImpl.java | 4 + .../descriptor/impl/DescriptorReaderImpl.java | 2 + .../descriptor/impl/TorperfResultImpl.java | 11 +- .../onionperf/OnionPerfAnalysisConverter.java | 326 ++++++++++++++++++++ .../onionperf/ParsedOnionPerfAnalysis.java | 329 +++++++++++++++++++++ .../onionperf/TorperfResultsBuilder.java | 101 +++++++ .../onionperf/OnionPerfAnalysisConverterTest.java | 108 +++++++ .../resources/onionperf/onionperf.analysis.json.xz | Bin 0 -> 17376 bytes 10 files changed, 887 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f78fe..c8d7508 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes in version 2.??.? - 2020-??-??
+ * Medium changes + - Add parsing support for OnionPerf analysis files by converting + and returning contained transfers as Torperf results. +
# Changes in version 2.11.0 - 2020-04-13
diff --git a/src/main/java/org/torproject/descriptor/TorperfResult.java b/src/main/java/org/torproject/descriptor/TorperfResult.java index da48e0b..961a206 100644 --- a/src/main/java/org/torproject/descriptor/TorperfResult.java +++ b/src/main/java/org/torproject/descriptor/TorperfResult.java @@ -170,9 +170,9 @@ public interface TorperfResult extends Descriptor { List<String> getPath();
/** - * Return a list of times in milliseconds since the epoch when circuit - * hops were built, or null if the torperf line didn't contain that - * information. + * Return a list of times in milliseconds between launching the circuit and + * extending to the next circuit hop, or null if the torperf line didn't + * contain that information. * * @since 1.0.0 */ diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java index 160baac..7dcd9d2 100644 --- a/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java +++ b/src/main/java/org/torproject/descriptor/impl/DescriptorParserImpl.java @@ -10,6 +10,7 @@ import org.torproject.descriptor.Descriptor; import org.torproject.descriptor.DescriptorParseException; import org.torproject.descriptor.DescriptorParser; import org.torproject.descriptor.log.LogDescriptorImpl; +import org.torproject.descriptor.onionperf.OnionPerfAnalysisConverter;
import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -130,6 +131,9 @@ public class DescriptorParserImpl implements DescriptorParser { } else if (firstLines.startsWith("@type torperf 1.")) { return TorperfResultImpl.parseTorperfResults(rawDescriptorBytes, sourceFile); + } else if (fileName.endsWith(".onionperf.analysis.json.xz")) { + return new OnionPerfAnalysisConverter(rawDescriptorBytes, sourceFile) + .asTorperfResults(); } else if (firstLines.startsWith("@type snowflake-stats 1.") || firstLines.startsWith(Key.SNOWFLAKE_STATS_END.keyword + SP) || firstLines.contains(NL + Key.SNOWFLAKE_STATS_END.keyword + SP)) { diff --git a/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java b/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java index 8ec04a5..0da32ad 100644 --- a/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java +++ b/src/main/java/org/torproject/descriptor/impl/DescriptorReaderImpl.java @@ -330,6 +330,8 @@ public class DescriptorReaderImpl implements DescriptorReader { InputStream is = fis; if (file.getName().endsWith(".gz")) { is = new GzipCompressorInputStream(fis); + } else if (file.getName().endsWith(".xz")) { + is = new XZCompressorInputStream(fis); } byte[] rawDescriptorBytes = IOUtils.toByteArray(is); if (rawDescriptorBytes.length > 0) { diff --git a/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java b/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java index feb551b..f8879d7 100644 --- a/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java +++ b/src/main/java/org/torproject/descriptor/impl/TorperfResultImpl.java @@ -23,7 +23,16 @@ public class TorperfResultImpl extends DescriptorImpl
private static final long serialVersionUID = 8961567618137500044L;
- protected static List<Descriptor> parseTorperfResults( + /** + * Parse the given descriptor to one or more {@link TorperfResult} instances. + * + * @param rawDescriptorBytes Bytes to parse + * @param descriptorFile Descriptor file containing the given bytes + * @return Parsed {@link TorperfResult} instances + * @throws DescriptorParseException Thrown if any of the lines cannot be + * parsed. + */ + public static List<Descriptor> parseTorperfResults( byte[] rawDescriptorBytes, File descriptorFile) throws DescriptorParseException { if (rawDescriptorBytes.length == 0) { diff --git a/src/main/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverter.java b/src/main/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverter.java new file mode 100644 index 0000000..1f9b1f8 --- /dev/null +++ b/src/main/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverter.java @@ -0,0 +1,326 @@ +/* Copyright 2020 The Tor Project + * See LICENSE for licensing information */ + +package org.torproject.descriptor.onionperf; + +import org.torproject.descriptor.Descriptor; +import org.torproject.descriptor.DescriptorParseException; +import org.torproject.descriptor.impl.TorperfResultImpl; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Converter that takes an OnionPerf analysis document as input and provides one + * or more {@link org.torproject.descriptor.TorperfResult} instances as output. + * + * <p>This conversion matches {@code tgen} transfers and {@code tor} streams by + * stream port and transfer/stream end timestamps. This is different from the + * approach taken in OnionPerf's analyze mode which only matches by stream + * port. The result is that converted Torperf results might contain different + * path or build time information as Torperf results written by OnionPerf.</p> + */ +public class OnionPerfAnalysisConverter { + + /** + * Uncompressed OnionPerf analysis file bytes. + */ + private final byte[] rawDescriptorBytes; + + /** + * OnionPerf analysis file. + */ + private final File descriptorFile; + + /** + * Converted Torperf results. + */ + private List<Descriptor> convertedTorperfResults; + + /** + * Construct a new instance from the given bytes and file reference. + * + * @param rawDescriptorBytes Uncompressed document bytes. + * @param descriptorFile Document file reference. + */ + public OnionPerfAnalysisConverter(byte[] rawDescriptorBytes, + File descriptorFile) { + this.rawDescriptorBytes = rawDescriptorBytes; + this.descriptorFile = descriptorFile; + } + + /** + * Parse the OnionPerf analysis JSON document, do some basic verification, and + * convert its contents to {@link org.torproject.descriptor.TorperfResult} + * descriptors. + * + * @return Converted transfers. + * @throws DescriptorParseException Thrown if something goes wrong while + * parsing, verifying, or converting the OnionPerf analysis file to + * Torperf results. + */ + public List<Descriptor> asTorperfResults() throws DescriptorParseException { + ParsedOnionPerfAnalysis parsedOnionPerfAnalysis; + try { + parsedOnionPerfAnalysis = ParsedOnionPerfAnalysis.fromBytes( + this.rawDescriptorBytes); + } catch (IOException ioException) { + throw new DescriptorParseException("Ran into an I/O error while " + + "attempting to parse an OnionPerf analysis document.", + ioException); + } + this.verifyDocumentTypeAndVersion(parsedOnionPerfAnalysis); + StringBuilder formattedTorperfResults + = this.formatTorperfResults(parsedOnionPerfAnalysis); + this.parseFormattedTorperfResults(formattedTorperfResults); + return this.convertedTorperfResults; + } + + /** + * Verify document type and version and throw an exception when either of the + * two indicates that we cannot process the document. + * + * @param parsedOnionPerfAnalysis Parsed OnionPerf analysis document. + * @throws DescriptorParseException Thrown if either type or version indicate + * that we cannot process the document. + */ + private void verifyDocumentTypeAndVersion( + ParsedOnionPerfAnalysis parsedOnionPerfAnalysis) + throws DescriptorParseException { + if (!"onionperf".equals(parsedOnionPerfAnalysis.type)) { + throw new DescriptorParseException("Parsed OnionPerf analysis file does " + + "not contain type information."); + } + if (null == parsedOnionPerfAnalysis.version) { + throw new DescriptorParseException("Parsed OnionPerf analysis file does " + + "not contain version information."); + } else if ((parsedOnionPerfAnalysis.version instanceof Double + && (double) parsedOnionPerfAnalysis.version > 1.999) + || (parsedOnionPerfAnalysis.version instanceof String + && !((String) parsedOnionPerfAnalysis.version).startsWith("1."))) { + throw new DescriptorParseException("Parsed OnionPerf analysis file " + + "contains unsupported version " + parsedOnionPerfAnalysis.version + + "."); + } + } + + /** + * Format the parsed OnionPerf analysis file as one or more Torperf result + * strings. + * + * @param parsedOnionPerfAnalysis Parsed OnionPerf analysis document. + */ + private StringBuilder formatTorperfResults( + ParsedOnionPerfAnalysis parsedOnionPerfAnalysis) { + StringBuilder formattedTorperfResults = new StringBuilder(); + Map<String, String> errorCodes = new HashMap<>(); + errorCodes.put("AUTH", "TGEN/AUTH"); + errorCodes.put("READ", "TGEN/READ"); + errorCodes.put("STALLOUT", "TGEN/STALLOUT"); + errorCodes.put("TIMEOUT", "TGEN/TIMEOUT"); + errorCodes.put("PROXY", "TOR"); + errorCodes.put("PROXY_CANT_ATTACH", "TOR/CANT_ATTACH"); + errorCodes.put("PROXY_DESTROY", "TOR/DESTROY"); + errorCodes.put("PROXY_END_TIMEOUT", "TOR/END/TIMEOUT"); + errorCodes.put("PROXY_END_CONNECTREFUSED", "TOR/END/CONNECTREFUSED"); + errorCodes.put("PROXY_RESOLVEFAILED", "TOR/RESOLVEFAILED"); + errorCodes.put("PROXY_TIMEOUT", "TOR/TIMEOUT"); + for (Map.Entry<String, ParsedOnionPerfAnalysis.MeasurementData> data + : parsedOnionPerfAnalysis.data.entrySet()) { + String nickname = data.getKey(); + ParsedOnionPerfAnalysis.MeasurementData measurements = data.getValue(); + if (null == measurements.measurementIp || null == measurements.tgen + || null == measurements.tgen.transfers) { + continue; + } + String measurementIp = measurements.measurementIp; + Map<String, List<ParsedOnionPerfAnalysis.Stream>> streamsBySourcePort + = new HashMap<>(); + Map<String, ParsedOnionPerfAnalysis.Circuit> circuitsByCircuitId + = new HashMap<>(); + if (null != measurements.tor) { + circuitsByCircuitId = measurements.tor.circuits; + if (null != measurements.tor.streams) { + for (ParsedOnionPerfAnalysis.Stream stream + : measurements.tor.streams.values()) { + if (null != stream.source && stream.source.contains(":")) { + String sourcePort = stream.source.split(":")[1]; + streamsBySourcePort.putIfAbsent(sourcePort, new ArrayList<>()); + streamsBySourcePort.get(sourcePort).add(stream); + } + } + } + } + for (ParsedOnionPerfAnalysis.Transfer transfer + : measurements.tgen.transfers.values()) { + if (null == transfer.endpointLocal) { + continue; + } + String[] endpointLocalParts = transfer.endpointLocal.split(":"); + if (endpointLocalParts.length < 3) { + continue; + } + TorperfResultsBuilder torperfResultsBuilder + = new TorperfResultsBuilder(); + + torperfResultsBuilder.addString("SOURCE", nickname); + torperfResultsBuilder.addString("SOURCEADDRESS", measurementIp); + this.formatTransferParts(torperfResultsBuilder, transfer); + List<String> errorCodeParts = null; + if (transfer.isError) { + errorCodeParts = new ArrayList<>(); + errorCodeParts.add(transfer.errorCode); + } + String sourcePort = endpointLocalParts[2]; + if (streamsBySourcePort.containsKey(sourcePort)) { + for (ParsedOnionPerfAnalysis.Stream stream + : streamsBySourcePort.get(sourcePort)) { + if (Math.abs(transfer.unixTsEnd - stream.unixTsEnd) < 150.0) { + if (null != errorCodeParts && null != stream.failureReasonLocal) { + errorCodeParts.add(stream.failureReasonLocal); + if (null != stream.failureReasonRemote) { + errorCodeParts.add(stream.failureReasonRemote); + } + } + if (null != stream.circuitId + && circuitsByCircuitId.containsKey(stream.circuitId)) { + ParsedOnionPerfAnalysis.Circuit circuit + = circuitsByCircuitId.get(stream.circuitId); + this.formatStreamParts(torperfResultsBuilder, stream); + this.formatCircuitParts(torperfResultsBuilder, circuit); + } + } + } + } + if (null != errorCodeParts) { + String errorCode = String.join("_", errorCodeParts); + torperfResultsBuilder.addString("ERRORCODE", + errorCodes.getOrDefault(errorCode, errorCode)); + } + formattedTorperfResults.append(torperfResultsBuilder.build()); + } + } + return formattedTorperfResults; + } + + /** + * Parse the previously formatted Torperf results. + * + * @param formattedTorperfResults Formatted Torperf result strings. + * @throws DescriptorParseException Thrown when an error occurs while parsing + * a previously formatted {@link org.torproject.descriptor.TorperfResult} + * string. + */ + private void parseFormattedTorperfResults( + StringBuilder formattedTorperfResults) throws DescriptorParseException { + this.convertedTorperfResults = TorperfResultImpl.parseTorperfResults( + formattedTorperfResults.toString().getBytes(), this.descriptorFile); + } + + /** + * Format relevant tgen transfer data as Torperf result key-value pairs. + * + * @param torperfResultsBuilder Torperf results builder to add key-value pairs + * to. + * @param transfer Transfer data obtained from the parsed OnionPerf analysis + * file. + */ + private void formatTransferParts(TorperfResultsBuilder torperfResultsBuilder, + ParsedOnionPerfAnalysis.Transfer transfer) { + torperfResultsBuilder.addString("ENDPOINTLOCAL", transfer.endpointLocal); + torperfResultsBuilder.addString("ENDPOINTPROXY", transfer.endpointProxy); + torperfResultsBuilder.addString("ENDPOINTREMOTE", transfer.endpointRemote); + torperfResultsBuilder.addString("HOSTNAMELOCAL", transfer.hostnameLocal); + torperfResultsBuilder.addString("HOSTNAMEREMOTE", transfer.hostnameRemote); + torperfResultsBuilder.addInteger("FILESIZE", transfer.filesizeBytes); + torperfResultsBuilder.addInteger("READBYTES", transfer.totalBytesRead); + torperfResultsBuilder.addInteger("WRITEBYTES", transfer.totalBytesWrite); + torperfResultsBuilder.addInteger("DIDTIMEOUT", 0); + for (String key : new String[] { "START", "SOCKET", "CONNECT", "NEGOTIATE", + "REQUEST", "RESPONSE", "DATAREQUEST", "DATARESPONSE", "DATACOMPLETE", + "LAUNCH", "DATAPERC10", "DATAPERC20", "DATAPERC30", "DATAPERC40", + "DATAPERC50", "DATAPERC60", "DATAPERC70", "DATAPERC80", "DATAPERC90", + "DATAPERC100" }) { + torperfResultsBuilder.addString(key, "0.0"); + } + torperfResultsBuilder.addTimestamp("START", transfer.unixTsStart, 0.0); + if (null != transfer.unixTsStart && null != transfer.elapsedSeconds) { + torperfResultsBuilder.addTimestamp("SOCKET", transfer.unixTsStart, + transfer.elapsedSeconds.socketCreate); + torperfResultsBuilder.addTimestamp("CONNECT", transfer.unixTsStart, + transfer.elapsedSeconds.socketConnect); + torperfResultsBuilder.addTimestamp("NEGOTIATE", transfer.unixTsStart, + transfer.elapsedSeconds.proxyChoice); + torperfResultsBuilder.addTimestamp("REQUEST", transfer.unixTsStart, + transfer.elapsedSeconds.proxyRequest); + torperfResultsBuilder.addTimestamp("RESPONSE", transfer.unixTsStart, + transfer.elapsedSeconds.proxyResponse); + torperfResultsBuilder.addTimestamp("DATAREQUEST", transfer.unixTsStart, + transfer.elapsedSeconds.command); + torperfResultsBuilder.addTimestamp("DATARESPONSE", transfer.unixTsStart, + transfer.elapsedSeconds.response); + if (null != transfer.elapsedSeconds.payloadProgress) { + for (Map.Entry<String, Double> payloadProgressEntry + : transfer.elapsedSeconds.payloadProgress.entrySet()) { + String key = String.format("DATAPERC%.0f", + Double.parseDouble(payloadProgressEntry.getKey()) * 100.0); + Double elapsedSeconds = payloadProgressEntry.getValue(); + torperfResultsBuilder.addTimestamp(key, transfer.unixTsStart, + elapsedSeconds); + } + } + torperfResultsBuilder.addTimestamp("DATACOMPLETE", transfer.unixTsStart, + transfer.elapsedSeconds.lastByte); + if (transfer.isError) { + torperfResultsBuilder.addInteger("DIDTIMEOUT", 1); + } + } + } + + /** + * Format relevant stream data as Torperf result key-value pairs. + * + * @param torperfResultsBuilder Torperf results builder to add key-value pairs + * to. + * @param stream Stream data obtained from the parsed OnionPerf analysis file. + */ + private void formatStreamParts(TorperfResultsBuilder torperfResultsBuilder, + ParsedOnionPerfAnalysis.Stream stream) { + torperfResultsBuilder.addTimestamp("USED_AT", stream.unixTsEnd, 0.0); + torperfResultsBuilder.addInteger("USED_BY", stream.streamId); + } + + /** + * Format relevant circuit data as Torperf result key-value pairs. + * + * @param torperfResultsBuilder Torperf results builder to add key-value pairs + * to. + * @param circuit Circuit data obtained from the parsed OnionPerf analysis + * file. + */ + private void formatCircuitParts(TorperfResultsBuilder torperfResultsBuilder, + ParsedOnionPerfAnalysis.Circuit circuit) { + torperfResultsBuilder.addTimestamp("LAUNCH", circuit.unixTsStart, 0.0); + if (null != circuit.path) { + List<String> path = new ArrayList<>(); + List<String> buildTimes = new ArrayList<>(); + for (Object[] pathElement : circuit.path) { + String fingerprintAndNickname = (String) pathElement[0]; + String fingerprint = fingerprintAndNickname.split("~")[0]; + path.add(fingerprint); + buildTimes.add(String.format("%.2f", (Double) pathElement[1])); + } + torperfResultsBuilder.addString("PATH", String.join(",", path)); + torperfResultsBuilder.addString("BUILDTIMES", + String.join(",", buildTimes)); + torperfResultsBuilder.addInteger("TIMEOUT", circuit.buildTimeout); + torperfResultsBuilder.addDouble("QUANTILE", circuit.buildQuantile); + torperfResultsBuilder.addInteger("CIRC_ID", circuit.circuitId); + } + } +} + diff --git a/src/main/java/org/torproject/descriptor/onionperf/ParsedOnionPerfAnalysis.java b/src/main/java/org/torproject/descriptor/onionperf/ParsedOnionPerfAnalysis.java new file mode 100644 index 0000000..679879e --- /dev/null +++ b/src/main/java/org/torproject/descriptor/onionperf/ParsedOnionPerfAnalysis.java @@ -0,0 +1,329 @@ +/* Copyright 2020 The Tor Project + * See LICENSE for licensing information */ + +package org.torproject.descriptor.onionperf; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; + +import java.io.IOException; +import java.util.Map; + +/** + * Parsed OnionPerf analysis document with all relevant fields for + * {@link OnionPerfAnalysisConverter} to convert contained measurements to + * {@link org.torproject.descriptor.TorperfResult} instances. + */ +public class ParsedOnionPerfAnalysis { + + /** + * Object mapper for deserializing OnionPerf analysis documents to instances + * of this class. + */ + private static final ObjectMapper objectMapper = new ObjectMapper() + .setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE) + .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + /** + * Deserialize an OnionPerf analysis document from the given uncompressed + * bytes. + * + * @param bytes Uncompressed contents of the OnionPerf analysis to + * deserialize. + * @return Parsed OnionPerf analysis document. + * @throws IOException Thrown if something goes wrong while deserializing the + * given JSON document, but before doing any verification or + * postprocessing. + */ + static ParsedOnionPerfAnalysis fromBytes(byte[] bytes) throws IOException { + return objectMapper.readValue(bytes, ParsedOnionPerfAnalysis.class); + } + + /** + * OnionPerf measurement data by source nickname. + */ + Map<String, MeasurementData> data; + + /** + * Descriptor type, which should always be {@code "onionperf"} for OnionPerf + * analysis documents. + */ + String type; + + /** + * Document version, which is either a {@link Double} in version 1.0 or a + * {@link String} in subsequent versions. + */ + Object version; + + /** + * Measurement data obtained from client-side {@code tgen} and {@code tor} + * controller event logs. + */ + static class MeasurementData { + + /** + * Public IP address of the OnionPerf host obtained by connecting to + * well-known servers and finding the IP address in the result, which may be + * {@code "unknown"} if OnionPerf was not able to find this information. + */ + String measurementIp; + + /** + * Measurement data obtained from client-side {@code tgen} logs. + */ + TgenData tgen; + + /** + * Measurement data obtained from client-side {@code tor} controller event + * logs. + */ + TorData tor; + } + + /** + * Measurement data obtained from client-side {@code tgen} logs. + */ + static class TgenData { + + /** + * Measurement data by transfer identifier. + */ + Map<String, Transfer> transfers; + } + + /** + * Measurement data related to a single transfer obtained from client-side + * {@code tgen} logs. + */ + static class Transfer { + + /** + * Elapsed seconds between starting a transfer at {@link #unixTsStart} and + * reaching a set of pre-defined states. + */ + ElapsedSeconds elapsedSeconds; + + /** + * Hostname, IP address, and port that the {@code tgen} client used to + * connect to the local {@code tor} SOCKS port, formatted as + * {@code "hostname:ip:port"}, which may be {@code "NULL:0.0.0.0:0"} if + * {@code tgen} was not able to find this information. + */ + String endpointLocal; + + /** + * Hostname, IP address, and port that the {@code tgen} client used to + * connect to the SOCKS proxy server that {@code tor} runs, formatted as + * {@code "hostname:ip:port"}, which may be {@code "NULL:0.0.0.0:0"} if + * {@code tgen} was not able to find this information. + */ + String endpointProxy; + + /** + * Hostname, IP address, and port that the {@code tgen} client used to + * connect to the remote server, formatted as {@code "hostname:ip:port"}, + * which may be {@code "NULL:0.0.0.0:0"} if {@code tgen} was not able to + * find this information. + */ + String endpointRemote; + + /** + * Error code reported in the client {@code tgen} logs, which can be + * {@code "NONE"} if no error was encountered, {@code "PROXY"} in case of an + * error in {@code tor}, or something else for {@code tgen}-specific errors. + */ + String errorCode; + + /** + * File size in bytes of the requested file in this transfer. + */ + Integer filesizeBytes; + + /** + * Client machine hostname, which may be {@code "(NULL)"} if the + * {@code tgen} client was not able to find this information. + */ + String hostnameLocal; + + /** + * Server machine hostname, which may be {@code "(NULL)"} if the + * {@code tgen} server was not able to find this information. + */ + String hostnameRemote; + + /** + * Whether or not an error was encountered in this transfer. + */ + Boolean isError; + + /** + * Total number of bytes read in this transfer. + */ + Integer totalBytesRead; + + /** + * Total number of bytes written in this transfer. + */ + Integer totalBytesWrite; + + /** + * Unix timestamp when this transfer started. + */ + Double unixTsStart; + + /** + * Unix timestamp when this transfer ended. + */ + Double unixTsEnd; + } + + /** + * Elapsed seconds between starting a transfer and reaching a set of + * pre-defined states. + */ + static class ElapsedSeconds { + + /** + * Time until the HTTP request was written. + */ + Double command; + + /** + * Time until the payload was complete. + */ + Double lastByte; + + /** + * Time until the given fraction of expected bytes were read. + */ + Map<String, Double> payloadProgress; + + /** + * Time until SOCKS 5 authentication methods have been negotiated. + */ + Double proxyChoice; + + /** + * Time until the SOCKS request was sent. + */ + Double proxyRequest; + + /** + * Time until the SOCKS response was received. + */ + Double proxyResponse; + + /** + * Time until the first response was received. + */ + Double response; + + /** + * Time until the socket was connected. + */ + Double socketConnect; + + /** + * Time until the socket was created. + */ + Double socketCreate; + } + + /** + * Measurement data obtained from client-side {@code tor} controller event + * logs. + */ + static class TorData { + + /** + * Circuits by identifier. + */ + Map<String, Circuit> circuits; + + /** + * Streams by identifier. + */ + Map<String, Stream> streams; + } + + /** + * Measurement data related to a single circuit obtained from client-side + * {@code tor} controller event logs. + */ + static class Circuit { + + /** + * Circuit build time quantile that the {@code tor} client uses to determine + * its circuit-build timeout. + */ + Double buildQuantile; + + /** + * Circuit build timeout in milliseconds that the {@code tor} client used + * when building this circuit. + */ + Integer buildTimeout; + + /** + * Circuit identifier. + */ + Integer circuitId; + + /** + * Path information as two-dimensional array with a mixed-type + * {@link Object[]} for each hop with {@code "$fingerprint~nickname"} as + * first element and elapsed seconds between creating and extending the + * circuit as second element. + */ + Object[][] path; + + /** + * Unix timestamp at the start of this circuit's lifetime. + */ + Double unixTsStart; + } + + /** + * Measurement data related to a single stream obtained from client-side + * {@code tor} controller event logs. + */ + static class Stream { + + /** + * Circuit identifier of the circuit that this stream was attached to. + */ + String circuitId; + + /** + * Local reason why this stream failed. + */ + String failureReasonLocal; + + /** + * Remote reason why this stream failed. + */ + String failureReasonRemote; + + /** + * Source address and port that requested the connection. + */ + String source; + + /** + * Stream identifier. + */ + Integer streamId; + + /** + * Unix timestamp at the end of this stream's lifetime. + */ + Double unixTsEnd; + } +} + diff --git a/src/main/java/org/torproject/descriptor/onionperf/TorperfResultsBuilder.java b/src/main/java/org/torproject/descriptor/onionperf/TorperfResultsBuilder.java new file mode 100644 index 0000000..a99257a --- /dev/null +++ b/src/main/java/org/torproject/descriptor/onionperf/TorperfResultsBuilder.java @@ -0,0 +1,101 @@ +/* Copyright 2020 The Tor Project + * See LICENSE for licensing information */ + +package org.torproject.descriptor.onionperf; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +/** + * Builder that accepts key-value pairs and produces a single line in the + * Torperf results format. + */ +public class TorperfResultsBuilder { + + /** + * Key-value pairs to be formatted as Torperf results line. + */ + private final SortedMap<String, String> keyValuePairs = new TreeMap<>(); + + /** + * Add a string value, unless it is {@code null}. + * + * @param key Key + * @param stringValue String value + */ + void addString(String key, String stringValue) { + if (null != stringValue) { + this.keyValuePairs.put(key, stringValue); + } + } + + /** + * Add an int value, unless it is {@code null}. + * + * @param key Key. + * @param integerValue Int value. + */ + void addInteger(String key, Integer integerValue) { + if (null != integerValue) { + keyValuePairs.put(key, String.valueOf(integerValue)); + } + } + + /** + * Add a double value, unless it is {@code null}. + * + * @param key Key. + * @param doubleValue Double value. + */ + void addDouble(String key, Double doubleValue) { + if (null != doubleValue) { + keyValuePairs.put(key, String.valueOf(doubleValue)); + } + } + + /** + * Add a timestamp value as the sum of two double values, formatted as seconds + * since the epoch with two decimal places, unless the first summand is + * {@code null}. + * + * @param key Key. + * @param unixTsStart First summand representing seconds since the epoch. + * @param elapsedSeconds Second summand representing seconds elapsed since the + * first summand. + */ + void addTimestamp(String key, Double unixTsStart, Double elapsedSeconds) { + if (null != unixTsStart) { + if (null != elapsedSeconds) { + keyValuePairs.put(key, String.format("%.2f", + unixTsStart + elapsedSeconds)); + } else { + keyValuePairs.put(key, String.format("%.2f", unixTsStart)); + } + } + } + + /** + * Build the Torperf results line by putting together all key-value pairs as + * {@code "key=value"}, separated by spaces, prefixed by an annotation line + * {@code "@type torperf 1.1"}. + * + * @return Torperf results line using the same format as OnionPerf would + * write it. + */ + String build() { + StringBuilder result = new StringBuilder(); + result.append("@type torperf 1.1\r\n"); + List<String> torperfResultsParts = new ArrayList<>(); + for (Map.Entry<String, String> keyValuePairsEntry + : this.keyValuePairs.entrySet()) { + torperfResultsParts.add(String.format("%s=%s", + keyValuePairsEntry.getKey(), keyValuePairsEntry.getValue())); + } + result.append(String.join(" ", torperfResultsParts)).append("\r\n"); + return result.toString(); + } +} + diff --git a/src/test/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverterTest.java b/src/test/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverterTest.java new file mode 100644 index 0000000..d840dd2 --- /dev/null +++ b/src/test/java/org/torproject/descriptor/onionperf/OnionPerfAnalysisConverterTest.java @@ -0,0 +1,108 @@ +/* Copyright 2020 The Tor Project + * See LICENSE for licensing information */ + +package org.torproject.descriptor.onionperf; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.torproject.descriptor.Descriptor; +import org.torproject.descriptor.DescriptorParseException; +import org.torproject.descriptor.TorperfResult; + +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream; +import org.apache.commons.compress.utils.IOUtils; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; + +public class OnionPerfAnalysisConverterTest { + + private final String torperfResultTransfer1m1 + = "BUILDTIMES=0.15,0.22,0.34 CIRC_ID=39 CONNECT=1587991280.37 " + + "DATACOMPLETE=1587991286.62 DATAPERC0=1587991283.81 " + + "DATAPERC10=1587991284.15 DATAPERC100=1587991286.62 " + + "DATAPERC20=1587991284.38 DATAPERC30=1587991284.66 " + + "DATAPERC40=1587991284.93 DATAPERC50=1587991285.14 " + + "DATAPERC60=1587991285.33 DATAPERC70=1587991285.67 " + + "DATAPERC80=1587991285.85 DATAPERC90=1587991286.14 " + + "DATAREQUEST=1587991283.36 DATARESPONSE=1587991283.81 DIDTIMEOUT=0 " + + "ENDPOINTLOCAL=localhost:127.0.0.1:40878 " + + "ENDPOINTPROXY=localhost:127.0.0.1:35900 " + + "ENDPOINTREMOTE=m3eahz7co6lzi6jn.onion:0.0.0.0:443 FILESIZE=1048576 " + + "HOSTNAMELOCAL=op-nl2 HOSTNAMEREMOTE=op-nl2 LAUNCH=1587991281.38 " + + "NEGOTIATE=1587991280.37 " + + "PATH=$970F0966DAA7EBDEE44E3772045527A6854E997B," + + "$8101421BEFCCF4C271D5483C5AABCAAD245BBB9D," + + "$1A7A2516A961F2838F7F94786A8811BE82F9CFFE READBYTES=1048643 " + + "REQUEST=1587991280.38 RESPONSE=1587991280.37 SOCKET=1587991280.37 " + + "SOURCE=op-nl2 SOURCEADDRESS=unknown START=1587991280.37 " + + "USED_AT=1587991286.62 USED_BY=71 WRITEBYTES=53"; + + private final String torperfResultTransfer1m3 + = "BUILDTIMES=22.81,23.57,24.45 CIRC_ID=72 CONNECT=1587991880.37 " + + "DATACOMPLETE=1587991927.74 DATAPERC0=1587991910.74 " + + "DATAPERC10=1587991913.71 DATAPERC100=1587991927.74 " + + "DATAPERC20=1587991916.00 DATAPERC30=1587991917.92 " + + "DATAPERC40=1587991919.69 DATAPERC50=1587991921.80 " + + "DATAPERC60=1587991923.35 DATAPERC70=1587991924.91 " + + "DATAPERC80=1587991925.77 DATAPERC90=1587991927.04 " + + "DATAREQUEST=1587991909.80 DATARESPONSE=1587991910.74 DIDTIMEOUT=0 " + + "ENDPOINTLOCAL=localhost:127.0.0.1:41016 " + + "ENDPOINTPROXY=localhost:127.0.0.1:35900 " + + "ENDPOINTREMOTE=3czoq6qyehjio6lcdo4tb4vk5uv2bm4gfk5iacnawza22do6klsj7wy" + + "d.onion:0.0.0.0:443 FILESIZE=1048576 HOSTNAMELOCAL=op-nl2 " + + "HOSTNAMEREMOTE=op-nl2 LAUNCH=1587991881.70 NEGOTIATE=1587991880.37 " + + "PATH=$D5C6F62A5D1B3C711CA5E6F9D3772A432E96F6C2," + + "$94EC34B871936504BE70671B44760BC99242E1F3," + + "$E0F638ECCE918B5455CE29D2CD9ECC9DBD8F8B21 READBYTES=1048643 " + + "REQUEST=1587991880.37 RESPONSE=1587991880.37 SOCKET=1587991880.37 " + + "SOURCE=op-nl2 SOURCEADDRESS=unknown START=1587991880.37 " + + "USED_AT=1587991927.74 USED_BY=112 WRITEBYTES=53"; + + private final String torperfResultTransfer50k2 + = "BUILDTIMES=0.09,0.15,0.27 CIRC_ID=49 CONNECT=1587991580.81 " + + "DATACOMPLETE=1587991580.80 DATAPERC10=0.0 DATAPERC100=0.0 " + + "DATAPERC20=0.0 DATAPERC30=0.0 DATAPERC40=0.0 DATAPERC50=0.0 " + + "DATAPERC60=0.0 DATAPERC70=0.0 DATAPERC80=0.0 DATAPERC90=0.0 " + + "DATAREQUEST=1587991580.80 DATARESPONSE=1587991580.80 DIDTIMEOUT=1 " + + "ENDPOINTLOCAL=localhost:127.0.0.1:40948 " + + "ENDPOINTPROXY=localhost:127.0.0.1:35900 " + + "ENDPOINTREMOTE=37.218.245.95:37.218.245.95:443 " + + "ERRORCODE=PROXY_END_MISC FILESIZE=51200 HOSTNAMELOCAL=op-nl2 " + + "HOSTNAMEREMOTE=(null) LAUNCH=1587991454.80 NEGOTIATE=1587991580.81 " + + "PATH=$12CF6DB4DAE106206D6C6B09988E865C0509843B," + + "$1DC17C4A52A458B5C8B1E79157F8665696210E10," + + "$39F17EC1BD41E652D1B80484D268E3933476FF42 READBYTES=0 " + + "REQUEST=1587991580.84 RESPONSE=1587991580.80 SOCKET=1587991580.81 " + + "SOURCE=op-nl2 SOURCEADDRESS=unknown START=1587991580.80 " + + "USED_AT=1587991580.84 USED_BY=93 WRITEBYTES=0"; + + @Test + public void testAsTorperfResults() throws IOException, + DescriptorParseException { + URL resouce = getClass().getClassLoader().getResource( + "onionperf/onionperf.analysis.json.xz"); + assertNotNull(resouce); + InputStream compressedInputStream = resouce.openStream(); + assertNotNull(compressedInputStream); + InputStream uncompressedInputStream = new XZCompressorInputStream( + compressedInputStream); + byte[] rawDescriptorBytes = IOUtils.toByteArray(uncompressedInputStream); + OnionPerfAnalysisConverter onionPerfAnalysisConverter + = new OnionPerfAnalysisConverter(rawDescriptorBytes, null); + for (Descriptor descriptor + : onionPerfAnalysisConverter.asTorperfResults()) { + assertTrue(descriptor instanceof TorperfResult); + String formattedTorperfResult + = new String(descriptor.getRawDescriptorBytes()).trim(); + assertNotNull(formattedTorperfResult); + assertTrue(formattedTorperfResult.equals(torperfResultTransfer1m1) + || formattedTorperfResult.equals(torperfResultTransfer1m3) + || formattedTorperfResult.equals(torperfResultTransfer50k2)); + } + } +} + diff --git a/src/test/resources/onionperf/onionperf.analysis.json.xz b/src/test/resources/onionperf/onionperf.analysis.json.xz new file mode 100644 index 0000000..41a4fa1 Binary files /dev/null and b/src/test/resources/onionperf/onionperf.analysis.json.xz differ