This is an old revision of the document!
OK, so now we have a basic Telnet hello world example going - let's move forward.
The plan for the tutorial at this stage is to handle feature negotiation in this part, and input on the next one. Haven't decided what to do after that, but probably a state machine interaction post or two, and then you should know most of what it takes to build a basic old-school BBS system. So, on to feature negotiation.
I've been using SyncTerm as my terminal problem for these posts. When connecting to the prototype BBS, I noticed the feature negotiation flow using the “toggle options” telnet client trick from the intro post. We'll focus on those being requested, and not the whole spectrum of options available. IANA has a list of possible options.
Our prototype will:
All of this will be handled at the start of every connection, so let's revisit our connection handler method:
private def handle_connection(socket) socket << "hello world\n" socket.close end
Pretty bare at this moment. Let's start by telling the client we'll be handling feature negotiation. Also, to hold the connection info, let's introduce a new variable in the mix.
private def handle_connection(socket) socket << "detecting client features..." conn = ConnHash.new socket.close end
We could write down the whole Crystal type, but since it's cumbersome we'll alias it to a shorter name (in this case, ConnHash
). To keep our main file uncluttered, let's move this to a separate file.
module BBS alias ConnHash = Hash(Symbol, Bool | String | Int32) end
Save this in the same folder as main.cr
and name it aliases.cr
since we'll be adding more aliases to this file as needed. Next, require it on main.cr
:
require "./aliases"
The first option we want to negotiate is WILL ECHO to signal the client we want user input to be shown locally. That messag's IAC byte array will look like:
We could create the byte array “by hand” for each message, but let's make it a utility method:
private def iac(*args) Slice(UInt8).new(args.size + 1) do |i| i == 0 ? 255.to_u8 : args[i - 1].to_u8 end end
Looks weird, but it's quite simple. We start off by splatting the arguments passed. This means we'll be able to handle a variable list of arguments, which serves us since some IAC messages have more arguments than others. We then create a byte array (Slice(UInt8)
in Crystal terms) with a size equal to the size of the arguments passed plus one - since we don't want to pass the IAC byte in every method call, we just insert it in the array, thus the plus one being added to the size, making room for it.
In the block's main line, we use the i
variable to loop through the array being constructed. When we're at the 0
index, we insert 255 (IAC) as an unsigned 8bit value, and if we're not at index 0
, we just pass the arguments from the method into the byte array. Ingenious, and gets the job done.
However, the 255
here does not make for very clean code, and we hate that, so let's create a file to house all of our constants. Save this as constants.cr
in the same folder as our main.cr
file:
module BBS IAC = 255 DONT = 254 DO = 253 WONT = 252 WILL = 251 SB = 250 SE = 240 BINARY = 0 ECHO = 1 SEND = 1 # in options context SUP_GOAHEAD = 3 TERMINAL_TYPE = 24 NAWS = 31 end
As before, don't forget to require it in the main file:
require "./constants"
With this file in place, we can now change the IAC method's source to something a bit more readable:
private def iac(*args) Slice(UInt8).new(args.size + 1) do |i| i == 0 ? IAC.to_u8 : args[i - 1].to_u8 end end
Alright, with all this in place, let's abstract the negotiating to its own method on the main handler method:
private def handle_connection(socket) socket << "detecting client features..." conn = ConnHash.new negotiate(iac(WILL, ECHO), :echo, socket, conn) socket.close end
The negotiate
method will take in a byte array (provided by our new iac
utility method), a key (:echo
in this case), the socket
and the conn
(our hash to keep connection state). The key being passed is to be set on the conn
hash as either true/false once the negotiation is done. The negotiate method looks like this:
private def negotiate(slice, key, socket, conn) socket.write slice reply = Slice(UInt8).new(3) socket.read_fully reply case reply[1] when DO, WILL conn[key] = true when DONT, WONT conn[key] = false else puts "error negotiating #{key}!/#{reply.inspect}" end end
First off, we write the slice (byte array) passed onto the socket. This will signal the client we want to initiate the negotiation for the feature encoded on the array. Then, we read the reply. The reply is always three bytes long, we so create a reply
slice and read the response onto it from the socket. Then we get a bit lazy and only check the middle byte in the reply! Since the first byte will be IAC, and the last one is a repetition of the value we got, we only care if the middle byte is DO/WILL or DONT/WONT. Beware that in a proper system, we'd need some error handling here. Based on the yes/no character of the reply, we set the key passed on the conn
hash as true or false.
Thus, after this method runs, the hash will hold if the client agreed to local echo or not!