diff --git a/README.md b/README.md index 94061c7..95e7b77 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,16 @@ on first boot it will: after that, local admin happens over the unix socket at `~/.local/share/imsg-bridge/imsg-bridge.sock`. -## pairing a client +## how to use it + +there are really two client modes today: + +- direct clients: cli tools, native apps, and anything that can live with the bridge's self-signed tls plus the pairing fingerprint +- browser clients: the web app shell, which needs a browser-trusted https host and should go through tailscale serve + +### direct clients + +direct clients can talk to the bridge itself on `:8443`. the normal path is: @@ -69,7 +78,100 @@ imsg-bridge-cli clients imsg-bridge-cli revoke c_01example ``` -web clients use a delegated session flow instead of qr pairing: +for local bridge work, you can also point the daemon at a patched `imsg` checkout: + +```sh +IMSGBRIDGE_IMSG_BIN=/path/to/imsg make run +``` + +or: + +```sh +./bin/imsg-bridge -imsg-bin /path/to/imsg +``` + +### browser clients + +the browser should not connect to the bridge's direct `:8443` listener. + +that listener uses a self-signed cert, which is fine for pinned native flows but not for a normal browser. + +for browsers, use: + +- cloudflare pages for the static shell +- tailscale serve for the bridge traffic +- the `*.ts.net` hostname as the saved browser host + +#### 1. run the bridge locally + +```sh +make run +``` + +or your built binary: + +```sh +./bin/imsg-bridge +``` + +#### 2. put tailscale serve in front of it + +if your bridge is listening on `127.0.0.1:8443` or `:8443` with its current self-signed cert, this is the simplest setup: + +```sh +tailscale serve --https=443 https+insecure://127.0.0.1:8443 +tailscale serve status +``` + +that gives you a browser-trusted tailscale https endpoint, usually something like: + +```text +https://bridge-name.your-tailnet.ts.net +``` + +#### 3. deploy the web shell + +the web app is just a static frontend: + +```sh +cd web +npm install +npm run build +``` + +then deploy `web/dist` to cloudflare pages. + +cloudflare is only hosting the shell. it is not proxying message traffic. + +#### 4. pair from the browser + +open the cloudflare-hosted web app and use either: + +- `pair with qr` +- `approval code` + +for the browser host, use the plain `*.ts.net` host: + +```text +bridge-name.your-tailnet.ts.net +``` + +not: + +```text +bridge-name.your-tailnet.ts.net:8443 +100.x.y.z:8443 +https://100.x.y.z:8443 +``` + +the web app stores: + +- `https://bridge-name.your-tailnet.ts.net` for api requests +- `wss://bridge-name.your-tailnet.ts.net` for live events + +### delegated browser login + +web clients can also use the delegated session flow instead of qr pairing: ```sh curl -sk https://127.0.0.1:8443/v1/sessions \ @@ -79,6 +181,14 @@ curl -sk https://127.0.0.1:8443/v1/sessions \ the browser polls the returned `session_id`, and an already-paired client approves it. +## pairing a client + +the short version: + +- use `imsg-bridge-cli pair` for direct/native clients +- use the cloudflare-hosted web shell plus your `*.ts.net` host for browser clients +- do not use `:8443` as the browser host + ## api shape main endpoints: @@ -127,10 +237,33 @@ curl -sk https://127.0.0.1:8443/v1/attachments \ webhook targets must use `https://` and cannot resolve to loopback, private, link-local, or metadata addresses. +## browser troubleshooting + +if desktop works but mobile chrome fails, check these first: + +- the saved browser host must be your `*.ts.net` hostname with no `:8443` +- `tailscale serve status` should show port `443` serving the bridge +- if you just deployed the web shell, do one hard refresh so the browser picks up the latest service worker and bundle + +if the bridge log shows: + +```text +tls: unknown certificate +``` + +or: + +```text +tls handshake error ... EOF +``` + +the browser is almost always talking to the bridge's direct self-signed tls listener instead of the tailscale serve host. + ## project notes - the daemon keeps its state in `~/.local/share/imsg-bridge/` - the api is meant for tailscale clients, not direct public exposure - websocket auth uses `?token=` because browser websocket clients cannot set custom auth headers +- for now, the browser story assumes tailscale serve and a `*.ts.net` hostname. direct self-signed tls is for native/direct clients. if you want the deeper architecture and rollout plan, that lives in the repo notes rather than this README. diff --git a/web/public/sw.js b/web/public/sw.js index 3bebb08..a313fe1 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,4 +1,4 @@ -const cacheName = 'imsg-bridge-web-shell-v1'; +const cacheName = 'imsg-bridge-web-shell-v2'; const shellAssets = ['/', '/manifest.webmanifest', '/icon.svg']; self.addEventListener('install', (event) => { @@ -33,21 +33,44 @@ self.addEventListener('fetch', (event) => { return; } - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) { - return cached; - } - - return fetch(event.request).then((response) => { - if (!response.ok || response.type === 'opaque') { - return response; - } - - const clone = response.clone(); - caches.open(cacheName).then((cache) => cache.put(event.request, clone)); - return response; - }); - }), - ); + const isShellRequest = + event.request.mode === 'navigate' || shellAssets.includes(requestUrl.pathname); + + if (isShellRequest) { + event.respondWith(networkFirst(event.request)); + return; + } + + event.respondWith(cacheFirst(event.request)); }); + +async function networkFirst(request) { + try { + const response = await fetch(request); + if (response.ok && response.type !== 'opaque') { + const cache = await caches.open(cacheName); + await cache.put(request, response.clone()); + } + return response; + } catch (error) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + throw error; + } +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + + const response = await fetch(request); + if (response.ok && response.type !== 'opaque') { + const cache = await caches.open(cacheName); + await cache.put(request, response.clone()); + } + return response; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index fcbd961..252ed39 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,6 +27,7 @@ import { buildProfileDraft, deriveApiBaseUrl, deriveBrowserPairTarget, + requireBrowserSafeHost, } from './lib/connection'; import { parsePairPayload } from './lib/qr'; import type { @@ -343,7 +344,8 @@ function PairPage(props: { try { const payload = parsePairPayload(payloadText); - const targetHost = browserHost.trim() || deriveBrowserPairTarget(payload.h).suggestedBrowserHost; + const fallbackHost = deriveBrowserPairTarget(payload.h).suggestedBrowserHost; + const targetHost = requireBrowserSafeHost(browserHost.trim() || fallbackHost); const apiBaseUrl = deriveApiBaseUrl(targetHost); const pairResult = await pairClient(apiBaseUrl, { code: payload.c, @@ -413,7 +415,7 @@ function PairPage(props: { placeholder="bridge-name.your-tailnet.ts.net" />

