prim-ctrl 0.8.1__tar.gz → 0.8.3__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.4
2
2
  Name: prim-ctrl
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -166,8 +166,8 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
166
166
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
167
167
 
168
168
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
169
- Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
170
- Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
169
+ Note: optionally if your phone is not reachable on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
170
+ Note: optionally if your laptop is reachable through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
171
171
 
172
172
  Output: 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
173
173
 
@@ -145,8 +145,8 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
145
145
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
146
146
 
147
147
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
148
- Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
149
- Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
148
+ Note: optionally if your phone is not reachable on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
149
+ Note: optionally if your laptop is reachable through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
150
150
 
151
151
  Output: 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
152
152
 
@@ -37,7 +37,7 @@ class LevelFormatter(logging.Formatter):
37
37
 
38
38
  def __init__(self, fmts: dict[int, str], fmt: str, **kwargs):
39
39
  super().__init__()
40
- self.formatters = dict({level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()})
40
+ self.formatters = {level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()}
41
41
  self.default_formatter = logging.Formatter(fmt, **kwargs)
42
42
 
43
43
  def format(self, record: logging.LogRecord) -> str:
@@ -75,10 +75,7 @@ class Logger(logging.Logger):
75
75
  if self.level == logging.NOTSET or self.level == logging.DEBUG:
76
76
  logger.exception(e)
77
77
  else:
78
- if hasattr(e, '__notes__'):
79
- logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
80
- else:
81
- logger.error(LazyStr(repr, e))
78
+ logger.error(LazyStr(exception_repr, e))
82
79
 
83
80
  def error(self, msg, *args, **kwargs):
84
81
  self.exitcode = 1
@@ -95,6 +92,9 @@ class Logger(logging.Logger):
95
92
  self.exitcode = 1
96
93
  super().log(level, msg, *args, **kwargs)
97
94
 
95
+ def exception_repr(e: BaseException) -> str:
96
+ return f"{repr(e)}: {", ".join(e.__notes__)}" if hasattr(e, '__notes__') else repr(e)
97
+
98
98
  class LazyStr:
99
99
  def __init__(self, func, *args, **kwargs):
100
100
  self.func = func
@@ -132,7 +132,7 @@ class SignalFence():
132
132
  if self.on_deferred_signal is not None:
133
133
  try:
134
134
  self.on_deferred_signal(signum, frame)
135
- except:
135
+ except: # NOSONAR(S5754)
136
136
  pass
137
137
 
138
138
  def disable(self) -> None:
@@ -151,11 +151,11 @@ class SignalFence():
151
151
  self.deferred_signal = None
152
152
  logger.debug("Handling deferred signal %d", self.signum)
153
153
  if isinstance(self.original_handler, signal.Handlers):
154
- if self.original_handler is signal.Handlers.SIG_IGN:
155
- pass
156
- elif self.original_handler is signal.Handlers.SIG_DFL:
154
+ if self.original_handler == signal.Handlers.SIG_DFL:
157
155
  signal.signal(self.signum, signal.SIG_DFL)
158
156
  os.kill(os.getpid(), self.signum)
157
+ # elif self.original_handler == signal.Handlers.SIG_IGN:
158
+ # pass
159
159
  elif callable(self.original_handler):
160
160
  self.original_handler(*deferred_signal)
161
161
 
@@ -193,7 +193,7 @@ class Subprocess:
193
193
  # return result.returncode == 0
194
194
 
195
195
  @staticmethod
196
- async def ping(host, packets: int = 1, timeout: float = 1):
196
+ async def ping(host, packets: int = 1, timeout: float = 1): # NOSONAR(S7483)
197
197
  if platform.system().lower() == 'windows':
198
198
  command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
199
199
  # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
@@ -214,7 +214,7 @@ class Subprocess:
214
214
  try:
215
215
  proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=creationflags)
216
216
  except FileNotFoundError as e:
217
- e.add_note(f"Please check that Tailscale is installed properly")
217
+ e.add_note("Please check that Tailscale is installed properly")
218
218
  raise
219
219
  stdout, stderr = await proc.communicate()
220
220
  return proc.returncode == 0, stdout.decode(), stderr.decode()
@@ -228,7 +228,7 @@ class ExternalDnsResolver(DnsResolver):
228
228
  def __init__(self, where: str):
229
229
  self.where = where
230
230
  self.dns_resolver = None
231
- self.cache = dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]]()
231
+ self.cache: dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]] = {}
232
232
 
233
233
  async def resolve(self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_UNSPEC) -> list[ResolveResult]:
