some more work, still not connected to mqtt tho

pull/40/head
Michael Zanetti 2018-08-08 23:05:34 +02:00
parent 383eb2f682
commit d2170f1adb
11 changed files with 397 additions and 54 deletions

View File

@ -7,7 +7,13 @@
#include <QJsonDocument>
#include <QSettings>
#include "qmqtt.h"
#include "sigv4utils.h"
static QByteArray clientId = "8rjhfdlf9jf1suok2jcrltd6v";
static QByteArray region = "eu-west-1";
//static QByteArray service = "iotdevicegateway";
static QByteArray service = "iotdata";
AWSClient::AWSClient(QObject *parent) : QObject(parent)
{
@ -18,6 +24,12 @@ AWSClient::AWSClient(QObject *parent) : QObject(parent)
m_username = settings.value("username").toString();
m_accessToken = settings.value("accessToken").toByteArray();
m_idToken = settings.value("idToken").toByteArray();
m_refreshToken = settings.value("refreshToken").toByteArray();
m_accessKeyId = settings.value("accessKeyId").toByteArray();
m_secretKey = settings.value("secretKey").toByteArray();
m_sessionToken = settings.value("sessionToken").toByteArray();
}
bool AWSClient::isLoggedIn() const
@ -55,46 +67,46 @@ void AWSClient::login(const QString &username, const QString &password)
authParams.insert("PASSWORD", password);
params.insert("AuthParameters", authParams);
QJsonDocument jsonDoc = QJsonDocument::fromVariant(params);
QJsonDocument jsonDoc = QJsonDocument::fromVariant(params);
QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact);
QNetworkReply *reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, &AWSClient::initiateAuthReply);
qDebug() << "Logging in to AWS as user:" << username;
QNetworkReply *reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Error logging in to aws:" << reply->error() << reply->errorString();
return;
}
QByteArray data = reply->readAll();
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qWarning() << "Failed to parse AWS login response" << error.errorString();
return;
}
QVariantMap authenticationResult = jsonDoc.toVariant().toMap().value("AuthenticationResult").toMap();
m_accessToken = authenticationResult.value("AccessToken").toByteArray();
m_idToken = authenticationResult.value("IdToken").toByteArray();
m_refreshToken = authenticationResult.value("RefreshToken").toByteArray();
QSettings settings;
settings.beginGroup("cloud");
settings.setValue("accessToken", m_accessToken);
settings.setValue("idToken", m_idToken);
settings.setValue("refreshToken", m_refreshToken);
qDebug() << "AWS login successful" << qUtf8Printable(jsonDoc.toJson(QJsonDocument::Indented));
emit isLoggedInChanged();
});
}
void AWSClient::initiateAuthReply()
void AWSClient::getId()
{
QNetworkReply* reply = static_cast<QNetworkReply*>(sender());
reply->deleteLater();
QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Error logging in to aws:" << reply->error() << reply->errorString() << qUtf8Printable(data);
return;
}
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qWarning() << "Failed to parse AWS login response" << error.errorString();
return;
}
QVariantMap authenticationResult = jsonDoc.toVariant().toMap().value("AuthenticationResult").toMap();
m_accessToken = authenticationResult.value("AccessToken").toByteArray();
m_idToken = authenticationResult.value("IdToken").toByteArray();
QSettings settings;
settings.beginGroup("cloud");
settings.setValue("accessToken", m_accessToken);
settings.setValue("idToken", m_idToken);
qDebug() << "AWS login successful";
emit isLoggedInChanged();
// return; // Why should we call GetId? Ask Luca
QUrl url("https://cognito-identity.eu-west-1.amazonaws.com/");
QUrlQuery query;
@ -114,19 +126,174 @@ void AWSClient::initiateAuthReply()
params.insert("IdentityPoolId", "eu-west-1:108a174c-5786-40f9-966a-1a0cd33d6801");
params.insert("Logins", logins);
jsonDoc = QJsonDocument::fromVariant(params);
QJsonDocument jsonDoc = QJsonDocument::fromVariant(params);
QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact);
reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, &AWSClient::getIdReply);
QNetworkReply *reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Error calling GetId" << reply->error() << reply->errorString();
return;
}
QByteArray data = reply->readAll();
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qWarning() << "Error parsing json reply for GetId" << error.errorString();
return;
}
QByteArray identityId = jsonDoc.toVariant().toMap().value("IdentityId").toByteArray();
qDebug() << "Received cognito identity id" << identityId;
getCredentialsForIdentity(identityId);
});
}
void AWSClient::getIdReply()
void AWSClient::getCredentialsForIdentity(const QString &identityId)
{
QNetworkReply* reply = static_cast<QNetworkReply*>(sender());
reply->deleteLater();
QByteArray data = reply->readAll();
qDebug() << "GetID reply" << reply->error() << reply->errorString() << qUtf8Printable(data);
QUrl url("https://cognito-identity.eu-west-1.amazonaws.com/");
QUrlQuery query;
query.addQueryItem("Action", "GetCredentialsForIdentity");
query.addQueryItem("Version", "2016-06-30");
url.setQuery(query);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0");
request.setRawHeader("Host", "cognito-identity.eu-west-1.amazonaws.com");
request.setRawHeader("X-Amz-Target", "AWSCognitoIdentityService.GetCredentialsForIdentity");
QVariantMap logins;
logins.insert("cognito-idp.eu-west-1.amazonaws.com/eu-west-1_6eX6YjmXr", m_idToken);
QVariantMap params;
params.insert("IdentityId", identityId);
params.insert("Logins", logins);
QJsonDocument jsonDoc = QJsonDocument::fromVariant(params);
QByteArray payload = jsonDoc.toJson(QJsonDocument::Compact);
qDebug() << "Calling GetCredentialsForIdentity:" << request.url();
qDebug() << "Headers:";
foreach (const QByteArray &headerName, request.rawHeaderList()) {
qDebug() << headerName << ":" << request.rawHeader(headerName);
}
qDebug() << "Payload:" << qUtf8Printable(payload);
QNetworkReply *reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Error calling GetCredentialsForIdentity" << reply->errorString();
return;
}
QByteArray data = reply->readAll();
QJsonParseError error;
QJsonDocument jsonDoc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
qWarning() << "Error parsing JSON reply from GetCredentialsForIdentity" << error.errorString();
return;
}
QVariantMap credentialsMap = jsonDoc.toVariant().toMap().value("Credentials").toMap();
m_accessKeyId = credentialsMap.value("AccessKeyId").toByteArray();
m_secretKey = credentialsMap.value("SecretKey").toByteArray();
m_sessionToken = credentialsMap.value("SessionToken").toByteArray();
m_expirationDate = QDateTime::fromSecsSinceEpoch(credentialsMap.value("Expiration").toLongLong());
QSettings settings;
settings.beginGroup("cloud");
settings.setValue("accessKeyId", m_accessKeyId);
settings.setValue("secretKey", m_secretKey);
settings.setValue("sessionToken", m_sessionToken);
qDebug() << "Raw GetCredentialsForIdentity reply:" << qUtf8Printable(data);
qDebug() << "GetCredentialsForIdentity reply: \nAccess Key ID:" << m_accessKeyId << "\nSecret Key:" << m_secretKey << "\nsessionkey:" << m_sessionToken << "\nExpiration:" << m_expirationDate;
postToMQTT();
});
}
void AWSClient::connectMQTT()
{
QString host = "a2addxakg5juii.iot.eu-west-1.amazonaws.com";
QString uri = "/mqtt";
QNetworkRequest request(QUrl("wss://" + host + uri));
request.setRawHeader("Host", host.toUtf8());
QByteArray dateTime = SigV4Utils::getCurrentDateTime();
// QByteArray canonicalQueryString = SigV4Utils::getCanonicalQueryString(request, m_accessKeyId, m_secretKey, m_sessionToken, region, service, QByteArray());
QByteArray canonicalQueryString = SigV4Utils::getCanonicalQueryString(request, m_accessKeyId, m_secretKey, QByteArray(), region, service, QByteArray());
QString signedRequestUrl = "wss://" + host + uri + '?' + canonicalQueryString;
qDebug() << "Connecting MQTT to" << signedRequestUrl;
QMQTT::Client *mqttClient = new QMQTT::Client(signedRequestUrl, QString(clientId), QWebSocketProtocol::VersionLatest, false);
mqttClient->setClientId(QString(clientId));
mqttClient->setPort(443);
mqttClient->setVersion(QMQTT::V3_1_1);
connect(mqttClient, &QMQTT::Client::connected, this, []{
qDebug() << "MQTT connected";
});
connect(mqttClient, &QMQTT::Client::disconnected, this, []{
qDebug() << "MQTT disconnected";
});
connect(mqttClient, &QMQTT::Client::error, this, [](const QMQTT::ClientError error){
qDebug() << "MQTT error" << error;
});
mqttClient->connectToHost();
}
void AWSClient::postToMQTT()
{
QString host = "a2addxakg5juii.iot.eu-west-1.amazonaws.com";
QString topic = "850593e9-f2ab-4e89-913a-16f848d48867/eu-west-1:88c8b0f1-3f26-46cb-81f3-ccc37dcb543a/";
QString path = "/topics/" + topic.toUtf8().toPercentEncoding().toPercentEncoding().toPercentEncoding() + "?qos=0";
// QString path1 = "/topics/" + topic.toUtf8().toPercentEncoding().toPercentEncoding() + "?qos=0";
QVariantMap params;
params.insert("message", "Hello box");
QByteArray payload = QJsonDocument::fromVariant(params).toJson(QJsonDocument::Compact);
QByteArray dateTime = SigV4Utils::getCurrentDateTime();
// dateTime = "20180808T134011Z";
QNetworkRequest request("https://" + host + path);
request.setRawHeader("content-type", "application/json");
request.setRawHeader("host", host.toUtf8());
request.setRawHeader("x-amz-date", dateTime);
request.setRawHeader("x-amz-security-token", m_sessionToken);
// request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-amz-json-1.0");
QByteArray canonicalRequest = SigV4Utils::getCanonicalRequest(QNetworkAccessManager::PostOperation, request, payload);
qDebug() << "canonical request:" << qUtf8Printable(canonicalRequest);
QByteArray stringToSign = SigV4Utils::getStringToSign(canonicalRequest, dateTime, region, service);
qDebug() << "string to sign:" << stringToSign;
QByteArray signature = SigV4Utils::getSignature(stringToSign, m_secretKey, dateTime, region, service);
qDebug() << "signature:" << signature;
QByteArray authorizeHeader = SigV4Utils::getAuthorizationHeader(m_accessKeyId, dateTime, region, service, request, signature);
request.setRawHeader("Authorization", authorizeHeader);
qDebug() << "Posting to MQTT:" << request.url().toString();
qDebug() << "HEADERS:";
foreach (const QByteArray &headerName, request.rawHeaderList()) {
qDebug() << headerName << ":" << request.rawHeader(headerName);
}
qDebug() << "Payload:" << payload;
QNetworkReply *reply = m_nam->post(request, payload);
connect(reply, &QNetworkReply::finished, this, [reply]() {
reply->deleteLater();
qDebug() << "post reply" << reply->readAll();
});
}
void AWSClient::fetchDevices()
@ -160,6 +327,9 @@ void AWSClient::fetchDevices()
ret.append(d);
}
emit devicesFetched(ret);
});
}
@ -168,3 +338,4 @@ QByteArray AWSClient::accessToken() const
{
return m_accessToken;
}

