PyConnect na plecach GSConnect

Mniej więcej na początku grudnia zainteresowałem się protokołem KDEConnect. Aplikacji tej używałem już od ponad roku - dobrze służyła mi do synchronizacji plików pomiędzy telefonem i komputerem. Coraz bardziej marzyła mi się własna wersja - po stronie komputera. Planowałem jej integrację z własnymi aplikacjami (np. automatyzację pobierania i tagowania nowych zdjęć z moich podróży).

Poznanie samego protokołu KDEConnect nie należało jednak do zadań prostych. Dokumentacja samego KDEConnect - jest mało czytelna i nie za bogata. W zasadzie więcej o komunikacji musiałem dowiedzieć się z analizy kodu GSConnect (czyli rozszerzenia do Gnome Shell, które realizuje na komputerze te same zadania, co KDEConnect). W końcu udało mi się nawiązać pierwsze połączenia z telefonem - początkowo dla uproszczenia korzystając z parowania urządzenia, jakie wykonał dla mnie sam GSConnect.

Co mi było potrzebne (poza analizą kodu GSConnect) - nawiązanie połączenia przez GSConnect. I jego wyłączenie zaraz po tym - inaczej zajmowałby on port 1716 potrzebny dla mojej aplikacji.

1
2
gnome-extensions disable gsconnect@andyholmes.github.io
ps axf | grep gsconnect | grep -v grep | awk '{print "sudo kill -9 " $1}' | sh

Rozszerzenie wyłączamy poleceniem gnome-extensions disable (enable pozwoli nam potem na jego włączenie). Musimy jeszcze oddzielnie zabić proces samego rozszerzenia (mający wywołanie w stylu gjs /home/systemik/.local/share/gnome-shell/extensions/gsconnect@andyholmes.github.io/service/daemon.js). Po tym przestanie zajmować ono nam port 1716 potrzebny do komunikacji z KDEConnect.

Z konfiguracji GSConnect będziemy potrzebowali certyfikatu z kluczem prywatnym dla komunikacji TLS - są one zapisane w plikach ~/.config/gsconnect/certificate.pem i ~/.config/gsconnect/private.pem.

1
2
3
4
5
6
7
import os.path


GSCONNECT_CERTFILE = os.path.expanduser(GSCONNECT_CERTFILE)
GSCONNECT_KEYFILE = os.path.expanduser(GSCONNECT_KEYFILE)

assert os.path.exists(GSCONNECT_CERTFILE) and os.path.exists(GSCONNECT_KEYFILE)

Drugi zestaw danych zawiera konfiguracja dconf (dconf dump /org/gnome/shell/extensions/gsconnect/) - poniżej wynik skracam tylko do tych danych, które będą nam potrzebne:

1
2
3
4
5
6
7
8
9
[/]
devices=['32151f87b8be9b96']
id='112ab5c3-fd8d-4bcb-ae11-9d08fcad0a05'
name='dell'

[device/32151f87b8be9b96]
certificate-pem='-----BEGIN CERTIFICATE-----...----END CERTIFICATE-----'
name='Redmi 6A'
paired=true

Dane te a naszym kodzie możemy odczytać na dwa sposoby - subprocess i wywołanie dconf (np. dconf read /org/gnome/shell/extensions/gsconnect/id). Lub wykorzystując Gio.Settings z PyGObject. Ten drugi model wydał mi się trochę jednak prostszy (mimo tego, że Gio.Settings obsługuje się w sposób mało Python-owy). Przykład odczytywania podstawowych danych:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from gi.repository import Gio


gsconnect_config = Gio.Settings.new_with_path(
    'org.gnome.Shell.Extensions.GSConnect.Device',
    '/org/gnome/shell/extensions/gsconnect/')
GSCONNECT_DEVICE_ID = gsconnect_config.get_string('id')
GSCONNECT_DEVICE_NAME = gsconnect_config.get_string('name')

# lista dostępnych ID połączeń
gsconnect_config = Gio.Settings.new_with_path(
    'org.gnome.Shell.Extensions.GSConnect',
    '/org/gnome/shell/extensions/gsconnect/')
