diff --git a/.gitignore b/.gitignore index 4dac6676..ff5ad994 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ **/.settings **/build .gradle -.project -.classpath +**/.project +**/.classpath **/*.rej **/*.orig diff --git a/core/src/main/groovy/com/muwire/core/Core.groovy b/core/src/main/groovy/com/muwire/core/Core.groovy index b93aa2a7..9ca8a284 100644 --- a/core/src/main/groovy/com/muwire/core/Core.groovy +++ b/core/src/main/groovy/com/muwire/core/Core.groovy @@ -85,6 +85,7 @@ import com.muwire.core.upload.UploadManager import com.muwire.core.util.MuWireLogManager import com.muwire.core.content.ContentControlEvent import com.muwire.core.content.ContentManager +import com.muwire.core.tracker.TrackerResponder import groovy.util.logging.Log import net.i2p.I2PAppContext @@ -112,7 +113,7 @@ public class Core { final Properties i2pOptions final MuWireSettings muOptions - private final I2PSession i2pSession; + final I2PSession i2pSession; final TrustService trustService final TrustSubscriber trustSubscriber private final PersisterService persisterService @@ -136,6 +137,7 @@ public class Core { private final FeedClient feedClient private final WatchedDirectoryConverter watchedDirectoryConverter final WatchedDirectoryManager watchedDirectoryManager + private final TrackerResponder trackerResponder private final Router router @@ -163,9 +165,9 @@ public class Core { i2pOptionsFile.withInputStream { i2pOptions.load(it) } if (!i2pOptions.containsKey("inbound.nickname")) - i2pOptions["inbound.nickname"] = "MuWire" + i2pOptions["inbound.nickname"] = tunnelName if (!i2pOptions.containsKey("outbound.nickname")) - i2pOptions["outbound.nickname"] = "MuWire" + i2pOptions["outbound.nickname"] = tunnelName } if (!(i2pOptions.hasProperty("i2np.ntcp.port") && i2pOptions.hasProperty("i2np.udp.port") @@ -371,6 +373,9 @@ public class Core { log.info("initializing upload manager") uploadManager = new UploadManager(eventBus, fileManager, meshManager, downloadManager, persisterFolderService, props) + + log.info("initializing tracker responder") + trackerResponder = new TrackerResponder(i2pSession, props, fileManager, downloadManager, meshManager, trustService, me) log.info("initializing connection establisher") connectionEstablisher = new ConnectionEstablisher(eventBus, i2pConnector, props, connectionManager, hostCache) @@ -450,6 +455,7 @@ public class Core { updateClient?.start() feedManager.start() feedClient.start() + trackerResponder.start() } public void shutdown() { @@ -489,6 +495,8 @@ public class Core { feedManager.stop() log.info("shutting down feed client") feedClient.stop() + log.info("shutting down tracker responder") + trackerResponder.stop() log.info("shutting down connection manager") connectionManager.shutdown() log.info("killing i2p session") diff --git a/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy b/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy index a290b745..f4aafc43 100644 --- a/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy +++ b/core/src/main/groovy/com/muwire/core/MuWireSettings.groovy @@ -31,6 +31,7 @@ class MuWireSettings { boolean shareHiddenFiles boolean searchComments boolean browseFiles + boolean allowTracking boolean fileFeed boolean advertiseFeed @@ -92,6 +93,7 @@ class MuWireSettings { outBw = Integer.valueOf(props.getProperty("outBw","128")) searchComments = Boolean.valueOf(props.getProperty("searchComments","true")) browseFiles = Boolean.valueOf(props.getProperty("browseFiles","true")) + allowTracking = Boolean.valueOf(props.getProperty("allowTracking","true")) // feed settings fileFeed = Boolean.valueOf(props.getProperty("fileFeed","true")) @@ -157,6 +159,7 @@ class MuWireSettings { props.setProperty("outBw", String.valueOf(outBw)) props.setProperty("searchComments", String.valueOf(searchComments)) props.setProperty("browseFiles", String.valueOf(browseFiles)) + props.setProperty("allowTracking", String.valueOf(allowTracking)) // feed settings props.setProperty("fileFeed", String.valueOf(fileFeed)) diff --git a/core/src/main/groovy/com/muwire/core/download/Pieces.groovy b/core/src/main/groovy/com/muwire/core/download/Pieces.groovy index 1c026d55..99609dd7 100644 --- a/core/src/main/groovy/com/muwire/core/download/Pieces.groovy +++ b/core/src/main/groovy/com/muwire/core/download/Pieces.groovy @@ -2,7 +2,7 @@ package com.muwire.core.download class Pieces { private final BitSet done, claimed - private final int nPieces + final int nPieces private final float ratio private final Random random = new Random() private final Map partials = new HashMap<>() diff --git a/core/src/main/groovy/com/muwire/core/mesh/Mesh.groovy b/core/src/main/groovy/com/muwire/core/mesh/Mesh.groovy index 95f0de87..227bc4d4 100644 --- a/core/src/main/groovy/com/muwire/core/mesh/Mesh.groovy +++ b/core/src/main/groovy/com/muwire/core/mesh/Mesh.groovy @@ -10,7 +10,7 @@ import net.i2p.util.ConcurrentHashSet class Mesh { private final InfoHash infoHash private final Set sources = new ConcurrentHashSet<>() - private final Pieces pieces + final Pieces pieces Mesh(InfoHash infoHash, Pieces pieces) { this.infoHash = infoHash diff --git a/core/src/main/groovy/com/muwire/core/tracker/TrackerResponder.groovy b/core/src/main/groovy/com/muwire/core/tracker/TrackerResponder.groovy new file mode 100644 index 00000000..c33c2e70 --- /dev/null +++ b/core/src/main/groovy/com/muwire/core/tracker/TrackerResponder.groovy @@ -0,0 +1,214 @@ +package com.muwire.core.tracker + +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.stream.Collectors + +import com.muwire.core.Constants +import com.muwire.core.InfoHash +import com.muwire.core.MuWireSettings +import com.muwire.core.Persona +import com.muwire.core.download.DownloadManager +import com.muwire.core.download.Pieces +import com.muwire.core.files.FileManager +import com.muwire.core.mesh.Mesh +import com.muwire.core.mesh.MeshManager +import com.muwire.core.trust.TrustLevel +import com.muwire.core.trust.TrustService +import com.muwire.core.util.DataUtil + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.util.logging.Log +import net.i2p.client.I2PSession +import net.i2p.client.I2PSessionMuxedListener +import net.i2p.client.SendMessageOptions +import net.i2p.client.datagram.I2PDatagramDissector +import net.i2p.client.datagram.I2PDatagramMaker +import net.i2p.data.Base64 + +@Log +class TrackerResponder { + private final I2PSession i2pSession + private final MuWireSettings muSettings + private final FileManager fileManager + private final DownloadManager downloadManager + private final MeshManager meshManager + private final TrustService trustService + private final Persona me + + private final Map uuids = new HashMap<>() + private final Timer expireTimer = new Timer("tracker-responder-timer", true) + + private static final long UUID_LIFETIME = 10 * 60 * 1000 + + TrackerResponder(I2PSession i2pSession, MuWireSettings muSettings, + FileManager fileManager, DownloadManager downloadManager, + MeshManager meshManager, TrustService trustService, + Persona me) { + this.i2pSession = i2pSession + this.muSettings = muSettings + this.fileManager = fileManager + this.downloadManager = downloadManager + this.meshManager = meshManager + this.trustService = trustService + this.me = me + } + + void start() { + i2pSession.addMuxedSessionListener(new Listener(), I2PSession.PROTO_DATAGRAM, Constants.TRACKER_PORT) + expireTimer.schedule({expireUUIDs()} as TimerTask, UUID_LIFETIME, UUID_LIFETIME) + } + + void stop() { + expireTimer.cancel() + } + + private void expireUUIDs() { + final long now = System.currentTimeMillis() + synchronized(uuids) { + for (Iterator iter = uuids.keySet().iterator(); iter.hasNext();) { + UUID uuid = iter.next(); + Long time = uuids.get(uuid) + if (now - time > UUID_LIFETIME) + iter.remove() + } + } + } + + private void respond(host, json) { + log.info("responding to host $host with json $json") + + def message = JsonOutput.toJson(json) + def maker = new I2PDatagramMaker(i2pSession) + message = maker.makeI2PDatagram(message.bytes) + def options = new SendMessageOptions() + options.setSendLeaseSet(false) + i2pSession.sendMessage(host, message, 0, message.length, I2PSession.PROTO_DATAGRAM, Constants.TRACKER_PORT, Constants.TRACKER_PORT, options) + } + + class Listener implements I2PSessionMuxedListener { + + @Override + public void messageAvailable(I2PSession session, int msgId, long size) { + } + + @Override + public void messageAvailable(I2PSession session, int msgId, long size, int proto, int fromport, int toport) { + if (proto != I2PSession.PROTO_DATAGRAM) { + log.warning "Received unexpected protocol $proto" + return + } + + byte[] payload = session.receiveMessage(msgId) + def dissector = new I2PDatagramDissector() + try { + dissector.loadI2PDatagram(payload) + def sender = dissector.getSender() + + log.info("got a tracker datagram from ${sender.toBase32()}") + + // if not trusted, just drop it + TrustLevel trustLevel = trustService.getLevel(sender) + + if (trustLevel == TrustLevel.DISTRUSTED || + (trustLevel == TrustLevel.NEUTRAL && !muSettings.allowUntrusted)) { + log.info("dropping, untrusted") + return + } + + payload = dissector.getPayload() + def slurper = new JsonSlurper() + def json = slurper.parse(payload) + + if (json.type != "TrackerPing") { + log.warning("unknown type $json.type") + return + } + + def response = [:] + response.type = "TrackerPong" + response.me = me.toBase64() + + if (json.infoHash == null) { + log.warning("infoHash missing") + return + } + + if (json.uuid == null) { + log.warning("uuid missing") + return + } + + UUID uuid = UUID.fromString(json.uuid) + synchronized(uuids) { + if (uuids.containsKey(uuid)) { + log.warning("duplicate uuid $uuid") + return + } + uuids.put(uuid, System.currentTimeMillis()) + } + response.uuid = json.uuid + + if (!muSettings.allowTracking) { + response.code = 403 + respond(sender, response) + return + } + + if (json.version != 1) { + log.warning("unknown version $json.version") + response.code = 400 + response.message = "I only support version 1" + respond(sender,response) + return + } + + byte[] infoHashBytes = Base64.decode(json.infoHash) + InfoHash infoHash = new InfoHash(infoHashBytes) + + log.info("servicing request for infoHash ${json.infoHash} with uuid ${json.uuid}") + + if (!(fileManager.isShared(infoHash) || downloadManager.isDownloading(infoHash))) { + response.code = 404 + respond(sender, response) + return + } + + Mesh mesh = meshManager.get(infoHash) + + if (fileManager.isShared(infoHash)) + response.code = 200 + else if (mesh != null) { + response.code = 206 + Pieces pieces = mesh.getPieces() + response.xHave = DataUtil.encodeXHave(pieces, pieces.getnPieces()) + } + + if (mesh != null) + response.altlocs = mesh.getRandom(10, me).stream().map({it.toBase64()}).collect(Collectors.toList()) + + respond(sender,response) + } catch (Exception e) { + log.log(Level.WARNING, "invalid datagram", e) + } + } + + @Override + public void reportAbuse(I2PSession session, int severity) { + } + + @Override + public void disconnected(I2PSession session) { + log.severe("session disconnected") + } + + @Override + public void errorOccurred(I2PSession session, String message, Throwable error) { + log.log(Level.SEVERE, message, error) + } + + } + + +} diff --git a/core/src/main/groovy/com/muwire/core/update/UpdateClient.groovy b/core/src/main/groovy/com/muwire/core/update/UpdateClient.groovy index 9175db04..87f62c82 100644 --- a/core/src/main/groovy/com/muwire/core/update/UpdateClient.groovy +++ b/core/src/main/groovy/com/muwire/core/update/UpdateClient.groovy @@ -2,6 +2,7 @@ package com.muwire.core.update import java.util.logging.Level +import com.muwire.core.Constants import com.muwire.core.EventBus import com.muwire.core.InfoHash import com.muwire.core.MuWireSettings @@ -63,7 +64,7 @@ class UpdateClient { } void start() { - session.addMuxedSessionListener(new Listener(), I2PSession.PROTO_DATAGRAM, 2) + session.addMuxedSessionListener(new Listener(), I2PSession.PROTO_DATAGRAM, Constants.UPDATE_PORT) timer.schedule({checkUpdate()} as TimerTask, 60000, 60 * 60 * 1000) } @@ -108,7 +109,7 @@ class UpdateClient { ping = maker.makeI2PDatagram(ping.bytes) def options = new SendMessageOptions() options.setSendLeaseSet(true) - session.sendMessage(UpdateServers.UPDATE_SERVER, ping, 0, ping.length, I2PSession.PROTO_DATAGRAM, 2, 0, options) + session.sendMessage(UpdateServers.UPDATE_SERVER, ping, 0, ping.length, I2PSession.PROTO_DATAGRAM, Constants.UPDATE_PORT, 0, options) } class Listener implements I2PSessionMuxedListener { diff --git a/core/src/main/java/com/muwire/core/Constants.java b/core/src/main/java/com/muwire/core/Constants.java index eef95fcc..5e92f6ef 100644 --- a/core/src/main/java/com/muwire/core/Constants.java +++ b/core/src/main/java/com/muwire/core/Constants.java @@ -17,5 +17,8 @@ public class Constants { public static final int MAX_COMMENT_LENGTH = 0x1 << 15; - public static final long MAX_QUERY_AGE = 5 * 60 * 1000L; + public static final long MAX_QUERY_AGE = 5 * 60 * 1000L; + + public static final int UPDATE_PORT = 2; + public static final int TRACKER_PORT = 3; } diff --git a/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy b/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy index 2d5ce460..4ab12114 100644 --- a/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy +++ b/gui/griffon-app/controllers/com/muwire/gui/OptionsController.groovy @@ -104,6 +104,10 @@ class OptionsController { model.browseFiles = browseFiles settings.browseFiles = browseFiles + boolean allowTracking = view.allowTrackingCheckbox.model.isSelected() + model.allowTracking = allowTracking + settings.allowTracking = allowTracking + text = view.speedSmoothSecondsField.text model.speedSmoothSeconds = Integer.valueOf(text) settings.speedSmoothSeconds = Integer.valueOf(text) diff --git a/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy b/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy index b0f62bc6..55a74f72 100644 --- a/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy +++ b/gui/griffon-app/models/com/muwire/gui/OptionsModel.groovy @@ -18,6 +18,7 @@ class OptionsModel { @Observable String incompleteLocation @Observable boolean searchComments @Observable boolean browseFiles + @Observable boolean allowTracking @Observable int speedSmoothSeconds @Observable int totalUploadSlots @Observable int uploadSlotsPerUser @@ -83,6 +84,7 @@ class OptionsModel { incompleteLocation = settings.incompleteLocation.getAbsolutePath() searchComments = settings.searchComments browseFiles = settings.browseFiles + allowTracking = settings.allowTracking speedSmoothSeconds = settings.speedSmoothSeconds totalUploadSlots = settings.totalUploadSlots uploadSlotsPerUser = settings.uploadSlotsPerUser diff --git a/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy b/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy index 5054232a..fe92a93c 100644 --- a/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy +++ b/gui/griffon-app/views/com/muwire/gui/OptionsView.groovy @@ -43,6 +43,7 @@ class OptionsView { def shareHiddenCheckbox def searchCommentsCheckbox def browseFilesCheckbox + def allowTrackingCheckbox def speedSmoothSecondsField def totalUploadSlotsField def uploadSlotsPerUserField @@ -107,6 +108,10 @@ class OptionsView { fill : GridBagConstraints.HORIZONTAL, weightx: 100)) browseFilesCheckbox = checkBox(selected : bind {model.browseFiles}, constraints : gbc(gridx : 1, gridy : 1, anchor : GridBagConstraints.LINE_END, fill : GridBagConstraints.HORIZONTAL, weightx: 0)) + label(text : "Allow tracking", constraints : gbc(gridx: 0, gridy: 2, anchor: GridBagConstraints.LINE_START, + fill : GridBagConstraints.HORIZONTAL, weightx: 100)) + allowTrackingCheckbox = checkBox(selected : bind {model.allowTracking}, constraints : gbc(gridx: 1, gridy : 2, + anchor : GridBagConstraints.LINE_END, fill : GridBagConstraints.HORIZONTAL, weightx : 0)) } panel (border : titledBorder(title : "Download Settings", border : etchedBorder(), titlePosition : TitledBorder.TOP, diff --git a/settings.gradle b/settings.gradle index 9803a55a..4ca40dca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,5 +5,6 @@ include 'core' include 'gui' include 'cli' include 'cli-lanterna' +include 'tracker' // include 'webui' // include 'plug' diff --git a/tracker/build.gradle b/tracker/build.gradle new file mode 100644 index 00000000..65b0f08e --- /dev/null +++ b/tracker/build.gradle @@ -0,0 +1,47 @@ +buildscript { + + repositories { + jcenter() + mavenLocal() + } + + dependencies { + classpath 'com.github.jengelman.gradle.plugins:shadow:5.2.0' + } +} + +plugins { + id 'org.springframework.boot' version '2.2.6.RELEASE' +} + +apply plugin : 'application' +apply plugin : 'io.spring.dependency-management' + +application { + mainClassName = 'com.muwire.tracker.Tracker' + applicationDefaultJvmArgs = ['-Djava.util.logging.config.file=logging.properties','-Xmx256M',"-Dbuild.version=${project.version}"] + applicationName = 'mwtrackerd' +} + +apply plugin : 'com.github.johnrengelman.shadow' + +springBoot { + buildInfo { + properties { + version = "${project.version}" + name = "mwtrackerd" + } + } +} + +dependencies { + compile project(":core") + compile 'com.github.briandilley.jsonrpc4j:jsonrpc4j:1.5.3' + + compile 'org.springframework.boot:spring-boot-starter' + compile 'org.springframework.boot:spring-boot-starter-actuator' + compile 'org.springframework.boot:spring-boot-starter-web' + + runtime 'javax.jws:jsr181-api:1.0-MR1' +} + diff --git a/tracker/src/main/groovy/com/muwire/tracker/Host.groovy b/tracker/src/main/groovy/com/muwire/tracker/Host.groovy new file mode 100644 index 00000000..eae0d24c --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/Host.groovy @@ -0,0 +1,28 @@ +package com.muwire.tracker + +import com.muwire.core.Persona + +/** + * A participant in a swarm. The same persona can be a member of multiple + * swarms, but in that case it would have multiple Host objects + */ +class Host { + final Persona persona + long lastPinged + long lastResponded + int failures + volatile String xHave + + Host(Persona persona) { + this.persona = persona + } + + boolean isExpired(long cutoff, int maxFailures) { + lastPinged > lastResponded && lastResponded <= cutoff && failures >= maxFailures + } + + @Override + public String toString() { + "Host:[${persona.getHumanReadableName()} lastPinged:$lastPinged lastResponded:$lastResponded failures:$failures xHave:$xHave]" + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/Pinger.groovy b/tracker/src/main/groovy/com/muwire/tracker/Pinger.groovy new file mode 100644 index 00000000..3668866b --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/Pinger.groovy @@ -0,0 +1,182 @@ +package com.muwire.tracker + +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level + +import javax.annotation.PostConstruct + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +import com.muwire.core.Constants +import com.muwire.core.Core +import com.muwire.core.Persona + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import groovy.util.logging.Log +import net.i2p.client.I2PSession +import net.i2p.client.I2PSessionMuxedListener +import net.i2p.client.SendMessageOptions +import net.i2p.client.datagram.I2PDatagramDissector +import net.i2p.client.datagram.I2PDatagramMaker +import net.i2p.data.Base64 + +@Component +@Log +class Pinger { + @Autowired + private Core core + + @Autowired + private SwarmManager swarmManager + + @Autowired + private TrackerProperties trackerProperties + + private final Map inFlight = new ConcurrentHashMap<>() + private final Timer expiryTimer = new Timer("pinger-timer",true) + + @PostConstruct + private void registerListener() { + core.getI2pSession().addMuxedSessionListener(new Listener(), I2PSession.PROTO_DATAGRAM, Constants.TRACKER_PORT) + expiryTimer.schedule({expirePings()} as TimerTask, 1000, 1000) + } + + private void expirePings() { + final long now = System.currentTimeMillis() + for(Iterator iter = inFlight.keySet().iterator(); iter.hasNext();) { + UUID uuid = iter.next() + PingInProgress ping = inFlight.get(uuid) + if (now - ping.pingTime > trackerProperties.getSwarmParameters().getPingTimeout() * 1000L) { + iter.remove() + swarmManager.fail(ping.target) + } + } + } + + void ping(SwarmManager.HostAndIH target, long now) { + UUID uuid = UUID.randomUUID() + def ping = new PingInProgress(target, now) + inFlight.put(uuid, ping) + + def message = [:] + message.type = "TrackerPing" + message.version = 1 + message.infoHash = Base64.encode(target.getInfoHash().getRoot()) + message.uuid = uuid.toString() + + message = JsonOutput.toJson(message) + def maker = new I2PDatagramMaker(core.getI2pSession()) + message = maker.makeI2PDatagram(message.bytes) + def options = new SendMessageOptions() + options.setSendLeaseSet(true) + core.getI2pSession().sendMessage(target.getHost().getPersona().getDestination(), message, 0, message.length, I2PSession.PROTO_DATAGRAM, + Constants.TRACKER_PORT, Constants.TRACKER_PORT, options) + } + + private static class PingInProgress { + private final SwarmManager.HostAndIH target + private final long pingTime + PingInProgress(SwarmManager.HostAndIH target, long pingTime) { + this.target = target + this.pingTime = pingTime + } + } + + private class Listener implements I2PSessionMuxedListener { + + @Override + public void messageAvailable(I2PSession session, int msgId, long size) { + } + + @Override + public void messageAvailable(I2PSession session, int msgId, long size, int proto, int fromport, int toport) { + if (proto != I2PSession.PROTO_DATAGRAM) { + log.warning("received unexpected protocol $proto") + return + } + + byte [] payload = session.receiveMessage(msgId) + def dissector = new I2PDatagramDissector() + try { + dissector.loadI2PDatagram(payload) + def sender = dissector.getSender() + + log.info("got a response from ${sender.toBase32()}") + + payload = dissector.getPayload() + def slurper = new JsonSlurper() + def json = slurper.parse(payload) + + if (json.type != "TrackerPong") { + log.warning("unknown type ${json.type}") + return + } + + if (json.me == null) { + log.warning("sender persona missing") + return + } + + Persona senderPersona = new Persona(new ByteArrayInputStream(Base64.decode(json.me))) + if (sender != senderPersona.getDestination()) { + log.warning("persona in payload does not match sender ${senderPersona.getHumanReadableName()}") + return + } + + if (json.uuid == null) { + log.warning("uuid missing") + return + } + + UUID uuid = UUID.fromString(json.uuid) + def ping = inFlight.remove(uuid) + + if (ping == null) { + log.warning("no ping in progress for $uuid") + return + } + + if (json.code == null) { + log.warning("no code") + return + } + + int code = json.code + + if (json.xHave != null) + ping.target.host.xHave = json.xHave + + + Set altlocs = new HashSet<>() + json.altlocs?.collect(altlocs,{ new Persona(new ByteArrayInputStream(Base64.decode(it))) }) + + log.info("For ${ping.target.infoHash} received code $code and altlocs ${altlocs.size()}") + + swarmManager.handleResponse(ping.target, code, altlocs) + + } catch (Exception e) { + log.log(Level.WARNING,"invalid datagram",e) + } + + } + + @Override + public void reportAbuse(I2PSession session, int severity) { + log.warning("reportabuse $session $severity") + } + + @Override + public void disconnected(I2PSession session) { + log.severe("disconnected") + } + + @Override + public void errorOccurred(I2PSession session, String message, Throwable error) { + log.log(Level.SEVERE,message,error) + } + + } + +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/SetupWizard.groovy b/tracker/src/main/groovy/com/muwire/tracker/SetupWizard.groovy new file mode 100644 index 00000000..48f9ae00 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/SetupWizard.groovy @@ -0,0 +1,71 @@ +package com.muwire.tracker + +class SetupWizard { + + private final File home + + SetupWizard(File home) { + this.home = home + } + + Properties performSetup() { + println "**** Welcome to mwtrackerd setup wizard *****" + println "This wizard ask you some questions and configure the settings for the MuWire tracker daemon." + println "The settings will be saved in ${home.getAbsolutePath()} where you can edit them manually if you wish." + println "You can re-run this wizard by launching mwtrackerd with the \"setup\" argument." + println "*****************" + + Scanner scanner = new Scanner(System.in) + + Properties rv = new Properties() + + // nickname + while(true) { + println "Please select a nickname for your tracker" + String nick = scanner.nextLine() + if (nick.trim().length() == 0) { + println "nickname cannot be empty" + continue + } + rv['nickname'] = nick + break + } + + + // i2cp host and port + println "Enter the address of an I2P or I2Pd router to connect to. (default is 127.0.0.1)" + String i2cpHost = scanner.nextLine() + if (i2cpHost.trim().length() == 0) + i2cpHost = "127.0.0.1" + rv['i2cp.tcp.host'] = i2cpHost + + println "Enter the port of the I2CP interface of the I2P[d] router (default is 7654)" + String i2cpPort = scanner.nextLine() + if (i2cpPort.trim().length() == 0) + i2cpPort = "7654" + rv['i2cp.tcp.port'] = i2cpPort + + // json-rpc interface + println "Enter the address to which to bind the JSON-RPC interface of the tracker." + println "Default is 127.0.0.1. If you want to allow JSON-RPC connections from other hosts you can enter 0.0.0.0" + String jsonRpcIface = scanner.nextLine() + if (jsonRpcIface.trim().length() == 0) + jsonRpcIface = "127.0.0.1" + rv['jsonrpc.iface'] = jsonRpcIface + + println "Enter the port on which the JSON-RPC interface should listen. (default is 12345)" + String jsonRpcPort = scanner.nextLine() + if (jsonRpcPort.trim().length() == 0) + jsonRpcPort = "12345" + rv['jsonrpc.port'] = jsonRpcPort + + // that's all + println "*****************" + println "That's all the setup that's required to get the tracker up and running." + println "The tracker has many other settings which can be changed in the config files." + println "Refer to the documentation for their description." + println "*****************" + + rv + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/Swarm.groovy b/tracker/src/main/groovy/com/muwire/tracker/Swarm.groovy new file mode 100644 index 00000000..0cb71ee0 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/Swarm.groovy @@ -0,0 +1,182 @@ +package com.muwire.tracker + +import java.util.function.Function + +import com.muwire.core.InfoHash +import com.muwire.core.Persona + +import groovy.util.logging.Log + +/** + * A swarm for a given file + */ +@Log +class Swarm { + final InfoHash infoHash + + /** + * Invariant: these four collections are mutually exclusive. + * A given host can be only in one of them at the same time. + */ + private final Map seeds = new HashMap<>() + private final Map leeches = new HashMap<>() + private final Map unknown = new HashMap<>() + private final Set negative = new HashSet<>() + + /** + * hosts which are currently being pinged. Hosts can be in here + * and in the collections above, except for negative. + */ + private final Map inFlight = new HashMap<>() + + /** + * Last time a query was made to the MW network for this hash + */ + private long lastQueryTime + + /** + * Last time a batch of hosts was pinged + */ + private long lastPingTime + + Swarm(InfoHash infoHash) { + this.infoHash = infoHash + } + + /** + * @param cutoff expire hosts older than this + */ + synchronized void expire(long cutoff, int maxFailures) { + doExpire(cutoff, maxFailures, seeds) + doExpire(cutoff, maxFailures, leeches) + doExpire(cutoff, maxFailures, unknown) + } + + private static void doExpire(long cutoff, int maxFailures, Map map) { + for (Iterator iter = map.keySet().iterator(); iter.hasNext();) { + Persona p = iter.next() + Host h = map.get(p) + if (h.isExpired(cutoff, maxFailures)) + iter.remove() + } + } + + synchronized boolean shouldQuery(long queryCutoff, long now) { + if (!(seeds.isEmpty() && + leeches.isEmpty() && + inFlight.isEmpty() && + unknown.isEmpty())) + return false + if (lastQueryTime <= queryCutoff) { + lastQueryTime = now + return true + } + false + } + + synchronized boolean isHealthy() { + !seeds.isEmpty() + // TODO add xHave accumulation of leeches + } + + synchronized void add(Persona p) { + if (!(seeds.containsKey(p) || leeches.containsKey(p) || + negative.contains(p) || inFlight.containsKey(p))) + unknown.computeIfAbsent(p, {new Host(it)} as Function) + } + + synchronized void handleResponse(Host responder, int code) { + Host h = inFlight.remove(responder.persona) + if (responder != h) + log.warning("received a response mismatch from host $responder vs $h") + + responder.lastResponded = System.currentTimeMillis() + responder.failures = 0 + switch(code) { + case 200: addSeed(responder); break + case 206 : addLeech(responder); break; + default : + addNegative(responder) + } + } + + synchronized void fail(Host failed) { + Host h = inFlight.remove(failed.persona) + if (h != failed) + log.warning("failed a host that wasn't in flight $failed vs $h") + h.failures++ + } + + private void addSeed(Host h) { + leeches.remove(h.persona) + unknown.remove(h.persona) + seeds.put(h.persona, h) + } + + private void addLeech(Host h) { + unknown.remove(h.persona) + seeds.remove(h.persona) + leeches.put(h.persona, h) + } + + private void addNegative(Host h) { + unknown.remove(h.persona) + seeds.remove(h.persona) + leeches.remove(h.persona) + negative.add(h.persona) + } + + /** + * @param max number of hosts to give back + * @param now what time is it now + * @param cutoff only consider hosts which have been pinged before this time + * @return hosts to be pinged + */ + synchronized List getBatchToPing(int max, long now, long cutOff) { + List rv = new ArrayList<>() + rv.addAll(unknown.values()) + rv.addAll(seeds.values()) + rv.addAll(leeches.values()) + rv.removeAll(inFlight.values()) + + rv.removeAll { it.lastPinged >= cutOff } + + Collections.sort(rv, {l, r -> + Long.compare(l.lastPinged, r.lastPinged) + } as Comparator) + + if (rv.size() > max) + rv = rv[0..(max-1)] + + rv.each { + it.lastPinged = now + inFlight.put(it.persona, it) + } + + if (!rv.isEmpty()) + lastPingTime = now + rv + } + + synchronized long getLastPingTime() { + lastPingTime + } + + public Info info() { + List seeders = seeds.keySet().collect { it.getHumanReadableName() } + List leechers = leeches.keySet().collect { it.getHumanReadableName() } + return new Info(seeders, leechers, unknown.size(), negative.size()) + } + + public static class Info { + final List seeders, leechers + final int unknown, negative + + Info(List seeders, List leechers, int unknown, int negative) { + this.seeders = seeders + this.leechers = leechers + this.unknown = unknown + this.negative = negative + } + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/SwarmManager.groovy b/tracker/src/main/groovy/com/muwire/tracker/SwarmManager.groovy new file mode 100644 index 00000000..e56a45b7 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/SwarmManager.groovy @@ -0,0 +1,165 @@ +package com.muwire.tracker + +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Function + +import javax.annotation.PostConstruct + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +import com.muwire.core.Core +import com.muwire.core.InfoHash +import com.muwire.core.Persona +import com.muwire.core.search.QueryEvent +import com.muwire.core.search.SearchEvent +import com.muwire.core.search.UIResultBatchEvent +import com.muwire.core.util.DataUtil + +import groovy.util.logging.Log +import net.i2p.crypto.DSAEngine +import net.i2p.data.Signature + +@Component +@Log +class SwarmManager { + @Autowired + private Core core + + @Autowired + private Pinger pinger + + @Autowired + private TrackerProperties trackerProperties + + private final Map swarms = new ConcurrentHashMap<>() + private final Map queries = new ConcurrentHashMap<>() + private final Timer swarmTimer = new Timer("swarm-timer",true) + + @PostConstruct + public void postConstruct() { + core.eventBus.register(UIResultBatchEvent.class, this) + swarmTimer.schedule({trackSwarms()} as TimerTask, 10 * 1000, 10 * 1000) + } + + void onUIResultBatchEvent(UIResultBatchEvent e) { + InfoHash stored = queries.get(e.uuid) + InfoHash ih = e.results[0].infohash + + if (ih != stored) { + log.warning("infohash mismatch in result $ih vs $stored") + return + } + + Swarm swarm = swarms.get(ih) + if (swarm == null) { + log.warning("no swarm found for result with infoHash $ih") + return + } + + log.info("got a result with uuid ${e.uuid} for infoHash $ih") + swarm.add(e.results[0].sender) + } + + int countSwarms() { + swarms.size() + } + + private void trackSwarms() { + final long now = System.currentTimeMillis() + final long expiryCutoff = now - trackerProperties.getSwarmParameters().getExpiry() * 60 * 1000L + final int maxFailures = trackerProperties.getSwarmParameters().getMaxFailures() + swarms.values().each { it.expire(expiryCutoff, maxFailures) } + final long queryCutoff = now - trackerProperties.getSwarmParameters().getQueryInterval() * 60 * 60 * 1000L + swarms.values().each { + if (it.shouldQuery(queryCutoff, now)) + query(it) + } + + List swarmList = new ArrayList<>(swarms.values()) + Collections.sort(swarmList,{Swarm x, Swarm y -> + Long.compare(x.getLastPingTime(), y.getLastPingTime()) + } as Comparator) + + List toPing = new ArrayList<>() + final int amount = trackerProperties.getSwarmParameters().getPingParallel() + final long pingCutoff = now - trackerProperties.getSwarmParameters().getPingInterval() * 60 * 1000L + + for(int i = 0; i < swarmList.size() && toPing.size() < amount; i++) { + Swarm s = swarmList.get(i) + List hostsFromSwarm = s.getBatchToPing(amount - toPing.size(), now, pingCutoff) + hostsFromSwarm.collect(toPing, { host -> new HostAndIH(host, s.getInfoHash())}) + } + + log.info("will ping $toPing") + + toPing.each { pinger.ping(it, now) } + } + + private void query(Swarm swarm) { + InfoHash infoHash = swarm.getInfoHash() + cleanQueryMap(infoHash) + UUID uuid = UUID.randomUUID() + queries.put(uuid, infoHash) + + log.info("will query MW network for $infoHash with uuid $uuid") + + def searchEvent = new SearchEvent(searchHash : infoHash.getRoot(), uuid: uuid, oobInfohash: true, compressedResults : true, persona : core.me) + byte [] payload = infoHash.getRoot() + boolean firstHop = core.muOptions.allowUntrusted || core.muOptions.searchExtraHop + + Signature sig = DSAEngine.getInstance().sign(payload, core.spk) + long timestamp = System.currentTimeMillis() + core.eventBus.publish(new QueryEvent(searchEvent : searchEvent, firstHop : firstHop, + replyTo: core.me.destination, receivedOn: core.me.destination, + originator : core.me, sig : sig.data, queryTime : timestamp, sig2 : DataUtil.signUUID(uuid, timestamp, core.spk))) + } + + void track(InfoHash infoHash) { + swarms.computeIfAbsent(infoHash, {new Swarm(it)} as Function) + } + + boolean forget(InfoHash infoHash) { + Swarm swarm = swarms.remove(infoHash) + if (swarm != null) { + cleanQueryMap(infoHash) + return true + } else + return false + } + + private void cleanQueryMap(InfoHash infoHash) { + queries.values().removeAll {it == infoHash} + } + + Swarm.Info info(InfoHash infoHash) { + swarms.get(infoHash)?.info() + } + + void fail(HostAndIH target) { + log.info("failing $target") + swarms.get(target.infoHash)?.fail(target.host) + } + + void handleResponse(HostAndIH target, int code, Set altlocs) { + Swarm swarm = swarms.get(target.infoHash) + swarm?.handleResponse(target.host, code) + altlocs.each { + swarm?.add(it) + } + } + + public static class HostAndIH { + final Host host + final InfoHash infoHash + HostAndIH(Host host, InfoHash infoHash) { + this.host = host + this.infoHash = infoHash + } + + @Override + public String toString() { + "$host:$infoHash" + } + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/TrackRequest.java b/tracker/src/main/groovy/com/muwire/tracker/TrackRequest.java new file mode 100644 index 00000000..4e102c1b --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/TrackRequest.java @@ -0,0 +1,10 @@ +package com.muwire.tracker; + +public class TrackRequest { + String infoHash; + + @Override + public String toString() { + return "infoHash: " +infoHash; + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/Tracker.groovy b/tracker/src/main/groovy/com/muwire/tracker/Tracker.groovy new file mode 100644 index 00000000..2a857581 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/Tracker.groovy @@ -0,0 +1,119 @@ +package com.muwire.tracker + +import java.nio.charset.StandardCharsets +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.web.server.ConfigurableWebServerFactory +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.context.annotation.Bean + +import com.googlecode.jsonrpc4j.spring.JsonServiceExporter +import com.muwire.core.Core +import com.muwire.core.MuWireSettings +import com.muwire.core.UILoadedEvent +import com.muwire.core.files.AllFilesLoadedEvent + +@SpringBootApplication +class Tracker { + + private static final String VERSION = System.getProperty("build.version") + + private static Core core + private static TrackerService trackerService + + public static void main(String [] args) { + println "Launching MuWire Tracker version $VERSION" + + File home = new File(System.getProperty("user.home")) + home = new File(home, ".mwtrackerd") + home.mkdir() + + File mwProps = new File(home, "MuWire.properties") + File i2pProps = new File(home, "i2p.properties") + File trackerProps = new File(home, "tracker.properties") + + boolean launchSetup = false + + if (args.length > 0 && args[0] == "setup") { + println "Setup requested, entering setup wizard" + launchSetup = true + } else if (!(mwProps.exists() && i2pProps.exists() && trackerProps.exists())) { + println "Config files not found, entering setup wizard" + launchSetup = true + } + + if (launchSetup) { + SetupWizard wizard = new SetupWizard(home) + Properties props = wizard.performSetup() + + // nickname goes to mw.props + MuWireSettings mwSettings = new MuWireSettings() + mwSettings.nickname = props['nickname'] + + mwProps.withPrintWriter("UTF-8", { + mwSettings.write(it) + }) + + // i2cp host & port go in i2p.properties + def i2cpProps = new Properties() + i2cpProps['i2cp.tcp.port'] = props['i2cp.tcp.port'] + i2cpProps['i2cp.tcp.host'] = props['i2cp.tcp.host'] + i2cpProps['inbound.nickname'] = "MuWire Tracker" + i2cpProps['outbound.nickname'] = "MuWire Tracker" + + i2pProps.withPrintWriter { i2cpProps.store(it, "") } + + // json rcp props go in tracker.properties + def jsonProps = new Properties() + jsonProps['tracker.jsonRpc.iface'] = props['jsonrpc.iface'] + jsonProps['tracker.jsonRpc.port'] = props['jsonrpc.port'] + + trackerProps.withPrintWriter { jsonProps.store(it, "") } + } + + Properties p = new Properties() + mwProps.withReader("UTF-8", { p.load(it) } ) + MuWireSettings muSettings = new MuWireSettings(p) + p = new Properties() + trackerProps.withInputStream { p.load(it) } + + core = new Core(muSettings, home, VERSION) + + + // init json service object + trackerService = new TrackerServiceImpl(core) + core.eventBus.with { + register(UILoadedEvent.class, trackerService) + } + + Thread coreStarter = new Thread({ + core.startServices() + core.eventBus.publish(new UILoadedEvent()) + } as Runnable) + coreStarter.start() + + System.setProperty("spring.config.location", trackerProps.getAbsolutePath()) + SpringApplication.run(Tracker.class, args) + } + + @Bean + Core core() { + core + } + + @Bean + public TrackerService trackerService() { + trackerService + } + + @Bean(name = '/tracker') + public JsonServiceExporter jsonServiceExporter() { + def exporter = new JsonServiceExporter() + exporter.setService(trackerService()) + exporter.setServiceInterface(TrackerService.class) + exporter + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/TrackerProperties.groovy b/tracker/src/main/groovy/com/muwire/tracker/TrackerProperties.groovy new file mode 100644 index 00000000..aa25229c --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/TrackerProperties.groovy @@ -0,0 +1,33 @@ +package com.muwire.tracker + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties("tracker") +class TrackerProperties { + + final JsonRpc jsonRpc = new JsonRpc() + + public static class JsonRpc { + InetAddress iface + int port + } + + final SwarmParameters swarmParameters = new SwarmParameters() + + public static class SwarmParameters { + /** how often to kick of queries on the MW net, in hours */ + int queryInterval = 1 + /** how many hosts to ping in parallel */ + int pingParallel = 5 + /** interval of time between pinging the same host, in minutes */ + int pingInterval = 15 + /** how long to wait before declaring a host is dead, in minutes */ + int expiry = 60 + /** how long to wait for a host to respond to a ping, in seconds */ + int pingTimeout = 20 + /** Do not expire a host until it has failed this many times */ + int maxFailures = 3 + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/TrackerService.java b/tracker/src/main/groovy/com/muwire/tracker/TrackerService.java new file mode 100644 index 00000000..7409258b --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/TrackerService.java @@ -0,0 +1,8 @@ +package com.muwire.tracker; + +public interface TrackerService { + public TrackerStatus status(); + public void track(String infoHash); + public boolean forget(String infoHash); + public Swarm.Info info(String infoHash); +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/TrackerServiceImpl.groovy b/tracker/src/main/groovy/com/muwire/tracker/TrackerServiceImpl.groovy new file mode 100644 index 00000000..340ef044 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/TrackerServiceImpl.groovy @@ -0,0 +1,54 @@ +package com.muwire.tracker + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Component + +import com.muwire.core.Core +import com.muwire.core.InfoHash +import com.muwire.core.UILoadedEvent + +import net.i2p.data.Base64 + +@Component +class TrackerServiceImpl implements TrackerService { + + private final TrackerStatus status = new TrackerStatus() + private final Core core + + @Autowired + private SwarmManager swarmManager + + TrackerServiceImpl(Core core) { + this.core = core + status.status = "Starting" + } + + public TrackerStatus status() { + status.connections = core.getConnectionManager().getConnections().size() + status.swarms = swarmManager.countSwarms() + status + } + + + void onUILoadedEvent(UILoadedEvent e) { + status.status = "Running" + } + + @Override + public void track(String infoHash) { + InfoHash ih = new InfoHash(Base64.decode(infoHash)) + swarmManager.track(ih) + } + + @Override + public boolean forget(String infoHash) { + InfoHash ih = new InfoHash(Base64.decode(infoHash)) + swarmManager.forget(ih) + } + + @Override + public Swarm.Info info(String infoHash) { + InfoHash ih = new InfoHash(Base64.decode(infoHash)) + swarmManager.info(ih) + } +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/TrackerStatus.groovy b/tracker/src/main/groovy/com/muwire/tracker/TrackerStatus.groovy new file mode 100644 index 00000000..bb916b83 --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/TrackerStatus.groovy @@ -0,0 +1,7 @@ +package com.muwire.tracker + +class TrackerStatus { + volatile String status + int connections + int swarms +} diff --git a/tracker/src/main/groovy/com/muwire/tracker/WebServerConfiguration.groovy b/tracker/src/main/groovy/com/muwire/tracker/WebServerConfiguration.groovy new file mode 100644 index 00000000..5feebb3f --- /dev/null +++ b/tracker/src/main/groovy/com/muwire/tracker/WebServerConfiguration.groovy @@ -0,0 +1,20 @@ +package com.muwire.tracker + +import org.springframework.boot.web.server.ConfigurableWebServerFactory +import org.springframework.boot.web.server.WebServerFactoryCustomizer +import org.springframework.stereotype.Component + +@Component +class WebServerConfiguration implements WebServerFactoryCustomizer { + + private final TrackerProperties trackerProperties + + WebServerConfiguration(TrackerProperties trackerProperties) { + this.trackerProperties = trackerProperties; + } + @Override + public void customize(ConfigurableWebServerFactory factory) { + factory.setAddress(trackerProperties.jsonRpc.getIface()) + factory.setPort(trackerProperties.jsonRpc.port) + } +} diff --git a/webui/src/main/java/com/muwire/webui/ConfigurationServlet.java b/webui/src/main/java/com/muwire/webui/ConfigurationServlet.java index 360664a4..f3989727 100644 --- a/webui/src/main/java/com/muwire/webui/ConfigurationServlet.java +++ b/webui/src/main/java/com/muwire/webui/ConfigurationServlet.java @@ -82,6 +82,7 @@ public class ConfigurationServlet extends HttpServlet { core.getMuOptions().setAutoPublishSharedFiles(false); core.getMuOptions().setDefaultFeedAutoDownload(false); core.getMuOptions().setDefaultFeedSequential(false); + core.getMuOptions().setAllowTracking(false); } private void update(String name, String value) throws Exception { @@ -99,6 +100,7 @@ public class ConfigurationServlet extends HttpServlet { case "shareHiddenFiles" : core.getMuOptions().setShareHiddenFiles(true); break; case "searchComments" : core.getMuOptions().setSearchComments(true); break; case "browseFiles" : core.getMuOptions().setBrowseFiles(true); break; + case "allowTracking" : core.getMuOptions().setAllowTracking(true); break; case "speedSmoothSeconds" : core.getMuOptions().setSpeedSmoothSeconds(Integer.parseInt(value)); break; case "inbound.length" : core.getI2pOptions().setProperty(name, value); break; case "inbound.quantity" : core.getI2pOptions().setProperty(name, value); break; diff --git a/webui/src/main/webapp/ConfigurationPage.jsp b/webui/src/main/webapp/ConfigurationPage.jsp index f24060b6..e9c1d982 100644 --- a/webui/src/main/webapp/ConfigurationPage.jsp +++ b/webui/src/main/webapp/ConfigurationPage.jsp @@ -59,6 +59,14 @@ Exception error = (Exception) application.getAttribute("MWConfigError");

name="browseFiles" value="true">

+ + +
<%=Util._t("Allow tracking")%> + <%=Util._t("Allow trackers to track your shared files?")%> +
+ +

name="allowTracking" value="true">

+