234
234
  logger.debug("Resolving DNS at %s for %s:%i (%s)", self.where, host, port, "ipv6" if family == socket.AF_INET6 else "ipv4")
@@ -250,8 +250,6 @@ class ExternalDnsResolver(DnsResolver):
250
250
  except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
251
251
  msg = '; '.join(e.args) if len(e.args) else "DNS lookup failed"
252
252
  exc = LookupError(msg)
253
- # this is captured in a TaskGroup that drops traceback information from "from e"
254
- exc.add_note(repr(e))
255
253
  raise exc from None
256
254
 
257
255
  hosts = []
@@ -288,11 +286,14 @@ class Pingable(ABC):
288
286
  return self.__qualname__ if hasattr(self, '__qualname__') else self.__class__.__qualname__.rsplit('.', maxsplit=1)[0]
289
287
 
290
288
  @staticmethod
291
- def get_state_name(available: bool):
289
+ def _get_state_name(available: bool):
292
290
  return 'up' if available else 'down'
293
291
 
294
- async def wait_for(self, available: bool, timeout: float):
295
- logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
292
+ def get_state_name(self, available: bool):
293
+ return Pingable._get_state_name(available)
294
+
295
+ async def wait_for(self, available: bool, timeout: float): # NOSONAR(S7483)
296
+ logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(self.get_state_name, available), int(timeout))
296
297
  async with asyncio.timeout(timeout):
297
298
  while await self.ping(available) != available:
298
299
  await self._sleep_while_wait(available)
@@ -315,10 +316,10 @@ class Manageable(Pingable):
315
316
  super().__init__()
316
317
  self.manager = manager
317
318
 
318
- async def _set_state(self, available: bool, repeat: float, timeout: float):
319
+ async def _set_state(self, available: bool, repeat: float, timeout: float): # NOSONAR(S7483)
319
320
  action_name = LazyStr(lambda: 'Starting' if available else 'Stopping')
320
321
  class_name = LazyStr(self.get_class_name)
321
- available_name = LazyStr(Pingable.get_state_name, available)
322
+ available_name = LazyStr(self.get_state_name, available)
322
323
  logger.info("%s %s...", action_name, class_name)
323
324
  logger.debug("%s %s (repeat after %ds, timeout is %ds)", action_name, class_name, int(repeat), int(timeout))
324
325
  try:
@@ -336,18 +337,18 @@ class Manageable(Pingable):
336
337
  except TimeoutError as e:
337
338
  e.add_note(f"Can't get {class_name} {available_name} for {timeout} seconds")
338
339
  raise
339
- logger.info(" %s is %s", class_name, available_name)
340
+ logger.info("...%s is %s", class_name, available_name)
340
341
  return available
341
342
 
342
343
  async def test(self):
343
344
  available = await self.ping()
344
- logger.info("%s is %s", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available))
345
+ logger.info("%s is %s", LazyStr(self.get_class_name), LazyStr(self.get_state_name, available))
345
346
  return available
346
347
 
347
- async def start(self, repeat: float, timeout: float):
348
+ async def start(self, repeat: float, timeout: float): # NOSONAR(S7483)
348
349
  return await self._set_state(True, repeat, timeout)
349
350
 
350
- async def stop(self, repeat: float, timeout: float):
351
+ async def stop(self, repeat: float, timeout: float): # NOSONAR(S7483)
351
352
  return await self._set_state(False, repeat, timeout)
352
353
 
353
354
  class Service(Manageable):
@@ -367,11 +368,11 @@ class Service(Manageable):
367
368
  writer.close()
368
369
  await writer.wait_closed()
369
370
 
370
- async def _ping(self, availability_hint: bool | None = None):
371
+ async def _ping(self, availability_hint: bool | None = None): # NOSONAR(S1172)
371
372
  logger.debug("Pinging %s (%s:%s)", LazyStr(self.get_class_name), str(self.host), str(self.port))
372
373
  await self._connect(self.host, self.port)
373
374
 
374
- async def ping(self, availability_hint: bool | None = None):
375
+ async def ping(self, availability_hint: bool | None = None): # NOSONAR(S3776)
375
376
  try:
376
377
  await self._ping(availability_hint)
377
378
  return True
@@ -411,6 +412,9 @@ class SshService(Service):
411
412
  return True
412
413
  self._special_exceptions_handler = _handle_special_exceptions
413
414
 
415
+ def get_state_name(self, available: bool):
416
+ return 'reachable' if available else 'unreachable'
417
+
414
418
  async def _connect(self, host: str, port: int):