View File

@ -3,9 +3,9 @@
#include <QObject>
#include <QNetworkRequest>
#include <QDate>
class QNetworkAccessManager;
struct SRPUser;
class AWSDevice {
public:
@ -28,6 +28,9 @@ public:
Q_INVOKABLE void fetchDevices();
Q_INVOKABLE void postToMQTT();
Q_INVOKABLE void getId();
QByteArray accessToken() const;
signals:
@ -35,9 +38,9 @@ signals:
void devicesFetched(QList<AWSDevice> devices);
private slots:
void initiateAuthReply();
void getIdReply();
private:
void getCredentialsForIdentity(const QString &identityId);
void connectMQTT();
private:
QNetworkAccessManager *m_nam = nullptr;
@ -45,6 +48,12 @@ private:
QString m_username;
QByteArray m_accessToken;
QByteArray m_idToken;
QByteArray m_refreshToken;
QByteArray m_accessKeyId;
QByteArray m_secretKey;
QByteArray m_sessionToken;
QDateTime m_expirationDate;
};
#endif // AWSCLIENT_H

View File

@ -0,0 +1,122 @@
#include "sigv4utils.h"
#include <QDateTime>
#include <QCryptographicHash>
#include <QMessageAuthenticationCode>
#include <QtDebug>
#include <QUrlQuery>
#include <QList>
SigV4Utils::SigV4Utils()
{
}
QByteArray SigV4Utils::getCurrentDateTime()
{
return QDateTime::currentDateTime().toUTC().toString("yyyyMMddThhmmssZ").toUtf8();
}
QByteArray SigV4Utils::getCanonicalQueryString(const QNetworkRequest &request, const QByteArray &accessKeyId, const QByteArray &secretAccessKey, const QByteArray &sessionToken, const QByteArray &region, const QByteArray &service, const QByteArray &payload)
{
QByteArray algorithm = "AWS4-HMAC-SHA256";
QByteArray dateTime = getCurrentDateTime();
QByteArray credentialScope = getCredentialScope(algorithm, dateTime, region, service);
QByteArray canonicalQueryString;
canonicalQueryString += "X-Amz-Algorithm=AWS4-HMAC-SHA256";
canonicalQueryString += "&X-Amz-Credential=" + QByteArray(accessKeyId + '/' + credentialScope).toPercentEncoding();
canonicalQueryString += "&X-Amz-Date=" + dateTime;
if (request.rawHeaderList().count() > 0){
canonicalQueryString += "&X-Amz-SignedHeaders=" + request.rawHeaderList().join(';').toLower();
}
QByteArray canonicalRequest = getCanonicalRequest(QNetworkAccessManager::GetOperation, request, payload);
QByteArray stringToSign = getStringToSign(canonicalRequest, dateTime, region, service);
QByteArray signature = getSignature(stringToSign, secretAccessKey, dateTime, region, service);
canonicalQueryString += "&X-Amz-Signature=" + signature;
if (!sessionToken.isEmpty()) {
canonicalQueryString += "&X-Amz-Security-Token=" + sessionToken.toPercentEncoding();
}
return canonicalQueryString;
}
QByteArray SigV4Utils::getSignatureKey(const QByteArray &key, const QByteArray &date, const QByteArray &region, const QByteArray &service)
{
QCryptographicHash::Algorithm hashAlgorithm = QCryptographicHash::Sha256;
return QMessageAuthenticationCode::hash("aws4_request",
QMessageAuthenticationCode::hash(service,
QMessageAuthenticationCode::hash(region,
QMessageAuthenticationCode::hash(date, "AWS4"+key,
hashAlgorithm), hashAlgorithm), hashAlgorithm), hashAlgorithm);
}
QByteArray SigV4Utils::getCanonicalRequest(QNetworkAccessManager::Operation operation, const QNetworkRequest &request, const QByteArray &payload)
{
QByteArray canonicalRequest;
QByteArray method;
switch (operation) {
case QNetworkAccessManager::GetOperation:
method = "GET";
break;
case QNetworkAccessManager::PostOperation:
method = "POST";
break;
default:
Q_ASSERT_X(false, "Network operation not implemented", "SigV4Utils");
}
QByteArray uri = request.url().path().toUtf8();
QUrlQuery query(request.url());
QList<QPair<QString, QString> > queryItems = query.queryItems();
QStringList queryItemStrings;
for (int i = 0; i < queryItems.count(); i++) {
QPair<QString, QString> queryItem = queryItems.at(i);
queryItemStrings.append(queryItem.first + '=' + queryItem.second);
}
queryItemStrings.sort(Qt::CaseInsensitive);
QByteArray canonicalQueryString = queryItemStrings.join('&').toUtf8();
QByteArray canonicalHeaders;
foreach(const QByteArray &headerName, request.rawHeaderList()) {
canonicalHeaders += headerName.toLower() + ':' + request.rawHeader(headerName) + '\n';
}
QByteArray payloadHash = QCryptographicHash::hash(payload, QCryptographicHash::Sha256).toHex();
canonicalRequest = method + '\n' + uri + '\n' + canonicalQueryString + '\n' + canonicalHeaders + '\n' + request.rawHeaderList().join(';').toLower() + '\n' + payloadHash;
return canonicalRequest;
}
QByteArray SigV4Utils::getCredentialScope(const QByteArray &algorithm, const QByteArray &dateTime, const QByteArray &region, const QByteArray &service)
{
QByteArray credentialScope = dateTime.left(8) + '/' + region + '/' + service + "/aws4_request";
return credentialScope;
}
QByteArray SigV4Utils::getStringToSign(const QByteArray &canonicalRequest, const QByteArray &dateTime, const QByteArray &region, const QByteArray &service)
{
QByteArray algorithm = "AWS4-HMAC-SHA256";
QByteArray credentialScope = getCredentialScope(algorithm, dateTime, region, service);
QByteArray stringToSign = algorithm + '\n' + dateTime + '\n' + credentialScope + '\n' + QCryptographicHash::hash(canonicalRequest, QCryptographicHash::Sha256).toHex();
return stringToSign;
}
QByteArray SigV4Utils::getSignature(const QByteArray &stringToSign, const QByteArray &secretAccessKey, const QByteArray &dateTime, const QString &region, const QString &service)
{
QByteArray signingKey = getSignatureKey(secretAccessKey, dateTime.left(8), region.toUtf8(), service.toUtf8());
QByteArray signature = QMessageAuthenticationCode::hash(stringToSign, signingKey, QCryptographicHash::Sha256).toHex();
return signature;
}
QByteArray SigV4Utils::getAuthorizationHeader(const QByteArray &accessKeyId, const QByteArray &dateTime, const QString &region, const QString &service, const QNetworkRequest &request, const QByteArray &signature)
{
QByteArray authHeader = "AWS4-HMAC-SHA256 Credential=" + accessKeyId + '/' + dateTime.left(8) + '/' + region.toUtf8() + '/' + service.toUtf8() + '/' + "aws4_request, SignedHeaders=" + request.rawHeaderList().join(';').toLower() + ", Signature=" + signature;
return authHeader;
}

