prim-ctrl 0.6.5__tar.gz → 0.7.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.6.5
3
+ Version: 0.7.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
@@ -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)
@@ -146,15 +147,15 @@ Notes:
146
147
  <details><summary>Ubuntu</summary>
147
148
 
148
149
  ```
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}
150
+ 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
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 stop -r ${PREV_STATE}
151
152
  ```
152
153
  </details>
153
154
  <details><summary>Windows</summary>
154
155
 
155
156
  ```
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!
157
+ 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
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 stop -r !PREV_STATE!
158
159
  ```
159
160
  </details>
160
161
 
@@ -162,7 +163,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
162
163
 
163
164
  ```
164
165
  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
166
+ automate-account automate-device automate-tokenfile server-name keyfile
166
167
 
167
168
  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
169
 
@@ -178,6 +179,7 @@ positional arguments:
178
179
  automate-tokenfile filename containing Automates's Secret that located under your .secrets folder
179
180
  (generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)
180
181
  server-name the Servername configuration option from Primitive FTPd app
182
+ keyfile private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details
181
183
 
182
184
  options:
183
185
  -h, --help show this help message and exit
@@ -121,15 +121,15 @@ Notes:
121
121
  <details><summary>Ubuntu</summary>
122
122
 
123
123
  ```
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}
124
+ 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
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 stop -r ${PREV_STATE}
126
126
  ```
127
127
  </details>
128
128
  <details><summary>Windows</summary>
129
129
 
130
130
  ```
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!
131
+ 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
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 stop -r !PREV_STATE!
133
133
  ```
134
134
  </details>
135
135
 
@@ -137,7 +137,7 @@ prim-ctrl Automate youraccount@gmail.com "SOME MANUFACTURER XXXX" automate your-
137
137
 
138
138
  ```
139
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
- automate-account automate-device automate-tokenfile server-name
140
+ automate-account automate-device automate-tokenfile server-name keyfile
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
143
143
 
@@ -153,6 +153,7 @@ positional arguments:
153
153
  automate-tokenfile filename containing Automates's Secret that located under your .secrets folder
154
154
  (generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)
155
155
  server-name the Servername configuration option from Primitive FTPd app
156
+ keyfile private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details
156
157
 
157
158
  options:
158
159
  -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,74 @@ 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], None] | 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
+ self._special_exceptions_handler(e)
310
+ else:
311
+ logger.debug(" Unexpected ping exception: %s", LazyStr(e))
312
+ raise
313
+ else:
314
+ return False
256
315
  except Exception as e:
257
- logger.debug(" Unexpected ping exception: %s", e.__str__())
316
+ logger.debug(" Unexpected ping exception: %s", LazyStr(e))
258
317
  raise
259
318
 
319
+ class SshService(Service):
320
+ def __init__( self, host: str, port: int, host_name: str, keyfile: str, manager: Manager, **kw):
321
+ super().__init__(host=host, port=port, manager=manager, **kw)
322
+ self.host_name = host_name
323
+ self.keyfile = keyfile
324
+
325
+ self._connect_timeout = 3
326
+ self._special_exceptions = (asyncssh.misc.HostKeyNotVerifiable, asyncssh.misc.PermissionDenied)
327
+ def _handle_special_exceptions(e: Exception):
328
+ if isinstance(e, asyncssh.misc.HostKeyNotVerifiable):
329
+ e.add_note("Check your known_hosts file, see the documentation of prim-sync for more details")
330
+ else:
331
+ e.add_note("Check your private SSH key file, see the documentation of prim-sync for more details")
332
+ self._special_exceptions_handler = _handle_special_exceptions
333
+
334
+ async def _connect(self, host: str, port: int):
335
+ logger.debug(" Connecting with SSH to %s:%d (timeout is %ds)", host, port, self._connect_timeout)
336
+ def _client_key():
337
+ try:
338
+ return asyncssh.read_private_key(str(Path.home() / ".ssh" / self.keyfile))
339
+ except asyncssh.KeyImportError:
340
+ # 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
341
+ # if client key is NOT encrypted, specify it, because asyncssh will try only the default key file names
342
+ # 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
343
+ return ()
344
+ async with (
345
+ # by default it will search for the known_hosts in str(Path.home() / ".ssh" / "known_hosts")
346
+ asyncssh.connect(host, port, options=asyncssh.SSHClientConnectionOptions(
347
+ host_key_alias=self.host_name,
348
+ client_keys=_client_key(),
349
+ connect_timeout=self._connect_timeout)) as conn
350
+ ):
351
+ pass
352
+
260
353
  class Device(Manageable):
