prim-ctrl 0.8.2__tar.gz → 0.8.4__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.2
3
+ Version: 0.8.4
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
 
@@ -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
@@ -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
 
292
+ def get_state_name(self, available: bool):
293
+ return Pingable._get_state_name(available)
294
+
294
295
  async def wait_for(self, available: bool, timeout: float): # NOSONAR(S7483)
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))
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)
@@ -318,7 +319,7 @@ class Manageable(Pingable):
318
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,12 +337,12 @@ 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
348
  async def start(self, repeat: float, timeout: float): # NOSONAR(S7483)
@@ -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():
@@ -428,7 +432,7 @@ class SshService(Service):
428
432
  client_keys=_client_key(),
429
433
  connect_timeout=self._connect_timeout))
430
434
  ):
431
- pass # NOSONAR(S108)
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
@@ -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()
@@ -655,11 +659,17 @@ class RemotePftpd(SshService):
655
659
  super().__init__(host, port, host_name, keyfile, manager)
656
660
  self.__qualname__ = "pFTPd"
657
661
 
662
+ def get_state_name(self, available: bool):
663
+ return super().get_state_name(available) + ' remotely'
664
+
658
665
  class ZeroconfPftpd(ZeroconfSshService):
659
666
  def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
660
667
  super().__init__(service_name, service_cache, service_resolver, keyfile, manager)
661
668
  self.__qualname__ = "pFTPd"
662
669
 
670
+ def get_state_name(self, available: bool):
671
+ return super().get_state_name(available) + ' locally'
672
+
663
673
  ########
664
674
 
665
675
  class SecretsTokenStorage(TokenStorage):
@@ -691,14 +701,21 @@ class Tailscale():
691
701
  self.tailscale_api = TailscaleApi(session=session, request_timeout=30, tailnet=tailnet,
692
702
  oauth_client_id=client_id, oauth_client_secret=client_secret, token_storage=SecretsTokenStorage(secrets, secretfile))
693
703
 
694
- async def device(self, machine_name: str) -> TailscaleDeviceInfo:
695
- logger.debug("Calling Tailscale API for devices")
696
- devices = await self.tailscale_api.devices()
697
- name = f"{machine_name}.{self.tailnet}"
698
- for device in devices.values():
699
- if device.name == name:
700
- return device
701
- 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
702
719
 
703
720
  class Funnel(Pingable):
704
721
  LOCAL_HOST = '127.0.0.1'
@@ -710,7 +727,9 @@ class Funnel(Pingable):
710
727
  self.external_port = external_port
711
728
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
712
729
  self.local_tailscale = local_tailscale
730
+ self.external_public_dns_resolver_address = None
713
731
  self.external_public_dns_resolver = None
732
+ self.external_tailscale_dns_resolver_addresses = None
714
733
  self.external_tailscale_dns_resolver = None
715
734
 
716
735
  async def wait_for(self, available: bool, timeout: float):
@@ -719,26 +738,38 @@ class Funnel(Pingable):
719
738
 
720
739
  async def ping(self, availability_hint: bool | None = None):
721
740
  logger.debug("Resolving DNS for %s (%s:%s)", LazyStr(self.get_class_name), self.external_name, self.external_port)
722
- # 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
723
- if self.external_tailscale_dns_resolver is None:
724
- self.external_tailscale_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_tailscale_dns_resolver())
741
+ # 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
742
+ if self.external_tailscale_dns_resolver_addresses is None:
743
+ resolvers = [await self.local_tailscale.external_tailscale_dns_resolver()]
744
+ resolvers.extend(await self._external_tailscale_dns_resolvers())
745
+ self.external_tailscale_dns_resolver_addresses = resolvers
746
+ resolver = self.external_tailscale_dns_resolver
747
+ if resolver is None:
748
+ resolver = ExternalDnsResolver(self.external_tailscale_dns_resolver_addresses[0])
725
749
  try:
726
- _answer = await self.external_tailscale_dns_resolver.resolve(self.external_name, self.external_port)
750
+ _answer = await resolver.resolve(self.external_name, self.external_port)
727
751
  except Exception as e:
728
- logger.debug("Resolving at Tailscale's external DNS has failed: %s", LazyStr(repr, e))
752
+ logger.debug("Resolving at Tailscale's external DNS %s has failed: %s", self.external_tailscale_dns_resolver_addresses[0], LazyStr(exception_repr, e))
753
+ # move current resolver to the end of the list
754
+ self.external_tailscale_dns_resolver_addresses.append(self.external_tailscale_dns_resolver_addresses.pop(0))
729
755
  return False
