prim-ctrl 0.8.0__tar.gz → 0.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: prim-ctrl
3
- Version: 0.8.0
3
+ Version: 0.8.2
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
@@ -37,7 +37,7 @@ class LevelFormatter(logging.Formatter):
37
37
 
38
38
  def __init__(self, fmts: dict[int, str], fmt: str, **kwargs):
39
39
  super().__init__()
40
- self.formatters = dict({level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()})
40
+ self.formatters = {level: logging.Formatter(fmt, **kwargs) for level, fmt in fmts.items()}
41
41
  self.default_formatter = logging.Formatter(fmt, **kwargs)
42
42
 
43
43
  def format(self, record: logging.LogRecord) -> str:
@@ -132,7 +132,7 @@ class SignalFence():
132
132
  if self.on_deferred_signal is not None:
133
133
  try:
134
134
  self.on_deferred_signal(signum, frame)
135
- except:
135
+ except: # NOSONAR(S5754)
136
136
  pass
137
137
 
138
138
  def disable(self) -> None:
@@ -151,11 +151,11 @@ class SignalFence():
151
151
  self.deferred_signal = None
152
152
  logger.debug("Handling deferred signal %d", self.signum)
153
153
  if isinstance(self.original_handler, signal.Handlers):
154
- if self.original_handler is signal.Handlers.SIG_IGN:
155
- pass
156
- elif self.original_handler is signal.Handlers.SIG_DFL:
154
+ if self.original_handler == signal.Handlers.SIG_DFL:
157
155
  signal.signal(self.signum, signal.SIG_DFL)
158
156
  os.kill(os.getpid(), self.signum)
157
+ # elif self.original_handler == signal.Handlers.SIG_IGN:
158
+ # pass
159
159
  elif callable(self.original_handler):
160
160
  self.original_handler(*deferred_signal)
161
161
 
@@ -193,7 +193,7 @@ class Subprocess:
193
193
  # return result.returncode == 0
194
194
 
195
195
  @staticmethod
196
- async def ping(host, packets: int = 1, timeout: float = 1):
196
+ async def ping(host, packets: int = 1, timeout: float = 1): # NOSONAR(S7483)
197
197
  if platform.system().lower() == 'windows':
198
198
  command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
199
199
  # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
@@ -214,7 +214,7 @@ class Subprocess:
214
214
  try:
215
215
  proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=creationflags)
216
216
  except FileNotFoundError as e:
217
- e.add_note(f"Please check that Tailscale is installed properly")
217
+ e.add_note("Please check that Tailscale is installed properly")
218
218
  raise
219
219
  stdout, stderr = await proc.communicate()
220
220
  return proc.returncode == 0, stdout.decode(), stderr.decode()
@@ -225,12 +225,10 @@ 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
- self.cache = dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]]()
231
+ self.cache: dict[tuple[str, int, socket.AddressFamily], tuple[float, list[ResolveResult]]] = {}
234
232
 
235
233
  async def resolve(self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_UNSPEC) -> list[ResolveResult]:
236
234
  logger.debug("Resolving DNS at %s for %s:%i (%s)", self.where, host, port, "ipv6" if family == socket.AF_INET6 else "ipv4")
@@ -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
@@ -293,7 +291,7 @@ class Pingable(ABC):
293
291
  def get_state_name(available: bool):
294
292
  return 'up' if available else 'down'
295
293
 
296
- async def wait_for(self, available: bool, timeout: float):
294
+ async def wait_for(self, available: bool, timeout: float): # NOSONAR(S7483)
297
295
  logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
298
296
  async with asyncio.timeout(timeout):
299
297
  while await self.ping(available) != available:
@@ -317,7 +315,7 @@ class Manageable(Pingable):
317
315
  super().__init__()
318
316
  self.manager = manager
319
317
 
320
- async def _set_state(self, available: bool, repeat: float, timeout: float):
318
+ async def _set_state(self, available: bool, repeat: float, timeout: float): # NOSONAR(S7483)
321
319
  action_name = LazyStr(lambda: 'Starting' if available else 'Stopping')
322
320
  class_name = LazyStr(self.get_class_name)
323
321
  available_name = LazyStr(Pingable.get_state_name, available)