GSCONNECT_KNOWN_DEVICES = list(gsconnect_config.get_value('devices'))

Przy wykonywaniu połączenia będziemy na pewno chcieli wydobyć certyfikat drugiej strony:

1
2
3
4
5
6
7
8
9
# remote_deviceId - to id urządzenia przysłany w pakiecie identyfikacji i zapisany w dconf

gsconnect_config = Gio.Settings.new_with_path(
    'org.gnome.Shell.Extensions.GSConnect.Device',
    f'/org/gnome/shell/extensions/gsconnect/device/{remote_deviceId}/')

ssl_context.load_verify_locations(
    cadata=ssl.PEM_cert_to_DER_cert(
        gsconnect_config.get_string('certificate-pem')))

Analiza GSConnect (a dokładniej plików src/service/core.js i src/service/backends/lan.js) naprowadziła mnie na najbardziej podstawowy mechanizm nawiązywania połączenia:

  1. Otwieramy nasłuch UDP na porcie 1716 (od wszystkich), czekamy na pakiet kdeconnect.identity. Jeśli jego nadawca (body.deviceId) jest nam znany - przechodzimy dalej.
  2. Otwieramy połączenie (TCP na porcie 1716) z nadawcą tego pakietu.
  3. Wysyłamy własny pakiet identyfikacji (kdeconnect.identity z naszymi danymi).
  4. Zmieniamy zwykłe gniazdo TCP na TLS (ale z server_side=True - mimo tego, że to my nawiązaliśmy połączenie, to mamy dalej zachować się, jak serwer).
  5. I możemy się cieszyć połączeniem z naszym KDEConnect.

W swoim kodzie (pyconnect.py) będę posługiwał się biblioteką AnyIO, która mocno upraszcza działanie z natywnym TCP i UDP, a jednocześnie umożliwia działanie w asyncio.

Nasłuch UDP nie jest trudnym zadaniem, używamy anyio.create_udp_socket(), pamiętając o local_host='255.255.255.255' (czyli nasłuch na broadcast) i odpowiednim local_port (1716).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import anyio


KDE_CONNECT_DEFAULT_PORT = 1716


async def wait_for_incoming_id(main_group):
    async with await anyio.create_udp_socket(
            family=socket.AF_INET, local_host='255.255.255.255',
            local_port=KDE_CONNECT_DEFAULT_PORT) as udp:
        async for data, (host, port) in udp:
            try:
                pack = json.loads(data.decode('utf-8'))
            except json.JSONDecodeError:
                log.exception(('wait_for_incoming_id(): malformed id packet '
                               f'{data} from {host}:{port}'))
                return

            if pack['type'] != 'kdeconnect.identity' or 'deviceId' not in pack['body']:  # noqa
                log.warning(
                    ('wait_for_incoming_id(): identity packet without '
                     f'body.deviceId or unknown type\n{pack=}'))
                return

            dev_id = pack['body']['deviceId']
            known = dev_id in GSCONNECT_KNOWN_DEVICES
            log.info(('wait_for_incoming_id(): id packet received from '
                      f'{pack["body"]["deviceName"]} / {dev_id} (IP: {host})'
                      f'{known=}'))

            if not known or dev_id == GSCONNECT_DEVICE_ID or dev_id in CONNECTED_DEVICES:  # noqa
                return

            main_group.start_soon(device_connection_task, pack, host)