756
+ self.external_tailscale_dns_resolver = resolver
730
757
  # then try at a public DNS
731
- if self.external_public_dns_resolver is None:
732
- self.external_public_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_public_dns_resolver())
758
+ if self.external_public_dns_resolver_address is None:
759
+ self.external_public_dns_resolver_address = await self.local_tailscale.external_public_dns_resolver()
760
+ resolver = self.external_public_dns_resolver
761
+ if resolver is None:
762
+ resolver = ExternalDnsResolver(self.external_public_dns_resolver_address)
733
763
  try:
734
- _answer = await self.external_public_dns_resolver.resolve(self.external_name, self.external_port)
764
+ _answer = await resolver.resolve(self.external_name, self.external_port)
735
765
  except Exception as e:
736
- logger.debug("Resolving at public external DNS has failed: %s", LazyStr(repr, e))
766
+ logger.debug("Resolving at public external DNS has failed: %s", LazyStr(exception_repr, e))
737
767
  return False
768
+ self.external_public_dns_resolver = resolver
738
769
  return True
739
770
 
740
771
  async def _sleep_while_wait(self, available: bool):
741
- if self.local_tailscale.is_started_now and 0 != self._sleepcounter and 0 == self._sleepcounter % 3:
772
+ if self.local_tailscale.is_started_now and 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
742
773
  logger.info("Restarting %s to retrigger public DNS records' configuration at Tailscale...", LazyStr(self.local_tailscale.get_class_name))
743
774
  await self.local_tailscale.stop(10, 30)
744
775
  await self.local_tailscale.start(10, 30)
@@ -747,6 +778,10 @@ class Funnel(Pingable):
747
778
  await asyncio.sleep(10)
748
779
  self._sleepcounter += 1
749
780
 
781
+ async def _external_tailscale_dns_resolvers(self):
782
+ logger.debug("Getting external Tailscale DNS resolvers for ts.net")
783
+ return [str(rr.to_text()) for rr in await dns.asyncresolver.Resolver().resolve("ts.net", rdtype=dns.rdatatype.NS)]
784
+
750
785
  class LocalTailscaleManager(Manager):
751
786
  async def start(self):
752
787
  success, _, stderr = await Subprocess.tailscale(['up'])
@@ -794,11 +829,13 @@ class LocalTailscale(Manageable):
794
829
  self._checked_fresh_start = True
795
830
  max_last_seen_age = 7200
796
831
  wait_on_fresh_start = 5
832
+ # if we started up now, then connected_to_control was False, use last_seen only
833
+ # additionally connected_to_control state changes are delayed, it can be True if it was stopped recently
797
834
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
798
835
  difference_sec = difference.total_seconds() if difference else None
799
836
  if difference_sec is None or difference_sec > max_last_seen_age:
800
837
  # wait a little to avoid caching empty DNS entry for 5 minutes, better to loose a few seconds than 300s
801
- 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)",
838
+ 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)",
802
839
  wait_on_fresh_start, LazyStr(self.get_class_name), max_last_seen_age/3600,
803
840
  LazyStr((lambda last_seen : str(last_seen.astimezone())[:19] if last_seen else None), device_info.last_seen), LazyStr(difference))
804
841
  await asyncio.sleep(wait_on_fresh_start)
@@ -846,8 +883,8 @@ class RemoteTailscale(StatSeenDevice):
846
883
  async def seen(self, days: int) -> bool:
847
884
  device_info = await self.tailscale.device(self.machine_name)
848
885
  logger.debug("%s connected: %s, last seen: %s", LazyStr(self.get_class_name), device_info.connected_to_control, device_info.last_seen)
849
- if device_info.connected_to_control:
850
- return True
886
+ # we check this only when it is not started, then connected_to_control is False, use last_seen only
887
+ # additionally connected_to_control state changes are delayed, it can be True if it was stopped recently
851
888
  if not device_info.last_seen or days == 0:
852
889
  return False
853
890
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen
@@ -981,7 +1018,7 @@ class WebhookPing(Pingable):
981
1018
 
982
1019
  async def _sleep_while_wait(self, available: bool):
983
1020
  if 0 != self._sleepcounter and 0 == self._sleepcounter % 60:
984
- logger.info("Waiting for %s (%s) to be accessible...", LazyStr(self.get_class_name), self.ping_url)
1021
+ logger.info("Waiting for %s (%s) to be reachable...", LazyStr(self.get_class_name), self.ping_url)
985
1022
  await asyncio.sleep(1)