@@ -346,10 +344,10 @@ class Manageable(Pingable):
346
344
  logger.info("%s is %s", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available))
347
345
  return available
348
346
 
349
- async def start(self, repeat: float, timeout: float):
347
+ async def start(self, repeat: float, timeout: float): # NOSONAR(S7483)
350
348
  return await self._set_state(True, repeat, timeout)
351
349
 
352
- async def stop(self, repeat: float, timeout: float):
350
+ async def stop(self, repeat: float, timeout: float): # NOSONAR(S7483)
353
351
  return await self._set_state(False, repeat, timeout)
354
352
 
355
353
  class Service(Manageable):
@@ -369,11 +367,11 @@ class Service(Manageable):
369
367
  writer.close()
370
368
  await writer.wait_closed()
371
369
 
372
- async def _ping(self, availability_hint: bool | None = None):
370
+ async def _ping(self, availability_hint: bool | None = None): # NOSONAR(S1172)
373
371
  logger.debug("Pinging %s (%s:%s)", LazyStr(self.get_class_name), str(self.host), str(self.port))
374
372
  await self._connect(self.host, self.port)
375
373
 
376
- async def ping(self, availability_hint: bool | None = None):
374
+ async def ping(self, availability_hint: bool | None = None): # NOSONAR(S3776)
377
375
  try:
378
376
  await self._ping(availability_hint)
379
377
  return True
@@ -428,9 +426,9 @@ class SshService(Service):
428
426
  asyncssh.connect(host, port, options=asyncssh.SSHClientConnectionOptions(
429
427
  host_key_alias=self.host_name,
430
428
  client_keys=_client_key(),
431
- connect_timeout=self._connect_timeout)) as conn
429
+ connect_timeout=self._connect_timeout))
432
430
  ):
433
- pass
431
+ pass # NOSONAR(S108)
434
432
 
435
433
  class Device(Manageable):
436
434
  def __init__(self, host: str, manager: Manager):
@@ -460,7 +458,7 @@ class StateSerializer:
460
458
  @staticmethod
461
459
  def loads(s: str):
462
460
  try:
463
- return dict({k: StateSerializer.load_value(v) for k, v in [s.split('=') for s in s.split(',')]})
461
+ return {k: StateSerializer.load_value(v) for k, v in [s.split('=') for s in s.split(',')]}
464
462
  except ValueError as e:
465
463
  e.add_note("Missing '=' in state")
466
464
  raise
@@ -470,7 +468,7 @@ class PhoneState(ABC):
470
468
  PFTPD = 'pftpd'
471
469
 
472
470
  @abstractmethod
473
- async def get(self, repeat: float, timeout: float) -> dict:
471
+ async def get(self, repeat: float, timeout: float) -> dict: # NOSONAR(S7483)
474
472
  pass
475
473
 
476
474
  ########
@@ -532,7 +530,7 @@ class ServiceResolver:
532
530
  self.zeroconf = zeroconf
533
531
  self.service_type = service_type
534
532
 
535
- async def get(self, service_name: str, timeout: float = 3):
533
+ async def get(self, service_name: str, timeout: float = 3): # NOSONAR(S7483)
536
534
  service_info = await self.zeroconf.async_get_service_info(self.service_type, f"{service_name}.{self.service_type}", timeout=int(timeout*1000))
537
535
  if not service_info or not service_info.port:
538
536
  raise TimeoutError("Unable to resolve zeroconf (DNS-SD) service information")
@@ -649,6 +647,7 @@ class PftpdServiceListener(ServiceListener):
649
647
  logger.debug(" (ServiceListener) Resolved %s to %s:%d", service_name, host, port)
650
648
 
651
649
  def del_service(self, service_name: str):
650
+ # we intentionally do not remove anything from the cache
652
651
  pass
653
652
 
654
653
  class RemotePftpd(SshService):
@@ -704,14 +703,14 @@ class Tailscale():
704
703
  class Funnel(Pingable):
705
704
  LOCAL_HOST = '127.0.0.1'
706
705
 
707
- def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int, dns_resolver: DnsResolver, local_tailscale: LocalTailscale):
706
+ def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int, local_tailscale: LocalTailscale):
708
707
  self.machine_name = machine_name
709
708
  self.local_port = local_port
