diff options
author | SoniEx2 <endermoneymod@gmail.com> | 2022-07-03 23:12:06 -0300 |
---|---|---|
committer | SoniEx2 <endermoneymod@gmail.com> | 2022-07-03 23:12:06 -0300 |
commit | bfb981cd49a6bbcd15482dceeb4ab121c0408157 (patch) | |
tree | 54f1fca2884e68f5a8e6205bf34a01cc62c8a953 /src/main/java/ganarchy/friendcode/sam | |
parent | 0c1c11065062c745ce49529e9bee48b05aa4bc41 (diff) |
[Project] Friend Code
A Minecraft mod which adds friend codes, an easy way to play in the same world with remote friends.
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam')
5 files changed, 848 insertions, 0 deletions
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java new file mode 100644 index 0000000..bed5bb8 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java @@ -0,0 +1,333 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * An I2P SAM command. + * + * @param name The command name. + * @param parameters The parameters. + */ +record I2PSamCommand(String name, String opcode, Map<String, String> parameters) { + /** + * Creates a new I2P SAM command. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + * @param parameters The parameters. + * @throws IllegalArgumentException If the command or parameters don't follow the expected format. + * @throws NullPointerException If anything (except for opcode) is null. + */ + public I2PSamCommand(final String name, final String opcode, final Map<String, String> parameters) { + this.name = name; + this.opcode = opcode; + this.parameters = ImmutableMap.copyOf(parameters); + + // command formats: + // COMMAND SUBCOMMAND KEY KEY= KEY="" KEY=VALUE KEY=" " KEY="\"" KEY="\\" + // COMMAND + // PING[ arbitrary text] + // PONG[ arbitrary text] + // none of these may contain newlines (\n) + + // edge-case: I2PRouter treats empty values as "true", while we distinguish empty values. + // it's not strictly specified how they are to be handled, but anyway. + + // check newlines + if (this.name.contains("\n") || (this.opcode != null && this.opcode.contains("\n")) || this.parameters.entrySet().stream().anyMatch(arg -> arg.getKey().contains("\n") || arg.getValue().contains("\n"))) { + throw new IllegalArgumentException("commands may not contain embedded newlines"); + } + + if (this.name.isEmpty()) { + throw new IllegalArgumentException("name must not be empty"); + } + + // check for PING/PONG + if (this.name.equals("PING") || this.name.equals("PONG")) { + // reject parameters + if (!this.parameters.isEmpty()) { + throw new IllegalArgumentException("PING/PONG does not accept parameters"); + } + // skip any other validation + return; + } + + // everything else is fine + + // NOTE: we don't special-case I2PRouter's weirdness with \"\"COMMAND\"\" and \"\"OPCODE\"\" + // for one, we require name != null and !name.isEmpty(), so \"\"COMMAND\"\" would error on I2PRouter anyway, + // for two, single-word commands don't take a subcommand, so it doesn't even matter if you set \"\"OPCODE\"\". + } + + /** + * Creates a new I2P SAM command, with no parameters. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + */ + public I2PSamCommand(final String name, final String opcode) { + this(name, opcode, ImmutableMap.of()); + } + + /** + * Builds the complete command line. + * + * @return The complete command line. + */ + public String buildCommandLine() { + final StringBuilder sb = new StringBuilder(); + sb.append(escape(this.name, false)); + if (this.opcode != null) { + sb.append(' '); + if (this.name.equals("PING") || this.name.equals("PONG")) { + sb.append(this.opcode); + } else { + sb.append(escape(this.opcode, false)); + } + } + for (final var argument : parameters.entrySet()) { + sb.append(' '); + sb.append(escape(argument.getKey(), false)); + sb.append('='); + sb.append(escape(argument.getValue(), true)); + } + return sb.toString(); + } + + /** + * Parses the given command line and returns an I2PSamCommand. + * + * @param commandLine The command line, without terminating newline. + * @return The parsed command, or null if there was an error. + */ + public static I2PSamCommand parse(final String commandLine) { + // we're a bit more strict than I2PRouter in how we parse these, but that should be okay. + if (commandLine.isEmpty()) { + return null; + } + if (commandLine.contains("\n")) { + throw new IllegalArgumentException("commandLine may not contain embedded newlines"); + } + final var rawParams = new StringBuilder(commandLine); + int index; + index = nextToken(rawParams); + final String name = rawParams.substring(0, index); + if (index == rawParams.length()) { // invariant: length() != 0 + // COMMAND + // (includes PING/PONG with no data) + return new I2PSamCommand(name, null); + } else if (name.isEmpty() || rawParams.charAt(index) != ' ') { + throw new IllegalArgumentException("commandLine must start with a command"); + } + rawParams.delete(0, index + 1); + if (name.equals("PING") || name.equals("PONG")) { + // PING arbitrary text + // PONG arbitrary text + return new I2PSamCommand(name, rawParams.toString()); + } + // COMMAND SUBCOMMAND[ ...] + index = nextToken(rawParams); + final String opcode = rawParams.substring(0, index); + if (opcode.isEmpty()) { // also if length() == 0 + // COMMAND =... or some other BS + // we do not currently accept trailing spaces or parameters for single-word commands coming from the bridge. + throw new IllegalArgumentException("expected subcommand"); + } else if (index == rawParams.length()) { // invariant: length() != 0 (checked above) + // COMMAND SUBCOMMAND + return new I2PSamCommand(name, opcode); + } else if (rawParams.charAt(index) != ' ') { // index != length() + throw new IllegalArgumentException("expected subcommand"); + } + rawParams.delete(0, index + 1); + // COMMAND SUBCOMMAND ... + final ImmutableMap.Builder<String, String> params = ImmutableMap.builder(); + // params + while (!rawParams.isEmpty()) { + index = nextToken(rawParams); + String key = rawParams.substring(0, index); + if (key.isEmpty()) { + throw new IllegalArgumentException("expected parameter key"); + } else if (index == rawParams.length()) { + // KEY + params.put(key, ""); + rawParams.setLength(0); + } else if (rawParams.charAt(index) == ' ') { + // KEY ... + params.put(key, ""); + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index) == '=') { + rawParams.delete(0, index + 1); + if (rawParams.length() == 0) { + // KEY= + params.put(key, ""); + } else if (rawParams.charAt(0) == '"') { + // KEY="... + // just special-case it + rawParams.deleteCharAt(0); + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == '\r' || c == '"') { + // characters that must be escaped in quotes + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + if (index == rawParams.length()) { + // KEY=" + throw new IllegalArgumentException("unterminated quote"); + } else if (rawParams.charAt(index) == '"') { + // KEY=""... + final String value = rawParams.substring(0, index); + params.put(key, value); + if (index + 1 == rawParams.length()) { + // KEY="" + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index + 1) == ' ') { + // KEY="" ... + rawParams.delete(0, index + 2); + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + // KEY=... + index = nextToken(rawParams); + if (index == rawParams.length()) { + // KEY=VALUE + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == '=') { + // KEY=VALUE=... + // just special-case it + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + final String value = rawParams.substring(0, index); + if (index == rawParams.length()) { + // KEY=VALUE= + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE= ... + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE ... + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } + } else { + throw new IllegalArgumentException("expected parameter key"); + } + } + return new I2PSamCommand(name, opcode, params.build()); + } + + @Override + public String toString() { + // The built command line is likely nicer to use when debugging. + return this.buildCommandLine(); + } + + /** + * Escapes and optionally quotes a string. + * + * @param input The input string. + * @param valueSection Whether this is a value section. + * @return The escaped and optionally quoted string. + */ + private static String escape(final String input, final boolean valueSection) { + final StringBuilder sb = new StringBuilder(0); + int lastIndex = 0; + for (int index = 0; index < input.length(); index++) { + final int c = input.charAt(index); + final boolean escape; + if (c == '=') { + // only escaped outside of value section, but does not require quoting. + escape = !valueSection; + } else if (c == ' ' || c == '\t' || c == '\f' || c == '\b') { + // only escaped outside of value section. requires quoting. + escape = !valueSection; + if (valueSection) { + // make sure the sb isn't empty so it actually adds quotes + sb.append(input, lastIndex, index + 1); + lastIndex = index + 1; + } + } else if (c == '\r' || c == '"' || c == '\\') { + // always escaped + escape = true; + } else { + // never escaped + escape = false; + } + if (escape) { + sb.append(input, lastIndex, index); + sb.append('\\'); + sb.append(c); + lastIndex = index + 1; + } + } + if (!sb.isEmpty()) { + sb.append(input, lastIndex, input.length()); + if (valueSection) { + sb.insert(0, '"'); + sb.append('"'); + } + return sb.toString(); + } else { + return input; + } + } + + /** + * Parses the next token. + * + * @param rawParams The input being processed. + * @return The index of the end of the next token. + */ + private static int nextToken(final StringBuilder rawParams) { + int index; + for (index = 0; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '=' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + return index; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java new file mode 100644 index 0000000..f470bb0 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamControl.java @@ -0,0 +1,173 @@ +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; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java new file mode 100644 index 0000000..d05717d --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java @@ -0,0 +1,220 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; +import ganarchy.friendcode.FriendCode; +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 { + // FIXME user/password from config + private static final I2PSamCommand HELLO_MESSAGE = new I2PSamCommand( + "HELLO", "VERSION", + ImmutableMap.of( + "MIN", "3.2", + "USER", "minecraft_friendcode", + "PASSWORD", "friendcode" + ) + ); + private Socket samSocket; + private PushbackReader reader; + private OutputStreamWriter writer; + private final ArrayDeque<I2PSamCommand> queue = new ArrayDeque<>(); + private boolean started; + private boolean connected; + private String id; + + protected I2PSamStateMachine() { + } + + /** + * Connects to the SAM socket. + * + * @return Whether the connection was successful. + */ + public abstract boolean connect(); + + protected boolean connect(Socket samSocket) { + this.samSocket = samSocket; + if (this.connected) { + throw new IllegalStateException(); + } + 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 + // FIXME let the user change the credentials + this.sendCommand(HELLO_MESSAGE); + return "OK".equals(this.getCommand("HELLO", "REPLY").parameters().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<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 { + this.samSocket.close(); + } + + protected Socket unwrap() { + Socket socket = this.samSocket; + this.samSocket = null; + this.reader = null; + this.writer = null; + return socket; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java new file mode 100644 index 0000000..95b6a61 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java @@ -0,0 +1,74 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.net.*; + +public class I2PSamStreamConnector extends I2PSamStateMachine { + private final String id; + private final SocketAddress socketAddress; + private final String friendCode; + private boolean connected; + private I2PSamCommand status; + + public I2PSamStreamConnector(SocketAddress socketAddress, String id, String friendCode) { + this.id = id; + this.socketAddress = socketAddress; + this.friendCode = friendCode; + } + + @Override + public boolean connect() { + try { + Socket samSocket = new Socket(); + samSocket.connect(this.socketAddress, 3000); + return this.connect(samSocket); + } catch (IOException e) { + return false; + } + } + + public boolean start() { + if (!super.start()) { + return false; + } + try { + this.sendCommand(new I2PSamCommand( + "STREAM", "CONNECT", + ImmutableMap.of( + "ID", this.id, + "DESTINATION", this.friendCode + ) + )); + return this.connected = "OK".equals((this.status = this.getCommand("STREAM", "STATUS")).parameters().get("RESULT")); + } catch (IOException e) { + return false; + } + } + + @Override + protected void sendCommand(I2PSamCommand command) throws IOException { + if (this.connected) { + throw new IllegalStateException("call unwrap() instead"); + } + super.sendCommand(command); + } + + @Override + public void step() throws IOException { + if (this.connected) { + throw new IllegalStateException("call unwrap() instead"); + } + super.step(); + } + + @Override + public Socket unwrap() { + return super.unwrap(); + } + + public I2PSamCommand getStatus() { + return this.status; + } +} diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java new file mode 100644 index 0000000..1813511 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java @@ -0,0 +1,48 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.net.*; + +public class I2PSamStreamForwarder extends I2PSamStateMachine { + private final String id; + private final String port; + private final SocketAddress socketAddress; + + public I2PSamStreamForwarder(SocketAddress socketAddress, String id, String port) { + this.id = id; + this.port = port; + this.socketAddress = socketAddress; + } + + @Override + public boolean connect() { + try { + Socket samSocket = new Socket(); + samSocket.connect(this.socketAddress, 3000); + return this.connect(samSocket); + } catch (IOException e) { + return false; + } + } + + public boolean start() { + if (!super.start()) { + return false; + } + try { + this.sendCommand(new I2PSamCommand( + "STREAM", "FORWARD", + ImmutableMap.of( + "ID", this.id, + "PORT", this.port, + "SILENT", "true" + ) + )); + return "OK".equals(this.getCommand("STREAM", "STATUS").parameters().get("RESULT")); + } catch (IOException e) { + return false; + } + } +} |