diff --git a/debian/control b/debian/control index d56a6ac5..49ef4ffd 100644 --- a/debian/control +++ b/debian/control @@ -581,6 +581,15 @@ Description: nymea integration plugin for Tempo time tracking This package contains the nymea integration plugin for Tempo time tracking. +Package: nymea-plugin-tmate +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + tmate, +Description: nymea integration plugin for tmate + This package contains the nymea integration plugin for tmate terminal access. + + Package: nymea-plugin-tplink Architecture: any Depends: ${shlibs:Depends}, diff --git a/debian/nymea-plugin-tmate.install.in b/debian/nymea-plugin-tmate.install.in new file mode 100644 index 00000000..709018cb --- /dev/null +++ b/debian/nymea-plugin-tmate.install.in @@ -0,0 +1,2 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_integrationplugintmate.so +tmate/translations/*qm usr/share/nymea/translations/ diff --git a/nymea-plugins.pro b/nymea-plugins.pro index 969f2796..6e9da803 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -75,6 +75,7 @@ PLUGIN_DIRS = \ telegram \ tempo \ texasinstruments \ + tmate \ tplink \ tuya \ udpcommander \ diff --git a/tmate/README.md b/tmate/README.md new file mode 100644 index 00000000..d6bcfe38 --- /dev/null +++ b/tmate/README.md @@ -0,0 +1,26 @@ +# Tmate + +This plugin allows to establish a terminal connection to the device where nymea is running. + +This is useful when maintaining remote nymea setups which may be hidden behind a firewall +and cannot be accessed from the public internet. A user can easily enable SSH access for +a nymea setup by adding a Thing using the app without having to deal with DNS and NAT. + +## Setup + +A tmate thing can be created without any additional information. Leaving all the fields +empty during setup will establish a default tmate session to tmate.io servers and generate +a new random token. This is the equivalent of just running `tmate` in a terminal. + +For a more permanent solution, an API key can be registered at tmate.io. Using the api key a custom session name can be set which will be used for every connection establishment, persisting across nymea restarts. In the +next step, provide the SSH credentials for the user on the SSH server which has been created before. Once the login succeeds, the thing should become connected. + +> Note: Using custom/self hosted tmate servers is not supported at this point. + +### Connecting clients + +The thing will inform about the session names via states and can be accessed via ssh or web. + +## More + +https://tmate.io/ diff --git a/tmate/integrationplugintmate.cpp b/tmate/integrationplugintmate.cpp new file mode 100644 index 00000000..c7e0d006 --- /dev/null +++ b/tmate/integrationplugintmate.cpp @@ -0,0 +1,194 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2023, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project is distributed in the hope that +* it will be useful, but WITHOUT ANY WARRANTY; without even the implied +* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "integrationplugintmate.h" +#include "plugininfo.h" + +#include +#include +#include + +IntegrationPluginTmate::IntegrationPluginTmate() +{ + +} + +IntegrationPluginTmate::~IntegrationPluginTmate() +{ + foreach (QProcess *process, m_processes) { + process->terminate(); + } +} + +void IntegrationPluginTmate::setupThing(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + + + QStringList arguments; + QString apiKey = thing->paramValue(tmateThingApiKeyParamTypeId).toString(); + QString sessionName = thing->paramValue(tmateThingSessionNameParamTypeId).toString(); + + arguments << "-F"; + if (!apiKey.isEmpty()) { + arguments << "-k" << apiKey; + if (!sessionName.isEmpty()) { + arguments << "-n" << sessionName; + arguments << "-r" << "ro-" + sessionName; + } + } + QProcess *process = new QProcess(thing); + process->setProgram("tmate"); + process->setArguments(arguments); + process->setProcessChannelMode(QProcess::MergedChannels); + + m_processes.insert(info->thing(), process); + + connect(process, &QProcess::stateChanged, thing, [=](QProcess::ProcessState newState){ + switch (newState) { + case QProcess::Starting: + qCDebug(dcTmate()) << "Connection starting for" << thing->name(); + return ; + case QProcess::Running: + qCInfo(dcTmate()) << "Reverse SSH connected for" << thing->name(); + thing->setStateValue(tmateConnectedStateTypeId, true); + return; + case QProcess::NotRunning: + qCInfo(dcTmate()) << "Reverse SSH disconnected for" << thing->name(); + thing->setStateValue(tmateConnectedStateTypeId, false); + thing->setStateValue(tmateSshStateTypeId, ""); + thing->setStateValue(tmateSshRoStateTypeId, ""); + thing->setStateValue(tmateWebStateTypeId, ""); + thing->setStateValue(tmateWebRoStateTypeId, ""); + thing->setStateValue(tmateClientsStateTypeId, 0); + return; + } + }); + connect(process, &QProcess::readyRead, thing, [=](){ + while (process->canReadLine()) { + QByteArray data = process->readLine(); + + qCDebug(dcTmate()) << thing->name() << ":" << data; + auto extractSession = [thing](const StateTypeId &stateTypeId, const QString &type, const QString &input) { + int sessionStart = input.indexOf(type); + if (sessionStart >= 0) { + int sessionEnd = input.indexOf(QChar('\n'), sessionStart); + qCInfo(dcTmate()) << input << "Session start" << sessionStart << "session end" << sessionEnd; + QString session =input.mid(sessionStart + type.length(), sessionEnd); + thing->setStateValue(stateTypeId, session); + } + }; + + extractSession(tmateSshStateTypeId, "ssh session: ssh ", data); + extractSession(tmateSshRoStateTypeId, "ssh session read only: ssh ", data); + extractSession(tmateWebStateTypeId, "web session: ", data); + extractSession(tmateWebRoStateTypeId, "web session read only: ", data); + + QRegularExpression joinAddressRegex("joined \\(([0-9\\.]+)\\)"); + QRegularExpressionMatchIterator it = joinAddressRegex.globalMatch(data); + while (it.hasNext()) { + QRegularExpressionMatch match = it.next(); + QString word = match.captured(1); + qCInfo(dcTmate()) << "Connected:" << word; + thing->emitEvent(tmateClientConnectedEventTypeId, {{tmateClientConnectedEventClientAddressParamTypeId, word}}); + thing->setStateValue(tmateClientsStateTypeId, thing->stateValue(tmateClientsStateTypeId).toUInt()+1); + } + QRegularExpression leftAddressRegex("left \\(([0-9\\.]+)\\)"); + it = leftAddressRegex.globalMatch(data); + while (it.hasNext()) { + QRegularExpressionMatch match = it.next(); + QString word = match.captured(1); + qCInfo(dcTmate()) << "Disconnected:" << word; + thing->emitEvent(tmateClientDisconnectedEventTypeId, {{tmateClientDisconnectedEventClientAddressParamTypeId, word}}); + thing->setStateValue(tmateClientsStateTypeId, thing->stateValue(tmateClientsStateTypeId).toUInt()-1); + } + } + + }); + + + // Start up now if enabled + bool enabled = thing->stateValue(tmateActiveStateTypeId).toBool(); + if (enabled) { + process->start(); + } + + info->finish(Thing::ThingErrorNoError); + + + // Create a watchdog to reconnect if a connection drops... + if (!m_watchdog) { + m_watchdog = hardwareManager()->pluginTimerManager()->registerTimer(10); + connect(m_watchdog, &PluginTimer::timeout, this, [this](){ + foreach (Thing *thing, m_processes.keys()) { + QProcess *process = m_processes.value(thing); + if (thing->stateValue(tmateActiveStateTypeId).toBool() && process->state() == QProcess::NotRunning) { + qCInfo(dcTmate()) << "Reconnecting tmate for" << thing->name(); + process->start(); + } + } + }); + } +} + + +void IntegrationPluginTmate::thingRemoved(Thing *thing) +{ + if (thing->thingClassId() == tmateThingClassId) { + QProcess *process = m_processes.take(thing); + if (process->state() != QProcess::NotRunning) { + process->terminate(); + process->waitForFinished(); + } + } + + if (myThings().isEmpty()) { + hardwareManager()->pluginTimerManager()->unregisterTimer(m_watchdog); + m_watchdog = nullptr; + } +} + + +void IntegrationPluginTmate::executeAction(ThingActionInfo *info) +{ + if (info->action().actionTypeId() == tmateActiveActionTypeId) { + bool active = info->action().paramValue(tmateActiveActionActiveParamTypeId).toBool(); + QProcess *process = m_processes.value(info->thing()); + if (active) { + qCInfo(dcTmate()) << "Reconnecting tmate for" << info->thing()->name(); + process->start(); + } else { + qCInfo(dcTmate()) << "Terminating session for" << info->thing()->name(); + process->terminate(); + } + info->thing()->setStateValue(tmateActiveStateTypeId, active); + info->finish(Thing::ThingErrorNoError); + } +} diff --git a/tmate/integrationplugintmate.h b/tmate/integrationplugintmate.h new file mode 100644 index 00000000..b308445e --- /dev/null +++ b/tmate/integrationplugintmate.h @@ -0,0 +1,62 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright 2013 - 2023, nymea GmbH +* Contact: contact@nymea.io +* +* This file is part of nymea. +* This project including source code and documentation is protected by +* copyright law, and remains the property of nymea GmbH. All rights, including +* reproduction, publication, editing and translation, are reserved. The use of +* this project is subject to the terms of a license agreement to be concluded +* with nymea GmbH in accordance with the terms of use of nymea GmbH, available +* under https://nymea.io/license +* +* GNU Lesser General Public License Usage +* Alternatively, this project may be redistributed and/or modified under the +* terms of the GNU Lesser General Public License as published by the Free +* Software Foundation; version 3. This project is distributed in the hope that +* it will be useful, but WITHOUT ANY WARRANTY; without even the implied +* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* Lesser General Public License for more details. +* +* You should have received a copy of the GNU Lesser General Public License +* along with this project. If not, see . +* +* For any further details and any questions please contact us under +* contact@nymea.io or see our FAQ/Licensing Information on +* https://nymea.io/license/faq +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINREMOTESSH_H +#define INTEGRATIONPLUGINREMOTESSH_H + +#include "plugintimer.h" +#include "integrations/integrationplugin.h" +#include "extern-plugininfo.h" + +#include + +class IntegrationPluginTmate : public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationplugintmate.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginTmate(); + ~IntegrationPluginTmate(); + +// void startPairing(ThingPairingInfo *info) override; +// void confirmPairing(ThingPairingInfo *info, const QString &user, const QString &secret) override; + void executeAction(ThingActionInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void thingRemoved(Thing *thing) override; + +private: + QHash m_processes; + PluginTimer *m_watchdog = nullptr; +}; + +#endif // INTEGRATIONPLUGINREMOTESSH_H diff --git a/tmate/integrationplugintmate.json b/tmate/integrationplugintmate.json new file mode 100644 index 00000000..5329448a --- /dev/null +++ b/tmate/integrationplugintmate.json @@ -0,0 +1,131 @@ +{ + "id": "d06ab0d1-dbfe-48af-b196-523cc37a1e5e", + "name": "Tmate", + "displayName": "Tmate", + "vendors": [ + { + "id": "b948d5e2-bfc6-4e28-a2ba-e40e46f4c213", + "name": "tmate", + "displayName": "Tmate", + "thingClasses": [ + { + "id": "3f06ad52-9514-41b1-9bf9-031241d34634", + "name": "tmate", + "displayName": "Tmate", + "createMethods": ["user"], + "setupMethod": "justadd", + "interfaces": [], + "paramTypes": [ + { + "id": "01f0c818-55e1-4842-a9b9-cc58bbfe76c6", + "name": "apiKey", + "displayName": "API key (optional)", + "type": "QString", + "defaultValue": "" + }, + { + "id": "e587e3dc-0beb-441f-8f07-b23c25580b10", + "name": "sessionName", + "displayName": "Session name (requires API key usage)", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes":[ + { + "id": "7009c176-e1aa-49bc-818c-63f7a9027306", + "name": "active", + "displayName": "Active", + "displayNameAction": "Set active", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "beac3113-04f1-4d70-875b-44ca8b307866", + "name": "connected", + "displayName": "Connected", + "type": "bool", + "defaultValue": false, + "suggestLogging": true, + "cached": false + }, + { + "id": "ef780fb1-b31a-4333-944b-a02bf3297fea", + "name": "sshRo", + "displayName": "SSH RO", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "e8ff1b90-7701-454c-a557-4b91dc8c649b", + "name": "ssh", + "displayName": "SSH", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "98248bc0-ddda-4ae6-8558-4d7155a39c33", + "name": "webRo", + "displayName": "Web RO", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "9c284ede-eea8-4a9b-a326-e59a6bc7bb7c", + "name": "web", + "displayName": "Web", + "type": "QString", + "defaultValue": "", + "cached": false + }, + { + "id": "786e7be7-917a-4062-83ff-aade80686ec5", + "name": "clients", + "displayName": "Clients", + "type": "uint", + "defaultValue": 0, + "cached": false + } + ], + "eventTypes": [ + { + "id": "0508a1e2-4ed2-42ee-ab70-ed7cdd1e261c", + "name": "clientConnected", + "displayName": "Client connected", + "suggestLogging": true, + "paramTypes": [ + { + "id": "a334c6e7-dffc-4720-aa21-815636be1bc1", + "name": "clientAddress", + "displayName": "Client address", + "type": "QString" + } + ] + }, + { + "id": "2871e481-1b67-4d77-b1ce-e0965784aa89", + "name": "clientDisconnected", + "displayName": "Client disconnected", + "suggestLogging": true, + "paramTypes": [ + { + "id": "0ad4ed71-4b9a-44ab-83b1-5c62482e1625", + "name": "clientAddress", + "displayName": "Client address", + "type": "QString" + } + ] + } + ] + } + ] + } + ] +} + + + diff --git a/tmate/logo.png b/tmate/logo.png new file mode 100644 index 00000000..0872a0dc Binary files /dev/null and b/tmate/logo.png differ diff --git a/tmate/meta.json b/tmate/meta.json new file mode 100644 index 00000000..3089444d --- /dev/null +++ b/tmate/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Tmate", + "tagline": "Shell access to the machine running nymea from anywhere via tmate.", + "icon": "logo.png", + "stability": "community", + "offline": false, + "technologies": [ + "cloud" + ], + "categories": [ + "tool" + ] +} diff --git a/tmate/tmate.pro b/tmate/tmate.pro new file mode 100644 index 00000000..b53656b6 --- /dev/null +++ b/tmate/tmate.pro @@ -0,0 +1,8 @@ +include(../plugins.pri) + +SOURCES += \ + integrationplugintmate.cpp \ + +HEADERS += \ + integrationplugintmate.h \ + diff --git a/tmate/translations/d06ab0d1-dbfe-48af-b196-523cc37a1e5e-en_US.ts b/tmate/translations/d06ab0d1-dbfe-48af-b196-523cc37a1e5e-en_US.ts new file mode 100644 index 00000000..e7936aff --- /dev/null +++ b/tmate/translations/d06ab0d1-dbfe-48af-b196-523cc37a1e5e-en_US.ts @@ -0,0 +1,103 @@ + + + + + Tmate + + + API key (optional) + The name of the ParamType (ThingClass: tmate, Type: thing, ID: {01f0c818-55e1-4842-a9b9-cc58bbfe76c6}) + + + + + + Active + The name of the ParamType (ThingClass: tmate, ActionType: active, ID: {7009c176-e1aa-49bc-818c-63f7a9027306}) +---------- +The name of the StateType ({7009c176-e1aa-49bc-818c-63f7a9027306}) of ThingClass tmate + + + + + + Client address + The name of the ParamType (ThingClass: tmate, EventType: clientDisconnected, ID: {0ad4ed71-4b9a-44ab-83b1-5c62482e1625}) +---------- +The name of the ParamType (ThingClass: tmate, EventType: clientConnected, ID: {a334c6e7-dffc-4720-aa21-815636be1bc1}) + + + + + Client connected + The name of the EventType ({0508a1e2-4ed2-42ee-ab70-ed7cdd1e261c}) of ThingClass tmate + + + + + Client disconnected + The name of the EventType ({2871e481-1b67-4d77-b1ce-e0965784aa89}) of ThingClass tmate + + + + + Clients + The name of the StateType ({786e7be7-917a-4062-83ff-aade80686ec5}) of ThingClass tmate + + + + + Connected + The name of the StateType ({beac3113-04f1-4d70-875b-44ca8b307866}) of ThingClass tmate + + + + + SSH + The name of the StateType ({e8ff1b90-7701-454c-a557-4b91dc8c649b}) of ThingClass tmate + + + + + SSH RO + The name of the StateType ({ef780fb1-b31a-4333-944b-a02bf3297fea}) of ThingClass tmate + + + + + Session name (requires API key usage) + The name of the ParamType (ThingClass: tmate, Type: thing, ID: {e587e3dc-0beb-441f-8f07-b23c25580b10}) + + + + + Set active + The name of the ActionType ({7009c176-e1aa-49bc-818c-63f7a9027306}) of ThingClass tmate + + + + + + + Tmate + The name of the ThingClass ({3f06ad52-9514-41b1-9bf9-031241d34634}) +---------- +The name of the vendor ({b948d5e2-bfc6-4e28-a2ba-e40e46f4c213}) +---------- +The name of the plugin Tmate ({d06ab0d1-dbfe-48af-b196-523cc37a1e5e}) + + + + + Web + The name of the StateType ({9c284ede-eea8-4a9b-a326-e59a6bc7bb7c}) of ThingClass tmate + + + + + Web RO + The name of the StateType ({98248bc0-ddda-4ae6-8558-4d7155a39c33}) of ThingClass tmate + + + +