415
419
  logger.debug(" Connecting with SSH to %s:%d (timeout is %ds)", host, port, self._connect_timeout)
416
420
  def _client_key():
@@ -426,9 +430,9 @@ class SshService(Service):
426
430
  asyncssh.connect(host, port, options=asyncssh.SSHClientConnectionOptions(
427
431
  host_key_alias=self.host_name,
428
432
  client_keys=_client_key(),
429
- connect_timeout=self._connect_timeout)) as conn
433
+ connect_timeout=self._connect_timeout))
430
434
  ):
431
- pass
435
+ return
432
436
 
433
437
  class Device(Manageable):
434
438
  def __init__(self, host: str, manager: Manager):
@@ -440,7 +444,7 @@ class Device(Manageable):
440
444
  return await Subprocess.ping(self.host, timeout=2)
441
445
 
442
446
  class StateSerializer:
443
- BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
447
+ BOOL = {False: Pingable._get_state_name(False), True: Pingable._get_state_name(True)}
444
448
  INV_BOOL = {v: k for k, v in BOOL.items()}
445
449
 
446
450
  @staticmethod
@@ -458,7 +462,7 @@ class StateSerializer:
458
462
  @staticmethod
459
463
  def loads(s: str):
460
464
  try:
461
- return dict({k: StateSerializer.load_value(v) for k, v in [s.split('=') for s in s.split(',')]})
465
+ return {k: StateSerializer.load_value(v) for k, v in [s.split('=') for s in s.split(',')]}
462
466
  except ValueError as e:
463
467
  e.add_note("Missing '=' in state")
464
468
  raise
@@ -468,7 +472,7 @@ class PhoneState(ABC):
468
472
  PFTPD = 'pftpd'
469
473
 
470
474
  @abstractmethod
471
- async def get(self, repeat: float, timeout: float) -> dict:
475
+ async def get(self, repeat: float, timeout: float) -> dict: # NOSONAR(S7483)
472
476
  pass
473
477
 
474
478
  ########
@@ -530,7 +534,7 @@ class ServiceResolver:
530
534
  self.zeroconf = zeroconf
531
535
  self.service_type = service_type
532
536
 
533
- async def get(self, service_name: str, timeout: float = 3):
537
+ async def get(self, service_name: str, timeout: float = 3): # NOSONAR(S7483)
534
538
  service_info = await self.zeroconf.async_get_service_info(self.service_type, f"{service_name}.{self.service_type}", timeout=int(timeout*1000))
535
539
  if not service_info or not service_info.port:
536
540
  raise TimeoutError("Unable to resolve zeroconf (DNS-SD) service information")
@@ -618,7 +622,7 @@ class ZeroconfService(Service):
618
622
  return
619
623
  except (TimeoutError, socket.gaierror, ConnectionRefusedError) + self._special_exceptions as e:
620
624
  if availability_hint is None or availability_hint:
621
- logger.debug(" %s", LazyStr(repr, e))
625
+ logger.debug(" %s", LazyStr(exception_repr, e))
622
626
  else:
623
627
  raise
624
628
  host, port = await self._resolve()
@@ -647,6 +651,7 @@ class PftpdServiceListener(ServiceListener):
647
651
  logger.debug(" (ServiceListener) Resolved %s to %s:%d", service_name, host, port)
648
652
 
649
653
  def del_service(self, service_name: str):
654
+ # we intentionally do not remove anything from the cache
650
655
  pass
651
656
 
652
657
  class RemotePftpd(SshService):
@@ -654,11 +659,17 @@ class RemotePftpd(SshService):
654
659
  super().__init__(host, port, host_name, keyfile, manager)
655
660
  self.__qualname__ = "pFTPd"
656
661
 
662
+ def get_state_name(self, available: bool):
663
+ return super().get_state_name(available) + ' remotely'
664
+
657
665
  class ZeroconfPftpd(ZeroconfSshService):
658
666
  def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
659
667
  super().__init__(service_name, service_cache, service_resolver, keyfile, manager)
660
668
  self.__qualname__ = "pFTPd"
661
669
 
670
+ def get_state_name(self, available: bool):
671
+ return super().get_state_name(available) + ' locally'
672
+
662
673
  ########
663
674
 
664
675
  class SecretsTokenStorage(TokenStorage):
@@ -690,14 +701,21 @@ class Tailscale():
690
701
  self.tailscale_api = TailscaleApi(session=session, request_timeout=30, tailnet=tailnet,
691
702
  oauth_client_id=client_id, oauth_client_secret=client_secret, token_storage=SecretsTokenStorage(secrets, secretfile))
