diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 204451aee4c..052b89a0177 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ - `BlockingEventManager` uses exponential backoff when no events validate to reduce overhead; events are re-queued if execution fails. - Default Gradle `test` tasks are disabled in `runelite-client/build.gradle.kts`; use explicit tasks such as `:client:runUnitTests`, `:client:runTests`, `:client:runDebugTests`, `:client:runIntegrationTest`, or `:client:runClientThreadScanner`. - World hopping short-circuits if the player is interacting or already hopping; confirmation widget auto-click handled in `Microbot.hopToWorld`. -- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token; the client thread and Swing thread get `false` instead. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, so it survives restarts and is shared by every client on the same IP. A "Too many login attempts" lockout (login index 33) sets it to zero. +- `Microbot.hopToWorld` paces hops against `HopBudget`, an estimate of Jagex's per-IP hop/login allowance (default 390 tokens, 93 regained per hour, 20 kept in reserve for logins). Every `HOPPING` and `LOGGING_IN` transition spends a token, including manual hops. When the allowance is down to the reserve, script threads wait for the next token, stop waiting if the player is logged out, and press a harmless key so a long wait doesn't trigger the idle logout. The client thread and Swing thread get `false` instead and only check this client's last known state, without touching the file. The state lives in `~/.runelite/microbot-hop-budget.properties`, keyed by SOCKS proxy host, port and a hash of the proxy username, so it survives restarts and is shared by every client on the same exit IP. Writes go to a temporary file that replaces the old one, with `microbot-hop-budget.properties.lock` held while reading and writing. A "Too many login attempts" lockout (login index 33) sets it to zero. ## References - Queryable API guide: `../runelite-client/src/main/java/net/runelite/client/plugins/microbot/api/QUERYABLE_API.md` diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java index 2ff220dcae8..6a045d8971e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Microbot.java @@ -14,6 +14,7 @@ import net.runelite.api.annotations.Varp; import net.runelite.api.events.ItemContainerChanged; import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarClientID; import net.runelite.api.widgets.Widget; import net.runelite.api.widgets.WidgetModalMode; import net.runelite.client.Notifier; @@ -42,10 +43,12 @@ import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.item.Rs2ItemManager; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; import net.runelite.client.plugins.microbot.util.mouse.Mouse; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.mouse.VirtualMouse; import net.runelite.client.plugins.microbot.util.mouse.naturalmouse.NaturalMouse; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; @@ -70,6 +73,7 @@ import javax.swing.Timer; import javax.swing.*; import java.awt.*; +import java.awt.event.KeyEvent; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; @@ -174,6 +178,8 @@ public class Microbot { private static net.runelite.api.World quickHopTargetWorld; private static final int WORLD_SWITCHER_LOAD_TIMEOUT_MS = 5000; private static final int WORLD_SWITCHER_REOPEN_INTERVAL_MS = 600; + private static final int HOP_WAIT_MIN_IDLE_MARGIN_TICKS = 3000; + private static final int HOP_WAIT_MAX_IDLE_MARGIN_TICKS = 10000; /** * PouchScript is injected in the main MicrobotPlugin as it's being used in multiple scripts */ @@ -470,20 +476,33 @@ public static boolean hopToWorld(int worldNumber) { static boolean awaitHopBudget(int worldNumber) { HopBudget budget = HopBudget.getInstance(); - long waitMs = budget.millisUntilHopAllowed(); - if (waitMs <= 0) { - return true; - } if (Microbot.getClient().isClientThread() || SwingUtilities.isEventDispatchThread()) { + long waitMs = budget.cachedMillisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } log.warn("Hop limit reached - not hopping to world {}, next hop allowed in {}s", worldNumber, TimeUnit.MILLISECONDS.toSeconds(waitMs)); return false; } + long waitMs = budget.millisUntilHopAllowed(); + if (waitMs <= 0) { + return true; + } log.info("Hop limit reached - waiting {}s before hopping to world {}", TimeUnit.MILLISECONDS.toSeconds(waitMs), worldNumber); String previousStatus = status; + int idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); try { while (waitMs > 0) { + if (!isLoggedIn()) { + log.info("Logged out while waiting for the hop limit - not hopping to world {}", worldNumber); + return false; + } + if (shouldPreventIdleLogout(idleMargin)) { + Rs2Keyboard.keyPress(KeyEvent.VK_BACK_SPACE); + idleMargin = Rs2Random.between(HOP_WAIT_MIN_IDLE_MARGIN_TICKS, HOP_WAIT_MAX_IDLE_MARGIN_TICKS); + } status = "Hop limit - next hop in " + TimeUnit.MILLISECONDS.toSeconds(waitMs + 999) + "s"; Thread.sleep(Math.min(waitMs, 1000L)); waitMs = budget.millisUntilHopAllowed(); @@ -497,6 +516,19 @@ static boolean awaitHopBudget(int worldNumber) { } } + private static boolean shouldPreventIdleLogout(int idleMargin) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Client client = Microbot.getClient(); + return Rs2Player.checkIdleLogout(idleMargin) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.CHATINPUT)) + && isNullOrEmpty(client.getVarcStrValue(VarClientID.MESLAYERINPUT)); + }).orElse(false); + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.isEmpty(); + } + static boolean hopViaWorldSwitcher(net.runelite.api.World rsWorld, int loadTimeoutMs) { if (!isWorldSwitcherLoaded() && !sleepUntil(Microbot::isWorldSwitcherLoaded, Microbot::openWorldSwitcher, loadTimeoutMs, WORLD_SWITCHER_REOPEN_INTERVAL_MS)) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java index 3caa11536b8..fab2528ae4f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/player/Rs2Player.java @@ -1705,12 +1705,13 @@ public static int distanceTo(WorldPoint endpoint) { * @return {@code true} if the player's idle time exceeds or equals the adjusted timeout, {@code false} otherwise. */ public static boolean checkIdleLogout(long randomDelay) { - long idleClientTicks = Long.min( - Microbot.getClient().getMouseIdleTicks(), - Microbot.getClient().getKeyboardIdleTicks() - ); - - return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + long idleClientTicks = Long.min( + Microbot.getClient().getMouseIdleTicks(), + Microbot.getClient().getKeyboardIdleTicks() + ); + return idleClientTicks >= Microbot.getClient().getIdleTimeout() - randomDelay; + }).orElse(false); } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java index 10d2776a5ef..eb881a317a4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/security/HopBudget.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.util.security; import lombok.extern.slf4j.Slf4j; +import net.runelite.client.proxy.ProxyConfiguration; import java.io.IOException; import java.io.StringReader; @@ -9,9 +10,14 @@ import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.function.LongSupplier; @@ -43,20 +49,49 @@ public static HopBudget getInstance() { } public static String networkKey() { - String proxyHost = System.getProperty("socksProxyHost"); - if (proxyHost == null || proxyHost.isBlank()) { + return networkKey(System.getProperty("socksProxyHost"), System.getProperty("socksProxyPort"), + ProxyConfiguration.getUsername()); + } + + static String networkKey(String host, String port, String username) { + if (host == null || host.isBlank()) { return "direct"; } - return "proxy." + proxyHost.trim().replaceAll("[^A-Za-z0-9._-]", "_"); + StringBuilder key = new StringBuilder("proxy.").append(sanitize(host)); + if (port != null && !port.isBlank()) { + key.append('_').append(sanitize(port)); + } + if (username != null && !username.isEmpty()) { + key.append('.').append(digest(username)); + } + return key.toString(); + } + + private static String digest(String value) { + try { + byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (int i = 0; i < 8; i++) { + hex.append(String.format("%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static String sanitize(String value) { + return value.trim().replaceAll("[^A-Za-z0-9._-]", "_"); } public void persistTo(Path file, String networkKey) { store = new FileStore(file, networkKey, store); + store.update(UnaryOperator.identity()); } public void configure(boolean enabled, int capacity, int refillPerHour, int reserve) { this.capacity = Math.max(1, capacity); - this.refillPerHour = Math.max(0, refillPerHour); + this.refillPerHour = Math.max(1, refillPerHour); this.reserve = Math.min(Math.max(0, reserve), this.capacity - 1); this.enabled = enabled; } @@ -70,18 +105,19 @@ public int available() { } public long millisUntilHopAllowed() { - if (!enabled) { - return 0L; - } - double missing = reserve + 1 - current().tokens; + return enabled ? millisUntil(current()) : 0L; + } + + public long cachedMillisUntilHopAllowed() { + return enabled ? millisUntil(refill(store.peek(), clock.getAsLong())) : 0L; + } + + private long millisUntil(State state) { + double missing = reserve + 1 - state.tokens; if (missing <= 0) { return 0L; } - double perMilli = refillPerMilli(); - if (perMilli <= 0) { - return Long.MAX_VALUE; - } - return Math.max(1L, (long) Math.ceil(missing / perMilli)); + return Math.max(1L, (long) Math.ceil(missing / refillPerMilli())); } public void recordHop() { @@ -102,7 +138,9 @@ public void recordLockout() { } private State current() { - return refill(store.update(UnaryOperator.identity()), clock.getAsLong()); + long now = clock.getAsLong(); + return refill(store.update(state -> state != null && state.updatedAt > now + ? new State(state.tokens, now) : state), now); } private State refill(State state, long now) { @@ -130,6 +168,8 @@ static final class State { interface Store { State update(UnaryOperator change); + + State peek(); } static final class MemoryStore implements Store { @@ -147,10 +187,16 @@ public synchronized State update(UnaryOperator change) { state = change.apply(state); return state; } + + @Override + public synchronized State peek() { + return state; + } } static final class FileStore implements Store { private final Path file; + private final Path lockFile; private final String tokensKey; private final String updatedAtKey; private final MemoryStore fallback; @@ -158,6 +204,7 @@ static final class FileStore implements Store { FileStore(Path file, String networkKey, Store seed) { this.file = file; + this.lockFile = file.resolveSibling(file.getFileName() + ".lock"); this.tokensKey = networkKey + ".tokens"; this.updatedAtKey = networkKey + ".updatedAt"; this.fallback = new MemoryStore(seed.update(UnaryOperator.identity())); @@ -168,16 +215,16 @@ public synchronized State update(UnaryOperator change) { boolean interrupted = Thread.interrupted(); try { Files.createDirectories(file.toAbsolutePath().getParent()); - try (FileChannel channel = FileChannel.open(file, StandardOpenOption.CREATE, - StandardOpenOption.READ, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, + StandardOpenOption.WRITE); FileLock ignored = channel.lock()) { - Properties properties = read(channel); + Properties properties = read(); State current = parse(properties); State next = change.apply(current); if (next != current && next != null) { properties.setProperty(tokensKey, Double.toString(next.tokens)); properties.setProperty(updatedAtKey, Long.toString(next.updatedAt)); - write(channel, properties); + write(properties); } fallback.update(state -> next); return next; @@ -195,6 +242,11 @@ public synchronized State update(UnaryOperator change) { } } + @Override + public State peek() { + return fallback.peek(); + } + private State parse(Properties properties) { String tokens = properties.getProperty(tokensKey); String updatedAt = properties.getProperty(updatedAtKey); @@ -202,31 +254,48 @@ private State parse(Properties properties) { return null; } try { - return new State(Double.parseDouble(tokens), Long.parseLong(updatedAt)); + double parsedTokens = Double.parseDouble(tokens); + if (!Double.isFinite(parsedTokens) || parsedTokens < 0) { + return null; + } + return new State(parsedTokens, Long.parseLong(updatedAt)); } catch (NumberFormatException e) { return null; } } - private static Properties read(FileChannel channel) throws IOException { - ByteBuffer buffer = ByteBuffer.allocate((int) channel.size()); - while (buffer.hasRemaining() && channel.read(buffer, buffer.position()) > 0) { - } + private Properties read() throws IOException { Properties properties = new Properties(); - properties.load(new StringReader(new String(buffer.array(), 0, buffer.position(), StandardCharsets.UTF_8))); + byte[] bytes; + try { + bytes = Files.readAllBytes(file); + } catch (NoSuchFileException e) { + return properties; + } + properties.load(new StringReader(new String(bytes, StandardCharsets.UTF_8))); return properties; } - private static void write(FileChannel channel, Properties properties) throws IOException { + private void write(Properties properties) throws IOException { StringWriter writer = new StringWriter(); properties.store(writer, null); ByteBuffer buffer = ByteBuffer.wrap(writer.toString().getBytes(StandardCharsets.UTF_8)); - channel.truncate(0); - long position = 0; - while (buffer.hasRemaining()) { - position += channel.write(buffer, position); + Path temp = Files.createTempFile(file.toAbsolutePath().getParent(), file.getFileName().toString(), ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(false); + } + try { + Files.move(temp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temp); } - channel.force(false); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java index a2c97757e4d..6aa909a2b3b 100644 --- a/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java +++ b/runelite-client/src/main/java/net/runelite/client/proxy/ProxyConfiguration.java @@ -15,6 +15,12 @@ */ public class ProxyConfiguration { + private static volatile String username; + + public static String getUsername() { + return username; + } + /** * Sets up the proxy configuration based on the provided options. * @param options @@ -44,6 +50,7 @@ public static void setupProxy(OptionSet options, ArgumentAcceptingOptionSpec files = Files.list(folder.getRoot().toPath())) { + assertEquals(java.util.Set.of(HopBudget.FILE_NAME, HopBudget.FILE_NAME + ".lock"), + files.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet())); + } + assertTrue(new String(Files.readAllBytes(file), StandardCharsets.UTF_8).contains("direct.tokens=27")); + } + + @Test public void storedTimestampInTheFutureIsRepairedSoRefillResumes() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget budget = budget(30, 5); + budget.persistTo(file, "direct"); + hop(budget, 25); + clock.addAndGet(-TimeUnit.HOURS.toMillis(1)); + assertTrue(budget.millisUntilHopAllowed() > 0); + clock.addAndGet(ONE_TOKEN_MILLIS); + assertEquals(0L, budget.millisUntilHopAllowed()); + } + + @Test public void zeroRefillRateIsClampedSoWaitsStayFinite() { + HopBudget budget = new HopBudget(clock::get, new HopBudget.MemoryStore()); + budget.configure(true, 30, 0, 5); + hop(budget, 25); + assertTrue(Math.abs(budget.millisUntilHopAllowed() - TimeUnit.HOURS.toMillis(1)) <= 1); + } + + @Test public void cachedWaitUsesThisClientsLastKnownState() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + HopBudget second = budget(30, 5); + second.persistTo(file, "direct"); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + hop(second, 25); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + assertTrue(first.millisUntilHopAllowed() > 0); + assertTrue(first.cachedMillisUntilHopAllowed() > 0); + first.configure(false, 30, 93, 5); + assertEquals(0L, first.cachedMillisUntilHopAllowed()); + } + @Test public void unwritableFileFallsBackToThisClientOnly() throws Exception { Path directory = folder.newFolder("blocked").toPath(); HopBudget budget = budget(30, 5); @@ -145,20 +202,55 @@ private static void hop(HopBudget budget, int times) { } @Test public void networkKeyFollowsTheSocksProxy() { - String previous = System.getProperty("socksProxyHost"); + String previousHost = System.getProperty("socksProxyHost"); + String previousPort = System.getProperty("socksProxyPort"); try { System.clearProperty("socksProxyHost"); + System.clearProperty("socksProxyPort"); assertEquals("direct", HopBudget.networkKey()); System.setProperty("socksProxyHost", "proxy.example.com"); - assertEquals("proxy.proxy.example.com", HopBudget.networkKey()); - System.setProperty("socksProxyHost", "fe80::1"); - assertEquals("proxy.fe80__1", HopBudget.networkKey()); + System.setProperty("socksProxyPort", "10001"); + assertEquals("proxy.proxy.example.com_10001", HopBudget.networkKey()); } finally { - if (previous == null) { - System.clearProperty("socksProxyHost"); - } else { - System.setProperty("socksProxyHost", previous); - } + restore("socksProxyHost", previousHost); + restore("socksProxyPort", previousPort); + } + } + + @Test public void networkKeySeparatesProxyPortsAndUsersWithoutStoringTheUsername() { + assertEquals("direct", HopBudget.networkKey(null, "1080", "user")); + assertEquals("direct", HopBudget.networkKey(" ", "1080", "user")); + assertEquals("proxy.fe80__1_1080", HopBudget.networkKey("fe80::1", "1080", null)); + assertNotEquals(HopBudget.networkKey("gate.example.com", "10001", null), + HopBudget.networkKey("gate.example.com", "10002", null)); + String first = HopBudget.networkKey("gate.example.com", "7000", "customer-session-a"); + String second = HopBudget.networkKey("gate.example.com", "7000", "customer-session-b"); + assertNotEquals(first, second); + assertFalse(first.contains("customer")); + assertTrue(first.matches("[A-Za-z0-9._-]+")); + } + + @Test public void networkKeyUsernamesWithEqualHashCodesGetSeparateKeys() { + assertEquals("Aa".hashCode(), "BB".hashCode()); + assertNotEquals(HopBudget.networkKey("gate.example.com", "7000", "Aa"), + HopBudget.networkKey("gate.example.com", "7000", "BB")); + } + + @Test public void freshClientReadsTheSharedFileBeforeAnsweringFromCache() throws Exception { + Path file = folder.getRoot().toPath().resolve(HopBudget.FILE_NAME); + HopBudget first = budget(30, 5); + first.persistTo(file, "direct"); + hop(first, 25); + HopBudget fresh = budget(30, 5); + fresh.persistTo(file, "direct"); + assertTrue(fresh.cachedMillisUntilHopAllowed() > 0); + } + + private static void restore(String property, String value) { + if (value == null) { + System.clearProperty(property); + } else { + System.setProperty(property, value); } } } diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index fac8ab0fc88..6f4c2dd57e1 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -544,7 +544,6 @@ net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel#getTransformedComposit net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#IsInInstance(): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.player.Rs2Player#checkIdleLogout(long): boolean -> net.runelite.api.Client#getKeyboardIdleTicks(): int net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#distanceTo(WorldPoint): int -> net.runelite.api.WorldView#getScene(): Scene