Compare commits

...

11 Commits

Author SHA1 Message Date
046deccc26 Write script for rebuilding and running the client and server 2025-02-25 17:34:22 +00:00
b522ef8a98 Implement connection recovery procedures in client 2025-02-25 17:30:23 +00:00
53857bc613 Fix message dropping issue on server
The sockets were initially set to active, which meant that data
received immediately after the TLS handshake (before the socket was
transferred to the handle_connection process) were received by the
acceptor process and hence were not handled.
2025-02-25 16:00:04 +00:00
538405fec9 Move child spec into proto_sup 2025-02-25 16:00:04 +00:00
4eceba338c Remove debug io:format() from tcp_server 2025-02-25 16:00:04 +00:00
ef9e578e25 Implement SEQUENCE encoding and decoding in client 2025-02-25 16:00:04 +00:00
5df58e9d28 Implement DER decoding for BOOLEAN, INTEGER and UTF8String in Client 2025-02-25 16:00:04 +00:00
4b22bd726f Implement DER encoding for BOOLEAN, INTEGER and UTF8String in Client 2025-02-25 16:00:04 +00:00
31712d5efa Move Connection and MainWindow classes to library 2025-02-25 16:00:04 +00:00
27ecce1211 Check in project plan 2025-02-23 19:28:36 +00:00
30d03e3739 Move client sources into client/src dir 2025-02-23 10:25:42 +00:00
14 changed files with 928 additions and 133 deletions

View File

@@ -1,99 +0,0 @@
using Gtk;
public class Connection {
public signal void response_received(uint8[] response);
private TlsClientConnection tls_client;
public Connection() throws Error {
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
var host = new InetSocketAddress(loopback, 12888);
var db_type = TlsBackend.get_default().get_file_database_type();
const string ca_path = Config.CERT_DIR + "/ca.pem";
var db = Object.new(db_type, "anchors", ca_path) as TlsDatabase;
var cert =
new TlsCertificate.from_file(Config.CERT_DIR + "/client.pem");
var plain_client = new SocketClient();
var plain_connection = plain_client.connect(host);
tls_client = TlsClientConnection.new(plain_connection, host);
tls_client.set_database(db);
tls_client.set_certificate(cert);
tls_client.handshake();
}
public async void send(uint8[] message) throws Error {
yield tls_client.output_stream.write_async(message);
var response = new uint8[1024];
var len = yield tls_client.input_stream.read_async(response);
response_received(response[0:len]);
}
}
public class MainWindow : Gtk.ApplicationWindow {
private Connection connection;
private Gtk.Button send_button;
private Gtk.Label response_label;
public MainWindow(Gtk.Application app, Connection connection) {
Object(application: app);
default_width = 600;
default_height = 400;
title = "Study System Client";
this.connection = connection;
connection.response_received.connect((response) => {
response_label.label = "Response: " + (string)response;
});
var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 10);
box.margin_start = 10;
box.margin_end = 10;
box.margin_top = 10;
box.margin_bottom = 10;
send_button = new Gtk.Button.with_label("Send");
send_button.clicked.connect(on_send_clicked);
box.append(send_button);
response_label = new Gtk.Label("");
response_label.wrap = true;
box.append(response_label);
set_child(box);
present();
}
private async void on_send_clicked() {
try {
yield connection.send("Foo".data);
} catch (Error e) {
response_label.label = "Error: " + e.message;
}
}
}
public class StudySystemClient : Gtk.Application {
public StudySystemClient() {
Object(application_id: "sh.wip.study-system-client");
}
protected override void activate() {
try {
var connection = new Connection();
new MainWindow(this, connection);
} catch (Error e) {
stderr.printf("Failed to initialize connection: %s\n",
e.message);
}
}
public static int main(string[] args) {
var app = new StudySystemClient();
return app.run(args);
}
}

View File

@@ -10,21 +10,5 @@ add_project_arguments('-w', language: 'c')
gtk_dep = dependency('gtk4')
conf = configuration_data()
conf.set_quoted(
'CONFIG_CERT_DIR',
join_paths(meson.project_source_root(), '..', 'test'))
configure_file(
output: 'config.h',
configuration: conf
)
exe = executable(
'study-system-client',
[
'main.vala',
'config.vapi'
],
dependencies: [gtk_dep],
c_args: ['-w']
)
subdir('src')
subdir('tests')

221
client/src/connection.vala Normal file
View File