692
703
 
693
- async def device(self, machine_name: str) -> TailscaleDeviceInfo:
694
- logger.debug("Calling Tailscale API for devices")
695
- devices = await self.tailscale_api.devices()
696
- name = f"{machine_name}.{self.tailnet}"
697
- for device in devices.values():
698
- if device.name == name:
699
- return device
700
- raise RuntimeError(f"Device {machine_name} in {self.tailnet} is unknown by Tailscale")
704
+ self._devices: dict[str, TailscaleDeviceInfo] | None = None
705
+
706
+ async def devices(self, use_cache: bool = True) -> dict[str, TailscaleDeviceInfo]:
707
+ if self._devices is not None and use_cache:
708
+ logger.debug("Using cached values instead of calling Tailscale API for devices")
709
+ else:
710
+ logger.debug("Calling Tailscale API for devices")
711
+ self._devices = {device.name: device for device in (await self.tailscale_api.devices()).values()}
712
+ return self._devices
713
+
714
+ async def device(self, machine_name: str, use_cache: bool = True) -> TailscaleDeviceInfo:
715
+ device = (await self.devices(use_cache)).get(f"{machine_name}.{self.tailnet}", None)
716
+ if device is None:
717
+ raise RuntimeError(f"Device {machine_name} in {self.tailnet} is unknown by Tailscale")
718
+ return device
701
719
 
702
720
  class Funnel(Pingable):
703
721
  LOCAL_HOST = '127.0.0.1'
@@ -710,7 +728,7 @@ class Funnel(Pingable):
710
728
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
711
729
  self.local_tailscale = local_tailscale
712
730
  self.external_public_dns_resolver = None
713
- self.external_tailscale_dns_resolver = None
731
+ self.external_tailscale_dns_resolvers = None
714
732
 
715
733
  async def wait_for(self, available: bool, timeout: float):
716
734
  self._sleepcounter = 0
@@ -718,13 +736,17 @@ class Funnel(Pingable):
718
736
 
719
737
  async def ping(self, availability_hint: bool | None = None):
720
738
  logger.debug("Resolving DNS for %s (%s:%s)", LazyStr(self.get_class_name), self.external_name, self.external_port)
721
- # first try at Tailscale's DNS, if it doesn't know, we should not resolve at a public DNS and cache nxdomain for 5 minutes
722
- if self.external_tailscale_dns_resolver is None:
723
- self.external_tailscale_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_tailscale_dns_resolver())
739
+ # first try at Tailscale's DNSs, if they don't know, we should not resolve at a public DNS and cache nxdomain for 5 minutes
740
+ if self.external_tailscale_dns_resolvers is None:
741
+ resolvers = [await self.local_tailscale.external_tailscale_dns_resolver()]
742
+ resolvers.extend(await self._external_tailscale_dns_resolvers())
743
+ self.external_tailscale_dns_resolvers = [ExternalDnsResolver(resolver) for resolver in resolvers]
724
744
  try:
725
- _answer = await self.external_tailscale_dns_resolver.resolve(self.external_name, self.external_port)
745
+ _answer = await self.external_tailscale_dns_resolvers[0].resolve(self.external_name, self.external_port)
726
746
  except Exception as e:
727
- logger.debug("Resolving at Tailscale's external DNS has failed: %s", LazyStr(repr, e))
747
+ logger.debug("Resolving at Tailscale's external DNS %s has failed: %s", self.external_tailscale_dns_resolvers[0].where, LazyStr(exception_repr, e))
748
+ # move current resolver to the end of the list
749
+ self.external_tailscale_dns_resolvers.append(self.external_tailscale_dns_resolvers.pop(0))
728
750
  return False
729
751
  # then try at a public DNS
730
752
  if self.external_public_dns_resolver is None:
@@ -732,12 +754,12 @@ class Funnel(Pingable):
732
754
  try:
733
755
  _answer = await self.external_public_dns_resolver.resolve(self.external_name, self.external_port)
734
756
  except Exception as e:
735
- logger.debug("Resolving at public external DNS has failed: %s", LazyStr(repr, e))
757
+ logger.debug("Resolving at public external DNS has failed: %s", LazyStr(exception_repr, e))
736
758
  return False
737
759
  return True
738
760
 
739
761
  async def _sleep_while_wait(self, available: bool):
740
- if self.local_tailscale.is_started_now and 0 != self._sleepcounter and 0 == self._sleepcounter % 3:
762
+ if self.local_tailscale.is_started_now and 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
741
763
  logger.info("Restarting %s to retrigger public DNS records' configuration at Tailscale...", LazyStr(self.local_tailscale.get_class_name))
