From ae5067e6d9705377333c0cf4271d81759b2cf92e Mon Sep 17 00:00:00 2001 From: metallkopf <47254555+metallkopf@users.noreply.github.com> Date: Sun, 27 Oct 2024 22:30:49 -0300 Subject: [PATCH] refactoring to execute commands --- README.md | 287 ++++++++++++++++++++++++++----------------- konnect/__init__.py | 2 +- konnect/api.py | 171 ++++++++++++++++++-------- konnect/client.py | 87 +++++++++++-- konnect/database.py | 107 +++++++++++++--- konnect/factories.py | 6 +- konnect/packet.py | 32 ++++- konnect/protocols.py | 59 +++++++-- 8 files changed, 545 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index 73efdcc..c27a795 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # Konnect - headless kde connect + Konnect is based on the [KDE Connect](https://community.kde.org/KDEConnect) protocol and allows a non-interactive enviroment (headless server) to send notifications to your devices via Rest API or a *simple* CLI +> Warning: Breaking chanches between versions 0.1.x and 0.2.x on the client tool and rest api. + ## Prerequisites + - Python 3.10+ - Systemd (optional) ## Installation + ```bash # Create virtualenv python3 -m venv venv @@ -20,86 +25,20 @@ venv/bin/pip install https://github.com/metallkopf/konnect/releases/download/0.2 venv/bin/pip install git+https://github.com/metallkopf/konnect.git@master#egg=konnect ``` -## Test run -```bash -# With KDE Connect installed -venv/bin/konnectd --name Test --admin-port 8080 +## Server -# Without KDE Connect installed -venv/bin/konnectd --name Test --receiver --admin-port 8080 -``` +### Server options -## Examples -- List available devices -```bash -# Rest API -curl -s -X GET http://localhost:8080/device - -# CLI -venv/bin/konnect devices -{ - "devices": [ - { - "identifier": "00112233_4455_6677_8899-aabbccddeeff", - "name": "server", - "trusted": true, - "reachable": true - }, - { - "identifier": "abcdef0123456789", - "name": "phone", - "trusted": false, - "reachable": true - } - ], - "success": true -} -``` -- Pair with device -```bash -# Rest API -curl -s -X POST http://localhost:8080/pair/@phone - -# CLI -venv/bin/konnect pair --device @phone -``` -- Accept pairing request on remote host -- Check successful pairing by sending ping -```bash -# Rest API -curl -s -X POST http://localhost:8080/ping/@phone - -# CLI -venv/bin/konnect ping --device @phone -``` -- Send notification -```bash -# Rest API -curl -s -X POST -d '{"application":"Package Manager","title":"Maintenance","text":"There are updates available!","reference":"update"}' http://localhost:8080/notification/@phone - -# CLI -venv/bin/konnect notification --device @phone --title maintenance --text "updates available" --application package_manager --reference update -``` -- Unpair device -```bash -# Rest API -curl -s -X DELETE http://localhost:8080/pair/@phone - -# CLI -venv/bin/konnect unpair --unpair @phone -``` - -## Daemon usage ```bash venv/bin/konnectd --help ``` + ``` -usage: konnectd [--name NAME] [--debug] [--discovery-port PORT] [--service-port PORT] [--transfer-port PORT] - [--max-transfer-ports NUM] [--admin-port PORT] [--admin-socket SOCK] [--admin-bind BIND] - [--config-dir DIR] [--receiver] [--service] [--help] [--version] +usage: konnectd [--name NAME] [--debug] [--discovery-port PORT] [--service-port PORT] [--transfer-port PORT] [--max-transfer-ports NUM] [--admin-port PORT] [--admin-socket SOCK] [--admin-bind BIND] [--config-dir DIR] + [--receiver] [--service] [--help] [--version] options: - --name NAME Device name (default: computer) + --name NAME Device name (default: HOSTNAME) --debug Show debug messages (default: False) --discovery-port PORT Discovery port (default: 1716) @@ -117,42 +56,102 @@ options: --version Version information (default: False) ``` -## Rest API +### Test run + +```bash +# With KDE Connect installed (admin interface by default on port 8080) +venv/bin/konnectd --name Test + +# With KDE Connect installed (socket by default on ${XDG_RUNTIME_DIR}/konnectd.sock) +venv/bin/konnectd --name Test --admin-bind socket + +# Without KDE Connect installed (listen for announce) +venv/bin/konnectd --name Test --receiver +``` + +### Run as service + +Create a file named `konnect.service` in `/etc/systemd/system`, change the value `User` and `WorkingDirectory` accordingly and the execute the following commands + +```ini +[Unit] +Description=Konnect +After=network.target +Requires=network.target + +[Service] +User=user +Restart=always +Type=simple +WorkingDirectory=/home/user/konnect +ExecStart=/home/user/konnect/venv/bin/konnectd --receiver --service + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Reload configurations +sudo systemctl daemon-reload + +# Start service +sudo systemctl start konnect + +# Start on boot +sudo systemctl enable konnect +``` + +### Rest API + | Method | Resource | Description | Parameters | | - | - | - | - | | GET | / | Application info | | | PUT | / | Announce identity | | -| GET | /device | List devices | | +| GET | /command | List all \(local\) commands | | +| GET | /command/\(@name\|identifier\) | List device commands | | +| POST | /command/\(@name\|identifier\) | Add device command | name, command | +| DELETE | /command/\(@name\|identifier\) | Remove all device commands | | +| PUT | /command/\(@name\|identifier\)/\(key\) | Update device command | name, command | +| DELETE | /command/\(@name\|identifier\)/\(key\) | Remove device command | | +| PATCH | /command/\(@name\|identifier\)/\(key\) | Execute \(remote\) device command | | +| POST | /custom/\(@name\|identifier\) | Custom packet \(for testing only\) | type, body \(optional\) | +| GET | /device | List all devices | | | GET | /device/\(@name\|identifier\) | Device info | | -| GET | /command | List all commands | | +| GET | /notification | List all notifications | | | POST | /notification/\(@name\|identifier\) | Send notification | text, title, application, reference \(optional\), icon \(optional\) | | DELETE | /notification/\(@name\|identifier\)/\(reference\) | Cancel notification | | | POST | /pair/\(@name\|identifier\) | Pair | | | DELETE | /pair/\(@name\|identifier\) | Unpair | | | POST | /ping/\(@name\|identifier\) | Ping device | | | POST | /ring/\(@name\|identifier\) | Ring device | | -| POST | /custom/\(@name\|identifier\) | Custom packet \(for testing only\) | type, body \(optional\) | -## CLI usage +## Client + +This utility can be used alone but requires the packages `requests` and `PIL` to work. + +### Client usage + ```bash -venv/bin/konnect help +./venv/bin/konnect help ``` + ``` -usage: konnect [--port PORT] [--debug] - {announce,custom,device,devices,exec,notification,pair,ping,ring,unpair,version,help} - ... +usage: konnect [--port PORT] [--debug] {announce,command,commands,custom,devices,exec,info,notifications,notification,pair,ping,ring,unpair,version,help} ... options: --port PORT Port running the admin interface --debug Show debug messages actions: - {announce,custom,device,devices,exec,notification,pair,ping,ring,unpair,version,help} + {announce,command,commands,custom,devices,exec,info,notifications,notification,pair,ping,ring,unpair,version,help} announce Announce your identity + command Configure local commands... + commands List all commands... custom Send custom packet... - device Show device info - devices List devices + devices List all devices... exec Execute remote command... + info Show server info + notifications List all notifications... notification Send or cancel notification... pair Pair with device... ping Send ping... @@ -162,46 +161,106 @@ actions: help This ``` -## Run as service -Create a file named `konnect.service` in `/etc/systemd/system`, change the value `User` and `WorkingDirectory` accordingly and the execute the following commands -```ini -[Unit] -Description=Konnect -After=network.target -Requires=network.target +### List devices -[Service] -User=user -Restart=always -Type=simple -WorkingDirectory=/home/user/konnect -ExecStart=/home/user/konnect/venv/bin/konnectd --receiver --service +```bash +./venv/bin/konnect devices +``` -[Install] -WantedBy=multi-user.target +```yaml +devices: +- identifier: f81d4fae-7dec-11d0-a765-00a0c91e6bf6 + name: computer + type: desktop + reachable: true + trusted: true + commands: + 00112233-4455-6677-8899-aabbccddeeff: + name: kernel + command: uname -a + 550e8400-e29b-41d4-a716-446655440000: + name: who + command: whoami +- identifier: 9c5b94b1-35ad-49bb-b118-8e8fc24abf80 + name: phone + type: smartphone + reachable: false + trusted: true + commands: {} ``` + +### Pair device + ```bash -# Reload configurations -sudo systemctl daemon-reload +./venv/bin/konnect pair --device @computer +``` -# Start service -sudo systemctl start konnect +### Ping device -# Start on boot -sudo systemctl enable konnect +```bash +./venv/bin/konnect ping --device @computer +``` + +### Send notification + +```bash +./venv/bin/konnect notification --device @computer --application "Package Manager" \ + --title Maintenance --text "There are updates available!" --reference update +``` + +```yaml +key: update ``` -## Compatibility -Tested *manually* on [kdeconnect](https://invent.kde.org/kde/kdeconnect-kde) 1.3.3+ and [kdeconnect-android](https://f-droid.org/en/packages/org.kde.kdeconnect_tp/) 1.13.0+ +### Dismiss notification + +```bash +./venv/bin/konnect notification --device @computer --reference update --delete +``` + +### Execute (remote) command + +```bash +./venv/bin/konnect exec --device @computer --key 00112233-4455-6677-8899-aabbccddeeff +``` + +### Add (local) command + +```bash +./venv/bin/konnect command --device @computer --name "reboot" --command "sudo reboot" +``` + +```yaml +key: 03000200-0400-0500-0006-000700080009 +``` + +### List (local) commands + +```bash +./venv/bin/konnect commands +``` + +```yaml +- identifier: f81d4fae-7dec-11d0-a765-00a0c91e6bf6 + device: computer + key: 03000200-0400-0500-0006-000700080009 + name: reboot + command: sudo reboot +``` ## Troubleshooting + ### Read how to open firewall ports on + - [KDE Connect\'s wiki](https://community.kde.org/KDEConnect#Troubleshooting) + ### Installation errors (required OS packages) + - Debian-based: `sudo apt-get install libsystemd-dev pkg-config python3-venv` - RedHat-like: `sudo dnf install gcc pkg-config python3-devel systemd-devel` ## To-do (in no particular order) + - Unit testing - Periodically announce identity - Connect to devices instead of just listening @@ -211,10 +270,10 @@ Tested *manually* on [kdeconnect](https://invent.kde.org/kde/kdeconnect-kde) 1.3 - MDNS support? - Share an receive files? -## Contributor(s) -- coxtor +## Development + +### Code Style -## Code Style ```bash venv/bin/isort --diff konnect/*.py @@ -223,12 +282,18 @@ venv/bin/flake8 konnect/*.py venv/bin/pytest -vv ``` -## Releasing +### Releasing + ```bash venv/bin/python -m build --wheel venv/bin/twine check dist/* ``` +## Contributor(s) + +- coxtor + ## License + [GPLv2](https://www.gnu.org/licenses/gpl-2.0.html) diff --git a/konnect/__init__.py b/konnect/__init__.py index 0a8da88..d3ec452 100644 --- a/konnect/__init__.py +++ b/konnect/__init__.py @@ -1 +1 @@ -__version__ = "0.1.6" +__version__ = "0.2.0" diff --git a/konnect/api.py b/konnect/api.py index 9aa4ef2..9653a9c 100644 --- a/konnect/api.py +++ b/konnect/api.py @@ -1,12 +1,13 @@ from hashlib import md5 from json import dumps, loads from json.decoder import JSONDecodeError -from logging import debug, error, info +from logging import debug, info from os import makedirs from os.path import getsize, isfile, join from re import match from shutil import copyfile, move from tempfile import gettempdir, mkstemp +from traceback import print_exc from uuid import uuid4 from PIL import Image @@ -15,29 +16,32 @@ from twisted.web.resource import Resource from konnect import __version__ -from konnect.exceptions import ApiError, DeviceNotReachableError, DeviceNotTrustedError, InvalidRequestError, \ - NotImplementedError2, UnserializationError +from konnect.exceptions import ApiError, DeviceNotReachableError, DeviceNotTrustedError, NotImplementedError2, \ + UnserializationError MAX_ICON_SIZE = 96 - -FUNCTIONS = { - # (method, resource): (trusted, reachable) - # TODO alias, params? - ("POST", "pair"): (False, True), - ("DELETE", "pair"): (True, False), - ("GET", "device"): (True, False), - ("POST", "ping"): (True, True), - ("POST", "ring"): (True, True), +CHECKS = { + # (method, resource): (trusted, reacheable, key) + ("POST", "pair"): (False, True, False), + ("DELETE", "pair"): (True, False, False), + ("GET", "device"): (True, False, False), + ("POST", "ping"): (True, True, False), + ("POST", "ring"): (True, True, False), ("POST", "notification"): (True, False), - ("DELETE", "notification"): (True, False), - ("POST", "custom"): (True, True), + ("DELETE", "notification"): (True, False, True), + ("GET", "command"): (True, False, False), + ("POST", "command"): (True, False, False), + ("PUT", "command"): (True, False, True), + ("DELETE", "command"): (True, False, True), + ("PATCH", "command"): (True, True, True), + ("POST", "custom"): (True, True, False), } class API(Resource): isLeaf = True - PATTERN = r"^\/(?P[a-z]+)\/(?P[\w+\.@\- ]+)((?:\/)(?P[\w+\-]+))?$" + PATTERN = r"^\/(?P[a-z]+)\/(?P[\w+\.@\- ]+)((?:\/)(?P[\w+\-]+))?$" def __init__(self, konnect, discovery, database, debug): super().__init__() @@ -78,6 +82,7 @@ def render(self, request): if e.parent: response["exception"] = e.parent else: + print_exc() response = {"message": "unknown error", "exception": str(e)} code = 500 @@ -88,35 +93,42 @@ def render(self, request): debug(f"RespHTTP({code}) - Body({response})") - log = info if code // 100 != 5 else error - if isinstance(address, IPv4Address): - log(f"{address.host}:{address.port} - {method} {uri} - {code}") + info(f"{address.host}:{address.port} - {method} {uri} - {code}") else: - log(f"unix:socket - {method} {uri} - {code}") + info(f"unix:socket - {method} {uri} - {code}") - return dumps(response).encode() + if debug: + return dumps(response, indent=2).encode() + b"\n" + else: + return dumps(response).encode() def process(self, method, uri, content): if uri == "/" and method == "GET": - return self._handleVersion() + return self._handleInfo() elif uri == "/" and method == "PUT": return self._handleAnnounce() elif uri == "/device" and method == "GET": return self._handleDevices() + elif uri == "/command" and method == "GET": + return self._handleCommands() + elif uri == "/notification" and method == "GET": + return self._handleNotifications() + elif uri == "/version" and method == "GET": + return self._handleVersion() matches = match(self.PATTERN, uri) if not matches: raise NotImplementedError2() - data = {} try: data = loads(content) except JSONDecodeError as e: raise UnserializationError(e) - checks = FUNCTIONS.get((method, matches["res"])) + resource = matches["res"] + checks = CHECKS.get((method, resource)) if not checks: raise NotImplementedError2() @@ -131,32 +143,46 @@ def process(self, method, uri, content): if not client and checks[1]: raise DeviceNotReachableError() - name = f"_handle{method.title()}{matches['res'].title()}" + key = matches["key"] - if not hasattr(self, name): + if key and not checks[2]: raise NotImplementedError2() - try: - function = getattr(self, name) - except AttributeError as e: - raise NotImplementedError2(e) - - params = [identifier, client] - - if matches["id"]: - params.append(matches["id"]) - - if method in ["POST", "PUT"] and data: - params.append(data) - - try: - return function(*params) - except TypeError as e: - raise InvalidRequestError(e) + if resource == "device" and method == "GET": + return self._handleGetDevice(identifier) + elif resource == "pair" and method == "POST": + return self._handlePair(client) + elif resource == "pair" and method == "DELETE": + return self._handleUnpair(identifier, client) + elif resource == "ping" and method == "POST": + return self._handlePing(client) + elif resource == "ring" and method == "POST": + return self._handleRing(client) + elif resource == "notification" and method == "POST": + return self._handleCreateNotification(identifier, client, data) + elif resource == "notification" and method == "DELETE": + return self._handleDeleteNotification(identifier, client, key) + elif resource == "command" and method == "GET": + return self._handleListCommands(identifier) + elif resource == "command" and method == "POST": + return self._handleCreateCommand(identifier, data) + elif resource == "command" and method == "PUT": + return self._handleUpdateCommand(identifier, key, data) + elif resource == "command" and method == "DELETE": + return self._handleDeleteCommand(identifier, key) + elif resource == "command" and method == "PATCH": + return self._handleExecuteCommand(client, key) + elif resource == "custom" and method == "POST": + return self._handleCustomPacket(client, data) + + raise NotImplementedError2() + + def _handleInfo(self): + return {"identifier": self.konnect.identifier, "device": self.konnect.name, + "server": "Konnect " + __version__}, 200 def _handleVersion(self): - info = {"id": self.konnect.identifier, "name": self.konnect.name, "application": "Konnect " + __version__} - return info, 200 + return {"version": __version__}, 200 def _handleAnnounce(self): try: @@ -168,33 +194,38 @@ def _handleAnnounce(self): def _handleDevices(self): return {"devices": list(self.konnect.getDevices().values())}, 200 + def _handleCommands(self): + return {"commands": self.database.listAllCommands()}, 200 + + def _handleNotifications(self): + return {"notifications": self.database.listAllNotifications()}, 200 - def _handlePostPair(self, identifier, client): + def _handlePair(self, client): client.sendPair() return {}, 200 - def _handleDeletePair(self, identifier, client): + def _handleUnpair(self, identifier, client): self.database.unpairDevice(identifier) client.sendUnpair() return {}, 200 - def _handleGetDevice(self, identifier, client): + def _handleGetDevice(self, identifier): for device in self.konnect.getDevices().values(): if device["identifier"] == identifier: return device, 200 raise Exception() - def _handlePostPing(self, identifier, client): + def _handlePing(self, client): client.sendPing() return {}, 200 - def _handlePostRing(self, identifier, client): + def _handleRing(self, client): client.sendRing() return {}, 200 - def _handlePostNotification(self, identifier, client, data): - if "text" not in data or "title" not in data or "application" not in data: + def _handleCreateNotification(self, identifier, client, data): + if not data.get("text") or not data.get("title") or not data.get("application"): raise ApiError("text or title or application not found", 400) text = data["text"] @@ -247,7 +278,43 @@ def _handleDeleteNotification(self, identifier, client, reference=None): return {}, 204 - def _handlePostCustom(self, identifier, client, data): + def _handleListCommands(self, identifier): + return {"commands": self.database.listCommands(identifier)}, 200 + + def _handleCreateCommand(self, identifier, data): + if not data.get("name") or not data.get("command"): + raise ApiError("name or command not found", 400) + + key = str(uuid4()) + self.database.addCommand(identifier, key, data["name"], data["command"]) + return {"key": key}, 201 + + def _handleUpdateCommand(self, identifier, key, data): + if not data.get("name") or not data.get("command"): + raise ApiError("name or command not found", 400) + + if self.database.getCommand(identifier, key): + self.database.updateCommand(identifier, key, data["name"], data["command"]) + return {}, 200 + + raise ApiError("not found", 404) + + def _handleDeleteCommand(self, identifier, key=None): + if key: + if self.database.getCommand(identifier, key): + self.database.remCommand(identifier, key) + else: + raise ApiError("not found", 404) + else: + self.database.remCommands(identifier) + + return {}, 204 + + def _handleExecuteCommand(self, client, key): + client.sendRun(key) + return {}, 200 + + def _handleCustomPacket(self, client, data): if not self.debug: raise ApiError("server is not in debug mode", 403) diff --git a/konnect/client.py b/konnect/client.py index fbce40c..7a8fbb8 100755 --- a/konnect/client.py +++ b/konnect/client.py @@ -2,7 +2,7 @@ import sys from argparse import ArgumentParser -from json import dumps, loads +from json import loads from os.path import join from traceback import print_exc @@ -11,18 +11,58 @@ from requests.exceptions import ConnectionError +def print_out(data, level=0, parent=None): # FIXME + if isinstance(data, dict): + for index, (key, value) in enumerate(data.items()): + if key == "success" and level == 0: + continue + + if isinstance(value, (dict, list)): + if len(value): + print(f"{''.ljust(level)}{key}:") + print_out(value, level + 2) + else: + print(f"{''.ljust(level)}{key}: {value}") + else: + if parent == list and index == 0: + print(f"{''.ljust(level - 2)}- {key}: {value}") + else: + print(f"{''.ljust(level)}{key}: {value}") + elif isinstance(data, list): + for value in data: + if isinstance(value, dict): + print_out(value, level, list) + else: + print(f"{''.ljust(level - 2)}- {value}") + + def query(args): method = None url = f"http://localhost:{args.port}" data = {} - if args.action == "version": + if args.action == "info": + method = "GET" + elif args.action == "version": method = "GET" + url = join(url, "version") elif args.action == "devices": method = "GET" url = join(url, "device") + if args.device: + url = join(url, args.device) elif args.action == "announce": method = "PUT" + elif args.action == "commands": + method = "GET" + url = join(url, "command") + if args.device: + url = join(url, args.device) + elif args.action == "notifications": + method = "GET" + url = join(url, "notification") + if args.device: + url = join(url, args.device) else: if args.action == "device": method = "GET" @@ -47,6 +87,14 @@ def query(args): except Exception: print("Error: invalid json") sys.exit(1) + elif args.action == "command": + if args.key: + method = "DELETE" if args.delete else "PUT" + url = join(url, "command", args.device, args.key) + else: + method = "POST" + url = join(url, "command", args.device) + data = {"name": args.name, "command": args.command} elif args.action == "notification": if args.cancel: method = "DELETE" @@ -66,6 +114,12 @@ def query(args): except FileNotFoundError: print("Error: icon not found") sys.exit(1) + elif args.action == "exec": + method = "PATCH" + url = join(url, "command", args.device, args.key) + else: + print("Error: action not implemented") + sys.exit(1) if args.debug: print("REQUEST:", method, url) @@ -90,8 +144,8 @@ def query(args): data = {} print() - print(dumps(data, indent=2)) - sys.exit(int(data["success"])) + print_out(data) + sys.exit(int(not data["success"])) def main(): @@ -102,15 +156,34 @@ def main(): subparsers = parser.add_subparsers(dest="action", title="actions") subparsers.add_parser("announce", help="Announce your identity") + command = subparsers.add_parser("command", help="Configure local commands...") + command.add_argument("--device", metavar="DEV", type=str, required=True, help="Device @name or id") + command.add_argument("--delete", action="store_true", help="Delete command") + is_delete = "--delete" in sys.argv + delete = command.add_argument_group("delete") + delete.add_argument("--key", type=str, required=is_delete, help="Key identifier") + details = command.add_argument_group("details") + details.add_argument("--name", type=str, required=not is_delete, help="Name to show") + details.add_argument("--command", metavar="CMD", type=str, required=not is_delete, help="Command to execute") + + commands = subparsers.add_parser("commands", help="List all commands...") + commands.add_argument("--device", metavar="DEV", type=str, help="Device @name or id") + custom = subparsers.add_parser("custom", help="Send custom packet...") custom.add_argument("--device", metavar="DEV", type=str, required=True, help="Device @name or id") custom.add_argument("--data", type=str, help="JSON string") - device = subparsers.add_parser("device", help="Show device info") # FIXME - device.add_argument("--device", metavar="DEV", type=str, required=True, help="Device @name or id") + devices = subparsers.add_parser("devices", help="List all devices...") + devices.add_argument("--device", metavar="DEV", type=str, help="Device @name or id") + + exec_ = subparsers.add_parser("exec", help="Execute remote command...") + exec_.add_argument("--device", metavar="DEV", type=str, required=True) + exec_.add_argument("--key", type=str, required=True, help="Key identifier") - subparsers.add_parser("devices", help="List devices") + subparsers.add_parser("info", help="Show server info") + notifications = subparsers.add_parser("notifications", help="List all notifications...") + notifications.add_argument("--device", metavar="DEV", type=str, help="Device @name or id") notification = subparsers.add_parser("notification", help="Send or cancel notification...") notification.add_argument("--device", metavar="DEV", type=str, required=True, help="Device @name or id") diff --git a/konnect/database.py b/konnect/database.py index 3bda202..b27a743 100644 --- a/konnect/database.py +++ b/konnect/database.py @@ -1,5 +1,5 @@ +from logging import debug from sqlite3 import OperationalError, connect -from uuid import uuid4 class Database: @@ -15,12 +15,38 @@ class Database: [ "ALTER TABLE notifications ADD COLUMN cancel INTEGER DEFAULT 0", ], + [ + "CREATE TABLE commands (key TEXT PRIMARY KEY, identifier TEXT, name TEXT, command TEXT, " + "FOREIGN KEY (identifier) REFERENCES trusted_devices (identifier) ON DELETE CASCADE)", + ], ] def __init__(self, path): self.instance = connect(path, isolation_level=None, check_same_thread=False) + self.instance.row_factory = self._dict_factory self._upgradeSchema() + def _dict_factory(self, cursor, row): + fields = [column[0] for column in cursor.description] + return dict(zip(fields, row)) + + def _execute(self, query, params=()): + debug(f"Query({query}) - Params({params})") + result = self.instance.execute(query, params) + keyword = query.split(" ", 1)[0].upper() + + if keyword == "SELECT": + result = result.fetchall() + debug(f"Result({result})") + elif keyword == "INSERT": + result = result.lastrowid + debug(f"LastRowId({result})") + elif keyword in ["UPDATE", "DELETE"]: + result = result.rowcount + debug(f"RowCount({result})") + + return result + def _upgradeSchema(self): version = int(self.loadConfig("schema", -1)) @@ -29,54 +55,101 @@ def _upgradeSchema(self): version = index for query in queries: - self.instance.execute(query) + self._execute(query) self.saveConfig("schema", version) def loadConfig(self, key, default=None): try: query = "SELECT value FROM config WHERE key = ?" - return self.instance.execute(query, (key,)).fetchone()[0] + return self._execute(query, (key,))[0]["value"] except (OperationalError, TypeError): return default def saveConfig(self, key, value): query = "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" - self.instance.execute(query, (key, value)) + self._execute(query, (key, value)) def isDeviceTrusted(self, identifier): - query = "SELECT COUNT(1) FROM trusted_devices WHERE identifier = ?" - return int(self.instance.execute(query, (identifier,)).fetchone()[0]) == 1 + query = "SELECT COUNT(1) AS count FROM trusted_devices WHERE identifier = ?" + try: + return int(self._execute(query, (identifier,))[0]["count"]) == 1 + except IndexError: + return False def getTrustedDevices(self): query = "SELECT identifier, name, type FROM trusted_devices" - return self.instance.execute(query).fetchall() + items = {} + + for row in self._execute(query): + items[row["identifier"]] = {"identifier": row["identifier"], "name": row["name"], "type": row["type"], + "reachable": False, "trusted": True, "commands": {}} + + return items def updateDevice(self, identifier, name, device): query = "UPDATE trusted_devices SET name = ?, type = ? WHERE identifier = ?" - self.instance.execute(query, (name, device, identifier)) + self._execute(query, (name, device, identifier)) def pairDevice(self, identifier, certificate, name, device): query = "INSERT INTO trusted_devices (identifier, certificate, name, type) VALUES (?, ?, ?, ?)" - self.instance.execute(query, (identifier, certificate, name, device)) + self._execute(query, (identifier, certificate, name, device)) def unpairDevice(self, identifier): query = "DELETE FROM trusted_devices WHERE identifier = ?" - self.instance.execute(query, (identifier,)) + self._execute(query, (identifier,)) def persistNotification(self, identifier, text, title, application, reference): - query = "INSERT INTO notifications (identifier, [text], title, application, reference) VALUES (?, ?, ?, ?, ?)" \ - "ON CONFLICT(identifier, reference) DO UPDATE SET text = excluded.text, title = excluded.title, application = excluded.application" - self.instance.execute(query, (identifier, text, title, application, reference)) + query = "INSERT INTO notifications (identifier, [text], title, application, reference) " \ + "VALUES (?, ?, ?, ?, ?) ON CONFLICT(identifier, reference) DO UPDATE SET text = excluded.text, " \ + "title = excluded.title, application = excluded.application" + self._execute(query, (identifier, text, title, application, reference)) def dismissNotification(self, identifier, reference): query = "DELETE FROM notifications WHERE identifier = ? AND reference = ?" - self.instance.execute(query, (identifier, reference)) + self._execute(query, (identifier, reference)) def cancelNotification(self, identifier, reference): query = "UPDATE notifications SET cancel = ? WHERE identifier = ? AND reference = ?" - self.instance.execute(query, (1, identifier, reference)) + self._execute(query, (1, identifier, reference)) - def showNotifications(self, identifier): + def listNotifications(self, identifier): query = "SELECT cancel, reference, [text], title, application FROM notifications WHERE identifier = ?" - return self.instance.execute(query, (identifier,)).fetchall() + return self._execute(query, (identifier,)) + + def listAllNotifications(self): + query = "SELECT d.identifier, d.name AS device, n.reference, n.[text], n.title, n.application, n.cancel " \ + "FROM notifications n INNER JOIN trusted_devices d ON (n.identifier = d.identifier) ORDER BY 2, 4" + return self._execute(query) + + def addCommand(self, identifier, key, name, command): + query = "INSERT INTO commands (key, identifier, name, command) VALUES (?, ?, ?, ?)" + self._execute(query, (key, identifier, name, command)) + + def updateCommand(self, identifier, key, name, command): + query = "UPDATE commands SET name = ?, command = ? WHERE identifier = ? AND key = ?" + self._execute(query, (name, command, identifier, key)) + + def remCommands(self, identifier): + query = "DELETE FROM commands WHERE identifier = ?" + self._execute(query, (identifier,)) + + def remCommand(self, identifier, key): + query = "DELETE FROM commands WHERE identifier = ? AND key = ?" + self._execute(query, (identifier, key)) + + def getCommand(self, identifier, key): + query = "SELECT command FROM commands WHERE identifier = ? AND key = ?" + try: + return self._execute(query, (identifier, key))[0]["command"] + except IndexError: + return None + + def listCommands(self, identifier): + query = "SELECT key, name, command FROM commands WHERE identifier = ?" + return self._execute(query, (identifier,)) + + def listAllCommands(self): + query = "SELECT d.identifier, d.name AS device, c.key, c.name, c.command FROM commands c " \ + "INNER JOIN trusted_devices d ON (c.identifier = d.identifier) ORDER BY 2, 4" + return self._execute(query) diff --git a/konnect/factories.py b/konnect/factories.py index b4e4a6c..c31aece 100644 --- a/konnect/factories.py +++ b/konnect/factories.py @@ -28,11 +28,7 @@ def findClient(self, identifier): return None def getDevices(self): - devices = {} - - for trusted in self.database.getTrustedDevices(): - devices[trusted[0]] = {"identifier": trusted[0], "name": trusted[1], "type": trusted[2], - "reachable": False, "trusted": True, "commands": {}} + devices = self.database.getTrustedDevices() for client in self.clients: trusted = client.identifier in devices diff --git a/konnect/packet.py b/konnect/packet.py index 2f82490..1e5908b 100644 --- a/konnect/packet.py +++ b/konnect/packet.py @@ -11,6 +11,8 @@ class PacketType: PAIR = "kdeconnect.pair" PING = "kdeconnect.ping" RING = "kdeconnect.findmyphone.request" + RUNCOMMAND = "kdeconnect.runcommand" + RUNCOMMAND_REQUEST = "kdeconnect.runcommand.request" class Packet: @@ -79,8 +81,10 @@ def createIdentity(identifier, name, port): packet.set("deviceName", name) packet.set("deviceType", Packet.DEVICE_TYPE) packet.set("tcpPort", port) - packet.set("incomingCapabilities", [PacketType.PING, PacketType.NOTIFICATION_REQUEST]) - packet.set("outgoingCapabilities", [PacketType.RING, PacketType.NOTIFICATION, PacketType.PING]) + packet.set("incomingCapabilities", [PacketType.PING, PacketType.NOTIFICATION_REQUEST, + PacketType.RUNCOMMAND_REQUEST, PacketType.RUNCOMMAND]) + packet.set("outgoingCapabilities", [PacketType.RING, PacketType.NOTIFICATION, PacketType.PING, + PacketType.RUNCOMMAND]) return packet @@ -117,13 +121,33 @@ def createCancel(reference): return packet @staticmethod - def createPing(): - return Packet(PacketType.PING) + def createPing(msg=None): + packet = Packet(PacketType.PING) + + if msg: + packet.set("message", msg) + + return packet @staticmethod def createRing(): return Packet(PacketType.RING) + @staticmethod + def createCommands(commands): + packet = Packet(PacketType.RUNCOMMAND) + packet.set("canAddCommand", False) + packet.set("commandList", dumps(commands)) + + return packet + + @staticmethod + def createRun(key): + packet = Packet(PacketType.RUNCOMMAND_REQUEST) + packet.set("key", key) + + return packet + @staticmethod def load(data): packet = Packet() diff --git a/konnect/protocols.py b/konnect/protocols.py index e34abdf..241e932 100644 --- a/konnect/protocols.py +++ b/konnect/protocols.py @@ -33,6 +33,7 @@ class Konnect(LineReceiver): name = "unnamed" device = "unknown" timeout = None + commands = {} database = None def __init__(self): @@ -59,8 +60,8 @@ def sendRing(self): ring = Packet.createRing() self._sendPacket(ring) - def sendPing(self): - ping = Packet.createPing() + def sendPing(self, msg=None): + ping = Packet.createPing(msg) self._sendPacket(ping) def sendNotification(self, text, title, application, reference, payload=None): @@ -78,6 +79,18 @@ def sendCancel(self, reference): cancel = Packet.createCancel(reference) self._sendPacket(cancel) + def sendCommands(self): + commands = {} + for row in self.database.listCommands(self.identifier): + commands[row["key"]] = {"name": row["name"], "command": row["command"]} + + cmd = Packet.createCommands(commands) + self._sendPacket(cmd) + + def sendRun(self, key): + cmd = Packet.createRun(key) + self._sendPacket(cmd) + def sendPair(self): self.status = InternalStatus.REQUESTED self._cancelTimeout() @@ -164,14 +177,14 @@ def _handleNotify(self, packet): info("Registered notifications listener") self.database.updateDevice(self.identifier, self.name, self.device) - for notification in self.database.showNotifications(self.identifier): - cancel = int(notification[0]) - reference = notification[1] + for notification in self.database.listNotifications(self.identifier): + cancel = int(notification["cancel"]) + reference = notification["reference"] if cancel == 0: - text = notification[2] - title = notification[3] - application = notification[4] + text = notification["text"] + title = notification["title"] + application = notification["application"] callLater(0.1, self.sendNotification, text, title, application, reference) else: @@ -180,6 +193,30 @@ def _handleNotify(self, packet): else: debug("Ignoring unknown request") + def _handleCommand(self, packet): + if not packet.get("commandList"): + return + + try: + self.commands = loads(packet.get("commandList")) + except Exception: + self.commands = {} + + def _handleCommandRequest(self, packet): + if packet.get("requestCommandList"): + self.sendCommands() + elif packet.get("key"): + key = packet.get("key") + command = self.database.getCommand(self.identifier, key) + + if not command: + warning(f"{key} is not a configured command") + else: + info(f"Running: {command}") + Popen([command], shell=True) + else: # TODO setup? + pass + def lineReceived(self, line): if self.status == InternalStatus.NOT_PAIRED and len(line) > BUFFER_SIZE: warning(f"Suspiciously long identity package received. Closing connection. {self.address}") @@ -221,7 +258,11 @@ def lineReceived(self, line): if packet.isType(PacketType.NOTIFICATION_REQUEST): self._handleNotify(packet) elif packet.isType(PacketType.PING): - self.sendPing() + self.sendPing(packet.get("message")) + elif packet.isType(PacketType.RUNCOMMAND): + self._handleCommand(packet) + elif packet.isType(PacketType.RUNCOMMAND_REQUEST): + self._handleCommandRequest(packet) else: warning(f"Discarding unsupported packet {packet.getType()} for {self.name}") else: