package ganarchy.friendcode.sam; import com.google.common.collect.ImmutableMap; import ganarchy.friendcode.FriendCode; import ganarchy.friendcode.util.ConfigUtil; 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 { private Socket samSocket; private PushbackReader reader; private OutputStreamWriter writer; private final ArrayDeque queue = new ArrayDeque<>(); private boolean started; private boolean connected; protected I2PSamStateMachine() { } /** * Connects to the SAM socket. * * @return Whether the connection was successful. */ public abstract boolean connect(); protected boolean connect(Socket samSocket) { if (this.connected) { throw new IllegalStateException(); } this.samSocket = samSocket; 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 var auth = I2PSamAuthUtil.getAuthPair(); this.sendCommand(new I2PSamCommand( "HELLO", "VERSION", ImmutableMap.of( "MIN", "3.2", "USER", auth.user(), "PASSWORD", auth.password() ) )); var replyParams = this.getCommand("HELLO", "REPLY").parameters(); if ("I2P_ERROR".equals(replyParams.get("RESULT"))) { var msg = replyParams.getOrDefault("MESSAGE", ""); FriendCode.LOGGER.error("Couldn't connect to I2P: {}", msg); FriendCode.LOGGER.error( "If the above error is about authorization," + " please create the relevant file at {} and provide" + " i2p.sam.username and i2p.sam.password.", ConfigUtil.getGlobalConfigFilePath() ); } return "OK".equals(replyParams.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 { if (this.connected) { this.samSocket.close(); } } protected Socket unwrap() { Socket socket = this.samSocket; this.samSocket = null; this.reader = null; this.writer = null; return socket; } }