742
764
  await self.local_tailscale.stop(10, 30)
743
765
  await self.local_tailscale.start(10, 30)
@@ -746,6 +768,10 @@ class Funnel(Pingable):
746
768
  await asyncio.sleep(10)
747
769
  self._sleepcounter += 1
748
770
 
771
+ async def _external_tailscale_dns_resolvers(self):
772
+ logger.debug("Getting external Tailscale DNS resolvers for ts.net")
773
+ return [str(rr.to_text()) for rr in await dns.asyncresolver.Resolver().resolve("ts.net", rdtype=dns.rdatatype.NS)]
774
+
749
775
  class LocalTailscaleManager(Manager):
750
776
  async def start(self):
751
777
  success, _, stderr = await Subprocess.tailscale(['up'])
@@ -793,11 +819,13 @@ class LocalTailscale(Manageable):
793
819
  self._checked_fresh_start = True
794
820
  max_last_seen_age = 7200
795
821
  wait_on_fresh_start = 5
822
+ # if we started up now, then connected_to_control was False, use last_seen only
823
+ # additionally connected_to_control state changes are delayed, it can be True if it was stopped recently
796
824
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
797
825
  difference_sec = difference.total_seconds() if difference else None
798
826
  if difference_sec is None or difference_sec > max_last_seen_age:
799
827
  # wait a little to avoid caching empty DNS entry for 5 minutes, better to loose a few seconds than 300s
800
- logger.debug("Waiting for %is, because %s is freshly started up and wasn't seen for more than %ih (last seen at %s, %s ago)",
828
+ logger.debug("Waiting for %is, because %s is freshly started up and hasn't been seen for more than %ih (last seen at %s, %s ago)",
801
829
  wait_on_fresh_start, LazyStr(self.get_class_name), max_last_seen_age/3600,
802
830
  LazyStr((lambda last_seen : str(last_seen.astimezone())[:19] if last_seen else None), device_info.last_seen), LazyStr(difference))
803
831
  await asyncio.sleep(wait_on_fresh_start)
@@ -845,8 +873,8 @@ class RemoteTailscale(StatSeenDevice):
845
873
  async def seen(self, days: int) -> bool:
846
874
  device_info = await self.tailscale.device(self.machine_name)
847
875
  logger.debug("%s connected: %s, last seen: %s", LazyStr(self.get_class_name), device_info.connected_to_control, device_info.last_seen)
848
- if device_info.connected_to_control:
849
- return True
876
+ # we check this only when it is not started, then connected_to_control is False, use last_seen only
877
+ # additionally connected_to_control state changes are delayed, it can be True if it was stopped recently
850
878
  if not device_info.last_seen or days == 0:
851
879
  return False
852
880
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen
@@ -861,7 +889,7 @@ class Webhooks:
861
889
  def __init__(self, host: str, port: int):
862
890
  self.host = host
863
891
  self.port = port
864
- self.variables = dict[str, asyncio.Queue[str]]()
892
+ self.variables: dict[str, asyncio.Queue[str]] = {}
865
893
 
866
894
  @staticmethod
867
895
  def get_ping_path():
@@ -872,7 +900,7 @@ class Webhooks:
872
900
  return f'/{Webhooks.VARIABLE_PATH}/{variable}'
873
901
 
874
902
  async def _start(self):
875
- async def _ping(request: web.Request):
903
+ async def _ping(request: web.Request): # NOSONAR(S7503)
876
904
  return web.Response(text='pong')
877
905
  async def _receive_variable(request: web.Request):
878
906
  queue = self.variables.get(request.match_info['name'])
@@ -899,7 +927,7 @@ class Webhooks:
899
927
  def unsubscribe_variable(self, variable: str):
900
928
  self.variables.pop(variable)
901
929
 
902
- async def get_variable(self, variable: str, timeout: float):
930
+ async def get_variable(self, variable: str, timeout: float): # NOSONAR(S7483)
903
931
  queue = self.variables.get(variable)
904
932
  if not queue:
905
933
  raise ValueError(f"The {variable} is unknown")
@@ -913,6 +941,7 @@ class Webhooks:
913
941
  def __enter__(self):
914
942
  raise TypeError("Use async with instead")
915
943
  def __exit__(self, exc_type, exc_value, exc_tb):
944
+ # can't be called
916
945
  pass
917
946
  async def __aenter__(self):
918
947
  await self._start()
@@ -936,7 +965,7 @@ class Automate:
936
965
  "payload": f"prim-ctrl;{time.time()};" + message
937
966
  }