@@ -0,0 +1,221 @@
namespace StudySystemClient {
public class Connection {
public signal void received(uint8[] msg);
private SessionManager session_manager;
private Worker worker;
public Connection(string cert_dir) throws Error {
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
var session_factory
= new SessionFactory(loopback, 12888, cert_dir);
session_manager = new SessionManager(
session_factory, (msg) => {
var msg_copy = new uint8[msg.length];
Memory.copy(msg_copy, msg, msg.length);
Idle.add(() => {
received(msg_copy);
return false;
}, GLib.Priority.DEFAULT_IDLE);
});
worker = new Worker(session_manager);
}
public void send(owned uint8[] msg) {
session_manager.send(msg);
}
}
private class Worker {
private uint TASK_PERIOD_MS = 10;
private SessionManager session_manager;
private bool exit;
private Thread<void> thread;
public Worker(SessionManager session_manager) {
this.session_manager = session_manager;
exit = false;
thread = new Thread<void>("connection_worker", body);
}
~Worker() {
exit = true;
thread.join();
}
private void body() {
while (!exit) {
session_manager.task();
Thread.usleep(1000 * TASK_PERIOD_MS);
}
}
}
private class SessionManager {
public delegate void ReceiveCallback(uint8[] msg);
private const uint INIT_RECONNECT_WAIT_MS = 500;
private const uint MAX_RECONNECT_WAIT_MS = 60000;
private const double RECONNECT_BACKOFF = 1.6;
private SessionFactory session_factory;
private ReceiveCallback receive_callback;
private Session? session;
private AsyncQueue<OutgoingMessage> queue;
private uint reconnect_wait_ms;
public SessionManager(SessionFactory session_factory,
owned ReceiveCallback receive_callback) {
this.session_factory = session_factory;
this.receive_callback = (owned) receive_callback;
this.session = null;
queue = new AsyncQueue<OutgoingMessage>();
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
}
public void send(uint8[] msg) {
queue.push(new OutgoingMessage(msg));
}
public void task() {
if (session != null) {
var failed_msg = session.task(queue);
if (failed_msg != null)
handle_failed_msg(failed_msg);
} else {
try_start_session();
}
}
private void handle_failed_msg(OutgoingMessage msg) {
msg.has_failed();
if (msg.should_retry())
queue.push(msg);
session = null;
}
private void try_start_session() {
try {
session = session_factory.start_session();
session.received.connect(
(msg) => receive_callback(msg));
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
} catch (Error _) {
Thread.usleep(1000 * reconnect_wait_ms);
update_reconnect_wait();
}
}
private void update_reconnect_wait() {
var new_wait = RECONNECT_BACKOFF * reconnect_wait_ms;
if (new_wait < MAX_RECONNECT_WAIT_MS)
reconnect_wait_ms = (uint)new_wait;
else
reconnect_wait_ms = MAX_RECONNECT_WAIT_MS;
}
}
private class SessionFactory {
private const string CA_FILENAME = "/ca.pem";
private const string CERT_FILENAME = "/client.pem";
private const uint TIMEOUT_S = 1;
private InetSocketAddress host;
private TlsCertificate cert;
private TlsDatabase ca_db;
public SessionFactory(InetAddress host_addr, uint16 host_port,
string cert_dir) throws Error {
host = new InetSocketAddress(host_addr, host_port);
var cert_path = cert_dir + CERT_FILENAME;
cert = new TlsCertificate.from_file(cert_path);
var ca_path = cert_dir + CA_FILENAME;
var db_type = TlsBackend.get_default().get_file_database_type();
ca_db = Object.new(db_type, "anchors", ca_path) as TlsDatabase;
}
public Session start_session() throws Error {
var plain_client = new SocketClient();
plain_client.set_timeout(TIMEOUT_S);
var plain_connection = plain_client.connect(host);
var connection = TlsClientConnection.new(plain_connection, host);
connection.set_database(ca_db);
connection.set_certificate(cert);
connection.handshake();
return new Session(connection);
}
}
private class Session {
public signal void received(uint8[] msg);
private const uint MAX_BATCH_SIZE = 10;
private const uint MAX_MSG_LEN = 1024;
private TlsClientConnection connection;
public Session(TlsClientConnection connection) {
this.connection = connection;
}
public OutgoingMessage? task(AsyncQueue<OutgoingMessage> queue) {
for (int i = 0; i < MAX_BATCH_SIZE; ++i) {
if (queue.length() == 0)
break;
var msg = queue.pop();
var success = true;
success &= send(msg);
success &= receive();
if (!success)
return msg;
}
return null;
}
private bool send(OutgoingMessage msg) {
try {
size_t written;
connection.output_stream.write_all(msg.content, out written);
return true;
} catch (IOError _) {
return false;
}
}
private bool receive() {
try {
var buffer = new uint8[MAX_MSG_LEN];
var len = connection.input_stream.read(buffer);
if (len <= 0)
return false;
received(buffer[0:len]);
return true;
} catch (IOError _) {
return false;
}
}
}
private class OutgoingMessage {
public uint8[] content { get; private set; }
private const uint MAX_FAIL_COUNT = 10;
private uint fail_count;
public OutgoingMessage(owned uint8[] content) {
this.content = (owned)content;
fail_count = 0;
}
public void has_failed() {
++fail_count;
}
public bool should_retry() {
return fail_count < MAX_FAIL_COUNT;
}
}
}