View File

@ -0,0 +1,27 @@
#ifndef SIGV4UTILS_H
#define SIGV4UTILS_H
#include <QString>
#include <QNetworkRequest>
#include <QNetworkAccessManager>
class SigV4Utils
{
public:
SigV4Utils();
static QByteArray getCurrentDateTime();
static QByteArray getCanonicalQueryString(const QNetworkRequest &request, const QByteArray &accessKeyId, const QByteArray &secretAccessKey, const QByteArray &sessionToken, const QByteArray &region, const QByteArray &service, const QByteArray &payload);
static QByteArray getCanonicalRequest(QNetworkAccessManager::Operation operation, const QNetworkRequest &request, const QByteArray &payload);
static QByteArray getCanonicalHeaders(const QNetworkRequest &request);
static QByteArray getCredentialScope(const QByteArray &algorithm, const QByteArray &dateTime, const QByteArray &region, const QByteArray &service);
static QByteArray getStringToSign(const QByteArray &canonicalRequest, const QByteArray &dateTime, const QByteArray &region, const QByteArray &service);
static QByteArray getSignatureKey(const QByteArray &key, const QByteArray &date, const QByteArray &region, const QByteArray &service);
static QByteArray getSignature(const QByteArray &stringToSign, const QByteArray &secretAccessKey, const QByteArray &dateTime, const QString &region, const QString &service);
static QByteArray getAuthorizationHeader(const QByteArray &accessKeyId, const QByteArray &dateTime, const QString &region, const QString &service, const QNetworkRequest &request, const QByteArray &signature);
};
#endif // SIGV4UTILS_H