938
967
  logger.debug("Messaging Automate with: %s", message)
939
- async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
968
+ async with self.session.post('https://llamalab.com/automate/cloud/message', json=data) as response:
940
969
  await response.text()
941
970
 
942
971
  class AutomatePftpdManager(Manager):
@@ -979,7 +1008,7 @@ class WebhookPing(Pingable):
979
1008
 
980
1009
  async def _sleep_while_wait(self, available: bool):
981
1010
  if 0 != self._sleepcounter and 0 == self._sleepcounter % 60:
982
- logger.info("Waiting for %s (%s) to be accessible...", LazyStr(self.get_class_name), self.ping_url)
1011
+ logger.info("Waiting for %s (%s) to be reachable...", LazyStr(self.get_class_name), self.ping_url)
983
1012
  await asyncio.sleep(1)
984
1013
  self._sleepcounter += 1
985
1014
 
@@ -1016,8 +1045,8 @@ class AutomatePhoneState(PhoneState):
1016
1045
  await self.local_webhook_ping.wait_for(True, test_timeout)
1017
1046
  except Exception as e:
1018
1047
  exc = RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}")
1019
- # this is captured in a TaskGroup that drops traceback information from "from e"
1020
- exc.add_note(repr(e))
1048
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1049
+ exc.add_note(exception_repr(e))
1021
1050
  raise exc from None
1022
1051
 
1023
1052
  # 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
@@ -1027,8 +1056,8 @@ class AutomatePhoneState(PhoneState):
1027
1056
  await self.funnel.wait_for(True, test_timeout)
1028
1057
  except Exception as e:
1029
1058
  exc = RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}")
1030
- # this is captured in a TaskGroup that drops traceback information from "from e"
1031
- exc.add_note(repr(e))
1059
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1060
+ exc.add_note(exception_repr(e))
1032
1061
  raise exc from None
1033
1062
 
1034
1063
  # test external funnel + webhooks availability, ie. test funnel tcp forwarders
@@ -1039,8 +1068,8 @@ class AutomatePhoneState(PhoneState):
1039
1068
  await self.external_webhook_ping.wait_for(True, test_timeout)
1040
1069
  except Exception as e:
1041
1070
  exc = RuntimeError(f"Funnel TCP forwarders are not configured by Tailscale for {self.funnel.external_name}")
1042
- # this is captured in a TaskGroup that drops traceback information from "from e"
1043
- exc.add_note(repr(e))
1071
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1072
+ exc.add_note(exception_repr(e))
1044
1073
  raise exc from None
1045
1074
 
1046
1075
  # get state
@@ -1072,15 +1101,15 @@ class WideHelpFormatter(argparse.RawTextHelpFormatter):
1072
1101
 
1073
1102
  async def gather_with_taskgroup(*coros):
1074
1103
  try:
1075
- async with asyncio.TaskGroup() as tg:
1104
+ async with asyncio.TaskGroup() as tg: # NOSONAR(S7513)
1076
1105
  tasks = [tg.create_task(coro) for coro in coros]
1077
- return tuple([task.result() for task in tasks])
1106
+ return tuple(task.result() for task in tasks)
1078
1107
  except ExceptionGroup as eg:
1079
1108
  exc = eg.exceptions[0]
1080
1109
  # this can be captured in another TaskGroup that drops traceback information from "from e"
1081
1110
  if len(eg.exceptions) > 1:
1082
1111
  for e in eg.exceptions[1:]:
1083
- exc.add_note(repr(e))
1112
+ exc.add_note(exception_repr(e))
1084
1113
  raise exc from None
1085
1114
 
1086
1115
  class Local:
@@ -1111,6 +1140,7 @@ class Control:
1111
1140
 
1112
1141
  @staticmethod
1113
1142
  def setup_parser_options(parser):
1143
+ # no options
1114
1144
  pass
1115
1145
 
1116
1146
  @staticmethod
@@ -1145,12 +1175,12 @@ class Control:
1145
1175
  self.phone = phone
1146
1176
  self.keyboard_interrupt = keyboard_interrupt
1147
1177
 
1148
- async def _stop(self, restore_state: dict | None, stop_only_started: bool = False):
1178
+ async def _stop(self, restore_state: dict | None, stop_only_started: bool = False): # NOSONAR(S3776)
1149
1179
  with self.keyboard_interrupt.protect():
1150
1180
  async def _suppress(coro, default: bool):
1151
1181
  try:
1152
1182
  return await coro
