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.
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/PKG-INFO +3 -3
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/README.md +2 -2
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/prim_ctrl/__main__.py +130 -100
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/pyproject.toml +1 -1
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/LICENSE +0 -0
- {prim_ctrl-0.8.1 → prim_ctrl-0.8.3}/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.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
|
|
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
|
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
289
|
+
def _get_state_name(available: bool):
|
|
292
290
|
return 'up' if available else 'down'
|
|
293
291
|
|
|
294
|
-
|
|
295
|
-
|
|
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(
|
|
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("
|
|
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
|
-
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))
|
|
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
|
|
@@ -458,7 +462,7 @@ class StateSerializer:
|
|
|
458
462
|
@staticmethod
|
|
459
463
|
def loads(s: str):
|
|
460
464
|
try:
|
|
461
|
-
return
|
|
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(
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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.
|
|
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
|
|
722
|
-
if self.
|
|
723
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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 %
|
|
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
|
|
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
|
-
|
|
849
|
-
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
1202
|
-
|
|
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(
|
|
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] =
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
1261
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1232
1262
|
else:
|
|
1233
1263
|
await self.phone.vpn.start(10, 60)
|
|
1234
|
-
|
|
1264
|
+
remote_reachable = await self.phone.remote_sftp.start(10, 30)
|
|
1235
1265
|
else:
|
|
1236
|
-
|
|
1266
|
+
remote_reachable = await self.phone.remote_sftp.start(10, 30)
|
|
1237
1267
|
if state[Control.PHONE_WIFI]:
|
|
1238
|
-
|
|
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 (
|
|
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
|
-
|
|
1273
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1244
1274
|
else:
|
|
1245
|
-
|
|
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 (
|
|
1278
|
+
if not (zeroconf_reachable := await self.phone.zeroconf_sftp.test()):
|
|
1249
1279
|
try:
|
|
1250
|
-
|
|
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
|
-
|
|
1283
|
+
remote_reachable = await self.phone.remote_sftp.test()
|
|
1254
1284
|
else:
|
|
1255
1285
|
if not state[Control.PHONE_SFTP]:
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
if not
|
|
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
|
|
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 =
|
|
1263
|
-
state[Control.CONNECTED] = Control.ZEROCONF if
|
|
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
|
|
1293
|
-
"Note: optionally if your laptop is
|
|
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
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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.
|
|
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
|
|
File without changes
|