prim-ctrl 0.6.4__tar.gz → 0.6.5__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.6.4 → prim_ctrl-0.6.5}/PKG-INFO +7 -3
- {prim_ctrl-0.6.4 → prim_ctrl-0.6.5}/README.md +5 -2
- {prim_ctrl-0.6.4 → prim_ctrl-0.6.5}/prim_ctrl/__main__.py +103 -22
- {prim_ctrl-0.6.4 → prim_ctrl-0.6.5}/pyproject.toml +2 -1
- {prim_ctrl-0.6.4 → prim_ctrl-0.6.5}/LICENSE +0 -0
- {prim_ctrl-0.6.4 → prim_ctrl-0.6.5}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
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
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
17
17
|
Requires-Dist: aiohttp (>=3.11.10,<4.0.0)
|
|
18
18
|
Requires-Dist: dnspython (>=2.7.0,<3.0.0)
|
|
19
19
|
Requires-Dist: platformdirs (>=4.3.6,<5.0.0)
|
|
20
|
+
Requires-Dist: tailscale (>=0.6.1,<0.7.0)
|
|
20
21
|
Requires-Dist: zeroconf (>=0.136.2,<0.137.0)
|
|
21
22
|
Project-URL: Change Log, https://github.com/lmagyar/prim-ctrl/blob/main/CHANGELOG.md
|
|
22
23
|
Project-URL: Repository, https://github.com/lmagyar/prim-ctrl
|
|
@@ -160,7 +161,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
|
|
|
160
161
|
### Options
|
|
161
162
|
|
|
162
163
|
```
|
|
163
|
-
usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tailscale tailnet remote-machine-name sftp-port] [--funnel local-machine-name local-port local-path external-port] [-ac] [-b] [-r STATE]
|
|
164
|
+
usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tailscale tailnet remote-machine-name sftp-port] [--funnel local-machine-name local-port local-path external-port secretfile] [-ac] [-b] [-r STATE]
|
|
164
165
|
automate-account automate-device automate-tokenfile server-name
|
|
165
166
|
|
|
166
167
|
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
|
|
@@ -200,11 +201,14 @@ VPN:
|
|
|
200
201
|
tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)
|
|
201
202
|
remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)
|
|
202
203
|
sftp-port: Primitive FTPd's sftp port
|
|
203
|
-
--funnel local-machine-name local-port local-path external-port
|
|
204
|
+
--funnel local-machine-name local-port local-path external-port secretfile
|
|
204
205
|
local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)
|
|
205
206
|
local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)
|
|
206
207
|
local-path: /prim-ctrl - if you used the example tailscale funnel command above
|
|
207
208
|
external-port: 8443 - if you used the example tailscale funnel command above
|
|
209
|
+
secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder
|
|
210
|
+
(generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,
|
|
211
|
+
save only the Client secret in the file, the Client ID is part of it)
|
|
208
212
|
-ac, --accept-cellular in case of start, if WiFi is not connected, don't return error, but start VPN up
|
|
209
213
|
-b, --backup-state in case of start, backup current state to stdout as single string (in case of an error, it will try to restore the original state but will not write it to stdout)
|
|
210
214
|
-r STATE, --restore-state STATE in case of stop, restore previous state from STATE (use -b to get a valid STATE string)
|
|
@@ -136,7 +136,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
|
|
|
136
136
|
### Options
|
|
137
137
|
|
|
138
138
|
```
|
|
139
|
-
usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tailscale tailnet remote-machine-name sftp-port] [--funnel local-machine-name local-port local-path external-port] [-ac] [-b] [-r STATE]
|
|
139
|
+
usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tailscale tailnet remote-machine-name sftp-port] [--funnel local-machine-name local-port local-path external-port secretfile] [-ac] [-b] [-r STATE]
|
|
140
140
|
automate-account automate-device automate-tokenfile server-name
|
|
141
141
|
|
|
142
142
|
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
|
|
@@ -176,11 +176,14 @@ VPN:
|
|
|
176
176
|
tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)
|
|
177
177
|
remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)
|
|
178
178
|
sftp-port: Primitive FTPd's sftp port
|
|
179
|
-
--funnel local-machine-name local-port local-path external-port
|
|
179
|
+
--funnel local-machine-name local-port local-path external-port secretfile
|
|
180
180
|
local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)
|
|
181
181
|
local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)
|
|
182
182
|
local-path: /prim-ctrl - if you used the example tailscale funnel command above
|
|
183
183
|
external-port: 8443 - if you used the example tailscale funnel command above
|
|
184
|
+
secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder
|
|
185
|
+
(generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,
|
|
186
|
+
save only the Client secret in the file, the Client ID is part of it)
|
|
184
187
|
-ac, --accept-cellular in case of start, if WiFi is not connected, don't return error, but start VPN up
|
|
185
188
|
-b, --backup-state in case of start, backup current state to stdout as single string (in case of an error, it will try to restore the original state but will not write it to stdout)
|
|
186
189
|
-r STATE, --restore-state STATE in case of stop, restore previous state from STATE (use -b to get a valid STATE string)
|
|
@@ -13,13 +13,14 @@ from abc import abstractmethod
|
|
|
13
13
|
from contextlib import nullcontext, suppress
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Dict
|
|
17
16
|
|
|
18
17
|
import aiohttp
|
|
19
18
|
import dns.asyncresolver
|
|
20
19
|
import dns.resolver
|
|
21
20
|
from aiohttp import ClientTimeout, web
|
|
22
21
|
from platformdirs import user_cache_dir
|
|
22
|
+
from tailscale import Device as TailscaleDeviceInfo
|
|
23
|
+
from tailscale import Tailscale as TailscaleApi
|
|
23
24
|
from zeroconf import Zeroconf, ServiceInfo, ServiceListener as ZeroconfServiceListener
|
|
24
25
|
from zeroconf.asyncio import AsyncZeroconf
|
|
25
26
|
|
|
@@ -28,7 +29,7 @@ from zeroconf.asyncio import AsyncZeroconf
|
|
|
28
29
|
class LevelFormatter(logging.Formatter):
|
|
29
30
|
logging.Formatter.default_msec_format = logging.Formatter.default_msec_format.replace(',', '.') if logging.Formatter.default_msec_format else None
|
|
30
31
|
|
|
31
|
-
def __init__(self, fmts:
|
|
32
|
+
def __init__(self, fmts: dict[int, str], fmt: str, **kwargs):
|
|
32
33
|
super().__init__()
|
|
33
34
|
self.formatters = dict({level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()})
|
|
34
35
|
self.default_formatter = logging.Formatter(fmt, **kwargs)
|
|
@@ -94,7 +95,10 @@ class LazyStr:
|
|
|
94
95
|
self.result = None
|
|
95
96
|
def __str__(self):
|
|
96
97
|
if self.result is None:
|
|
97
|
-
|
|
98
|
+
if callable(self.func):
|
|
99
|
+
self.result = str(self.func(*self.args, **self.kwargs))
|
|
100
|
+
else:
|
|
101
|
+
self.result = str(self.func)
|
|
98
102
|
return self.result
|
|
99
103
|
|
|
100
104
|
logger = Logger(Path(sys.argv[0]).name)
|
|
@@ -299,8 +303,8 @@ class PhoneState:
|
|
|
299
303
|
class Cache:
|
|
300
304
|
PRIM_SYNC_APP_NAME = 'prim-sync'
|
|
301
305
|
|
|
302
|
-
def __init__(self):
|
|
303
|
-
self.cache_path = Path(user_cache_dir(
|
|
306
|
+
def __init__(self, app_name: str):
|
|
307
|
+
self.cache_path = Path(user_cache_dir(app_name, False))
|
|
304
308
|
|
|
305
309
|
def set(self, key: str, value: str):
|
|
306
310
|
self.cache_path.mkdir(parents=True, exist_ok=True)
|
|
@@ -490,6 +494,56 @@ class Local:
|
|
|
490
494
|
def __init__(self, vpn: Manageable | None):
|
|
491
495
|
self.vpn = vpn
|
|
492
496
|
|
|
497
|
+
class Tailscale():
|
|
498
|
+
TOKEN_SUFFIX = '.token'
|
|
499
|
+
|
|
500
|
+
def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, tailnet: str, secretfile: str):
|
|
501
|
+
self.secrets = secrets
|
|
502
|
+
self.session = session
|
|
503
|
+
self.tailnet = tailnet
|
|
504
|
+
self.secretfile = secretfile
|
|
505
|
+
self.tailscale_api = None
|
|
506
|
+
|
|
507
|
+
async def _start(self):
|
|
508
|
+
# create new access_token from client_secret if previous access_token is expired or nonexistent
|
|
509
|
+
tokenfile = self.secretfile + Tailscale.TOKEN_SUFFIX
|
|
510
|
+
token = None
|
|
511
|
+
try:
|
|
512
|
+
if 3300 > self.secrets.get_age(tokenfile):
|
|
513
|
+
token = self.secrets.get(tokenfile)
|
|
514
|
+
except FileNotFoundError:
|
|
515
|
+
pass
|
|
516
|
+
if token is None:
|
|
517
|
+
secret = self.secrets.get(self.secretfile)
|
|
518
|
+
client_id = secret.split('-')[2]
|
|
519
|
+
data = {
|
|
520
|
+
"client_id": client_id,
|
|
521
|
+
"client_secret": secret,
|
|
522
|
+
"grant_type": "client_credentials",
|
|
523
|
+
"scope" : "devices:core:read"
|
|
524
|
+
}
|
|
525
|
+
logger.debug("Generating new Tailscale API token")
|
|
526
|
+
async with self.session.post('https://api.tailscale.com/api/v2/oauth/token', data=data) as response:
|
|
527
|
+
json_response = await response.json()
|
|
528
|
+
expires_in = json_response.get('expires_in')
|
|
529
|
+
token = json_response.get('access_token')
|
|
530
|
+
assert expires_in is not None and token is not None
|
|
531
|
+
if expires_in < 3600:
|
|
532
|
+
raise RuntimeError(f'Tailscale access token received shorter that 1 hour, {expires_in} seconds expiration')
|
|
533
|
+
self.secrets.set(tokenfile, token)
|
|
534
|
+
self.tailscale_api = TailscaleApi(session=self.session, tailnet=self.tailnet, api_key=token)
|
|
535
|
+
|
|
536
|
+
async def device(self, machine_name: str) -> TailscaleDeviceInfo:
|
|
537
|
+
if not self.tailscale_api:
|
|
538
|
+
await self._start()
|
|
539
|
+
assert self.tailscale_api is not None
|
|
540
|
+
logger.debug("Calling Tailscale API for devices")
|
|
541
|
+
devices = await self.tailscale_api.devices()
|
|
542
|
+
for device in devices.values():
|
|
543
|
+
if device.hostname == machine_name:
|
|
544
|
+
return device
|
|
545
|
+
raise RuntimeError(f"Device {machine_name} in {self.tailnet} is unknown by Tailscale")
|
|
546
|
+
|
|
493
547
|
class LocalTailscaleManager(Manager):
|
|
494
548
|
async def start(self):
|
|
495
549
|
if not (await Subprocess.async_tailscale(['up']))[0]:
|
|
@@ -500,8 +554,10 @@ class LocalTailscaleManager(Manager):
|
|
|
500
554
|
raise RuntimeError("Failed to shut down local Tailscale")
|
|
501
555
|
|
|
502
556
|
class LocalTailscale(Manageable):
|
|
503
|
-
def __init__(self):
|
|
557
|
+
def __init__(self, tailscale: Tailscale | None = None, machine_name: str | None = None):
|
|
504
558
|
super().__init__(LocalTailscaleManager())
|
|
559
|
+
self.tailscale = tailscale
|
|
560
|
+
self.machine_name = machine_name
|
|
505
561
|
self.__qualname__ = "Local Tailscale"
|
|
506
562
|
|
|
507
563
|
async def ping(self, availability_hint: bool | None = None):
|
|
@@ -514,13 +570,31 @@ class LocalTailscale(Manageable):
|
|
|
514
570
|
async def _sleep_while_wait(self, available: bool):
|
|
515
571
|
await asyncio.sleep(0.250)
|
|
516
572
|
|
|
573
|
+
async def start(self, repeat: float, timeout: float):
|
|
574
|
+
if self.tailscale and self.machine_name:
|
|
575
|
+
device_info = await self.tailscale.device(self.machine_name)
|
|
576
|
+
start_result = await super().start(repeat, timeout)
|
|
577
|
+
if start_result and self.tailscale and self.machine_name:
|
|
578
|
+
max_last_seen_age = 7200
|
|
579
|
+
wait_on_fresh_start = 10
|
|
580
|
+
difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
|
|
581
|
+
difference_sec = difference.total_seconds() if difference else None
|
|
582
|
+
if difference_sec is None or difference_sec > max_last_seen_age:
|
|
583
|
+
# wait a little to avoid caching empty DNS entry for 5 minutes, better to loose a few seconds than 300s
|
|
584
|
+
logger.debug("Waiting for %is, because %s is freshly started up and wasn't seen for more than %ih (last seen at %s, %s ago)",
|
|
585
|
+
wait_on_fresh_start, LazyStr(self.get_class_name), max_last_seen_age/3600,
|
|
586
|
+
LazyStr((lambda last_seen : str(last_seen.astimezone())[:19] if last_seen else None), device_info.last_seen), LazyStr(difference))
|
|
587
|
+
await asyncio.sleep(wait_on_fresh_start)
|
|
588
|
+
return start_result
|
|
589
|
+
|
|
517
590
|
class Funnel(Pingable):
|
|
518
591
|
LOCAL_HOST = '127.0.0.1'
|
|
519
592
|
|
|
520
|
-
def __init__(self,
|
|
593
|
+
def __init__(self, tailnet: str, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
594
|
+
self.machine_name = machine_name
|
|
521
595
|
self.local_port = local_port
|
|
522
|
-
self.external_name = f'{machine_name}.{
|
|
523
|
-
self.external_url = f'https://{machine_name}.{
|
|
596
|
+
self.external_name = f'{machine_name}.{tailnet}'
|
|
597
|
+
self.external_url = f'https://{machine_name}.{tailnet}:{external_port}{local_path}'
|
|
524
598
|
|
|
525
599
|
async def wait_for(self, available: bool, timeout: float):
|
|
526
600
|
self._sleepcounter = 0
|
|
@@ -912,7 +986,7 @@ class AutomateControl(Control):
|
|
|
912
986
|
parser.add_argument('automate_account', metavar='automate-account', help="your Google account email you set up in the Automate flow's first Set variable block's Value field")
|
|
913
987
|
parser.add_argument('automate_device', metavar='automate-device', help="the device name you can see at the Automate flow's Cloud receive block's This device field")
|
|
914
988
|
parser.add_argument('automate_tokenfile', metavar='automate-tokenfile', help="filename containing Automates's Secret that located under your .secrets folder\n"
|
|
915
|
-
|
|
989
|
+
"(generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)")
|
|
916
990
|
|
|
917
991
|
Control.setup_parser_arguments(parser)
|
|
918
992
|
|
|
@@ -928,14 +1002,17 @@ class AutomateControl(Control):
|
|
|
928
1002
|
"Note: --backup-state is accurate only, when --funnel is used\n"
|
|
929
1003
|
"Note: --accept-cellular option can be used only when --funnel is used")
|
|
930
1004
|
vpn_group.add_argument('--tailscale', nargs=3, metavar=('tailnet', 'remote-machine-name', 'sftp-port'), help=
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
vpn_group.add_argument('--funnel', nargs=
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1005
|
+
"tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
|
|
1006
|
+
"remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)\n"
|
|
1007
|
+
"sftp-port: Primitive FTPd's sftp port")
|
|
1008
|
+
vpn_group.add_argument('--funnel', nargs=5, metavar=('local-machine-name', 'local-port', 'local-path', 'external-port', 'secretfile'), help=
|
|
1009
|
+
"local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)\n"
|
|
1010
|
+
"local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)\n"
|
|
1011
|
+
"local-path: /prim-ctrl - if you used the example tailscale funnel command above\n"
|
|
1012
|
+
"external-port: 8443 - if you used the example tailscale funnel command above\n"
|
|
1013
|
+
"secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder\n"
|
|
1014
|
+
" (generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,\n"
|
|
1015
|
+
" save only the Client secret in the file, the Client ID is part of it)")
|
|
939
1016
|
Control.setup_parser_vpngroup(vpn_group)
|
|
940
1017
|
|
|
941
1018
|
parser.set_defaults(ctor=AutomateControl)
|
|
@@ -960,18 +1037,22 @@ class AutomateControl(Control):
|
|
|
960
1037
|
connector=aiohttp.TCPConnector(force_close=True)) as session,
|
|
961
1038
|
AsyncZeroconf() as zeroconf
|
|
962
1039
|
):
|
|
963
|
-
service_cache = ServiceCache(Cache())
|
|
1040
|
+
service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
|
|
964
1041
|
service_resolver = SftpServiceResolver(zeroconf)
|
|
965
1042
|
service_listener = pFTPdServiceListener(args.server_name, service_cache)
|
|
966
1043
|
service_browser = SftpServiceBrowser(zeroconf)
|
|
967
1044
|
await service_browser.add_service_listener(service_listener)
|
|
968
1045
|
|
|
969
|
-
|
|
970
|
-
|
|
1046
|
+
secrets = Secrets()
|
|
1047
|
+
automate = Automate(secrets, session, args.automate_account, args.automate_device, args.automate_tokenfile)
|
|
971
1048
|
remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
|
|
972
1049
|
zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
|
|
973
1050
|
remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale else None
|
|
974
|
-
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
|
|
1051
|
+
funnel = Funnel(remote_tailscale.tailnet, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if remote_tailscale and args.funnel else None
|
|
1052
|
+
local_tailscale = (
|
|
1053
|
+
LocalTailscale(Tailscale(secrets, session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
|
|
1054
|
+
LocalTailscale() if args.tailscale else
|
|
1055
|
+
None)
|
|
975
1056
|
|
|
976
1057
|
async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
|
|
977
1058
|
local = Local(local_tailscale)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "prim-ctrl"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.5"
|
|
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>"]
|
|
@@ -22,6 +22,7 @@ aiohttp = "^3.11.10"
|
|
|
22
22
|
platformdirs = "^4.3.6"
|
|
23
23
|
zeroconf = "^0.136.2"
|
|
24
24
|
dnspython = "^2.7.0"
|
|
25
|
+
tailscale = "^0.6.1"
|
|
25
26
|
|
|
26
27
|
[tool.poetry.scripts]
|
|
27
28
|
prim-ctrl = "prim_ctrl.__main__:run"
|
|
File without changes
|
|
File without changes
|