prim-ctrl 0.5.0__tar.gz → 0.6.1__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.5.0 → prim_ctrl-0.6.1}/PKG-INFO +5 -5
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.1}/README.md +4 -4
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.1}/prim_ctrl/__main__.py +181 -99
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.1}/pyproject.toml +1 -1
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.1}/LICENSE +0 -0
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.1}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
|
|
5
5
|
Home-page: https://github.com/lmagyar/prim-ctrl
|
|
6
6
|
License: Apache-2.0
|
|
@@ -27,7 +27,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
|
|
|
27
27
|
|
|
28
28
|
Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
|
|
29
29
|
|
|
30
|
-
But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
|
|
30
|
+
But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
|
|
31
31
|
|
|
32
32
|
With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
|
|
33
33
|
|
|
@@ -39,9 +39,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
|
|
|
39
39
|
|
|
40
40
|
## Features
|
|
41
41
|
|
|
42
|
-
- Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
|
|
42
|
+
- Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
|
|
43
43
|
- Using VPN on cellular can be refused
|
|
44
|
-
- Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
|
|
44
|
+
- Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
|
|
45
45
|
|
|
46
46
|
## Installation
|
|
47
47
|
|
|
@@ -163,7 +163,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
|
|
|
163
163
|
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
|
|
164
164
|
|
|
165
165
|
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)
|
|
166
|
-
Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
|
|
166
|
+
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
|
|
167
167
|
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
|
|
168
168
|
|
|
169
169
|
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
|
|
@@ -5,7 +5,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
|
|
|
5
5
|
|
|
6
6
|
Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
|
|
7
7
|
|
|
8
|
-
But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
|
|
8
|
+
But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
|
|
9
9
|
|
|
10
10
|
With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
|
|
11
11
|
|
|
@@ -17,9 +17,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
|
|
|
17
17
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
20
|
-
- Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
|
|
20
|
+
- Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
|
|
21
21
|
- Using VPN on cellular can be refused
|
|
22
|
-
- Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
|
|
22
|
+
- Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
@@ -141,7 +141,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
|
|
|
141
141
|
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
|
|
142
142
|
|
|
143
143
|
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)
|
|
144
|
-
Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
|
|
144
|
+
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
|
|
145
145
|
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
|
|
146
146
|
|
|
147
147
|
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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import argparse
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
6
7
|
import platform
|
|
@@ -61,6 +62,15 @@ class Logger(logging.Logger):
|
|
|
61
62
|
if self.level == logging.NOTSET:
|
|
62
63
|
self.setLevel(logging.WARNING if silent else logging.INFO)
|
|
63
64
|
|
|
65
|
+
def exception_or_error(self, e: Exception, args):
|
|
66
|
+
if not args or args.debug:
|
|
67
|
+
logger.exception(e)
|
|
68
|
+
else:
|
|
69
|
+
if hasattr(e, '__notes__'):
|
|
70
|
+
logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
|
|
71
|
+
else:
|
|
72
|
+
logger.error(LazyStr(repr, e))
|
|
73
|
+
|
|
64
74
|
def error(self, msg, *args, **kwargs):
|
|
65
75
|
self.exitcode = 1
|
|
66
76
|
super().error(msg, *args, **kwargs)
|
|
@@ -114,6 +124,26 @@ async def async_ping(host, packets: int = 1, timeout: float = 1):
|
|
|
114
124
|
_stdout, _stderr = await proc.communicate()
|
|
115
125
|
return proc.returncode == 0
|
|
116
126
|
|
|
127
|
+
async def async_tailscale(args: list[str]):
|
|
128
|
+
command = ['tailscale']
|
|
129
|
+
command.extend(args)
|
|
130
|
+
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
131
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
132
|
+
stdout, _stderr = await proc.communicate()
|
|
133
|
+
return proc.returncode == 0, stdout
|
|
134
|
+
|
|
135
|
+
async def async_tailscale_up():
|
|
136
|
+
return (await async_tailscale(['up']))[0]
|
|
137
|
+
|
|
138
|
+
async def async_tailscale_down():
|
|
139
|
+
return (await async_tailscale(['down']))[0]
|
|
140
|
+
|
|
141
|
+
async def async_tailscale_is_online():
|
|
142
|
+
success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
143
|
+
if success:
|
|
144
|
+
status = json.loads(stdout)
|
|
145
|
+
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
146
|
+
|
|
117
147
|
########
|
|
118
148
|
|
|
119
149
|
class Secrets:
|
|
@@ -150,8 +180,11 @@ class Pingable:
|
|
|
150
180
|
logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
|
|
151
181
|
async with asyncio.timeout(timeout):
|
|
152
182
|
while await self.ping(available) != available:
|
|
153
|
-
|
|
154
|
-
|
|
183
|
+
await self._sleep_while_wait(available)
|
|
184
|
+
|
|
185
|
+
async def _sleep_while_wait(self, available: bool):
|
|
186
|
+
if not available:
|
|
187
|
+
await asyncio.sleep(1)
|
|
155
188
|
|
|
156
189
|
class Manager:
|
|
157
190
|
@abstractmethod
|
|
@@ -258,10 +291,9 @@ class StateSerializer:
|
|
|
258
291
|
e.add_note("Missing '=' in state")
|
|
259
292
|
raise
|
|
260
293
|
|
|
261
|
-
class
|
|
294
|
+
class PhoneState:
|
|
262
295
|
WIFI = 'wifi'
|
|
263
296
|
PFTPD = 'pftpd'
|
|
264
|
-
NAMES = { WIFI: "Wi-Fi", PFTPD: "pFTPd"}
|
|
265
297
|
|
|
266
298
|
@abstractmethod
|
|
267
299
|
async def get(self, repeat: float, timeout: float) -> dict:
|
|
@@ -374,7 +406,7 @@ class AutomateTailscaleManager(Manager):
|
|
|
374
406
|
async def stop(self):
|
|
375
407
|
await self.automate.send_message('stop-tailscale')
|
|
376
408
|
|
|
377
|
-
class
|
|
409
|
+
class AutomatePhoneState(PhoneState):
|
|
378
410
|
VARIABLE_STATE = 'state'
|
|
379
411
|
|
|
380
412
|
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
|
|
@@ -384,7 +416,7 @@ class AutomateState(State):
|
|
|
384
416
|
self.external_url = external_url
|
|
385
417
|
|
|
386
418
|
async def get(self, repeat: float, timeout: float):
|
|
387
|
-
logger.info("Getting state...")
|
|
419
|
+
logger.info("Getting Phone state...")
|
|
388
420
|
# first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
|
|
389
421
|
# though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
|
|
390
422
|
logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
|
|
@@ -395,23 +427,23 @@ class AutomateState(State):
|
|
|
395
427
|
except:
|
|
396
428
|
raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
|
|
397
429
|
# get state
|
|
398
|
-
logger.debug("Getting state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
399
|
-
self.webhooks.subscribe_variable(
|
|
430
|
+
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
431
|
+
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
400
432
|
try:
|
|
401
433
|
async with asyncio.timeout(timeout):
|
|
402
434
|
while True:
|
|
403
435
|
try:
|
|
404
|
-
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(
|
|
405
|
-
state = await self.webhooks.get_variable(
|
|
436
|
+
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
437
|
+
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
406
438
|
break
|
|
407
439
|
except TimeoutError:
|
|
408
440
|
pass
|
|
409
441
|
except TimeoutError as e:
|
|
410
|
-
e.add_note(f"Can't get value of {
|
|
442
|
+
e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
|
|
411
443
|
raise
|
|
412
444
|
finally:
|
|
413
|
-
self.webhooks.unsubscribe_variable(
|
|
414
|
-
logger.info("
|
|
445
|
+
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
446
|
+
logger.info("Phone state is %s", state)
|
|
415
447
|
return StateSerializer.loads(state)
|
|
416
448
|
|
|
417
449
|
########
|
|
@@ -567,8 +599,8 @@ class ZeroconfService(Manageable):
|
|
|
567
599
|
########
|
|
568
600
|
|
|
569
601
|
class Phone:
|
|
570
|
-
def __init__(self,
|
|
571
|
-
self.
|
|
602
|
+
def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
|
|
603
|
+
self.zeroconf_sftp = zeroconf_sftp
|
|
572
604
|
self.vpn = vpn
|
|
573
605
|
self.remote_sftp = remote_sftp
|
|
574
606
|
self.state = state
|
|
@@ -588,28 +620,56 @@ class pFTPdServiceListener(ServiceListener):
|
|
|
588
620
|
def del_service(self, service_name: str):
|
|
589
621
|
pass
|
|
590
622
|
|
|
591
|
-
class
|
|
592
|
-
|
|
623
|
+
class RemotepFTPd(Service):
|
|
624
|
+
def __init__(self, host: str, port: int, manager: Manager):
|
|
625
|
+
super().__init__(host, port, manager)
|
|
626
|
+
self.__qualname__ = "pFTPd"
|
|
593
627
|
|
|
594
|
-
class
|
|
595
|
-
def __init__(self,
|
|
596
|
-
super().__init__(
|
|
597
|
-
self.__qualname__ = pFTPd
|
|
628
|
+
class ZeroconfpFTPd(ZeroconfService):
|
|
629
|
+
def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
|
|
630
|
+
super().__init__(service_name, service_cache, service_resolver, manager)
|
|
631
|
+
self.__qualname__ = "pFTPd"
|
|
598
632
|
|
|
599
|
-
class
|
|
633
|
+
class RemoteTailscale(Device):
|
|
600
634
|
def __init__(self, tailnet: str, machine_name: str, manager: Manager):
|
|
601
635
|
super().__init__(f'{machine_name}.{tailnet}', manager)
|
|
602
636
|
self.tailnet = tailnet
|
|
637
|
+
self.__qualname__ = "Remote Tailscale"
|
|
603
638
|
|
|
604
639
|
class Funnel:
|
|
605
640
|
LOCAL_HOST = '127.0.0.1'
|
|
606
641
|
|
|
607
|
-
def __init__(self, tailscale:
|
|
642
|
+
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
608
643
|
self.local_port = local_port
|
|
609
644
|
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
610
645
|
|
|
611
646
|
########
|
|
612
647
|
|
|
648
|
+
class Local:
|
|
649
|
+
def __init__(self, vpn: Manageable | None):
|
|
650
|
+
self.vpn = vpn
|
|
651
|
+
|
|
652
|
+
class LocalTailscaleManager(Manager):
|
|
653
|
+
async def start(self):
|
|
654
|
+
await async_tailscale_up()
|
|
655
|
+
|
|
656
|
+
async def stop(self):
|
|
657
|
+
await async_tailscale_down()
|
|
658
|
+
|
|
659
|
+
class LocalTailscale(Manageable):
|
|
660
|
+
def __init__(self):
|
|
661
|
+
super().__init__(LocalTailscaleManager())
|
|
662
|
+
self.__qualname__ = "Local Tailscale"
|
|
663
|
+
|
|
664
|
+
async def ping(self, availability_hint: bool | None = None):
|
|
665
|
+
logger.debug("Pinging %s", LazyStr(self.get_class_name))
|
|
666
|
+
return await async_tailscale_is_online()
|
|
667
|
+
|
|
668
|
+
async def _sleep_while_wait(self, available: bool):
|
|
669
|
+
await asyncio.sleep(0.250)
|
|
670
|
+
|
|
671
|
+
########
|
|
672
|
+
|
|
613
673
|
class WideHelpFormatter(argparse.RawTextHelpFormatter):
|
|
614
674
|
def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 35, width: int | None = None) -> None:
|
|
615
675
|
super().__init__(prog, indent_increment, max_help_position, width)
|
|
@@ -623,11 +683,12 @@ async def gather_with_taskgroup(*coros):
|
|
|
623
683
|
raise eg.exceptions[0] from (None if len(eg.exceptions) == 1 else eg)
|
|
624
684
|
|
|
625
685
|
class Control:
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
686
|
+
PHONE_WIFI = 'remote-wifi'
|
|
687
|
+
LOCAL_VPN = 'local-vpn'
|
|
688
|
+
PHONE_VPN = 'remote-vpn'
|
|
689
|
+
PHONE_SFTP = 'remote-sftp'
|
|
629
690
|
CONNECTED = 'connected'
|
|
630
|
-
|
|
691
|
+
ZEROCONF = 'local'
|
|
631
692
|
REMOTE = 'remote'
|
|
632
693
|
|
|
633
694
|
@staticmethod
|
|
@@ -668,51 +729,74 @@ class Control:
|
|
|
668
729
|
if args.restore_state and args.intent != 'stop':
|
|
669
730
|
raise ValueError("The --restore-state option can be enabled only for the stop intent")
|
|
670
731
|
|
|
671
|
-
async def execute(self, args, phone: Phone):
|
|
672
|
-
async def _stop(restore_state: dict | None):
|
|
673
|
-
if phone.vpn and phone.remote_sftp and await phone.vpn.test():
|
|
674
|
-
if (restore_state is None or not restore_state.get(Control.
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
732
|
+
async def execute(self, args, local: Local, phone: Phone):
|
|
733
|
+
async def _stop(restore_state: dict | None, stop_only_started: bool = False):
|
|
734
|
+
if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
|
|
735
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await phone.remote_sftp.test():
|
|
736
|
+
try:
|
|
737
|
+
await phone.remote_sftp.stop(10, 30)
|
|
738
|
+
except Exception as e:
|
|
739
|
+
logger.exception_or_error(e, args)
|
|
740
|
+
if restore_state is None or not restore_state.get(Control.PHONE_VPN, stop_only_started):
|
|
741
|
+
try:
|
|
742
|
+
await phone.vpn.stop(10, 60)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
logger.exception_or_error(e, args)
|
|
678
745
|
else:
|
|
679
|
-
if (restore_state is None or not restore_state.get(Control.
|
|
680
|
-
|
|
746
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)):
|
|
747
|
+
try:
|
|
748
|
+
await phone.zeroconf_sftp.stop(10, 30)
|
|
749
|
+
except Exception as e:
|
|
750
|
+
logger.exception_or_error(e, args)
|
|
751
|
+
if local.vpn:
|
|
752
|
+
if restore_state is None or not restore_state.get(Control.LOCAL_VPN, stop_only_started):
|
|
753
|
+
try:
|
|
754
|
+
await local.vpn.stop(10, 30)
|
|
755
|
+
except Exception as e:
|
|
756
|
+
logger.exception_or_error(e, args)
|
|
681
757
|
match args.intent:
|
|
682
758
|
case 'test':
|
|
683
|
-
if phone.vpn and phone.remote_sftp and phone.state:
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
elif phone.vpn and phone.remote_sftp and await phone.vpn.test():
|
|
759
|
+
if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
|
|
760
|
+
if await local.vpn.test():
|
|
761
|
+
phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
762
|
+
elif local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
|
|
688
763
|
await phone.remote_sftp.test()
|
|
689
764
|
else:
|
|
690
|
-
await phone.
|
|
765
|
+
await phone.zeroconf_sftp.test()
|
|
691
766
|
case 'start':
|
|
692
|
-
if phone.vpn and phone.remote_sftp:
|
|
767
|
+
if local.vpn and phone.vpn and phone.remote_sftp:
|
|
693
768
|
state = dict()
|
|
694
|
-
local_accessible = False
|
|
695
|
-
remote_accessible = False
|
|
696
|
-
# gather state info
|
|
697
|
-
if phone.state:
|
|
698
|
-
phone_state, vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
699
|
-
state[Control.WIFI] = phone_state[State.WIFI]
|
|
700
|
-
state[Control.VPN] = vpn_state
|
|
701
|
-
state[Control.SFTP] = phone_state[State.PFTPD]
|
|
702
|
-
if not state[Control.WIFI] and not args.accept_cellular:
|
|
703
|
-
raise RuntimeError(f"Phone is not on Wi-Fi network")
|
|
704
|
-
else:
|
|
705
|
-
state[Control.VPN] = await phone.vpn.test()
|
|
706
|
-
if state[Control.VPN]:
|
|
707
|
-
state[Control.SFTP] = remote_accessible = await phone.remote_sftp.test()
|
|
708
|
-
# start changing state
|
|
709
769
|
try:
|
|
770
|
+
# gather local state info
|
|
771
|
+
local_vpn_state = await local.vpn.test()
|
|
772
|
+
state[Control.LOCAL_VPN] = local_vpn_state
|
|
773
|
+
|
|
774
|
+
# start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
|
|
775
|
+
await local.vpn.start(10, 30)
|
|
776
|
+
|
|
777
|
+
zeroconf_accessible = False
|
|
778
|
+
remote_accessible = False
|
|
779
|
+
|
|
780
|
+
# gather phone state info
|
|
710
781
|
if phone.state:
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
782
|
+
phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
783
|
+
state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
|
|
784
|
+
state[Control.PHONE_VPN] = phone_vpn_state
|
|
785
|
+
state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
|
|
786
|
+
if not state[Control.PHONE_WIFI] and not args.accept_cellular:
|
|
787
|
+
raise RuntimeError(f"Phone is not on Wi-Fi network")
|
|
788
|
+
else:
|
|
789
|
+
state[Control.PHONE_VPN] = await phone.vpn.test()
|
|
790
|
+
if state[Control.PHONE_VPN]:
|
|
791
|
+
state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
|
|
792
|
+
|
|
793
|
+
# start changing phone state
|
|
794
|
+
if phone.state:
|
|
795
|
+
if not state[Control.PHONE_SFTP]:
|
|
796
|
+
if not state[Control.PHONE_VPN]:
|
|
797
|
+
if state[Control.PHONE_WIFI]:
|
|
714
798
|
try:
|
|
715
|
-
|
|
799
|
+
zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
|
|
716
800
|
except TimeoutError:
|
|
717
801
|
await phone.vpn.start(10, 60)
|
|
718
802
|
remote_accessible = await phone.remote_sftp.test()
|
|
@@ -721,43 +805,45 @@ class Control:
|
|
|
721
805
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
722
806
|
else:
|
|
723
807
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
724
|
-
if state[Control.
|
|
725
|
-
|
|
808
|
+
if state[Control.PHONE_WIFI]:
|
|
809
|
+
zeroconf_accessible = await phone.zeroconf_sftp.test()
|
|
726
810
|
else:
|
|
727
|
-
if not state[Control.
|
|
728
|
-
if not state[Control.
|
|
811
|
+
if not state[Control.PHONE_VPN]:
|
|
812
|
+
if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
|
|
729
813
|
await phone.vpn.start(10, 60)
|
|
730
814
|
remote_accessible = await phone.remote_sftp.test()
|
|
731
815
|
else:
|
|
732
|
-
|
|
816
|
+
zeroconf_accessible, remote_accessible = await gather_with_taskgroup(phone.zeroconf_sftp.test(), phone.remote_sftp.test())
|
|
733
817
|
else:
|
|
734
|
-
if not state[Control.
|
|
735
|
-
if not (
|
|
818
|
+
if not state[Control.PHONE_VPN]:
|
|
819
|
+
if not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
|
|
736
820
|
try:
|
|
737
|
-
|
|
821
|
+
zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
|
|
738
822
|
except TimeoutError:
|
|
739
823
|
await phone.vpn.start(10, 60)
|
|
740
824
|
remote_accessible = await phone.remote_sftp.test()
|
|
741
825
|
else:
|
|
742
|
-
if not state[Control.
|
|
826
|
+
if not state[Control.PHONE_SFTP]:
|
|
743
827
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
744
|
-
|
|
745
|
-
if not
|
|
828
|
+
zeroconf_accessible = await phone.zeroconf_sftp.test()
|
|
829
|
+
if not zeroconf_accessible and not remote_accessible:
|
|
746
830
|
raise RuntimeError(f"Even when {phone.vpn.get_class_name()} and {phone.remote_sftp.get_class_name()} is started, {phone.remote_sftp.get_class_name()} is still not accessible")
|
|
831
|
+
|
|
832
|
+
# print out result on stdout
|
|
833
|
+
if not args.backup_state:
|
|
834
|
+
state = dict()
|
|
835
|
+
state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
|
|
836
|
+
print(StateSerializer.dumps(state))
|
|
747
837
|
except:
|
|
748
|
-
await _stop(state)
|
|
838
|
+
await _stop(state, stop_only_started = True)
|
|
749
839
|
raise
|
|
750
|
-
if not args.backup_state:
|
|
751
|
-
state = dict()
|
|
752
|
-
state[Control.CONNECTED] = Control.LOCAL if local_accessible else Control.REMOTE
|
|
753
|
-
print(StateSerializer.dumps(state))
|
|
754
840
|
else:
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
await phone.
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
841
|
+
try:
|
|
842
|
+
if not await phone.zeroconf_sftp.test():
|
|
843
|
+
await phone.zeroconf_sftp.start(10, 30)
|
|
844
|
+
except:
|
|
845
|
+
await _stop(None)
|
|
846
|
+
raise
|
|
761
847
|
case 'stop':
|
|
762
848
|
await _stop(StateSerializer.loads(args.restore_state) if args.restore_state else None)
|
|
763
849
|
|
|
@@ -767,7 +853,7 @@ class AutomateControl(Control):
|
|
|
767
853
|
parser = subparsers.add_parser('Automate', aliases=['a'],
|
|
768
854
|
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"
|
|
769
855
|
"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"
|
|
770
|
-
"Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
|
|
856
|
+
"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"
|
|
771
857
|
"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"
|
|
772
858
|
"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",
|
|
773
859
|
formatter_class=WideHelpFormatter)
|
|
@@ -830,15 +916,17 @@ class AutomateControl(Control):
|
|
|
830
916
|
await service_browser.add_service_listener(service_listener)
|
|
831
917
|
|
|
832
918
|
automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
919
|
+
local_tailscale = LocalTailscale() if args.tailscale else None
|
|
920
|
+
remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
|
|
921
|
+
zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
|
|
922
|
+
remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale else None
|
|
923
|
+
funnel = Funnel(remote_tailscale, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if remote_tailscale and args.funnel else None
|
|
837
924
|
|
|
838
925
|
async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
926
|
+
local = Local(local_tailscale)
|
|
927
|
+
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
|
|
928
|
+
phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
|
|
929
|
+
await self.execute(args, local, phone)
|
|
842
930
|
|
|
843
931
|
async def main():
|
|
844
932
|
args = None
|
|
@@ -855,13 +943,7 @@ async def main():
|
|
|
855
943
|
await args.ctor().run(args)
|
|
856
944
|
|
|
857
945
|
except Exception as e:
|
|
858
|
-
|
|
859
|
-
logger.exception(e)
|
|
860
|
-
else:
|
|
861
|
-
if hasattr(e, '__notes__'):
|
|
862
|
-
logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
|
|
863
|
-
else:
|
|
864
|
-
logger.error(LazyStr(repr, e))
|
|
946
|
+
logger.exception_or_error(e, args)
|
|
865
947
|
|
|
866
948
|
return logger.exitcode
|
|
867
949
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "prim-ctrl"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.1"
|
|
4
4
|
description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
|
|
5
5
|
license = "Apache-2.0"
|
|
6
6
|
authors = ["Laszlo Magyar <lmagyar1973@gmail.com>"]
|
|
File without changes
|
|
File without changes
|