Odbierany pakiet będzie miał formę JSON - zajmującego tylko jedną linię. Jego przykładowa wartość wygląda następująco:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
{
    "id":1644153455113,
    "type":"kdeconnect.identity",
    "body":{
        "deviceId":"32151f87b8be9b96",
        "deviceName":"Redmi 6A",
        "protocolVersion":7,
        "deviceType":"phone",
        "incomingCapabilities":[
            "kdeconnect.telephony.request_mute",
            "kdeconnect.notification",
            "kdeconnect.ping",
            "kdeconnect.notification.reply",
            "kdeconnect.notification.action",
            "kdeconnect.share.request",
            "kdeconnect.bigscreen.stt",
            "kdeconnect.clipboard.connect",
            "kdeconnect.runcommand",
            "kdeconnect.connectivity_report.request",
            "kdeconnect.contacts.request_all_uids_timestamps",
            "kdeconnect.sms.request_conversations",
            "kdeconnect.telephony.request",
            "kdeconnect.mpris",
            "kdeconnect.sms.request_conversation",
            "kdeconnect.findmyphone.request",
            "kdeconnect.sms.request_attachment",
            "kdeconnect.systemvolume",
            "kdeconnect.mousepad.keyboardstate",
            "kdeconnect.sftp.request",
            "kdeconnect.share.request.update",
            "kdeconnect.notification.request",
            "kdeconnect.mousepad.request",
            "kdeconnect.photo.request",
            "kdeconnect.sms.request",
            "kdeconnect.contacts.request_vcards_by_uid",
            "kdeconnect.mpris.request",
            "kdeconnect.battery.request",
            "kdeconnect.battery",
            "kdeconnect.clipboard"
        ],
        "outgoingCapabilities":[
            "kdeconnect.telephony",
            "kdeconnect.notification",
            "kdeconnect.contacts.response_uids_timestamps",
            "kdeconnect.ping",
            "kdeconnect.share.request",
            "kdeconnect.bigscreen.stt",
            "kdeconnect.clipboard.connect",
            "kdeconnect.connectivity_report",
            "kdeconnect.sftp",
            "kdeconnect.sms.attachment_file",
            "kdeconnect.systemvolume.request",
            "kdeconnect.sms.messages",
            "kdeconnect.mpris",
            "kdeconnect.findmyphone.request",
            "kdeconnect.mousepad.keyboardstate",
            "kdeconnect.contacts.response_vcards",
            "kdeconnect.notification.request",
            "kdeconnect.mousepad.echo",
            "kdeconnect.mousepad.request",
            "kdeconnect.presenter",
            "kdeconnect.photo",
            "kdeconnect.runcommand.request",
            "kdeconnect.mpris.request",
            "kdeconnect.battery.request",
            "kdeconnect.battery",
            "kdeconnect.clipboard"
        ],
        "tcpPort":1716
    }
}

Co warto wiedzieć o pakietach - mają one typ (type - na tym etapie interesują nas tylko pakiety kdeconnect.identity). id to po prostu aktualny czas. Najciekawsze pola w body to deviceId i deviceName (oba będą zgodne z danymi z konfiguracji). incomingCapabilities i outgoingCapabilities to lista pluginów, jakie mogę być obsługiwane - my na początku skrócimy ją do kdeconnect.share.request, czyli umożliwimy tylko przesyłanie plików. W kwestii tcpPort - nie widziałem nigdy, aby był używany jakiś inny, niż domyślny 1716, choć kod GSConnect trzyma się tego, aby nawiązywać połączenie na porcie podawanym przez ten pakiet.

Pakiet próbujemy odczytać, potem - sprawdzamy czy ma on wymagany typ i interesujące nas body.deviceId (z ID, z którym nawiązał wcześniej połączenie GSConnect, a my te dane przechwyciliśmy z jego konfiguracji). Jak już wszystkie warunki zostaną spełnione - przechodzimy do wystartowania zadania device_connection_task(), które ogarnie nam temat połączenia TCP.

Połączenie to nawiązujemy za pomocą anyio.connect_tcp().

1
2
3

async def device_connection_task(id_packet, remote_ip):
    async with await anyio.connect_tcp(remote_ip, KDE_CONNECT_DEFAULT_PORT) as sock:  # noqa

Następnie musimy w nim wysłać nasz pakiet identyfikacji:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
    "id":1644153455971504483,
    "type":"kdeconnect.identity",
    "body":{
        "deviceId":"112ab5c3-fd8d-4bcb-ae11-9d08fcad0a05",
        "deviceName":"dell",
        "deviceType":"laptop",
        "protocolVersion":7,
        "incomingCapabilities":[
            "kdeconnect.share.request"
        ],
        "outgoingCapabilities":[
            "kdeconnect.share.request"
        ],
        "tcpPort":1716
    }
}

