prim-ctrl 0.6.1__tar.gz → 0.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/PKG-INFO +5 -3
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/README.md +1 -0
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/prim_ctrl/__main__.py +242 -210
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/pyproject.toml +4 -3
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/LICENSE +0 -0
- {prim_ctrl-0.6.1 → prim_ctrl-0.6.2}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
|
|
5
5
|
Home-page: https://github.com/lmagyar/prim-ctrl
|
|
6
6
|
License: Apache-2.0
|
|
@@ -13,9 +13,10 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Requires-Dist: aiohttp (>=3.11.
|
|
16
|
+
Requires-Dist: aiohttp (>=3.11.10,<4.0.0)
|
|
17
|
+
Requires-Dist: dnspython (>=2.7.0,<3.0.0)
|
|
17
18
|
Requires-Dist: platformdirs (>=4.3.6,<5.0.0)
|
|
18
|
-
Requires-Dist: zeroconf (>=0.136.
|
|
19
|
+
Requires-Dist: zeroconf (>=0.136.2,<0.137.0)
|
|
19
20
|
Project-URL: Change Log, https://github.com/lmagyar/prim-ctrl/blob/main/CHANGELOG.md
|
|
20
21
|
Project-URL: Repository, https://github.com/lmagyar/prim-ctrl
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
@@ -136,6 +137,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
|
|
|
136
137
|
|
|
137
138
|
Notes:
|
|
138
139
|
- Even when -b option is **not** used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
|
|
140
|
+
- If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
|
|
139
141
|
|
|
140
142
|
### Some example
|
|
141
143
|
|
|
@@ -114,6 +114,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
|
|
|
114
114
|
|
|
115
115
|
Notes:
|
|
116
116
|
- Even when -b option is **not** used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
|
|
117
|
+
- If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
|
|
117
118
|
|
|
118
119
|
### Some example
|
|
119
120
|
|
|
@@ -16,6 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Dict
|
|
17
17
|
|
|
18
18
|
import aiohttp
|
|
19
|
+
import dns.asyncresolver
|
|
20
|
+
import dns.resolver
|
|
19
21
|
from aiohttp import ClientTimeout, web
|
|
20
22
|
from platformdirs import user_cache_dir
|
|
21
23
|
from zeroconf import Zeroconf, ServiceInfo, ServiceListener as ZeroconfServiceListener
|
|
@@ -35,7 +37,7 @@ class LevelFormatter(logging.Formatter):
|
|
|
35
37
|
return self.formatters.get(record.levelno, self.default_formatter).format(record)
|
|
36
38
|
|
|
37
39
|
class Logger(logging.Logger):
|
|
38
|
-
def __init__(self, name, level=logging.NOTSET):
|
|
40
|
+
def __init__(self, name, level = logging.NOTSET):
|
|
39
41
|
super().__init__(name, level)
|
|
40
42
|
self.exitcode = 0
|
|
41
43
|
|
|
@@ -99,50 +101,43 @@ logger = Logger(Path(sys.argv[0]).name)
|
|
|
99
101
|
|
|
100
102
|
########
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
104
|
+
class Subprocess:
|
|
105
|
+
|
|
106
|
+
# based on https://stackoverflow.com/a/55656177/2755656
|
|
107
|
+
@staticmethod
|
|
108
|
+
def sync_ping(host, packets: int = 1, timeout: float = 1):
|
|
109
|
+
if platform.system().lower() == 'windows':
|
|
110
|
+
command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
|
|
111
|
+
# don't use text=True, the async version will raise ValueError("text must be False"), who knows why
|
|
112
|
+
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
113
|
+
return result.returncode == 0 and b'TTL=' in result.stdout
|
|
114
|
+
else:
|
|
115
|
+
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
116
|
+
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
117
|
+
return result.returncode == 0
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
async def async_ping(host, packets: int = 1, timeout: float = 1):
|
|
121
|
+
if platform.system().lower() == 'windows':
|
|
122
|
+
command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
|
|
123
|
+
# don't use text=True, the async version will raise ValueError("text must be False"), who knows why
|
|
124
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
125
|
+
stdout, _stderr = await proc.communicate()
|
|
126
|
+
return proc.returncode == 0 and b'TTL=' in stdout
|
|
127
|
+
else:
|
|
128
|
+
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
129
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
130
|
+
_stdout, _stderr = await proc.communicate()
|
|
131
|
+
return proc.returncode == 0
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
async def async_tailscale(args: list[str]):
|
|
135
|
+
command = ['tailscale']
|
|
136
|
+
command.extend(args)
|
|
137
|
+
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
138
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
119
139
|
stdout, _stderr = await proc.communicate()
|
|
120
|
-
return proc.returncode == 0
|
|
121
|
-
else:
|
|
122
|
-
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
123
|
-
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
124
|
-
_stdout, _stderr = await proc.communicate()
|
|
125
|
-
return proc.returncode == 0
|
|
126
|
-
|
|
127
|
-
async def async_tailscale(args: list[str]):
|
|
128
|
-
command = ['tailscale']
|
|
129
|
-
command.extend(args)
|
|
130
|
-
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
131
|
-
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
132
|
-
stdout, _stderr = await proc.communicate()
|
|
133
|
-
return proc.returncode == 0, stdout
|
|
134
|
-
|
|
135
|
-
async def async_tailscale_up():
|
|
136
|
-
return (await async_tailscale(['up']))[0]
|
|
137
|
-
|
|
138
|
-
async def async_tailscale_down():
|
|
139
|
-
return (await async_tailscale(['down']))[0]
|
|
140
|
-
|
|
141
|
-
async def async_tailscale_is_online():
|
|
142
|
-
success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
143
|
-
if success:
|
|
144
|
-
status = json.loads(stdout)
|
|
145
|
-
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
140
|
+
return proc.returncode == 0, stdout
|
|
146
141
|
|
|
147
142
|
########
|
|
148
143
|
|
|
@@ -265,7 +260,7 @@ class Device(Manageable):
|
|
|
265
260
|
|
|
266
261
|
async def ping(self, availability_hint: bool | None = None):
|
|
267
262
|
logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
|
|
268
|
-
return await async_ping(self.host, timeout=2)
|
|
263
|
+
return await Subprocess.async_ping(self.host, timeout=2)
|
|
269
264
|
|
|
270
265
|
class StateSerializer:
|
|
271
266
|
BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
|
|
@@ -301,153 +296,6 @@ class PhoneState:
|
|
|
301
296
|
|
|
302
297
|
########
|
|
303
298
|
|
|
304
|
-
class Webhooks:
|
|
305
|
-
PING_PATH = 'ping'
|
|
306
|
-
VARIABLE_PATH = 'variable'
|
|
307
|
-
|
|
308
|
-
def __init__(self, host: str, port: int):
|
|
309
|
-
self.host = host
|
|
310
|
-
self.port = port
|
|
311
|
-
self.variables = dict[str, asyncio.Queue[str]]()
|
|
312
|
-
|
|
313
|
-
@staticmethod
|
|
314
|
-
def get_ping_path():
|
|
315
|
-
return f'/{Webhooks.PING_PATH}'
|
|
316
|
-
|
|
317
|
-
@staticmethod
|
|
318
|
-
def get_variable_path(variable: str):
|
|
319
|
-
return f'/{Webhooks.VARIABLE_PATH}/{variable}'
|
|
320
|
-
|
|
321
|
-
async def _start(self):
|
|
322
|
-
async def _ping(request: web.Request):
|
|
323
|
-
return web.Response(text='pong')
|
|
324
|
-
async def _receive_variable(request: web.Request):
|
|
325
|
-
queue = self.variables.get(request.match_info['name'])
|
|
326
|
-
if queue:
|
|
327
|
-
with suppress(asyncio.QueueFull):
|
|
328
|
-
queue.put_nowait(await request.text())
|
|
329
|
-
return web.Response(text='OK')
|
|
330
|
-
app = web.Application()
|
|
331
|
-
app.add_routes([
|
|
332
|
-
web.get(f'/{Webhooks.PING_PATH}', _ping),
|
|
333
|
-
web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
|
|
334
|
-
self.runner = web.AppRunner(app)
|
|
335
|
-
await self.runner.setup()
|
|
336
|
-
self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
|
|
337
|
-
await self.site.start()
|
|
338
|
-
|
|
339
|
-
async def _stop(self):
|
|
340
|
-
await self.runner.cleanup()
|
|
341
|
-
|
|
342
|
-
def subscribe_variable(self, variable: str):
|
|
343
|
-
if variable not in self.variables:
|
|
344
|
-
self.variables[variable] = asyncio.Queue[str](maxsize=16)
|
|
345
|
-
|
|
346
|
-
def unsubscribe_variable(self, variable: str):
|
|
347
|
-
self.variables.pop(variable)
|
|
348
|
-
|
|
349
|
-
async def get_variable(self, variable: str, timeout: float):
|
|
350
|
-
queue = self.variables.get(variable)
|
|
351
|
-
if not queue:
|
|
352
|
-
raise ValueError(f"The {variable} is unknown")
|
|
353
|
-
try:
|
|
354
|
-
async with asyncio.timeout(timeout):
|
|
355
|
-
return await queue.get()
|
|
356
|
-
except TimeoutError as e:
|
|
357
|
-
e.add_note(f"Can't get value of {variable} for {timeout} seconds")
|
|
358
|
-
raise
|
|
359
|
-
|
|
360
|
-
def __enter__(self):
|
|
361
|
-
raise TypeError("Use async with instead")
|
|
362
|
-
def __exit__(self, exc_type, exc_value, exc_tb):
|
|
363
|
-
pass
|
|
364
|
-
async def __aenter__(self):
|
|
365
|
-
await self._start()
|
|
366
|
-
return self
|
|
367
|
-
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
|
368
|
-
await self._stop()
|
|
369
|
-
|
|
370
|
-
class Automate:
|
|
371
|
-
def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
|
|
372
|
-
self.session = session
|
|
373
|
-
self.account = account
|
|
374
|
-
self.device = device
|
|
375
|
-
self.secret = secrets.get(tokenfile)
|
|
376
|
-
|
|
377
|
-
async def send_message(self, message: str):
|
|
378
|
-
data = {
|
|
379
|
-
"secret": self.secret,
|
|
380
|
-
"to": self.account,
|
|
381
|
-
"device": self.device,
|
|
382
|
-
"priority": "high",
|
|
383
|
-
"payload": f"prim-ctrl;{time.time()};" + message
|
|
384
|
-
}
|
|
385
|
-
logger.debug("Messaging Automate with: %s", message)
|
|
386
|
-
async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
|
|
387
|
-
await response.text()
|
|
388
|
-
|
|
389
|
-
class AutomatepFTPdManager(Manager):
|
|
390
|
-
def __init__(self, automate: Automate):
|
|
391
|
-
self.automate = automate
|
|
392
|
-
|
|
393
|
-
async def start(self):
|
|
394
|
-
await self.automate.send_message('start-pftpd')
|
|
395
|
-
|
|
396
|
-
async def stop(self):
|
|
397
|
-
await self.automate.send_message('stop-pftpd')
|
|
398
|
-
|
|
399
|
-
class AutomateTailscaleManager(Manager):
|
|
400
|
-
def __init__(self, automate: Automate):
|
|
401
|
-
self.automate = automate
|
|
402
|
-
|
|
403
|
-
async def start(self):
|
|
404
|
-
await self.automate.send_message('start-tailscale')
|
|
405
|
-
|
|
406
|
-
async def stop(self):
|
|
407
|
-
await self.automate.send_message('stop-tailscale')
|
|
408
|
-
|
|
409
|
-
class AutomatePhoneState(PhoneState):
|
|
410
|
-
VARIABLE_STATE = 'state'
|
|
411
|
-
|
|
412
|
-
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
|
|
413
|
-
self.session = session
|
|
414
|
-
self.webhooks = webhooks
|
|
415
|
-
self.automate = automate
|
|
416
|
-
self.external_url = external_url
|
|
417
|
-
|
|
418
|
-
async def get(self, repeat: float, timeout: float):
|
|
419
|
-
logger.info("Getting Phone state...")
|
|
420
|
-
# first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
|
|
421
|
-
# though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
|
|
422
|
-
logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
|
|
423
|
-
try:
|
|
424
|
-
async with self.session.get(f'{self.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=timeout)) as response:
|
|
425
|
-
if await response.text() != 'pong':
|
|
426
|
-
raise Exception()
|
|
427
|
-
except:
|
|
428
|
-
raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
|
|
429
|
-
# get state
|
|
430
|
-
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
431
|
-
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
432
|
-
try:
|
|
433
|
-
async with asyncio.timeout(timeout):
|
|
434
|
-
while True:
|
|
435
|
-
try:
|
|
436
|
-
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
437
|
-
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
438
|
-
break
|
|
439
|
-
except TimeoutError:
|
|
440
|
-
pass
|
|
441
|
-
except TimeoutError as e:
|
|
442
|
-
e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
|
|
443
|
-
raise
|
|
444
|
-
finally:
|
|
445
|
-
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
446
|
-
logger.info("Phone state is %s", state)
|
|
447
|
-
return StateSerializer.loads(state)
|
|
448
|
-
|
|
449
|
-
########
|
|
450
|
-
|
|
451
299
|
class Cache:
|
|
452
300
|
PRIM_SYNC_APP_NAME = 'prim-sync'
|
|
453
301
|
|
|
@@ -557,19 +405,19 @@ class ZeroconfService(Manageable):
|
|
|
557
405
|
|
|
558
406
|
async def ping(self, availability_hint: bool | None = None):
|
|
559
407
|
async def _connect(connect_timeout: float, resolve_timeout: float):
|
|
560
|
-
async def
|
|
408
|
+
async def _asyncio_open_connection(host: str, port: int, timeout: float):
|
|
561
409
|
logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
|
|
562
410
|
async with asyncio.timeout(timeout):
|
|
563
411
|
return await asyncio.open_connection(host, port)
|
|
564
|
-
async def
|
|
412
|
+
async def _service_resolver_get(service_name: str, timeout: float):
|
|
565
413
|
logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
|
|
566
414
|
return await self.service_resolver.get(service_name, timeout)
|
|
567
415
|
if self.host and self.port:
|
|
568
|
-
return await
|
|
416
|
+
return await _asyncio_open_connection(self.host, self.port, connect_timeout)
|
|
569
417
|
host, port = self.service_cache.get(self.service_name)
|
|
570
418
|
if host and port:
|
|
571
419
|
try:
|
|
572
|
-
reader_writer = await
|
|
420
|
+
reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
|
|
573
421
|
self.host = host
|
|
574
422
|
self.port = port
|
|
575
423
|
return reader_writer
|
|
@@ -578,8 +426,8 @@ class ZeroconfService(Manageable):
|
|
|
578
426
|
pass
|
|
579
427
|
else:
|
|
580
428
|
raise
|
|
581
|
-
host, port = await
|
|
582
|
-
reader_writer = await
|
|
429
|
+
host, port = await _service_resolver_get(self.service_name, resolve_timeout)
|
|
430
|
+
reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
|
|
583
431
|
self.service_cache.set(self.service_name, host, port)
|
|
584
432
|
self.host = host
|
|
585
433
|
self.port = port
|
|
@@ -636,13 +484,6 @@ class RemoteTailscale(Device):
|
|
|
636
484
|
self.tailnet = tailnet
|
|
637
485
|
self.__qualname__ = "Remote Tailscale"
|
|
638
486
|
|
|
639
|
-
class Funnel:
|
|
640
|
-
LOCAL_HOST = '127.0.0.1'
|
|
641
|
-
|
|
642
|
-
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
643
|
-
self.local_port = local_port
|
|
644
|
-
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
645
|
-
|
|
646
487
|
########
|
|
647
488
|
|
|
648
489
|
class Local:
|
|
@@ -651,10 +492,12 @@ class Local:
|
|
|
651
492
|
|
|
652
493
|
class LocalTailscaleManager(Manager):
|
|
653
494
|
async def start(self):
|
|
654
|
-
await
|
|
495
|
+
if not (await Subprocess.async_tailscale(['up']))[0]:
|
|
496
|
+
raise RuntimeError("Failed to start up local Tailscale")
|
|
655
497
|
|
|
656
498
|
async def stop(self):
|
|
657
|
-
await
|
|
499
|
+
if not (await Subprocess.async_tailscale(['down']))[0]:
|
|
500
|
+
raise RuntimeError("Failed to shut down local Tailscale")
|
|
658
501
|
|
|
659
502
|
class LocalTailscale(Manageable):
|
|
660
503
|
def __init__(self):
|
|
@@ -662,12 +505,201 @@ class LocalTailscale(Manageable):
|
|
|
662
505
|
self.__qualname__ = "Local Tailscale"
|
|
663
506
|
|
|
664
507
|
async def ping(self, availability_hint: bool | None = None):
|
|
665
|
-
logger.debug("
|
|
666
|
-
|
|
508
|
+
logger.debug("Getting status of %s", LazyStr(self.get_class_name))
|
|
509
|
+
success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
510
|
+
if success:
|
|
511
|
+
status = json.loads(stdout)
|
|
512
|
+
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
667
513
|
|
|
668
514
|
async def _sleep_while_wait(self, available: bool):
|
|
669
515
|
await asyncio.sleep(0.250)
|
|
670
516
|
|
|
517
|
+
class Funnel(Pingable):
|
|
518
|
+
LOCAL_HOST = '127.0.0.1'
|
|
519
|
+
|
|
520
|
+
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
521
|
+
self.local_port = local_port
|
|
522
|
+
self.external_name = f'{machine_name}.{tailscale.tailnet}'
|
|
523
|
+
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
524
|
+
|
|
525
|
+
async def wait_for(self, available: bool, timeout: float):
|
|
526
|
+
self._sleepcounter = 0
|
|
527
|
+
await super().wait_for(available, timeout)
|
|
528
|
+
|
|
529
|
+
async def ping(self, availability_hint: bool | None = None):
|
|
530
|
+
logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
|
|
531
|
+
try:
|
|
532
|
+
# resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
|
|
533
|
+
_answer = await dns.asyncresolver.resolve_at('1.1.1.1', self.external_name)
|
|
534
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
|
535
|
+
return False
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
async def _sleep_while_wait(self, available: bool):
|
|
539
|
+
if 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
|
|
540
|
+
logger.info("Waiting for public DNS records to be updated for %s (%s)...", LazyStr(self.get_class_name), self.external_name)
|
|
541
|
+
await asyncio.sleep(10)
|
|
542
|
+
self._sleepcounter += 1
|
|
543
|
+
|
|
544
|
+
########
|
|
545
|
+
|
|
546
|
+
class Webhooks:
|
|
547
|
+
PING_PATH = 'ping'
|
|
548
|
+
VARIABLE_PATH = 'variable'
|
|
549
|
+
|
|
550
|
+
def __init__(self, host: str, port: int):
|
|
551
|
+
self.host = host
|
|
552
|
+
self.port = port
|
|
553
|
+
self.variables = dict[str, asyncio.Queue[str]]()
|
|
554
|
+
|
|
555
|
+
@staticmethod
|
|
556
|
+
def get_ping_path():
|
|
557
|
+
return f'/{Webhooks.PING_PATH}'
|
|
558
|
+
|
|
559
|
+
@staticmethod
|
|
560
|
+
def get_variable_path(variable: str):
|
|
561
|
+
return f'/{Webhooks.VARIABLE_PATH}/{variable}'
|
|
562
|
+
|
|
563
|
+
async def _start(self):
|
|
564
|
+
async def _ping(request: web.Request):
|
|
565
|
+
return web.Response(text='pong')
|
|
566
|
+
async def _receive_variable(request: web.Request):
|
|
567
|
+
queue = self.variables.get(request.match_info['name'])
|
|
568
|
+
if queue:
|
|
569
|
+
with suppress(asyncio.QueueFull):
|
|
570
|
+
queue.put_nowait(await request.text())
|
|
571
|
+
return web.Response(text='OK')
|
|
572
|
+
app = web.Application()
|
|
573
|
+
app.add_routes([
|
|
574
|
+
web.get(f'/{Webhooks.PING_PATH}', _ping),
|
|
575
|
+
web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
|
|
576
|
+
self.runner = web.AppRunner(app)
|
|
577
|
+
await self.runner.setup()
|
|
578
|
+
self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
|
|
579
|
+
await self.site.start()
|
|
580
|
+
|
|
581
|
+
async def _stop(self):
|
|
582
|
+
await self.runner.cleanup()
|
|
583
|
+
|
|
584
|
+
def subscribe_variable(self, variable: str):
|
|
585
|
+
if variable not in self.variables:
|
|
586
|
+
self.variables[variable] = asyncio.Queue[str](maxsize=16)
|
|
587
|
+
|
|
588
|
+
def unsubscribe_variable(self, variable: str):
|
|
589
|
+
self.variables.pop(variable)
|
|
590
|
+
|
|
591
|
+
async def get_variable(self, variable: str, timeout: float):
|
|
592
|
+
queue = self.variables.get(variable)
|
|
593
|
+
if not queue:
|
|
594
|
+
raise ValueError(f"The {variable} is unknown")
|
|
595
|
+
try:
|
|
596
|
+
async with asyncio.timeout(timeout):
|
|
597
|
+
return await queue.get()
|
|
598
|
+
except TimeoutError as e:
|
|
599
|
+
e.add_note(f"Can't get value of {variable} for {timeout} seconds")
|
|
600
|
+
raise
|
|
601
|
+
|
|
602
|
+
def __enter__(self):
|
|
603
|
+
raise TypeError("Use async with instead")
|
|
604
|
+
def __exit__(self, exc_type, exc_value, exc_tb):
|
|
605
|
+
pass
|
|
606
|
+
async def __aenter__(self):
|
|
607
|
+
await self._start()
|
|
608
|
+
return self
|
|
609
|
+
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
|
610
|
+
await self._stop()
|
|
611
|
+
|
|
612
|
+
class Automate:
|
|
613
|
+
def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
|
|
614
|
+
self.session = session
|
|
615
|
+
self.account = account
|
|
616
|
+
self.device = device
|
|
617
|
+
self.secret = secrets.get(tokenfile)
|
|
618
|
+
|
|
619
|
+
async def send_message(self, message: str):
|
|
620
|
+
data = {
|
|
621
|
+
"secret": self.secret,
|
|
622
|
+
"to": self.account,
|
|
623
|
+
"device": self.device,
|
|
624
|
+
"priority": "high",
|
|
625
|
+
"payload": f"prim-ctrl;{time.time()};" + message
|
|
626
|
+
}
|
|
627
|
+
logger.debug("Messaging Automate with: %s", message)
|
|
628
|
+
async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
|
|
629
|
+
await response.text()
|
|
630
|
+
|
|
631
|
+
class AutomatepFTPdManager(Manager):
|
|
632
|
+
def __init__(self, automate: Automate):
|
|
633
|
+
self.automate = automate
|
|
634
|
+
|
|
635
|
+
async def start(self):
|
|
636
|
+
await self.automate.send_message('start-pftpd')
|
|
637
|
+
|
|
638
|
+
async def stop(self):
|
|
639
|
+
await self.automate.send_message('stop-pftpd')
|
|
640
|
+
|
|
641
|
+
class AutomateTailscaleManager(Manager):
|
|
642
|
+
def __init__(self, automate: Automate):
|
|
643
|
+
self.automate = automate
|
|
644
|
+
|
|
645
|
+
async def start(self):
|
|
646
|
+
await self.automate.send_message('start-tailscale')
|
|
647
|
+
|
|
648
|
+
async def stop(self):
|
|
649
|
+
await self.automate.send_message('stop-tailscale')
|
|
650
|
+
|
|
651
|
+
class AutomatePhoneState(PhoneState):
|
|
652
|
+
VARIABLE_STATE = 'state'
|
|
653
|
+
|
|
654
|
+
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
|
|
655
|
+
self.session = session
|
|
656
|
+
self.webhooks = webhooks
|
|
657
|
+
self.automate = automate
|
|
658
|
+
self.funnel = funnel
|
|
659
|
+
|
|
660
|
+
async def get(self, repeat: float, timeout: float):
|
|
661
|
+
logger.info("Getting Phone state...")
|
|
662
|
+
|
|
663
|
+
# test funnel + webhooks availability, to not wait for a reply if funnel isn't configured properly
|
|
664
|
+
# though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
|
|
665
|
+
test_timeout = 10.0
|
|
666
|
+
logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
|
|
667
|
+
try:
|
|
668
|
+
async with self.session.get(f'{self.funnel.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=test_timeout)) as response:
|
|
669
|
+
if await response.text() != 'pong':
|
|
670
|
+
raise Exception()
|
|
671
|
+
except Exception as e:
|
|
672
|
+
raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
|
|
673
|
+
|
|
674
|
+
# test funnel's DNS resolvability, if local Tailscale is freshly started up after longer down state, it can take up to 10 minutes for public DNS records to get updated
|
|
675
|
+
test_timeout = 600.0
|
|
676
|
+
logger.debug("Testing Funnel's DNS configuration (timeout is %ds)", int(test_timeout))
|
|
677
|
+
try:
|
|
678
|
+
await self.funnel.wait_for(True, test_timeout)
|
|
679
|
+
except Exception as e:
|
|
680
|
+
raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
|
|
681
|
+
|
|
682
|
+
# get state
|
|
683
|
+
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
684
|
+
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
685
|
+
try:
|
|
686
|
+
async with asyncio.timeout(timeout):
|
|
687
|
+
while True:
|
|
688
|
+
try:
|
|
689
|
+
await self.automate.send_message(f'get-state;{self.funnel.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
690
|
+
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
691
|
+
break
|
|
692
|
+
except TimeoutError:
|
|
693
|
+
pass
|
|
694
|
+
except TimeoutError as e:
|
|
695
|
+
e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
|
|
696
|
+
raise
|
|
697
|
+
finally:
|
|
698
|
+
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
699
|
+
|
|
700
|
+
logger.info("Phone state is %s", state)
|
|
701
|
+
return StateSerializer.loads(state)
|
|
702
|
+
|
|
671
703
|
########
|
|
672
704
|
|
|
673
705
|
class WideHelpFormatter(argparse.RawTextHelpFormatter):
|
|
@@ -924,7 +956,7 @@ class AutomateControl(Control):
|
|
|
924
956
|
|
|
925
957
|
async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
|
|
926
958
|
local = Local(local_tailscale)
|
|
927
|
-
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel
|
|
959
|
+
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
|
|
928
960
|
phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
|
|
929
961
|
await self.execute(args, local, phone)
|
|
930
962
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "prim-ctrl"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.2"
|
|
4
4
|
description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
|
|
5
5
|
license = "Apache-2.0"
|
|
6
6
|
authors = ["Laszlo Magyar <lmagyar1973@gmail.com>"]
|
|
@@ -18,9 +18,10 @@ packages = [{include = "prim_ctrl"}]
|
|
|
18
18
|
|
|
19
19
|
[tool.poetry.dependencies]
|
|
20
20
|
python = "^3.12"
|
|
21
|
-
aiohttp = "^3.11.
|
|
21
|
+
aiohttp = "^3.11.10"
|
|
22
22
|
platformdirs = "^4.3.6"
|
|
23
|
-
zeroconf = "^0.136.
|
|
23
|
+
zeroconf = "^0.136.2"
|
|
24
|
+
dnspython = "^2.7.0"
|
|
24
25
|
|
|
25
26
|
[tool.poetry.scripts]
|
|
26
27
|
prim-ctrl = "prim_ctrl.__main__:run"
|
|
File without changes
|
|
File without changes
|