prim-ctrl 0.6.5__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.6.5
3
+ Version: 0.7.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
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Requires-Dist: aiohttp (>=3.11.10,<4.0.0)
18
+ Requires-Dist: asyncssh (>=2.19.0,<3.0.0)
18
19
  Requires-Dist: dnspython (>=2.7.0,<3.0.0)
19
20
  Requires-Dist: platformdirs (>=4.3.6,<5.0.0)
20
21
  Requires-Dist: tailscale (>=0.6.1,<0.7.0)
@@ -107,7 +108,8 @@ Optionally, if you want to edit or even contribute to the source, you also need
107
108
  - Click on the flow, edit the 2. block ("Set variable google_account to...), enter your Google account and press Save
108
109
  - Start the flow
109
110
  - Settings
110
- - Run on system startup: enable
111
+ - Safety
112
+ - Run on system startup: enable
111
113
 
112
114
  ### Primitive FTPd
113
115
 
@@ -146,15 +148,15 @@ Notes:
146
148
  <details><summary>Ubuntu</summary>
147
149
 
148
150
  ```
149
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i start -b
150
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i stop -r ${PREV_STATE}
151
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i start -b
152
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i stop -r ${PREV_STATE}
151
153
  ```
152
154
  </details>
153
155
  <details><summary>Windows</summary>
154
156
 
155
157
  ```
156
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i start -b
157
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i stop -r !PREV_STATE!
158
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i start -b
159
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i stop -r !PREV_STATE!
158
160
  ```
159
161
  </details>
160
162
 
@@ -162,7 +164,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
162
164
 
163
165
  ```
164
166
  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]
165
- automate-account automate-device automate-tokenfile server-name
167
+ automate-account automate-device automate-tokenfile server-name keyfile
166
168
 
167
169
  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
168
170
 
@@ -178,6 +180,7 @@ positional arguments:
178
180
  automate-tokenfile filename containing Automates's Secret that located under your .secrets folder
179
181
  (generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)
180
182
  server-name the Servername configuration option from Primitive FTPd app
183
+ keyfile private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details
181
184
 
182
185
  options:
183
186
  -h, --help show this help message and exit
@@ -82,7 +82,8 @@ Optionally, if you want to edit or even contribute to the source, you also need
82
82
  - Click on the flow, edit the 2. block ("Set variable google_account to...), enter your Google account and press Save
83
83
  - Start the flow
84
84
  - Settings
85
- - Run on system startup: enable
85
+ - Safety
86
+ - Run on system startup: enable
86
87
 
87
88
  ### Primitive FTPd
88
89
 
@@ -121,15 +122,15 @@ Notes:
121
122
  <details><summary>Ubuntu</summary>
122
123
 
123
124
  ```
124
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i start -b
125
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i stop -r ${PREV_STATE}
125
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i start -b
126
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i stop -r ${PREV_STATE}
126
127
  ```
127
128
  </details>
128
129
  <details><summary>Windows</summary>
129
130
 
130
131
  ```
131
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i start -b
132
- prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 -t -i stop -r !PREV_STATE!
132
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i start -b
133
+ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-phone-pftpd id_ed25519_sftp --tailscale tailxxxx.ts.net your-phone 2222 --funnel your-laptop 12345 /prim-ctrl 8443 tailscale-secretfile -t -i stop -r !PREV_STATE!
133
134
  ```
134
135
  </details>
135
136
 
@@ -137,7 +138,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
137
138
 
138
139
  ```
139
140
  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
- automate-account automate-device automate-tokenfile server-name
141
+ automate-account automate-device automate-tokenfile server-name keyfile
141
142
 
142
143
  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
143
144
 
@@ -153,6 +154,7 @@ positional arguments:
153
154
  automate-tokenfile filename containing Automates's Secret that located under your .secrets folder
154
155
  (generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)
155
156
  server-name the Servername configuration option from Primitive FTPd app
157
+ keyfile private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details
156
158
 
157
159
  options:
158
160
  -h, --help show this help message and exit
@@ -13,11 +13,15 @@ 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 Callable
16
17
 
17
18
  import aiohttp
19
+ import asyncssh
18
20
  import dns.asyncresolver
19
21
  import dns.resolver
22
+ import dns.rdatatype
20
23
  from aiohttp import ClientTimeout, web
24
+ from aiohttp.abc import AbstractResolver as DnsResolver, ResolveResult
21
25
  from platformdirs import user_cache_dir
22
26
  from tailscale import Device as TailscaleDeviceInfo
23
27
  from tailscale import Tailscale as TailscaleApi
@@ -107,21 +111,21 @@ logger = Logger(Path(sys.argv[0]).name)
107
111
 
108
112
  class Subprocess:
109
113
 
110
- # based on https://stackoverflow.com/a/55656177/2755656
111
- @staticmethod
112
- def sync_ping(host, packets: int = 1, timeout: float = 1):
113
- if platform.system().lower() == 'windows':
114
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
115
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
116
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
117
- return result.returncode == 0 and b'TTL=' in result.stdout
118
- else:
119
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
120
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
121
- return result.returncode == 0
114
+ # # based on https://stackoverflow.com/a/55656177/2755656
115
+ # @staticmethod
116
+ # def sync_ping(host, packets: int = 1, timeout: float = 1):
117
+ # if platform.system().lower() == 'windows':
118
+ # command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
119
+ # # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
120
+ # result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
121
+ # return result.returncode == 0 and b'TTL=' in result.stdout
122
+ # else:
123
+ # command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
124
+ # result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
125
+ # return result.returncode == 0
122
126
 
123
127
  @staticmethod
124
- async def async_ping(host, packets: int = 1, timeout: float = 1):
128
+ async def ping(host, packets: int = 1, timeout: float = 1):
125
129
  if platform.system().lower() == 'windows':
126
130
  command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
127
131
  # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
@@ -135,7 +139,7 @@ class Subprocess:
135
139
  return proc.returncode == 0
136
140
 
137
141
  @staticmethod
138
- async def async_tailscale(args: list[str]):
142
+ async def tailscale(args: list[str]):
139
143
  command = ['tailscale']
140
144
  command.extend(args)
141
145
  creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
@@ -145,23 +149,61 @@ class Subprocess:
145
149
 
146
150
  ########
147
151
 
148
- class Secrets:
149
- DIR_NAME = '.secrets'
152
+ # resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
153
+ # Note: aiohttp's AsyncResolver can't be used when asyncio.create_subprocess_exec is used
154
+ # aiohttp's AsyncResolver uses aiodns, that needs a SelectorEventLoop on Windows, and that's loop.subprocess_exec is not implemented, but required by asyncio.create_subprocess_exec
155
+ class ExternalDnsResolver(DnsResolver):
156
+ EXTERNAL_DNS = '1.1.1.1'
150
157
 
151
158
  def __init__(self):
152
- self.secrets_path = Path.home() / Secrets.DIR_NAME
153
-
154
- def get(self, tokenfile: str):
155
- with open(str(self.secrets_path / tokenfile), 'rt') as file:
156
- return file.readline().rstrip()
159
+ self.dns_resolver = None
160
+ self.cache = dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]]()
161
+
162
+ async def resolve(self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_UNSPEC) -> list[ResolveResult]:
163
+ logger.debug("Resolving DNS for %s:%i (%s)", host, port, "ipv6" if family == socket.AF_INET6 else "ipv4")
164
+
165
+ key = (host, port, family)
166
+ expiration, hosts = self.cache.get(key, (None, None))
167
+ if expiration and hosts:
168
+ if time.time() < expiration:
169
+ logger.debug(" Found in cache")
170
+ return hosts
171
+ else:
172
+ del self.cache[key]
173
+ # in case of a long-running process, we should regularly delete other expired items also
157
174
 