body.deviceId i body.deviceName odczytaliśmy wcześniej z konfiguracji GSConnect. body.incomingCapabilities i body.outgoingCapabilities zostały mocno skrócone - w pierwszej wersji chciałem mieć tylko przesyłanie plików (plugin src/service/plugins/share.js wymagający tylko wartości kdeconnect.share.request). Generowanie tego pakietu w kodzie wygląda następująco:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
PROTOCOL_VERSION = 7
INCOMING_CAPABILITIES = [
    'kdeconnect.share.request',
]
OUTGOING_CAPABILITIES = [
    'kdeconnect.share.request',
]


def generate_my_identity():
    return {
        'type': 'kdeconnect.identity',
        'body': {
            'deviceId': GSCONNECT_DEVICE_ID,
            'deviceName': GSCONNECT_DEVICE_NAME,
            'deviceType': 'laptop',
            'protocolVersion': PROTOCOL_VERSION,
            'incomingCapabilities': INCOMING_CAPABILITIES,
            'outgoingCapabilities': OUTGOING_CAPABILITIES,
            'tcpPort': KDE_CONNECT_DEFAULT_PORT
        }
    }


def prepare_to_send(pack):
    pack2 = pack.copy()
    pack2['id'] = time.time_ns()
    return (json.dumps(pack2) + '\n').encode('utf-8')


# wysyłanie w device_connection_task()
        await sock.send(prepare_to_send(generate_my_identity()))

Po wysłaniu pakietu identyfikacji - musimy przekształcić nasze gniazdo w TLS (za pomocą TLSStream.wrap() z AnyIO, pamiętając, że protokół KDEConnect wymaga od nas zachowywania się jak strona serwera - server_side=True). Kontekst TLS - musi zawierać pliki certyfikatu i klucza przechwycone z GSConnect i mieć załadowany certyfikat urządzenia (odczytany z konfiguracji GSConnect):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from anyio.streams.tls import TLSStream, TLSListener


    # ...
    remote_deviceId = id_packet['body']['deviceId']

    ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    ssl_context.load_cert_chain(GSCONNECT_CERTFILE, GSCONNECT_KEYFILE)
    ssl_context.verify_flags = ssl.CERT_REQUIRED

    gsconnect_config = Gio.Settings.new_with_path(
        'org.gnome.Shell.Extensions.GSConnect.Device',
        f'/org/gnome/shell/extensions/gsconnect/device/{remote_deviceId}/')

    ssl_context.load_verify_locations(
        cadata=ssl.PEM_cert_to_DER_cert(
            gsconnect_config.get_string('certificate-pem')))

    # ...
    async with await anyio.connect_tcp(remote_ip, KDE_CONNECT_DEFAULT_PORT) as sock:  # noqa
        # ... po wysłaniu pakietu ID

        ssock = await TLSStream.wrap(sock, server_side=True,
                                     ssl_context=ssl_context)

Po poprawnym przekształceniu połączenia na TLS - możemy czekać na pakiety od KDEConnect. Dla uproszczenie użyłem BufferedByteReceiveStream, który ułatwia czekanie na koniec pakietu - każdy pakiet to JSON przesyłany w jednej linii - czekamy więc na kolejne '\n'.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
from anyio.streams.buffered import BufferedByteReceiveStream

        # ...
        bssock = BufferedByteReceiveStream(ssock)
        
        while True:
            pack_data = await bssock.receive_until(b'\n', 1024 * 1024)

            try:
                pack = json.loads(pack_data)
            except json.JSONDecodeError:
                log.exception(
                    ('device_connection_task(): Error while decoding '
                     f'packet / {pack_data}'))
                continue
            log.debug(f'device_connection_task(): Packet {pack_data}')
            
            
            # obsługa samego pakietu

Wspominany tu kod znajduje się w pliku pyconnect.py.

W następnym poście przedstawię mechanizm odbierania i wysyłania plików.