An attempt to talk to taskserver
15 Aug 2020Since long I wanted to explore writing code to use a low-level protocol, but I don’t have the patience to read long RFCs and also I haven’t delved into byte-level stuff much. Working with things like these means writing a lot of C code which again I am not particularly interested in.
I am using Taskwarrior on and off to manage tasks. It has nice features like organizing tasks into projects, adding tags to them and so on, has an Android app, CLI client and a server to sync task statuses across multiple clients so it works for me!
I was wondering if there is any good web app that interfaces with Taskserver and saw github.com/theunraveler/taskwarrior-web. Looking at this I thought of trying to write something that talks to taskserver through its API.
Taskserver details
To talk more about taskserver, it is a server that helps multiple taskwarrior clients sync tasks. It does the magic of merging changes coming from clients when they sync with server so that clients can be dumb and just store tasks, local changes made to them and show them to user.
Taskwarrior website has a page on the details of what it would take to write a client that interfaces with taskserver here. It has its own text-based protocol for syncing the tasks that is documented here. The docs also mention that the server strictly talks over TLS. The protocol isn’t complex and a 15-minute read gave me a good idea of what I need to do.
I already had my taskserver running, so all I had to do was create a new org, user and client certificates so that I don’t mess up my existing tasks accidentally. (Setting up taskserver and clients is very neatly mentioned here.)
There was an attempt
Day 1
Since I did not want to start writing header files, Makefiles and deal with bunch of pointer math, I tried to use NodeJs and began with writing a small snippet that makes a single request to taskserver and get a response.
I copied the client certificates generated to my project directory and got hold of the unique user key.
The documentation is pretty good and there are clear instructions about what fields in protocol are mandatory and what can be optional.
Details of the request
The request I started with is the ‘First Sync’ request mentioned in client design document. As per this document and message specification, the request should be constructed as follows -
- Size - a 4-byte big-Endian binary byte count of the length of request including the 4 bytes of size.
- Header section - Set of key-value pairs separated by newline characters (
U+000D
). Key and value is separated by colon and space (U+003A
andU+0020
). Header section is terminated by two consequtive newline characters. - Payload section - Arbitrary, message specific UTF-8 encoded text.
The ‘First Sync’ request does not contain any payload section. Based on above, it would be constructed as -
<size-4-bytes>type: sync<newline>org: testorg<newline>user: testuser<newline>key: 703ade42-37c4-4ee8-b2ad-3fd854126380<newline>client: nodewarrior 2.3.0<newline>protocol: v1<newline><newline>
Making request in NodeJs
After searching for how to communicate via a socket over TLS in Node documentation, how to write the \u000D
carriage return characters as per the specification, using buffers and writing the request payload for initial synchronization request from client as per the protocol, this is what I had -
const tls = require('tls');
const fs = require('fs');
const options = {
host: 'taskserver.example.com',
// Necessary only if the server requires client certificate authentication.
key: fs.readFileSync('./testuser.key.pem'),
cert: fs.readFileSync('./testuser.cert.pem'),
// Necessary only if the server uses a self-signed certificate.
ca: [ fs.readFileSync('./ca.cert.pem') ],
// Necessary only if the server's cert isn't for "localhost".
checkServerIdentity: () => { return null; },
};
const socket = tls.connect(12381, options, () => {
console.log('client connected',
socket.authorized ? 'authorized' : 'unauthorized');
// Payload for initial sync request
let str = '1234type: sync\u000Dorg: testorg\u000Duser: testuser\u000Dkey: 703ade42-37c4-4ee8-b2ad-3fd854126380\u000Dclient: nodewarrior 2.3.0\u000Dprotocol: v1\u000D\u000D';
let payload = Buffer.from(str, 'utf-8');
// Overwrite the first 4 bytes occupied by 1234 temporarily with a 32-bit big-Endian number designating length of payload in bytes
payload.writeInt32BE(payload.byteLength, 0);
console.log('Buffer length: ' + payload.byteLength);
socket.write(payload);
});
socket.setEncoding('utf8');
socket.on('data', (data) => {
console.log(data);
});
socket.on('end', () => {
console.log('server ends connection');
});
Host and port are changed in above snippet!
In above snippet a string is constructed as per the First Sync request specification. To reserve the first 4 bytes for size, I prepended the string with 4 characters - ‘1234’. These 4 characters are later overwritten with proper buffer length in bytes with writeInt32BE
at offset 0.
My first thought was there are going to hours where I am going to struggle with TLS setup and errors about certificates. Once that is sorted out the communication is going to be easy. Half expecting an un-readable response or an error from Node, I tried running this and got -
client connected authorized
Buffer length: 125
Eclient: taskd 1.1.0
code: 500
status: ERROR: Malformed message
server ends connection
Since the communication happened successfully (no TLS-errors and so on), I couldn’t believe my luck and actually changed the cert files with a different pem file and so on. In those cases I got TLS errors, so I thought so far so good!
Now onto fixing any mistakes with the payload, I read and re-read the documentation and checked my payload for initial sync request. Everything seemed correct. To be sure that a sync can happen with these credentials, I used a local installation of task
(a client that can talk to taskserver) with the same cert files and my credentials. It was able to sync with the server successfully.
Maybe I had the payload constructed wrong in Node? Maybe Node constructs the payload bytes in some different way internally? I inspected the byte buffer just before writing it to the socket -
Buffer(125) [0, 0, 0, 125, 116, 121, 112, 101, 58, 32, 115, 121, 110, 99, 13.....
The payload length was indeed 125 bytes. I counted the string manually character by character.
‘The first is the size, which is a 4-byte, big- Endian, binary byte count of the length of the message, including the 4 bytes for the size.’, they said. I am overwriting first 4 bytes with writeInt32BE
so that checks out.
UTF-8 codes after 125 - 116, 121, 112 matched with ‘t’, ‘y’, ‘p’ which is the next part of the request. Looks good.
At this point I had spent 3-4 hours trying to think what could be wrong here. Time to take some rest and try more next day!
Day 2
Inspecting the buffer
Next day I tried to write the buffer into a file and inspect that. Is Node sending any extra spurious bytes at the end? At the beginning? Who knows?
Replacing socket.write(payload);
with fs.writeFileSync('./bytes', payload);
I dumped the buffer (or at this stage a bugger?) into a file. (Thank you Node for making it a one-liner for writing to a file!)
Trying to inspect the file - hexdump -x bytes
0000000 0000 7d00 7974 6570 203a 7973 636e 6f0d
0000010 6772 203a 6574 7473 726f 0d67 7375 7265
0000020 203a 6574 7473 7375 7265 6b0d 7965 203a
0000030 3037 6133 6564 3234 332d 6337 2d34 6534
0000040 3865 622d 6132 2d64 6633 3864 3435 3231
0000050 3336 3038 630d 696c 6e65 3a74 6e20 646f
0000060 7765 7261 6972 726f 3220 332e 302e 700d
0000070 6f72 6f74 6f63 3a6c 7620 0d31 000d
000007d
Hmm, so hexdump
shows a pairs of bytes in reversed order. For example we wrote first 4 bytes with the buffer length, so first 4 bytes should have been 0000 007d
even as per the buffer object I got in debug console. Every pair of bytes is reversed. 7974
translates to (121, 116), but buffer object showed 121 after 116.
To verify that this isn’t an issue, I tried dumping a single byte at a known offset. Dumping 5th byte in this file to see if it’s 79 or 74 -
dd skip=4 count=1 bs=1 if=bytes of=bytes2
(By now I really felt there was more BS happening than bs=1
). Hexdumping the new file - hexdump -x bytes2
-
000000 0074
0000001
So it’s just the way of showing the results by hexdump
that’s confusing. ls -l bytes2
-rw-r--r-- 1 shaarad shaarad 1 15 Aug 13:01 bytes2
shows the file size is 1 byte as well and the only byte it holds is the correct 5th byte in buffer - 0x74
.
Using the file dump of buffer to make requests to taskserver
By this point I was sure to the best of my knowledge that the buffer payload is correct. Now the question whether is it being sent correctly by Node or not? I started searching about how can I talk to the server through an alternate way. After few Google searches this article gave me hope. Searching more about ncat
, I decided to give it a go.
But how do you use ncat
for the first time and send bytes from file to server through it? You Google more! Found this StackExchange post that could get me even closer.
Created a FIFO as the post said. Set up ncat
piped to this FIFO with
ncat --ssl --ssl-cert testuser.cert.pem --ssl-key testuser.key.pem --ssl-trustfile ca.cert.pem taskserver.example.com 12381 < "$tmpf"
Now it’s time to send data to FIFO and keep fingers crossed! From another shell -
exec 3> /var/folders/hs/lpd3p3s52dx8xlgbk1zs4ddw0000gn/T/tmp.GgCG7mgI/fifo
cat ./bytes >&3
And on the shell running ncat
, I got -
Eclient: taskd 1.1.0
code: 500
status: ERROR: Malformed message
At this point I let out a sigh. Sometimes things don’t work out the way you expect. I had checked and double-checked each piece that I thought can go wrong but couldn’t identify the problem.
Next steps?
- Increase verbosity on the server side to see if I can get why it says ‘Malformed message’? - Tried changing the
verbose
setting in taskd config but I didn’t get any extra helpful messages. - Check the code on GitHub repository for ‘Malformed message’? - I din’t find any such string there - at least on master. I didn’t clone the repository, checkout the exact commit and search.
- Enable packet capturing and capture the sync request from
task
CLI that worked with the same credentials? - I can try that but it’s time consuming as I have no experience with TCP-level packet capture and I am not sure if I could decrypt the packet even if I captured it. - Ask for help on IRC? - asked for help on #taskwarrior on Freenode but didn’t get any reply.
- Ask on mailing list for Taskserver? - Sent a mail with the details of the problem.
Success
Later the same day a gentleman on Reddit helped with how I could get more details from taskserver side. Basically if the server runs as a daemon, it has limiteed verbosity. Running it interactively from shell with --debug
gives much more details
taskd server --debug --debug.tls=2
After starting it interactively and trying to make the same requests as earlier, this is what I got on server side -
s: INFO connection from <IP_ADDRESS> port 58092
s: 2 checking 13.02 (GNUTLS_AES_256_GCM_SHA384) for compatibility
s: 2 Selected (RSA) cert based on ciphersuite 13.2: GNUTLS_AES_256_GCM_SHA384
s: 2 EXT[0x56325af745a0]: server generated X25519 shared key
s: INFO Verifying certificate.
s: INFO The certificate is trusted.
s: INFO Handshake was completed: (TLS1.3)-(ECDHE-X25519)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
s: INFO expecting 131 bytes.
' (131 bytes)arrior 0.0.13ac-3fd85412e380
s: INFO Sending 'XXXXclient: taskd 1.1.0
code: 500
status: ERROR: Malformed message
' (69 bytes)
The part ' (131 bytes)arrior 0.0.13ac-3fd85412e380
did look malformed.
Then I tried to send the same sync request, but this time by sending the payload from stdin
. For this I had to first send the 4 bytes of payload size directly and then pipe stdin
to socket so that I could send rest of the headers myself.
let sizeBuf = Buffer.allocUnsafe(4);
sizeBuf.writeInt32BE(125); // As the request payload I was attempting was 125 bytes
socket.write(sizeBuf);
process.stdin.pipe(socket); // Pipe stdin to socket to enter rest of the payload manually
And it worked!
With this the suspect was the newline character I was using (as per the spec of course) - \u000D
. I changed my request payload by replacing \u000D
with usual newline characters - \n
.
// Payload for initial sync request
let str = '1234type: sync\norg: testorg\nuser: testuser\nkey: 703ade42-37c4-4ee8-b2ad-3fd854126380\nclient: nodewarrior 2.3.0\nprotocol: v1\n\n';
With this change the request worked perfectly and I got all the tasks stored on the server in the response!
s: INFO expecting 125 bytes.
s: INFO Receiving 'XXXXtype: sync
org: testorg
user: testuser
key: 703ade42-37c4-4ee8-b2ad-3fd854126380
client: nodewarrior 2.3.0
protocol: v1
' (125 bytes)
s: INFO Sending 'XXXXclient: taskd 1.1.0
code: 200
status: Ok
{"description":"test","end":"20191209T143039Z",.......
Closing thoughts
Taskserver message format has numerous mentions of newline characters as U+000D
. However as per Wikipedia’s Unicode character list, U+000D
is ‘Carriage return’ (CR) and line feed (LF) is U+000A
. This is also explained more in this StackOverflow post.
A simple difference in EOL characters took around 6-7 hours of Googling and fiddling. Nevertheless it was fun trying to find and isolate the problem!
I have sent a mail to the taskserver mailing list requesting to correct this!