265
client/src/der.vala Normal file
View File

@@ -0,0 +1,265 @@
namespace StudySystemClient.Der {
public errordomain DecodeError {
INCOMPLETE,
INVALID_CONTENT,
UNKNOWN_TYPE,
}
private const uint BASE_HEADER_SIZE = 2;
public static Datum decode(uint8[] bytes, out uint? size = null)
throws DecodeError {
if (bytes.length < BASE_HEADER_SIZE) {
throw new DecodeError.INCOMPLETE(
"Message is fewer than %u bytes", BASE_HEADER_SIZE);
}
uint header_size;
var length = decode_length(bytes, out header_size);
if (header_size + length > bytes.length) {
throw new DecodeError.INCOMPLETE(
"Length %u but only %u bytes available", length,
bytes.length - header_size);
}
var content = bytes[header_size:header_size + length];
size = header_size + length;
return decode_datum(bytes[0], content);
}
private static uint decode_length(uint8[] bytes, out uint header_size)
throws DecodeError {
if ((bytes[1] & 0x80) != 0) {
var length_size = bytes[1] & 0x7f;
if (BASE_HEADER_SIZE + length_size > bytes.length) {
throw new DecodeError.INCOMPLETE(
"Length with size %u but only %u bytes available",
length_size, bytes.length - BASE_HEADER_SIZE);
}
var length = 0;
for (int i = 0; i < length_size; ++i)
length = length << 8 | bytes[BASE_HEADER_SIZE + i];
header_size = BASE_HEADER_SIZE + length_size;
return length;
} else {
header_size = BASE_HEADER_SIZE;
return bytes[1];
}
}
private static Datum decode_datum(uint8 type, uint8[] content)
throws DecodeError {
switch (type) {
case Boolean.TYPE:
return new Boolean.from_content(content);
case Integer.TYPE:
return new Integer.from_content(content);
case Utf8String.TYPE:
return new Utf8String.from_content(content);
case Sequence.TYPE:
return new Sequence.from_content(content);
default:
throw new DecodeError.UNKNOWN_TYPE("Unsupported type: %02x",
type);
}
}
public abstract class Datum {
internal uint8 type;
internal uint8[] content;
public uint8[] encode() {
var buffer = new ByteArray();
buffer.append({type});
if (content.length >= 0x80) {
var length_bytes = encode_length(content.length);
buffer.append({0x80 | (uint8)length_bytes.length});
buffer.append(length_bytes);
} else {
buffer.append({(uint8)content.length});
}
buffer.append(content);
return buffer.data;
}
private static uint8[] encode_length(uint length) {
var buffer = new ByteArray();
int shift = 0;
while (length >> (shift + 8) != 0)
shift += 8;
for (; shift >= 0; shift -= 8)
buffer.append({(uint8)(length >> shift)});
return buffer.data;
}
}
public class Boolean : Datum {
internal const uint8 TYPE = 0x01;
public bool value { get; private set; }
public Boolean(bool val) {
type = TYPE;
content = new uint8[] { val ? 0xff : 0x00 };
value = val;
}
internal Boolean.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_bool(content);
}
internal static bool decode_bool(uint8[] bytes)
throws DecodeError {
if (bytes.length != 1) {
throw new DecodeError.INVALID_CONTENT(
"Invalid length for boolean: %u", bytes.length);
}
switch (bytes[0]) {
case 0xff:
return true;
case 0x00:
return false;
default:
throw new DecodeError.INVALID_CONTENT(
"Invalid value of boolean: %02x", bytes[0]);
}
}
}
public class Integer : Datum {
internal const uint8 TYPE = 0x02;
private const uint8 MAX_BYTES = 8;
public int64 value { get; private set; }
public Integer(int64 val) {
type = TYPE;
content = encode_int64(val);
value = val;
}
internal Integer.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_int64(content);
}
private static uint64 twos_complement(uint64 x) {
return ~x + 1;
}
private static int min_bits(bool negative, uint64 x) {
int n = 0;
if (negative) {
while ((x >> (n + 8) & 0xff) != 0xff)
n += 8;
} else {
while (x >> (n + 8) > 0)
n += 8;
}
return n;
}
private static uint8[] encode_int64(int64 val) {
var negative = val < 0;
var uval = negative ? twos_complement(val.abs()) : val;
var shift = min_bits(negative, uval);
var buffer = new ByteArray();
for (; shift >= 0; shift -= 8)
buffer.append({(uint8)(uval >> shift)});
if (!negative && (buffer.data[0] & 0x80) != 0)
buffer.prepend({0x00});
return buffer.data;
}
private static int64 decode_int64(uint8[] bytes) throws DecodeError {
if (bytes.length > MAX_BYTES) {
throw new DecodeError.INVALID_CONTENT(
"int64 too small for %u bytes", bytes.length);
}
var negative = (bytes[0] & 0x80) != 0;
var val = decode_start_val(negative, bytes.length);
foreach (var byte in bytes)
val = val << 8 | byte;
return negative ? -(int64)twos_complement(val) : (int64)val;
}
private static uint64 decode_start_val(bool negative, uint length)
{
if (!negative)
return 0;
var val = 0;
for (uint i = 0; i < MAX_BYTES - length; ++i)
val = val << 8 | 0xff;
return val;
}
}
public class Utf8String : Datum {
internal const uint8 TYPE = 0x0c;
public string value { get; private set; }
public Utf8String(string val) {
type = TYPE;
content = val.data;
value = val;
}
public Utf8String.from_content(uint8[] bytes) {
type = TYPE;
content = bytes;
value = decode_string(bytes);
}
private static string decode_string(uint8[] bytes) {
var buffer = new uint8[bytes.length + 1];
Memory.copy(buffer, bytes, bytes.length);
buffer[bytes.length] = 0;
return (string)buffer;
}
}
public class Sequence : Datum {
internal const uint8 TYPE = 0x30;
public Datum[] value { get; private set; }
public Sequence(Datum[] val) {
type = TYPE;
content = encode_array(val);
value = val;
}
internal Sequence.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_array(bytes);
}
private static uint8[] encode_array(Datum[] val) {
var buffer = new ByteArray();
foreach (var datum in val)
buffer.append(datum.encode());
return buffer.data;
}
private static Datum[] decode_array(uint8[] bytes)
throws DecodeError {
var elems = new GenericArray<Datum>();
uint offset = 0;
uint size;
while (offset < bytes.length) {
elems.add(decode(bytes[offset:], out size));
offset += size;
}
return elems.data;
}
}
}

