Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 \
Expand All @@ -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:
Expand Down Expand Up @@ -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.
59 changes: 41 additions & 18 deletions web/public/sw.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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;
}
Loading
Loading