prim-ctrl 0.6.4__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.4
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,8 +15,10 @@ 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)
21
+ Requires-Dist: tailscale (>=0.6.1,<0.7.0)
20
22
  Requires-Dist: zeroconf (>=0.136.2,<0.137.0)
21
23
  Project-URL: Change Log, https://github.com/lmagyar/prim-ctrl/blob/main/CHANGELOG.md
22
24
  Project-URL: Repository, https://github.com/lmagyar/prim-ctrl
@@ -145,23 +147,23 @@ Notes:
145
147
  <details><summary>Ubuntu</summary>
146
148
 
147
149
  ```
148
- 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
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 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}
150
152
  ```
151
153
  </details>
152
154
  <details><summary>Windows</summary>
153
155
 
154
156
  ```
155
- 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
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 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!
157
159
  ```
158
160
  </details>
159
161
 
160
162
  ### Options
161
163
 
162
164
  ```
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
- automate-account automate-device automate-tokenfile server-name
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]
166
+ automate-account automate-device automate-tokenfile server-name keyfile
165
167
 
166
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
167
169
 
@@ -177,6 +179,7 @@ positional arguments:
177
179
  automate-tokenfile filename containing Automates's Secret that located under your .secrets folder
178
180
  (generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)
179
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
180
183
 
181
184
  options:
182
185
  -h, --help show this help message and exit
@@ -200,11 +203,14 @@ VPN:
200
203
  tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)
201
204
  remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)
202
205
  sftp-port: Primitive FTPd's sftp port
203
- --funnel local-machine-name local-port local-path external-port
206
+ --funnel local-machine-name local-port local-path external-port secretfile
204
207
  local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)
205
208
  local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)
206
209
  local-path: /prim-ctrl - if you used the example tailscale funnel command above
207
210
  external-port: 8443 - if you used the example tailscale funnel command above
211
+ secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder
212
+ (generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,
213
+ save only the Client secret in the file, the Client ID is part of it)
208
214
  -ac, --accept-cellular in case of start, if WiFi is not connected, don't return error, but start VPN up
209
215
  -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
216
  -r STATE, --restore-state STATE in case of stop, restore previous state from STATE (use -b to get a valid STATE string)
@@ -121,23 +121,23 @@ 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
 
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]
140
- automate-account automate-device automate-tokenfile server-name
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 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
@@ -176,11 +177,14 @@ VPN:
176
177
  tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)
177
178
  remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)
178
179
  sftp-port: Primitive FTPd's sftp port
179
- --funnel local-machine-name local-port local-path external-port
180
+ --funnel local-machine-name local-port local-path external-port secretfile
180
181
  local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)
181
182
  local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)
182
183
  local-path: /prim-ctrl - if you used the example tailscale funnel command above
183
184
  external-port: 8443 - if you used the example tailscale funnel command above
185
+ secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder
186
+ (generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,
187
+ save only the Client secret in the file, the Client ID is part of it)
184
188
  -ac, --accept-cellular in case of start, if WiFi is not connected, don't return error, but start VPN up
185
189
  -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
190
  -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,18 @@ 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
16
+ from typing import Callable
17
17
 
18
18
  import aiohttp
19
+ import asyncssh
19
20
  import dns.asyncresolver
20
21
  import dns.resolver
22
+ import dns.rdatatype
21
23
  from aiohttp import ClientTimeout, web
24
+ from aiohttp.abc import AbstractResolver as DnsResolver, ResolveResult
22
25
  from platformdirs import user_cache_dir
26
+ from tailscale import Device as TailscaleDeviceInfo
27
+ from tailscale import Tailscale as TailscaleApi
23
28
  from zeroconf import Zeroconf, ServiceInfo, ServiceListener as ZeroconfServiceListener
24
29
  from zeroconf.asyncio import AsyncZeroconf
25
30
 
@@ -28,7 +33,7 @@ from zeroconf.asyncio import AsyncZeroconf
28
33
  class LevelFormatter(logging.Formatter):
29
34
  logging.Formatter.default_msec_format = logging.Formatter.default_msec_format.replace(',', '.') if logging.Formatter.default_msec_format else None
30
35
 
31
- def __init__(self, fmts: Dict[int, str], fmt: str, **kwargs):
36
+ def __init__(self, fmts: dict[int, str], fmt: str, **kwargs):
32
37
  super().__init__()
33
38
  self.formatters = dict({level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()})
34
39
  self.default_formatter = logging.Formatter(fmt, **kwargs)
@@ -94,7 +99,10 @@ class LazyStr:
94
99
  self.result = None
95
100
  def __str__(self):
96
101
  if self.result is None:
97
- self.result = str(self.func(*self.args, **self.kwargs))
102
+ if callable(self.func):
103
+ self.result = str(self.func(*self.args, **self.kwargs))
104
+ else:
105
+ self.result = str(self.func)
98
106
  return self.result
99
107
 
100
108
  logger = Logger(Path(sys.argv[0]).name)
@@ -103,21 +111,21 @@ logger = Logger(Path(sys.argv[0]).name)
103
111
 
104
112
  class Subprocess:
105
113
 
106
- # based on https://stackoverflow.com/a/55656177/2755656
107
- @staticmethod
108
- def sync_ping(host, packets: int = 1, timeout: float = 1):
109
- if platform.system().lower() == 'windows':
110
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
111
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
112
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
113
- return result.returncode == 0 and b'TTL=' in result.stdout
114
- else:
115
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
116
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
117
- 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
118
126
 
119
127
  @staticmethod
120
- async def async_ping(host, packets: int = 1, timeout: float = 1):
128
+ async def ping(host, packets: int = 1, timeout: float = 1):
121
129
  if platform.system().lower() == 'windows':
122
130
  command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
123
131
  # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
@@ -131,7 +139,7 @@ class Subprocess:
131
139
  return proc.returncode == 0
132
140
 
133
141
  @staticmethod
134
- async def async_tailscale(args: list[str]):
142
+ async def tailscale(args: list[str]):
135
143
  command = ['tailscale']
136
144
  command.extend(args)
137
145
  creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
@@ -141,23 +149,61 @@ class Subprocess:
141
149
 
142
150
  ########
143
151
 
144
- class Secrets:
145
- 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'
146
157
 
147
158
  def __init__(self):
148
- self.secrets_path = Path.home() / Secrets.DIR_NAME
149
-
150
- def get(self, tokenfile: str):
151
- with open(str(self.secrets_path / tokenfile), 'rt') as file:
152
- 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
153
174
 
154
- def set(self, tokenfile: str, token: str):
155
- self.secrets_path.mkdir(parents=True, exist_ok=True)
156
- with open(str(self.secrets_path / tokenfile), 'wt') as file:
157
- 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
158
205
 
159
- def get_age(self, tokenfile: str):
160
- return (datetime.now(timezone.utc) - datetime.fromtimestamp(os.stat(str(self.secrets_path / tokenfile)).st_mtime, timezone.utc)).total_seconds()
206
+ ########
161
207
 
162
208
  class Pingable:
163
209
  @abstractmethod
@@ -236,23 +282,74 @@ class Service(Manageable):
236
282
  self.host = host
237
283
  self.port = port
238
284
 
239
- async def ping(self, _availability_hint: bool | None = None):
240
- async def _connect(connect_timeout: float):
241
- logger.debug(" Connecting to %s on port %d (timeout is %ds)", self.host, self.port, connect_timeout)
242
- async with asyncio.timeout(connect_timeout):
243
- return await asyncio.open_connection(self.host, self.port)
244
- logger.debug("Pinging %s (%s:%d)", LazyStr(self.get_class_name), self.host, self.port)
245
- try:
246
- _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)
247
293
  writer.close()
248
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)
249
303
  return True
250
304
  except (TimeoutError, socket.gaierror, ConnectionRefusedError):
251
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
252
315
  except Exception as e:
253
- logger.debug(" Unexpected ping exception: %s", e.__str__())
316
+ logger.debug(" Unexpected ping exception: %s", LazyStr(e))
254
317
  raise
255
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
+
256
353
  class Device(Manageable):
257
354
  def __init__(self, host: str, manager: Manager):
258
355
  super().__init__(manager)
@@ -260,7 +357,7 @@ class Device(Manageable):
260
357
 
261
358
  async def ping(self, availability_hint: bool | None = None):
262
359
  logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
263
- return await Subprocess.async_ping(self.host, timeout=2)
360
+ return await Subprocess.ping(self.host, timeout=2)
264
361
 
265
362
  class StateSerializer:
266
363
  BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
@@ -296,11 +393,29 @@ class PhoneState:
296
393
 
297
394
  ########
298
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
+
299
414
  class Cache:
300
415
  PRIM_SYNC_APP_NAME = 'prim-sync'
301
416
 
302
- def __init__(self):
303
- self.cache_path = Path(user_cache_dir(Cache.PRIM_SYNC_APP_NAME, False))
417
+ def __init__(self, app_name: str):
418
+ self.cache_path = Path(user_cache_dir(app_name, False))
304
419
 
305
420
  def set(self, key: str, value: str):
306
421
  self.cache_path.mkdir(parents=True, exist_ok=True)
@@ -317,6 +432,8 @@ class Cache:
317
432
  else:
318
433
  return None
319
434
 
435
+ ########
436
+
320
437
  class ServiceCache:
321
438
  def __init__(self, cache: Cache):
322
439
  self.cache = cache
@@ -394,66 +511,57 @@ class SftpServiceBrowser(ServiceBrowser):
394
511
  def __init__(self, zeroconf: AsyncZeroconf):
395
512
  super().__init__(zeroconf, SFTP_SERVICE_TYPE)
396
513
 
397
- class ZeroconfService(Manageable):
398
- def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
399
- 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
400
517
  self.service_name = service_name
401
- self.host = None
402
- self.port = None
403
518
  self.service_cache = service_cache
404
519
  self.service_resolver = service_resolver
405
520
 
406
- async def ping(self, availability_hint: bool | None = None):
407
- async def _connect(connect_timeout: float, resolve_timeout: float):
408
- async def _asyncio_open_connection(host: str, port: int, timeout: float):
409
- logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
410
- async with asyncio.timeout(timeout):
411
- return await asyncio.open_connection(host, port)
412
- async def _service_resolver_get(service_name: str, timeout: float):
413
- logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
414
- return await self.service_resolver.get(service_name, timeout)
415
- if self.host and self.port:
416
- 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:
417
532
  host, port = self.service_cache.get(self.service_name)
418
533
  if host and port:
419
534
  try:
420
- reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
535
+ await self._connect(host, port)
421
536
  self.host = host
422
537
  self.port = port
423
- return reader_writer
424
- except (TimeoutError, socket.gaierror, ConnectionRefusedError):
538
+ return
539
+ except (TimeoutError, socket.gaierror, ConnectionRefusedError) + self._special_exceptions as e:
425
540
  if availability_hint is None or availability_hint:
426
- pass
541
+ logger.debug(" %s", LazyStr(repr, e))
427
542
  else:
428
543
  raise
429
- host, port = await _service_resolver_get(self.service_name, resolve_timeout)
430
- 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
431
547
  self.service_cache.set(self.service_name, host, port)
432
548
  self.host = host
433
549
  self.port = port
434
- return reader_writer
435
- logger.debug("Pinging %s (%s - %s:%s)", LazyStr(self.get_class_name), self.service_name, str(self.host), str(self.port))
436
- try:
437
- _reader, writer = await _connect(2, 6)
438
- writer.close()
439
- await writer.wait_closed()
440
- return True
441
- except (TimeoutError, socket.gaierror, ConnectionRefusedError):
442
- return False
443
- except Exception as e:
444
- logger.debug(" Unexpected ping exception: %s", e.__str__())
445
- 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)
446
554
 
447
555
  ########
448
556
 
449
557
  class Phone:
450
- 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):
451
559
  self.zeroconf_sftp = zeroconf_sftp
452
560
  self.vpn = vpn
453
561
  self.remote_sftp = remote_sftp
454
562
  self.state = state
455
563
 
456
- class pFTPdServiceListener(ServiceListener):
564
+ class PftpdServiceListener(ServiceListener):
457
565
  def __init__(self, server_name: str, cache: ServiceCache):
458
566
  self.server_name = server_name
459
567
  self.cache = cache
@@ -468,14 +576,14 @@ class pFTPdServiceListener(ServiceListener):
468
576
  def del_service(self, service_name: str):
469
577
  pass
470
578
 
471
- class RemotepFTPd(Service):
472
- def __init__(self, host: str, port: int, manager: Manager):
473
- 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)
474
582
  self.__qualname__ = "pFTPd"
475
583
 
476
- class ZeroconfpFTPd(ZeroconfService):
477
- def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
478
- 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)
479
587
  self.__qualname__ = "pFTPd"
480
588
 
481
589
  class RemoteTailscale(Device):
@@ -490,23 +598,75 @@ class Local:
490
598
  def __init__(self, vpn: Manageable | None):
491
599
  self.vpn = vpn
492
600
 
601
+ class Tailscale():
602
+ TOKEN_SUFFIX = '.token'
603
+
604
+ def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, tailnet: str, secretfile: str):
605
+ self.secrets = secrets
606
+ self.session = session
607
+ self.tailnet = tailnet
608
+ self.secretfile = secretfile
609
+ self.tailscale_api = None
610
+
611
+ async def _start(self):
612
+ # create new access_token from client_secret if previous access_token is expired or nonexistent
613
+ tokenfile = self.secretfile + Tailscale.TOKEN_SUFFIX
614
+ token = None
615
+ try:
616
+ if 3300 > self.secrets.get_age(tokenfile):
617
+ token = self.secrets.get(tokenfile)
618
+ except FileNotFoundError:
619
+ pass
620
+ if token is None:
621
+ secret = self.secrets.get(self.secretfile)
622
+ client_id = secret.split('-')[2]
623
+ data = {
624
+ "client_id": client_id,
625
+ "client_secret": secret,
626
+ "grant_type": "client_credentials",
627
+ "scope" : "devices:core:read"
628
+ }
629
+ logger.debug("Generating new Tailscale API token")
630
+ async with self.session.post('https://api.tailscale.com/api/v2/oauth/token', data=data) as response:
631
+ json_response = await response.json()
632
+ expires_in = json_response.get('expires_in')
633
+ token = json_response.get('access_token')
634
+ assert expires_in is not None and token is not None
635
+ if expires_in < 3600:
636
+ raise RuntimeError(f'Tailscale access token received shorter that 1 hour, {expires_in} seconds expiration')
637
+ self.secrets.set(tokenfile, token)
638
+ self.tailscale_api = TailscaleApi(session=self.session, tailnet=self.tailnet, api_key=token)
639
+
640
+ async def device(self, machine_name: str) -> TailscaleDeviceInfo:
641
+ if not self.tailscale_api:
642
+ await self._start()
643
+ assert self.tailscale_api is not None
644
+ logger.debug("Calling Tailscale API for devices")
645
+ devices = await self.tailscale_api.devices()
646
+ for device in devices.values():
647
+ if device.hostname == machine_name:
648
+ return device
649
+ raise RuntimeError(f"Device {machine_name} in {self.tailnet} is unknown by Tailscale")
650
+
493
651
  class LocalTailscaleManager(Manager):
494
652
  async def start(self):
495
- if not (await Subprocess.async_tailscale(['up']))[0]:
653
+ if not (await Subprocess.tailscale(['up']))[0]:
496
654
  raise RuntimeError("Failed to start up local Tailscale")
497
655
 
498
656
  async def stop(self):
499
- if not (await Subprocess.async_tailscale(['down']))[0]:
657
+ if not (await Subprocess.tailscale(['down']))[0]:
500
658
  raise RuntimeError("Failed to shut down local Tailscale")
501
659
 
502
660
  class LocalTailscale(Manageable):
503
- def __init__(self):
661
+ def __init__(self, tailscale: Tailscale | None = None, machine_name: str | None = None):
504
662
  super().__init__(LocalTailscaleManager())
663
+ self.tailscale = tailscale
664
+ self.machine_name = machine_name
505
665
  self.__qualname__ = "Local Tailscale"
506
666
 
507
667
  async def ping(self, availability_hint: bool | None = None):
508
668
  logger.debug("Getting status of %s", LazyStr(self.get_class_name))
509
- success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
669
+ success, stdout = await Subprocess.tailscale(['status', '--json', '--peers=false', '--self=true'])
510
670
  if success:
511
671
  status = json.loads(stdout)
512
672
  return success and status['BackendState'] == 'Running' and status['Self']['Online']
@@ -514,13 +674,33 @@ class LocalTailscale(Manageable):
514
674
  async def _sleep_while_wait(self, available: bool):
515
675
  await asyncio.sleep(0.250)
516
676
 
677
+ async def start(self, repeat: float, timeout: float):
678
+ if self.tailscale and self.machine_name:
679
+ device_info = await self.tailscale.device(self.machine_name)
680
+ start_result = await super().start(repeat, timeout)
681
+ if start_result and self.tailscale and self.machine_name:
682
+ max_last_seen_age = 7200
683
+ wait_on_fresh_start = 10
684
+ difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
685
+ difference_sec = difference.total_seconds() if difference else None
686
+ if difference_sec is None or difference_sec > max_last_seen_age:
687
+ # wait a little to avoid caching empty DNS entry for 5 minutes, better to loose a few seconds than 300s
688
+ 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)",
689
+ wait_on_fresh_start, LazyStr(self.get_class_name), max_last_seen_age/3600,
690
+ LazyStr((lambda last_seen : str(last_seen.astimezone())[:19] if last_seen else None), device_info.last_seen), LazyStr(difference))
691
+ await asyncio.sleep(wait_on_fresh_start)
692
+ return start_result
693
+
517
694
  class Funnel(Pingable):
518
695
  LOCAL_HOST = '127.0.0.1'
519
696
 
520
- def __init__(self, tailscale: RemoteTailscale, 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):
698
+ self.machine_name = machine_name
521
699
  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}'
700
+ self.external_name = f'{machine_name}.{tailnet}'
701
+ self.external_port = external_port
702
+ self.external_url = f'https://{machine_name}.{tailnet}:{external_port}{local_path}'
703
+ self.dns_resolver = dns_resolver
524
704
 
525
705
  async def wait_for(self, available: bool, timeout: float):
526
706
  self._sleepcounter = 0
@@ -529,9 +709,8 @@ class Funnel(Pingable):
529
709
  async def ping(self, availability_hint: bool | None = None):
530
710
  logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
531
711
  try:
532
- # resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
533
- _answer = await dns.asyncresolver.resolve_at('1.1.1.1', self.external_name)
534
- except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
712
+ _answer = await self.dns_resolver.resolve(self.external_name, self.external_port)
713
+ except Exception:
535
714
  return False
536
715
  return True
537
716
 
@@ -628,7 +807,7 @@ class Automate:
628
807
  async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
629
808
  await response.text()
630
809
 
631
- class AutomatepFTPdManager(Manager):
810
+ class AutomatePftpdManager(Manager):
632
811
  def __init__(self, automate: Automate):
633
812
  self.automate = automate
634
813
 
@@ -670,12 +849,12 @@ class WebhookPing(Pingable):
670
849
  class AutomatePhoneState(PhoneState):
671
850
  VARIABLE_STATE = 'state'
672
851
 
673
- def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
674
- self.session = session
852
+ def __init__(self, general_session: aiohttp.ClientSession, external_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
675
853
  self.webhooks = webhooks
676
854
  self.automate = automate
677
855
  self.funnel = funnel
678
- 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)
679
858
 
680
859
  async def get(self, repeat: float, timeout: float):
681
860
  logger.info("Getting Phone state...")
@@ -685,7 +864,7 @@ class AutomatePhoneState(PhoneState):
685
864
  test_timeout = 10.0
686
865
  logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
687
866
  try:
688
- await self.webhook_ping.wait_for(True, test_timeout)
867
+ await self.local_webhook_ping.wait_for(True, test_timeout)
689
868
  except Exception as e:
690
869
  raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
691
870
 
@@ -697,6 +876,15 @@ class AutomatePhoneState(PhoneState):
697
876
  except Exception as e:
698
877
  raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
699
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
+
700
888
  # get state
701
889
  logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
702
890
  self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
@@ -744,6 +932,7 @@ class Control:
744
932
  @staticmethod
745
933
  def setup_parser_arguments(parser):
746
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")
747
936
  parser.add_argument('-i', '--intent', choices=["test", "start", "stop"], help="what to do with the apps, default: test", default="test")
748
937
 
749
938
  @staticmethod
@@ -885,14 +1074,14 @@ class Control:
885
1074
  state = dict()
886
1075
  state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
887
1076
  print(StateSerializer.dumps(state))
888
- except:
1077
+ except Exception:
889
1078
  await _stop(state, stop_only_started = True)
890
1079
  raise
891
1080
  else:
892
1081
  try:
893
1082
  if not await phone.zeroconf_sftp.test():
894
1083
  await phone.zeroconf_sftp.start(10, 30)
895
- except:
1084
+ except Exception:
896
1085
  await _stop(None)
897
1086
  raise
898
1087
  case 'stop':
@@ -912,7 +1101,7 @@ class AutomateControl(Control):
912
1101
  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
1102
  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
1103
  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)")
1104
+ "(generated on https://llamalab.com/automate/cloud, use the same Google account you set up on the Cloud receive block)")
916
1105
 
917
1106
  Control.setup_parser_arguments(parser)
918
1107
 
@@ -928,14 +1117,17 @@ class AutomateControl(Control):
928
1117
  "Note: --backup-state is accurate only, when --funnel is used\n"
929
1118
  "Note: --accept-cellular option can be used only when --funnel is used")
930
1119
  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")
1120
+ "tailnet: your Tailscale tailnet name (eg. tailxxxx.ts.net)\n"
1121
+ "remote-machine-name: your phone's name within your tailnet (just the name, without the tailnet)\n"
1122
+ "sftp-port: Primitive FTPd's sftp port")
1123
+ vpn_group.add_argument('--funnel', nargs=5, metavar=('local-machine-name', 'local-port', 'local-path', 'external-port', 'secretfile'), help=
1124
+ "local-machine-name: your laptop's name within your tailnet (just the name, without the tailnet)\n"
1125
+ "local-port: 12345 - if you used the example tailscale funnel command above (the local webhook will be started on this port)\n"
1126
+ "local-path: /prim-ctrl - if you used the example tailscale funnel command above\n"
1127
+ "external-port: 8443 - if you used the example tailscale funnel command above\n"
1128
+ "secretfile: filename containing Tailscale's Client secret (not API access token, not Auth key) that located under your .secrets folder\n"
1129
+ " (generated on https://login.tailscale.com/admin/settings/oauth, with 'devices:core:read' scope,\n"
1130
+ " save only the Client secret in the file, the Client ID is part of it)")
939
1131
  Control.setup_parser_vpngroup(vpn_group)
940
1132
 
941
1133
  parser.set_defaults(ctor=AutomateControl)
@@ -954,28 +1146,39 @@ class AutomateControl(Control):
954
1146
  async def run(self, args):
955
1147
  self.prepare(args)
956
1148
 
1149
+ external_dns_resolver = ExternalDnsResolver()
957
1150
  async with (
1151
+ aiohttp.ClientSession() as general_session,
958
1152
  aiohttp.ClientSession(
959
1153
  # Automate messaging server prefers closing connections
960
- 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,
961
1158
  AsyncZeroconf() as zeroconf
962
1159
  ):
963
- service_cache = ServiceCache(Cache())
1160
+ service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
964
1161
  service_resolver = SftpServiceResolver(zeroconf)
965
- service_listener = pFTPdServiceListener(args.server_name, service_cache)
1162
+
1163
+ service_listener = PftpdServiceListener(args.server_name, service_cache)
966
1164
  service_browser = SftpServiceBrowser(zeroconf)
967
1165
  await service_browser.add_service_listener(service_listener)
968
1166
 
969
- automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
970
- local_tailscale = LocalTailscale() if args.tailscale else None
1167
+ secrets = Secrets()
1168
+ automate = Automate(secrets, force_close_session, args.automate_account, args.automate_device, args.automate_tokenfile)
971
1169
  remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
972
- zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
973
- 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
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
1174
+ local_tailscale = (
1175
+ LocalTailscale(Tailscale(secrets, general_session, remote_tailscale.tailnet, args.funnel[4]), funnel.machine_name) if remote_tailscale and funnel else
1176
+ LocalTailscale() if args.tailscale else
1177
+ None)
975
1178
 
976
1179
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
977
1180
  local = Local(local_tailscale)
978
- 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
979
1182
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
980
1183
  await self.execute(args, local, phone)
981
1184
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.6.4"
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>"]
@@ -22,6 +22,8 @@ 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"
26
+ asyncssh = "^2.19.0"
25
27
 
26
28
  [tool.poetry.scripts]
27
29
  prim-ctrl = "prim_ctrl.__main__:run"
File without changes