prim-ctrl 0.8.2__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.2
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
 
@@ -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'
@@ -711,7 +728,7 @@ class Funnel(Pingable):
711
728
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
712
729
  self.local_tailscale = local_tailscale
713
730
  self.external_public_dns_resolver = None
714
- self.external_tailscale_dns_resolver = None
731
+ self.external_tailscale_dns_resolvers = None
715
732
 
716
733
  async def wait_for(self, available: bool, timeout: float):
717
734
  self._sleepcounter = 0
@@ -719,13 +736,17 @@ class Funnel(Pingable):
719
736
 
720
737
  async def ping(self, availability_hint: bool | None = None):
721
738
  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())
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]
725
744
  try:
726
- _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)
727
746
  except Exception as e:
728
- 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))
729
750
  return False
730
751
  # then try at a public DNS
731
752
  if self.external_public_dns_resolver is None:
@@ -733,12 +754,12 @@ class Funnel(Pingable):
733
754
  try:
734
755
  _answer = await self.external_public_dns_resolver.resolve(self.external_name, self.external_port)
735
756
  except Exception as e:
736
- 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))
737
758
  return False
738
759
  return True
739
760
 
740
761
  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:
762
+ if self.local_tailscale.is_started_now and 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
742
763
  logger.info("Restarting %s to retrigger public DNS records' configuration at Tailscale...", LazyStr(self.local_tailscale.get_class_name))
743
764
  await self.local_tailscale.stop(10, 30)
744
765
  await self.local_tailscale.start(10, 30)
@@ -747,6 +768,10 @@ class Funnel(Pingable):
747
768
  await asyncio.sleep(10)
748
769
  self._sleepcounter += 1
749
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
+
750
775
  class LocalTailscaleManager(Manager):
751
776
  async def start(self):
752
777
  success, _, stderr = await Subprocess.tailscale(['up'])
@@ -794,11 +819,13 @@ class LocalTailscale(Manageable):
794
819
  self._checked_fresh_start = True
795
820
  max_last_seen_age = 7200
796
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
797
824
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
798
825
  difference_sec = difference.total_seconds() if difference else None
799
826
  if difference_sec is None or difference_sec > max_last_seen_age:
800
827
  # 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)",
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)",
802
829
  wait_on_fresh_start, LazyStr(self.get_class_name), max_last_seen_age/3600,
803
830
  LazyStr((lambda last_seen : str(last_seen.astimezone())[:19] if last_seen else None), device_info.last_seen), LazyStr(difference))
804
831
  await asyncio.sleep(wait_on_fresh_start)
@@ -846,8 +873,8 @@ class RemoteTailscale(StatSeenDevice):
846
873
  async def seen(self, days: int) -> bool:
847
874
  device_info = await self.tailscale.device(self.machine_name)
848
875
  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
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
851
878
  if not device_info.last_seen or days == 0:
852
879
  return False
853
880
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen
@@ -981,7 +1008,7 @@ class WebhookPing(Pingable):
981
1008
 
982
1009
  async def _sleep_while_wait(self, available: bool):
983
1010
  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)
1011
+ logger.info("Waiting for %s (%s) to be reachable...", LazyStr(self.get_class_name), self.ping_url)
985
1012
  await asyncio.sleep(1)
986
1013
  self._sleepcounter += 1
987
1014
 
@@ -1018,8 +1045,8 @@ class AutomatePhoneState(PhoneState):
1018
1045
  await self.local_webhook_ping.wait_for(True, test_timeout)
1019
1046
  except Exception as e:
1020
1047
  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))
1048
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1049
+ exc.add_note(exception_repr(e))
1023
1050
  raise exc from None
1024
1051
 
1025
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
@@ -1029,8 +1056,8 @@ class AutomatePhoneState(PhoneState):
1029
1056
  await self.funnel.wait_for(True, test_timeout)
1030
1057
  except Exception as e:
1031
1058
  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))
1059
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1060
+ exc.add_note(exception_repr(e))
1034
1061
  raise exc from None
1035
1062
 
1036
1063
  # test external funnel + webhooks availability, ie. test funnel tcp forwarders
@@ -1041,8 +1068,8 @@ class AutomatePhoneState(PhoneState):
1041
1068
  await self.external_webhook_ping.wait_for(True, test_timeout)
1042
1069
  except Exception as e:
1043
1070
  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))
1071
+ # if this is captured in a TaskGroup that drops traceback information from "from e"
1072
+ exc.add_note(exception_repr(e))
1046
1073
  raise exc from None
1047
1074
 
1048
1075
  # get state
@@ -1082,7 +1109,7 @@ async def gather_with_taskgroup(*coros):
1082
1109
  # this can be captured in another TaskGroup that drops traceback information from "from e"