View File

@ -269,7 +269,7 @@ void JsonRpcClient::sendRequest(const QVariantMap &request)
{
QVariantMap newRequest = request;
newRequest.insert("token", m_token);
qDebug() << "Sending request" << qUtf8Printable(QJsonDocument::fromVariant(newRequest).toJson());
// qDebug() << "Sending request" << qUtf8Printable(QJsonDocument::fromVariant(newRequest).toJson());
m_connection->sendData(QJsonDocument::fromVariant(newRequest).toJson(QJsonDocument::Compact) + "\n");
}

View File

@ -12,7 +12,8 @@ include(../config.pri)
}
DEFINES += QT_STATIC
include(../qmqtt/src/mqtt/mqtt.pri)
HEADERS += $$PUBLIC_HEADERS $$PRIVATE_HEADERS
HEADERS += $$PUBLIC_HEADERS $$PRIVATE_HEADERS \
connection/sigv4utils.h
QT -= gui
QT += network websockets bluetooth
@ -77,7 +78,8 @@ SOURCES += \
ruletemplates/stateevaluatortemplate.cpp \
ruletemplates/statedescriptortemplate.cpp \
discovery/bluetoothservicediscovery.cpp \
connection/cloudtransport.cpp
connection/cloudtransport.cpp \
connection/sigv4utils.cpp
HEADERS += \
engine.h \

