localghost 3.2.1__tar.gz → 3.3.0__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 (75) hide show
  1. {localghost-3.2.1 → localghost-3.3.0}/PKG-INFO +1 -1
  2. {localghost-3.2.1 → localghost-3.3.0}/pyproject.toml +1 -1
  3. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/cli.py +242 -62
  4. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/feedback.py +41 -15
  5. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_tailscale.yaml +10 -2
  6. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_tailscale_https.yaml +1 -1
  7. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale.py +44 -7
  8. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/Dockerfile +1 -1
  9. localghost-3.3.0/src/localghost/tailscale_gateway/identity.go +98 -0
  10. localghost-3.3.0/src/localghost/tailscale_gateway/identity_test.go +94 -0
  11. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/main.go +2 -1
  12. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/.traefik.yml +1 -1
  13. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client.go +62 -14
  14. localghost-3.3.0/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity.go +154 -0
  15. localghost-3.3.0/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity_test.go +220 -0
  16. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/trust.py +11 -6
  17. {localghost-3.2.1 → localghost-3.3.0}/LICENSE +0 -0
  18. {localghost-3.2.1 → localghost-3.3.0}/README.md +0 -0
  19. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/.dockerignore +0 -0
  20. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/Dockerfile +0 -0
  21. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/__init__.py +0 -0
  22. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/compose.py +0 -0
  23. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/config.py +0 -0
  24. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/forwarder.py +0 -0
  25. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/generator.py +0 -0
  26. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/paths.py +0 -0
  27. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/picker.py +0 -0
  28. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/ports.py +0 -0
  29. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose.yaml +0 -0
  30. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_https.yaml +0 -0
  31. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/registry.py +0 -0
  32. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/roots.py +0 -0
  33. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/routes.py +0 -0
  34. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/runner.py +0 -0
  35. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/sessions.py +0 -0
  36. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/statusbar.py +0 -0
  37. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/go.mod +0 -0
  38. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/go.sum +0 -0
  39. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/main_test.go +0 -0
  40. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/proxy_test.go +0 -0
  41. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/.traefik.yml +0 -0
  42. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca.go +0 -0
  43. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca_test.go +0 -0
  44. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/cmd/bootstrap/main.go +0 -0
  45. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker.go +0 -0
  46. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker_test.go +0 -0
  47. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.mod +0 -0
  48. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.sum +0 -0
  49. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider.go +0 -0
  50. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider_test.go +0 -0
  51. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/storage.go +0 -0
  52. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/LICENSE +0 -0
  53. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/config.go +0 -0
  54. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/http_config.go +0 -0
  55. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/marshaler.go +0 -0
  56. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/middlewares.go +0 -0
  57. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/plugins.go +0 -0
  58. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_config.go +0 -0
  59. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tcp_middlewares.go +0 -0
  60. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/certificate.go +0 -0
  61. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/tls/tls.go +0 -0
  62. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/domains.go +0 -0
  63. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/types/tls.go +0 -0
  64. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/github.com/traefik/genconf/dynamic/udp_config.go +0 -0
  65. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/modules.txt +0 -0
  66. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client_test.go +0 -0
  67. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/go.mod +0 -0
  68. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/.traefik.yml +0 -0
  69. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback.go +0 -0
  70. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback_test.go +0 -0
  71. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/go.mod +0 -0
  72. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/logo.go +0 -0
  73. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/.traefik.yml +0 -0
  74. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/go.mod +0 -0
  75. {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/provider.go +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localghost
3
- Version: 3.2.1
3
+ Version: 3.3.0
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 = "3.2.1"
3
+ version = "3.3.0"
4
4
  description = "Localghost serves local applications at friendly .localhost URLs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -16,7 +16,7 @@ import tempfile
16
16
  import time
17
17
  from collections.abc import Iterator
18
18
  from contextlib import contextmanager, suppress
19
- from dataclasses import dataclass
19
+ from dataclasses import dataclass, replace
20
20
  from pathlib import Path
21
21
 
22
22
  import click
@@ -35,10 +35,12 @@ from .feedback import (
35
35
  choices,
36
36
  compose_dry_run,
37
37
  details,
38
+ failure,
38
39
  info,
39
40
  next_actions,
40
41
  routes,
41
42
  run_plan,
43
+ sign_off,
42
44
  success,
43
45
  title,
44
46
  warning,
@@ -88,6 +90,9 @@ from .tailscale import (
88
90
  TailscaleError,
89
91
  TailscaleState,
90
92
  )
93
+ from .tailscale import (
94
+ default_node as default_tailscale_node,
95
+ )
91
96
  from .tailscale import (
92
97
  delete_credential as delete_tailscale_credential,
93
98
  )
@@ -118,7 +123,13 @@ from .tailscale import (
118
123
  from .tailscale import (
119
124
  validate_suffix as validate_tailscale_suffix,
120
125
  )
121
- from .trust import MkcertInstaller, PublicCertificate, TrustError, ZenNssInstaller
126
+ from .trust import (
127
+ MkcertInstaller,
128
+ PublicCertificate,
129
+ TrustError,
130
+ ZenNssInstaller,
131
+ require_mkcert,
132
+ )
122
133
 
123
134
  LOCALGHOST_VERSION = importlib.metadata.version("localghost")
124
135
  TRAEFIK_IMAGE = f"localghost-traefik:v{LOCALGHOST_VERSION}"
@@ -716,6 +727,11 @@ def tailscale_status() -> None:
716
727
  _tailscale_status()
717
728
 
718
729
 
730
+ def _explicit_tailnet_row(tailnet: str) -> list[tuple[str, str]]:
731
+ """Name the tailnet only when one was chosen; '-' is the credential's own."""
732
+ return [] if tailnet == "-" else [("Tailnet", tailnet)]
733
+
734
+
719
735
  def _tailscale_status() -> None:
720
736
  try:
721
737
  state = load_tailscale_state()
@@ -725,14 +741,10 @@ def _tailscale_status() -> None:
725
741
  details([("Tailnet hosting", "disabled")], title="Tailscale status")
726
742
  return
727
743
  details(
728
- [
729
- ("Tailnet hosting", "enabled"),
730
- ("Tailnet", state.tailnet),
744
+ [("Tailnet hosting", "enabled")]
745
+ + _explicit_tailnet_row(state.tailnet)
746
+ + [
731
747
  ("Route suffix", f".{state.suffix}"),
732
- (
733
- "Tailnet HTTPS",
734
- "enabled" if state.https else "HTTP only (public DNS name)",
735
- ),
736
748
  ("Gateway", ", ".join(state.gateway_ips)),
737
749
  ("Gateway health", _tailscale_gateway_health()),
738
750
  ("Device tag", state.tag),
@@ -744,12 +756,15 @@ def _tailscale_status() -> None:
744
756
  ),
745
757
  title="Tailscale status",
746
758
  )
759
+ if not state.https:
760
+ _public_suffix_notice(state.suffix)
761
+ return
747
762
  root_path = _tailnet_root_path(state.suffix)
748
- if state.https and root_path.is_file():
763
+ if root_path.is_file():
749
764
  with suppress(TrustError):
750
765
  certificate = PublicCertificate.parse(root_path.read_bytes())
751
766
  action(
752
- "Trust on other tailnet machines",
767
+ f"Trust https://*.{state.suffix} on other machines",
753
768
  _share_command(state.suffix, certificate),
754
769
  )
755
770
 
@@ -869,10 +884,16 @@ def tailscale_enable(
869
884
  client_id: str | None,
870
885
  client_secret: str | None,
871
886
  ) -> None:
872
- """Enroll a tagged gateway and add split DNS for the chosen suffix."""
887
+ """Enroll a tagged gateway and add split DNS for the chosen suffix.
888
+
889
+ When tailnet hosting is already enabled, a different suffix, given or
890
+ detected, moves the routes to it, keeping the same gateway device.
891
+ """
873
892
  title()
874
- if load_tailscale_state() is not None:
875
- raise click.ClickException("tailnet hosting is already enabled")
893
+ current = load_tailscale_state()
894
+ if current is not None:
895
+ _switch_tailnet_suffix(current, suffix, takeover, client_id, client_secret)
896
+ return
876
897
  localhost_trusted = _https_configured()
877
898
  if not tag.startswith("tag:"):
878
899
  raise click.UsageError("--tag must start with 'tag:'")
@@ -884,12 +905,12 @@ def tailscale_enable(
884
905
  suffix or detect_tailscale_suffix()
885
906
  )
886
907
  https = not tailscale_suffix_is_public(chosen_suffix)
908
+ node = default_tailscale_node()
887
909
  details(
888
- [
889
- ("Tailnet", "credential's own tailnet" if tailnet == "-" else tailnet),
910
+ _explicit_tailnet_row(tailnet)
911
+ + [
890
912
  ("Route suffix", f".{chosen_suffix}"),
891
- ("Tailnet HTTPS", "enabled" if https else "HTTP only"),
892
- ("Gateway", f"localghost-{chosen_suffix}"),
913
+ ("Gateway", f"localghost-{node}"),
893
914
  ("Device tag", tag),
894
915
  ],
895
916
  title="Tailnet hosting",
@@ -900,40 +921,23 @@ def tailscale_enable(
900
921
  api = TailscaleAPI.authenticate(client_id, client_secret)
901
922
  info("Reading the existing split-DNS configuration…")
902
923
  previous = api.split_dns(tailnet)
903
- active = previous.get(chosen_suffix)
904
- if active and not takeover:
905
- raise click.ClickException(
906
- f"split DNS for .{chosen_suffix} already points at "
907
- f"{', '.join(active)} — another machine may be hosting this "
908
- "suffix. Choose a different --suffix, or pass --takeover to "
909
- "replace the mapping."
910
- )
911
- certificate: PublicCertificate | None = None
912
- if https:
913
- # Tailnet TLS terminates on Traefik's websecure entrypoint.
914
- # Bootstrap its localhost signer as well, but do not install
915
- # localhost trust as an implicit side effect of enabling a remote
916
- # route.
917
- info("Preparing the localhost and tailnet HTTPS authorities…")
918
- _bootstrap_public_root()
919
- certificate = _bootstrap_tailnet_root(chosen_suffix)
920
- # Keep the tailnet root beside the state so `tailscale status` can
921
- # repeat the share command without a running gateway.
922
- _write_public_root(_tailnet_root_path(chosen_suffix), certificate.pem)
924
+ _refuse_active_mapping(chosen_suffix, previous, takeover)
925
+ certificate = _prepare_tailnet_authority(chosen_suffix) if https else None
923
926
  # The auth key lives ten minutes; the first gateway image build can
924
927
  # take longer, so build before minting the key rather than after.
925
928
  info("Preparing the gateway image…")
926
929
  _ensure_gateway_image(chosen_suffix)
927
930
  info("Creating a single-use gateway auth key…")
928
931
  auth_key = api.create_auth_key(tailnet, tag)
929
- info(f"Enrolling localghost-{chosen_suffix} in the tailnet…")
930
- gateway_ips = _bootstrap_tailscale_gateway(chosen_suffix, auth_key)
932
+ info(f"Enrolling localghost-{node} in the tailnet…")
933
+ gateway_ips = _bootstrap_tailscale_gateway(chosen_suffix, node, auth_key)
931
934
  state = TailscaleState(
932
935
  tailnet=tailnet,
933
936
  suffix=chosen_suffix,
934
937
  gateway_ips=gateway_ips,
935
938
  previous_split_dns=previous,
936
939
  tag=tag,
940
+ node=node,
937
941
  )
938
942
  save_tailscale_state(state)
939
943
  try:
@@ -948,22 +952,143 @@ def tailscale_enable(
948
952
  raise
949
953
  except (TailscaleError, ValueError) as exc:
950
954
  raise click.ClickException(str(exc)) from exc
955
+ _remember_tailscale_credential(client_id, client_secret)
956
+ success(f"Tailnet routes are enabled at {_tailnet_url(state, '<project>')}.")
957
+ _offer_tailnet_trust(chosen_suffix, certificate, localhost_trusted)
958
+
959
+
960
+ def _switch_tailnet_suffix(
961
+ current: TailscaleState,
962
+ suffix: str | None,
963
+ takeover: bool,
964
+ client_id: str | None,
965
+ client_secret: str | None,
966
+ ) -> None:
967
+ """Move the tailnet routes to another suffix on the same gateway device.
968
+
969
+ The gateway keeps its enrollment, addresses, tailnet and tag: only the
970
+ split-DNS entries move, the suffix's HTTPS root is prepared, and the hub is
971
+ recreated to answer for the new names.
972
+ """
973
+ try:
974
+ # Without --suffix, detection runs again, so renaming the machine in
975
+ # the admin console carries its routes to the new name.
976
+ chosen_suffix = validate_tailscale_suffix(
977
+ suffix or detect_tailscale_suffix()
978
+ )
979
+ except (TailscaleError, ValueError) as exc:
980
+ if suffix:
981
+ raise click.ClickException(str(exc)) from exc
982
+ chosen_suffix = current.suffix
983
+ if chosen_suffix == current.suffix:
984
+ raise click.ClickException(
985
+ f"tailnet hosting is already enabled at .{current.suffix}; pass "
986
+ "--suffix to move it to another suffix"
987
+ )
988
+ old_suffix = current.suffix
989
+ localhost_trusted = _https_configured()
990
+ client_id, client_secret = _resolve_tailscale_credential(
991
+ client_id, client_secret, tag=current.tag
992
+ )
993
+ https = not tailscale_suffix_is_public(chosen_suffix)
994
+ details(
995
+ [
996
+ ("Route suffix", f".{old_suffix} → .{chosen_suffix}"),
997
+ ("Gateway", f"localghost-{current.node_label} (unchanged)"),
998
+ ],
999
+ title="Switching tailnet suffix",
1000
+ )
1001
+ if not https:
1002
+ _public_suffix_notice(chosen_suffix)
1003
+ try:
1004
+ info("Authenticating with the Tailscale API…")
1005
+ api = TailscaleAPI.authenticate(client_id, client_secret)
1006
+ info("Reading the existing split-DNS configuration…")
1007
+ previous = api.split_dns(current.tailnet)
1008
+ _refuse_active_mapping(chosen_suffix, previous, takeover)
1009
+ certificate = _prepare_tailnet_authority(chosen_suffix) if https else None
1010
+ state = replace(
1011
+ current,
1012
+ suffix=chosen_suffix,
1013
+ previous_split_dns=previous,
1014
+ node=current.node_label,
1015
+ )
1016
+ save_tailscale_state(state)
1017
+ try:
1018
+ info(f"Routing *.{chosen_suffix} DNS to the gateway…")
1019
+ api.update_split_dns(
1020
+ current.tailnet,
1021
+ {
1022
+ chosen_suffix: list(current.gateway_ips),
1023
+ old_suffix: current.previous_split_dns.get(old_suffix),
1024
+ },
1025
+ )
1026
+ info("Starting the mirrored localhost and tailnet routes…")
1027
+ _run_proxy(
1028
+ "up", https_enabled=https or localhost_trusted, force_recreate=True
1029
+ )
1030
+ except Exception:
1031
+ _rollback_switch(api, current, chosen_suffix, previous)
1032
+ raise
1033
+ except (TailscaleError, ValueError) as exc:
1034
+ raise click.ClickException(str(exc)) from exc
1035
+ _remember_tailscale_credential(client_id, client_secret)
1036
+ success(f"Tailnet routes moved to {_tailnet_url(state, '<project>')}.")
1037
+ if current.https:
1038
+ info(
1039
+ f"Machines that trusted the .{old_suffix} root still trust it; run "
1040
+ "`localghost trust remove` on each to revoke."
1041
+ )
1042
+ _offer_tailnet_trust(chosen_suffix, certificate, localhost_trusted)
1043
+
1044
+
1045
+ def _refuse_active_mapping(
1046
+ suffix: str, split_dns: dict[str, list[str]], takeover: bool
1047
+ ) -> None:
1048
+ active = split_dns.get(suffix)
1049
+ if active and not takeover:
1050
+ raise click.ClickException(
1051
+ f"split DNS for .{suffix} already points at "
1052
+ f"{', '.join(active)} — another machine may be hosting this "
1053
+ "suffix. Choose a different --suffix, or pass --takeover to "
1054
+ "replace the mapping."
1055
+ )
1056
+
1057
+
1058
+ def _prepare_tailnet_authority(suffix: str) -> PublicCertificate:
1059
+ # Tailnet TLS terminates on Traefik's websecure entrypoint. Bootstrap its
1060
+ # localhost signer as well, but do not install localhost trust as an
1061
+ # implicit side effect of enabling a remote route.
1062
+ info("Preparing the localhost and tailnet HTTPS authorities…")
1063
+ _bootstrap_public_root()
1064
+ certificate = _bootstrap_tailnet_root(suffix)
1065
+ # Keep the tailnet root beside the state so `tailscale status` can repeat
1066
+ # the share command without a running gateway.
1067
+ _write_public_root(_tailnet_root_path(suffix), certificate.pem)
1068
+ return certificate
1069
+
1070
+
1071
+ def _remember_tailscale_credential(client_id: str, client_secret: str) -> None:
951
1072
  if not store_tailscale_credential(client_id, client_secret):
952
1073
  warning(
953
1074
  "The OAuth credential was not stored",
954
1075
  ["no usable system keyring; disable will ask for the credential again"],
955
1076
  )
956
- success(f"Tailnet routes are enabled at {_tailnet_url(state, '<project>')}.")
1077
+
1078
+
1079
+ def _offer_tailnet_trust(
1080
+ suffix: str, certificate: PublicCertificate | None, localhost_trusted: bool
1081
+ ) -> None:
957
1082
  if certificate is None:
958
1083
  return
959
1084
  action(
960
- "Trust on other tailnet machines",
961
- _share_command(chosen_suffix, certificate),
1085
+ f"Trust https://*.{suffix} on other machines",
1086
+ _share_command(suffix, certificate),
962
1087
  )
963
1088
  if localhost_trusted:
964
1089
  info("Installing the tailnet HTTPS root (sudo may be requested)…")
965
1090
  try:
966
- _install_tailnet_trust(chosen_suffix, show_details=False)
1091
+ _install_tailnet_trust(suffix, show_details=False)
967
1092
  except click.ClickException as exc:
968
1093
  warning("Tailnet trust was not installed", [str(exc)])
969
1094
  action("Trust this client", "localghost trust")
@@ -976,7 +1101,12 @@ def tailscale_enable(
976
1101
  def _resolve_tailscale_credential(
977
1102
  client_id: str | None, client_secret: str | None, *, tag: str | None = None
978
1103
  ) -> tuple[str, str]:
979
- """Find the OAuth credential: options, then keyring, then a guided prompt."""
1104
+ """Find the OAuth credential: options, then keyring, then a guided prompt.
1105
+
1106
+ With a tag, the prompt follows enable's full admin-console setup; without
1107
+ one it only names the scope disable uses, since disable deletes the
1108
+ credential afterwards anyway.
1109
+ """
980
1110
  if not client_id and not client_secret:
981
1111
  stored = load_tailscale_credential()
982
1112
  if stored is not None:
@@ -998,6 +1128,11 @@ def _resolve_tailscale_credential(
998
1128
  ],
999
1129
  title="One-time Tailscale admin-console setup",
1000
1130
  )
1131
+ else:
1132
+ details(
1133
+ [("OAuth client with dns:write", OAUTH_CONSOLE_URL)],
1134
+ title="No OAuth credential is stored in the system keyring",
1135
+ )
1001
1136
  if not client_id:
1002
1137
  client_id = click.prompt("Client id")
1003
1138
  if not client_secret:
@@ -1026,17 +1161,27 @@ def tailscale_disable(client_id: str | None, client_secret: str | None) -> None:
1026
1161
  previous = state.previous_split_dns.get(state.suffix)
1027
1162
  api.update_split_dns(state.tailnet, {state.suffix: previous})
1028
1163
  remove_tailscale_state()
1029
- delete_tailscale_credential()
1164
+ forgot_credential = delete_tailscale_credential()
1030
1165
  if proxy_is_running():
1031
- _run_proxy("up", https_enabled=_https_configured(), force_recreate=True)
1166
+ _run_proxy(
1167
+ "up",
1168
+ already_running=True,
1169
+ https_enabled=_https_configured(),
1170
+ force_recreate=True,
1171
+ )
1032
1172
  except TailscaleError as exc:
1033
1173
  raise click.ClickException(str(exc)) from exc
1034
1174
  success("Tailnet DNS was restored and the local gateway was removed.")
1175
+ if forgot_credential:
1176
+ info("The OAuth credential was removed from the system keyring.")
1035
1177
  info(
1036
1178
  f"Machines that trusted the .{state.suffix} root still trust it; run "
1037
1179
  "`localghost trust remove` on each to revoke."
1038
1180
  )
1039
- info("Remove the offline tagged localghost device in the Tailscale admin console.")
1181
+ info(
1182
+ f"Remove the offline tagged localghost-{state.node_label} device in the "
1183
+ "Tailscale admin console."
1184
+ )
1040
1185
 
1041
1186
 
1042
1187
  @tailscale.command("trust")
@@ -1093,6 +1238,33 @@ def _rollback_enable(
1093
1238
  )
1094
1239
 
1095
1240
 
1241
+ def _rollback_switch(
1242
+ api: TailscaleAPI,
1243
+ current: TailscaleState,
1244
+ suffix: str,
1245
+ previous: dict[str, list[str]],
1246
+ ) -> None:
1247
+ """Return the routes to the old suffix after a late switch failure."""
1248
+ try:
1249
+ api.update_split_dns(
1250
+ current.tailnet,
1251
+ {
1252
+ suffix: previous.get(suffix),
1253
+ current.suffix: previous.get(current.suffix),
1254
+ },
1255
+ )
1256
+ except TailscaleError as exc:
1257
+ warning("Tailnet DNS was not restored", [str(exc)])
1258
+ save_tailscale_state(current)
1259
+ warning(
1260
+ "The switch failed and was rolled back",
1261
+ [
1262
+ f"tailnet routes stay on .{current.suffix}",
1263
+ "run `localghost hub up` to restore them on the running hub",
1264
+ ],
1265
+ )
1266
+
1267
+
1096
1268
  def _tailnet_root_path(suffix: str) -> Path:
1097
1269
  return _state_directory() / f"tailscale-{suffix}-rootCA.pem"
1098
1270
 
@@ -1110,6 +1282,7 @@ def _install_tailnet_trust(
1110
1282
  ) -> None:
1111
1283
  expected = _normalize_fingerprint(expected_fingerprint)
1112
1284
  try:
1285
+ require_mkcert()
1113
1286
  suffix = validate_tailscale_suffix(suffix)
1114
1287
  state = load_tailscale_state()
1115
1288
  gateway_ips = state.gateway_ips if state and state.suffix == suffix else ()
@@ -1131,7 +1304,7 @@ def _install_tailnet_trust(
1131
1304
  if show_details:
1132
1305
  details(
1133
1306
  [
1134
- ("Authorization", "system authorization is required now"),
1307
+ ("Authorization", "sudo password may be requested for system trust"),
1135
1308
  ("Scope", f"development names under .{suffix}"),
1136
1309
  ("Public-root fingerprint", certificate.fingerprint),
1137
1310
  ("Private keys", "remain on the hosting machine"),
@@ -1326,10 +1499,9 @@ def run(
1326
1499
  secondary_origin=_tailnet_origin(plan.name),
1327
1500
  status_bar=not no_status_bar,
1328
1501
  )
1502
+ _report_application_exit(plan.name, status)
1329
1503
  if status:
1330
- _report_application_exit(status)
1331
1504
  raise click.exceptions.Exit(status)
1332
- success("Application stopped.")
1333
1505
 
1334
1506
 
1335
1507
  def _report_live_session(session: Session, *, refuse: bool) -> None:
@@ -1537,7 +1709,7 @@ def _run_compose(
1537
1709
  bar.status("starting")
1538
1710
  result = subprocess.run(command, cwd=cwd, check=False)
1539
1711
  if result.returncode:
1540
- _report_application_exit(result.returncode)
1712
+ _report_application_exit(project, result.returncode)
1541
1713
  raise click.exceptions.Exit(result.returncode)
1542
1714
  if detach:
1543
1715
  log = _session_log_path(project)
@@ -1554,20 +1726,20 @@ def _run_compose(
1554
1726
  success(f"Started detached Compose session for {project}.")
1555
1727
 
1556
1728
 
1557
- def _report_application_exit(status: int) -> None:
1729
+ def _report_application_exit(name: str, status: int) -> None:
1558
1730
  """Leave a stable diagnosis after the transient status display is gone."""
1559
- warning(
1560
- "Application exited",
1561
- [
1562
- f"The application command exited with status {status}.",
1563
- "Its output is above; use --no-status-bar for plain terminal output.",
1564
- ],
1565
- )
1731
+ sign_off()
1732
+ if status in (0, 128 + signal.SIGINT):
1733
+ # Ctrl+C is how a foreground run is meant to end, not a failure.
1734
+ success(f"Stopped {name}.")
1735
+ return
1736
+ failure(f"{name} exited with status {status}.")
1566
1737
 
1567
1738
 
1568
1739
  def _print_run_plan(plan: RunPlan, dry_run: bool, detach: bool = False) -> None:
1569
1740
  public_origin = _proxy_origin(plan.name)
1570
1741
  run_plan(
1742
+ name=plan.name,
1571
1743
  type=plan.type,
1572
1744
  command=plan.command,
1573
1745
  port=plan.port,
@@ -1764,6 +1936,7 @@ def _run_proxy(
1764
1936
  environment["LOCALGHOST_IMAGE_TAG"] = f"v{LOCALGHOST_VERSION}"
1765
1937
  if tailscale_state is not None:
1766
1938
  environment["LOCALGHOST_TAILSCALE_SUFFIX"] = tailscale_state.suffix
1939
+ environment["LOCALGHOST_TAILSCALE_NODE"] = tailscale_state.node_label
1767
1940
  # The tailnet overlay owns Traefik's command, so it also has
1768
1941
  # to be told which CA providers have a signer to run with.
1769
1942
  environment["LOCALGHOST_LOCALHOST_CA_MODE"] = (
@@ -1927,7 +2100,9 @@ def _ensure_gateway_image(suffix: str) -> None:
1927
2100
  raise TailscaleError(detail or "could not build the gateway image")
1928
2101
 
1929
2102
 
1930
- def _bootstrap_tailscale_gateway(suffix: str, auth_key: str) -> tuple[str, ...]:
2103
+ def _bootstrap_tailscale_gateway(
2104
+ suffix: str, node: str, auth_key: str
2105
+ ) -> tuple[str, ...]:
1931
2106
  with _proxy_resource_directory() as resource_root:
1932
2107
  command = [
1933
2108
  "docker",
@@ -1946,13 +2121,14 @@ def _bootstrap_tailscale_gateway(suffix: str, auth_key: str) -> tuple[str, ...]:
1946
2121
  "-T",
1947
2122
  "tailscale-gateway",
1948
2123
  f"--suffix={suffix}",
1949
- f"--hostname=localghost-{suffix}",
2124
+ f"--hostname=localghost-{node}",
1950
2125
  "--state-dir=/var/lib/localghost-tailscale",
1951
2126
  "--bootstrap",
1952
2127
  ]
1953
2128
  environment = os.environ.copy()
1954
2129
  environment["LOCALGHOST_IMAGE_TAG"] = f"v{LOCALGHOST_VERSION}"
1955
2130
  environment["LOCALGHOST_TAILSCALE_SUFFIX"] = suffix
2131
+ environment["LOCALGHOST_TAILSCALE_NODE"] = node
1956
2132
  try:
1957
2133
  result = subprocess.run(
1958
2134
  command,
@@ -2061,6 +2237,10 @@ def _detect_root_rotation(certificate: PublicCertificate) -> bool:
2061
2237
 
2062
2238
 
2063
2239
  def _enable_https() -> None:
2240
+ try:
2241
+ require_mkcert()
2242
+ except TrustError as exc:
2243
+ raise click.ClickException(str(exc)) from exc
2064
2244
  was_configured = _https_configured()
2065
2245
  certificate = _bootstrap_public_root()
2066
2246
  certificate_path = _public_root_path()
@@ -9,7 +9,6 @@ from pathlib import Path
9
9
 
10
10
  from rich import box
11
11
  from rich.console import Console
12
- from rich.panel import Panel
13
12
  from rich.table import Table
14
13
  from rich.text import Text
15
14
 
@@ -34,12 +33,25 @@ def title(*, welcome: bool = False) -> None:
34
33
  return
35
34
  _wordmark_shown = True
36
35
  console = _console(False)
37
- console.print(Text.assemble(("local", "bold"), ("ghost", f"bold {LIME}")))
36
+ console.print(_wordmark())
38
37
  console.print()
39
38
  if welcome:
40
39
  console.print("Easy .localhost URLs for your local apps.")
41
40
 
42
41
 
42
+ def sign_off() -> None:
43
+ """Mark where localghost speaks again after an application's own output."""
44
+ if not _rich_terminal(False):
45
+ return
46
+ console = _console(False)
47
+ console.print()
48
+ console.print(_wordmark())
49
+
50
+
51
+ def _wordmark() -> Text:
52
+ return Text.assemble(("local", "bold"), ("ghost", f"bold {LIME}"))
53
+
54
+
43
55
  def next_actions(*, https_enabled: bool) -> None:
44
56
  """Show useful commands after a successful hub launch."""
45
57
  action("Stop the hub", "uvx localghost hub down")
@@ -137,32 +149,42 @@ def success(message: str, *, err: bool = False) -> None:
137
149
  _message(message, LIME, err=err, symbol="✓")
138
150
 
139
151
 
152
+ def failure(message: str) -> None:
153
+ _message(message, "red", err=True, symbol="✗")
154
+
155
+
140
156
  def warning(title: str, messages: Iterable[str]) -> None:
141
157
  items = list(messages)
142
158
  if _rich_terminal(True):
143
- body = Text("\n".join(f"• {message}" for message in items))
144
- _console(True).print(Panel(body, title=title, border_style="yellow"))
159
+ console = _console(True)
160
+ console.print(Text(title, style="bold yellow"))
161
+ for message in items:
162
+ console.print(Text.assemble(("• ", "yellow"), message))
145
163
  return
146
164
  for message in items:
147
165
  _console(True).print(f"Warning: {title}: {message}", soft_wrap=True)
148
166
 
149
167
 
150
- def _labeled_block(rows: list[tuple[str, str]], *, title: str, err: bool) -> None:
151
- """Render label/value rows under a title, as a Panel or a plain heading."""
168
+ def _labeled_block(rows: list[tuple[str, str]], *, heading: str, err: bool) -> None:
169
+ """Render label/value rows under a heading, marked by a mint accent bar."""
152
170
  if _rich_terminal(err):
171
+ console = _console(err)
172
+ console.print(Text.assemble(("▌ ", MINT), (heading, "bold")))
153
173
  table = Table.grid(padding=(0, 1))
174
+ table.add_column(style=MINT)
154
175
  table.add_column(style="bold")
155
176
  table.add_column()
156
177
  for label, value in rows:
157
- table.add_row(label, value)
158
- _console(err).print(Panel(table, title=title, border_style=MINT))
178
+ table.add_row("▌", label, value)
179
+ console.print(table)
159
180
  return
160
- lines = [f"{title}:", *(f" {label}: {value}" for label, value in rows)]
181
+ lines = [heading, *(f" {label}: {value}" for label, value in rows)]
161
182
  _console(err).print("\n".join(lines), soft_wrap=True)
162
183
 
163
184
 
164
185
  def run_plan(
165
186
  *,
187
+ name: str,
166
188
  type: str,
167
189
  command: tuple[str, ...],
168
190
  port: int,
@@ -171,7 +193,7 @@ def run_plan(
171
193
  project_root: Path | None = None,
172
194
  working_directory: Path | None = None,
173
195
  ) -> None:
174
- rows = [("Type", type)]
196
+ rows = []
175
197
  if project_root:
176
198
  rows.append(("Project root", str(project_root)))
177
199
  if working_directory and working_directory != project_root:
@@ -183,18 +205,22 @@ def run_plan(
183
205
  ("Public URL", url),
184
206
  ]
185
207
  )
186
- title = "Dry run" if dry_run else "Run configuration"
187
- _labeled_block(rows, title=title, err=dry_run)
208
+ heading = f"Dry run: {name}" if dry_run else f"Running {name}"
209
+ # A custom command says nothing as a type; the Command row shows it.
210
+ if type != "custom":
211
+ heading += f" ({type})"
212
+ _labeled_block(rows, heading=heading, err=dry_run)
188
213
 
189
214
 
190
215
  def compose_dry_run(*, project: str, url: str) -> None:
191
216
  """The Compose analogue of `run_plan`'s dry-run block.
192
217
 
193
218
  Compose owns its own port and command, so those rows never apply here;
194
- only the type, the project name, and the public URL are meaningful.
219
+ only the project name and the public URL are meaningful.
195
220
  """
196
- rows = [("Type", "compose"), ("Project", project), ("Public URL", url)]
197
- _labeled_block(rows, title="Dry run", err=True)
221
+ _labeled_block(
222
+ [("Public URL", url)], heading=f"Dry run: {project} (compose)", err=True
223
+ )
198
224
 
199
225
 
200
226
  def routes(items: Iterable[tuple[str, str]]) -> None: