prim-ctrl 0.6.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.6.0
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.0,<4.0.0)
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.0,<0.137.0)
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
 
@@ -62,6 +64,15 @@ class Logger(logging.Logger):
62
64
  if self.level == logging.NOTSET:
63
65
  self.setLevel(logging.WARNING if silent else logging.INFO)
64
66
 
67
+ def exception_or_error(self, e: Exception, args):
68
+ if not args or args.debug:
69
+ logger.exception(e)
70
+ else:
71
+ if hasattr(e, '__notes__'):
72
+ logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
73
+ else:
74
+ logger.error(LazyStr(repr, e))
75
+
65
76
  def error(self, msg, *args, **kwargs):
66
77
  self.exitcode = 1
67
78
  super().error(msg, *args, **kwargs)
@@ -90,50 +101,43 @@ logger = Logger(Path(sys.argv[0]).name)
90
101
 
91
102
  ########
92
103
 
93
- # based on https://stackoverflow.com/a/55656177/2755656
94
- def sync_ping(host, packets: int = 1, timeout: float = 1):
95
- if platform.system().lower() == 'windows':
96
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
97
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
98
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
99
- return result.returncode == 0 and b'TTL=' in result.stdout
100
- else:
101
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
102
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
103
- return result.returncode == 0
104
-
105
- async def async_ping(host, packets: int = 1, timeout: float = 1):
106
- if platform.system().lower() == 'windows':
107
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
108
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
109
- proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
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)
110
139
  stdout, _stderr = await proc.communicate()
111
- return proc.returncode == 0 and b'TTL=' in stdout
112
- else:
113
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
114
- proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
115
- _stdout, _stderr = await proc.communicate()
116
- return proc.returncode == 0
117
-
118
- async def async_tailscale(args: list[str]):
119
- command = ['tailscale']
120
- command.extend(args)
121
- creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
122
- proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
123
- stdout, _stderr = await proc.communicate()
124
- return proc.returncode == 0, stdout
125
-
126
- async def async_tailscale_up():
127
- return (await async_tailscale(['up']))[0]
128
-
129
- async def async_tailscale_down():
130
- return (await async_tailscale(['down']))[0]
131
-
132
- async def async_tailscale_is_online():
133
- success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
134
- if success:
135
- status = json.loads(stdout)
136
- return success and status['BackendState'] == 'Running' and status['Self']['Online']
140
+ return proc.returncode == 0, stdout
137
141
 
138
142
  ########
139
143
 
@@ -171,8 +175,11 @@ class Pingable:
171
175
  logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
172
176
  async with asyncio.timeout(timeout):
173
177
  while await self.ping(available) != available:
174
- if not available:
175
- await asyncio.sleep(1)
178
+ await self._sleep_while_wait(available)
179
+
180
+ async def _sleep_while_wait(self, available: bool):
181
+ if not available:
182
+ await asyncio.sleep(1)
176
183
 
177
184
  class Manager:
178
185
  @abstractmethod
@@ -253,7 +260,7 @@ class Device(Manageable):
253
260
 
254
261
  async def ping(self, availability_hint: bool | None = None):
255
262
  logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
256
- return await async_ping(self.host, timeout=2)
263
+ return await Subprocess.async_ping(self.host, timeout=2)
257
264
 
258
265
  class StateSerializer:
259
266
  BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
@@ -289,153 +296,6 @@ class PhoneState:
289
296
 
290
297
  ########
291
298
 
