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.
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/PKG-INFO +8 -6
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/README.md +6 -5
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/prim_ctrl/__main__.py +223 -101
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/pyproject.toml +2 -1
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/LICENSE +0 -0
- {prim_ctrl-0.6.5 → prim_ctrl-0.7.0}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
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.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
logger.debug("
|
|
249
|
-
|
|
250
|
-
_reader, writer = await
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
535
|
+
await self._connect(host, port)
|
|
425
536
|
self.host = host
|
|
426
537
|
self.port = port
|
|
427
|
-
return
|
|
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
|
-
|
|
541
|
+
logger.debug(" %s", LazyStr(repr, e))
|
|
431
542
|
else:
|
|
432
543
|
raise
|
|
433
|
-
host, port = await
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
607
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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,
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|