158
- def set(self, tokenfile: str, token: str):
159
- self.secrets_path.mkdir(parents=True, exist_ok=True)
160
- with open(str(self.secrets_path / tokenfile), 'wt') as file:
161
- file.write(token)
175
+ try:
176
+ if not self.dns_resolver:
177
+ self.dns_resolver = await dns.asyncresolver.make_resolver_at(ExternalDnsResolver.EXTERNAL_DNS)
178
+ answer = await self.dns_resolver.resolve(host, rdtype=dns.rdatatype.AAAA if family == socket.AF_INET6 else dns.rdatatype.A)
179
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as exc:
180
+ msg = exc.args[1] if len(exc.args) >= 1 else "DNS lookup failed"
181
+ raise OSError(None, msg) from exc
182
+
183
+ hosts = []
184
+ for rr in answer:
185
+ address = rr.to_text()
186
+ logger.debug(" Resolved as: %s", address)
187
+ hosts.append(
188
+ ResolveResult(
189
+ hostname=host,
190
+ host=address,
191
+ port=port,
192
+ family=family,
193
+ proto=0,
194
+ flags=socket.AI_NUMERICHOST,
195
+ )
196
+ )
197
+ if not hosts:
198
+ raise OSError(None, "DNS lookup failed")
199
+
200
+ self.cache[key] = (answer.expiration, hosts)
201
+ return hosts
202
+
203
+ async def close(self):
204
+ pass
162
205
 