24
client/src/main.vala Normal file
View File

@@ -0,0 +1,24 @@
using Gtk;
namespace StudySystemClient {
public class App : Gtk.Application {
public App() {
Object(application_id: "sh.wip.study-system-client");
}
protected override void activate() {
try {
var connection = new Connection(Config.CERT_DIR);
new MainWindow(this, connection);
} catch (Error e) {
stderr.printf("Failed to initialize connection: %s\n",
e.message);
}
}
}
}
int main(string[] args) {
var app = new StudySystemClient.App();
return app.run(args);
}

View File

@@ -0,0 +1,39 @@
namespace StudySystemClient {
const string title = "Study System Client";
public class MainWindow : Gtk.ApplicationWindow {
private Connection connection;
private Gtk.Button send_button;
private Gtk.Label response_label;
public MainWindow(Gtk.Application app, Connection connection) {
Object(application: app);
default_width = 360;
default_height = 580;
this.connection = connection;
connection.received.connect((msg) => {
response_label.label = "Response: " + (string)msg;
});
var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 10);
box.margin_start = 10;
box.margin_end = 10;
box.margin_top = 10;
box.margin_bottom = 10;
send_button = new Gtk.Button.with_label("Send");
send_button.clicked.connect(() => connection.send("Foo".data));
box.append(send_button);
response_label = new Gtk.Label("");
response_label.wrap = true;
box.append(response_label);
set_child(box);
present();
}
}
}

