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.
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/PKG-INFO +3 -3
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/README.md +2 -2
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/prim_ctrl/__main__.py +109 -72
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/pyproject.toml +1 -1
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/LICENSE +0 -0
- {prim_ctrl-0.8.2 → prim_ctrl-0.8.4}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.8.
|
|
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
|
|
170
|
-
Note: optionally if your laptop is
|
|
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
|
|
149
|
-
Note: optionally if your laptop is
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
723
|
-
if self.
|
|
724
|
-
|
|
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
|
|
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(
|
|
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.
|
|
732
|
-
self.
|
|
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
|
|
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(
|
|
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 %
|
|
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
|
|
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
|
-
|
|
850
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1205
|
-
|
|
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] =
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
1271
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1235
1272
|
else:
|
|
1236
1273
|
await self.phone.vpn.start(10, 60)
|
|
1237
|
-
|
|
1274
|
+
remote_reachable = await self.phone.remote_sftp.start(10, 30)
|
|
1238
1275
|
else:
|
|
1239
|
-
|
|
1276
|
+
remote_reachable = await self.phone.remote_sftp.start(10, 30)
|
|
1240
1277
|
if state[Control.PHONE_WIFI]:
|
|
1241
|
-
|
|
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 (
|
|
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
|
-
|
|
1283
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1247
1284
|
else:
|
|
1248
|
-
|
|
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 (
|
|
1288
|
+
if not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
|
|
1252
1289
|
try:
|
|
1253
|
-
|
|
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
|
-
|
|
1293
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1257
1294
|
else:
|
|
1258
1295
|
if not state[Control.PHONE_SFTP]:
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
if not
|
|
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
|
|
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
|
|
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
|
|
1296
|
-
"Note: optionally if your laptop is
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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.
|
|
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
|
|
File without changes
|