From bfb981cd49a6bbcd15482dceeb4ab121c0408157 Mon Sep 17 00:00:00 2001 From: SoniEx2 Date: Sun, 3 Jul 2022 23:12:06 -0300 Subject: [Project] Friend Code A Minecraft mod which adds friend codes, an easy way to play in the same world with remote friends. --- src/main/java/ganarchy/friendcode/FriendCode.java | 21 ++ .../friendcode/client/FriendCodeScreen.java | 141 +++++++++ .../friendcode/client/FriendConnectScreen.java | 111 +++++++ .../friendcode/client/FriendConnectingScreen.java | 110 +++++++ .../ganarchy/friendcode/client/I2PSamPinger.java | 113 +++++++ .../ganarchy/friendcode/client/LanSendPing.java | 6 + .../ganarchy/friendcode/client/SamProxyThread.java | 184 ++++++++++++ .../client/WaitingForFriendCodeScreen.java | 93 ++++++ .../friendcode/mixin/DirectConnectScreenMixin.java | 65 ++++ .../mixin/FriendCodeIntegratedServerExt.java | 17 ++ .../ganarchy/friendcode/mixin/LanPingerMixin.java | 29 ++ .../friendcode/mixin/OpenToLanScreenMixin.java | 57 ++++ .../ganarchy/friendcode/sam/I2PSamCommand.java | 333 +++++++++++++++++++++ .../ganarchy/friendcode/sam/I2PSamControl.java | 173 +++++++++++ .../friendcode/sam/I2PSamStateMachine.java | 220 ++++++++++++++ .../friendcode/sam/I2PSamStreamConnector.java | 74 +++++ .../friendcode/sam/I2PSamStreamForwarder.java | 48 +++ 17 files changed, 1795 insertions(+) create mode 100644 src/main/java/ganarchy/friendcode/FriendCode.java create mode 100644 src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java create mode 100644 src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java create mode 100644 src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java create mode 100644 src/main/java/ganarchy/friendcode/client/I2PSamPinger.java create mode 100644 src/main/java/ganarchy/friendcode/client/LanSendPing.java create mode 100644 src/main/java/ganarchy/friendcode/client/SamProxyThread.java create mode 100644 src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java create mode 100644 src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java create mode 100644 src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java create mode 100644 src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java create mode 100644 src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java create mode 100644 src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java create mode 100644 src/main/java/ganarchy/friendcode/sam/I2PSamControl.java create mode 100644 src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java create mode 100644 src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java create mode 100644 src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java (limited to 'src/main/java/ganarchy') diff --git a/src/main/java/ganarchy/friendcode/FriendCode.java b/src/main/java/ganarchy/friendcode/FriendCode.java new file mode 100644 index 0000000..8aab90e --- /dev/null +++ b/src/main/java/ganarchy/friendcode/FriendCode.java @@ -0,0 +1,21 @@ +package ganarchy.friendcode; + +import net.fabricmc.api.ModInitializer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FriendCode implements ModInitializer { + // This logger is used to write text to the console and the log file. + // It is considered best practice to use your mod id as the logger's name. + // That way, it's clear which mod wrote info, warnings, and errors. + public static final Logger LOGGER = LoggerFactory.getLogger("friendcode"); + + @Override + public void onInitialize() { + // This code runs as soon as Minecraft is in a mod-load-ready state. + // However, some things (like resources) may still be uninitialized. + // Proceed with mild caution. + + LOGGER.info("Hello Fabric world!"); + } +} diff --git a/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java new file mode 100644 index 0000000..38703a5 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/FriendCodeScreen.java @@ -0,0 +1,141 @@ +package ganarchy.friendcode.client; + +import ganarchy.friendcode.FriendCode; +import ganarchy.friendcode.mixin.FriendCodeIntegratedServerExt; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.OpenToLanScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.CyclingButtonWidget; +import net.minecraft.client.util.NetworkUtils; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.world.GameMode; + +import java.io.IOException; + +@Environment(EnvType.CLIENT) +public class FriendCodeScreen extends Screen { + private static final Text ALLOW_COMMANDS_TEXT = Text.translatable("selectWorld.allowCommands"); + private static final Text GAME_MODE_TEXT = Text.translatable("selectWorld.gameMode"); + private static final Text OTHER_PLAYERS_TEXT = Text.translatable("lanServer.otherPlayers"); + private final Screen parent; + private GameMode gameMode = GameMode.SURVIVAL; + private boolean allowCommands; + + private static final Text START_SHARING = Text.translatable("friendcode.start"); + + public FriendCodeScreen(Screen parent) { + super(Text.translatable("friendcode.title")); + this.parent = parent; + } + + @Override + protected void init() { + // title button allows going back to LAN screen + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 155, + 50, + 310, + 20, + this.title, + button -> this.client.setScreen(new OpenToLanScreen(this.parent)) + )); + + // game setting buttons + this.addDrawableChild( + CyclingButtonWidget + .builder(GameMode::getSimpleTranslatableName) + .values(GameMode.SURVIVAL, GameMode.SPECTATOR, GameMode.CREATIVE, GameMode.ADVENTURE) + .initially(this.gameMode) + .build( + this.width / 2 - 155, + 100, + 150, + 20, + GAME_MODE_TEXT, + (button, gameMode) -> { + this.gameMode = gameMode; + } + ) + ); + this.addDrawableChild( + CyclingButtonWidget + .onOffBuilder(this.allowCommands) + .build( + this.width / 2 + 5, + 100, + 150, + 20, + ALLOW_COMMANDS_TEXT, + (button, allowCommands) -> { + this.allowCommands = allowCommands; + } + ) + ); + + // start sharing + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 155, + this.height - 28, + 150, + 20, + START_SHARING, + button -> { + // FIXME + int i = NetworkUtils.findLocalPort(); + var samPinger = openToFriends(this.client, this.gameMode, this.allowCommands, i); + MutableText text = samPinger != null ? Text.translatable("commands.publish.started", i) : Text.translatable("commands.publish.failed"); + this.client.inGameHud.getChatHud().addMessage(text); + this.client.updateWindowTitle(); + this.client.setScreen(new WaitingForFriendCodeScreen(samPinger)); + } + )); + + // go back to options + this.addDrawableChild(new ButtonWidget( + this.width / 2 + 5, + this.height - 28, + 150, + 20, + ScreenTexts.CANCEL, + button -> this.client.setScreen(this.parent) + )); + } + + private static I2PSamPinger openToFriends(MinecraftClient client, GameMode gameMode, boolean allowCommands, int port) { + try { + client.loadBlockList(); + client.getServer().getNetworkIo().bind(null, port); + FriendCode.LOGGER.info("Started serving on {}", port); + ((FriendCodeIntegratedServerExt) client.getServer()).lanPort(port); + // reuse LAN pinger machinery instead of rolling our own + var lanPinger = new I2PSamPinger(client.getServer().getServerMotd(), "" + port); + ((FriendCodeIntegratedServerExt) client.getServer()).lanPinger(lanPinger); + lanPinger.start(); + ((FriendCodeIntegratedServerExt) client.getServer()).forcedGameMode(gameMode); + client.getServer().getPlayerManager().setCheatsAllowed(allowCommands); + int i = client.getServer().getPermissionLevel(client.player.getGameProfile()); + client.player.setClientPermissionLevel(i); + for (ServerPlayerEntity serverPlayerEntity : client.getServer().getPlayerManager().getPlayerList()) { + client.getServer().getCommandManager().sendCommandTree(serverPlayerEntity); + } + return lanPinger; + } + catch (IOException iOException) { + return null; + } + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + this.renderBackground(matrices); + FriendCodeScreen.drawCenteredText(matrices, this.textRenderer, OTHER_PLAYERS_TEXT, this.width / 2, 82, 0xFFFFFF); + super.render(matrices, mouseX, mouseY, delta); + } +} diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java new file mode 100644 index 0000000..53282d7 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/FriendConnectScreen.java @@ -0,0 +1,111 @@ +package ganarchy.friendcode.client; + +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.screen.DirectConnectScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.client.network.ServerInfo; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.Text; +import org.lwjgl.glfw.GLFW; + +import java.util.regex.Pattern; + +public class FriendConnectScreen extends Screen { + private static final Text ENTER_CODE_TEXT = Text.translatable("friendcode.enter_code"); + /** + * Pattern for old-style b32 addresses. + */ + // FIXME? maybe add support for b33 addresses? + private static final Pattern B32_PATTERN = Pattern.compile("^[A-Za-z2-7]{52}\\.b32\\.i2p$"); + private final Screen parent; + private final ServerInfo serverEntry; + private final BooleanConsumer callback; + private TextFieldWidget addressField; + private ButtonWidget selectServerButton; + + public FriendConnectScreen(Screen parent, BooleanConsumer callback, ServerInfo server) { + super(Text.translatable("friendcode.connect")); + this.parent = parent; + this.serverEntry = server; + this.callback = callback; + } + + @Override + public void tick() { + this.addressField.tick(); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (this.selectServerButton.active && this.getFocused() == this.addressField && (keyCode == GLFW.GLFW_KEY_ENTER || keyCode == GLFW.GLFW_KEY_KP_ENTER)) { + this.saveAndClose(); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + protected void init() { + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 100, + 20, + 200, + 20, + this.title, + button -> this.client.setScreen(new DirectConnectScreen(this.parent, this.callback, this.serverEntry)) + )); + this.client.keyboard.setRepeatEvents(true); + this.selectServerButton = this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 96 + 12, 200, 20, Text.translatable("friendcode.select"), button -> this.saveAndClose())); + this.addDrawableChild(new ButtonWidget(this.width / 2 - 100, this.height / 4 + 120 + 12, 200, 20, ScreenTexts.CANCEL, button -> this.callback.accept(false))); + this.addressField = new TextFieldWidget(this.textRenderer, this.width / 2 - 100, 116, 200, 20, ENTER_CODE_TEXT); + this.addressField.setMaxLength(128); + this.addressField.setTextFieldFocused(true); + //this.addressField.setText(this.client.options.lastServer); + this.addressField.setChangedListener(text -> this.onAddressFieldChanged()); + this.addSelectableChild(this.addressField); + this.setInitialFocus(this.addressField); + this.onAddressFieldChanged(); + } + + @Override + public void resize(MinecraftClient client, int width, int height) { + String string = this.addressField.getText(); + this.init(client, width, height); + this.addressField.setText(string); + } + + private void saveAndClose() { + this.client.setScreen(new FriendConnectingScreen(this.serverEntry, this.callback, this.addressField.getText())); +// this.serverEntry.address = this.addressField.getText(); +// this.callback.accept(true); + } + + @Override + public void close() { + this.client.setScreen(this.parent); + } + + @Override + public void removed() { + this.client.keyboard.setRepeatEvents(false); + // FIXME store this.addressField.getText() somewhere. +// this.client.options.lastServer = this.addressField.getText(); +// this.client.options.write(); + } + + private void onAddressFieldChanged() { + this.selectServerButton.active = B32_PATTERN.matcher(this.addressField.getText()).matches(); + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + this.renderBackground(matrices); + FriendConnectScreen.drawTextWithShadow(matrices, this.textRenderer, ENTER_CODE_TEXT, this.width / 2 - 100, 100, 0xA0A0A0); + this.addressField.render(matrices, mouseX, mouseY, delta); + super.render(matrices, mouseX, mouseY, delta); + } +} diff --git a/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java new file mode 100644 index 0000000..748801c --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/FriendConnectingScreen.java @@ -0,0 +1,110 @@ +package ganarchy.friendcode.client; + +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.client.gui.screen.LoadingDisplay; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.network.ServerInfo; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Util; +import org.apache.commons.codec.binary.Base32; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Locale; + +public class FriendConnectingScreen extends Screen { + private static final Text PATIENCE_TEXT = Text.translatable("friendcode.patience"); + private final ServerInfo serverEntry; + private final BooleanConsumer callback; + private final SamProxyThread proxyThread; + private long startTimeMs; + private Text renderText = null; + + public FriendConnectingScreen(ServerInfo serverEntry, BooleanConsumer callback, String text) { + super(Text.translatable("friendcode.connecting")); + this.serverEntry = serverEntry; + this.callback = callback; + this.startTimeMs = Util.getMeasuringTimeMs(); + this.proxyThread = new SamProxyThread(text); + } + + @Override + protected void init() { + try { + this.proxyThread.start(); + } catch (IllegalThreadStateException ignored) { + } + // cancel friend code. + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 155, + this.height - 28, + 310, + 20, + ScreenTexts.CANCEL, + button -> this.close() + )); + } + + + @Override + public void close() { + super.close(); + if (this.proxyThread.status() != SamProxyThread.Status.RUNNING) { + this.proxyThread.stopProxy(); + } + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + this.renderBackground(matrices); + FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF); + if (this.renderText != null) { + FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, this.renderText, this.width / 2, 82, 0xFFFFFF); + } else { + FriendConnectingScreen.drawCenteredText(matrices, this.textRenderer, PATIENCE_TEXT, this.width / 2, 82, 0xFFFFFF); + final String string = LoadingDisplay.get(Util.getMeasuringTimeMs()); + this.client.textRenderer.draw(matrices, string, (float) (this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float) (this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080); + } + super.render(matrices, mouseX, mouseY, delta); + } + + @Override + public void tick() { + super.tick(); + if (proxyThread.status() != SamProxyThread.Status.IDLE) { + switch (proxyThread.status()) { + case CONNECTION_FAILED -> { + this.renderText = Text.translatable("friendcode.connect.no_i2p"); + break; + } + case SETUP_FAILED -> { + this.renderText = Text.translatable("friendcode.connect.failed"); + break; + } + case RESOLUTION_FAILED -> { + this.renderText = Text.translatable("friendcode.connect.no_such_code"); + break; + } + case RUNNING -> { + this.serverEntry.address = "[::1]:" + this.proxyThread.port(); + this.callback.accept(true); + break; + } + } + } + if (!this.proxyThread.isConnecting()) { + startTimeMs = Util.getMeasuringTimeMs(); + } + if (Util.getMeasuringTimeMs() - startTimeMs > 20 * 1000) { + // keep it from getting stuck. + // FIXME this is wrong. + //this.proxyThread.interrupt(); + } + } +} diff --git a/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java new file mode 100644 index 0000000..96448db --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/I2PSamPinger.java @@ -0,0 +1,113 @@ +package ganarchy.friendcode.client; + +import ganarchy.friendcode.FriendCode; +import ganarchy.friendcode.sam.I2PSamControl; +import ganarchy.friendcode.sam.I2PSamStreamForwarder; +import net.minecraft.client.network.LanServerPinger; + +import java.io.*; + +public class I2PSamPinger extends LanServerPinger implements LanSendPing { + private final I2PSamControl sam; + private I2PSamStreamForwarder stream; + private final String port; + private volatile Status status = Status.IDLE; + private boolean hasWarned; + private volatile boolean stopSam; + + public I2PSamPinger(String motd, String port) throws IOException { + super(motd, port); + // cba to mixin + this.port = port; + this.sam = new I2PSamControl(true); + } + + @Override + public void run() { + try (final I2PSamControl sam = this.sam) { + if (!sam.connect()) { + this.status = Status.CONNECTION_FAILED; + // fall back to LAN server. + super.run(); + return; + } + + if (!sam.start()) { + this.status = Status.SETUP_FAILED; + // fall back to LAN server. + super.run(); + return; + } + + try (final I2PSamStreamForwarder stream = this.stream = sam.forwardStream(this.port)) { + if (!stream.connect()) { + this.status = Status.CONNECTION_FAILED; + super.run(); + return; + } + + if (!stream.start()) { + this.status = Status.SETUP_FAILED; + super.run(); + return; + } + + this.status = Status.RUNNING; + super.run(); + } catch (IOException ignored) { + } + } catch (IOException ignored) { + } + } + + @Override + public void friendcodeSendPing() { + if (this.stopSam) { + if (this.stream != null) { + try { + this.stream.close(); + } catch (IOException ignored) { + } + } + try { + this.sam.close(); + } catch (IOException ignored) { + } + this.status = Status.CONNECTION_CLOSED; + this.hasWarned = true; + return; + } + if (this.sam.sendPing() < 0) { + this.status = Status.CONNECTION_CLOSED; + } + if (this.stream != null) { + if (this.stream.sendPing() < 0) { + this.status = Status.CONNECTION_CLOSED; + } + } + if (this.status == Status.CONNECTION_CLOSED && !this.hasWarned) { + this.hasWarned = true; + FriendCode.LOGGER.warn("Friend Code connection closed!"); + } + } + + public Status status() { + return this.status; + } + + public String pubkey() { + return this.sam.pubkey(); + } + + public void stopSam() { + this.stopSam = true; + } + + public enum Status { + IDLE, + RUNNING, + CONNECTION_FAILED, + SETUP_FAILED, + CONNECTION_CLOSED; + } +} diff --git a/src/main/java/ganarchy/friendcode/client/LanSendPing.java b/src/main/java/ganarchy/friendcode/client/LanSendPing.java new file mode 100644 index 0000000..914a8d5 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/LanSendPing.java @@ -0,0 +1,6 @@ +package ganarchy.friendcode.client; + +public interface LanSendPing { + + void friendcodeSendPing(); +} diff --git a/src/main/java/ganarchy/friendcode/client/SamProxyThread.java b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java new file mode 100644 index 0000000..c7db41f --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/SamProxyThread.java @@ -0,0 +1,184 @@ +package ganarchy.friendcode.client; + +import ganarchy.friendcode.FriendCode; +import ganarchy.friendcode.sam.I2PSamControl; +import ganarchy.friendcode.sam.I2PSamStreamConnector; + +import java.io.IOException; +import java.net.Inet6Address; +import java.net.ServerSocket; +import java.net.Socket; + +public class SamProxyThread extends Thread { + private final String target; + private volatile int localPort; + private volatile Status status = Status.IDLE; + private volatile boolean running = true; + private volatile boolean isConnecting = false; + + public SamProxyThread(String target) { + this.target = target; + this.setDaemon(true); + this.setName("SAM Proxy Control Thread"); + } + + @Override + public void run() { + if (this.running) { + tryRun(true); + } + if (this.running) { + tryRun(false); + } + this.status = Status.SETUP_FAILED; + } + + private void tryRun(final boolean zeroHop) { + // this is all very inefficient but we don't particularly care + // who cares if you have 3 threads just to connect to a minecraft server/friend code + // it's only 3 threads + // the "server" is far more efficient, only requiring one + try (final I2PSamControl control = new I2PSamControl(zeroHop)) { + if (!control.connect()) { + this.running = false; + this.status = Status.CONNECTION_FAILED; + return; + } + if (!control.start()) { + this.running = false; + this.status = Status.SETUP_FAILED; + return; + } + switch (control.checkName(this.target)) { + case UNKNOWN, INVALID -> { + this.running = false; + this.status = Status.RESOLUTION_FAILED; + return; + } + case FAILED -> { + return; + } + } + // we can't distinguish if we should retry the connection + // (e.g. because building tunnels took too long, destination is known but gateway/peer is not, etc) + // vs if we should try with 1hop (because we can't reach peer from our connection, + // e.g. ipv4-only client vs ipv6-only friend, symmetric NAT, etc) + // so we just try to "warm up" the connection and hope it works. + I2PSamStreamConnector warmup = control.connectStream(this.target); + if (!warmup.connect()) { + this.running = false; + this.status = Status.CONNECTION_FAILED; + return; + } + warmup.start(); + warmup.close(); + I2PSamStreamConnector stream = control.connectStream(this.target); + if (!stream.connect()) { + this.running = false; + this.status = Status.CONNECTION_FAILED; + return; + } + this.isConnecting = true; + if (!stream.start()) { + FriendCode.LOGGER.warn("[SAM error] {}", stream.getStatus()); + this.isConnecting = false; + if (!zeroHop) { + this.running = false; + this.status = Status.SETUP_FAILED; + } + return; + } + try ( + Socket socket = stream.unwrap(); + ServerSocket server = new ServerSocket(0, 8, Inet6Address.getByAddress("localhost", new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0)); + ) { + this.localPort = server.getLocalPort(); + server.setSoTimeout(3000); + this.status = Status.RUNNING; + try (Socket single = server.accept()) { + socket.setTcpNoDelay(true); + single.setTcpNoDelay(true); + Thread clientThread = new Thread("SAM Proxy CTS Thread") { + @Override + public void run() { + try ( + var clientRead = single.getInputStream(); + var serverWrite = socket.getOutputStream(); + ) { + byte[] buffer = new byte[128*1024]; + while (SamProxyThread.this.running) { + int read = clientRead.read(buffer); + if (read > 0) { + serverWrite.write(buffer, 0, read); + } + } + } catch (IOException ignored) { + } + } + }; + Thread serverThread = new Thread("SAM Proxy STC Thread") { + @Override + public void run() { + try ( + var serverRead = socket.getInputStream(); + var clientWrite = single.getOutputStream(); + ) { + byte[] buffer = new byte[128*1024]; + while (SamProxyThread.this.running) { + int read = serverRead.read(buffer); + if (read > 0) { + clientWrite.write(buffer, 0, read); + } + } + } catch (IOException ignored) { + } + } + }; + clientThread.setDaemon(true); + clientThread.start(); + serverThread.setDaemon(true); + serverThread.start(); + while (!this.isInterrupted() && this.running) { + if (control.sendPing() < 0) { + this.running = false; + } + try { + Thread.sleep(1500L); + } catch (InterruptedException ignored) { + } + } + } finally { + this.running = false; + } + } + } catch (IOException e) { + // don't really need to do anything + } + } + + public void stopProxy() { + this.running = false; + // FIXME this is wrong, need to actually close sockets. + this.interrupt(); + } + + public Status status() { + return this.status; + } + + public int port() { + return this.localPort; + } + + public boolean isConnecting() { + return this.isConnecting; + } + + public enum Status { + IDLE, + RUNNING, + CONNECTION_FAILED, + SETUP_FAILED, + RESOLUTION_FAILED; + } +} diff --git a/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java new file mode 100644 index 0000000..8429a41 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/client/WaitingForFriendCodeScreen.java @@ -0,0 +1,93 @@ +package ganarchy.friendcode.client; + +import net.minecraft.client.gui.screen.LoadingDisplay; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.screen.ScreenTexts; +import net.minecraft.text.MutableText; +import net.minecraft.text.Text; +import net.minecraft.util.Util; +import org.apache.commons.codec.binary.Base32; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Locale; + +public class WaitingForFriendCodeScreen extends Screen { + private final I2PSamPinger samPinger; + + public WaitingForFriendCodeScreen(I2PSamPinger samPinger) { + super(Text.translatable("friendcode.opening")); + this.samPinger = samPinger; + } + + @Override + protected void init() { + // cancel friend code. + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 155, + this.height - 28, + 310, + 20, + ScreenTexts.CANCEL, + button -> this.close() + )); + } + + @Override + public void render(MatrixStack matrices, int mouseX, int mouseY, float delta) { + this.renderBackground(matrices); + WaitingForFriendCodeScreen.drawCenteredText(matrices, this.textRenderer, this.title, this.width / 2, 50, 0xFFFFFF); + final String string = LoadingDisplay.get(Util.getMeasuringTimeMs()); + this.client.textRenderer.draw(matrices, string, (float)(this.client.currentScreen.width / 2 - this.client.textRenderer.getWidth(string) / 2), (float)(this.client.currentScreen.height / 2 + this.client.textRenderer.fontHeight), 0x808080); + super.render(matrices, mouseX, mouseY, delta); + } + + @Override + public void close() { + super.close(); + this.samPinger.stopSam(); + final MutableText text = Text.translatable("friendcode.publish.stopped"); + this.client.inGameHud.getChatHud().addMessage(text); + } + + @Override + public void tick() { + super.tick(); + if (samPinger.status() != I2PSamPinger.Status.IDLE) { + this.client.setScreen(null); + switch (samPinger.status()) { + case CONNECTION_FAILED -> { + final MutableText text = Text.translatable("friendcode.publish.no_i2p"); + this.client.inGameHud.getChatHud().addMessage(text); + break; + } + case SETUP_FAILED, CONNECTION_CLOSED -> { + final MutableText text = Text.translatable("friendcode.publish.failed"); + this.client.inGameHud.getChatHud().addMessage(text); + break; + } + case RUNNING -> { + // convert from I2P base64 to URL-safe base64 so we don't have to write our own Base64 decoder + final String pubkey_b64 = samPinger.pubkey().replace('~', '_'); + final byte[] pubkey = Base64.getUrlDecoder().decode(pubkey_b64.getBytes(StandardCharsets.UTF_8)); + // a .b32.i2p is just a Base32-encoded SHA256 + final String b32; + try { + b32 = new Base32().encodeAsString(MessageDigest.getInstance("SHA-256").digest(pubkey)); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + // but we wanna massage it a little + final String b32_i2p = b32.replaceFirst("=+$", "").toLowerCase(Locale.ROOT) + ".b32.i2p"; + final MutableText text = Text.translatable("friendcode.publish.started", b32_i2p); + this.client.inGameHud.getChatHud().addMessage(text); + break; + } + } + } + } +} diff --git a/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java b/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java new file mode 100644 index 0000000..6b3a182 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/mixin/DirectConnectScreenMixin.java @@ -0,0 +1,65 @@ +package ganarchy.friendcode.mixin; + +import ganarchy.friendcode.client.FriendCodeScreen; +import ganarchy.friendcode.client.FriendConnectScreen; +import it.unimi.dsi.fastutil.booleans.BooleanConsumer; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.screen.DirectConnectScreen; +import net.minecraft.client.gui.screen.OpenToLanScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.network.ServerInfo; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(DirectConnectScreen.class) +public abstract class DirectConnectScreenMixin extends Screen { + protected DirectConnectScreenMixin(Text title) { + super(title); + } + + @Shadow + private Screen parent; + @Shadow + private BooleanConsumer callback; + @Shadow + private ServerInfo serverEntry; + + @Inject(at = @At("HEAD"), method = "init()V") + private void init(CallbackInfo info) { + // title, meet button + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 100, + 20, + 200, + 20, + this.title, + button -> this.client.setScreen(new FriendConnectScreen(this.parent, this.callback, this.serverEntry)) + )); + } + + @Redirect( + method = "render(Lnet/minecraft/client/util/math/MatrixStack;IIF)V", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/screen/DirectConnectScreen;drawCenteredText(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;III)V", + ordinal = 0 + ) + ) + private void removeTitle( + MatrixStack matrices, + TextRenderer renderer, + Text text, + int centerX, + int y, + int color + ) { + // literally does nothing + } +} diff --git a/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java b/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java new file mode 100644 index 0000000..49d3b3f --- /dev/null +++ b/src/main/java/ganarchy/friendcode/mixin/FriendCodeIntegratedServerExt.java @@ -0,0 +1,17 @@ +package ganarchy.friendcode.mixin; + +import net.minecraft.client.network.LanServerPinger; +import net.minecraft.server.integrated.IntegratedServer; +import net.minecraft.world.GameMode; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(IntegratedServer.class) +public interface FriendCodeIntegratedServerExt { + @Accessor("forcedGameMode") + void forcedGameMode(GameMode gameMode); + @Accessor("lanPort") + void lanPort(int port); + @Accessor("lanPinger") + void lanPinger(LanServerPinger lanPinger); +} diff --git a/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java b/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java new file mode 100644 index 0000000..187904d --- /dev/null +++ b/src/main/java/ganarchy/friendcode/mixin/LanPingerMixin.java @@ -0,0 +1,29 @@ +package ganarchy.friendcode.mixin; + +import ganarchy.friendcode.client.LanSendPing; +import net.minecraft.client.network.LanServerPinger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LanServerPinger.class) +public class LanPingerMixin implements LanSendPing { + @Inject( + method = "run()V", + remap = false, + at = @At( + remap = false, + value = "INVOKE", + target = "Ljava/net/InetAddress;getByName(Ljava/lang/String;)Ljava/net/InetAddress;" + ) + ) + public void onSendPing(CallbackInfo info) { + this.friendcodeSendPing(); + } + + @Override + public void friendcodeSendPing() { + + } +} diff --git a/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java b/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java new file mode 100644 index 0000000..fd2cccb --- /dev/null +++ b/src/main/java/ganarchy/friendcode/mixin/OpenToLanScreenMixin.java @@ -0,0 +1,57 @@ +package ganarchy.friendcode.mixin; + +import ganarchy.friendcode.client.FriendCodeScreen; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.screen.OpenToLanScreen; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.text.Text; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(OpenToLanScreen.class) +public abstract class OpenToLanScreenMixin extends Screen { + protected OpenToLanScreenMixin(Text title) { + super(title); + } + + @Shadow + private Screen parent; + + @Inject(at = @At("HEAD"), method = "init()V") + private void init(CallbackInfo info) { + // title, meet button + this.addDrawableChild(new ButtonWidget( + this.width / 2 - 155, + 50, + 310, + 20, + this.title, + button -> this.client.setScreen(new FriendCodeScreen(this.parent)) + )); + } + + @Redirect( + method = "render(Lnet/minecraft/client/util/math/MatrixStack;IIF)V", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/screen/OpenToLanScreen;drawCenteredText(Lnet/minecraft/client/util/math/MatrixStack;Lnet/minecraft/client/font/TextRenderer;Lnet/minecraft/text/Text;III)V", + ordinal = 0 + ) + ) + private void removeTitle( + MatrixStack matrices, + TextRenderer renderer, + Text text, + int centerX, + int y, + int color + ) { + // literally does nothing + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java new file mode 100644 index 0000000..bed5bb8 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java @@ -0,0 +1,333 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * An I2P SAM command. + * + * @param name The command name. + * @param parameters The parameters. + */ +record I2PSamCommand(String name, String opcode, Map parameters) { + /** + * Creates a new I2P SAM command. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + * @param parameters The parameters. + * @throws IllegalArgumentException If the command or parameters don't follow the expected format. + * @throws NullPointerException If anything (except for opcode) is null. + */ + public I2PSamCommand(final String name, final String opcode, final Map parameters) { + this.name = name; + this.opcode = opcode; + this.parameters = ImmutableMap.copyOf(parameters); + + // command formats: + // COMMAND SUBCOMMAND KEY KEY= KEY="" KEY=VALUE KEY=" " KEY="\"" KEY="\\" + // COMMAND + // PING[ arbitrary text] + // PONG[ arbitrary text] + // none of these may contain newlines (\n) + + // edge-case: I2PRouter treats empty values as "true", while we distinguish empty values. + // it's not strictly specified how they are to be handled, but anyway. + + // check newlines + if (this.name.contains("\n") || (this.opcode != null && this.opcode.contains("\n")) || this.parameters.entrySet().stream().anyMatch(arg -> arg.getKey().contains("\n") || arg.getValue().contains("\n"))) { + throw new IllegalArgumentException("commands may not contain embedded newlines"); + } + + if (this.name.isEmpty()) { + throw new IllegalArgumentException("name must not be empty"); + } + + // check for PING/PONG + if (this.name.equals("PING") || this.name.equals("PONG")) { + // reject parameters + if (!this.parameters.isEmpty()) { + throw new IllegalArgumentException("PING/PONG does not accept parameters"); + } + // skip any other validation + return; + } + + // everything else is fine + + // NOTE: we don't special-case I2PRouter's weirdness with \"\"COMMAND\"\" and \"\"OPCODE\"\" + // for one, we require name != null and !name.isEmpty(), so \"\"COMMAND\"\" would error on I2PRouter anyway, + // for two, single-word commands don't take a subcommand, so it doesn't even matter if you set \"\"OPCODE\"\". + } + + /** + * Creates a new I2P SAM command, with no parameters. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + */ + public I2PSamCommand(final String name, final String opcode) { + this(name, opcode, ImmutableMap.of()); + } + + /** + * Builds the complete command line. + * + * @return The complete command line. + */ + public String buildCommandLine() { + final StringBuilder sb = new StringBuilder(); + sb.append(escape(this.name, false)); + if (this.opcode != null) { + sb.append(' '); + if (this.name.equals("PING") || this.name.equals("PONG")) { + sb.append(this.opcode); + } else { + sb.append(escape(this.opcode, false)); + } + } + for (final var argument : parameters.entrySet()) { + sb.append(' '); + sb.append(escape(argument.getKey(), false)); + sb.append('='); + sb.append(escape(argument.getValue(), true)); + } + return sb.toString(); + } + + /** + * Parses the given command line and returns an I2PSamCommand. + * + * @param commandLine The command line, without terminating newline. + * @return The parsed command, or null if there was an error. + */ + public static I2PSamCommand parse(final String commandLine) { + // we're a bit more strict than I2PRouter in how we parse these, but that should be okay. + if (commandLine.isEmpty()) { + return null; + } + if (commandLine.contains("\n")) { + throw new IllegalArgumentException("commandLine may not contain embedded newlines"); + } + final var rawParams = new StringBuilder(commandLine); + int index; + index = nextToken(rawParams); + final String name = rawParams.substring(0, index); + if (index == rawParams.length()) { // invariant: length() != 0 + // COMMAND + // (includes PING/PONG with no data) + return new I2PSamCommand(name, null); + } else if (name.isEmpty() || rawParams.charAt(index) != ' ') { + throw new IllegalArgumentException("commandLine must start with a command"); + } + rawParams.delete(0, index + 1); + if (name.equals("PING") || name.equals("PONG")) { + // PING arbitrary text + // PONG arbitrary text + return new I2PSamCommand(name, rawParams.toString()); + } + // COMMAND SUBCOMMAND[ ...] + index = nextToken(rawParams); + final String opcode = rawParams.substring(0, index); + if (opcode.isEmpty()) { // also if length() == 0 + // COMMAND =... or some other BS + // we do not currently accept trailing spaces or parameters for single-word commands coming from the bridge. + throw new IllegalArgumentException("expected subcommand"); + } else if (index == rawParams.length()) { // invariant: length() != 0 (checked above) + // COMMAND SUBCOMMAND + return new I2PSamCommand(name, opcode); + } else if (rawParams.charAt(index) != ' ') { // index != length() + throw new IllegalArgumentException("expected subcommand"); + } + rawParams.delete(0, index + 1); + // COMMAND SUBCOMMAND ... + final ImmutableMap.Builder params = ImmutableMap.builder(); + // params + while (!rawParams.isEmpty()) { + index = nextToken(rawParams); + String key = rawParams.substring(0, index); + if (key.isEmpty()) { + throw new IllegalArgumentException("expected parameter key"); + } else if (index == rawParams.length()) { + // KEY + params.put(key, ""); + rawParams.setLength(0); + } else if (rawParams.charAt(index) == ' ') { + // KEY ... + params.put(key, ""); + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index) == '=') { + rawParams.delete(0, index + 1); + if (rawParams.length() == 0) { + // KEY= + params.put(key, ""); + } else if (rawParams.charAt(0) == '"') { + // KEY="... + // just special-case it + rawParams.deleteCharAt(0); + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == '\r' || c == '"') { + // characters that must be escaped in quotes + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + if (index == rawParams.length()) { + // KEY=" + throw new IllegalArgumentException("unterminated quote"); + } else if (rawParams.charAt(index) == '"') { + // KEY=""... + final String value = rawParams.substring(0, index); + params.put(key, value); + if (index + 1 == rawParams.length()) { + // KEY="" + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index + 1) == ' ') { + // KEY="" ... + rawParams.delete(0, index + 2); + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + // KEY=... + index = nextToken(rawParams); + if (index == rawParams.length()) { + // KEY=VALUE + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == '=') { + // KEY=VALUE=... + // just special-case it + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + final String value = rawParams.substring(0, index); + if (index == rawParams.length()) { + // KEY=VALUE= + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE= ... + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE ... + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } + } else { + throw new IllegalArgumentException("expected parameter key"); + } + } + return new I2PSamCommand(name, opcode, params.build()); + } + + @Override + public String toString() { + // The built command line is likely nicer to use when debugging. + return this.buildCommandLine(); + } + + /** + * Escapes and optionally quotes a string. + * + * @param input The input string. + * @param valueSection Whether this is a value section. + * @return The escaped and optionally quoted string. + */ + private static String escape(final String input, final boolean valueSection) { + final StringBuilder sb = new StringBuilder(0); + int lastIndex = 0; + for (int index = 0; index < input.length(); index++) { + final int c = input.charAt(index); + final boolean escape; + if (c == '=') { + // only escaped outside of value section, but does not require quoting. + escape = !valueSection; + } else if (c == ' ' || c == '\t' || c == '\f' || c == '\b') { + // only escaped outside of value section. requires quoting. + escape = !valueSection; + if (valueSection) { + // make sure the sb isn't empty so it actually adds quotes + sb.append(input, lastIndex, index + 1); + lastIndex = index + 1; + } + } else if (c == '\r' || c == '"' || c == '\\') { + // always escaped + escape = true; + } else { + // never escaped + escape = false; + } + if (escape) { + sb.append(input, lastIndex, index); + sb.append('\\'); + sb.append(c); + lastIndex = index + 1; + } + } + if (!sb.isEmpty()) { + sb.append(input, lastIndex, input.length()); + if (valueSection) { + sb.insert(0, '"'); + sb.append('"'); + } + return sb.toString(); + } else { + return input; + } + } + + /** + * Parses the next token. + * + * @param rawParams The input being processed. + * @return The index of the end of the next token. + */ + private static int nextToken(final StringBuilder rawParams) { + int index; + for (index = 0; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '=' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + return index; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java new file mode 100644 index 0000000..f470bb0 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java @@ -0,0 +1,173 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetSocketAddress; +import java.net.Socket; + +public class I2PSamControl extends I2PSamStateMachine { + private final boolean zeroHop; + private String pubkey; + private String id; + + public I2PSamControl(boolean zeroHop) { + this.zeroHop = zeroHop; + } + + @Override + public boolean connect() { + try { + // try IPv6 first + // there's no Inet6Address.getLoopbackAddress() which is unfortunate. + final Socket samSocket = new Socket(); + samSocket.connect(new InetSocketAddress(Inet6Address.getByAddress("localhost", new byte[] {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1}, 0), 7656), 3000); + return this.connect(samSocket); + } catch (IOException e) { + try { + final Socket samSocket = new Socket(); + samSocket.connect(new InetSocketAddress(Inet4Address.getByAddress("localhost", new byte[] {127,0,0,1}), 7656), 3000); + return this.connect(samSocket); + } catch (IOException ex) { + return false; + } + } + } + + @Override + public boolean start() { + if (!super.start()) { + return false; + } + try { + // try to enable auth + this.enableAuth(); + // FIXME "Friend Code Type: World" vs "Friend Code Type: Session" + // generate our keys + this.sendCommand(new I2PSamCommand( + "DEST", "GENERATE", + ImmutableMap.of("SIGNATURE_TYPE", "EdDSA_SHA512_Ed25519") + )); + var dest = this.getCommand("DEST", "REPLY"); + this.pubkey = dest.parameters().get("PUB"); + var privkey = dest.parameters().get("PRIV"); + // setup our session + int i = 0; + String status; + do { + i++; + // we want a session with the given privkey and zero hops for maximum performance + // this isn't anonymous but then it's not meant to be - we're using I2P as a free STUN/TURN service + // the connecting clients handle the need for TURN by setting their hop count to 1 as needed + this.id = "minecraft_friendcode_" + i; + final ImmutableMap.Builder builder = ImmutableMap.builder(); + this.sendCommand(new I2PSamCommand( + "SESSION", "CREATE", + builder.put( + "STYLE", "STREAM" + ).put( + "ID", this.id + ).put( + "DESTINATION", privkey + ).put( + "inbound.length", this.zeroHop ? "0" : "1" + ).put( + "outbound.length", this.zeroHop ? "0" : "1" + ).put( + "inbound.allowZeroHop", "true" + ).put( + "outbound.allowZeroHop", "true" + ).put( + "shouldBundleReplyInfo", "true" + ).put( + "i2cp.dontPublishLeaseSet", "false" + ).put( + "streaming.maxWindowSize", "1024" + ).build() + )); + } while ("DUPLICATED_ID".equals(status = this.getCommand("SESSION", "STATUS").parameters().get("RESULT"))); + return "OK".equals(status); + } catch (IOException e) { + return false; + } + } + + /** + * Creates a stream forwarder. + * @return A stream forwarder. + */ + public I2PSamStreamForwarder forwardStream(String port) { + return new I2PSamStreamForwarder(this.getSamBridgeAddress(), id, port); + } + + /** + * Creates a stream forwarder. + * @return A stream connector. + */ + public I2PSamStreamConnector connectStream(String b32) { + return new I2PSamStreamConnector(this.getSamBridgeAddress(), id, b32); + } + + /** + * Returns the session pubkey. + */ + public String pubkey() { + return this.pubkey; + } + + /** + * Set up and enable auth. + * + * @throws IOException If an I/O error occurs. + */ + private void enableAuth() throws IOException { + // enable auth (if it hasn't been explicitly disabled by the user), for the user's sake + // (ugh i2p you really fucked up by not making this the default) + // btw tor does this with a filesystem path to an auth cookie (which is much more secure) but we digress + this.sendCommand(new I2PSamCommand( + "AUTH", "ADD", + ImmutableMap.of("USER", "minecraft_friendcode", "PASSWORD", "friendcode") + )); + // if the user already exists, don't enable auth + if ("OK".equals(this.getCommand("AUTH", "STATUS").parameters().get("RESULT"))) { + this.sendCommand(new I2PSamCommand("AUTH", "ENABLE")); + // ignore the response on this one, just get it off the queue + this.getCommand("AUTH", "STATUS"); + } + } + + public NameStatus checkName(String target) { + try { + this.sendCommand(new I2PSamCommand("NAMING", "LOOKUP", ImmutableMap.of("NAME", target))); + I2PSamCommand result = this.getCommand("NAMING", "REPLY"); + if (result.parameters().get("RESULT") == null) { + return NameStatus.FAILED; + } + switch (result.parameters().get("RESULT")) { + case "OK" -> { + return NameStatus.OK; + } + case "INVALID_KEY" -> { + return NameStatus.INVALID; + } + case "KEY_NOT_FOUND" -> { + return NameStatus.UNKNOWN; + } + default -> { + return NameStatus.FAILED; + } + } + } catch (IOException e) { + return NameStatus.FAILED; + } + } + + public enum NameStatus { + OK, + INVALID, + UNKNOWN, + FAILED; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java new file mode 100644 index 0000000..d05717d --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java @@ -0,0 +1,220 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; +import ganarchy.friendcode.FriendCode; +import net.minecraft.util.Util; + +import java.io.*; +import java.net.Socket; +import java.net.SocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Iterator; +import java.util.Objects; + +public abstract class I2PSamStateMachine implements Closeable { + // FIXME user/password from config + private static final I2PSamCommand HELLO_MESSAGE = new I2PSamCommand( + "HELLO", "VERSION", + ImmutableMap.of( + "MIN", "3.2", + "USER", "minecraft_friendcode", + "PASSWORD", "friendcode" + ) + ); + private Socket samSocket; + private PushbackReader reader; + private OutputStreamWriter writer; + private final ArrayDeque queue = new ArrayDeque<>(); + private boolean started; + private boolean connected; + private String id; + + protected I2PSamStateMachine() { + } + + /** + * Connects to the SAM socket. + * + * @return Whether the connection was successful. + */ + public abstract boolean connect(); + + protected boolean connect(Socket samSocket) { + this.samSocket = samSocket; + if (this.connected) { + throw new IllegalStateException(); + } + this.connected = true; + return true; + } + + /** + * Starts the SAM session. + * + * @return Whether we were able to start the SAM session. + */ + public boolean start() { + if (!this.connected) { + throw new IllegalStateException(); + } + if (this.started) { + throw new IllegalStateException(); + } + this.started = true; + try { + // 8 KiC limit + this.reader = new PushbackReader(new InputStreamReader(this.samSocket.getInputStream(), StandardCharsets.UTF_8), 8192); + this.writer = new OutputStreamWriter(this.samSocket.getOutputStream(), StandardCharsets.UTF_8); + + + // send HELLO + // FIXME let the user change the credentials + this.sendCommand(HELLO_MESSAGE); + return "OK".equals(this.getCommand("HELLO", "REPLY").parameters().get("RESULT")); + } catch (IOException e) { + return false; + } + } + + /** + * Attempts to step the SAM session. + * + * This will generally not block, unless something went wrong. + */ + public void tryStep() { + try { + if (this.reader != null && this.reader.ready()) { + this.step(); + } + } catch (IOException ignored) { + } + } + + /** + * Steps the SAM session. + * + * @throws IOException If an I/O error occurs. + */ + public void step() throws IOException { + this.writer.flush(); + final String line; + // 8 KiC limit + final var buffer = new char[8192]; + final var builder = new StringBuilder(); + int cursor = 0; + while (true) { + final int read = this.reader.read(buffer, cursor, buffer.length - cursor); + if (read < 0) { + line = null; + break; + } + builder.append(buffer, cursor, read); + final int target; + if ((target = builder.indexOf("\n", cursor) + 1) > 0) { + this.reader.unread(buffer, target, cursor + read - target); + builder.setLength(target - 1); + line = builder.toString(); + break; + } + cursor += read; + } + if (line == null) { + throw new EOFException(); + } + // NOTE \r will result in fuckery, but the bridge should not generally send them + FriendCode.LOGGER.debug("[SAM (RAW)] {}", line); + final I2PSamCommand command; + try { + command = I2PSamCommand.parse(line); + } catch (IllegalArgumentException e) { + FriendCode.LOGGER.error("[SAM Error] Closing SAM bridge.", e); + this.samSocket.close(); + return; + } + if (command == null) { + // empty line + return; + } + if (command.name().equals("PING")) { + this.sendCommand(new I2PSamCommand("PONG", command.opcode(), command.parameters())); + } else { + queue.push(command); + } + } + + /** + * Reads a name from the SAM session. + * + * @param name The command name. + * @param opcode The subcommand name. + * @return The name line, or null if something went wrong. + * @throws IOException If an I/O error occurs. + */ + protected I2PSamCommand getCommand(String name, String opcode) throws IOException { + final boolean checkPing = name.equals("PING") || name.equals("PONG"); + while (true) { + // check queue for lines + for (Iterator iterator = this.queue.iterator(); iterator.hasNext(); ) { + I2PSamCommand queued = iterator.next(); + // special check for PING/PONG + if (queued.name().equals(name) && (checkPing || Objects.equals(queued.opcode(), opcode))) { + iterator.remove(); + return queued; + } + } + // if that failed, read more lines + this.step(); + } + } + + protected void sendCommand(I2PSamCommand command) throws IOException { + String line = command.buildCommandLine() + "\n"; + if (line.length() >= 8192) { + throw new IOException("command too long - max 8 KiC"); + } + this.writer.write(line); + } + + /** + * Sends a PING. + * + * @return The round-trip-time in milliseconds. + */ + public long sendPing() { + try { + long time = Util.getMeasuringTimeMs(); + this.sendCommand(new I2PSamCommand("PING", "" + time)); + return Util.getMeasuringTimeMs() - Long.parseLong(this.getCommand("PONG", null).opcode()); + } catch (IOException e) { + return -1; + } + } + + /** + * Returns the SAM bridge address. + * @return The SAM bridge address. + */ + protected SocketAddress getSamBridgeAddress() { + if (!this.connected) { + throw new IllegalStateException("not connected"); + } + return this.samSocket.getRemoteSocketAddress(); + } + + /** + * Closes this connection. + * @throws IOException As per {@link Socket#close()}. + */ + public void close() throws IOException { + this.samSocket.close(); + } + + protected Socket unwrap() { + Socket socket = this.samSocket; + this.samSocket = null; + this.reader = null; + this.writer = null; + return socket; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java new file mode 100644 index 0000000..95b6a61 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java @@ -0,0 +1,74 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.net.*; + +public class I2PSamStreamConnector extends I2PSamStateMachine { + private final String id; + private final SocketAddress socketAddress; + private final String friendCode; + private boolean connected; + private I2PSamCommand status; + + public I2PSamStreamConnector(SocketAddress socketAddress, String id, String friendCode) { + this.id = id; + this.socketAddress = socketAddress; + this.friendCode = friendCode; + } + + @Override + public boolean connect() { + try { + Socket samSocket = new Socket(); + samSocket.connect(this.socketAddress, 3000); + return this.connect(samSocket); + } catch (IOException e) { + return false; + } + } + + public boolean start() { + if (!super.start()) { + return false; + } + try { + this.sendCommand(new I2PSamCommand( + "STREAM", "CONNECT", + ImmutableMap.of( + "ID", this.id, + "DESTINATION", this.friendCode + ) + )); + return this.connected = "OK".equals((this.status = this.getCommand("STREAM", "STATUS")).parameters().get("RESULT")); + } catch (IOException e) { + return false; + } + } + + @Override + protected void sendCommand(I2PSamCommand command) throws IOException { + if (this.connected) { + throw new IllegalStateException("call unwrap() instead"); + } + super.sendCommand(command); + } + + @Override + public void step() throws IOException { + if (this.connected) { + throw new IllegalStateException("call unwrap() instead"); + } + super.step(); + } + + @Override + public Socket unwrap() { + return super.unwrap(); + } + + public I2PSamCommand getStatus() { + return this.status; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java new file mode 100644 index 0000000..1813511 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java @@ -0,0 +1,48 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.net.*; + +public class I2PSamStreamForwarder extends I2PSamStateMachine { + private final String id; + private final String port; + private final SocketAddress socketAddress; + + public I2PSamStreamForwarder(SocketAddress socketAddress, String id, String port) { + this.id = id; + this.port = port; + this.socketAddress = socketAddress; + } + + @Override + public boolean connect() { + try { + Socket samSocket = new Socket(); + samSocket.connect(this.socketAddress, 3000); + return this.connect(samSocket); + } catch (IOException e) { + return false; + } + } + + public boolean start() { + if (!super.start()) { + return false; + } + try { + this.sendCommand(new I2PSamCommand( + "STREAM", "FORWARD", + ImmutableMap.of( + "ID", this.id, + "PORT", this.port, + "SILENT", "true" + ) + )); + return "OK".equals(this.getCommand("STREAM", "STATUS").parameters().get("RESULT")); + } catch (IOException e) { + return false; + } + } +} -- cgit 1.4.1