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.
- {localghost-3.2.1 → localghost-3.3.0}/PKG-INFO +1 -1
- {localghost-3.2.1 → localghost-3.3.0}/pyproject.toml +1 -1
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/cli.py +242 -62
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/feedback.py +41 -15
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_tailscale.yaml +10 -2
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_tailscale_https.yaml +1 -1
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale.py +44 -7
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/Dockerfile +1 -1
- localghost-3.3.0/src/localghost/tailscale_gateway/identity.go +98 -0
- localghost-3.3.0/src/localghost/tailscale_gateway/identity_test.go +94 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/main.go +2 -1
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/.traefik.yml +1 -1
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client.go +62 -14
- localghost-3.3.0/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity.go +154 -0
- localghost-3.3.0/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/identity_test.go +220 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/trust.py +11 -6
- {localghost-3.2.1 → localghost-3.3.0}/LICENSE +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/README.md +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/.dockerignore +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/Dockerfile +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/__init__.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/compose.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/config.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/forwarder.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/generator.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/paths.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/picker.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/ports.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose.yaml +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/proxy_compose_https.yaml +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/registry.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/roots.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/routes.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/runner.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/sessions.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/statusbar.py +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/go.mod +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/go.sum +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/main_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/tailscale_gateway/proxy_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/.traefik.yml +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/ca_test.go +0 -0
- {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
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/docker_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.mod +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/go.sum +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/provider_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/storage.go +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-ca/vendor/modules.txt +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/client_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-client/go.mod +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/.traefik.yml +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/fallback_test.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/go.mod +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-fallback/logo.go +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/.traefik.yml +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/go.mod +0 -0
- {localghost-3.2.1 → localghost-3.3.0}/src/localghost/traefik_plugin/src/github.com/SmileyChris/traefik-localghost-tailnet-ca/provider.go +0 -0
|
@@ -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
|
|
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
|
-
|
|
730
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
910
|
+
_explicit_tailnet_row(tailnet)
|
|
911
|
+
+ [
|
|
890
912
|
("Route suffix", f".{chosen_suffix}"),
|
|
891
|
-
("
|
|
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
|
-
|
|
904
|
-
if
|
|
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-{
|
|
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
|
-
|
|
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
|
|
961
|
-
_share_command(
|
|
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(
|
|
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(
|
|
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(
|
|
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", "
|
|
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
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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(
|
|
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-{
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
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]], *,
|
|
151
|
-
"""Render label/value rows under a
|
|
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
|
-
|
|
178
|
+
table.add_row("▌", label, value)
|
|
179
|
+
console.print(table)
|
|
159
180
|
return
|
|
160
|
-
lines = [
|
|
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 = [
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
219
|
+
only the project name and the public URL are meaningful.
|
|
195
220
|
"""
|
|
196
|
-
|
|
197
|
-
|
|
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:
|