163
- def get_age(self, tokenfile: str):
164
- return (datetime.now(timezone.utc) - datetime.fromtimestamp(os.stat(str(self.secrets_path / tokenfile)).st_mtime, timezone.utc)).total_seconds()
206
+ ########
165
207
 
166
208
  class Pingable:
167
209
  @abstractmethod
@@ -240,23 +282,80 @@ class Service(Manageable):
240
282
  self.host = host
241
283
  self.port = port
242
284
 
243
- async def ping(self, _availability_hint: bool | None = None):
244
- async def _connect(connect_timeout: float):
245
- logger.debug(" Connecting to %s on port %d (timeout is %ds)", self.host, self.port, connect_timeout)
246
- async with asyncio.timeout(connect_timeout):
247
- return await asyncio.open_connection(self.host, self.port)
248
- logger.debug("Pinging %s (%s:%d)", LazyStr(self.get_class_name), self.host, self.port)
249
- try:
250
- _reader, writer = await _connect(2)
285
+ self._connect_timeout = 2
286
+ self._special_exceptions = ()
287
+ self._special_exceptions_handler : Callable[[Exception], bool] | None = None
288
+
289
+ async def _connect(self, host: str, port: int):
290
+ logger.debug(" Connecting with TCP to %s:%d (timeout is %ds)", host, port, self._connect_timeout)
291
+ async with asyncio.timeout(self._connect_timeout):
292
+ _reader, writer = await asyncio.open_connection(host, port)
251
293
  writer.close()
252
294
  await writer.wait_closed()
295
+
296
+ async def _ping(self, availability_hint: bool | None = None):
297
+ logger.debug("Pinging %s (%s:%s)", LazyStr(self.get_class_name), str(self.host), str(self.port))
298
+ await self._connect(self.host, self.port)
299
+
300
+ async def ping(self, availability_hint: bool | None = None):
301
+ try:
302
+ await self._ping(availability_hint)
253
303
  return True
254
304
  except (TimeoutError, socket.gaierror, ConnectionRefusedError):
255
305
  return False
306
+ except self._special_exceptions as e:
307
+ if availability_hint is None or availability_hint:
308
+ if self._special_exceptions_handler:
309
+ if self._special_exceptions_handler(e):
310
+ raise
311
+ else:
312
+ return False
313
+ else:
314
+ logger.debug(" Unexpected ping exception: %s", LazyStr(e))
315
+ raise
316
+ else:
317
+ return False
256
318
  except Exception as e:
257
- logger.debug(" Unexpected ping exception: %s", e.__str__())
319
+ logger.debug(" Unexpected ping exception: %s", LazyStr(e))
258
320
  raise
