localghost 4.2.0__tar.gz → 4.2.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.
Files changed (77) hide show
  1. {localghost-4.2.0 → localghost-4.2.1}/PKG-INFO +1 -1
  2. {localghost-4.2.0 → localghost-4.2.1}/pyproject.toml +1 -1
  3. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/cli.py +43 -20
  4. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/feedback.py +21 -6
  5. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale.py +34 -13
  6. {localghost-4.2.0 → localghost-4.2.1}/LICENSE +0 -0
  7. {localghost-4.2.0 → localghost-4.2.1}/README.md +0 -0
  8. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/.dockerignore +0 -0
  9. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/Dockerfile +0 -0
  10. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/__init__.py +0 -0
  11. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/compose.py +0 -0
  12. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/config.py +0 -0
  13. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/detached.py +0 -0
  14. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/forwarder.py +0 -0
  15. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/generator.py +0 -0
  16. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/init.py +0 -0
  17. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/paths.py +0 -0
  18. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/picker.py +0 -0
  19. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/ports.py +0 -0
  20. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/proxy_compose.yaml +0 -0
  21. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/proxy_compose_https.yaml +0 -0
  22. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/proxy_compose_tailscale.yaml +0 -0
  23. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/proxy_compose_tailscale_https.yaml +0 -0
  24. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/registry.py +0 -0
  25. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/roots.py +0 -0
  26. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/routes.py +0 -0
  27. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/runner.py +0 -0
  28. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/sessions.py +0 -0
  29. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/statusbar.py +0 -0
  30. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/Dockerfile +0 -0
  31. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/go.mod +0 -0
  32. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/go.sum +0 -0
  33. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/identity.go +0 -0
  34. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/identity_test.go +0 -0
  35. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/main.go +0 -0
  36. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/main_test.go +0 -0
  37. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/tailscale_gateway/proxy_test.go +0 -0
  38. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/.traefik.yml +0 -0
  39. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca.go +0 -0
  40. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca_test.go +0 -0
  41. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/cmd/bootstrap/main.go +0 -0
  42. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker.go +0 -0
  43. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker_test.go +0 -0
  44. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.mod +0 -0
  45. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.sum +0 -0
  46. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider.go +0 -0
  47. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider_test.go +0 -0
  48. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/storage.go +0 -0
  49. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/LICENSE +0 -0
  50. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/config.go +0 -0
  51. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/http_config.go +0 -0
  52. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/marshaler.go +0 -0
  53. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/middlewares.go +0 -0
  54. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/plugins.go +0 -0
  55. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_config.go +0 -0
  56. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_middlewares.go +0 -0
  57. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/certificate.go +0 -0
  58. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/tls.go +0 -0
  59. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/domains.go +0 -0
  60. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/tls.go +0 -0
  61. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/udp_config.go +0 -0
  62. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/modules.txt +0 -0
  63. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/.traefik.yml +0 -0
  64. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client.go +0 -0
  65. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client_test.go +0 -0
  66. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/go.mod +0 -0
  67. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity.go +0 -0
  68. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity_test.go +0 -0
  69. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/.traefik.yml +0 -0
  70. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback.go +0 -0
  71. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback_test.go +0 -0
  72. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/go.mod +0 -0
  73. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/logo.go +0 -0
  74. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/.traefik.yml +0 -0
  75. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/go.mod +0 -0
  76. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/provider.go +0 -0
  77. {localghost-4.2.0 → localghost-4.2.1}/src/localghost/trust.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localghost
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Localghost serves local applications at friendly .localhost URLs
5
5
  Keywords: localghost,localhost,docker,compose,traefik,development,proxy
6
6
  Author: SmileyChris
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "localghost"
3
- version = "4.2.0"
3
+ version = "4.2.1"
4
4
  description = "Localghost serves local applications at friendly .localhost URLs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -31,6 +31,8 @@ from .config import (
31
31
  write_run_config,
32
32
  )