986
1023
  self._sleepcounter += 1
987
1024
 
@@ -1018,8 +1055,8 @@ class AutomatePhoneState(PhoneState):
1018
1055
  await self.local_webhook_ping.wait_for(True, test_timeout)
1019
1056
  except Exception as e:
1020
1057
  exc = RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}")
1021
- # this is captured in a TaskGroup that drops traceback information from "from e"
1022
- exc.add_note(repr(e))
1058
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1059
+ exc.add_note(exception_repr(e))
1023
1060
  raise exc from None
1024
1061
 
1025
1062
  # 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
@@ -1029,8 +1066,8 @@ class AutomatePhoneState(PhoneState):
1029
1066
  await self.funnel.wait_for(True, test_timeout)
1030
1067
  except Exception as e:
1031
1068
  exc = RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}")
1032
- # this is captured in a TaskGroup that drops traceback information from "from e"
1033
- exc.add_note(repr(e))
1069
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1070
+ exc.add_note(exception_repr(e))
1034
1071
  raise exc from None
1035
1072
 
1036
1073
  # test external funnel + webhooks availability, ie. test funnel tcp forwarders
@@ -1041,8 +1078,8 @@ class AutomatePhoneState(PhoneState):
1041
1078
  await self.external_webhook_ping.wait_for(True, test_timeout)
1042
1079
  except Exception as e:
1043
1080
  exc = RuntimeError(f"Funnel TCP forwarders are not configured by Tailscale for {self.funnel.external_name}")
1044
- # this is captured in a TaskGroup that drops traceback information from "from e"
1045
- exc.add_note(repr(e))
1081
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1082
+ exc.add_note(exception_repr(e))
1046
1083
  raise exc from None
1047
1084
 
1048
1085
  # get state
@@ -1082,7 +1119,7 @@ async def gather_with_taskgroup(*coros):
1082
1119
  # this can be captured in another TaskGroup that drops traceback information from "from e"
1083
1120
  if len(eg.exceptions) > 1:
1084
1121
  for e in eg.exceptions[1:]:
1085
- exc.add_note(repr(e))
1122
+ exc.add_note(exception_repr(e))
1086
1123
  raise exc from None
1087
1124
 
1088
1125
  class Local:
@@ -1201,8 +1238,8 @@ class Control:
1201
1238
  if not state[Control.LOCAL_VPN]:
1202
1239
  await self.local.vpn.start(10, 30)
1203
1240
 
1204
- zeroconf_accessible = False
1205
- remote_accessible = False
1241
+ zeroconf_reachable = False
1242
+ remote_reachable = False
1206
1243
 
1207
1244
  # gather phone state info
1208
1245
  if self.phone.state:
@@ -1215,55 +1252,55 @@ class Control:
1215
1252
  else:
1216
1253
  state[Control.PHONE_VPN] = phone_vpn_state = await self.phone.vpn.test()
1217
1254
  if phone_vpn_state:
1218
- state[Control.PHONE_SFTP] = remote_accessible = await self.phone.remote_sftp.test()
1255
+ state[Control.PHONE_SFTP] = remote_reachable = await self.phone.remote_sftp.test()
1219
1256
  # start changing phone state
1220
1257
  phone_vpn = state[Control.PHONE_VPN]
1221
1258
  if not phone_vpn and self.args.restart_vpn is not None and not await self.phone.vpn.seen(self.args.restart_vpn):
1222
1259
  logger.info("%s wasn't seen for %d days", LazyStr(self.phone.vpn.get_class_name), self.args.restart_vpn)
1223
1260
  phone_vpn = await self.phone.vpn.start(10, 60)
1224
1261
  if not self.phone.state:
1225
- state[Control.PHONE_SFTP] = remote_accessible = await self.phone.remote_sftp.test()
1262
+ state[Control.PHONE_SFTP] = remote_reachable = await self.phone.remote_sftp.test()
1226
1263
  if self.phone.state:
1227
1264
  if not state[Control.PHONE_SFTP]:
1228
1265
  if not phone_vpn:
1229
1266
  if state[Control.PHONE_WIFI]:
1230
1267
  try:
1231
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1268
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1232
1269
  except TimeoutError:
1233
1270
  await self.phone.vpn.start(10, 60)
1234
- remote_accessible = await self.phone.remote_sftp.test()
1271
+ remote_reachable = await self.phone.remote_sftp.test()
1235
1272
  else:
1236
1273
  await self.phone.vpn.start(10, 60)
1237
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1274
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1238
1275
  else:
1239
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1276
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1240
1277
  if state[Control.PHONE_WIFI]:
1241
- zeroconf_accessible = await self.phone.zeroconf_sftp.test()
1278
+ zeroconf_reachable = await self.phone.zeroconf_sftp.test()
1242
1279
  else:
1243
1280
  if not phone_vpn:
1244
- if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await self.phone.zeroconf_sftp.test()):
1281
+ if not state[Control.PHONE_WIFI] or not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
1245
1282
  await self.phone.vpn.start(10, 60)
1246
- remote_accessible = await self.phone.remote_sftp.test()
1283
+ remote_reachable = await self.phone.remote_sftp.test()
1247
1284
  else:
1248
- zeroconf_accessible, remote_accessible = await gather_with_taskgroup(self.phone.zeroconf_sftp.test(), self.phone.remote_sftp.test())
1285
+ zeroconf_reachable, remote_reachable = await gather_with_taskgroup(self.phone.zeroconf_sftp.test(), self.phone.remote_sftp.test())
1249
1286
  else:
1250
1287
  if not phone_vpn:
1251
- if not (zeroconf_accessible := await self.phone.zeroconf_sftp.test()):
1288
+ if not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
1252
1289
  try:
1253
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1290
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1254
1291
  except TimeoutError:
1255
1292
  await self.phone.vpn.start(10, 60)
1256
- remote_accessible = await self.phone.remote_sftp.test()
1293
+ remote_reachable = await self.phone.remote_sftp.test()
1257
1294
  else:
1258
1295
  if not state[Control.PHONE_SFTP]:
1259
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1260
- zeroconf_accessible = await self.phone.zeroconf_sftp.test()
1261
- if not zeroconf_accessible and not remote_accessible:
1262
- 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")
1296
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1297
+ zeroconf_reachable = await self.phone.zeroconf_sftp.test()
1298
+ if not zeroconf_reachable and not remote_reachable:
1299
+ 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")
1263
1300
  # print out result on stdout
1264
1301
  if not self.args.backup_state:
1265
1302
  state = {}
1266
- state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
1303
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_reachable else Control.REMOTE
1267
1304
  print(StateSerializer.dumps(state))
1268
1305
  except:
1269
1306
  try:
@@ -1292,8 +1329,8 @@ class AutomateControl(Control):
1292
1329
  parser = subparsers.add_parser('Automate', aliases=['a'],
1293
1330
  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"
1294
1331
  "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"
1295
- "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"
1296
- "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"
1332
+ "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"
1333
+ "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"
1297
1334
  "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",
1298
1335
  formatter_class=WideHelpFormatter)
1299
1336
 
@@ -1310,13 +1347,13 @@ class AutomateControl(Control):
1310
1347
 
1311
1348
  Control.setup_parser_groups(parser)
1312
1349
 
1313
- vpn_group = parser.add_argument_group('VPN',
1314
- description="To use --tailscale option you must install Tailscale and configure Tailscale VPN on your phone and your laptop\n"
1315
- "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"
1316
- " (eg.: tailscale funnel --bg --https=8443 --set-path=/prim-ctrl \"http://127.0.0.1:12345\")\n"
1317
- "Note: --funnel, --restart-vpn, --backup-state and --restore-state options can be used only when --tailscale is used\n"
1318
- "Note: --backup-state is accurate only, when --funnel is used\n"
1319
- "Note: --accept-cellular option can be used only when --funnel is used")
1350
+ vpn_group = parser.add_argument_group('VPN', description=
1351
+ "To use --tailscale option you must install Tailscale and configure Tailscale VPN on your phone and your laptop\n"
1352
+ "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"
1353
+ " (eg.: tailscale funnel --bg --https=8443 --set-path=/prim-ctrl \"http://127.0.0.1:12345\")\n"
1354
+ "Note: --funnel, --restart-vpn, --backup-state and --restore-state options can be used only when --tailscale is used\n"
1355
+ "Note: --backup-state is accurate only, when --funnel is used\n"
1356
+ "Note: --accept-cellular option can be used only when --funnel is used")
1320
1357
  vpn_group.add_argument('--tailscale', nargs=4, metavar=('tailnet', 'secretfile', 'remote-machine-name', 'sftp-port'), help=
1321
1358
  "tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
1322
1359
  "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.2"
6
+ version = "0.8.4"
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