- prefer the *.ts.net serve host for browser connections. + use a browser-trusted https hostname here, usually your *.ts.net serve host.

@@ -437,7 +439,7 @@ function PairPage(props: { host: {parsedPayload.h}

- target: {browserHost || deriveBrowserPairTarget(parsedPayload.h).suggestedBrowserHost} + target: {browserHost || deriveBrowserPairTarget(parsedPayload.h).suggestedBrowserHost || 'enter your browser-safe host'}

) : null} @@ -478,7 +480,7 @@ function SessionPage(props: { } let cancelled = false; - const apiBaseUrl = deriveApiBaseUrl(host); + const apiBaseUrl = deriveApiBaseUrl(requireBrowserSafeHost(host)); const timer = window.setInterval(() => { pollSession(apiBaseUrl, session.session_id) .then(async (result) => { @@ -524,7 +526,7 @@ function SessionPage(props: { setError(null); try { - const apiBaseUrl = deriveApiBaseUrl(host); + const apiBaseUrl = deriveApiBaseUrl(requireBrowserSafeHost(host)); const created = await createSession(apiBaseUrl, { clientName, clientType: 'web', @@ -555,6 +557,9 @@ function SessionPage(props: { onChange={(event) => setHost(event.target.value)} placeholder="bridge-name.your-tailnet.ts.net" /> +

+ use the browser-safe https hostname, not the direct bridge ip or :8443. +