summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam/I2PSamControl.java')
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamControl.java173
1 files changed, 173 insertions, 0 deletions
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<String, String> 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;
+    }
+}