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