prim-ctrl 0.6.0__tar.gz → 0.6.2__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.0 → prim_ctrl-0.6.2}/PKG-INFO +5 -3
- {prim_ctrl-0.6.0 → prim_ctrl-0.6.2}/README.md +1 -0
- {prim_ctrl-0.6.0 → prim_ctrl-0.6.2}/prim_ctrl/__main__.py +317 -260
- {prim_ctrl-0.6.0 → prim_ctrl-0.6.2}/pyproject.toml +4 -3
- {prim_ctrl-0.6.0 → prim_ctrl-0.6.2}/LICENSE +0 -0
- {prim_ctrl-0.6.0 → prim_ctrl-0.6.2}/prim_ctrl/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: prim-ctrl
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -13,9 +13,10 @@ Classifier: License :: OSI Approved :: Apache Software License
|
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Requires-Dist: aiohttp (>=3.11.
|
|
16
|
+
Requires-Dist: aiohttp (>=3.11.10,<4.0.0)
|
|
17
|
+
Requires-Dist: dnspython (>=2.7.0,<3.0.0)
|
|
17
18
|
Requires-Dist: platformdirs (>=4.3.6,<5.0.0)
|
|
18
|
-
Requires-Dist: zeroconf (>=0.136.
|
|
19
|
+
Requires-Dist: zeroconf (>=0.136.2,<0.137.0)
|
|
19
20
|
Project-URL: Change Log, https://github.com/lmagyar/prim-ctrl/blob/main/CHANGELOG.md
|
|
20
21
|
Project-URL: Repository, https://github.com/lmagyar/prim-ctrl
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
@@ -136,6 +137,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
|
|
|
136
137
|
|
|
137
138
|
Notes:
|
|
138
139
|
- Even when -b option is **not** used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
|
|
140
|
+
- If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
|
|
139
141
|
|
|
140
142
|
### Some example
|
|
141
143
|
|
|
@@ -114,6 +114,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
|
|
|
114
114
|
|
|
115
115
|
Notes:
|
|
116
116
|
- Even when -b option is **not** used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
|
|
117
|
+
- If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
|
|
117
118
|
|
|
118
119
|
### Some example
|
|
119
120
|
|
|
@@ -16,6 +16,8 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Dict
|
|
17
17
|
|
|
18
18
|
import aiohttp
|
|
19
|
+
import dns.asyncresolver
|
|
20
|
+
import dns.resolver
|
|
19
21
|
from aiohttp import ClientTimeout, web
|
|
20
22
|
from platformdirs import user_cache_dir
|
|
21
23
|
from zeroconf import Zeroconf, ServiceInfo, ServiceListener as ZeroconfServiceListener
|
|
@@ -35,7 +37,7 @@ class LevelFormatter(logging.Formatter):
|
|
|
35
37
|
return self.formatters.get(record.levelno, self.default_formatter).format(record)
|
|
36
38
|
|
|
37
39
|
class Logger(logging.Logger):
|
|
38
|
-
def __init__(self, name, level=logging.NOTSET):
|
|
40
|
+
def __init__(self, name, level = logging.NOTSET):
|
|
39
41
|
super().__init__(name, level)
|
|
40
42
|
self.exitcode = 0
|
|
41
43
|
|
|
@@ -62,6 +64,15 @@ class Logger(logging.Logger):
|
|
|
62
64
|
if self.level == logging.NOTSET:
|
|
63
65
|
self.setLevel(logging.WARNING if silent else logging.INFO)
|
|
64
66
|
|
|
67
|
+
def exception_or_error(self, e: Exception, args):
|
|
68
|
+
if not args or args.debug:
|
|
69
|
+
logger.exception(e)
|
|
70
|
+
else:
|
|
71
|
+
if hasattr(e, '__notes__'):
|
|
72
|
+
logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
|
|
73
|
+
else:
|
|
74
|
+
logger.error(LazyStr(repr, e))
|
|
75
|
+
|
|
65
76
|
def error(self, msg, *args, **kwargs):
|
|
66
77
|
self.exitcode = 1
|
|
67
78
|
super().error(msg, *args, **kwargs)
|
|
@@ -90,50 +101,43 @@ logger = Logger(Path(sys.argv[0]).name)
|
|
|
90
101
|
|
|
91
102
|
########
|
|
92
103
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
class Subprocess:
|
|
105
|
+
|
|
106
|
+
# based on https://stackoverflow.com/a/55656177/2755656
|
|
107
|
+
@staticmethod
|
|
108
|
+
def sync_ping(host, packets: int = 1, timeout: float = 1):
|
|
109
|
+
if platform.system().lower() == 'windows':
|
|
110
|
+
command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
|
|
111
|
+
# don't use text=True, the async version will raise ValueError("text must be False"), who knows why
|
|
112
|
+
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
113
|
+
return result.returncode == 0 and b'TTL=' in result.stdout
|
|
114
|
+
else:
|
|
115
|
+
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
116
|
+
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
117
|
+
return result.returncode == 0
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
async def async_ping(host, packets: int = 1, timeout: float = 1):
|
|
121
|
+
if platform.system().lower() == 'windows':
|
|
122
|
+
command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
|
|
123
|
+
# don't use text=True, the async version will raise ValueError("text must be False"), who knows why
|
|
124
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
125
|
+
stdout, _stderr = await proc.communicate()
|
|
126
|
+
return proc.returncode == 0 and b'TTL=' in stdout
|
|
127
|
+
else:
|
|
128
|
+
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
129
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
130
|
+
_stdout, _stderr = await proc.communicate()
|
|
131
|
+
return proc.returncode == 0
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
async def async_tailscale(args: list[str]):
|
|
135
|
+
command = ['tailscale']
|
|
136
|
+
command.extend(args)
|
|
137
|
+
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
138
|
+
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
110
139
|
stdout, _stderr = await proc.communicate()
|
|
111
|
-
return proc.returncode == 0
|
|
112
|
-
else:
|
|
113
|
-
command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
|
|
114
|
-
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
115
|
-
_stdout, _stderr = await proc.communicate()
|
|
116
|
-
return proc.returncode == 0
|
|
117
|
-
|
|
118
|
-
async def async_tailscale(args: list[str]):
|
|
119
|
-
command = ['tailscale']
|
|
120
|
-
command.extend(args)
|
|
121
|
-
creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
|
|
122
|
-
proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
|
|
123
|
-
stdout, _stderr = await proc.communicate()
|
|
124
|
-
return proc.returncode == 0, stdout
|
|
125
|
-
|
|
126
|
-
async def async_tailscale_up():
|
|
127
|
-
return (await async_tailscale(['up']))[0]
|
|
128
|
-
|
|
129
|
-
async def async_tailscale_down():
|
|
130
|
-
return (await async_tailscale(['down']))[0]
|
|
131
|
-
|
|
132
|
-
async def async_tailscale_is_online():
|
|
133
|
-
success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
134
|
-
if success:
|
|
135
|
-
status = json.loads(stdout)
|
|
136
|
-
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
140
|
+
return proc.returncode == 0, stdout
|
|
137
141
|
|
|
138
142
|
########
|
|
139
143
|
|
|
@@ -171,8 +175,11 @@ class Pingable:
|
|
|
171
175
|
logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
|
|
172
176
|
async with asyncio.timeout(timeout):
|
|
173
177
|
while await self.ping(available) != available:
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
await self._sleep_while_wait(available)
|
|
179
|
+
|
|
180
|
+
async def _sleep_while_wait(self, available: bool):
|
|
181
|
+
if not available:
|
|
182
|
+
await asyncio.sleep(1)
|
|
176
183
|
|
|
177
184
|
class Manager:
|
|
178
185
|
@abstractmethod
|
|
@@ -253,7 +260,7 @@ class Device(Manageable):
|
|
|
253
260
|
|
|
254
261
|
async def ping(self, availability_hint: bool | None = None):
|
|
255
262
|
logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
|
|
256
|
-
return await async_ping(self.host, timeout=2)
|
|
263
|
+
return await Subprocess.async_ping(self.host, timeout=2)
|
|
257
264
|
|
|
258
265
|
class StateSerializer:
|
|
259
266
|
BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
|
|
@@ -289,153 +296,6 @@ class PhoneState:
|
|
|
289
296
|
|
|
290
297
|
########
|
|
291
298
|
|
|
292
|
-
class Webhooks:
|
|
293
|
-
PING_PATH = 'ping'
|
|
294
|
-
VARIABLE_PATH = 'variable'
|
|
295
|
-
|
|
296
|
-
def __init__(self, host: str, port: int):
|
|
297
|
-
self.host = host
|
|
298
|
-
self.port = port
|
|
299
|
-
self.variables = dict[str, asyncio.Queue[str]]()
|
|
300
|
-
|
|
301
|
-
@staticmethod
|
|
302
|
-
def get_ping_path():
|
|
303
|
-
return f'/{Webhooks.PING_PATH}'
|
|
304
|
-
|
|
305
|
-
@staticmethod
|
|
306
|
-
def get_variable_path(variable: str):
|
|
307
|
-
return f'/{Webhooks.VARIABLE_PATH}/{variable}'
|
|
308
|
-
|
|
309
|
-
async def _start(self):
|
|
310
|
-
async def _ping(request: web.Request):
|
|
311
|
-
return web.Response(text='pong')
|
|
312
|
-
async def _receive_variable(request: web.Request):
|
|
313
|
-
queue = self.variables.get(request.match_info['name'])
|
|
314
|
-
if queue:
|
|
315
|
-
with suppress(asyncio.QueueFull):
|
|
316
|
-
queue.put_nowait(await request.text())
|
|
317
|
-
return web.Response(text='OK')
|
|
318
|
-
app = web.Application()
|
|
319
|
-
app.add_routes([
|
|
320
|
-
web.get(f'/{Webhooks.PING_PATH}', _ping),
|
|
321
|
-
web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
|
|
322
|
-
self.runner = web.AppRunner(app)
|
|
323
|
-
await self.runner.setup()
|
|
324
|
-
self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
|
|
325
|
-
await self.site.start()
|
|
326
|
-
|
|
327
|
-
async def _stop(self):
|
|
328
|
-
await self.runner.cleanup()
|
|
329
|
-
|
|
330
|
-
def subscribe_variable(self, variable: str):
|
|
331
|
-
if variable not in self.variables:
|
|
332
|
-
self.variables[variable] = asyncio.Queue[str](maxsize=16)
|
|
333
|
-
|
|
334
|
-
def unsubscribe_variable(self, variable: str):
|
|
335
|
-
self.variables.pop(variable)
|
|
336
|
-
|
|
337
|
-
async def get_variable(self, variable: str, timeout: float):
|
|
338
|
-
queue = self.variables.get(variable)
|
|
339
|
-
if not queue:
|
|
340
|
-
raise ValueError(f"The {variable} is unknown")
|
|
341
|
-
try:
|
|
342
|
-
async with asyncio.timeout(timeout):
|
|
343
|
-
return await queue.get()
|
|
344
|
-
except TimeoutError as e:
|
|
345
|
-
e.add_note(f"Can't get value of {variable} for {timeout} seconds")
|
|
346
|
-
raise
|
|
347
|
-
|
|
348
|
-
def __enter__(self):
|
|
349
|
-
raise TypeError("Use async with instead")
|
|
350
|
-
def __exit__(self, exc_type, exc_value, exc_tb):
|
|
351
|
-
pass
|
|
352
|
-
async def __aenter__(self):
|
|
353
|
-
await self._start()
|
|
354
|
-
return self
|
|
355
|
-
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
|
356
|
-
await self._stop()
|
|
357
|
-
|
|
358
|
-
class Automate:
|
|
359
|
-
def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
|
|
360
|
-
self.session = session
|
|
361
|
-
self.account = account
|
|
362
|
-
self.device = device
|
|
363
|
-
self.secret = secrets.get(tokenfile)
|
|
364
|
-
|
|
365
|
-
async def send_message(self, message: str):
|
|
366
|
-
data = {
|
|
367
|
-
"secret": self.secret,
|
|
368
|
-
"to": self.account,
|
|
369
|
-
"device": self.device,
|
|
370
|
-
"priority": "high",
|
|
371
|
-
"payload": f"prim-ctrl;{time.time()};" + message
|
|
372
|
-
}
|
|
373
|
-
logger.debug("Messaging Automate with: %s", message)
|
|
374
|
-
async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
|
|
375
|
-
await response.text()
|
|
376
|
-
|
|
377
|
-
class AutomatepFTPdManager(Manager):
|
|
378
|
-
def __init__(self, automate: Automate):
|
|
379
|
-
self.automate = automate
|
|
380
|
-
|
|
381
|
-
async def start(self):
|
|
382
|
-
await self.automate.send_message('start-pftpd')
|
|
383
|
-
|
|
384
|
-
async def stop(self):
|
|
385
|
-
await self.automate.send_message('stop-pftpd')
|
|
386
|
-
|
|
387
|
-
class AutomateTailscaleManager(Manager):
|
|
388
|
-
def __init__(self, automate: Automate):
|
|
389
|
-
self.automate = automate
|
|
390
|
-
|
|
391
|
-
async def start(self):
|
|
392
|
-
await self.automate.send_message('start-tailscale')
|
|
393
|
-
|
|
394
|
-
async def stop(self):
|
|
395
|
-
await self.automate.send_message('stop-tailscale')
|
|
396
|
-
|
|
397
|
-
class AutomatePhoneState(PhoneState):
|
|
398
|
-
VARIABLE_STATE = 'state'
|
|
399
|
-
|
|
400
|
-
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
|
|
401
|
-
self.session = session
|
|
402
|
-
self.webhooks = webhooks
|
|
403
|
-
self.automate = automate
|
|
404
|
-
self.external_url = external_url
|
|
405
|
-
|
|
406
|
-
async def get(self, repeat: float, timeout: float):
|
|
407
|
-
logger.info("Getting Phone state...")
|
|
408
|
-
# first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
|
|
409
|
-
# though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
|
|
410
|
-
logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
|
|
411
|
-
try:
|
|
412
|
-
async with self.session.get(f'{self.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=timeout)) as response:
|
|
413
|
-
if await response.text() != 'pong':
|
|
414
|
-
raise Exception()
|
|
415
|
-
except:
|
|
416
|
-
raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
|
|
417
|
-
# get state
|
|
418
|
-
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
419
|
-
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
420
|
-
try:
|
|
421
|
-
async with asyncio.timeout(timeout):
|
|
422
|
-
while True:
|
|
423
|
-
try:
|
|
424
|
-
await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
425
|
-
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
426
|
-
break
|
|
427
|
-
except TimeoutError:
|
|
428
|
-
pass
|
|
429
|
-
except TimeoutError as e:
|
|
430
|
-
e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
|
|
431
|
-
raise
|
|
432
|
-
finally:
|
|
433
|
-
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
434
|
-
logger.info("Phone state is %s", state)
|
|
435
|
-
return StateSerializer.loads(state)
|
|
436
|
-
|
|
437
|
-
########
|
|
438
|
-
|
|
439
299
|
class Cache:
|
|
440
300
|
PRIM_SYNC_APP_NAME = 'prim-sync'
|
|
441
301
|
|
|
@@ -545,19 +405,19 @@ class ZeroconfService(Manageable):
|
|
|
545
405
|
|
|
546
406
|
async def ping(self, availability_hint: bool | None = None):
|
|
547
407
|
async def _connect(connect_timeout: float, resolve_timeout: float):
|
|
548
|
-
async def
|
|
408
|
+
async def _asyncio_open_connection(host: str, port: int, timeout: float):
|
|
549
409
|
logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
|
|
550
410
|
async with asyncio.timeout(timeout):
|
|
551
411
|
return await asyncio.open_connection(host, port)
|
|
552
|
-
async def
|
|
412
|
+
async def _service_resolver_get(service_name: str, timeout: float):
|
|
553
413
|
logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
|
|
554
414
|
return await self.service_resolver.get(service_name, timeout)
|
|
555
415
|
if self.host and self.port:
|
|
556
|
-
return await
|
|
416
|
+
return await _asyncio_open_connection(self.host, self.port, connect_timeout)
|
|
557
417
|
host, port = self.service_cache.get(self.service_name)
|
|
558
418
|
if host and port:
|
|
559
419
|
try:
|
|
560
|
-
reader_writer = await
|
|
420
|
+
reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
|
|
561
421
|
self.host = host
|
|
562
422
|
self.port = port
|
|
563
423
|
return reader_writer
|
|
@@ -566,8 +426,8 @@ class ZeroconfService(Manageable):
|
|
|
566
426
|
pass
|
|
567
427
|
else:
|
|
568
428
|
raise
|
|
569
|
-
host, port = await
|
|
570
|
-
reader_writer = await
|
|
429
|
+
host, port = await _service_resolver_get(self.service_name, resolve_timeout)
|
|
430
|
+
reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
|
|
571
431
|
self.service_cache.set(self.service_name, host, port)
|
|
572
432
|
self.host = host
|
|
573
433
|
self.port = port
|
|
@@ -624,13 +484,6 @@ class RemoteTailscale(Device):
|
|
|
624
484
|
self.tailnet = tailnet
|
|
625
485
|
self.__qualname__ = "Remote Tailscale"
|
|
626
486
|
|
|
627
|
-
class Funnel:
|
|
628
|
-
LOCAL_HOST = '127.0.0.1'
|
|
629
|
-
|
|
630
|
-
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
631
|
-
self.local_port = local_port
|
|
632
|
-
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
633
|
-
|
|
634
487
|
########
|
|
635
488
|
|
|
636
489
|
class Local:
|
|
@@ -639,10 +492,12 @@ class Local:
|
|
|
639
492
|
|
|
640
493
|
class LocalTailscaleManager(Manager):
|
|
641
494
|
async def start(self):
|
|
642
|
-
await
|
|
495
|
+
if not (await Subprocess.async_tailscale(['up']))[0]:
|
|
496
|
+
raise RuntimeError("Failed to start up local Tailscale")
|
|
643
497
|
|
|
644
498
|
async def stop(self):
|
|
645
|
-
await
|
|
499
|
+
if not (await Subprocess.async_tailscale(['down']))[0]:
|
|
500
|
+
raise RuntimeError("Failed to shut down local Tailscale")
|
|
646
501
|
|
|
647
502
|
class LocalTailscale(Manageable):
|
|
648
503
|
def __init__(self):
|
|
@@ -650,8 +505,200 @@ class LocalTailscale(Manageable):
|
|
|
650
505
|
self.__qualname__ = "Local Tailscale"
|
|
651
506
|
|
|
652
507
|
async def ping(self, availability_hint: bool | None = None):
|
|
653
|
-
logger.debug("
|
|
654
|
-
|
|
508
|
+
logger.debug("Getting status of %s", LazyStr(self.get_class_name))
|
|
509
|
+
success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
|
|
510
|
+
if success:
|
|
511
|
+
status = json.loads(stdout)
|
|
512
|
+
return success and status['BackendState'] == 'Running' and status['Self']['Online']
|
|
513
|
+
|
|
514
|
+
async def _sleep_while_wait(self, available: bool):
|
|
515
|
+
await asyncio.sleep(0.250)
|
|
516
|
+
|
|
517
|
+
class Funnel(Pingable):
|
|
518
|
+
LOCAL_HOST = '127.0.0.1'
|
|
519
|
+
|
|
520
|
+
def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
|
|
521
|
+
self.local_port = local_port
|
|
522
|
+
self.external_name = f'{machine_name}.{tailscale.tailnet}'
|
|
523
|
+
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
524
|
+
|
|
525
|
+
async def wait_for(self, available: bool, timeout: float):
|
|
526
|
+
self._sleepcounter = 0
|
|
527
|
+
await super().wait_for(available, timeout)
|
|
528
|
+
|
|
529
|
+
async def ping(self, availability_hint: bool | None = None):
|
|
530
|
+
logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
|
|
531
|
+
try:
|
|
532
|
+
# resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
|
|
533
|
+
_answer = await dns.asyncresolver.resolve_at('1.1.1.1', self.external_name)
|
|
534
|
+
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
|
535
|
+
return False
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
async def _sleep_while_wait(self, available: bool):
|
|
539
|
+
if 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
|
|
540
|
+
logger.info("Waiting for public DNS records to be updated for %s (%s)...", LazyStr(self.get_class_name), self.external_name)
|
|
541
|
+
await asyncio.sleep(10)
|
|
542
|
+
self._sleepcounter += 1
|
|
543
|
+
|
|
544
|
+
########
|
|
545
|
+
|
|
546
|
+
class Webhooks:
|
|
547
|
+
PING_PATH = 'ping'
|
|
548
|
+
VARIABLE_PATH = 'variable'
|
|
549
|
+
|
|
550
|
+
def __init__(self, host: str, port: int):
|
|
551
|
+
self.host = host
|
|
552
|
+
self.port = port
|
|
553
|
+
self.variables = dict[str, asyncio.Queue[str]]()
|
|
554
|
+
|
|
555
|
+
@staticmethod
|
|
556
|
+
def get_ping_path():
|
|
557
|
+
return f'/{Webhooks.PING_PATH}'
|
|
558
|
+
|
|
559
|
+
@staticmethod
|
|
560
|
+
def get_variable_path(variable: str):
|
|
561
|
+
return f'/{Webhooks.VARIABLE_PATH}/{variable}'
|
|
562
|
+
|
|
563
|
+
async def _start(self):
|
|
564
|
+
async def _ping(request: web.Request):
|
|
565
|
+
return web.Response(text='pong')
|
|
566
|
+
async def _receive_variable(request: web.Request):
|
|
567
|
+
queue = self.variables.get(request.match_info['name'])
|
|
568
|
+
if queue:
|
|
569
|
+
with suppress(asyncio.QueueFull):
|
|
570
|
+
queue.put_nowait(await request.text())
|
|
571
|
+
return web.Response(text='OK')
|
|
572
|
+
app = web.Application()
|
|
573
|
+
app.add_routes([
|
|
574
|
+
web.get(f'/{Webhooks.PING_PATH}', _ping),
|
|
575
|
+
web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
|
|
576
|
+
self.runner = web.AppRunner(app)
|
|
577
|
+
await self.runner.setup()
|
|
578
|
+
self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
|
|
579
|
+
await self.site.start()
|
|
580
|
+
|
|
581
|
+
async def _stop(self):
|
|
582
|
+
await self.runner.cleanup()
|
|
583
|
+
|
|
584
|
+
def subscribe_variable(self, variable: str):
|
|
585
|
+
if variable not in self.variables:
|
|
586
|
+
self.variables[variable] = asyncio.Queue[str](maxsize=16)
|
|
587
|
+
|
|
588
|
+
def unsubscribe_variable(self, variable: str):
|
|
589
|
+
self.variables.pop(variable)
|
|
590
|
+
|
|
591
|
+
async def get_variable(self, variable: str, timeout: float):
|
|
592
|
+
queue = self.variables.get(variable)
|
|
593
|
+
if not queue:
|
|
594
|
+
raise ValueError(f"The {variable} is unknown")
|
|
595
|
+
try:
|
|
596
|
+
async with asyncio.timeout(timeout):
|
|
597
|
+
return await queue.get()
|
|
598
|
+
except TimeoutError as e:
|
|
599
|
+
e.add_note(f"Can't get value of {variable} for {timeout} seconds")
|
|
600
|
+
raise
|
|
601
|
+
|
|
602
|
+
def __enter__(self):
|
|
603
|
+
raise TypeError("Use async with instead")
|
|
604
|
+
def __exit__(self, exc_type, exc_value, exc_tb):
|
|
605
|
+
pass
|
|
606
|
+
async def __aenter__(self):
|
|
607
|
+
await self._start()
|
|
608
|
+
return self
|
|
609
|
+
async def __aexit__(self, exc_type, exc_value, exc_tb):
|
|
610
|
+
await self._stop()
|
|
611
|
+
|
|
612
|
+
class Automate:
|
|
613
|
+
def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
|
|
614
|
+
self.session = session
|
|
615
|
+
self.account = account
|
|
616
|
+
self.device = device
|
|
617
|
+
self.secret = secrets.get(tokenfile)
|
|
618
|
+
|
|
619
|
+
async def send_message(self, message: str):
|
|
620
|
+
data = {
|
|
621
|
+
"secret": self.secret,
|
|
622
|
+
"to": self.account,
|
|
623
|
+
"device": self.device,
|
|
624
|
+
"priority": "high",
|
|
625
|
+
"payload": f"prim-ctrl;{time.time()};" + message
|
|
626
|
+
}
|
|
627
|
+
logger.debug("Messaging Automate with: %s", message)
|
|
628
|
+
async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
|
|
629
|
+
await response.text()
|
|
630
|
+
|
|
631
|
+
class AutomatepFTPdManager(Manager):
|
|
632
|
+
def __init__(self, automate: Automate):
|
|
633
|
+
self.automate = automate
|
|
634
|
+
|
|
635
|
+
async def start(self):
|
|
636
|
+
await self.automate.send_message('start-pftpd')
|
|
637
|
+
|
|
638
|
+
async def stop(self):
|
|
639
|
+
await self.automate.send_message('stop-pftpd')
|
|
640
|
+
|
|
641
|
+
class AutomateTailscaleManager(Manager):
|
|
642
|
+
def __init__(self, automate: Automate):
|
|
643
|
+
self.automate = automate
|
|
644
|
+
|
|
645
|
+
async def start(self):
|
|
646
|
+
await self.automate.send_message('start-tailscale')
|
|
647
|
+
|
|
648
|
+
async def stop(self):
|
|
649
|
+
await self.automate.send_message('stop-tailscale')
|
|
650
|
+
|
|
651
|
+
class AutomatePhoneState(PhoneState):
|
|
652
|
+
VARIABLE_STATE = 'state'
|
|
653
|
+
|
|
654
|
+
def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
|
|
655
|
+
self.session = session
|
|
656
|
+
self.webhooks = webhooks
|
|
657
|
+
self.automate = automate
|
|
658
|
+
self.funnel = funnel
|
|
659
|
+
|
|
660
|
+
async def get(self, repeat: float, timeout: float):
|
|
661
|
+
logger.info("Getting Phone state...")
|
|
662
|
+
|
|
663
|
+
# test funnel + webhooks availability, to not wait for a reply if funnel isn't configured properly
|
|
664
|
+
# though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
|
|
665
|
+
test_timeout = 10.0
|
|
666
|
+
logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
|
|
667
|
+
try:
|
|
668
|
+
async with self.session.get(f'{self.funnel.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=test_timeout)) as response:
|
|
669
|
+
if await response.text() != 'pong':
|
|
670
|
+
raise Exception()
|
|
671
|
+
except Exception as e:
|
|
672
|
+
raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
|
|
673
|
+
|
|
674
|
+
# test funnel's DNS resolvability, if local Tailscale is freshly started up after longer down state, it can take up to 10 minutes for public DNS records to get updated
|
|
675
|
+
test_timeout = 600.0
|
|
676
|
+
logger.debug("Testing Funnel's DNS configuration (timeout is %ds)", int(test_timeout))
|
|
677
|
+
try:
|
|
678
|
+
await self.funnel.wait_for(True, test_timeout)
|
|
679
|
+
except Exception as e:
|
|
680
|
+
raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
|
|
681
|
+
|
|
682
|
+
# get state
|
|
683
|
+
logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
|
|
684
|
+
self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
685
|
+
try:
|
|
686
|
+
async with asyncio.timeout(timeout):
|
|
687
|
+
while True:
|
|
688
|
+
try:
|
|
689
|
+
await self.automate.send_message(f'get-state;{self.funnel.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
|
|
690
|
+
state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
|
|
691
|
+
break
|
|
692
|
+
except TimeoutError:
|
|
693
|
+
pass
|
|
694
|
+
except TimeoutError as e:
|
|
695
|
+
e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
|
|
696
|
+
raise
|
|
697
|
+
finally:
|
|
698
|
+
self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
|
|
699
|
+
|
|
700
|
+
logger.info("Phone state is %s", state)
|
|
701
|
+
return StateSerializer.loads(state)
|
|
655
702
|
|
|
656
703
|
########
|
|
657
704
|
|
|
@@ -715,18 +762,30 @@ class Control:
|
|
|
715
762
|
raise ValueError("The --restore-state option can be enabled only for the stop intent")
|
|
716
763
|
|
|
717
764
|
async def execute(self, args, local: Local, phone: Phone):
|
|
718
|
-
async def _stop(restore_state: dict | None):
|
|
765
|
+
async def _stop(restore_state: dict | None, stop_only_started: bool = False):
|
|
719
766
|
if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
|
|
720
|
-
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP,
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
767
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await phone.remote_sftp.test():
|
|
768
|
+
try:
|
|
769
|
+
await phone.remote_sftp.stop(10, 30)
|
|
770
|
+
except Exception as e:
|
|
771
|
+
logger.exception_or_error(e, args)
|
|
772
|
+
if restore_state is None or not restore_state.get(Control.PHONE_VPN, stop_only_started):
|
|
773
|
+
try:
|
|
774
|
+
await phone.vpn.stop(10, 60)
|
|
775
|
+
except Exception as e:
|
|
776
|
+
logger.exception_or_error(e, args)
|
|
724
777
|
else:
|
|
725
|
-
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP,
|
|
726
|
-
|
|
778
|
+
if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)):
|
|
779
|
+
try:
|
|
780
|
+
await phone.zeroconf_sftp.stop(10, 30)
|
|
781
|
+
except Exception as e:
|
|
782
|
+
logger.exception_or_error(e, args)
|
|
727
783
|
if local.vpn:
|
|
728
|
-
if restore_state is None or not restore_state.get(Control.LOCAL_VPN,
|
|
729
|
-
|
|
784
|
+
if restore_state is None or not restore_state.get(Control.LOCAL_VPN, stop_only_started):
|
|
785
|
+
try:
|
|
786
|
+
await local.vpn.stop(10, 30)
|
|
787
|
+
except Exception as e:
|
|
788
|
+
logger.exception_or_error(e, args)
|
|
730
789
|
match args.intent:
|
|
731
790
|
case 'test':
|
|
732
791
|
if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
|
|
@@ -739,29 +798,31 @@ class Control:
|
|
|
739
798
|
case 'start':
|
|
740
799
|
if local.vpn and phone.vpn and phone.remote_sftp:
|
|
741
800
|
state = dict()
|
|
742
|
-
zeroconf_accessible = False
|
|
743
|
-
remote_accessible = False
|
|
744
|
-
|
|
745
|
-
# gather local state info
|
|
746
|
-
local_vpn_state = await local.vpn.test()
|
|
747
|
-
state[Control.LOCAL_VPN] = local_vpn_state
|
|
748
|
-
# start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
|
|
749
|
-
await local.vpn.start(10, 30)
|
|
750
|
-
|
|
751
|
-
# gather remote state info
|
|
752
|
-
if phone.state:
|
|
753
|
-
phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
754
|
-
state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
|
|
755
|
-
state[Control.PHONE_VPN] = phone_vpn_state
|
|
756
|
-
state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
|
|
757
|
-
if not state[Control.PHONE_WIFI] and not args.accept_cellular:
|
|
758
|
-
raise RuntimeError(f"Phone is not on Wi-Fi network")
|
|
759
|
-
else:
|
|
760
|
-
state[Control.PHONE_VPN] = await phone.vpn.test()
|
|
761
|
-
if state[Control.PHONE_VPN]:
|
|
762
|
-
state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
|
|
763
|
-
# start changing remote state
|
|
764
801
|
try:
|
|
802
|
+
# gather local state info
|
|
803
|
+
local_vpn_state = await local.vpn.test()
|
|
804
|
+
state[Control.LOCAL_VPN] = local_vpn_state
|
|
805
|
+
|
|
806
|
+
# start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
|
|
807
|
+
await local.vpn.start(10, 30)
|
|
808
|
+
|
|
809
|
+
zeroconf_accessible = False
|
|
810
|
+
remote_accessible = False
|
|
811
|
+
|
|
812
|
+
# gather phone state info
|
|
813
|
+
if phone.state:
|
|
814
|
+
phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
|
|
815
|
+
state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
|
|
816
|
+
state[Control.PHONE_VPN] = phone_vpn_state
|
|
817
|
+
state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
|
|
818
|
+
if not state[Control.PHONE_WIFI] and not args.accept_cellular:
|
|
819
|
+
raise RuntimeError(f"Phone is not on Wi-Fi network")
|
|
820
|
+
else:
|
|
821
|
+
state[Control.PHONE_VPN] = await phone.vpn.test()
|
|
822
|
+
if state[Control.PHONE_VPN]:
|
|
823
|
+
state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
|
|
824
|
+
|
|
825
|
+
# start changing phone state
|
|
765
826
|
if phone.state:
|
|
766
827
|
if not state[Control.PHONE_SFTP]:
|
|
767
828
|
if not state[Control.PHONE_VPN]:
|
|
@@ -799,20 +860,22 @@ class Control:
|
|
|
799
860
|
zeroconf_accessible = await phone.zeroconf_sftp.test()
|
|
800
861
|
if not zeroconf_accessible and not remote_accessible:
|
|
801
862
|
raise RuntimeError(f"Even when {phone.vpn.get_class_name()} and {phone.remote_sftp.get_class_name()} is started, {phone.remote_sftp.get_class_name()} is still not accessible")
|
|
863
|
+
|
|
864
|
+
# print out result on stdout
|
|
865
|
+
if not args.backup_state:
|
|
866
|
+
state = dict()
|
|
867
|
+
state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
|
|
868
|
+
print(StateSerializer.dumps(state))
|
|
802
869
|
except:
|
|
803
|
-
await _stop(state)
|
|
870
|
+
await _stop(state, stop_only_started = True)
|
|
804
871
|
raise
|
|
805
|
-
if not args.backup_state:
|
|
806
|
-
state = dict()
|
|
807
|
-
state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
|
|
808
|
-
print(StateSerializer.dumps(state))
|
|
809
872
|
else:
|
|
810
|
-
|
|
811
|
-
|
|
873
|
+
try:
|
|
874
|
+
if not await phone.zeroconf_sftp.test():
|
|
812
875
|
await phone.zeroconf_sftp.start(10, 30)
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
876
|
+
except:
|
|
877
|
+
await _stop(None)
|
|
878
|
+
raise
|
|
816
879
|
case 'stop':
|
|
817
880
|
await _stop(StateSerializer.loads(args.restore_state) if args.restore_state else None)
|
|
818
881
|
|
|
@@ -893,7 +956,7 @@ class AutomateControl(Control):
|
|
|
893
956
|
|
|
894
957
|
async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
|
|
895
958
|
local = Local(local_tailscale)
|
|
896
|
-
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel
|
|
959
|
+
automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
|
|
897
960
|
phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
|
|
898
961
|
await self.execute(args, local, phone)
|
|
899
962
|
|
|
@@ -912,13 +975,7 @@ async def main():
|
|
|
912
975
|
await args.ctor().run(args)
|
|
913
976
|
|
|
914
977
|
except Exception as e:
|
|
915
|
-
|
|
916
|
-
logger.exception(e)
|
|
917
|
-
else:
|
|
918
|
-
if hasattr(e, '__notes__'):
|
|
919
|
-
logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
|
|
920
|
-
else:
|
|
921
|
-
logger.error(LazyStr(repr, e))
|
|
978
|
+
logger.exception_or_error(e, args)
|
|
922
979
|
|
|
923
980
|
return logger.exitcode
|
|
924
981
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "prim-ctrl"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.2"
|
|
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>"]
|
|
@@ -18,9 +18,10 @@ packages = [{include = "prim_ctrl"}]
|
|
|
18
18
|
|
|
19
19
|
[tool.poetry.dependencies]
|
|
20
20
|
python = "^3.12"
|
|
21
|
-
aiohttp = "^3.11.
|
|
21
|
+
aiohttp = "^3.11.10"
|
|
22
22
|
platformdirs = "^4.3.6"
|
|
23
|
-
zeroconf = "^0.136.
|
|
23
|
+
zeroconf = "^0.136.2"
|
|
24
|
+
dnspython = "^2.7.0"
|
|
24
25
|
|
|
25
26
|
[tool.poetry.scripts]
|
|
26
27
|
prim-ctrl = "prim_ctrl.__main__:run"
|
|
File without changes
|
|
File without changes
|