summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java
blob: 37deda39119236c70c3358e793e1f2cdb9d6c93c (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
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;
    }
}