33
33
  from .feedback import (
34
+ LIME,
35
+ MINT,
34
36
  action,
35
37
  choices,
36
38
  compose_dry_run,
@@ -41,6 +43,7 @@ from .feedback import (
41
43
  routes,
42
44
  run_plan,
43
45
  sign_off,
46
+ styled,
44
47
  success,
45
48
  title,
46
49
  warning,
@@ -90,11 +93,14 @@ from .tailscale import (
90
93
  API as TailscaleAPI,
91
94
  )
92
95
  from .tailscale import (
96
+ DNS_CONSOLE_URL,
97
+ MACHINES_CONSOLE_URL,
93
98
  OAUTH_CONSOLE_URL,
94
99
  POLICY_CONSOLE_URL,
95
100
  TailnetHub,
96
101
  TailscaleError,
97
102
  TailscaleState,
103
+ tag_in_use,
98
104
  tailnet_hubs,
99
105
  )
100
106
  from .tailscale import (
@@ -1252,7 +1258,7 @@ def _refuse_active_mapping(
1252
1258
  f"split DNS for .{suffix} already points at "
1253
1259
  f"{', '.join(active)} — another machine may be hosting this "
1254
1260
  "suffix. Choose a different --suffix, or pass --takeover to "
1255
- "replace the mapping."
1261
+ f"replace the mapping. Split DNS is listed at {DNS_CONSOLE_URL}."
1256
1262
  )
1257
1263
 
1258
1264
 
@@ -1310,31 +1316,41 @@ def _resolve_tailscale_credential(
1310
1316
  info("Using the OAuth credential stored in the system keyring.")
1311
1317
  return stored.client_id, stored.client_secret
1312
1318
  if tag is not None:
1313
- details(
1314
- [
1319
+ client = "Add OAuth credential"
1320
+ if tag_in_use(tag):
1321
+ # A device already carries the tag, so only the client is left.
1322
+ success(f"The {tag} device tag is already allowed on the tailnet.")
1323
+ steps = [(client, OAUTH_CONSOLE_URL)]
1324
+ indent = ""
1325
+ else:
1326
+ steps = [
1315
1327
  ("1. Allow the device tag", POLICY_CONSOLE_URL),
1316
1328
  (
1317
1329
  " Add to the policy file",
1318
1330
  f'"tagOwners": {{"{tag}": ["autogroup:admin"]}}',
1319
1331
  ),
1320
- ("2. Create an OAuth client", OAUTH_CONSOLE_URL),
1321
- (
1322
- " Scopes",
1323
- f"auth_keys (with {tag} allowed) and dns:write",
1324
- ),
1325
- ],
1326
- title=(
1327
- "Tailscale admin-console setup (already done? enter that "
1328
- "client's id and secret)"
1329
- ),
1332
+ (f"2. {client}", OAUTH_CONSOLE_URL),
1333
+ ]
1334
+ indent = " "
1335
+ details(steps)
1336
+ styled(
1337
+ [
1338
+ (f"{indent}with scopes ", "default"),
1339
+ ("DNS: Write", MINT),
1340
+ (", ", "default"),
1341
+ ("Auth Keys: Write", MINT),
1342
+ (" (", "default"),
1343
+ (tag, MINT),
1344
+ (")", "default"),
1345
+ ]
1330
1346
  )
1331
1347
  else:
1332
1348
  details(
1333
- [("OAuth client with dns:write", OAUTH_CONSOLE_URL)],
1349
+ [("OAuth credential with DNS: Write", OAUTH_CONSOLE_URL)],
1334
1350
  title="No OAuth credential is stored in the system keyring",
1335
1351
  )
1336
1352
  if not client_id:
1337
- client_id = click.prompt("Client id")
1353
+ client_id = click.prompt("Client ID")
1338
1354
  if not client_secret:
1339
1355
  client_secret = click.prompt("Client secret", hide_input=True)
1340
1356
  return client_id, client_secret
@@ -1399,14 +1415,18 @@ def tailscale_unshare(
1399
1415
  "`localghost trust remove` on each to revoke."
1400
1416
  )
1401
1417
  info(
1402
- f"Remove the offline tagged localghost-{state.node_label} device in the "
1403
- "Tailscale admin console."
1418
+ f"Remove the offline tagged localghost-{state.node_label} device at "
1419
+ f"{MACHINES_CONSOLE_URL}."
1404
1420
  )
1405
1421
 
1406
1422
 
1407
1423
  def _forget_tailscale_credential() -> None:
1408
1424
  if delete_tailscale_credential():
1409
1425
  info("The OAuth credential was removed from the system keyring.")
1426
+ info(
1427
+ "The OAuth client itself still exists; revoke it on the Trust "
1428
+ f"credentials page ({OAUTH_CONSOLE_URL}) if no other machine uses it."
1429
+ )
1410
1430
  else:
1411
1431
  info("No OAuth credential was stored in the system keyring.")
1412
1432
 
@@ -1515,14 +1535,17 @@ def _trust_tailnet_hub(target: str | None) -> None:
1515
1535
  def _share_trust(suffix: str, certificate: PublicCertificate) -> None:
1516
1536
  """Show another tailnet machine how to trust this hub's root."""
1517
1537
  action(
1518
- f"Trust https://*.{suffix} on other machines",
1538
+ f"To trust https://*.{suffix} on other machines",
1519
1539
  f"uvx localghost tailscale trust {suffix}",
1520
1540
  )
1521
1541
  # The fingerprint is for clients that cannot ask Tailscale for the hub;
1522
1542
  # the plain command above is the way forward for everyone else.
1523
1543
  info(
1524
- "Without the tailscale CLI, add --fingerprint "
1525
- f"{certificate.fingerprint} to pin the root."
1544
+ [
1545
+ ("Without the tailscale CLI, add ", "default"),
1546
+ (f"--fingerprint {certificate.fingerprint}", LIME),
1547
+ (" to pin the root.", "default"),
1548
+ ]
1526
1549
  )
1527
1550
 
1528
1551
 
@@ -106,7 +106,7 @@ def details(
106
106
  console.print(Text(title, style="bold"))
107
107
  table = Table.grid(padding=(0, 1))
108
108
  table.add_column(style="bold")
109
- table.add_column(style=MINT)
109
+ table.add_column(style=MINT, overflow="fold")
110
110
  for label, value in entries:
111
111
  table.add_row(label, value)
112
112
  console.print(table)
@@ -117,6 +117,16 @@ def details(
117
117
  console.print("\n".join(lines), soft_wrap=True)
118
118
 
119
119
 
120
+ def styled(parts: Iterable[tuple[str, str]], *, err: bool = False) -> None:
121
+ """Show one line of (text, style) parts; plain text off a terminal."""
122
+ entries = list(parts)
123
+ console = _console(err)
124
+ if _rich_terminal(err):
125
+ console.print(Text.assemble(*entries))
126
+ return
127
+ console.print("".join(text for text, _ in entries), soft_wrap=True)
128
+
129
+
120
130
  def choices(title: str, items: Iterable[tuple[str, str, bool]]) -> None:
121
131
  """Show interactive choices with the same terminal hierarchy as other output."""
122
132
  entries = list(items)
@@ -141,7 +151,7 @@ def choices(title: str, items: Iterable[tuple[str, str, bool]]) -> None:
141
151
  console.print("\n".join(lines), soft_wrap=True)
142
152
 
143
153
 
144
- def info(message: str, *, err: bool = False) -> None:
154
+ def info(message: str | list[tuple[str, str]], *, err: bool = False) -> None:
145
155
  _message(message, MINT, err=err)
146
156
 
147
157
 
@@ -171,7 +181,7 @@ def _labeled_block(rows: list[tuple[str, str]], *, heading: str, err: bool) -> N
171
181
  console = _console(err)
172
182
  console.print(Text.assemble(("▌ ", MINT), (heading, "bold")))
173
183
  table = Table.grid(padding=(0, 1))
174
- table.add_column(style=MINT)
184
+ table.add_column(style=MINT, overflow="fold")
175
185
  table.add_column(style="bold")
176
186
  table.add_column()
177
187
  for label, value in rows:
@@ -269,12 +279,17 @@ def interrupt_break() -> None:
269
279
  _console(False).print()
270
280
 
271
281
 
272
- def _message(message: str, color: str, *, err: bool, symbol: str = "•") -> None:
282
+ def _message(
283
+ message: str | list[tuple[str, str]], color: str, *, err: bool, symbol: str = "•"
284
+ ) -> None:
285
+ """Show a message; one given as (text, style) parts can highlight a command."""
286
+ parts = [message] if isinstance(message, str) else message
273
287
  console = _console(err)
274
288
  if _rich_terminal(err):
275
- console.print(Text.assemble((f"{symbol} ", color), message))
289
+ console.print(Text.assemble((f"{symbol} ", color), *parts))
276
290
  else:
277
- console.print(message, soft_wrap=True)
291
+ plain = "".join(part if isinstance(part, str) else part[0] for part in parts)
292
+ console.print(plain, soft_wrap=True)
278
293
 
279
294
 
280
295
  def _console(err: bool) -> Console:
@@ -25,8 +25,10 @@ from .paths import state_directory
25
25
 
26
26
  API_ROOT = "https://api.tailscale.com/api/v2"
27
27
  TOKEN_URL = f"{API_ROOT}/oauth/token"
28
- OAUTH_CONSOLE_URL = "https://login.tailscale.com/admin/settings/oauth"
29
- POLICY_CONSOLE_URL = "https://login.tailscale.com/admin/acls/file"
28
+ OAUTH_CONSOLE_URL = "https://console.tailscale.com/admin/settings/trust-credentials"
29
+ POLICY_CONSOLE_URL = "https://console.tailscale.com/admin/acls/file"
30
+ MACHINES_CONSOLE_URL = "https://console.tailscale.com/admin/machines"
31
+ DNS_CONSOLE_URL = "https://console.tailscale.com/admin/dns"
30
32
  KEYRING_SERVICE = "localghost-tailscale"
31
33
  _SUFFIX = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
32
34
 
@@ -329,7 +331,8 @@ class API:
329
331
  if exc.code == 403:
330
332
  raise TailscaleError(
331
333
  f"the OAuth client may not create auth keys: it needs the "
332
- f"auth_keys scope with {tag} allowed ({OAUTH_CONSOLE_URL})"
334
+ f"Auth Keys: Write scope with {tag} selected (Trust "
335
+ f"credentials page, {OAUTH_CONSOLE_URL})"
333
336
  ) from exc
334
337
  raise
335
338
  try:
@@ -341,8 +344,8 @@ class API:
341
344
  def _dns_scope_guidance(exc: APIError) -> TailscaleError:
342
345
  if exc.code == 403:
343
346
  return TailscaleError(
344
- "the OAuth client lacks the dns:write scope; recreate it at "
345
- f"{OAUTH_CONSOLE_URL}"
347
+ "the OAuth client lacks the DNS: Write scope; recreate it on the "
348
+ f"Trust credentials page ({OAUTH_CONSOLE_URL})"
346
349
  )
347
350
  return exc
348
351
 
@@ -435,13 +438,8 @@ class TailnetHub:
435
438
  ) # fmt: skip
436
439
 
437
440
 
438
- def tailnet_hubs(tag: str = "tag:localghost") -> list[TailnetHub] | None:
439
- """The online localghost gateways in this client's network map.
440
-
441
- The names and addresses come from the coordination server through the
442
- local Tailscale client, not from DNS, so split DNS cannot redirect them.
443
- None means there is no tailscale CLI to ask.
444
- """
441
+ def _network_map_peers() -> dict | None:
442
+ """The peers in this client's network map; None without a tailscale CLI."""
445
443
  try:
446
444
  result = subprocess.run(
447
445
  ["tailscale", "status", "--json"],
@@ -454,9 +452,32 @@ def tailnet_hubs(tag: str = "tag:localghost") -> list[TailnetHub] | None:
454
452
  if result.returncode != 0:
455
453
  return None
456
454
  try:
457
- peers = json.loads(result.stdout).get("Peer") or {}
455
+ return json.loads(result.stdout).get("Peer") or {}
458
456
  except (json.JSONDecodeError, AttributeError):
459
457
  return None
458
+
459
+
460
+ def tag_in_use(tag: str) -> bool:
461
+ """Whether a device in this client's network map, online or not, has tag.
462
+
463
+ A device can only carry a tag its owner allowed, so this proves the
464
+ policy file once allowed it. False proves nothing: the policy may hide
465
+ tagged devices from this client.
466
+ """
467
+ peers = _network_map_peers() or {}
468
+ return any(tag in (peer.get("Tags") or ()) for peer in peers.values())
469
+
470
+
471
+ def tailnet_hubs(tag: str = "tag:localghost") -> list[TailnetHub] | None:
472
+ """The online localghost gateways in this client's network map.
473
+
474
+ The names and addresses come from the coordination server through the
475
+ local Tailscale client, not from DNS, so split DNS cannot redirect them.
476
+ None means there is no tailscale CLI to ask.
477
+ """
478
+ peers = _network_map_peers()
479
+ if peers is None:
480
+ return None
460
481
  hubs = [
461
482
  TailnetHub(
462
483
  str(peer["DNSName"]).lower().removesuffix("."),
File without changes
File without changes