diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java
index 6cfbf7d5690..4ac06048ea7 100644
--- a/core/src/main/java/bisq/core/trade/TradeManager.java
+++ b/core/src/main/java/bisq/core/trade/TradeManager.java
@@ -88,7 +88,9 @@
import java.util.ArrayList;
import java.util.Date;
+import java.util.HashSet;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -265,6 +267,7 @@ public void onUpdatedDataReceived() {
}
public void shutDown() {
+ reportActiveHiddenServices();
}
private void initPendingTrades() {
@@ -584,6 +587,21 @@ public void removePreparedTrade(Trade trade) {
private void removeTrade(Trade trade) {
tradableList.remove(trade);
+
+ reportActiveHiddenServices();
+ }
+
+ /**
+ * Collect all node addresses (hidden services) we still need for active offers/trades
+ * and report them to the P2P Service. The P2PService will report to the NetworkNode
+ * for cleaning up unnecessary hidden services.
+ */
+ private void reportActiveHiddenServices() {
+ Set result = new HashSet<>();
+ result.addAll(openOfferManager.getObservableList().stream().map(openOffer -> openOffer.getOffer().getOfferPayload().getOwnerNodeAddress()).collect(Collectors.toSet()));
+ result.addAll(getTradableList().stream().map(Trade::getContract).filter(Objects::nonNull).map(contract -> contract.getMyNodeAddress(keyRing.getPubKeyRing())).collect(Collectors.toSet()));
+
+ p2PService.reportRequiredHiddenServices(result);
}
diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties
index 405932fbaf3..670bb28357d 100644
--- a/core/src/main/resources/i18n/displayStrings.properties
+++ b/core/src/main/resources/i18n/displayStrings.properties
@@ -1073,6 +1073,15 @@ setting.preferences.dao.fullNodeInfo.cancel=No, I stick with lite node mode
settings.net.btcHeader=Bitcoin network
settings.net.p2pHeader=Bisq network
settings.net.onionAddressLabel=My onion address
+settings.net.renewAddressButton=Create new address
+settings.net.renewAddress=Please be aware that you will loose your reputation when you create and use a new onion address. Furthermore, you need to restart the application to set your new onion address active.\n\nProceed?
+settings.net.exportAddressButton=Backup address
+settings.net.exportAddressFileDialog=Select the backup file
+settings.net.exportAddressFileEnding=Bisq Onion Address Backup (*.bisq)
+settings.net.importAddressButton=Restore address
+settings.net.importAddressFileDialog=Select the backup file
+settings.net.importAddress=Please be aware that you will loose your reputation when you restore a previous onion address (but restore your reputation connected to the restored onion address). Furthermore, you need to restart the application to set your new onion address active.
+settings.net.importAddressError=Bisq could not import the backup file you specified. Be sure you chose a valid file and the file exists. If you are sure everything has been correct, please proceed below.
settings.net.btcNodesLabel=Use custom Bitcoin Core nodes
settings.net.bitcoinPeersLabel=Connected peers
settings.net.useTorForBtcJLabel=Use Tor for Bitcoin network
diff --git a/core/src/main/resources/i18n/displayStrings_de.properties b/core/src/main/resources/i18n/displayStrings_de.properties
index 4d57eabc8fc..dd4ccf231ba 100644
--- a/core/src/main/resources/i18n/displayStrings_de.properties
+++ b/core/src/main/resources/i18n/displayStrings_de.properties
@@ -940,6 +940,15 @@ setting.preferences.dao.fullNodeInfo.cancel=Nein, ich möchte weiterhin den Lite
settings.net.btcHeader=Bitcoin-Netzwerk
settings.net.p2pHeader=Bisq-Netzwerk
settings.net.onionAddressLabel=Meine Onion-Adresse
+settings.net.renewAddressButton=Neue Adresse verwenden
+settings.net.renewAddress=Ihre Reputation ist an die aktuelle Onion-Adresse gebunden. Wenn Sie die aktuelle Adresse ersetzen, verlieren Sie auch die damit verbundene Reputation. Die neue Adresse wird nach einem Neustart der Software aktiv.\n\nWollen Sie den Adresswechsel anwenden und die Software neu starten?
+settings.net.exportAddressButton=Adresse sichern
+settings.net.exportAddressFileDialog=Sicherungsdatei auswählen
+settings.net.exportAddressFileEnding=Bisq Onion Adresse Sicherungsdatei (*.bisq)
+settings.net.importAddressButton=Adresse wiederherstellen
+settings.net.importAddressFileDialog=Sicherungsdatei auswählen
+settings.net.importAddress=Ihre Reputation ist an die aktuelle Onion-Adresse gebunden. Wenn Sie die aktuelle Adresse ersetzen, verlieren Sie auch die damit verbundene Reputation. Dafür wird mit der Wiederherstellung einer alten Adresse deren Reputation wieder aktiv. Die neue Adresse wird nach einem Neustart der Software aktiv.
+settings.net.importAddressError=Wiederherstellen der Sicherung war nicht erfolgreich. Stellen Sie sicher, dass Sie die korrekte Sicherungsdatei ausgewählt haben und diese auch existiert und versuchen Sie es erneut. Sollte das Problem wiederholt auftreten, bitte beachten Sie die nachfolgenden Hinweise.
settings.net.btcNodesLabel=Spezifische Bitcoin-Core-Knoten verwenden
settings.net.bitcoinPeersLabel=Verbundene Peers
settings.net.useTorForBtcJLabel=Tor für das Bitcoin-Netzwerk verwenden
diff --git a/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.fxml b/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.fxml
index d583b18a099..414b73f6053 100644
--- a/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.fxml
+++ b/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.fxml
@@ -103,12 +103,19 @@
-
+
-
+
+
+
+
+
+
+
+
diff --git a/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.java b/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.java
index 2c58462bce0..f02bbe7cc4e 100644
--- a/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.java
+++ b/desktop/src/main/java/bisq/desktop/main/settings/network/NetworkSettingsView.java
@@ -48,6 +48,8 @@
import javafx.fxml.FXML;
+import javafx.stage.FileChooser;
+
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.control.RadioButton;
@@ -70,6 +72,9 @@
import javafx.collections.ObservableList;
import javafx.collections.transformation.SortedList;
+import java.io.File;
+import java.io.IOException;
+
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -103,7 +108,7 @@ public class NetworkSettingsView extends ActivatableView {
@FXML
Label reSyncSPVChainLabel;
@FXML
- AutoTooltipButton reSyncSPVChainButton, openTorSettingsButton;
+ AutoTooltipButton reSyncSPVChainButton, renewIdButton, exportIdButton, importIdButton, openTorSettingsButton;
private final Preferences preferences;
private final BtcNodes btcNodes;
@@ -177,6 +182,9 @@ public void initialize() {
usePublicNodesRadio.setText(Res.get("settings.net.usePublicNodesRadio"));
reSyncSPVChainLabel.setText(Res.get("settings.net.reSyncSPVChainLabel"));
reSyncSPVChainButton.updateText(Res.get("settings.net.reSyncSPVChainButton"));
+ renewIdButton.updateText(Res.get("settings.net.renewAddressButton"));
+ exportIdButton.updateText(Res.get("settings.net.exportAddressButton"));
+ importIdButton.updateText(Res.get("settings.net.importAddressButton"));
p2PPeersLabel.setText(Res.get("settings.net.p2PPeersLabel"));
onionAddressColumn.setGraphic(new AutoTooltipLabel(Res.get("settings.net.onionAddressColumn")));
onionAddressColumn.getStyleClass().add("first-column");
@@ -288,6 +296,47 @@ public void activate() {
reSyncSPVChainButton.setOnAction(event -> GUIUtil.reSyncSPVChain(preferences));
+ renewIdButton.setOnAction(event -> {
+ new Popup().information(Res.get("settings.net.renewAddress"))
+ .actionButtonText(Res.get("shared.applyAndShutDown"))
+ .onAction(() -> {
+ p2PService.renewHiddenService();
+ UserThread.runAfter(BisqApp.getShutDownHandler()::run, 500, TimeUnit.MILLISECONDS);
+ })
+ .closeButtonText(Res.get("shared.cancel"))
+ .show();
+ });
+
+ exportIdButton.setOnAction(event -> {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle(Res.get("settings.net.exportAddressFileDialog"));
+ fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(Res.get("settings.net.exportAddressFileEnding"), "*.bisq"));
+ File file = fileChooser.showSaveDialog(root.getScene().getWindow());
+ if (file != null)
+ p2PService.exportHiddenService(file);
+ });
+
+ importIdButton.setOnAction(event -> {
+ new Popup().information(Res.get("settings.net.importAddress"))
+ .actionButtonText(Res.get("settings.net.importAddressFileDialog"))
+ .onAction(() -> {
+ FileChooser fileChooser = new FileChooser();
+ fileChooser.setTitle(Res.get("settings.net.importAddressFileDialog"));
+ fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(Res.get("settings.net.exportAddressFileEnding"), "*.bisq"));
+ File file = fileChooser.showOpenDialog(root.getScene().getWindow());
+ if (file == null)
+ return;
+ try {
+ p2PService.importHiddenService(file);
+ UserThread.runAfter(BisqApp.getShutDownHandler()::run, 500, TimeUnit.MILLISECONDS);
+ } catch (IOException e) {
+ new Popup().error(Res.get("settings.net.importAddressError")).show();
+ }
+ })
+ .closeButtonText(Res.get("shared.cancel"))
+ .show();
+ });
+
bitcoinPeersSubscription = EasyBind.subscribe(walletsSetup.connectedPeersProperty(),
connectedPeers -> updateBitcoinPeersTable());
@@ -330,6 +379,10 @@ public void deactivate() {
useTorForBtcJCheckBox.setOnAction(null);
+ renewIdButton.setOnAction(null);
+ exportIdButton.setOnAction(null);
+ importIdButton.setOnAction(null);
+
if (nodeAddressSubscription != null)
nodeAddressSubscription.unsubscribe();
diff --git a/monitor/src/main/java/bisq/monitor/AvailableTor.java b/monitor/src/main/java/bisq/monitor/AvailableTor.java
index 650425fca52..760068fd235 100644
--- a/monitor/src/main/java/bisq/monitor/AvailableTor.java
+++ b/monitor/src/main/java/bisq/monitor/AvailableTor.java
@@ -43,8 +43,8 @@ public Tor getTor() {
}
@Override
- public String getHiddenServiceDirectory() {
- return hiddenServiceDirectory;
+ public File getHiddenServiceBaseDirectory() {
+ return new File(hiddenServiceDirectory);
}
}
diff --git a/p2p/src/main/java/bisq/network/p2p/P2PService.java b/p2p/src/main/java/bisq/network/p2p/P2PService.java
index d90fdeb2565..e6c07be31db 100644
--- a/p2p/src/main/java/bisq/network/p2p/P2PService.java
+++ b/p2p/src/main/java/bisq/network/p2p/P2PService.java
@@ -74,6 +74,9 @@
import java.security.PublicKey;
+import java.io.File;
+import java.io.IOException;
+
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
@@ -204,6 +207,10 @@ public void onAllServicesInitialized() {
}
}
+ public void renewHiddenService() {
+ networkNode.renewHiddenService();
+ }
+
public void shutDown(Runnable shutDownCompleteHandler) {
if (!shutDownInProgress) {
shutDownInProgress = true;
@@ -903,13 +910,43 @@ public KeyRing getKeyRing() {
///////////////////////////////////////////////////////////////////////////////////////////
private boolean verifyAddressPrefixHash(PrefixedSealedAndSignedMessage prefixedSealedAndSignedMessage) {
- if (networkNode.getNodeAddress() != null) {
- byte[] blurredAddressHash = networkNode.getNodeAddress().getAddressPrefixHash();
- return blurredAddressHash != null &&
- Arrays.equals(blurredAddressHash, prefixedSealedAndSignedMessage.getAddressPrefixHash());
- } else {
+ if (networkNode.getNodeAddress() == null) {
log.debug("myOnionAddress is null at verifyAddressPrefixHash. That is expected at startup.");
return false;
}
+
+ Set activeNodeAddresses = networkNode.getActiveNodeAddresses();
+
+ return activeNodeAddresses.stream().map(nodeAddress -> Arrays.equals(nodeAddress.getAddressPrefixHash(), prefixedSealedAndSignedMessage.getAddressPrefixHash())).reduce(false, (a, b) -> a || b);
+ }
+
+ /**
+ * Reporting hidden services that are still needed has the following background:
+ *
+ * - when we only report a hidden service if it is no longer used, we have that
+ * one chance of shutting it down. If for whatever reason we fail on doing so, we
+ * are stuck with this particular HS forever (without additional code)
+ *
- we do not need to find our exact node address by complex queries to the trade
+ * and offers, we just add all node address to the list. If we report
+ * a HS that is not ours, well, then we cannot retain it as we do not have it
+ * and that is it. No harm done. On the other hand, if we select the wrong HS as
+ * being ours, we probably remove a HS that is still in use.
+ *
- the code is much more compact and therefore, less error prone
+ *
- by reporting the stuff we need to keep, our code is more robust in terms of
+ * missing one
+ *
+ *
+ * @param nodeAddressList the list of node addresses we need to retain
+ */
+ public void reportRequiredHiddenServices(Set nodeAddressList) {
+ networkNode.clearHiddenServices(nodeAddressList);
+ }
+
+ public void exportHiddenService(File file) {
+ networkNode.exportHiddenService(file);
+ }
+
+ public void importHiddenService(File file) throws IOException {
+ networkNode.importHiddenService(file);
}
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/LocalhostNetworkNode.java b/p2p/src/main/java/bisq/network/p2p/network/LocalhostNetworkNode.java
index b49bff1e737..fe9757e7fe8 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/LocalhostNetworkNode.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/LocalhostNetworkNode.java
@@ -25,8 +25,10 @@
import java.net.ServerSocket;
import java.net.Socket;
+import java.io.File;
import java.io.IOException;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
@@ -84,9 +86,29 @@ public void start(@Nullable SetupListener setupListener) {
}, simulateTorDelayHiddenService, TimeUnit.MILLISECONDS);
}
+ @Override
+ public File renewHiddenService() {
+ return null;
+ }
+
// Called from NetworkNode thread
@Override
protected Socket createSocket(NodeAddress peerNodeAddress) throws IOException {
return new Socket(peerNodeAddress.getHostName(), peerNodeAddress.getPort());
}
+
+ @Override
+ public void clearHiddenServices(Set nodeAddressList) {
+
+ }
+
+ @Override
+ public void exportHiddenService(File file) {
+
+ }
+
+ @Override
+ public void importHiddenService(File source) {
+
+ }
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java b/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java
index 82b30c46ef4..2e3c86766a3 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java
@@ -40,6 +40,7 @@
import java.net.ServerSocket;
import java.net.Socket;
+import java.io.File;
import java.io.IOException;
import java.util.HashSet;
@@ -231,6 +232,20 @@ public void onFailure(@NotNull Throwable throwable) {
}
}
+ public abstract File renewHiddenService();
+
+ /**
+ * Gets rid of all hidden services except those given in the retain
+ * parameter.
+ *
+ * @param retain contains the hidden services that are still in use
+ */
+ public abstract void clearHiddenServices(Set retain);
+
+ public abstract void exportHiddenService(File target);
+
+ public abstract void importHiddenService(File source) throws IOException;
+
@Nullable
private InboundConnection getInboundConnection(@NotNull NodeAddress peersNodeAddress) {
Optional inboundConnectionOptional = lookupInBoundConnection(peersNodeAddress);
@@ -468,4 +483,13 @@ private void printInboundConnections() {
public NodeAddress getNodeAddress() {
return nodeAddressProperty.get();
}
+
+ /**
+ * Returns a collection of node addresses which have been used in the past and are
+ * kept active until no longer needed.
+ * @return
+ */
+ public Set getActiveNodeAddresses() {
+ return Set.of(getNodeAddress());
+ }
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/NewTor.java b/p2p/src/main/java/bisq/network/p2p/network/NewTor.java
index b94c3886028..cf41851e390 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/NewTor.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/NewTor.java
@@ -110,10 +110,4 @@ public Tor getTor() throws IOException, TorCtlException {
return result;
}
-
- @Override
- public String getHiddenServiceDirectory() {
- return "";
- }
-
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/RunningTor.java b/p2p/src/main/java/bisq/network/p2p/network/RunningTor.java
index 4af31d2ab94..d86fa7892b3 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/RunningTor.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/RunningTor.java
@@ -77,10 +77,4 @@ else if (cookieFile.exists())
return result;
}
-
- @Override
- public String getHiddenServiceDirectory() {
- return new File(torDir, HIDDEN_SERVICE_DIRECTORY).getAbsolutePath();
- }
-
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/TorMode.java b/p2p/src/main/java/bisq/network/p2p/network/TorMode.java
index b1a8e88b823..84a6b3c6cc7 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/TorMode.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/TorMode.java
@@ -28,28 +28,17 @@
/**
* Holds information on how tor should be created and delivers a respective
* {@link Tor} object when asked.
- *
+ *
* @author Florian Reimair
*
*/
public abstract class TorMode {
- /**
- * The sub-directory where the private_key
file sits in. Kept
- * private, because it only concerns implementations of {@link TorMode}.
- */
- protected static final String HIDDEN_SERVICE_DIRECTORY = "hiddenservice";
-
protected final File torDir;
/**
* @param torDir points to the place, where we will persist private
* key and address data
- * @param hiddenServiceDir The directory where the private_key
file
- * sits in. Note that, due to the inner workings of the
- * Netlayer
dependency, it does not
- * necessarily equal
- * {@link TorMode#getHiddenServiceDirectory()}.
*/
public TorMode(File torDir) {
this.torDir = torDir;
@@ -57,33 +46,14 @@ public TorMode(File torDir) {
/**
* Returns a fresh {@link Tor} object.
- *
+ *
* @return a fresh instance of {@link Tor}
* @throws IOException
* @throws TorCtlException
*/
public abstract Tor getTor() throws IOException, TorCtlException;
- /**
- * {@link NativeTor}'s inner workings prepend its Tor installation path and some
- * other stuff to the hiddenServiceDir, thus, selecting nothing (i.e.
- * ""
) as a hidden service directory is fine. {@link ExternalTor},
- * however, does not have a Tor installation path and thus, takes the hidden
- * service path literally. Hence, we set "torDir/hiddenservice"
as
- * the hidden service directory. By doing so, we use the same
- * private_key
file as in {@link NewTor} mode.
- *
- * @return ""
in {@link NewTor} Mode,
- * "torDir/externalTorHiddenService"
in {@link RunningTor}
- * mode
- */
- public abstract String getHiddenServiceDirectory();
-
- /**
- * Do a rolling backup of the "private_key" file.
- */
- protected void doRollingBackup() {
- FileUtil.rollingBackup(new File(torDir, HIDDEN_SERVICE_DIRECTORY), "private_key", 20);
+ public File getHiddenServiceBaseDirectory() {
+ return new File(torDir, "hiddenservice");
}
-
}
diff --git a/p2p/src/main/java/bisq/network/p2p/network/TorNetworkNode.java b/p2p/src/main/java/bisq/network/p2p/network/TorNetworkNode.java
index 6659c603640..b10efac229b 100644
--- a/p2p/src/main/java/bisq/network/p2p/network/TorNetworkNode.java
+++ b/p2p/src/main/java/bisq/network/p2p/network/TorNetworkNode.java
@@ -23,6 +23,7 @@
import bisq.common.Timer;
import bisq.common.UserThread;
import bisq.common.proto.network.NetworkProtoResolver;
+import bisq.common.storage.FileUtil;
import bisq.common.util.Utilities;
import org.berndpruenster.netlayer.tor.HiddenServiceSocket;
@@ -45,13 +46,30 @@
import java.security.SecureRandom;
+import java.text.SimpleDateFormat;
+
import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Base64;
+import java.util.Comparator;
import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -75,7 +93,6 @@ public class TorNetworkNode extends NetworkNode {
private int restartCounter;
@SuppressWarnings("FieldCanBeLocal")
private MonadicBinding allShutDown;
- private Tor tor;
private TorMode torMode;
@@ -83,6 +100,8 @@ public class TorNetworkNode extends NetworkNode {
private Socks5Proxy socksProxy;
+ private Map nodeAddressToHSDirectory = new HashMap<>();
+
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////////////////
@@ -100,15 +119,202 @@ public TorNetworkNode(int servicePort, NetworkProtoResolver networkProtoResolver
// API
///////////////////////////////////////////////////////////////////////////////////////////
+ /**
+ * only prepares a fresh hidden service folder. The actual HS is only established and
+ * started on Bisq restart!
+ * @return
+ */
@Override
- public void start(@Nullable SetupListener setupListener) {
- torMode.doRollingBackup();
+ public File renewHiddenService() {
+ // find suitable folder name
+ int seed = 0;
+ File newDir = null;
+ do {
+ String newHiddenServiceDirectory = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + seed;
+ newDir = new File(torMode.getHiddenServiceBaseDirectory(), newHiddenServiceDirectory);
+ seed += 1;
+ } while (newDir.exists());
+
+ newDir.mkdirs();
+
+ return newDir;
+ }
+
+ /**
+ * only marks the folders of the hidden services that are not to be retained for deletion.
+ * Once the application shuts down, the folders are deleted so that on app restart,
+ * the unnecessary hidden services are gone.
+ */
+ @Override
+ public void clearHiddenServices(Set retain) {
+ // first and foremost, we always retain the newest HS.
+ retain.add(nodeAddressProperty.getValue());
+
+ // then, we clean the hidden service directory accordingly
+ // so they are gone after an app restart
+ nodeAddressToHSDirectory.entrySet().stream().filter(nodeAddressFileEntry -> !retain.contains(nodeAddressFileEntry.getKey()))
+ .forEach(nodeAddressFileEntry -> {
+ deleteHiddenServiceDir(nodeAddressFileEntry.getValue());
+ });
+ }
+
+ private void deleteHiddenServiceDir(File dir) {
+ try {
+ Files.walk(dir.toPath())
+ .sorted(Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(File::delete);
+ } catch (IOException e) {
+ log.error("Error while trying to delete deprecated hidden service directory", e);
+ }
+ }
+
+ @Override
+ public void exportHiddenService(File target) {
+ // is directory?
+ if (target.isDirectory())
+ target = new File(target, nodeAddressProperty.getValue().getHostName());
+
+ if (!target.getName().endsWith(".bisq"))
+ target = new File(target.getAbsolutePath() + ".bisq");
+
+ // create zip
+ File hiddenServiceDir = nodeAddressToHSDirectory.get(nodeAddressProperty.getValue());
+ // write to file
+ try {
+ FileOutputStream fos = new FileOutputStream(target);
+ ZipOutputStream zipOut = new ZipOutputStream(fos);
+
+ Arrays.stream(hiddenServiceDir.listFiles()).filter(file -> !file.isDirectory())
+ .forEach(file -> {
+ try {
+ zipOut.putNextEntry(new ZipEntry(file.getName()));
+
+ FileInputStream fis = new FileInputStream(file);
+ byte[] bytes = new byte[1024];
+ int length;
+ while ((length = fis.read(bytes)) >= 0) {
+ zipOut.write(bytes, 0, length);
+ }
+ fis.close();
+ } catch (IOException e) {
+ log.error("Error while exporting hidden service.", e);
+ }
+ }
+ );
+ zipOut.close();
+ fos.close();
+ } catch (IOException | NullPointerException e) {
+ log.error("Error while exporting hidden service.", e);
+ }
+ }
+ @Override
+ public void importHiddenService(File source) throws IOException {
+ if (!source.getName().endsWith(".bisq")) {
+ log.error("Tried to import from a file not ending in '.bisq'");
+ throw new IOException("Cannot read backup file.");
+ }
+
+ try {
+ // create hidden service directory
+ File newHiddenServiceDir = renewHiddenService();
+
+ // unzip contents of source
+ byte[] buffer = new byte[1024];
+ ZipInputStream zis = new ZipInputStream(new FileInputStream(source));
+ ZipEntry zipEntry = zis.getNextEntry();
+ while (zipEntry != null) {
+ File destination = new File(newHiddenServiceDir, zipEntry.getName());
+ FileOutputStream fos = new FileOutputStream(destination);
+ int len;
+ while ((len = zis.read(buffer)) > 0)
+ fos.write(buffer, 0, len);
+ fos.close();
+ zipEntry = zis.getNextEntry();
+ }
+ zis.closeEntry();
+ zis.close();
+ } catch (IOException e) {
+ log.error("Importing a hidden service failed. ", e);
+ throw e;
+ }
+ }
+
+ @Override
+ public Set getActiveNodeAddresses() {
+ return nodeAddressToHSDirectory.keySet();
+ }
+
+ @Override
+ public void start(@Nullable SetupListener setupListener) {
if (setupListener != null)
addSetupListener(setupListener);
- // Create the tor node (takes about 6 sec.)
- createTorAndHiddenService(Utils.findFreeSystemPort(), servicePort);
+ ListenableFuture future = executorService.submit(() -> {
+ // Create the tor node (takes about 6 sec.)
+ createTor(torMode);
+
+ // check if we start our client for the first time
+ if (!torMode.getHiddenServiceBaseDirectory().exists())
+ renewHiddenService();
+
+ // see if we have to migrate the old file structure
+ if (torMode.getHiddenServiceBaseDirectory().listFiles((dir, name) -> name.equals("hostname")).length > 0) {
+ File newHiddenServiceDirectory = renewHiddenService();
+ for (File current : torMode.getHiddenServiceBaseDirectory().listFiles())
+ current.renameTo(new File(newHiddenServiceDirectory, current.getName()));
+ }
+
+ // find hidden service candidates
+ File[] hiddenServiceDirs = torMode.getHiddenServiceBaseDirectory().listFiles((dir, name) -> name.matches("\\d{15,}"));
+
+ // start
+ CountDownLatch gate = new CountDownLatch(hiddenServiceDirs.length);
+ nodeAddressToHSDirectory.clear();
+
+ // sort newest first, so we can just mark duplicate services for deletion
+ Arrays.stream(hiddenServiceDirs).sorted(Comparator.comparing(File::getName).reversed())
+ .forEachOrdered(current -> {
+ try {
+ NodeAddress nodeAddress = createHiddenService(current.getName(), Utils.findFreeSystemPort(), servicePort, gate);
+
+ // use newest HS as for NodeAddress
+ if (nodeAddressProperty.get() == null)
+ nodeAddressProperty.set(nodeAddress);
+
+ FileUtil.rollingBackup(current, "private_key", 20);
+
+ nodeAddressToHSDirectory.put(nodeAddress, current);
+ } catch (Exception e) {
+ if (e instanceof IOException && e.getMessage().contains("collision")) {
+ deleteHiddenServiceDir(current);
+ gate.countDown();
+ } else
+ throw e;
+ }
+ });
+
+ UserThread.execute(() -> setupListeners.forEach(SetupListener::onTorNodeReady));
+
+ // only report HiddenServicePublished once all are published
+ if (!gate.await(90, TimeUnit.SECONDS)) {
+ log.error("{} hidden services failed to start in time.", gate.getCount());
+ String msg = "Some hidden services failed to start in time.";
+ UserThread.execute(() -> setupListeners.forEach(s -> s.onSetupFailed(new RuntimeException(msg))));
+ }
+ UserThread.execute(() -> setupListeners.forEach(SetupListener::onHiddenServicePublished));
+
+ return null;
+ });
+ Futures.addCallback(future, new FutureCallback() {
+ public void onSuccess(Void ignore) {
+ }
+
+ public void onFailure(@NotNull Throwable throwable) {
+ UserThread.execute(() -> log.error("Hidden service creation failed: " + throwable));
+ }
+ });
}
@Override
@@ -131,7 +337,7 @@ public Socks5Proxy getSocksProxy() {
}
if (socksProxy == null || streamIsolation) {
- tor = Tor.getDefault();
+ Tor tor = Tor.getDefault();
// ask for the connection
socksProxy = tor != null ? tor.getProxy(stream) : null;
@@ -185,8 +391,8 @@ private BooleanProperty torNetworkNodeShutDown() {
long ts = System.currentTimeMillis();
log.debug("Shutdown torNetworkNode");
try {
- if (tor != null)
- tor.shutdown();
+ if (Tor.getDefault() != null)
+ Tor.getDefault().shutdown();
log.debug("Shutdown torNetworkNode done after " + (System.currentTimeMillis() - ts) + " ms.");
} catch (Throwable e) {
log.error("Shutdown torNetworkNode failed with exception: " + e.getMessage());
@@ -244,72 +450,66 @@ private void restartTor(String errorMessage) {
// create tor
///////////////////////////////////////////////////////////////////////////////////////////
- private void createTorAndHiddenService(int localPort, int servicePort) {
- ListenableFuture future = executorService.submit(() -> {
- try {
- // get tor
- Tor.setDefault(torMode.getTor());
-
- // start hidden service
- long ts2 = new Date().getTime();
- hiddenServiceSocket = new HiddenServiceSocket(localPort, torMode.getHiddenServiceDirectory(), servicePort);
- nodeAddressProperty.set(new NodeAddress(hiddenServiceSocket.getServiceName() + ":" + hiddenServiceSocket.getHiddenServicePort()));
- UserThread.execute(() -> setupListeners.forEach(SetupListener::onTorNodeReady));
- hiddenServiceSocket.addReadyListener(socket -> {
- try {
- log.info("\n################################################################\n" +
- "Tor hidden service published after {} ms. Socked={}\n" +
- "################################################################",
- (new Date().getTime() - ts2), socket); //takes usually 30-40 sec
- new Thread() {
- @Override
- public void run() {
- try {
- nodeAddressProperty.set(new NodeAddress(hiddenServiceSocket.getServiceName() + ":" + hiddenServiceSocket.getHiddenServicePort()));
- startServer(socket);
- UserThread.execute(() -> setupListeners.forEach(SetupListener::onHiddenServicePublished));
- } catch (final Exception e1) {
- log.error(e1.toString());
- e1.printStackTrace();
- }
- }
- }.start();
- } catch (final Exception e) {
- log.error(e.toString());
- e.printStackTrace();
- }
- return null;
- });
- log.info("It will take some time for the HS to be reachable (~40 seconds). You will be notified about this");
- } catch (TorCtlException e) {
- String msg = e.getCause() != null ? e.getCause().toString() : e.toString();
- log.error("Tor node creation failed: {}", msg);
- if (e.getCause() instanceof IOException) {
- // Since we cannot connect to Tor, we cannot do nothing.
- // Furthermore, we have no hidden services started yet, so there is no graceful
- // shutdown needed either
- UserThread.execute(() -> setupListeners.forEach(s -> s.onSetupFailed(new RuntimeException(msg))));
- } else {
- restartTor(e.getMessage());
- }
- } catch (IOException e) {
- log.error("Could not connect to running Tor: {}", e.getMessage());
+ /**
+ * Attempt to create tor. Handles all exceptions and tries to restart Tor if necessary.
+ *
+ * @param torMode
+ */
+ private void createTor(TorMode torMode) {
+ try {
+ Tor.setDefault(torMode.getTor());
+ } catch (TorCtlException e) {
+ String msg = e.getCause() != null ? e.getCause().toString() : e.toString();
+ log.error("Tor node creation failed: {}", msg);
+ if (e.getCause() instanceof IOException) {
// Since we cannot connect to Tor, we cannot do nothing.
// Furthermore, we have no hidden services started yet, so there is no graceful
// shutdown needed either
- UserThread.execute(() -> setupListeners.forEach(s -> s.onSetupFailed(new RuntimeException(e.getMessage()))));
- } catch (Throwable ignore) {
+ UserThread.execute(() -> setupListeners.forEach(s -> s.onSetupFailed(new RuntimeException(msg))));
+ } else {
+ restartTor(e.getMessage());
}
+ } catch (IOException e) {
+ log.error("Could not connect to running Tor: {}", e.getMessage());
+ // Since we cannot connect to Tor, we cannot do nothing.
+ // Furthermore, we have no hidden services started yet, so there is no graceful
+ // shutdown needed either
+ UserThread.execute(() -> setupListeners.forEach(s -> s.onSetupFailed(new RuntimeException(e.getMessage()))));
+ }
+ }
+ private NodeAddress createHiddenService(String hiddenServiceDirectory, int localPort, int servicePort, CountDownLatch onHSReady) {
+ long ts2 = new Date().getTime();
+ hiddenServiceSocket = new HiddenServiceSocket(localPort, hiddenServiceDirectory, servicePort);
+ NodeAddress nodeAddress = new NodeAddress(hiddenServiceSocket.getServiceName() + ":" + hiddenServiceSocket.getHiddenServicePort());
+ hiddenServiceSocket.addReadyListener(socket -> {
+ try {
+ log.info("\n################################################################\n" +
+ "Tor hidden service published after {} ms. Socked={}\n" +
+ "################################################################",
+ (new Date().getTime() - ts2), socket); //takes usually 30-40 sec
+ new Thread() {
+ @Override
+ public void run() {
+ try {
+ startServer(socket);
+ } catch (final Exception e1) {
+ log.error(e1.toString());
+ e1.printStackTrace();
+ }
+ }
+ }.start();
+ } catch (final Exception e) {
+ log.error(e.toString());
+ e.printStackTrace();
+ }
return null;
});
- Futures.addCallback(future, new FutureCallback() {
- public void onSuccess(Void ignore) {
- }
-
- public void onFailure(@NotNull Throwable throwable) {
- UserThread.execute(() -> log.error("Hidden service creation failed: " + throwable));
- }
+ hiddenServiceSocket.addReadyListener(hiddenServiceSocket1 -> {
+ onHSReady.countDown();
+ return null;
});
+ log.info("It will take some time for the HS to be reachable (~40 seconds). You will be notified about this");
+ return nodeAddress;
}
}
diff --git a/p2p/src/main/java/bisq/network/p2p/peers/PeerManager.java b/p2p/src/main/java/bisq/network/p2p/peers/PeerManager.java
index 0b918328ba9..6b1a1a4dfe3 100644
--- a/p2p/src/main/java/bisq/network/p2p/peers/PeerManager.java
+++ b/p2p/src/main/java/bisq/network/p2p/peers/PeerManager.java
@@ -584,7 +584,7 @@ public boolean isSelf(Peer reportedPeer) {
}
public boolean isSelf(NodeAddress nodeAddress) {
- return nodeAddress.equals(networkNode.getNodeAddress());
+ return networkNode.getActiveNodeAddresses().contains(nodeAddress);
}
public boolean isConfirmed(Peer reportedPeer) {
diff --git a/p2p/src/test/java/bisq/network/p2p/network/MultiHSTest.java b/p2p/src/test/java/bisq/network/p2p/network/MultiHSTest.java
new file mode 100644
index 00000000000..2954d69c9a1
--- /dev/null
+++ b/p2p/src/test/java/bisq/network/p2p/network/MultiHSTest.java
@@ -0,0 +1,238 @@
+/*
+ * This file is part of Bisq.
+ *
+ * Bisq is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or (at
+ * your option) any later version.
+ *
+ * Bisq is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
+ * License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with Bisq. If not, see .
+ */
+
+package bisq.network.p2p.network;
+
+import bisq.network.p2p.TestUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+/**
+ * Tests functionality around the export and import hidden service address feature. Please
+ * be aware that these tests are not exhaustive.
+ */
+@SuppressWarnings("ConstantConditions")
+@Ignore
+public class MultiHSTest {
+ int port = 9001;
+ static File torWorkingDir = new File(MultiHSTest.class.getSimpleName());
+ static File hiddenServiceDir = new File(torWorkingDir, "hiddenservice");
+ static String hiddenServiceDirPattern = "\\d{15}";
+ static File exportFile = new File(torWorkingDir, "export.bisq");
+
+ /**
+ * Device(s) Under Test
+ */
+ TorNetworkNode DUT, DUT2;
+
+ @Before
+ public void setup() {
+ DUT = new TorNetworkNode(port, TestUtils.getNetworkProtoResolver(), false,
+ new NewTor(torWorkingDir, "", "", new ArrayList<>()));
+ DUT2 = new TorNetworkNode(port, TestUtils.getNetworkProtoResolver(), false,
+ new NewTor(torWorkingDir, "", "", new ArrayList<>()));
+ }
+
+ @After
+ public void cleanup() {
+ cleanupRecursively(hiddenServiceDir);
+ if (exportFile.exists())
+ exportFile.delete();
+ }
+
+ @AfterClass
+ public static void cleanupThoroughly() {
+ cleanupRecursively(torWorkingDir);
+ }
+
+ static void cleanupRecursively(File current) {
+ if (current.isDirectory())
+ for (File child : current.listFiles())
+ cleanupRecursively(child);
+
+ current.delete();
+ }
+
+ static void checkHiddenServiceDirs(boolean checkContent) {
+ for (String current : hiddenServiceDir.list())
+ Assert.assertTrue(current.matches(hiddenServiceDirPattern));
+
+ if (!checkContent)
+ return;
+
+ for (File current : hiddenServiceDir.listFiles())
+ // if there are 3 file, a "backup" dir likely is one of them
+ Assert.assertEquals(3, current.list().length);
+ }
+
+ static void startAndStopDUT(TorNetworkNode DUT) throws InterruptedException {
+ CountDownLatch latch = new CountDownLatch(1);
+
+ DUT.start(new SetupListener() {
+ @Override
+ public void onTorNodeReady() {
+ try {
+ Thread.sleep(2000); // sleep to give other listeners a change to do their stuff. If for example the listener responsible for starting the HS is executed after we already shut down tor, there would be no hs files to export
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ DUT.shutDown(() -> {
+ });
+ latch.countDown();
+ }
+
+ @Override
+ public void onHiddenServicePublished() {
+ latch.countDown();
+ }
+
+ @Override
+ public void onSetupFailed(Throwable throwable) {
+ Assert.fail("setup failed");
+ }
+
+ @Override
+ public void onRequestCustomBridges() {
+ Assert.fail("requested custom bridges");
+ }
+ });
+ latch.await(10, TimeUnit.SECONDS);
+ }
+
+ // - start the app and see if one hidden service is created
+ @Test
+ public void firstLaunch() throws InterruptedException {
+ startAndStopDUT(DUT);
+
+ Assert.assertEquals(1, hiddenServiceDir.list().length);
+ checkHiddenServiceDirs(true);
+ }
+
+ // - renew the hidden service and see if 2 are active
+ @Test
+ public void renewHiddenService() throws InterruptedException {
+ DUT.renewHiddenService();
+ DUT.renewHiddenService();
+ Assert.assertEquals(2, hiddenServiceDir.list().length);
+ checkHiddenServiceDirs(false);
+ }
+
+ // - migrate to new structure
+ @Test
+ public void migrateHiddenService() throws InterruptedException, IOException {
+ // get ourselves a valid data structure
+ startAndStopDUT(DUT);
+
+ // move files to old structure
+ File source = hiddenServiceDir.listFiles()[0];
+ for (File current : source.listFiles())
+ current.renameTo(new File(hiddenServiceDir, current.getName()));
+ source.delete();
+
+ // start up
+ startAndStopDUT(DUT2);
+
+ // and see if things got migrated correctly
+ Assert.assertEquals(1, hiddenServiceDir.list().length);
+ checkHiddenServiceDirs(true);
+ }
+
+ // - export hidden service
+ @Test
+ public void exportHiddenService() throws InterruptedException, IOException {
+ startAndStopDUT(DUT);
+
+ DUT.exportHiddenService(exportFile);
+
+ Assert.assertTrue(exportFile.exists());
+ Assert.assertTrue(0 < exportFile.length());
+ }
+
+ // - import hidden service
+ @Test
+ public void importHiddenService() throws InterruptedException, IOException {
+ startAndStopDUT(DUT);
+
+ DUT.exportHiddenService(exportFile);
+
+ DUT.renewHiddenService();
+
+ startAndStopDUT(DUT2);
+
+ DUT2.clearHiddenServices(new HashSet<>());
+
+ DUT2.importHiddenService(exportFile);
+
+ Assert.assertEquals(2, hiddenServiceDir.list().length);
+ }
+
+ // - duplicate hidden service and see if only one is started up and the other one is deleted
+ @Test
+ public void importDuplicateHiddenService() throws InterruptedException, IOException {
+ startAndStopDUT(DUT);
+
+ DUT.exportHiddenService(exportFile);
+ DUT.importHiddenService(exportFile);
+
+ startAndStopDUT(DUT2);
+
+ Assert.assertEquals(1, hiddenServiceDir.list().length);
+ }
+
+ // - remove hidden services and see if they are removed
+ @Test
+ public void removeHiddenService() throws InterruptedException {
+ DUT.renewHiddenService();
+ DUT.renewHiddenService();
+ Assert.assertEquals(2, hiddenServiceDir.list().length);
+
+ startAndStopDUT(DUT);
+
+ DUT.clearHiddenServices(new HashSet<>());
+
+ Assert.assertNotEquals("Even the current HS has been deleted!", 0, hiddenServiceDir.list().length);
+ Assert.assertEquals(1, hiddenServiceDir.list().length);
+
+ checkHiddenServiceDirs(true);
+ }
+
+ // - simulate MacOSs .DS_STORE file
+ @Test
+ public void macsDsStore() throws InterruptedException, IOException {
+ DUT.renewHiddenService();
+
+ File dsStoreFile = new File(hiddenServiceDir, ".DS_STORE");
+ dsStoreFile.mkdir();
+
+ startAndStopDUT(DUT);
+
+ Assert.assertEquals(0, dsStoreFile.list().length);
+ }
+}
diff --git a/p2p/src/test/resources/logback.xml b/p2p/src/test/resources/logback.xml
new file mode 100644
index 00000000000..8cea861c79d
--- /dev/null
+++ b/p2p/src/test/resources/logback.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+