261
354
  def __init__(self, host: str, manager: Manager):
262
355
  super().__init__(manager)
@@ -264,7 +357,7 @@ class Device(Manageable):
264
357
 
265
358
  async def ping(self, availability_hint: bool | None = None):
266
359
  logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
267
- return await Subprocess.async_ping(self.host, timeout=2)
360
+ return await Subprocess.ping(self.host, timeout=2)
268
361
 
269
362
  class StateSerializer:
270
363
  BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
@@ -300,6 +393,24 @@ class PhoneState:
300
393
 
301
394
  ########
302
395
 
396
+ class Secrets:
397
+ DIR_NAME = '.secrets'
398
+
399
+ def __init__(self):
400
+ self.secrets_path = Path.home() / Secrets.DIR_NAME
401
+
402
+ def get(self, tokenfile: str):
403
+ with open(str(self.secrets_path / tokenfile), 'rt') as file:
404
+ return file.readline().rstrip()
405
+
406
+ def set(self, tokenfile: str, token: str):
407
+ self.secrets_path.mkdir(parents=True, exist_ok=True)
408
+ with open(str(self.secrets_path / tokenfile), 'wt') as file:
409
+ file.write(token)
410
+
411
+ def get_age(self, tokenfile: str):
412
+ return (datetime.now(timezone.utc) - datetime.fromtimestamp(os.stat(str(self.secrets_path / tokenfile)).st_mtime, timezone.utc)).total_seconds()
413
+
303
414
  class Cache:
304
415
  PRIM_SYNC_APP_NAME = 'prim-sync'
305
416
 
@@ -321,6 +432,8 @@ class Cache:
321
432
  else:
322
433
  return None
323
434
 
435
+ ########
436
+
324
437
  class ServiceCache:
325
438
  def __init__(self, cache: Cache):
326
439
  self.cache = cache
@@ -398,66 +511,57 @@ class SftpServiceBrowser(ServiceBrowser):
398
511
  def __init__(self, zeroconf: AsyncZeroconf):
399
512
  super().__init__(zeroconf, SFTP_SERVICE_TYPE)
400
513
 
401
- class ZeroconfService(Manageable):
402
- def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
403
- super().__init__(manager)
514
+ class ZeroconfService(Service):
515
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager, **kw):
516
+ super().__init__(host=None, port=None, manager=manager, **kw) # type: ignore
404
517
  self.service_name = service_name
405
- self.host = None
406
- self.port = None
407
518
  self.service_cache = service_cache
408
519
  self.service_resolver = service_resolver
409
520
 
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)
521
+ self._resolve_timeout = 6
522
+
523
+ async def _resolve(self):
524
+ logger.debug(" Resolving %s (timeout is %ds)", self.service_name, self._resolve_timeout)
525
+ return await self.service_resolver.get(self.service_name, self._resolve_timeout)
526
+
527
+ async def _ping(self, availability_hint: bool | None = None):
528
+ logger.debug("Pinging %s (%s - %s:%s)", LazyStr(self.get_class_name), self.service_name, str(self.host), str(self.port))
529
+ if self.host and self.port:
530
+ await self._connect(self.host, self.port)
531
+ else:
421
532
  host, port = self.service_cache.get(self.service_name)
