diff --git a/pom.xml b/pom.xml index 7b7e866f..c4c786d9 100644 --- a/pom.xml +++ b/pom.xml @@ -335,13 +335,12 @@ commons-text 1.4 - commons-net commons-net 3.3 - --> com.google.guava guava diff --git a/src/main/java/org/qora/controller/Controller.java b/src/main/java/org/qora/controller/Controller.java index 426d6deb..baacd1c3 100644 --- a/src/main/java/org/qora/controller/Controller.java +++ b/src/main/java/org/qora/controller/Controller.java @@ -70,6 +70,7 @@ import org.qora.transaction.Transaction.TransactionType; import org.qora.transaction.Transaction.ValidationResult; import org.qora.ui.UiService; import org.qora.utils.Base58; +import org.qora.utils.NTP; import org.qora.utils.Triple; public class Controller extends Thread { @@ -91,6 +92,7 @@ public class Controller extends Thread { private static final long ARBITRARY_REQUEST_TIMEOUT = 5 * 1000; // ms private static final long REPOSITORY_BACKUP_PERIOD = 123 * 60 * 1000; // ms private static final long NTP_NAG_PERIOD = 5 * 60 * 1000; // ms + private static final long MAX_NTP_OFFSET = 500; // ms private static volatile boolean isStopping = false; private static BlockGenerator blockGenerator = null; @@ -420,46 +422,50 @@ public class Controller extends Thread { } } - /** Nag Windows users that don't have many/any peers and not using Windows' auto time sync. */ + /** Nag if we detect system clock is too far from internet time. */ private void ntpNag() { - // Only for Windows users - if (!System.getProperty("os.name").toLowerCase().contains("win")) - return; + // Fetch mean offset from internet time (ms). + Long meanOffset = NTP.getOffset(); - // Suffering from lack of peers? - final int numberOfPeers = Network.getInstance().getUniqueHandshakedPeers().size(); - if (numberOfPeers > 0) - return; + final boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win"); + Boolean isNtpActive = null; + if (isWindows) { + // Detecting Windows Time service - // Do we actually know any peers to connect to? - try (final Repository repository = RepositoryManager.getRepository()) { - final int numberOfKnownPeers = repository.getNetworkRepository().getAllPeers().size(); - if (numberOfKnownPeers == 0) - return; - } catch (DataException e) { - // Not important - return; - } - - String[] detectCmd = new String[] { "net", "start" }; - try { - Process process = new ProcessBuilder(Arrays.asList(detectCmd)).start(); - try (InputStream in = process.getInputStream(); Scanner scanner = new Scanner(in, "UTF8")) { - scanner.useDelimiter("\\A"); - String output = scanner.hasNext() ? scanner.next() : ""; - boolean isRunning = output.contains("Windows Time"); - if (isRunning) - return; + String[] detectCmd = new String[] { "net", "start" }; + try { + Process process = new ProcessBuilder(Arrays.asList(detectCmd)).start(); + try (InputStream in = process.getInputStream(); Scanner scanner = new Scanner(in, "UTF8")) { + scanner.useDelimiter("\\A"); + String output = scanner.hasNext() ? scanner.next() : ""; + isNtpActive = output.contains("Windows Time"); + } + } catch (IOException e) { + // Not important + } + } else { + // Very basic unix-based attempt to check for ntpd + String[] detectCmd = new String[] { "ps", "-agx" }; + try { + Process process = new ProcessBuilder(Arrays.asList(detectCmd)).start(); + try (InputStream in = process.getInputStream(); Scanner scanner = new Scanner(in, "UTF8")) { + scanner.useDelimiter("\\A"); + String output = scanner.hasNext() ? scanner.next() : ""; + isNtpActive = output.contains("ntpd"); + } + } catch (IOException e) { + // Not important } - } catch (IOException e) { - // Not important - return; } + // If offset is good and ntp is active then we're good + if (Math.abs(meanOffset) < MAX_NTP_OFFSET && isNtpActive == true) + return; + // Time to nag String caption = Translator.INSTANCE.translate("SysTray", "NTP_NAG_CAPTION"); - String text = Translator.INSTANCE.translate("SysTray", "NTP_NAG_TEXT"); - SysTray.getInstance().showMessage(caption, text, MessageType.INFO); + String text = Translator.INSTANCE.translate("SysTray", isWindows ? "NTP_NAG_TEXT_WINDOWS" : "NTP_NAG_TEXT_UNIX"); + SysTray.getInstance().showMessage(caption, text, MessageType.WARNING); } public void updateSysTray() { @@ -467,7 +473,9 @@ public class Controller extends Thread { final int height = getChainHeight(); - String tooltip = String.format("qora-core - %d peer%s - height %d", numberOfPeers, (numberOfPeers != 1 ? "s" : ""), height); + String connectionsText = Translator.INSTANCE.translate("SysTray", numberOfPeers != 1 ? "CONNECTIONS" : "CONNECTION"); + String heightText = Translator.INSTANCE.translate("SysTray", "BLOCK_HEIGHT"); + String tooltip = String.format("qora-core - %d %s - %s %d", numberOfPeers, connectionsText, heightText, height); SysTray.getInstance().setToolTipText(tooltip); } diff --git a/src/main/java/org/qora/settings/Settings.java b/src/main/java/org/qora/settings/Settings.java index 9ceb1840..741416ff 100644 --- a/src/main/java/org/qora/settings/Settings.java +++ b/src/main/java/org/qora/settings/Settings.java @@ -95,6 +95,20 @@ public class Settings { "https://raw.githubusercontent.com@151.101.16.133/catbref/qora-core/%s/qora-core.jar" }; + // NTP sources + private String[] ntpServers = new String[] { + "pool.ntp.org", + "0.pool.ntp.org", + "1.pool.ntp.org", + "2.pool.ntp.org", + "3.pool.ntp.org", + "asia.pool.ntp.org", + "0.asia.pool.ntp.org", + "1.asia.pool.ntp.org", + "2.asia.pool.ntp.org", + "3.asia.pool.ntp.org" + }; + // Constructors private Settings() { @@ -308,4 +322,8 @@ public class Settings { return this.autoUpdateRepos; } + public String[] getNtpServers() { + return this.ntpServers; + } + } diff --git a/src/main/java/org/qora/utils/NTP.java b/src/main/java/org/qora/utils/NTP.java new file mode 100644 index 00000000..414f2fc8 --- /dev/null +++ b/src/main/java/org/qora/utils/NTP.java @@ -0,0 +1,99 @@ +package org.qora.utils; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.net.ntp.NTPUDPClient; +import org.apache.commons.net.ntp.TimeInfo; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qora.settings.Settings; + +public class NTP { + + private static final Logger LOGGER = LogManager.getLogger(NTP.class); + private static final double MAX_STDDEV = 25; // ms + + /** + * Returns aggregated internet time. + * + * @return internet time (ms), or null if unsuccessful. + */ + public static Long getTime() { + Long meanOffset = getOffset(); + if (meanOffset == null) + return null; + + return System.currentTimeMillis() + meanOffset; + } + + /** + * Returns mean offset from internet time. + * + * Positive offset means local clock is behind internet time. + * + * @return offset (ms), or null if unsuccessful. + */ + public static Long getOffset() { + String[] ntpServers = Settings.getInstance().getNtpServers(); + + NTPUDPClient client = new NTPUDPClient(); + client.setDefaultTimeout(2000); + + List offsets = new ArrayList<>(); + + for (String server : ntpServers) { + try { + TimeInfo timeInfo = client.getTime(InetAddress.getByName(server)); + + timeInfo.computeDetails(); + + LOGGER.debug(() -> String.format("%c%16.16s %16.16s %2d %c %4d %4d %3o %6dms % 5dms % 5dms", + ' ', + server, + timeInfo.getMessage().getReferenceIdString(), + timeInfo.getMessage().getStratum(), + 'u', + 0, + 1 << timeInfo.getMessage().getPoll(), + 1, + timeInfo.getDelay(), + timeInfo.getOffset(), + 0 + )); + + offsets.add((double) timeInfo.getOffset()); + } catch (IOException e) { + // Try next server... + } + } + + if (offsets.size() < ntpServers.length / 2) { + LOGGER.debug("Not enough replies"); + return null; + } + + // sₙ represents sum of offsetⁿ + double s0 = 0; + double s1 = 0; + double s2 = 0; + + for (Double offset : offsets) { + s0 += 1; + s1 += offset; + s2 += offset * offset; + } + + double mean = s1 / s0; + double stddev = Math.sqrt(((s0 * s2) - (s1 * s1)) / (s0 * (s0 - 1))); + + // If stddev is excessive then we're not very sure so give up + if (stddev > MAX_STDDEV) + return null; + + return (long) mean; + } + +} diff --git a/src/main/resources/i18n/SysTray_en.properties b/src/main/resources/i18n/SysTray_en.properties index d161acd0..be493d1b 100644 --- a/src/main/resources/i18n/SysTray_en.properties +++ b/src/main/resources/i18n/SysTray_en.properties @@ -1,14 +1,22 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu +BLOCK_HEIGHT = height + CHECK_TIME_ACCURACY = Check time accuracy +CONNECTION = block + +CONNECTIONS = connections + EXIT = Exit # Nagging about lack of NTP time sync -NTP_NAG_CAPTION = No connections? +NTP_NAG_CAPTION = Computer's clock is inaccurate! -NTP_NAG_TEXT = Please enable Windows automatic time synchronization +NTP_NAG_TEXT_UNIX = Install NTP service to get an accurate clock. + +NTP_NAG_TEXT_WINDOWS = Select "Synchronize clock" from menu to fix. OPEN_NODE_UI = Open Node UI diff --git a/src/main/resources/i18n/SysTray_zh.properties b/src/main/resources/i18n/SysTray_zh.properties index ff161d3d..d3349242 100644 --- a/src/main/resources/i18n/SysTray_zh.properties +++ b/src/main/resources/i18n/SysTray_zh.properties @@ -1,14 +1,20 @@ #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/) # SysTray pop-up menu +BLOCK_HEIGHT = \u5757\u9AD8\u5EA6 + CHECK_TIME_ACCURACY = \u68C0\u67E5\u65F6\u95F4\u51C6\u786E\u6027 +CONNECTIONS = \u4E2A\u8FDE\u63A5 + EXIT = \u9000\u51FA\u8F6F\u4EF6 # Nagging about lack of NTP time sync -NTP_NAG_CAPTION = \u6CA1\u6709\u8FDE\u63A5\u4E0A\u8282\u70B9\uFF1F +NTP_NAG_CAPTION = \u7535\u8111\u7684\u65F6\u949F\u4E0D\u51C6\u786E\uFF01 -NTP_NAG_TEXT = \u8BF7\u542F\u7528Windows\u81EA\u52A8\u65F6\u95F4\u540C\u6B65\u3002 +NTP_NAG_TEXT_UNIX = \u5B89\u88C5NTP\u670D\u52A1\u4EE5\u83B7\u5F97\u51C6\u786E\u7684\u65F6\u949F\u3002 + +NTP_NAG_TEXT_WINDOWS = \u4ECE\u83DC\u5355\u4E2D\u9009\u62E9\u201C\u540C\u6B65\u65F6\u949F\u201D\u8FDB\u884C\u4FEE\u590D\u3002 OPEN_NODE_UI = \u5F00\u542F\u754C\u9762 diff --git a/src/test/java/org/qora/test/NTPTests.java b/src/test/java/org/qora/test/NTPTests.java new file mode 100644 index 00000000..f5f73236 --- /dev/null +++ b/src/test/java/org/qora/test/NTPTests.java @@ -0,0 +1,86 @@ +package org.qora.test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.commons.net.ntp.NTPUDPClient; +import org.apache.commons.net.ntp.NtpV3Packet; +import org.apache.commons.net.ntp.TimeInfo; + +public class NTPTests { + + private static final List CC_TLDS = Arrays.asList("oceania", "europe", "lat", "asia", "africa"); + + public static void main(String[] args) throws UnknownHostException, IOException { + NTPUDPClient client = new NTPUDPClient(); + client.setDefaultTimeout(2000); + + System.out.println(String.format("%c%16s %16s %2s %c %4s %4s %3s %7s %7s %7s", + ' ', "remote", "refid", "st", 't', "when", "poll", "reach", "delay", "offset", "jitter" + )); + + List offsets = new ArrayList<>(); + + List ntpServers = new ArrayList<>(); + for (String ccTld : CC_TLDS) { + ntpServers.add(ccTld + ".pool.ntp.org"); + for (int subpool = 0; subpool <=3; ++subpool) + ntpServers.add(subpool + "." + ccTld + ".pool.ntp.org"); + } + + for (String server : ntpServers) { + try { + TimeInfo timeInfo = client.getTime(InetAddress.getByName(server)); + + timeInfo.computeDetails(); + NtpV3Packet ntpMessage = timeInfo.getMessage(); + + System.out.println(String.format("%c%16.16s %16.16s %2d %c %4d %4d %3o %6dms % 5dms % 5dms", + ' ', + server, + ntpMessage.getReferenceIdString(), + ntpMessage.getStratum(), + 'u', + 0, + 1 << ntpMessage.getPoll(), + 1, + timeInfo.getDelay(), + timeInfo.getOffset(), + 0 + )); + + offsets.add((double) timeInfo.getOffset()); + } catch (IOException e) { + // Try next server... + } + } + + if (offsets.size() < ntpServers.size() / 2) { + System.err.println("Not enough replies"); + System.exit(1); + } + + double s0 = 0; + double s1 = 0; + double s2 = 0; + + for (Double offset : offsets) { + // Exclude nearby results for more extreme testing + if (offset < 100.0) + continue; + + s0 += 1; + s1 += offset; + s2 += offset * offset; + } + + double mean = s1 / s0; + double stddev = Math.sqrt(((s0 * s2) - (s1 * s1)) / (s0 * (s0 - 1))); + System.out.println(String.format("mean: %7.3f, stddev: %7.3f", mean, stddev)); + } + +}