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; } }