prim-ctrl 0.8.0__tar.gz → 0.8.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.8.0 → prim_ctrl-0.8.1}/PKG-INFO +1 -1
- {prim_ctrl-0.8.0 → prim_ctrl-0.8.1}/prim_ctrl/__main__.py +34 -25
- {prim_ctrl-0.8.0 → prim_ctrl-0.8.1}/pyproject.toml +1 -1
- {prim_ctrl-0.8.0 → prim_ctrl-0.8.1}/LICENSE +0 -0
- {prim_ctrl-0.8.0 → prim_ctrl-0.8.1}/README.md +0 -0
- {prim_ctrl-0.8.0 → prim_ctrl-0.8.1}/prim_ctrl/__init__.py +0 -0
|
@@ -225,9 +225,7 @@ class Subprocess:
|
|
|
225
225
|
# Note: aiohttp's AsyncResolver can't be used when asyncio.create_subprocess_exec is used
|
|
226
226
|
# 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
|
|
227
227
|
class ExternalDnsResolver(DnsResolver):
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def __init__(self, where: str = EXTERNAL_DNS):
|
|
228
|
+
def __init__(self, where: str):
|
|
231
229
|
self.where = where
|
|
232
230
|
self.dns_resolver = None
|
|
233
231
|
self.cache = dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]]()
|
|
@@ -250,8 +248,8 @@ class ExternalDnsResolver(DnsResolver):
|
|
|
250
248
|
self.dns_resolver = await dns.asyncresolver.make_resolver_at(self.where)
|
|
251
249
|
answer = await self.dns_resolver.resolve(host, rdtype=dns.rdatatype.AAAA if family == socket.AF_INET6 else dns.rdatatype.A)
|
|
252
250
|
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e:
|
|
253
|
-
msg = e.args
|
|
254
|
-
exc =
|
|
251
|
+
msg = '; '.join(e.args) if len(e.args) else "DNS lookup failed"
|
|
252
|
+
exc = LookupError(msg)
|
|
255
253
|
# this is captured in a TaskGroup that drops traceback information from "from e"
|
|
256
254
|
exc.add_note(repr(e))
|
|
257
255
|
raise exc from None
|
|
@@ -271,7 +269,7 @@ class ExternalDnsResolver(DnsResolver):
|
|
|
271
269
|
)
|
|
272
270
|
)
|
|
273
271
|
if not hosts:
|
|
274
|
-
raise
|
|
272
|
+
raise LookupError("DNS lookup failed")
|
|
275
273
|
|
|
276
274
|
self.cache[key] = (answer.expiration, hosts)
|
|
277
275
|
return hosts
|
|
@@ -704,14 +702,14 @@ class Tailscale():
|
|
|
704
702
|
class Funnel(Pingable):
|
|
705
703
|
LOCAL_HOST = '127.0.0.1'
|
|
706
704
|
|
|
707
|
-
def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int,
|
|
705
|
+
def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int, local_tailscale: LocalTailscale):
|
|
708
706
|
self.machine_name = machine_name
|
|
709
707
|
self.local_port = local_port
|
|
710
708
|
self.external_name = f'{machine_name}.{tailscale.tailnet}'
|
|
711
709
|
self.external_port = external_port
|
|
712
710
|
self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
|
|
713
|
-
self.external_public_dns_resolver = dns_resolver
|
|
714
711
|
self.local_tailscale = local_tailscale
|
|
712
|
+
self.external_public_dns_resolver = None
|
|
715
713
|
self.external_tailscale_dns_resolver = None
|
|
716
714
|
|
|
717
715
|
async def wait_for(self, available: bool, timeout: float):
|
|
@@ -720,15 +718,17 @@ class Funnel(Pingable):
|
|
|
720
718
|
|
|
721
719
|
async def ping(self, availability_hint: bool | None = None):
|
|
722
720
|
logger.debug("Resolving DNS for %s (%s:%s)", LazyStr(self.get_class_name), self.external_name, self.external_port)
|
|
723
|
-
if self.external_tailscale_dns_resolver is None:
|
|
724
|
-
self.external_tailscale_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_dns_resolver())
|
|
725
721
|
# first try at Tailscale's DNS, if it doesn't know, we should not resolve at a public DNS and cache nxdomain for 5 minutes
|
|
722
|
+
if self.external_tailscale_dns_resolver is None:
|
|
723
|
+
self.external_tailscale_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_tailscale_dns_resolver())
|
|
726
724
|
try:
|
|
727
725
|
_answer = await self.external_tailscale_dns_resolver.resolve(self.external_name, self.external_port)
|
|
728
726
|
except Exception as e:
|
|
729
727
|
logger.debug("Resolving at Tailscale's external DNS has failed: %s", LazyStr(repr, e))
|
|
730
728
|
return False
|
|
731
729
|
# then try at a public DNS
|
|
730
|
+
if self.external_public_dns_resolver is None:
|
|
731
|
+
self.external_public_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_public_dns_resolver())
|
|
732
732
|
try:
|
|
733
733
|
_answer = await self.external_public_dns_resolver.resolve(self.external_name, self.external_port)
|
|
734
734
|
except Exception as e:
|
|
@@ -791,7 +791,7 @@ class LocalTailscale(Manageable):
|
|
|
791
791
|
self._is_started_now = start_result
|
|
792
792
|
if start_result and not self._checked_fresh_start and self.machine_name:
|
|
793
793
|
self._checked_fresh_start = True
|
|
794
|
-
max_last_seen_age =
|
|
794
|
+
max_last_seen_age = 7200
|
|
795
795
|
wait_on_fresh_start = 5
|
|
796
796
|
difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
|
|
797
797
|
difference_sec = difference.total_seconds() if difference else None
|
|
@@ -803,16 +803,23 @@ class LocalTailscale(Manageable):
|
|
|
803
803
|
await asyncio.sleep(wait_on_fresh_start)
|
|
804
804
|
return start_result
|
|
805
805
|
|
|
806
|
-
async def
|
|
807
|
-
logger.debug("Getting external DNS resolver of %s for ts.net", LazyStr(self.get_class_name))
|
|
806
|
+
async def _tailscale_dns_status(self):
|
|
808
807
|
success, stdout, stderr = await Subprocess.tailscale(['dns', 'status', '--json'])
|
|
809
808
|
if not success:
|
|
810
809
|
exc = RuntimeError("Failed to get local DNS status from local Tailscale")
|
|
811
810
|
exc.add_note(stderr.rstrip().replace("\n", "; "))
|
|
812
811
|
raise exc
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
812
|
+
return json.loads(stdout)
|
|
813
|
+
|
|
814
|
+
async def external_public_dns_resolver(self):
|
|
815
|
+
logger.debug("Getting external public DNS resolver of %s", LazyStr(self.get_class_name))
|
|
816
|
+
status = await self._tailscale_dns_status()
|
|
817
|
+
return str(status['SystemDNS']['Nameservers'][0])
|
|
818
|
+
|
|
819
|
+
async def external_tailscale_dns_resolver(self):
|
|
820
|
+
logger.debug("Getting external Tailscale DNS resolver of %s for ts.net", LazyStr(self.get_class_name))
|
|
821
|
+
status = await self._tailscale_dns_status()
|
|
822
|
+
return str(next(addr['Addr'] for addr in status['SplitDNSRoutes']['ts.net.'] if ':' not in addr['Addr']))
|
|
816
823
|
|
|
817
824
|
class StatSeen(ABC):
|
|
818
825
|
@abstractmethod
|
|
@@ -991,12 +998,12 @@ class ExternalWebhookPing(WebhookPing):
|
|
|
991
998
|
class AutomatePhoneState(PhoneState):
|
|
992
999
|
VARIABLE_STATE = 'state'
|
|
993
1000
|
|
|
994
|
-
def __init__(self, general_session: aiohttp.ClientSession,
|
|
1001
|
+
def __init__(self, general_session: aiohttp.ClientSession, external_public_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel, local_tailscale: LocalTailscale):
|
|
995
1002
|
self.webhooks = webhooks
|
|
996
1003
|
self.automate = automate
|
|
997
1004
|
self.funnel = funnel
|
|
998
1005
|
self.local_webhook_ping = WebhookPing(general_session, funnel)
|
|
999
|
-
self.external_webhook_ping = ExternalWebhookPing(
|
|
1006
|
+
self.external_webhook_ping = ExternalWebhookPing(external_public_dns_session, funnel, local_tailscale)
|
|
1000
1007
|
|
|
1001
1008
|
async def get(self, repeat: float, timeout: float):
|
|
1002
1009
|
logger.info("Getting Phone state...")
|
|
@@ -1342,15 +1349,11 @@ class AutomateControl(Control):
|
|
|
1342
1349
|
async def runner(args: argparse.Namespace) -> None:
|
|
1343
1350
|
AutomateControl.prepare(args)
|
|
1344
1351
|
|
|
1345
|
-
external_dns_resolver = ExternalDnsResolver()
|
|
1346
1352
|
async with (
|
|
1347
1353
|
aiohttp.ClientSession() as general_session,
|
|
1348
1354
|
aiohttp.ClientSession(
|
|
1349
1355
|
# Automate messaging server prefers closing connections
|
|
1350
1356
|
connector=aiohttp.TCPConnector(force_close=True)) as force_close_session,
|
|
1351
|
-
aiohttp.ClientSession(
|
|
1352
|
-
# Uses external DNS to access Funnel TCP forwarder servers instead of local MagicDNS route
|
|
1353
|
-
connector=aiohttp.TCPConnector(resolver=external_dns_resolver)) as external_dns_session,
|
|
1354
1357
|
AsyncZeroconf() as zeroconf
|
|
1355
1358
|
):
|
|
1356
1359
|
service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
|
|
@@ -1385,14 +1388,20 @@ class AutomateControl(Control):
|
|
|
1385
1388
|
tailscale = Tailscale(secrets, general_session, _tailscale_tailnet(), _tailscale_secretfile()) if args.tailscale else None
|
|
1386
1389
|
local_tailscale = LocalTailscale(tailscale, _funnel_local_machine_name_or_none(), LocalTailscaleManager()) if tailscale else None
|
|
1387
1390
|
remote_tailscale = RemoteTailscale(tailscale, _tailscale_remote_machine_name(), AutomateTailscaleManager(automate)) if tailscale else None
|
|
1388
|
-
funnel = Funnel(tailscale, _funnel_local_machine_name(), _funnel_local_port(), _funnel_local_path(), _funnel_external_port(),
|
|
1391
|
+
funnel = Funnel(tailscale, _funnel_local_machine_name(), _funnel_local_port(), _funnel_local_path(), _funnel_external_port(), local_tailscale) if tailscale and args.funnel and local_tailscale else None
|
|
1389
1392
|
pftpd_manager = AutomatePftpdManager(automate)
|
|
1390
1393
|
zeroconf_pftpd = ZeroconfPftpd(args.server_name, service_cache, service_resolver, args.keyfile, pftpd_manager)
|
|
1391
1394
|
remote_pftpd = RemotePftpd(remote_tailscale.host, _tailscale_sftp_port(), args.server_name, args.keyfile, pftpd_manager) if remote_tailscale else None
|
|
1392
1395
|
|
|
1393
|
-
async with
|
|
1396
|
+
async with (
|
|
1397
|
+
aiohttp.ClientSession(
|
|
1398
|
+
# Uses external DNS to access Funnel TCP forwarder servers instead of local MagicDNS route
|
|
1399
|
+
connector=aiohttp.TCPConnector(resolver=funnel.external_public_dns_resolver)
|
|
1400
|
+
) if funnel else nullcontext() as external_public_dns_session,
|
|
1401
|
+
Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks
|
|
1402
|
+
):
|
|
1394
1403
|
local = Local(local_tailscale)
|
|
1395
|
-
automate_phone_state = AutomatePhoneState(general_session,
|
|
1404
|
+
automate_phone_state = AutomatePhoneState(general_session, external_public_dns_session, webhooks, automate, funnel, local_tailscale) if external_public_dns_session and webhooks and funnel and local_tailscale else None
|
|
1396
1405
|
phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
|
|
1397
1406
|
keyboard_interrupt = SignalFence(signal.SIGINT, lambda signum, frame: logger.debug("Keyboard interrupt received, finishing ongoing operations before exit"))
|
|
1398
1407
|
control = AutomateControl(args, local, phone, keyboard_interrupt)
|
|
@@ -3,7 +3,7 @@ packages = [{include = "prim_ctrl"}]
|
|
|
3
3
|
|
|
4
4
|
[project]
|
|
5
5
|
name = "prim-ctrl"
|
|
6
|
-
version = "0.8.
|
|
6
|
+
version = "0.8.1"
|
|
7
7
|
description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
authors = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|