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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.6.4
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: Dict[int, str], fmt: str, **kwargs):
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
- self.result = str(self.func(*self.args, **self.kwargs))
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(Cache.PRIM_SYNC_APP_NAME, False))
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, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
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}.{tailscale.tailnet}'
523
- self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
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
- "(generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)")
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
- "tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
932
- "remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)\n"
933
- "sftp-port: Primitive FTPd's sftp port")
934
- vpn_group.add_argument('--funnel', nargs=4, metavar=('local-machine-name', 'local-port', 'local-path', 'external-port'), help=
935
- "local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)\n"
936
- "local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)\n"
937
- "local-path: /prim-ctrl - if you used the example tailscale funnel command above\n"
938
- "external-port: 8443 - if you used the example tailscale funnel command above")
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
- automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
970
- local_tailscale = LocalTailscale() if args.tailscale else None
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.4"
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