259
321
 
322
+ class SshService(Service):
323
+ def __init__( self, host: str, port: int, host_name: str, keyfile: str, manager: Manager, **kw):
324
+ super().__init__(host=host, port=port, manager=manager, **kw)
325
+ self.host_name = host_name
326
+ self.keyfile = keyfile
327
+
328
+ self._connect_timeout = 3
329
+ self._special_exceptions = (asyncssh.misc.HostKeyNotVerifiable, asyncssh.misc.PermissionDenied, asyncssh.misc.DisconnectError)
330
+ def _handle_special_exceptions(e: Exception):
331
+ if isinstance(e, asyncssh.misc.HostKeyNotVerifiable):
332
+ e.add_note("Check your known_hosts file, see the documentation of prim-sync for more details")
333
+ elif isinstance(e, asyncssh.misc.PermissionDenied):
334
+ e.add_note("Check your private SSH key file, see the documentation of prim-sync for more details")
335
+ else:
336
+ return False
337
+ return True
338
+ self._special_exceptions_handler = _handle_special_exceptions
339
+
340
+ async def _connect(self, host: str, port: int):
341
+ logger.debug(" Connecting with SSH to %s:%d (timeout is %ds)", host, port, self._connect_timeout)
342
+ def _client_key():
343
+ try:
344
+ return asyncssh.read_private_key(str(Path.home() / ".ssh" / self.keyfile))
345
+ except asyncssh.KeyImportError:
346
+ # if client key is encrypted, do not specify any key, by default asyncssh first will try to use an ssh-agent to find a decrypted key
347
+ # if client key is NOT encrypted, specify it, because asyncssh will try only the default key file names
348
+ # this is the exact opposite of Paramiko, where we can always specify a key, only when it is encrypted will Paramiko try an ssh-agent
349
+ return ()
350
+ async with (
351
+ # by default it will search for the known_hosts in str(Path.home() / ".ssh" / "known_hosts")
352
+ asyncssh.connect(host, port, options=asyncssh.SSHClientConnectionOptions(
353
+ host_key_alias=self.host_name,
354
+ client_keys=_client_key(),
355
+ connect_timeout=self._connect_timeout)) as conn
356
+ ):
357
+ pass
358
+
260
359
  class Device(Manageable):
261
360
  def __init__(self, host: str, manager: Manager):
262
361
  super().__init__(manager)
@@ -264,7 +363,7 @@ class Device(Manageable):
264
363
 
265
364
  async def ping(self, availability_hint: bool | None = None):
266
365
  logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
267
- return await Subprocess.async_ping(self.host, timeout=2)
366
+ return await Subprocess.ping(self.host, timeout=2)
268
367
 
269
368
  class StateSerializer:
270
369
  BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
@@ -300,6 +399,24 @@ class PhoneState:
300
399
 
301
400
  ########
302
401
 
402
+ class Secrets:
403
+ DIR_NAME = '.secrets'
404
+
405
+ def __init__(self):
406
+ self.secrets_path = Path.home() / Secrets.DIR_NAME
407
+
408
+ def get(self, tokenfile: str):
409
+ with open(str(self.secrets_path / tokenfile), 'rt') as file:
410
+ return file.readline().rstrip()
411
+
412
+ def set(self, tokenfile: str, token: str):
413
+ self.secrets_path.mkdir(parents=True, exist_ok=True)
414
+ with open(str(self.secrets_path / tokenfile), 'wt') as file:
415
+ file.write(token)
416
+
417
+ def get_age(self, tokenfile: str):
418
+ return (datetime.now(timezone.utc) - datetime.fromtimestamp(os.stat(str(self.secrets_path / tokenfile)).st_mtime, timezone.utc)).total_seconds()
419
+
303
420
  class Cache:
304
421
  PRIM_SYNC_APP_NAME = 'prim-sync'
305
422
 
@@ -321,6 +438,8 @@ class Cache:
321
438
  else:
322
439
  return None
323
440
 
