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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prim-ctrl
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -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
- EXTERNAL_DNS = '1.1.1.1'
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[1] if len(e.args) >= 1 else "DNS lookup failed"
254
- exc = OSError(None, msg)
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 OSError(None, "DNS lookup failed")
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, dns_resolver: DnsResolver, local_tailscale: LocalTailscale):
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 = 3600
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 external_dns_resolver(self):
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
- status = json.loads(stdout)
814
- external_dns_resolver = next(addr['Addr'] for addr in status['SplitDNSRoutes']['ts.net.'] if ':' not in addr['Addr'])
815
- return str(external_dns_resolver)
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, external_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel, local_tailscale: LocalTailscale):
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(external_dns_session, funnel, local_tailscale)
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(), external_dns_resolver, local_tailscale) if tailscale and local_tailscale and args.funnel else None
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 Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
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, external_dns_session, webhooks, automate, funnel, local_tailscale) if local_tailscale and funnel and webhooks else None
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.0"
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