1083
1110
  if len(eg.exceptions) > 1:
1084
1111
  for e in eg.exceptions[1:]:
1085
- exc.add_note(repr(e))
1112
+ exc.add_note(exception_repr(e))
1086
1113
  raise exc from None
1087
1114
 
1088
1115
  class Local:
@@ -1201,8 +1228,8 @@ class Control:
1201
1228
  if not state[Control.LOCAL_VPN]:
1202
1229
  await self.local.vpn.start(10, 30)
1203
1230
 
1204
- zeroconf_accessible = False
1205
- remote_accessible = False
1231
+ zeroconf_reachable = False
1232
+ remote_reachable = False
1206
1233
 
1207
1234
  # gather phone state info
1208
1235
  if self.phone.state:
@@ -1215,55 +1242,55 @@ class Control:
1215
1242
  else:
1216
1243
  state[Control.PHONE_VPN] = phone_vpn_state = await self.phone.vpn.test()
1217
1244
  if phone_vpn_state:
1218
- 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()
1219
1246
  # start changing phone state
1220
1247
  phone_vpn = state[Control.PHONE_VPN]
1221
1248
  if not phone_vpn and self.args.restart_vpn is not None and not await self.phone.vpn.seen(self.args.restart_vpn):
1222
1249
  logger.info("%s wasn't seen for %d days", LazyStr(self.phone.vpn.get_class_name), self.args.restart_vpn)
1223
1250
  phone_vpn = await self.phone.vpn.start(10, 60)
1224
1251
  if not self.phone.state:
1225
- 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()
1226
1253
  if self.phone.state:
1227
1254
  if not state[Control.PHONE_SFTP]:
1228
1255
  if not phone_vpn:
1229
1256
  if state[Control.PHONE_WIFI]:
1230
1257
  try:
1231
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1258
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1232
1259
  except TimeoutError:
1233
1260
  await self.phone.vpn.start(10, 60)
1234
- remote_accessible = await self.phone.remote_sftp.test()
1261
+ remote_reachable = await self.phone.remote_sftp.test()
1235
1262
  else:
1236
1263
  await self.phone.vpn.start(10, 60)
1237
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1264
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1238
1265
  else:
1239
- remote_accessible = await self.phone.remote_sftp.start(10, 30)
1266
+ remote_reachable = await self.phone.remote_sftp.start(10, 30)
1240
1267
  if state[Control.PHONE_WIFI]:
1241
- zeroconf_accessible = await self.phone.zeroconf_sftp.test()
1268
+ zeroconf_reachable = await self.phone.zeroconf_sftp.test()
1242
1269
  else:
1243
1270
  if not phone_vpn:
1244
- 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()):
1245
1272
  await self.phone.vpn.start(10, 60)
1246
- remote_accessible = await self.phone.remote_sftp.test()
1273
+ remote_reachable = await self.phone.remote_sftp.test()
1247
1274
  else:
1248
- 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())
1249
1276
  else:
1250
1277
  if not phone_vpn:
1251
- if not (zeroconf_accessible := await self.phone.zeroconf_sftp.test()):
1278
+ if not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
1252
1279
  try:
1253
- zeroconf_accessible = await self.phone.zeroconf_sftp.start(10, 30)
1280
+ zeroconf_reachable = await self.phone.zeroconf_sftp.start(10, 30)
1254
1281
  except TimeoutError:
1255
1282
  await self.phone.vpn.start(10, 60)
1256
- remote_accessible = await self.phone.remote_sftp.test()
1283
+ remote_reachable = await self.phone.remote_sftp.test()
1257
1284
  else:
1258
1285
  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")
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")
1263
1290
  # print out result on stdout
1264
1291
  if not self.args.backup_state:
1265
1292
  state = {}
1266
- state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
1293
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_reachable else Control.REMOTE
1267
1294
  print(StateSerializer.dumps(state))
1268
1295
  except:
1269
1296
  try:
@@ -1292,8 +1319,8 @@ class AutomateControl(Control):
1292
1319
  parser = subparsers.add_parser('Automate', aliases=['a'],
1293
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"
1294
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"
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"
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"
1297
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",
1298
1325
  formatter_class=WideHelpFormatter)
1299
1326
 
@@ -1310,13 +1337,13 @@ class AutomateControl(Control):
1310
1337
 
1311
1338
  Control.setup_parser_groups(parser)
1312
1339
 
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")
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")
1320
1347
  vpn_group.add_argument('--tailscale', nargs=4, metavar=('tailnet', 'secretfile', 'remote-machine-name', 'sftp-port'), help=
1321
1348
  "tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
1322
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.2"
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