diff --git a/README.md b/README.md index e5d5fc2..79b8d3b 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ git CLI only — same portability heritage as the estate's `gitea_sync.py` mesh. ## Quickstart (invited user) You need a shre-id account — an operator creates it; there is no open signup -(see "Invite-only story"). You also need to be on the tailnet: the -provisioning service is not publicly exposed yet. +(see "Invite-only story"). You do **not** need to be on any private network: +the provisioning service answers at `https://granthi-link.shre.ai`, which is +the client's default server. Internal machines can still pass +`--server http://100.111.127.127:3042` to reach it over the tailnet. ```sh git clone https://granthi.shre.ai/nirpa/granthi-sync.git @@ -166,11 +168,18 @@ So: * **Our own machines** may join the tailnet — that is an operator action with an operator's judgement behind it. * **Customer devices never do.** Their transport is public HTTPS to - granthi-link and the forge through cloudflared. That is the same exposure - step already in the promotion window below, and it is what makes a genuinely - new computer able to onboard itself at all — today `link` only works from - inside the tailnet, which means "you can't set up a new computer without an - operator first" is the honest status. + granthi-link and the forge through cloudflared. **Done 2026-08-23:** + `https://granthi-link.shre.ai` fronts `:3042` on the `pulse-granthi-edge` + tunnel, so a new computer signs itself in with one command and never + touches the private network. + + Two things that bit during that rollout, worth not rediscovering: + `link.granthi.shre.ai` fails TLS — Cloudflare's Universal SSL covers + `shre.ai` and `*.shre.ai`, **not** a third-level `*.granthi.shre.ai`, so + the hostname has to be second-level. And exposure REQUIRES + `trust_forwarded_for: true` with `trusted_proxies: ["100.107.37.98/32"]` + in the same change: behind the tunnel every request otherwise looks like + the tunnel itself, and one abuser would spend everybody's rate budget. ## Components @@ -537,8 +546,10 @@ Shape this should take, so the next session does not re-litigate it: ## Promotion window (beta → prod) -1. **Expose :3042** behind cloudflared (granthi.shre.ai vhost or - link.granthi.shre.ai) — today it is tailnet-only by design. +1. ~~**Expose :3042** behind cloudflared~~ **DONE 2026-08-23** — + `https://granthi-link.shre.ai` (second-level, see above), origin stays + tailnet-only, `trust_forwarded_for` on with the tunnel as the only + trusted proxy. 2. **Swap forge base URLs** in `/opt/granthi-link/config.json`: `gitea_base` → prod forge, `public_gitea_base` → `https://granthi.shre.ai`; the client default server URL moves to the diff --git a/client/granthi_sync_client.py b/client/granthi_sync_client.py index 48284ff..2f6f427 100644 --- a/client/granthi_sync_client.py +++ b/client/granthi_sync_client.py @@ -87,7 +87,12 @@ ZITADEL_BASE = "https://id.shre.ai" DEVICE_CLIENT_ID = "386909715541590022" # ^ Zitadel native app "granthi-sync-device" (appId 386909715541524486) in # project granthi-forge (386906525790109702); device-code + refresh grants. -DEFAULT_SERVER = "http://100.111.127.127:3042" +# Public by default: a genuinely new computer cannot be expected to join a +# private network before it can sign in. The tailnet address still works and +# is what internal machines should pass to --server. +DEFAULT_SERVER = os.environ.get("GRANTHI_LINK_SERVER", + "https://granthi-link.shre.ai") +TAILNET_SERVER = "http://100.111.127.127:3042" DEVICE_SCOPE = "openid profile email" FORGE_PAGE_LIMIT = 50 diff --git a/tests/test_client.py b/tests/test_client.py index 9352ddd..d4821f5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1102,5 +1102,23 @@ class TestPruneClockIsPersisted(GitScenarioBase): self.assertEqual(pruner.call_count, 0) # no snapshot folders linked + +class TestDefaultServer(unittest.TestCase): + """A new computer must be able to sign in without joining a private + network first -- that is the whole point of exposing the endpoint.""" + + def test_default_server_is_public_https(self): + self.assertTrue(client.DEFAULT_SERVER.startswith("https://"), + client.DEFAULT_SERVER) + self.assertNotIn("100.111.", client.DEFAULT_SERVER) + + def test_env_override_wins_for_internal_machines(self): + import importlib + with mock.patch.dict(os.environ, + {"GRANTHI_LINK_SERVER": "http://10.0.0.5:3042"}): + reloaded = importlib.reload(client) + self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042") + importlib.reload(client) # restore for the rest of the suite + if __name__ == "__main__": unittest.main()