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