View File

@ -39,5 +39,16 @@ Page {
Engine.awsClient.login(usernameTextField.text, passwordTextField.text);
}
}
Button {
Layout.fillWidth: true
text: "GetId"
onClicked: Engine.awsClient.getId();
}
Button {
Layout.fillWidth: true
text: "Post to MQTT"
onClicked: Engine.awsClient.postToMQTT();
}
}
}

View File

@ -88,7 +88,7 @@ Page {
ListElement { iconSource: "../images/network-vpn.svg"; text: qsTr("Manual connection"); page: "ManualConnectPage.qml" }
ListElement { iconSource: "../images/bluetooth.svg"; text: qsTr("Wireless setup"); page: "BluetoothDiscoveryPage.qml"; }
ListElement { iconSource: "../images/cloud.svg"; text: qsTr("Cloud login"); page: "CloudLoginPage.qml" }
ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "AppSettingsPage.qml" }
ListElement { iconSource: "../images/stock_application.svg"; text: qsTr("App settings"); page: "../AppSettingsPage.qml" }
}
onClicked: {
pageStack.push(model.get(index).page);

View File

@ -6,7 +6,7 @@
#include <QtOpenGL/qgl.h>
#endif
#include "libmea-core.h"
#include "libnymea-app-core.h"
int main(int argc, char **argv)
{

View File

@ -10,8 +10,9 @@ INCLUDEPATH += ../../nymea-app/ \
../../libnymea-common/ \
../../libnymea-app-core/
LIBS += -L$$top_builddir/libmea-core/ -lmea-core \
-L$$top_builddir/libnymea-common/ -lnymea-common
LIBS += -L$$top_builddir/libnymea-app-core/ -lnymea-app-core \
-L$$top_builddir/libnymea-common/ -lnymea-common \
-lavahi-common -lavahi-client
win32:Debug:LIBS += -L$$top_builddir/libmea-core/debug \
-L$$top_builddir/libnymea-common/debug
win32:Release:LIBS += -L$$top_builddir/libmea-core/release \

View File

@ -1,3 +1,3 @@
TEMPLATE = subdirs
SUBDIRS = testrunner
SUBDIRS = testrunner unit