422
533
  if host and port:
423
534
  try:
424
- reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
535
+ await self._connect(host, port)
425
536
  self.host = host
426
537
  self.port = port
427
- return reader_writer
428
- except (TimeoutError, socket.gaierror, ConnectionRefusedError):
538
+ return
539
+ except (TimeoutError, socket.gaierror, ConnectionRefusedError) + self._special_exceptions as e:
429
540
  if availability_hint is None or availability_hint:
430
- pass
541
+ logger.debug(" %s", LazyStr(repr, e))
431
542
  else:
432
543
  raise
433
- host, port = await _service_resolver_get(self.service_name, resolve_timeout)
434
- reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
544
+ host, port = await self._resolve()
545
+ await self._connect(host, port)
546
+ # if resolution is happened through the ServiceListener, cache is already set, but resolution can happen through request/response also
435
547
  self.service_cache.set(self.service_name, host, port)
436
548
  self.host = host
437
549
  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
550
+
551
+ class ZeroconfSshService(ZeroconfService, SshService):
552
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
553
+ super().__init__(service_name=service_name, service_cache=service_cache, service_resolver=service_resolver, host_name=service_name, keyfile=keyfile, manager=manager)
450
554
 
451
555
  ########
452
556
 
453
557
  class Phone:
454
- def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
558
+ def __init__(self, zeroconf_sftp: ZeroconfSshService, vpn: Device | None, remote_sftp: SshService | None, state: PhoneState | None):
455
559
  self.zeroconf_sftp = zeroconf_sftp
456
560
  self.vpn = vpn
457
561
  self.remote_sftp = remote_sftp
458
562
  self.state = state
459
563
 
460
- class pFTPdServiceListener(ServiceListener):
564
+ class PftpdServiceListener(ServiceListener):
461
565
  def __init__(self, server_name: str, cache: ServiceCache):
462
566
  self.server_name = server_name
463
567
  self.cache = cache
@@ -472,14 +576,14 @@ class pFTPdServiceListener(ServiceListener):
472
576
  def del_service(self, service_name: str):
473
577
  pass
474
578
 
475
- class RemotepFTPd(Service):
476
- def __init__(self, host: str, port: int, manager: Manager):
477
- super().__init__(host, port, manager)
579
+ class RemotePftpd(SshService):
580
+ def __init__(self, host: str, port: int, host_name: str, keyfile: str, manager: Manager):
581
+ super().__init__(host, port, host_name, keyfile, manager)
478
582
  self.__qualname__ = "pFTPd"
479
583
 
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)
584
+ class ZeroconfPftpd(ZeroconfSshService):
585
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, keyfile: str, manager: Manager):
586
+ super().__init__(service_name, service_cache, service_resolver, keyfile, manager)
483
587
  self.__qualname__ = "pFTPd"
484
588
 
485
589
  class RemoteTailscale(Device):
@@ -546,11 +650,11 @@ class Tailscale():
546
650
 
547
651
  class LocalTailscaleManager(Manager):
548
652
  async def start(self):
549
- if not (await Subprocess.async_tailscale(['up']))[0]:
653
+ if not (await Subprocess.tailscale(['up']))[0]:
550
654
  raise RuntimeError("Failed to start up local Tailscale")
551
655
 
552
656
  async def stop(self):
553
- if not (await Subprocess.async_tailscale(['down']))[0]:
657
+ if not (await Subprocess.tailscale(['down']))[0]:
554
658
  raise RuntimeError("Failed to shut down local Tailscale")
555
659
 
556
660
  class LocalTailscale(Manageable):
@@ -562,7 +666,7 @@ class LocalTailscale(Manageable):
562
666
 
563
667
  async def ping(self, availability_hint: bool | None = None):
564
668
  logger.debug("Getting status of %s", LazyStr(self.get_class_name))
