summary refs log blame commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
blob: 37deda39119236c70c3358e793e1f2cdb9d6c93c (plain) (tree)
1
2
3
4
5



                                              
                                           










                                                               





                                                                       











                                                     


                                              
                                   























                                                                                                                                   








                                                    











                                                                              






                                        
          

































































                                                                                                
                                      













































                                                                                                           
      










                                                             
      


                                                         

                                   
         









                                       
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<I2PSamCommand> 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.
     * <p>
     * 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<I2PSamCommand> 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;
    }
}