1153
- except:
1183
+ except: # NOSONAR(S5754)
1154
1184
  return default
1155
1185
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp and await _suppress(self.local.vpn.test(), True) and await _suppress(self.phone.vpn.test(), True):
1156
1186
  if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await _suppress(self.phone.remote_sftp.test(), True):
@@ -1176,7 +1206,7 @@ class Control:
1176
1206
  except Exception as e:
1177
1207
  logger.exception_or_error(e)
1178
1208
 
1179
- async def run(self):
1209
+ async def run(self): # NOSONAR(S3776)
1180
1210
  match self.args.intent:
1181
1211
  case 'test':
1182
1212
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp and self.phone.state:
@@ -1188,7 +1218,7 @@ class Control:
1188
1218
  await self.phone.zeroconf_sftp.test()
1189
1219
  case 'start':
1190
1220
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp:
1191
- state = dict()
1221
+ state = {}
1192
1222
  try:
1193
1223
  # gather local state info
1194
1224
  local_vpn_state = await self.local.vpn.test()
@@ -1198,8 +1228,8 @@ class Control:
1198
1228
  if not state[Control.LOCAL_VPN]:
1199
1229
  await self.local.vpn.start(10, 30)
1200
1230
 
1201
- zeroconf_accessible = False
1202
- remote_accessible = False
1231
+ zeroconf_reachable = False
1232
+ remote_reachable = False
1203
1233
 
1204
1234
  # gather phone state info
1205
1235
  if self.phone.state:
@@ -1208,59 +1238,59 @@ class Control:
1208
1238
  state[Control.PHONE_VPN] = phone_vpn_state
1209
1239
  state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
1210
1240
  if not state[Control.PHONE_WIFI] and not self.args.accept_cellular:
1211
- raise RuntimeError(f"Phone is not on Wi-Fi network")
1241
+ raise RuntimeError("Phone is not on Wi-Fi network")
1212
1242
  else:
1213
1243
  state[Control.PHONE_VPN] = phone_vpn_state = await self.phone.vpn.test()
1214
1244
  if phone_vpn_state:
1215
- state[Control.PHONE_SFTP] = remote_accessible = await self.phone.remote_sftp.test()
1245
+ state[Control.PHONE_SFTP] = remote_reachable = await self.phone.remote_sftp.test()
1216
1246
  # start changing phone state
1217
1247
  phone_vpn = state[Control.PHONE_VPN]
1218
1248
  if not phone_vpn and self.args.restart_vpn is not None and not await self.phone.vpn.seen(self.args.restart_vpn):
1219
1249
  logger.info("%s wasn't seen for %d days", LazyStr(self.phone.vpn.get_class_name), self.args.restart_vpn)
1220
1250
  phone_vpn = await self.phone.vpn.start(10, 60)
1221
1251
  if not self.phone.state:
1222
- state[Control.PHONE_SFTP] = remote_accessible = await self.phone.remote_sftp.test()
1252
+ state[Control.PHONE_SFTP] = remote_reachable = await self.phone.remote_sftp.test()
1223
1253
  if self.phone.state:
1224
1254
  if not state[Control.PHONE_SFTP]:
1225
1255
  if not phone_vpn:
1226
1256
  if state[Control.PHONE_WIFI]:
1227
1257
  try:
1228
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1258
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1229
1259
  except TimeoutError:
1230
1260
  await self.phone.vpn.start(10, 60)
1231
- remote_accessible = await self.phone.remote_sftp.test()
1261
+ remote_reachable = await self.phone.remote_sftp.test()
1232
1262
  else:
1233
1263
  await self.phone.vpn.start(10, 60)
1234
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1264
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1235
1265
  else:
1236
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1266
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1237
1267
  if state[Control.PHONE_WIFI]:
1238
- zeroconf_accessible = await self.phone.zeroconf_sftp.test()
1268
+ zeroconf_reachable = await self.phone.zeroconf_sftp.test()
1239
1269
  else:
1240
1270
  if not phone_vpn:
1241
- if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await self.phone.zeroconf_sftp.test()):
1271
+ if not state[Control.PHONE_WIFI] or not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
1242
1272
  await self.phone.vpn.start(10, 60)
1243
- remote_accessible = await self.phone.remote_sftp.test()
1273
+ remote_reachable = await self.phone.remote_sftp.test()
1244
1274
  else:
1245
- zeroconf_accessible, remote_accessible = await gather_with_taskgroup(self.phone.zeroconf_sftp.test(), self.phone.remote_sftp.test())
1275
+ zeroconf_reachable, remote_reachable = await gather_with_taskgroup(self.phone.zeroconf_sftp.test(), self.phone.remote_sftp.test())
1246
1276
  else:
1247
1277
  if not phone_vpn:
1248
- if not (zeroconf_accessible := await self.phone.zeroconf_sftp.test()):
1278
+ if not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
1249
1279
  try:
1250
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1280
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1251
1281
  except TimeoutError:
1252
1282
  await self.phone.vpn.start(10, 60)
1253
- remote_accessible = await self.phone.remote_sftp.test()
1283
+ remote_reachable = await self.phone.remote_sftp.test()
1254
1284
  else:
1255
1285
  if not state[Control.PHONE_SFTP]:
1256
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1257
- zeroconf_accessible = await self.phone.zeroconf_sftp.test()
1258
- if not zeroconf_accessible and not remote_accessible:
1259
- raise RuntimeError(f"Even when {self.phone.vpn.get_class_name()} and {self.phone.remote_sftp.get_class_name()} is started, {self.phone.remote_sftp.get_class_name()} is still not accessible")
1286
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1287
+ zeroconf_reachable = await self.phone.zeroconf_sftp.test()
1288
+ if not zeroconf_reachable and not remote_reachable:
1289
+ raise RuntimeError(f"Even when {self.phone.vpn.get_class_name()} and {self.phone.remote_sftp.get_class_name()} is started, {self.phone.remote_sftp.get_class_name()} is still unreachable")
1260
1290
  # print out result on stdout
1261
1291
  if not self.args.backup_state:
1262
- state = dict()
1263
- state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
1292
+ state = {}
1293
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_reachable else Control.REMOTE
1264
1294
  print(StateSerializer.dumps(state))
1265
1295
  except:
1266
1296
  try:
@@ -1289,8 +1319,8 @@ class AutomateControl(Control):
1289
1319
  parser = subparsers.add_parser('Automate', aliases=['a'],
1290
1320
  description="Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl\n\n"
1291
1321
  "Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)\n"
1292
- "Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
1293
- "Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored\n\n"
1322
+ "Note: optionally if your phone is not reachable on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
1323
+ "Note: optionally if your laptop is reachable through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored\n\n"
1294
1324
  "Output: 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",
1295
1325
  formatter_class=WideHelpFormatter)
1296
1326
 
@@ -1307,13 +1337,13 @@ class AutomateControl(Control):
1307
1337
 
1308
1338
  Control.setup_parser_groups(parser)
1309
1339
 
1310
- vpn_group = parser.add_argument_group('VPN',
1311
- description="To use --tailscale option you must install Tailscale and configure Tailscale VPN on your phone and your laptop\n"
1312
- "To use --funnel option you must configure Tailscale Funnel on your laptop for prim-ctrl's local webhook to accept responses from the Automate app\n"
1313
- " (eg.: tailscale funnel --bg --https=8443 --set-path=/prim-ctrl \"http://127.0.0.1:12345\")\n"
1314
- "Note: --funnel, --restart-vpn, --backup-state and --restore-state options can be used only when --tailscale is used\n"
1315
- "Note: --backup-state is accurate only, when --funnel is used\n"
1316
- "Note: --accept-cellular option can be used only when --funnel is used")
1340
+ vpn_group = parser.add_argument_group('VPN', description=
1341
+ "To use --tailscale option you must install Tailscale and configure Tailscale VPN on your phone and your laptop\n"
1342
+ "To use --funnel option you must configure Tailscale Funnel on your laptop for prim-ctrl's local webhook to accept responses from the Automate app\n"
1343
+ " (eg.: tailscale funnel --bg --https=8443 --set-path=/prim-ctrl \"http://127.0.0.1:12345\")\n"
1344
+ "Note: --funnel, --restart-vpn, --backup-state and --restore-state options can be used only when --tailscale is used\n"
1345
+ "Note: --backup-state is accurate only, when --funnel is used\n"
1346
+ "Note: --accept-cellular option can be used only when --funnel is used")
1317
1347
  vpn_group.add_argument('--tailscale', nargs=4, metavar=('tailnet', 'secretfile', 'remote-machine-name', 'sftp-port'), help=
1318
1348
  "tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
1319
1349
  "secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder\n"
@@ -3,7 +3,7 @@ packages = [{include = "prim_ctrl"}]
3
3
 
4
4
  [project]
5
5
  name = "prim-ctrl"
6
- version = "0.8.1"
6
+ version = "0.8.3"
7
7
  description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
8
8
  license = "Apache-2.0"
9
9
  authors = [
File without changes