565
- success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
669
+ success, stdout = await Subprocess.tailscale(['status', '--json', '--peers=false', '--self=true'])
566
670
  if success:
567
671
  status = json.loads(stdout)
568
672
  return success and status['BackendState'] == 'Running' and status['Self']['Online']
@@ -590,11 +694,13 @@ class LocalTailscale(Manageable):
590
694
  class Funnel(Pingable):
591
695
  LOCAL_HOST = '127.0.0.1'
592
696
 
593
- def __init__(self, tailnet: str, machine_name: str, local_port: int, local_path: str, external_port: int):
697
+ def __init__(self, tailnet: str, machine_name: str, local_port: int, local_path: str, external_port: int, dns_resolver: DnsResolver):
594
698
  self.machine_name = machine_name
595
699
  self.local_port = local_port
596
700
  self.external_name = f'{machine_name}.{tailnet}'
701
+ self.external_port = external_port
597
702
  self.external_url = f'https://{machine_name}.{tailnet}:{external_port}{local_path}'
703
+ self.dns_resolver = dns_resolver
598
704
 
599
705
  async def wait_for(self, available: bool, timeout: float):
600
706
  self._sleepcounter = 0
@@ -603,9 +709,8 @@ class Funnel(Pingable):
603
709
  async def ping(self, availability_hint: bool | None = None):
604
710
  logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
605
711
  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):
712
+ _answer = await self.dns_resolver.resolve(self.external_name, self.external_port)
713
+ except Exception:
609
714
  return False
610
715
  return True
611
716
 
@@ -702,7 +807,7 @@ class Automate:
702
807
  async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
703
808
  await response.text()
704
809
 
705
- class AutomatepFTPdManager(Manager):
810
+ class AutomatePftpdManager(Manager):
706
811
  def __init__(self, automate: Automate):
707
812
  self.automate = automate
708
813
 
@@ -744,12 +849,12 @@ class WebhookPing(Pingable):
744
849
  class AutomatePhoneState(PhoneState):
745
850
  VARIABLE_STATE = 'state'
746
851
 