710
709
  self.external_name = f'{machine_name}.{tailscale.tailnet}'
711
710
  self.external_port = external_port
712
711
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
713
- self.external_public_dns_resolver = dns_resolver
714
712
  self.local_tailscale = local_tailscale
713
+ self.external_public_dns_resolver = None
715
714
  self.external_tailscale_dns_resolver = None
716
715
 
717
716
  async def wait_for(self, available: bool, timeout: float):
@@ -720,15 +719,17 @@ class Funnel(Pingable):
720
719
 
721
720
  async def ping(self, availability_hint: bool | None = None):
722
721
  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
722
  # 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
723
+ if self.external_tailscale_dns_resolver is None:
724
+ self.external_tailscale_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_tailscale_dns_resolver())
726
725
  try:
727
726
  _answer = await self.external_tailscale_dns_resolver.resolve(self.external_name, self.external_port)
728
727
  except Exception as e:
729
728
  logger.debug("Resolving at Tailscale's external DNS has failed: %s", LazyStr(repr, e))
730
729
  return False
731
730
  # then try at a public DNS
731
+ if self.external_public_dns_resolver is None:
732
+ self.external_public_dns_resolver = ExternalDnsResolver(await self.local_tailscale.external_public_dns_resolver())
732
733
  try:
733
734
  _answer = await self.external_public_dns_resolver.resolve(self.external_name, self.external_port)
734
735
  except Exception as e:
@@ -791,7 +792,7 @@ class LocalTailscale(Manageable):
791
792
  self._is_started_now = start_result
792
793
  if start_result and not self._checked_fresh_start and self.machine_name:
793
794
  self._checked_fresh_start = True
794
- max_last_seen_age = 3600
795
+ max_last_seen_age = 7200
795
796
  wait_on_fresh_start = 5
796
797
  difference = datetime.now(timezone.utc).replace(microsecond=0) - device_info.last_seen if device_info.last_seen else None
797
798
  difference_sec = difference.total_seconds() if difference else None
@@ -803,16 +804,23 @@ class LocalTailscale(Manageable):
803
804
  await asyncio.sleep(wait_on_fresh_start)
804
805
  return start_result
805
806
 
806
- async def external_dns_resolver(self):
807
- logger.debug("Getting external DNS resolver of %s for ts.net", LazyStr(self.get_class_name))
807
+ async def _tailscale_dns_status(self):
808
808
  success, stdout, stderr = await Subprocess.tailscale(['dns', 'status', '--json'])
809
809
  if not success:
810
810
  exc = RuntimeError("Failed to get local DNS status from local Tailscale")
811
811
  exc.add_note(stderr.rstrip().replace("\n", "; "))
812
812
  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)
813
+ return json.loads(stdout)
814
+
815
+ async def external_public_dns_resolver(self):
816
+ logger.debug("Getting external public DNS resolver of %s", LazyStr(self.get_class_name))
817
+ status = await self._tailscale_dns_status()
818
+ return str(status['SystemDNS']['Nameservers'][0])
819
+
820
+ async def external_tailscale_dns_resolver(self):
821
+ logger.debug("Getting external Tailscale DNS resolver of %s for ts.net", LazyStr(self.get_class_name))
822
+ status = await self._tailscale_dns_status()
823
+ return str(next(addr['Addr'] for addr in status['SplitDNSRoutes']['ts.net.'] if ':' not in addr['Addr']))
816
824
 
817
825
  class StatSeen(ABC):
818
826
  @abstractmethod
@@ -854,7 +862,7 @@ class Webhooks:
854
862
  def __init__(self, host: str, port: int):
855
863
  self.host = host
856
864
  self.port = port
857
- self.variables = dict[str, asyncio.Queue[str]]()
865
+ self.variables: dict[str, asyncio.Queue[str]] = {}
858
866
 
859
867
  @staticmethod
860
868
  def get_ping_path():
@@ -865,7 +873,7 @@ class Webhooks:
865
873
  return f'/{Webhooks.VARIABLE_PATH}/{variable}'
866
874
 
867
875
  async def _start(self):
868
- async def _ping(request: web.Request):
876
+ async def _ping(request: web.Request): # NOSONAR(S7503)
869
877
  return web.Response(text='pong')