33
client/src/meson.build Normal file
View File

@@ -0,0 +1,33 @@
conf = configuration_data()
conf.set_quoted(
'CONFIG_CERT_DIR',
join_paths(meson.project_source_root(), '..', 'test'))
configure_file(
output: 'config.h',
configuration: conf
)
lib = library(
'study-system-client',
sources: files(
'connection.vala',
'der.vala',
'main_window.vala',
),
dependencies: [gtk_dep],
vala_vapi: 'study-system-client.vapi'
)
lib_dep = declare_dependency(
link_with: lib,
include_directories: include_directories('.')
)
exe = executable(
'study-system-client',
sources: files(
'config.vapi',
'main.vala',
),
dependencies: [lib_dep, gtk_dep],
c_args: ['-w']
)

259
client/tests/der_tests.vala Normal file
View File

@@ -0,0 +1,259 @@
using StudySystemClient;
using StudySystemClient.Der;
static bool bytes_equal(uint8[] expected, uint8[] actual) {
if (expected.length != actual.length)
return false;
return Memory.cmp(expected, actual, expected.length) == 0;
}
static string bytes_to_string(uint8[] bytes) {
var s = "";
foreach (var byte in bytes)
s += "%02x".printf(byte);
return s;
}
static void test_encode(Datum datum, uint8[] expected) {
var bytes = datum.encode();
if (!bytes_equal(expected, bytes)) {
Test.message("Encoding is incorrect: expected %s got %s",
bytes_to_string(expected), bytes_to_string(bytes));
Test.fail();
return;
}
}
static void test_decode_boolean(uint8[] bytes, bool expected) {
Boolean boolean;
try {
boolean = decode(bytes) as Boolean;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (boolean == null) {
Test.message("Bytes were not decoded as a BOOLEAN");
Test.fail();
return;
}
if (boolean.value != expected) {
Test.message(@"Expected $expected got $(boolean.value)");
Test.fail();
return;
}
}
static void test_decode_integer(uint8[] bytes, int64 expected) {
Datum datum;
try {
datum = decode(bytes);
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
test_integer_value(expected, datum);
}
static void test_decode_utf8string(uint8[] bytes, string expected) {
Datum datum;
try {
datum = decode(bytes);
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
test_utf8string_value(expected, datum);
}
static void test_integer_value(int64 expected, Datum datum) {
var integer = datum as Integer;
if (integer == null) {
Test.message("Bytes were not decoded as a INTEGER");
Test.fail();
return;
}
if (integer.value != expected) {
Test.message(@"Expected $expected got $(integer.value)");
Test.fail();
return;
}
}
static void test_utf8string_value(string expected, Datum datum) {
var utf8string = datum as Utf8String;
if (utf8string == null) {
Test.message("Bytes were not decoded as a UTF8String");
Test.fail();
return;
}
if (utf8string.value != expected) {
Test.message(@"Expected $expected got $(utf8string.value)");
Test.fail();
return;
}
}
void main(string[] args) {
Test.init(ref args);
/*
* Encoding
*/
Test.add_func("/encode/boolean/true", () => {
test_encode(new Boolean(true), {0x01, 0x01, 0xff});
});
Test.add_func("/encode/boolean/false", () => {
test_encode(new Boolean(false), {0x01, 0x01, 0x00});
});
Test.add_func("/encode/integer/small/0", () => {
test_encode(new Integer(0), {0x02, 0x01, 0x00});
});
Test.add_func("/encode/integer/small/5", () => {
test_encode(new Integer(5), {0x02, 0x01, 0x05});
});
Test.add_func("/encode/integer/small/42", () => {
test_encode(new Integer(42), {0x02, 0x01, 0x2a});
});
Test.add_func("/encode/integer/large/1337", () => {
test_encode(new Integer(1337), {0x02, 0x02, 0x05, 0x39});
});
Test.add_func("/encode/integer/sign/128", () => {
test_encode(new Integer(128), {0x02, 0x02, 0x00, 0x80});
});
Test.add_func("/encode/integer/sign/0xbeef", () => {
test_encode(new Integer(0xbeef), {0x02, 0x03, 0x00, 0xbe, 0xef});
});
Test.add_func("/encode/integer/sign/-128", () => {
test_encode(new Integer(-128), {0x02, 0x01, 0x80});
});
Test.add_func("/encode/integer/sign/-1337", () => {
test_encode(new Integer(-1337), {0x02, 0x02, 0xfa, 0xc7});
});
Test.add_func("/encode/utf8string/short/foo", () => {
test_encode(new Utf8String("foo"),
{0x0c, 0x03, 0x66, 0x6f, 0x6f});
});
Test.add_func("/encode/utf8string/short/bar", () => {
test_encode(new Utf8String("bar"),
{0x0c, 0x03, 0x62, 0x61, 0x72});
});
Test.add_func("/encode/utf8string/long/x300", () => {
var expected = new uint8[304];
expected[0] = 0x0c;
expected[1] = 0x82;
expected[2] = 0x01;
expected[3] = 0x2c;
Memory.set(expected[4:], 0x78, 300);
test_encode(new Utf8String(string.nfill(300, 'x')), expected);
});
Test.add_func("/encode/utf8string/long/x128", () => {
var expected = new uint8[131];
expected[0] = 0x0c;
expected[1] = 0x81;
expected[2] = 0x80;
Memory.set(expected[3:], 0x78, 128);
test_encode(new Utf8String(string.nfill(128, 'x')), expected);
});
Test.add_func("/encode/sequence/foo,42", () => {
var sequence = new Der.Sequence(
{new Utf8String("foo"), new Integer(42)});
var expected = new uint8[] {
0x30, 0x08, 0x0c, 0x03, 0x66, 0x6f, 0x6f, 0x02, 0x01, 0x2a
};
test_encode(sequence, expected);
});
/*
* Decoding
*/
Test.add_func("/decode/boolean/true", () => {
test_decode_boolean({0x01, 0x01, 0xff}, true);
});
Test.add_func("/decode/boolean/false", () => {
test_decode_boolean({0x01, 0x01, 0x00}, false);
});
Test.add_func("/decode/integer/small/42", () => {
test_decode_integer({0x02, 0x01, 0x2a}, 42);
});
Test.add_func("/decode/integer/large/1337", () => {
test_decode_integer({0x02, 0x02, 0x05, 0x39}, 1337);
});
Test.add_func("/decode/integer/sign/128", () => {
test_decode_integer({0x02, 0x02, 0x00, 0x80}, 128);
});
Test.add_func("/decode/integer/sign/0xbeef", () => {
test_decode_integer({0x02, 0x03, 0x00, 0xbe, 0xef}, 0xbeef);
});
Test.add_func("/decode/integer/sign/-128", () => {
test_decode_integer({0x02, 0x01, 0x80}, -128);
});
Test.add_func("/decode/integer/sign/-1337", () => {
test_decode_integer({0x02, 0x02, 0xfa, 0xc7}, -1337);
});
Test.add_func("/decode/utf8string/short/foo", () => {
test_decode_utf8string({0x0c, 0x03, 0x66, 0x6f, 0x6f}, "foo");
});
Test.add_func("/decode/utf8string/short/bar", () => {
test_decode_utf8string({0x0c, 0x03, 0x62, 0x61, 0x72}, "bar");
});
Test.add_func("/decode/utf8string/long/x300", () => {
var bytes = new uint8[304];
bytes[0] = 0x0c;
bytes[1] = 0x82;
bytes[2] = 0x01;
bytes[3] = 0x2c;
Memory.set(bytes[4:], 0x78, 300);
test_decode_utf8string(bytes, string.nfill(300, 'x'));
});
Test.add_func("/decode/utf8string/long/x128", () => {
var bytes = new uint8[131];
bytes[0] = 0x0c;
bytes[1] = 0x81;
bytes[2] = 0x80;
Memory.set(bytes[3:], 0x78, 128);
test_decode_utf8string(bytes, string.nfill(128, 'x'));
});
Test.add_func("/decode/sequence/foo,42", () => {
var bytes = new uint8[] {
0x30, 0x08, 0x0c, 0x03, 0x66, 0x6f, 0x6f, 0x02, 0x01, 0x2a
};
var expected = 2;
Der.Sequence sequence;
try {
sequence = decode(bytes) as Der.Sequence;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (sequence == null) {
Test.message("Bytes were not decoded as a SEQUENCE");
Test.fail();
return;
}
Datum[] elems = sequence.value;
if (elems.length != expected) {
Test.message(
@"Expected $expected elements, got $(elems.length)");
Test.fail();
return;
}
test_utf8string_value("foo", elems[0]);
test_integer_value(42, elems[1]);
});
Test.run();
}

8
client/tests/meson.build Normal file
View File

@@ -0,0 +1,8 @@
test(
'DER tests',
executable(
'der_tests',
'der_tests.vala',
dependencies: [lib_dep, gtk_dep]
)
)

46
plan.txt Normal file
View File

@@ -0,0 +1,46 @@
STUDY SYSTEM
GOALS
1. Support consistent and balanced study across multiple subjects
2. Help prioritize what to study when time/energy is available
3. Low friction of usage
4. Apply cybernetic principles to self-directed education
NON-GOALS
1. Setting targets for time spent studying
2. Measuring effectiveness of studies
3. Simplicity of set-up for non-technical users
4. Reading mail
CORE FEATURES
Priority System:
- Prioritized list of potential study activities
- Separate tracking of reading and exercises
- Neglected subjects increase in priority
- Balance maintenance between reading and exercises
Study health monitoring:
- Subject-level algedonic signals indicating consistency and
balance between reading and exercises
- System-wide algedonic signal indicating balance across subjects
IMPLEMENTATION DECISIONS
- Client/server to enable consistency across multiple devices
- Simple over-the-wire protocol so that future client
implementations for different platforms are feasible
(e.g. Android app)
- TCP with TLS for persistent connection
- Client authentication via certificates (mTLS) for simplicity of
implementation and security
- Server implementation in Erlang/OTP as it's very cool and I want
more practise with it
- ASN.1/DER for message serialization as it's flexible and reasonably
compact and has good Erlang integration
- Native Linux client because I hate the web
- Client implementation in GTK+Vala because it's boring

13
scripts/build-run.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
cd client
if [ ! -d build ]; then
meson setup build client
fi
meson compile -C build
build/src/study-system-client &
cd ..
cd server
rebar3 shell --apps study_system_server

View File

@@ -14,5 +14,10 @@ init([Port, CertDir]) ->
SupFlags = #{stragegy => one_for_all,
intensity => 1,
period => 5},
ChildSpecs = [tcp_server:child_spec(Port, CertDir)],
ChildSpecs = [#{id => tcp_server,
start => {tcp_server, start_link, [Port, CertDir]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [tcp_server]}],
{ok, {SupFlags, ChildSpecs}}.

View File

@@ -4,7 +4,7 @@
-module(tcp_server).
-behaviour(gen_server).
-export([start_link/2, child_spec/2]).
-export([start_link/2]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
@@ -13,22 +13,13 @@
start_link(Port, CertDir) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir], []).
child_spec(Port, CertDir) ->
#{id => ?MODULE,
start => {?MODULE, start_link, [Port, CertDir]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [?MODULE]}.
init([Port, CertDir]) ->
io:format("cert dir: ~p~n", [CertDir]),
TcpOpts = [binary, inet6, {active, false}, {reuseaddr, true}],
SslOpts = [{certfile, filename:join([CertDir, "server.pem"])},
{cacertfile, filename:join([CertDir, "ca.pem"])},
{verify, verify_peer},
{fail_if_no_peer_cert, true}],
{ok, Socket} =
ssl:listen(Port, [binary, inet6, {active, true} | SslOpts]),
{ok, Socket} = ssl:listen(Port, TcpOpts ++ SslOpts),
Pid = spawn_link(fun() -> acceptor_loop(Socket) end),
{ok, #state{socket = Socket, acceptor = Pid}}.
@@ -58,7 +49,6 @@ acceptor_loop(Socket) ->
Pid = spawn(
fun() -> handle_connection(ClientSocket) end),
ok = ssl:controlling_process(ClientSocket, Pid),
ssl:setopts(ClientSocket, [{active, true}]),
acceptor_loop(Socket);
{error, _Reason} ->
acceptor_loop(Socket)
@@ -68,10 +58,17 @@ acceptor_loop(Socket) ->
end.
handle_connection(Socket) ->
ssl:setopts(Socket, [{active, true}]),
handle_connection_loop(Socket).
handle_connection_loop(Socket) ->
receive
{ssl, Socket, Data} ->
ssl:send(Socket, Data),
handle_connection(Socket);
handle_client_msg(Socket, Data),
handle_connection_loop(Socket);
{ssl_closed, Socket} ->
ok
end.
handle_client_msg(Socket, Msg) ->
ssl:send(Socket, Msg).