prim-ctrl 0.5.0__tar.gz → 0.6.0__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.0}/PKG-INFO +5 -5
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.0}/README.md +4 -4
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.0}/prim_ctrl/__main__.py +132 -75
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.0}/pyproject.toml +1 -1
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.0}/LICENSE +0 -0
- {prim_ctrl-0.5.0 → prim_ctrl-0.6.0}/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.0
|
|
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
|
|
@@ -114,6 +115,26 @@ async def async_ping(host, packets: int = 1, timeout: float = 1):
|
|
|
114
115
|
_stdout, _stderr = await proc.communicate()
|
|
115
116
|
return proc.returncode == 0
|
|
116
117
|
|
|
118
|
+
async def async_tailscale(args: list[str]):
|
|
119
|
+
command = ['tailscale']
|
|
120
|
+
command.extend(args)
|
|
121
|
+
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
122
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
123
|
+
stdout, _stderr = await proc.communicate()
|
|
124
|
+
return proc.returncode == 0, stdout
|
|
125
|
+
|
|
126
|
+
async def async_tailscale_up():
|
|
127
|
+
return (await async_tailscale(['up']))[0]
|
|
128
|
+
|
|
129
|
+
async def async_tailscale_down():
|
|
130
|
+
return (await async_tailscale(['down']))[0]
|
|
131
|
+
|
|
132
|
+
async def async_tailscale_is_online():
|
|
133
|
+
success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
134
|
+
if success:
|
|
135
|
+
status = json.loads(stdout)
|
|
136
|
+
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
137
|
+
|
|
117
138
|
########
|
|
118
139
|
|
|
119
140
|
class Secrets:
|
|
@@ -258,10 +279,9 @@ class StateSerializer:
|
|
|
258
279
|
e.add_note("Missing '=' in state")
|
|
259
280
|
raise
|
|
260
281
|
|
|
261
|
-
class
|
|
282
|
+
class PhoneState:
|
|
262
283
|
WIFI = 'wifi'
|
|
263
284
|
PFTPD = 'pftpd'
|
|
264
|
-
NAMES = { WIFI: "Wi-Fi", PFTPD: "pFTPd"}
|
|
265
285
|
|
|
266
286
|
@abstractmethod
|
|
267
287
|
async def get(self, repeat: float, timeout: float) -> dict:
|
|
@@ -374,7 +394,7 @@ class AutomateTailscaleManager(Manager):
|
|
|
374
394
|
async def stop(self):
|
|
375
395
|
await self.automate.send_message('stop-tailscale')
|
|
376
396
|
|
|
377
|
-
class
|
|
397
|
+
class AutomatePhoneState(PhoneState):
|
|
378
398
|
VARIABLE_STATE = 'state'
|
|
379
399
|
|
|
380
400
|
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
|
|
@@ -384,7 +404,7 @@ class AutomateState(State):
|
|
|
384
404
|
self.external_url = external_url
|
|
385
405
|
|
|
386
406
|
async def get(self, repeat: float, timeout: float):
|
|
387
|
-
logger.info("Getting state...")
|
|
407
|
+
logger.info("Getting Phone state...")
|
|
388
408
|
# first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
|
|
389
409
|
# 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
410
|
logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
|
|
@@ -395,23 +415,23 @@ class AutomateState(State):
|
|
|
395
415
|
except:
|
|
396
416
|
raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
|
|
397
417
|
# get state
|
|
398
|
-
logger.debug("Getting state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
399
|
-
self.webhooks.subscribe_variable(
|
|
418
|
+
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
419
|
+
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
400
420
|
try:
|
|
401
421
|
async with asyncio.timeout(timeout):
|
|
402
422
|
while True:
|
|
403
423
|
try:
|
|
404
|
-
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(
|
|
405
|
-
state = await self.webhooks.get_variable(
|
|
424
|
+
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
425
|
+
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
406
426
|
break
|
|
407
427
|
except TimeoutError:
|
|
408
428
|
pass
|
|
409
429
|
except TimeoutError as e:
|
|
410
|
-
e.add_note(f"Can't get value of {
|
|
430
|
+
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
431
|
raise
|
|
412
432
|
finally:
|
|
413
|
-
self.webhooks.unsubscribe_variable(
|
|
414
|
-
logger.info("
|
|
433
|
+
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
434
|
+
logger.info("Phone state is %s", state)
|
|
415
435
|
return StateSerializer.loads(state)
|
|
416
436
|
|
|
417
437
|
########
|
|
@@ -567,8 +587,8 @@ class ZeroconfService(Manageable):
|
|
|
567
587
|
########
|
|
568
588
|
|
|
569
589
|
class Phone:
|
|
570
|
-
def __init__(self,
|
|
571
|
-
self.
|
|
590
|
+
def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
|
|
591
|
+
self.zeroconf_sftp = zeroconf_sftp
|
|
572
592
|
self.vpn = vpn
|
|
573
593
|
self.remote_sftp = remote_sftp
|
|
574
594
|
self.state = state
|
|
@@ -588,28 +608,53 @@ class pFTPdServiceListener(ServiceListener):
|
|
|
588
608
|
def del_service(self, service_name: str):
|
|
589
609
|
pass
|
|
590
610
|
|
|
591
|
-
class
|
|
592
|
-
|
|
611
|
+
class RemotepFTPd(Service):
|
|
612
|
+
def __init__(self, host: str, port: int, manager: Manager):
|
|
613
|
+
super().__init__(host, port, manager)
|
|
614
|
+
self.__qualname__ = "pFTPd"
|
|
593
615
|
|
|
594
|
-
class
|
|
595
|
-
def __init__(self,
|
|
596
|
-
super().__init__(
|
|
597
|
-
self.__qualname__ = pFTPd
|
|
616
|
+
class ZeroconfpFTPd(ZeroconfService):
|
|
617
|
+
def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
|
|
618
|
+
super().__init__(service_name, service_cache, service_resolver, manager)
|
|
619
|
+
self.__qualname__ = "pFTPd"
|
|
598
620
|
|
|
599
|
-
class
|
|
621
|
+
class RemoteTailscale(Device):
|
|
600
622
|
def __init__(self, tailnet: str, machine_name: str, manager: Manager):
|
|
601
623
|
super().__init__(f'{machine_name}.{tailnet}', manager)
|
|
602
624
|
self.tailnet = tailnet
|
|
625
|
+
self.__qualname__ = "Remote Tailscale"
|
|
603
626
|
|
|
604
627
|
class Funnel:
|
|
605
628
|
LOCAL_HOST = '127.0.0.1'
|
|
606
629
|
|
|
607
|
-
def __init__(self, tailscale:
|
|
630
|
+
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
608
631
|
self.local_port = local_port
|
|
609
632
|
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
610
633
|
|
|
611
634
|
########
|
|
612
635
|
|
|
636
|
+
class Local:
|
|
637
|
+
def __init__(self, vpn: Manageable | None):
|
|
638
|
+
self.vpn = vpn
|
|
639
|
+
|
|
640
|
+
class LocalTailscaleManager(Manager):
|
|
641
|
+
async def start(self):
|
|
642
|
+
await async_tailscale_up()
|
|
643
|
+
|
|
644
|
+
async def stop(self):
|
|
645
|
+
await async_tailscale_down()
|
|
646
|
+
|
|
647
|
+
class LocalTailscale(Manageable):
|
|
648
|
+
def __init__(self):
|
|
649
|
+
super().__init__(LocalTailscaleManager())
|
|
650
|
+
self.__qualname__ = "Local Tailscale"
|
|
651
|
+
|
|
652
|
+
async def ping(self, availability_hint: bool | None = None):
|
|
653
|
+
logger.debug("Pinging %s", LazyStr(self.get_class_name))
|
|
654
|
+
return await async_tailscale_is_online()
|
|
655
|
+
|
|
656
|
+
########
|
|
657
|
+
|
|
613
658
|
class WideHelpFormatter(argparse.RawTextHelpFormatter):
|
|
614
659
|
def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 35, width: int | None = None) -> None:
|
|
615
660
|
super().__init__(prog, indent_increment, max_help_position, width)
|
|
@@ -623,11 +668,12 @@ async def gather_with_taskgroup(*coros):
|
|
|
623
668
|
raise eg.exceptions[0] from (None if len(eg.exceptions) == 1 else eg)
|
|
624
669
|
|
|
625
670
|
class Control:
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
671
|
+
PHONE_WIFI = 'remote-wifi'
|
|
672
|
+
LOCAL_VPN = 'local-vpn'
|
|
673
|
+
PHONE_VPN = 'remote-vpn'
|
|
674
|
+
PHONE_SFTP = 'remote-sftp'
|
|
629
675
|
CONNECTED = 'connected'
|
|
630
|
-
|
|
676
|
+
ZEROCONF = 'local'
|
|
631
677
|
REMOTE = 'remote'
|
|
632
678
|
|
|
633
679
|
@staticmethod
|
|
@@ -668,51 +714,60 @@ class Control:
|
|
|
668
714
|
if args.restore_state and args.intent != 'stop':
|
|
669
715
|
raise ValueError("The --restore-state option can be enabled only for the stop intent")
|
|
670
716
|
|
|
671
|
-
async def execute(self, args, phone: Phone):
|
|
717
|
+
async def execute(self, args, local: Local, phone: Phone):
|
|
672
718
|
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.
|
|
719
|
+
if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
|
|
720
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)) and await phone.remote_sftp.test():
|
|
675
721
|
await phone.remote_sftp.stop(10, 30)
|
|
676
|
-
if restore_state is None or not restore_state.get(Control.
|
|
722
|
+
if restore_state is None or not restore_state.get(Control.PHONE_VPN, False):
|
|
677
723
|
await phone.vpn.stop(10, 60)
|
|
678
724
|
else:
|
|
679
|
-
if (restore_state is None or not restore_state.get(Control.
|
|
680
|
-
await phone.
|
|
725
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, False)):
|
|
726
|
+
await phone.zeroconf_sftp.stop(10, 30)
|
|
727
|
+
if local.vpn:
|
|
728
|
+
if restore_state is None or not restore_state.get(Control.LOCAL_VPN, False):
|
|
729
|
+
await local.vpn.stop(10, 30)
|
|
681
730
|
match args.intent:
|
|
682
731
|
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():
|
|
732
|
+
if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
|
|
733
|
+
if await local.vpn.test():
|
|
734
|
+
phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
735
|
+
elif local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
|
|
688
736
|
await phone.remote_sftp.test()
|
|
689
737
|
else:
|
|
690
|
-
await phone.
|
|
738
|
+
await phone.zeroconf_sftp.test()
|
|
691
739
|
case 'start':
|
|
692
|
-
if phone.vpn and phone.remote_sftp:
|
|
740
|
+
if local.vpn and phone.vpn and phone.remote_sftp:
|
|
693
741
|
state = dict()
|
|
694
|
-
|
|
742
|
+
zeroconf_accessible = False
|
|
695
743
|
remote_accessible = False
|
|
696
|
-
|
|
744
|
+
|
|
745
|
+
# gather local state info
|
|
746
|
+
local_vpn_state = await local.vpn.test()
|
|
747
|
+
state[Control.LOCAL_VPN] = local_vpn_state
|
|
748
|
+
# start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
|
|
749
|
+
await local.vpn.start(10, 30)
|
|
750
|
+
|
|
751
|
+
# gather remote state info
|
|
697
752
|
if phone.state:
|
|
698
|
-
phone_state,
|
|
699
|
-
state[Control.
|
|
700
|
-
state[Control.
|
|
701
|
-
state[Control.
|
|
702
|
-
if not state[Control.
|
|
753
|
+
phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
754
|
+
state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
|
|
755
|
+
state[Control.PHONE_VPN] = phone_vpn_state
|
|
756
|
+
state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
|
|
757
|
+
if not state[Control.PHONE_WIFI] and not args.accept_cellular:
|
|
703
758
|
raise RuntimeError(f"Phone is not on Wi-Fi network")
|
|
704
759
|
else:
|
|
705
|
-
state[Control.
|
|
706
|
-
if state[Control.
|
|
707
|
-
state[Control.
|
|
708
|
-
# start changing state
|
|
760
|
+
state[Control.PHONE_VPN] = await phone.vpn.test()
|
|
761
|
+
if state[Control.PHONE_VPN]:
|
|
762
|
+
state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
|
|
763
|
+
# start changing remote state
|
|
709
764
|
try:
|
|
710
765
|
if phone.state:
|
|
711
|
-
if not state[Control.
|
|
712
|
-
if not state[Control.
|
|
713
|
-
if state[Control.
|
|
766
|
+
if not state[Control.PHONE_SFTP]:
|
|
767
|
+
if not state[Control.PHONE_VPN]:
|
|
768
|
+
if state[Control.PHONE_WIFI]:
|
|
714
769
|
try:
|
|
715
|
-
|
|
770
|
+
zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
|
|
716
771
|
except TimeoutError:
|
|
717
772
|
await phone.vpn.start(10, 60)
|
|
718
773
|
remote_accessible = await phone.remote_sftp.test()
|
|
@@ -721,40 +776,40 @@ class Control:
|
|
|
721
776
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
722
777
|
else:
|
|
723
778
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
724
|
-
if state[Control.
|
|
725
|
-
|
|
779
|
+
if state[Control.PHONE_WIFI]:
|
|
780
|
+
zeroconf_accessible = await phone.zeroconf_sftp.test()
|
|
726
781
|
else:
|
|
727
|
-
if not state[Control.
|
|
728
|
-
if not state[Control.
|
|
782
|
+
if not state[Control.PHONE_VPN]:
|
|
783
|
+
if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
|
|
729
784
|
await phone.vpn.start(10, 60)
|
|
730
785
|
remote_accessible = await phone.remote_sftp.test()
|
|
731
786
|
else:
|
|
732
|
-
|
|
787
|
+
zeroconf_accessible, remote_accessible = await gather_with_taskgroup(phone.zeroconf_sftp.test(), phone.remote_sftp.test())
|
|
733
788
|
else:
|
|
734
|
-
if not state[Control.
|
|
735
|
-
if not (
|
|
789
|
+
if not state[Control.PHONE_VPN]:
|
|
790
|
+
if not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
|
|
736
791
|
try:
|
|
737
|
-
|
|
792
|
+
zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
|
|
738
793
|
except TimeoutError:
|
|
739
794
|
await phone.vpn.start(10, 60)
|
|
740
795
|
remote_accessible = await phone.remote_sftp.test()
|
|
741
796
|
else:
|
|
742
|
-
if not state[Control.
|
|
797
|
+
if not state[Control.PHONE_SFTP]:
|
|
743
798
|
remote_accessible = await phone.remote_sftp.start(10, 30)
|
|
744
|
-
|
|
745
|
-
if not
|
|
799
|
+
zeroconf_accessible = await phone.zeroconf_sftp.test()
|
|
800
|
+
if not zeroconf_accessible and not remote_accessible:
|
|
746
801
|
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")
|
|
747
802
|
except:
|
|
748
803
|
await _stop(state)
|
|
749
804
|
raise
|
|
750
805
|
if not args.backup_state:
|
|
751
806
|
state = dict()
|
|
752
|
-
state[Control.CONNECTED] = Control.
|
|
807
|
+
state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
|
|
753
808
|
print(StateSerializer.dumps(state))
|
|
754
809
|
else:
|
|
755
|
-
if not await phone.
|
|
810
|
+
if not await phone.zeroconf_sftp.test():
|
|
756
811
|
try:
|
|
757
|
-
await phone.
|
|
812
|
+
await phone.zeroconf_sftp.start(10, 30)
|
|
758
813
|
except:
|
|
759
814
|
await _stop(None)
|
|
760
815
|
raise
|
|
@@ -767,7 +822,7 @@ class AutomateControl(Control):
|
|
|
767
822
|
parser = subparsers.add_parser('Automate', aliases=['a'],
|
|
768
823
|
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
824
|
"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"
|
|
825
|
+
"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
826
|
"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
827
|
"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
828
|
formatter_class=WideHelpFormatter)
|
|
@@ -830,15 +885,17 @@ class AutomateControl(Control):
|
|
|
830
885
|
await service_browser.add_service_listener(service_listener)
|
|
831
886
|
|
|
832
887
|
automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
888
|
+
local_tailscale = LocalTailscale() if args.tailscale else None
|
|
889
|
+
remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
|
|
890
|
+
zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
|
|
891
|
+
remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale else None
|
|
892
|
+
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
893
|
|
|
838
894
|
async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
895
|
+
local = Local(local_tailscale)
|
|
896
|
+
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
|
|
897
|
+
phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
|
|
898
|
+
await self.execute(args, local, phone)
|
|
842
899
|
|
|
843
900
|
async def main():
|
|
844
901
|
args = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "prim-ctrl"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
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
|