870
878
  async def _receive_variable(request: web.Request):
871
879
  queue = self.variables.get(request.match_info['name'])
@@ -892,7 +900,7 @@ class Webhooks:
892
900
  def unsubscribe_variable(self, variable: str):
893
901
  self.variables.pop(variable)
894
902
 
895
- async def get_variable(self, variable: str, timeout: float):
903
+ async def get_variable(self, variable: str, timeout: float): # NOSONAR(S7483)
896
904
  queue = self.variables.get(variable)
897
905
  if not queue:
898
906
  raise ValueError(f"The {variable} is unknown")
@@ -906,6 +914,7 @@ class Webhooks:
906
914
  def __enter__(self):
907
915
  raise TypeError("Use async with instead")
908
916
  def __exit__(self, exc_type, exc_value, exc_tb):
917
+ # can't be called
909
918
  pass
910
919
  async def __aenter__(self):
911
920
  await self._start()
@@ -929,7 +938,7 @@ class Automate:
929
938
  "payload": f"prim-ctrl;{time.time()};" + message
930
939
  }
931
940
  logger.debug("Messaging Automate with: %s", message)
932
- async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
941
+ async with self.session.post('https://llamalab.com/automate/cloud/message', json=data) as response:
933
942
  await response.text()
934
943
 
935
944
  class AutomatePftpdManager(Manager):
@@ -991,12 +1000,12 @@ class ExternalWebhookPing(WebhookPing):
991
1000
  class AutomatePhoneState(PhoneState):
992
1001
  VARIABLE_STATE = 'state'
993
1002
 
994
- def __init__(self, general_session: aiohttp.ClientSession, external_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel, local_tailscale: LocalTailscale):
1003
+ def __init__(self, general_session: aiohttp.ClientSession, external_public_dns_session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel, local_tailscale: LocalTailscale):
995
1004
  self.webhooks = webhooks
996
1005
  self.automate = automate
997
1006
  self.funnel = funnel
998
1007
  self.local_webhook_ping = WebhookPing(general_session, funnel)
999
- self.external_webhook_ping = ExternalWebhookPing(external_dns_session, funnel, local_tailscale)
1008
+ self.external_webhook_ping = ExternalWebhookPing(external_public_dns_session, funnel, local_tailscale)
1000
1009
 
1001
1010
  async def get(self, repeat: float, timeout: float):
1002
1011
  logger.info("Getting Phone state...")
@@ -1065,9 +1074,9 @@ class WideHelpFormatter(argparse.RawTextHelpFormatter):
1065
1074
 
1066
1075
  async def gather_with_taskgroup(*coros):
1067
1076
  try:
1068
- async with asyncio.TaskGroup() as tg:
1077
+ async with asyncio.TaskGroup() as tg: # NOSONAR(S7513)
1069
1078
  tasks = [tg.create_task(coro) for coro in coros]
1070
- return tuple([task.result() for task in tasks])
1079
+ return tuple(task.result() for task in tasks)
1071
1080
  except ExceptionGroup as eg:
1072
1081
  exc = eg.exceptions[0]
1073
1082
  # this can be captured in another TaskGroup that drops traceback information from "from e"
@@ -1104,6 +1113,7 @@ class Control:
1104
1113
 
1105
1114
  @staticmethod
1106
1115
  def setup_parser_options(parser):
1116
+ # no options
1107
1117
  pass
1108
1118
 
1109
1119
  @staticmethod
@@ -1138,12 +1148,12 @@ class Control:
1138
1148
  self.phone = phone
1139
1149
  self.keyboard_interrupt = keyboard_interrupt
1140
1150
 
1141
- async def _stop(self, restore_state: dict | None, stop_only_started: bool = False):
1151
+ async def _stop(self, restore_state: dict | None, stop_only_started: bool = False): # NOSONAR(S3776)
1142
1152
  with self.keyboard_interrupt.protect():
1143
1153
  async def _suppress(coro, default: bool):
1144
1154
  try:
1145
1155
  return await coro
1146
- except:
1156
+ except: # NOSONAR(S5754)
1147
1157
  return default
1148
1158
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp and await _suppress(self.local.vpn.test(), True) and await _suppress(self.phone.vpn.test(), True):
1149
1159
  if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await _suppress(self.phone.remote_sftp.test(), True):