441
+ ########
442
+
324
443
  class ServiceCache:
325
444
  def __init__(self, cache: Cache):
326
445
  self.cache = cache
@@ -398,66 +517,57 @@ class SftpServiceBrowser(ServiceBrowser):
398
517
  def __init__(self, zeroconf: AsyncZeroconf):
399
518
  super().__init__(zeroconf, SFTP_SERVICE_TYPE)
400
519
 
401
- class ZeroconfService(Manageable):
402
- def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
403
- super().__init__(manager)
520
+ class ZeroconfService(Service):
521
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager, **kw):
522
+ super().__init__(host=None, port=None, manager=manager, **kw) # type: ignore
404
523
  self.service_name = service_name
405
- self.host = None
406
- self.port = None
407
524
  self.service_cache = service_cache
408
525
  self.service_resolver = service_resolver
409
526
 
410
- async def ping(self, availability_hint: bool | None = None):
411
- async def _connect(connect_timeout: float, resolve_timeout: float):
412
- async def _asyncio_open_connection(host: str, port: int, timeout: float):
413
- logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
414
- async with asyncio.timeout(timeout):
415
- return await asyncio.open_connection(host, port)
416
- async def _service_resolver_get(service_name: str, timeout: float):
417
- logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
418
- return await self.service_resolver.get(service_name, timeout)
419
- if self.host and self.port:
420
- return await _asyncio_open_connection(self.host, self.port, connect_timeout)
527
+ self._resolve_timeout = 6
528
+
529
+ async def _resolve(self):
530
+ logger.debug(" Resolving %s (timeout is %ds)", self.service_name, self._resolve_timeout)
531
+ return await self.service_resolver.get(self.service_name, self._resolve_timeout)
532
+
533
+ async def _ping(self, availability_hint: bool | None = None):
534
+ logger.debug("Pinging %s (%s - %s:%s)", LazyStr(self.get_class_name), self.service_name, str(self.host), str(self.port))
535
+ if self.host and self.port:
536
+ await self._connect(self.host, self.port)
537
+ else:
421
538
  host, port = self.service_cache.get(self.service_name)
422
539
  if host and port:
423
540
  try:
424
- reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
541
+ await self._connect(host, port)
425
542
  self.host = host
426
543
  self.port = port
427
- return reader_writer
428
- except (TimeoutError, socket.gaierror, ConnectionRefusedError):
544
+ return
545
+ except (TimeoutError, socket.gaierror, ConnectionRefusedError) + self._special_exceptions as e:
429
546
  if availability_hint is None or availability_hint:
430
- pass
547
+ logger.debug(" %s", LazyStr(repr, e))
431
548
  else:
432
549
  raise
433
- host, port = await _service_resolver_get(self.service_name, resolve_timeout)
434
- reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
550
+ host, port = await self._resolve()
551
+ await self._connect(host, port)
552
+ # if resolution is happened through the ServiceListener, cache is already set, but resolution can happen through request/response also
435
553
  self.service_cache.set(self.service_name, host, port)
436
554
  self.host = host
437
555
  self.port = port
438
- return reader_writer
439
- logger.debug("Pinging %s (%s - %s:%s)", LazyStr(self.get_class_name), self.service_name, str(self.host), str(self.port))
440
- try:
441
- _reader, writer = await _connect(2, 6)
442
- writer.close()
443
- await writer.wait_closed()
444
- return True
445
- except (TimeoutError, socket.gaierror, ConnectionRefusedError):
446
- return False
447
- except Exception as e:
448
- logger.debug(" Unexpected ping exception: %s", e.__str__())
449
- raise
556
+
557
+ class ZeroconfSshService(ZeroconfService, SshService):
558
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
559
+ super().__init__(service_name=service_name, service_cache=service_cache, service_resolver=service_resolver, host_name=service_name, keyfile=keyfile, manager=manager)
450
560
 
451
561
  ########
452
562
 
453
563
  class Phone:
454
- def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
564
+ def __init__(self, zeroconf_sftp: ZeroconfSshService, vpn: Device | None, remote_sftp: SshService | None, state: PhoneState | None):
455
565
  self.zeroconf_sftp = zeroconf_sftp
456
566
  self.vpn = vpn
457
567
  self.remote_sftp = remote_sftp
458
568
  self.state = state
459
569
 
460
- class pFTPdServiceListener(ServiceListener):
570
+ class PftpdServiceListener(ServiceListener):
461
571
  def __init__(self, server_name: str, cache: ServiceCache):
462
572
  self.server_name = server_name
463
573
  self.cache = cache
@@ -472,14 +582,14 @@ class pFTPdServiceListener(ServiceListener):
472
582
  def del_service(self, service_name: str):
473
583
  pass
474
584
 
475
- class RemotepFTPd(Service):
476
- def __init__(self, host: str, port: int, manager: Manager):
477
- super().__init__(host, port, manager)
585
+ class RemotePftpd(SshService):
586
+ def __init__(self, host: str, port: int, host_name: str, keyfile: str, manager: Manager):
587
+ super().__init__(host, port, host_name, keyfile, manager)
478
588
  self.__qualname__ = "pFTPd"
479
589
 
480
- class ZeroconfpFTPd(ZeroconfService):
481
- def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
482
- super().__init__(service_name, service_cache, service_resolver, manager)
590
+ class ZeroconfPftpd(ZeroconfSshService):
591
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
592
+ super().__init__(service_name, service_cache, service_resolver, keyfile, manager)
483
593
  self.__qualname__ = "pFTPd"
484
594
 
485
595
  class RemoteTailscale(Device):
@@ -546,11 +656,11 @@ class Tailscale():
546
656
 
547
657
  class LocalTailscaleManager(Manager):
548
658
  async def start(self):
549
- if not (await Subprocess.async_tailscale(['up']))[0]:
659
+ if not (await Subprocess.tailscale(['up']))[0]:
550
660
  raise RuntimeError("Failed to start up local Tailscale")
551
661
 
552
662
  async def stop(self):
553
- if not (await Subprocess.async_tailscale(['down']))[0]:
663
+ if not (await Subprocess.tailscale(['down']))[0]:
554
664
  raise RuntimeError("Failed to shut down local Tailscale")
555
665
 
556
666
  class LocalTailscale(Manageable):
@@ -562,7 +672,7 @@ class LocalTailscale(Manageable):
562
672
 
563
673
  async def ping(self, availability_hint: bool | None = None):
564
674
  logger.debug("Getting status of %s", LazyStr(self.get_class_name))
565
- success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
675
+ success, stdout = await Subprocess.tailscale(['status', '--json', '--peers=false', '--self=true'])
566
676
  if success:
567
677
  status = json.loads(stdout)
568
678
  return success and status['BackendState'] == 'Running' and status['Self']['Online']
@@ -590,11 +700,13 @@ class LocalTailscale(Manageable):
590
700
  class Funnel(Pingable):
591
701
  LOCAL_HOST = '127.0.0.1'
592
702
 
593
- def __init__(self, tailnet: str, machine_name: str, local_port: int, local_path: str, external_port: int):
703
+ def __init__(self, tailnet: str, machine_name: str, local_port: int, local_path: str, external_port: int, dns_resolver: DnsResolver):
594
704
  self.machine_name = machine_name
595
705
  self.local_port = local_port
596
706
  self.external_name = f'{machine_name}.{tailnet}'
707
+ self.external_port = external_port
597
708
  self.external_url = f'https://{machine_name}.{tailnet}:{external_port}{local_path}'
709
+ self.dns_resolver = dns_resolver
598
710
 
599
711
  async def wait_for(self, available: bool, timeout: float):
600
712
  self._sleepcounter = 0
@@ -603,9 +715,8 @@ class Funnel(Pingable):
603
715
  async def ping(self, availability_hint: bool | None = None):
604
716
  logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
605
717
  try:
606
- # resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
607
- _answer = await dns.asyncresolver.resolve_at('1.1.1.1', self.external_name)
608
- except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
718
+ _answer = await self.dns_resolver.resolve(self.external_name, self.external_port)
719
+ except Exception:
609
720
  return False
610
721
  return True
611
722
 
@@ -702,7 +813,7 @@ class Automate:
702
813
  async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
703
814
  await response.text()
704
815
 
705
- class AutomatepFTPdManager(Manager):
816
+ class AutomatePftpdManager(Manager):
706
817
  def __init__(self, automate: Automate):
707
818
  self.automate = automate
708
819
 
@@ -744,12 +855,12 @@ class WebhookPing(Pingable):
744
855
  class AutomatePhoneState(PhoneState):
745
856
  VARIABLE_STATE = 'state'
746
857
 
747
- def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
748
- self.session = session
858
+ def __init__(self, general_session: aiohttp.ClientSession, external_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
749
859
  self.webhooks = webhooks
750
860
  self.automate = automate
751
861
  self.funnel = funnel
752
- self.webhook_ping = WebhookPing(session, webhooks, funnel)
862
+ self.local_webhook_ping = WebhookPing(general_session, webhooks, funnel)
863
+ self.external_webhook_ping = WebhookPing(external_dns_session, webhooks, funnel)
753
864
 
754
865
  async def get(self, repeat: float, timeout: float):
755
866
  logger.info("Getting Phone state...")
@@ -759,7 +870,7 @@ class AutomatePhoneState(PhoneState):
759
870
  test_timeout = 10.0
760
871
  logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
761
872
  try:
762
- await self.webhook_ping.wait_for(True, test_timeout)
873
+ await self.local_webhook_ping.wait_for(True, test_timeout)
763
874
  except Exception as e:
764
875
  raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
765
876
 
@@ -771,6 +882,15 @@ class AutomatePhoneState(PhoneState):
771
882
  except Exception as e:
772
883
  raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
773
884
 
885
+ # test external funnel + webhooks availability, ie. test funnel tcp forwarders
886
+ # it will NOT be routed locally, so the route is equivalent with / similar to what Automate will see
887
+ test_timeout = 30.0
888
+ logger.debug("Testing Funnel with calling external webhook (timeout is %ds)", int(test_timeout))
889
+ try:
890
+ await self.external_webhook_ping.wait_for(True, test_timeout)
891
+ except Exception as e:
892
+ raise RuntimeError(f"Funnel TCP forwarders are not configured by Tailscale for {self.funnel.external_name}") from e
893
+
774
894
  # get state
775
895
  logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
776
896
  self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
@@ -818,6 +938,7 @@ class Control:
818
938
  @staticmethod
819
939
  def setup_parser_arguments(parser):
820
940
  parser.add_argument('server_name', metavar='server-name', help="the Servername configuration option from Primitive FTPd app")
941
+ parser.add_argument('keyfile', help="private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details")
821
942
  parser.add_argument('-i', '--intent', choices=["test", "start", "stop"], help="what to do with the apps, default: test", default="test")
822
943
 
823
944
  @staticmethod
@@ -855,8 +976,13 @@ class Control:
855
976
 
856
977
  async def execute(self, args, local: Local, phone: Phone):
857
978
  async def _stop(restore_state: dict | None, stop_only_started: bool = False):
858
- if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
859
- if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await phone.remote_sftp.test():
979
+ async def _suppress(coro, default: bool):
980
+ try:
981
+ return await coro
982
+ except:
983
+ return default
984
+ if local.vpn and phone.vpn and phone.remote_sftp and await _suppress(local.vpn.test(), True) and await _suppress(phone.vpn.test(), True):
985
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await _suppress(phone.remote_sftp.test(), True):
860
986
  try:
861
987
  await phone.remote_sftp.stop(10, 30)
862
988
  except Exception as e:
@@ -959,14 +1085,17 @@ class Control:
959
1085
  state = dict()
960
1086
  state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
961
1087
  print(StateSerializer.dumps(state))
962
- except:
963
- await _stop(state, stop_only_started = True)
1088
+ except Exception:
1089
+ try:
1090
+ await _stop(state, stop_only_started = True)
1091
+ except Exception as e:
1092
+ logger.exception_or_error(e, args)
964
1093
  raise
965
1094
  else:
966
1095
  try:
967
1096
  if not await phone.zeroconf_sftp.test():
968
1097
  await phone.zeroconf_sftp.start(10, 30)
969
- except:
1098
+ except Exception:
970
1099
  await _stop(None)
971
1100
  raise
972
1101
  case 'stop':
@@ -1031,32 +1160,39 @@ class AutomateControl(Control):
1031
1160
  async def run(self, args):
1032
1161
  self.prepare(args)
1033
1162
 
1163
+ external_dns_resolver = ExternalDnsResolver()
1034
1164
  async with (
1165
+ aiohttp.ClientSession() as general_session,
1035
1166
  aiohttp.ClientSession(
1036
1167
  # Automate messaging server prefers closing connections
1037
- connector=aiohttp.TCPConnector(force_close=True)) as session,
1168
+ connector=aiohttp.TCPConnector(force_close=True)) as force_close_session,
1169
+ aiohttp.ClientSession(
1170
+ # Uses external DNS to access Funnet TCP forwarder servers instead of local MagicDNS route
1171
+ connector=aiohttp.TCPConnector(resolver=external_dns_resolver)) as external_dns_session,
1038
1172
  AsyncZeroconf() as zeroconf
1039
1173
  ):
