summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam')
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java333
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamControl.java173
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStateMachine.java220
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStreamConnector.java74
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamStreamForwarder.java48
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;
+        }
+    }
+}