@@ -1169,7 +1179,7 @@ class Control:
1169
1179
  except Exception as e:
1170
1180
  logger.exception_or_error(e)
1171
1181
 
1172
- async def run(self):
1182
+ async def run(self): # NOSONAR(S3776)
1173
1183
  match self.args.intent:
1174
1184
  case 'test':
1175
1185
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp and self.phone.state:
@@ -1181,7 +1191,7 @@ class Control:
1181
1191
  await self.phone.zeroconf_sftp.test()
1182
1192
  case 'start':
1183
1193
  if self.local.vpn and self.phone.vpn and self.phone.remote_sftp:
1184
- state = dict()
1194
+ state = {}
1185
1195
  try:
1186
1196
  # gather local state info
1187
1197
  local_vpn_state = await self.local.vpn.test()
@@ -1201,7 +1211,7 @@ class Control:
1201
1211
  state[Control.PHONE_VPN] = phone_vpn_state
1202
1212
  state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
1203
1213
  if not state[Control.PHONE_WIFI] and not self.args.accept_cellular:
1204
- raise RuntimeError(f"Phone is not on Wi-Fi network")
1214
+ raise RuntimeError("Phone is not on Wi-Fi network")
1205
1215
  else:
1206
1216
  state[Control.PHONE_VPN] = phone_vpn_state = await self.phone.vpn.test()
1207
1217
  if phone_vpn_state:
@@ -1252,7 +1262,7 @@ class Control:
1252
1262
  raise RuntimeError(f"Even when {self.phone.vpn.get_class_name()} and {self.phone.remote_sftp.get_class_name()} is started, {self.phone.remote_sftp.get_class_name()} is still not accessible")
1253
1263
  # print out result on stdout
1254
1264
  if not self.args.backup_state:
1255
- state = dict()
1265
+ state = {}
1256
1266
  state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
1257
1267
  print(StateSerializer.dumps(state))
1258
1268
  except:
@@ -1342,15 +1352,11 @@ class AutomateControl(Control):
1342
1352
  async def runner(args: argparse.Namespace) -> None:
1343
1353
  AutomateControl.prepare(args)
1344
1354
 
1345
- external_dns_resolver = ExternalDnsResolver()
1346
1355
  async with (
1347
1356
  aiohttp.ClientSession() as general_session,
1348
1357
  aiohttp.ClientSession(
1349
1358
  # Automate messaging server prefers closing connections
1350
1359
  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
1360
  AsyncZeroconf() as zeroconf
1355
1361
  ):
1356
1362
  service_cache = ServiceCache(Cache(Cache.PRIM_SYNC_APP_NAME))
@@ -1385,14 +1391,20 @@ class AutomateControl(Control):
1385
1391
  tailscale = Tailscale(secrets, general_session, _tailscale_tailnet(), _tailscale_secretfile()) if args.tailscale else None
1386
1392
  local_tailscale = LocalTailscale(tailscale, _funnel_local_machine_name_or_none(), LocalTailscaleManager()) if tailscale else None
1387
1393
  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
1394
+ 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
1395
  pftpd_manager = AutomatePftpdManager(automate)
1390
1396
  zeroconf_pftpd = ZeroconfPftpd(args.server_name, service_cache, service_resolver, args.keyfile, pftpd_manager)
1391
1397
  remote_pftpd = RemotePftpd(remote_tailscale.host, _tailscale_sftp_port(), args.server_name, args.keyfile, pftpd_manager) if remote_tailscale else None
1392
1398
 
1393
- async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
1399
+ async with (
1400
+ aiohttp.ClientSession(
1401
+ # Uses external DNS to access Funnel TCP forwarder servers instead of local MagicDNS route
1402
+ connector=aiohttp.TCPConnector(resolver=funnel.external_public_dns_resolver)
1403
+ ) if funnel else nullcontext() as external_public_dns_session,
1404
+ Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks
1405
+ ):
1394
1406
  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
1407
+ 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
1408
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
1397
1409
  keyboard_interrupt = SignalFence(signal.SIGINT, lambda signum, frame: logger.debug("Keyboard interrupt received, finishing ongoing operations before exit"))
1398
1410
  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.2"
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