747
- def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
748
- self.session = session
852
+ def __init__(self, general_session: aiohttp.ClientSession, external_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
749
853
  self.webhooks = webhooks
750
854
  self.automate = automate
751
855
  self.funnel = funnel
752
- self.webhook_ping = WebhookPing(session, webhooks, funnel)
856
+ self.local_webhook_ping = WebhookPing(general_session, webhooks, funnel)
857
+ self.external_webhook_ping = WebhookPing(external_dns_session, webhooks, funnel)
753
858
 
754
859
  async def get(self, repeat: float, timeout: float):
755
860
  logger.info("Getting Phone state...")
@@ -759,7 +864,7 @@ class AutomatePhoneState(PhoneState):
759
864
  test_timeout = 10.0
760
865
  logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
761
866
  try:
762
- await self.webhook_ping.wait_for(True, test_timeout)
867
+ await self.local_webhook_ping.wait_for(True, test_timeout)
763
868
  except Exception as e:
764
869
  raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
765
870
 
@@ -771,6 +876,15 @@ class AutomatePhoneState(PhoneState):
771
876
  except Exception as e:
772
877
  raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
773
878
 
879
+ # test external funnel + webhooks availability, ie. test funnel tcp forwarders
880
+ # it will NOT be routed locally, so the route is equivalent with / similar to what Automate will see
881
+ test_timeout = 30.0
882
+ logger.debug("Testing Funnel with calling external webhook (timeout is %ds)", int(test_timeout))
883
+ try:
884
+ await self.external_webhook_ping.wait_for(True, test_timeout)
885
+ except Exception as e:
886
+ raise RuntimeError(f"Funnel TCP forwarders are not configured by Tailscale for {self.funnel.external_name}") from e
887
+
774
888
  # get state
775
889
  logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
776
890
  self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
@@ -818,6 +932,7 @@ class Control:
818
932
  @staticmethod
819
933
  def setup_parser_arguments(parser):
820
934
  parser.add_argument('server_name', metavar='server-name', help="the Servername configuration option from Primitive FTPd app")
935
+ parser.add_argument('keyfile', help="private SSH key filename located under your .ssh folder, see the documentation of prim-sync for more details")
821
936
  parser.add_argument('-i', '--intent', choices=["test", "start", "stop"], help="what to do with the apps, default: test", default="test")
822
937
 
823
938
  @staticmethod
@@ -959,14 +1074,14 @@ class Control:
959
1074
  state = dict()
960
1075
  state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
961
1076
  print(StateSerializer.dumps(state))
962
- except:
1077
+ except Exception:
963
1078
  await _stop(state, stop_only_started = True)
964
1079
  raise
965
1080
  else:
966
1081
  try:
967
1082
  if not await phone.zeroconf_sftp.test():
968
1083
  await phone.zeroconf_sftp.start(10, 30)
969
- except:
1084
+ except Exception:
970
1085
  await _stop(None)
971
1086
  raise
972
1087
  case 'stop':
@@ -1031,32 +1146,39 @@ class AutomateControl(Control):
1031
1146
  async def run(self, args):
1032
1147
  self.prepare(args)
1033
1148
 
1149
+ external_dns_resolver = ExternalDnsResolver()
1034
1150
  async with (
1151
+ aiohttp.ClientSession() as general_session,
1035
1152
  aiohttp.ClientSession(
1036
1153
  # Automate messaging server prefers closing connections
1037
- connector=aiohttp.TCPConnector(force_close=True)) as session,
1154
+ connector=aiohttp.TCPConnector(force_close=True)) as force_close_session,
1155
+ aiohttp.ClientSession(
1156
+ # Uses external DNS to access Funnet TCP forwarder servers instead of local MagicDNS route
1157
+ connector=aiohttp.TCPConnector(resolver=external_dns_resolver)) as external_dns_session,
1038
1158
  AsyncZeroconf() as zeroconf
1039
1159
  ):
1040
1160
  service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
1041
1161
  service_resolver = SftpServiceResolver(zeroconf)
1042
- service_listener = pFTPdServiceListener(args.server_name, service_cache)
1162
+
1163
+ service_listener = PftpdServiceListener(args.server_name, service_cache)
1043
1164
  service_browser = SftpServiceBrowser(zeroconf)
1044
1165
  await service_browser.add_service_listener(service_listener)
1045
1166
 
1046
1167
  secrets = Secrets()
1047
- automate = Automate(secrets, session, args.automate_account, args.automate_device, args.automate_tokenfile)
1168
+ automate = Automate(secrets, force_close_session, args.automate_account, args.automate_device, args.automate_tokenfile)
1048
1169
  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
1170
+ pftpd_manager = AutomatePftpdManager(automate)
1171
+ zeroconf_pftpd = ZeroconfPftpd(args.server_name, service_cache, service_resolver, args.keyfile, pftpd_manager)
1172
+ remote_pftpd = RemotePftpd(remote_tailscale.host, int(args.tailscale[2]), args.server_name, args.keyfile, pftpd_manager) if remote_tailscale else None
1173
+ 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
1174
  local_tailscale = (
1053
- LocalTailscale(Tailscale(secrets, session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
1175
+ LocalTailscale(Tailscale(secrets, general_session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
1054
1176
  LocalTailscale() if args.tailscale else
1055
1177
  None)
1056
1178
 
1057
1179
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
1058
1180
  local = Local(local_tailscale)
1059
- automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
1181
+ automate_phone_state = AutomatePhoneState(general_session, external_dns_session, webhooks, automate, funnel) if funnel and webhooks else None
1060
1182
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
1061
1183
  await self.execute(args, local, phone)
1062
1184
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.6.5"
3
+ version = "0.7.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>"]
@@ -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