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.
- {prim_ctrl-0.6.4 → prim_ctrl-0.7.0}/PKG-INFO +14 -8
- {prim_ctrl-0.6.4 → prim_ctrl-0.7.0}/README.md +11 -7
- {prim_ctrl-0.6.4 → prim_ctrl-0.7.0}/prim_ctrl/__main__.py +322 -119
- {prim_ctrl-0.6.4 → prim_ctrl-0.7.0}/pyproject.toml +3 -1
- {prim_ctrl-0.6.4 → prim_ctrl-0.7.0}/LICENSE +0 -0
- {prim_ctrl-0.6.4 → 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,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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
logger.debug("
|
|
245
|
-
|
|
246
|
-
_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)
|
|
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
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
535
|
+
await self._connect(host, port)
|
|
421
536
|
self.host = host
|
|
422
537
|
self.port = port
|
|
423
|
-
return
|
|
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
|
-
|
|
541
|
+
logger.debug(" %s", LazyStr(repr, e))
|
|
427
542
|
else:
|
|
428
543
|
raise
|
|
429
|
-
host, port = await
|
|
430
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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}.{
|
|
523
|
-
self.
|
|
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
|
-
|
|
533
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
vpn_group.add_argument('--funnel', nargs=
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
970
|
-
|
|
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
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|