292
- class Webhooks:
293
- PING_PATH = 'ping'
294
- VARIABLE_PATH = 'variable'
295
-
296
- def __init__(self, host: str, port: int):
297
- self.host = host
298
- self.port = port
299
- self.variables = dict[str, asyncio.Queue[str]]()
300
-
301
- @staticmethod
302
- def get_ping_path():
303
- return f'/{Webhooks.PING_PATH}'
304
-
305
- @staticmethod
306
- def get_variable_path(variable: str):
307
- return f'/{Webhooks.VARIABLE_PATH}/{variable}'
308
-
309
- async def _start(self):
310
- async def _ping(request: web.Request):
311
- return web.Response(text='pong')
312
- async def _receive_variable(request: web.Request):
313
- queue = self.variables.get(request.match_info['name'])
314
- if queue:
315
- with suppress(asyncio.QueueFull):
316
- queue.put_nowait(await request.text())
317
- return web.Response(text='OK')
318
- app = web.Application()
319
- app.add_routes([
320
- web.get(f'/{Webhooks.PING_PATH}', _ping),
321
- web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
322
- self.runner = web.AppRunner(app)
323
- await self.runner.setup()
324
- self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
325
- await self.site.start()
326
-
327
- async def _stop(self):
328
- await self.runner.cleanup()
329
-
330
- def subscribe_variable(self, variable: str):
331
- if variable not in self.variables:
332
- self.variables[variable] = asyncio.Queue[str](maxsize=16)
333
-
334
- def unsubscribe_variable(self, variable: str):
335
- self.variables.pop(variable)
336
-
337
- async def get_variable(self, variable: str, timeout: float):
338
- queue = self.variables.get(variable)
339
- if not queue:
340
- raise ValueError(f"The {variable} is unknown")
341
- try:
342
- async with asyncio.timeout(timeout):
343
- return await queue.get()
344
- except TimeoutError as e:
345
- e.add_note(f"Can't get value of {variable} for {timeout} seconds")
346
- raise
347
-
348
- def __enter__(self):
349
- raise TypeError("Use async with instead")
350
- def __exit__(self, exc_type, exc_value, exc_tb):
351
- pass
352
- async def __aenter__(self):
353
- await self._start()
354
- return self
355
- async def __aexit__(self, exc_type, exc_value, exc_tb):
356
- await self._stop()
357
-
358
- class Automate:
359
- def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
360
- self.session = session
361
- self.account = account
362
- self.device = device
363
- self.secret = secrets.get(tokenfile)
364
-
365
- async def send_message(self, message: str):
366
- data = {
367
- "secret": self.secret,
368
- "to": self.account,
369
- "device": self.device,
370
- "priority": "high",
371
- "payload": f"prim-ctrl;{time.time()};" + message
372
- }
373
- logger.debug("Messaging Automate with: %s", message)
374
- async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
375
- await response.text()
376
-
377
- class AutomatepFTPdManager(Manager):
378
- def __init__(self, automate: Automate):
379
- self.automate = automate
380
-
381
- async def start(self):
382
- await self.automate.send_message('start-pftpd')
383
-
384
- async def stop(self):
385
- await self.automate.send_message('stop-pftpd')
386
-
387
- class AutomateTailscaleManager(Manager):
388
- def __init__(self, automate: Automate):
389
- self.automate = automate
390
-
391
- async def start(self):
392
- await self.automate.send_message('start-tailscale')
393
-
394
- async def stop(self):
395
- await self.automate.send_message('stop-tailscale')
396
-
397
- class AutomatePhoneState(PhoneState):
398
- VARIABLE_STATE = 'state'
399
-
400
- def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
401
- self.session = session
402
- self.webhooks = webhooks
403
- self.automate = automate
404
- self.external_url = external_url
405
-
406
- async def get(self, repeat: float, timeout: float):
407
- logger.info("Getting Phone state...")
408
- # first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
409
- # 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
410
- logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
411
- try:
412
- async with self.session.get(f'{self.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=timeout)) as response:
413
- if await response.text() != 'pong':
414
- raise Exception()
415
- except:
416
- raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
417
- # get state
418
- logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
419
- self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
420
- try:
421
- async with asyncio.timeout(timeout):
422
- while True:
423
- try:
424
- await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
425
- state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
426
- break
427
- except TimeoutError:
428
- pass
429
- except TimeoutError as e:
430
- 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")
431
- raise
432
- finally:
433
- self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
434
- logger.info("Phone state is %s", state)
435
- return StateSerializer.loads(state)
436
-
437
- ########
438
-
439
299
  class Cache:
440
300
  PRIM_SYNC_APP_NAME = 'prim-sync'
441
301
 
@@ -545,19 +405,19 @@ class ZeroconfService(Manageable):
545
405
 
546
406
  async def ping(self, availability_hint: bool | None = None):
547
407
  async def _connect(connect_timeout: float, resolve_timeout: float):
548
- async def asyncio_open_connection(host: str, port: int, timeout: float):
408
+ async def _asyncio_open_connection(host: str, port: int, timeout: float):
549
409
  logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
550
410
  async with asyncio.timeout(timeout):
551
411
  return await asyncio.open_connection(host, port)
552
- async def service_resolver_get(service_name: str, timeout: float):
412
+ async def _service_resolver_get(service_name: str, timeout: float):
553
413
  logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
554
414
  return await self.service_resolver.get(service_name, timeout)
555
415
  if self.host and self.port:
556
- return await asyncio_open_connection(self.host, self.port, connect_timeout)
416
+ return await _asyncio_open_connection(self.host, self.port, connect_timeout)
557
417
  host, port = self.service_cache.get(self.service_name)
558
418
  if host and port:
559
419
  try:
560
- reader_writer = await asyncio_open_connection(host, port, connect_timeout)
420
+ reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
561
421
  self.host = host
562
422
  self.port = port
563
423
  return reader_writer
@@ -566,8 +426,8 @@ class ZeroconfService(Manageable):
566
426
  pass
567
427
  else:
568
428
  raise
569
- host, port = await service_resolver_get(self.service_name, resolve_timeout)
570
- reader_writer = await asyncio_open_connection(host, port, connect_timeout)
429
+ host, port = await _service_resolver_get(self.service_name, resolve_timeout)
430
+ reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
571
431
  self.service_cache.set(self.service_name, host, port)
572
432
  self.host = host
573
433
  self.port = port
@@ -624,13 +484,6 @@ class RemoteTailscale(Device):
624
484
  self.tailnet = tailnet
625
485
  self.__qualname__ = "Remote Tailscale"
626
486
 
627
- class Funnel:
628
- LOCAL_HOST = '127.0.0.1'
629
-
630
- def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
631
- self.local_port = local_port
632
- self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
633
-
634
487
  ########
635
488
 
636
489
  class Local:
@@ -639,10 +492,12 @@ class Local:
639
492
 
640
493
  class LocalTailscaleManager(Manager):
641
494
  async def start(self):
642
- await async_tailscale_up()
495
+ if not (await Subprocess.async_tailscale(['up']))[0]:
496
+ raise RuntimeError("Failed to start up local Tailscale")
643
497
 
644
498
  async def stop(self):
645
- await async_tailscale_down()
499
+ if not (await Subprocess.async_tailscale(['down']))[0]:
500
+ raise RuntimeError("Failed to shut down local Tailscale")
646
501
 
647
502
  class LocalTailscale(Manageable):
648
503
  def __init__(self):
@@ -650,8 +505,200 @@ class LocalTailscale(Manageable):
650
505
  self.__qualname__ = "Local Tailscale"
651
506
 
652
507
  async def ping(self, availability_hint: bool | None = None):
653
- logger.debug("Pinging %s", LazyStr(self.get_class_name))
654
- return await async_tailscale_is_online()
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']
513
+
514
+ async def _sleep_while_wait(self, available: bool):
515
+ await asyncio.sleep(0.250)
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)
655
702
 
656
703
  ########
657
704
 
@@ -715,18 +762,30 @@ class Control:
715
762
  raise ValueError("The --restore-state option can be enabled only for the stop intent")
716
763
 
717
764
  async def execute(self, args, local: Local, phone: Phone):
718
- async def _stop(restore_state: dict | None):
765
+ async def _stop(restore_state: dict | None, stop_only_started: bool = False):
719
766
  if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
720
- if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)) and await phone.remote_sftp.test():
721
- await phone.remote_sftp.stop(10, 30)
722
- if restore_state is None or not restore_state.get(Control.PHONE_VPN, False):
723
- await phone.vpn.stop(10, 60)
767
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await phone.remote_sftp.test():
768
+ try:
769
+ await phone.remote_sftp.stop(10, 30)
770
+ except Exception as e:
771
+ logger.exception_or_error(e, args)
772
+ if restore_state is None or not restore_state.get(Control.PHONE_VPN, stop_only_started):
773
+ try:
774
+ await phone.vpn.stop(10, 60)
775
+ except Exception as e:
776
+ logger.exception_or_error(e, args)
724
777
  else:
725
- if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)):
726
- await phone.zeroconf_sftp.stop(10, 30)
778
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)):
779
+ try:
780
+ await phone.zeroconf_sftp.stop(10, 30)
781
+ except Exception as e:
782
+ logger.exception_or_error(e, args)
727
783
  if local.vpn:
728
- if restore_state is None or not restore_state.get(Control.LOCAL_VPN, False):
729
- await local.vpn.stop(10, 30)
784
+ if restore_state is None or not restore_state.get(Control.LOCAL_VPN, stop_only_started):
785
+ try:
786
+ await local.vpn.stop(10, 30)
787
+ except Exception as e:
788
+ logger.exception_or_error(e, args)
730
789
  match args.intent:
731
790
  case 'test':
732
791
  if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
@@ -739,29 +798,31 @@ class Control:
739
798
  case 'start':
740
799
  if local.vpn and phone.vpn and phone.remote_sftp:
741
800
  state = dict()
742
- zeroconf_accessible = False
743
- remote_accessible = False
744
-
745
- # gather local state info
746
- local_vpn_state = await local.vpn.test()
747
- state[Control.LOCAL_VPN] = local_vpn_state
748
- # start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
749
- await local.vpn.start(10, 30)
750
-
751
- # gather remote state info
752
- if phone.state:
753
- phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
754
- state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
755
- state[Control.PHONE_VPN] = phone_vpn_state
756
- state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
757
- if not state[Control.PHONE_WIFI] and not args.accept_cellular:
758
- raise RuntimeError(f"Phone is not on Wi-Fi network")
759
- else:
760
- state[Control.PHONE_VPN] = await phone.vpn.test()
761
- if state[Control.PHONE_VPN]:
762
- state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
763
- # start changing remote state
764
801
  try:
802
+ # gather local state info
803
+ local_vpn_state = await local.vpn.test()
804
+ state[Control.LOCAL_VPN] = local_vpn_state
805
+
806
+ # start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
807
+ await local.vpn.start(10, 30)
808
+
809
+ zeroconf_accessible = False
810
+ remote_accessible = False
811
+
812
+ # gather phone state info
813
+ if phone.state:
814
+ phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
815
+ state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
816
+ state[Control.PHONE_VPN] = phone_vpn_state
817
+ state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
818
+ if not state[Control.PHONE_WIFI] and not args.accept_cellular:
819
+ raise RuntimeError(f"Phone is not on Wi-Fi network")
820
+ else:
821
+ state[Control.PHONE_VPN] = await phone.vpn.test()
822
+ if state[Control.PHONE_VPN]:
823
+ state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
824
+
825
+ # start changing phone state
765
826
  if phone.state:
766
827
  if not state[Control.PHONE_SFTP]:
767
828
  if not state[Control.PHONE_VPN]:
@@ -799,20 +860,22 @@ class Control:
799
860
  zeroconf_accessible = await phone.zeroconf_sftp.test()
800
861
  if not zeroconf_accessible and not remote_accessible:
801
862
  raise RuntimeError(f"Even when {phone.vpn.get_class_name()} and {phone.remote_sftp.get_class_name()} is started, {phone.remote_sftp.get_class_name()} is still not accessible")
863
+
864
+ # print out result on stdout
865
+ if not args.backup_state:
866
+ state = dict()
867
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
868
+ print(StateSerializer.dumps(state))
802
869
  except:
803
- await _stop(state)
870
+ await _stop(state, stop_only_started = True)
804
871
  raise
805
- if not args.backup_state:
806
- state = dict()
807
- state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
808
- print(StateSerializer.dumps(state))
809
872
  else:
810
- if not await phone.zeroconf_sftp.test():
811
- try:
873
+ try:
874
+ if not await phone.zeroconf_sftp.test():
812
875
  await phone.zeroconf_sftp.start(10, 30)
813
- except:
814
- await _stop(None)
815
- raise
876
+ except:
877
+ await _stop(None)
878
+ raise
816
879
  case 'stop':
817
880
  await _stop(StateSerializer.loads(args.restore_state) if args.restore_state else None)
818
881
 
@@ -893,7 +956,7 @@ class AutomateControl(Control):
893
956
 
894
957
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
895
958
  local = Local(local_tailscale)
896
- automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
959
+ automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
897
960
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
898
961
  await self.execute(args, local, phone)
899
962
 
@@ -912,13 +975,7 @@ async def main():
912
975
  await args.ctor().run(args)
913
976
 
914
977
  except Exception as e:
915
- if not args or args.debug:
916
- logger.exception(e)
917
- else:
918
- if hasattr(e, '__notes__'):
919
- logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
920
- else:
921
- logger.error(LazyStr(repr, e))
978
+ logger.exception_or_error(e, args)
922
979
 
923
980
  return logger.exitcode
924
981
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.6.0"
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.0"
21
+ aiohttp = "^3.11.10"
22
22
  platformdirs = "^4.3.6"
23
- zeroconf = "^0.136.0"
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