1040
1174
  service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
1041
1175
  service_resolver = SftpServiceResolver(zeroconf)
1042
- service_listener = pFTPdServiceListener(args.server_name, service_cache)
1176
+
1177
+ service_listener = PftpdServiceListener(args.server_name, service_cache)
1043
1178
  service_browser = SftpServiceBrowser(zeroconf)
1044
1179
  await service_browser.add_service_listener(service_listener)
1045
1180
 
1046
1181
  secrets = Secrets()
1047
- automate = Automate(secrets, session, args.automate_account, args.automate_device, args.automate_tokenfile)
1182
+ automate = Automate(secrets, force_close_session, args.automate_account, args.automate_device, args.automate_tokenfile)
1048
1183
  remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
1049
- zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
1050
- remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale 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
1184
+ pftpd_manager = AutomatePftpdManager(automate)
1185
+ zeroconf_pftpd = ZeroconfPftpd(args.server_name, service_cache, service_resolver, args.keyfile, pftpd_manager)
1186
+ remote_pftpd = RemotePftpd(remote_tailscale.host, int(args.tailscale[2]), args.server_name, args.keyfile, pftpd_manager) if remote_tailscale else None
1187
+ funnel = Funnel(remote_tailscale.tailnet, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3]), external_dns_resolver) if remote_tailscale and args.funnel else None
1052
1188
  local_tailscale = (
1053
- LocalTailscale(Tailscale(secrets, session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
1189
+ LocalTailscale(Tailscale(secrets, general_session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
1054
1190
  LocalTailscale() if args.tailscale else
1055
1191
  None)
1056
1192
 
1057
1193
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
1058
1194
  local = Local(local_tailscale)
1059
- automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
1195
+ automate_phone_state = AutomatePhoneState(general_session, external_dns_session, webhooks, automate, funnel) if funnel and webhooks else None
1060
1196
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
1061
1197
  await self.execute(args, local, phone)
1062
1198
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.6.5"
3
+ version = "0.7.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>"]
@@ -23,6 +23,7 @@ platformdirs = "^4.3.6"
23
23
  zeroconf = "^0.136.2"
24
24
  dnspython = "^2.7.0"
25
25
  tailscale = "^0.6.1"
26
+ asyncssh = "^2.19.0"
26
27
 
27
28
  [tool.poetry.scripts]
28
29
  prim-ctrl = "prim_ctrl.__main__:run"
File without changes