@gotcos/glasses-server 6.88.0 → 6.90.0
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.
- package/.env.example +14 -0
- package/CHANGELOG.md +67 -0
- package/SECURITY.md +5 -10
- package/docs/tailscale-self-heal.md +159 -0
- package/managed-runtime-contract.json +2 -0
- package/package.json +4 -3
- package/server/index.ts +21 -12
- package/server/lib/api-auth.ts +0 -7
- package/server/lib/mac-voice-labels.ts +50 -0
- package/server/lib/meeting-batch-transcribe.ts +9 -3
- package/server/lib/meeting-relabel-enrolment.ts +12 -1
- package/server/lib/meeting-relabel.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +4 -1
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-final.ts +314 -7
- package/server/lib/nemotron-live.ts +3 -1
- package/server/lib/speaker-embeddings.ts +39 -1
- package/server/lib/tailscale-nudge.ts +540 -0
- package/server/routes/health.ts +3 -15
- package/server/routes/transcribe-stream.ts +13 -2
- package/docs/lan-first.md +0 -158
- package/server/lib/lan-first.ts +0 -236
- package/server/routes/route-proof.ts +0 -83
package/docs/lan-first.md
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
# LAN first: the server contract (6.88.0)
|
|
2
|
-
|
|
3
|
-
The server half of the glasses "LAN first" reconnect plan (2026-10-22, decisions of 2026-10-23). The phone
|
|
4
|
-
reaches the Mac over the home network first and over Tailscale second. The server adds two things, both
|
|
5
|
-
additive and both capability gated:
|
|
6
|
-
|
|
7
|
-
1. `GET /api/route-proof`, a public identity proof, so the phone can check WHICH Mac answers at an address
|
|
8
|
-
before it sends `X-Cos-Token` there.
|
|
9
|
-
2. `routes` on authenticated `GET /api/models`: the addresses this Mac answers on, re-learned after a DHCP change.
|
|
10
|
-
|
|
11
|
-
An older server has neither capability flag. A phone must then keep today's single stored URL and change
|
|
12
|
-
nothing. `COS_LAN_FIRST=0` in the server environment turns both pieces off (read per request).
|
|
13
|
-
|
|
14
|
-
## Capability flags
|
|
15
|
-
|
|
16
|
-
Published on `GET /api/health` (public) and `GET /api/models` (token) under `capabilities`, from one helper,
|
|
17
|
-
so the two surfaces always agree:
|
|
18
|
-
|
|
19
|
-
```json
|
|
20
|
-
"routeProof": { "protocol": 1 },
|
|
21
|
-
"lanRoutes": { "protocol": 1 }
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
| Flag | Present when | Absent means |
|
|
25
|
-
|---|---|---|
|
|
26
|
-
| `routeProof` | the switch is on, the server instance id is minted, and the token is at least 32 characters | do not call `/api/route-proof`; never send the token to an unproven address |
|
|
27
|
-
| `lanRoutes` | the switch is on | `/api/models` will not carry `routes` |
|
|
28
|
-
|
|
29
|
-
Public health carries only these flags. It never carries an address.
|
|
30
|
-
|
|
31
|
-
## `GET /api/route-proof?nonce=<nonce>`
|
|
32
|
-
|
|
33
|
-
Public: no `X-Cos-Token`. GET only.
|
|
34
|
-
|
|
35
|
-
### Request
|
|
36
|
-
|
|
37
|
-
| Part | Rule |
|
|
38
|
-
|---|---|
|
|
39
|
-
| `nonce` | exactly one query parameter, 22 to 64 characters of base64url (`A-Z a-z 0-9 - _`), no padding. Fresh random per request; 16 random bytes encode to 22 characters. |
|
|
40
|
-
| Headers | none needed. Do not send `X-Cos-Token` to an address you have not proven. |
|
|
41
|
-
| Timeout | the plan's 1.5 s for a LAN proof. |
|
|
42
|
-
|
|
43
|
-
### Success: `200`
|
|
44
|
-
|
|
45
|
-
```json
|
|
46
|
-
{ "v": 1, "serverInstanceId": "<uuid v4>", "proof": "<64 lowercase hex>" }
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
`Cache-Control: no-store`.
|
|
50
|
-
|
|
51
|
-
### The HMAC
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
message = "cos-route-proof:v1:" + nonce + ":" + serverInstanceId (UTF-8)
|
|
55
|
-
proof = lowercase hex( HMAC-SHA256( key = token (UTF-8 bytes), message ) )
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
`token` is the exact pairing token string the phone stores (the `X-Cos-Token` value). `serverInstanceId` is the
|
|
59
|
-
value in the response, which must also equal the phone's stored `active.serverInstanceId`.
|
|
60
|
-
|
|
61
|
-
Known-answer vector:
|
|
62
|
-
|
|
63
|
-
| Input | Value |
|
|
64
|
-
|---|---|
|
|
65
|
-
| token | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` |
|
|
66
|
-
| nonce | `AAAAAAAAAAAAAAAAAAAAAA` |
|
|
67
|
-
| serverInstanceId | `00000000-0000-4000-8000-000000000000` |
|
|
68
|
-
| message | `cos-route-proof:v1:AAAAAAAAAAAAAAAAAAAAAA:00000000-0000-4000-8000-000000000000` |
|
|
69
|
-
| proof | `445fc7d579849fa3fdfb8c2831cf0ace04c6db74e2817b75e87139cc9a3cd207` |
|
|
70
|
-
|
|
71
|
-
### Phone-side check (the whole point)
|
|
72
|
-
|
|
73
|
-
The route counts only if ALL hold, and only then may the phone send `X-Cos-Token` to that address:
|
|
74
|
-
|
|
75
|
-
1. Status 200 and `v === 1`.
|
|
76
|
-
2. `serverInstanceId` equals the stored `active.serverInstanceId`.
|
|
77
|
-
3. `proof` equals the phone's own HMAC over the nonce IT generated for this request and the STORED id.
|
|
78
|
-
|
|
79
|
-
Anything else (timeout, any other status, a body that does not parse, a mismatch) means "not my Mac": drop that
|
|
80
|
-
address for this cycle and never send the token there. Compare in constant time where the platform offers it;
|
|
81
|
-
the comparison happens on the phone, so there is no remote timing oracle either way.
|
|
82
|
-
|
|
83
|
-
### Errors
|
|
84
|
-
|
|
85
|
-
Every error body is `{ "v": 1, "reason": "<code>" }` with `Cache-Control: no-store`.
|
|
86
|
-
|
|
87
|
-
| Status | `reason` | When | Phone should |
|
|
88
|
-
|---|---|---|---|
|
|
89
|
-
| 400 | `bad_nonce` | nonce missing, repeated, wrong length or charset | fix the client; treat as not proven |
|
|
90
|
-
| 401 | (the standard `pairing_token_rejected` body) | any verb other than GET without a token, or any path other than exactly `/api/route-proof` | treat as not proven |
|
|
91
|
-
| 404 | `disabled` | `COS_LAN_FIRST=0` | stop LAN proofs until the capability returns |
|
|
92
|
-
| 404 | (no body contract) | a server older than 6.88.0 | stop LAN proofs; it has no `routeProof` flag anyway |
|
|
93
|
-
| 405 | `method_not_allowed` | a non-GET verb that carried a valid token. `Allow: GET` | fix the client |
|
|
94
|
-
| 429 | `rate_limited` | over the limit below. `Retry-After` in seconds | wait `Retry-After`, keep the current route |
|
|
95
|
-
| 503 | `not_ready` | the instance id is not minted yet (still binding after a restart), or the token is too short to expose. `Retry-After: 30` | keep the current route, retry later |
|
|
96
|
-
| 403 | (network filter) | the caller is not on loopback, Tailscale or a private network | treat as not proven |
|
|
97
|
-
|
|
98
|
-
### Rate limit
|
|
99
|
-
|
|
100
|
-
Per socket address (never `X-Forwarded-For`): 20 requests in any 60 s window. Global: 240 in any 60 s window
|
|
101
|
-
across all addresses. Bad-nonce requests spend the same budget. The plan's phone schedule (boot, foreground,
|
|
102
|
-
reconnect, then one every 60 s while on Tailscale) stays far below it.
|
|
103
|
-
|
|
104
|
-
## `routes` on `GET /api/models` (token)
|
|
105
|
-
|
|
106
|
-
```json
|
|
107
|
-
"routes": {
|
|
108
|
-
"v": 1,
|
|
109
|
-
"serverInstanceId": "<uuid v4, same as top-level serverInstanceId>",
|
|
110
|
-
"learnedAt": "<ISO time the interfaces were read>",
|
|
111
|
-
"lan": [ { "ip": "192.168.1.204", "port": 3141, "scheme": "http" } ],
|
|
112
|
-
"tailscale": [ { "ip": "100.108.149.114", "port": 3141, "scheme": "http" } ]
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
| Field | Rule |
|
|
117
|
-
|---|---|
|
|
118
|
-
| `lan` | RFC1918 IPv4 (10/8, 172.16/12, 192.168/16) on a non-internal interface that is not a tunnel, bridge or VM network (`utun`, `tun`, `tap`, `ipsec`, `ppp`, `bridge`, `awdl`, `llw`, `vmnet`, `vboxnet`, `docker`, `br-`, `veth`, `virbr`, `lxc`, `lxd`, `cni`, `flannel`, `podman`, `cali`, `vnic`, `tailscale` are left out). Link-local 169.254/16 and public addresses never appear. |
|
|
119
|
-
| `tailscale` | 100.64/10 on Tailscale's own interface (`utun*` on macOS, `tailscale*` elsewhere). A carrier-grade-NAT address on Wi-Fi is not Tailscale and is left out. |
|
|
120
|
-
| `port` | the plain-http port (`PORT`, default 3141) for both kinds. HTTPS 3143 is never advertised: its certificate does not cover these addresses. |
|
|
121
|
-
| `scheme` | always `http` in protocol 1. |
|
|
122
|
-
| MagicDNS name | not published. Reading it means running `tailscale status --json` per request, which is not cheap; a later protocol may add `name`. |
|
|
123
|
-
| order | interface order as the OS reports it; at most 8 per list; no duplicates. |
|
|
124
|
-
| freshness | read from the interfaces at most every 10 s, so a new DHCP lease shows up on the next read after that. |
|
|
125
|
-
| absent | the switch is off, the instance id is not minted, or the interface read failed. The rest of `/api/models` is unchanged in every case. |
|
|
126
|
-
|
|
127
|
-
Node does not expose interface up/down flags; an address the OS still lists can appear. That is why every
|
|
128
|
-
address must pass the route proof before the token goes there.
|
|
129
|
-
|
|
130
|
-
Replace the stored LAN list each time `/api/models` is read on either path. Never hardcode an address.
|
|
131
|
-
|
|
132
|
-
## What a stranger can learn
|
|
133
|
-
|
|
134
|
-
| Surface | Without the token |
|
|
135
|
-
|---|---|
|
|
136
|
-
| `/api/route-proof` | the instance id (already public on `/api/health` as `server_instance_id`), an HMAC of a message it cannot choose, and whether the token is long enough to be proven (it gets `not_ready` otherwise) |
|
|
137
|
-
| `/api/health` | the two protocol flags |
|
|
138
|
-
| `/api/models` | the standard 401 |
|
|
139
|
-
|
|
140
|
-
## Security decisions
|
|
141
|
-
|
|
142
|
-
- **No token oracle.** The caller chooses only the nonce, its charset has no `:`, and the message always starts with
|
|
143
|
-
the fixed prefix and ends with the server's own id. The only other HMAC keyed by the token signs
|
|
144
|
-
`display-stream:<exp>` (display tickets), which can never start with the prefix, so a proof is never a ticket.
|
|
145
|
-
- **Offline guessing.** A proof lets anyone who can reach the port test token guesses offline. Server- and
|
|
146
|
-
Control-generated tokens (44 and 64 characters of random) make that hopeless; a token under 32 characters gets no
|
|
147
|
-
proof and no `routeProof` flag, so that install keeps today's behaviour.
|
|
148
|
-
- **What the proof does and does not stop.** It stops the token going to a DIFFERENT machine at a remembered
|
|
149
|
-
address (someone else's Wi-Fi, a reused DHCP lease). It does not stop an active attacker on the same Wi-Fi who relays
|
|
150
|
-
the proof request to the real Mac: the LAN path is plain HTTP, so the token crosses the home network unencrypted, as
|
|
151
|
-
a pasted token and "Allow pairing on this Wi-Fi" already do. Over Tailscale the traffic is encrypted.
|
|
152
|
-
- **Nothing logged.** The route never logs or echoes the nonce or the proof.
|
|
153
|
-
- **No state on disk.** The rate limiter is in memory; a restart clears it.
|
|
154
|
-
|
|
155
|
-
## Not in this release
|
|
156
|
-
|
|
157
|
-
Phase 3 of the plan (a loopback `GET /api/glasses/link` for COS Control's banner) is not built: it needs new
|
|
158
|
-
tracking of the client's last path and check-in time, so it is not a trivially additive change.
|
package/server/lib/lan-first.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
// 6.88.0 LAN first (docs/lan-first.md). Two additive pieces for the phone:
|
|
2
|
-
//
|
|
3
|
-
// 1. A pre-token identity proof. `GET /api/route-proof?nonce=<phone nonce>` is public, so a
|
|
4
|
-
// phone can ask an address it has not used before "are you the Mac I paired with?"
|
|
5
|
-
// BEFORE it sends X-Cos-Token there. The answer is an HMAC keyed by the token over a
|
|
6
|
-
// fixed, domain-separated message; the phone computes the same HMAC with its stored
|
|
7
|
-
// token and sends the token only on a match. A machine without the token cannot make a
|
|
8
|
-
// matching proof, and the response never carries the token.
|
|
9
|
-
// 2. The addresses this Mac answers on, published ONLY on authenticated /api/models (never
|
|
10
|
-
// on public /api/health), so the phone can learn its LAN route after a DHCP change.
|
|
11
|
-
//
|
|
12
|
-
// This module is pure apart from the cached interface read. Clock, interfaces, token and
|
|
13
|
-
// instance id are injected so every rule is testable.
|
|
14
|
-
|
|
15
|
-
import { createHmac } from 'node:crypto'
|
|
16
|
-
import { networkInterfaces as osNetworkInterfaces } from 'node:os'
|
|
17
|
-
import { isRfc1918Ipv4, isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
|
|
18
|
-
|
|
19
|
-
export const ROUTE_PROOF_PROTOCOL = 1
|
|
20
|
-
export const LAN_ROUTES_PROTOCOL = 1
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The fixed domain-separation prefix. The caller chooses only the nonce, never the whole
|
|
24
|
-
* message, and the nonce charset has no `:`, so the message always parses one way. The only
|
|
25
|
-
* other HMAC keyed by the token signs `display-stream:<exp>` (lib/display-ticket.ts), which
|
|
26
|
-
* can never begin with this prefix, so a proof is never a usable display ticket.
|
|
27
|
-
*/
|
|
28
|
-
export const ROUTE_PROOF_PREFIX = 'cos-route-proof:v1:'
|
|
29
|
-
/** base64url alphabet, 22 to 64 characters. 22 base64url characters carry 132 bits. */
|
|
30
|
-
export const ROUTE_PROOF_NONCE_PATTERN = /^[A-Za-z0-9_-]{22,64}$/
|
|
31
|
-
/**
|
|
32
|
-
* A proof lets anyone on the network check token guesses offline, so it is served only for
|
|
33
|
-
* a token long enough that guessing is hopeless. COS Control writes 64 hex characters and
|
|
34
|
-
* the server's own token is 44 base64url characters; a short hand-set legacy token gets no
|
|
35
|
-
* proof (the phone keeps today's single-route behaviour).
|
|
36
|
-
*/
|
|
37
|
-
export const ROUTE_PROOF_MIN_TOKEN_LENGTH = 32
|
|
38
|
-
export const ROUTE_PROOF_IP_LIMIT = 20
|
|
39
|
-
export const ROUTE_PROOF_GLOBAL_LIMIT = 240
|
|
40
|
-
export const ROUTE_PROOF_WINDOW_MS = 60_000
|
|
41
|
-
/** Past this many tracked addresses, idle ones are dropped. Live ones are bounded by the global ceiling. */
|
|
42
|
-
export const ROUTE_PROOF_MAX_TRACKED_IPS = 1024
|
|
43
|
-
|
|
44
|
-
export const LAN_ROUTES_CACHE_MS = 10_000
|
|
45
|
-
export const LAN_ROUTES_MAX_PER_KIND = 8
|
|
46
|
-
|
|
47
|
-
/** `COS_LAN_FIRST=0` turns off both pieces (read per request). Anything else leaves them on. */
|
|
48
|
-
export function lanFirstEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
49
|
-
return env.COS_LAN_FIRST !== '0'
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The plain-http port the phone uses on both paths (server/index.ts reads the same `PORT`). */
|
|
53
|
-
export function lanFirstHttpPort(env: NodeJS.ProcessEnv = process.env): number {
|
|
54
|
-
const port = parseInt(env.PORT ?? '3141', 10)
|
|
55
|
-
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 3141
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function isValidRouteProofNonce(value: unknown): value is string {
|
|
59
|
-
return typeof value === 'string' && ROUTE_PROOF_NONCE_PATTERN.test(value)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function routeProofTokenUsable(token: unknown): token is string {
|
|
63
|
-
return typeof token === 'string' && token.length >= ROUTE_PROOF_MIN_TOKEN_LENGTH
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function routeProofMessage(nonce: string, serverInstanceId: string): string {
|
|
67
|
-
return `${ROUTE_PROOF_PREFIX}${nonce}:${serverInstanceId}`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Lowercase hex HMAC-SHA256(token, "cos-route-proof:v1:" + nonce + ":" + serverInstanceId). */
|
|
71
|
-
export function computeRouteProof(token: string, nonce: string, serverInstanceId: string): string {
|
|
72
|
-
return createHmac('sha256', token).update(routeProofMessage(nonce, serverInstanceId), 'utf8').digest('hex')
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** True when the proof is servable right now: switch on, instance id minted, token strong. */
|
|
76
|
-
export function routeProofAvailable(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): boolean {
|
|
77
|
-
return lanFirstEnabled(input.env) && !!input.serverInstanceId && routeProofTokenUsable(input.token)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** `capabilities.routeProof` / `capabilities.lanRoutes`, or nothing. Absent means "do not try". */
|
|
81
|
-
export function lanFirstCapabilities(input: { token: unknown; serverInstanceId: string | null; env?: NodeJS.ProcessEnv }): {
|
|
82
|
-
routeProof?: { protocol: number }
|
|
83
|
-
lanRoutes?: { protocol: number }
|
|
84
|
-
} {
|
|
85
|
-
if (!lanFirstEnabled(input.env)) return {}
|
|
86
|
-
return {
|
|
87
|
-
...(routeProofAvailable(input) ? { routeProof: { protocol: ROUTE_PROOF_PROTOCOL } } : {}),
|
|
88
|
-
lanRoutes: { protocol: LAN_ROUTES_PROTOCOL },
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Rate limit ───────────────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
export type RouteProofAdmission = { ok: true } | { ok: false; retryAfterSeconds: number }
|
|
95
|
-
|
|
96
|
-
/** Sliding one-minute window per remote address, plus a global ceiling. In memory only. */
|
|
97
|
-
export class RouteProofRateLimiter {
|
|
98
|
-
private readonly perIp = new Map<string, number[]>()
|
|
99
|
-
private global: number[] = []
|
|
100
|
-
|
|
101
|
-
constructor(private readonly now: () => number = () => Date.now()) {}
|
|
102
|
-
|
|
103
|
-
admit(remoteAddress: string | undefined | null): RouteProofAdmission {
|
|
104
|
-
const at = this.now()
|
|
105
|
-
const ip = normalizeRemoteIp(remoteAddress ?? '') || 'unknown'
|
|
106
|
-
const live = (times: number[]) => times.filter(t => at - t < ROUTE_PROOF_WINDOW_MS)
|
|
107
|
-
const retry = (oldest: number) => Math.max(1, Math.ceil((ROUTE_PROOF_WINDOW_MS - (at - oldest)) / 1000))
|
|
108
|
-
|
|
109
|
-
this.global = live(this.global)
|
|
110
|
-
const recent = live(this.perIp.get(ip) ?? [])
|
|
111
|
-
if (recent.length >= ROUTE_PROOF_IP_LIMIT) {
|
|
112
|
-
this.perIp.set(ip, recent)
|
|
113
|
-
return { ok: false, retryAfterSeconds: retry(recent[0]) }
|
|
114
|
-
}
|
|
115
|
-
if (this.global.length >= ROUTE_PROOF_GLOBAL_LIMIT) {
|
|
116
|
-
// Nothing is stored for a refused address, so a flood of new addresses grows nothing.
|
|
117
|
-
if (recent.length) this.perIp.set(ip, recent)
|
|
118
|
-
return { ok: false, retryAfterSeconds: retry(this.global[0]) }
|
|
119
|
-
}
|
|
120
|
-
recent.push(at)
|
|
121
|
-
this.global.push(at)
|
|
122
|
-
this.perIp.set(ip, recent)
|
|
123
|
-
// Live entries are bounded by the global ceiling, so dropping the idle ones is enough.
|
|
124
|
-
if (this.perIp.size > ROUTE_PROOF_MAX_TRACKED_IPS) {
|
|
125
|
-
for (const [key, times] of this.perIp) if (live(times).length === 0) this.perIp.delete(key)
|
|
126
|
-
}
|
|
127
|
-
return { ok: true }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** For tests: how many addresses the limiter is holding. */
|
|
131
|
-
trackedAddressCount(): number {
|
|
132
|
-
return this.perIp.size
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
export interface RouteEntry {
|
|
139
|
-
ip: string
|
|
140
|
-
port: number
|
|
141
|
-
scheme: 'http'
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export interface LanRoutes {
|
|
145
|
-
v: number
|
|
146
|
-
serverInstanceId: string
|
|
147
|
-
learnedAt: string
|
|
148
|
-
lan: RouteEntry[]
|
|
149
|
-
tailscale: RouteEntry[]
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
type InterfaceInfo = { address: string; family: string | number; internal: boolean }
|
|
153
|
-
export type InterfaceTable = Record<string, InterfaceInfo[] | undefined>
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Interfaces a phone on the home Wi-Fi can never reach through: VPN tunnels (utun is checked
|
|
157
|
-
* separately for Tailscale), Internet Sharing and VM bridges, AirDrop (awdl), low-latency WLAN
|
|
158
|
-
* (llw), VM host-only networks, and Linux container bridges and veth pairs.
|
|
159
|
-
*/
|
|
160
|
-
const NOT_LAN_INTERFACE = /^(utun|tun|tap|ipsec|ppp|bridge|awdl|llw|vmnet|vboxnet|docker|br-|veth|virbr|lxc|lxd|cni|flannel|podman|cali|vnic|tailscale)/i
|
|
161
|
-
/** Tailscale's own interface: utunN on macOS, tailscale0 on Linux, "Tailscale" on Windows. */
|
|
162
|
-
const TAILSCALE_INTERFACE = /^(utun|tailscale)/i
|
|
163
|
-
|
|
164
|
-
function ipv4(info: InterfaceInfo): boolean {
|
|
165
|
-
const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : info.family
|
|
166
|
-
return family === 'IPv4' && !info.internal
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Pure. LAN = RFC1918 IPv4 on a non-internal interface that is not a tunnel, bridge or VM
|
|
171
|
-
* network. Tailscale = 100.64/10 on Tailscale's own interface (a carrier-grade-NAT address
|
|
172
|
-
* on en0 is not Tailscale and is left out). Link-local 169.254/16 is neither. Each list keeps
|
|
173
|
-
* interface order, is de-duplicated, and is capped.
|
|
174
|
-
*/
|
|
175
|
-
export function buildRouteLists(nets: InterfaceTable, port: number): { lan: RouteEntry[]; tailscale: RouteEntry[] } {
|
|
176
|
-
const lan: RouteEntry[] = []
|
|
177
|
-
const tailscale: RouteEntry[] = []
|
|
178
|
-
const seen = new Set<string>()
|
|
179
|
-
for (const [name, infos] of Object.entries(nets)) {
|
|
180
|
-
for (const info of infos ?? []) {
|
|
181
|
-
if (!info || typeof info.address !== 'string' || !ipv4(info)) continue
|
|
182
|
-
const ip = info.address
|
|
183
|
-
if (seen.has(ip)) continue
|
|
184
|
-
if (isTailscaleIpv4(ip)) {
|
|
185
|
-
if (TAILSCALE_INTERFACE.test(name) && tailscale.length < LAN_ROUTES_MAX_PER_KIND) {
|
|
186
|
-
seen.add(ip)
|
|
187
|
-
tailscale.push({ ip, port, scheme: 'http' })
|
|
188
|
-
}
|
|
189
|
-
continue
|
|
190
|
-
}
|
|
191
|
-
if (isRfc1918Ipv4(ip) && !NOT_LAN_INTERFACE.test(name) && lan.length < LAN_ROUTES_MAX_PER_KIND) {
|
|
192
|
-
seen.add(ip)
|
|
193
|
-
lan.push({ ip, port, scheme: 'http' })
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return { lan, tailscale }
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
let readInterfaces: () => InterfaceTable = () => osNetworkInterfaces() as InterfaceTable
|
|
201
|
-
let cache: { at: number; port: number; lists: { lan: RouteEntry[]; tailscale: RouteEntry[] } } | null = null
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* The `routes` field for authenticated /api/models, or null to omit it. Re-read from the
|
|
205
|
-
* interfaces at most every 10 s, so a DHCP change shows up within one cache period. Never
|
|
206
|
-
* throws: a failed interface read omits the field and leaves the rest of the response alone.
|
|
207
|
-
*/
|
|
208
|
-
export function lanRoutesField(input: {
|
|
209
|
-
port: number
|
|
210
|
-
serverInstanceId: string | null
|
|
211
|
-
env?: NodeJS.ProcessEnv
|
|
212
|
-
now?: () => number
|
|
213
|
-
}): LanRoutes | null {
|
|
214
|
-
try {
|
|
215
|
-
if (!lanFirstEnabled(input.env) || !input.serverInstanceId) return null
|
|
216
|
-
const at = (input.now ?? Date.now)()
|
|
217
|
-
if (!cache || cache.port !== input.port || at - cache.at >= LAN_ROUTES_CACHE_MS || at < cache.at) {
|
|
218
|
-
cache = { at, port: input.port, lists: buildRouteLists(readInterfaces(), input.port) }
|
|
219
|
-
}
|
|
220
|
-
return {
|
|
221
|
-
v: LAN_ROUTES_PROTOCOL,
|
|
222
|
-
serverInstanceId: input.serverInstanceId,
|
|
223
|
-
learnedAt: new Date(cache.at).toISOString(),
|
|
224
|
-
lan: cache.lists.lan.map(r => ({ ...r })),
|
|
225
|
-
tailscale: cache.lists.tailscale.map(r => ({ ...r })),
|
|
226
|
-
}
|
|
227
|
-
} catch {
|
|
228
|
-
cache = null
|
|
229
|
-
return null
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function __setInterfaceReaderForTests(reader: (() => InterfaceTable) | null): void {
|
|
234
|
-
readInterfaces = reader ?? (() => osNetworkInterfaces() as InterfaceTable)
|
|
235
|
-
cache = null
|
|
236
|
-
}
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
// 6.88.0 LAN first: the pre-token identity proof (docs/lan-first.md).
|
|
2
|
-
//
|
|
3
|
-
// GET /api/route-proof?nonce=<22-64 base64url chars> is PUBLIC (an exact GET door in
|
|
4
|
-
// api-auth.ts), because it exists to prove which Mac answers at an address BEFORE the phone
|
|
5
|
-
// sends its token there. Rules, each with its reason:
|
|
6
|
-
// - The nonce is validated strictly (charset and length) and never echoed or logged.
|
|
7
|
-
// - The message is fixed: "cos-route-proof:v1:" + nonce + ":" + serverInstanceId. The caller
|
|
8
|
-
// picks only the nonce, so this is never a general HMAC oracle, and the prefix keeps it
|
|
9
|
-
// apart from the display-stream ticket, the only other HMAC keyed by the token.
|
|
10
|
-
// - The response carries the instance id (already on public /api/health as
|
|
11
|
-
// server_instance_id) and the HMAC. Never the token, never an address, never a version.
|
|
12
|
-
// - Served only while lib/lan-first.ts `routeProofAvailable` holds: switch on, instance id
|
|
13
|
-
// minted, token at least 32 characters (a proof would let a stranger test guesses of a
|
|
14
|
-
// short token offline).
|
|
15
|
-
// - Rate limited per socket address (never req.ip or X-Forwarded-For) plus a global ceiling.
|
|
16
|
-
// - Writes nothing, so it takes no lease and is served during a drain.
|
|
17
|
-
|
|
18
|
-
import { Router, type Request, type Response } from 'express'
|
|
19
|
-
import {
|
|
20
|
-
ROUTE_PROOF_PROTOCOL,
|
|
21
|
-
RouteProofRateLimiter,
|
|
22
|
-
computeRouteProof,
|
|
23
|
-
isValidRouteProofNonce,
|
|
24
|
-
lanFirstEnabled,
|
|
25
|
-
routeProofTokenUsable,
|
|
26
|
-
} from '../lib/lan-first.js'
|
|
27
|
-
|
|
28
|
-
export type RouteProofReason = 'bad_nonce' | 'rate_limited' | 'not_ready' | 'disabled' | 'method_not_allowed'
|
|
29
|
-
|
|
30
|
-
export const ROUTE_PROOF_REASON_STATUS: Record<RouteProofReason, number> = {
|
|
31
|
-
bad_nonce: 400,
|
|
32
|
-
disabled: 404,
|
|
33
|
-
method_not_allowed: 405,
|
|
34
|
-
rate_limited: 429,
|
|
35
|
-
not_ready: 503,
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface RouteProofRouterOptions {
|
|
39
|
-
/** Read per request, so a token change is honoured without a remount. */
|
|
40
|
-
token: () => string
|
|
41
|
-
serverInstanceId: () => string | null
|
|
42
|
-
env?: () => NodeJS.ProcessEnv
|
|
43
|
-
limiter?: RouteProofRateLimiter
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function send(res: Response, reason: RouteProofReason, retryAfterSeconds?: number): void {
|
|
47
|
-
if (retryAfterSeconds != null) res.setHeader('Retry-After', String(retryAfterSeconds))
|
|
48
|
-
res.setHeader('Cache-Control', 'no-store')
|
|
49
|
-
res.status(ROUTE_PROOF_REASON_STATUS[reason]).json({ v: ROUTE_PROOF_PROTOCOL, reason })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function createRouteProofRouter(options: RouteProofRouterOptions): Router {
|
|
53
|
-
const router = Router()
|
|
54
|
-
const limiter = options.limiter ?? new RouteProofRateLimiter()
|
|
55
|
-
const env = options.env ?? (() => process.env)
|
|
56
|
-
|
|
57
|
-
router.all('/route-proof', (req: Request, res: Response) => {
|
|
58
|
-
// The public door in api-auth.ts is GET only, so any other method has already been
|
|
59
|
-
// refused without a token. With a token it is still refused here: one shape, one verb.
|
|
60
|
-
if (req.method !== 'GET') {
|
|
61
|
-
res.setHeader('Allow', 'GET')
|
|
62
|
-
return send(res, 'method_not_allowed')
|
|
63
|
-
}
|
|
64
|
-
if (!lanFirstEnabled(env())) return send(res, 'disabled')
|
|
65
|
-
// Counted before the nonce check, so malformed requests spend the same budget.
|
|
66
|
-
const admission = limiter.admit(req.socket?.remoteAddress)
|
|
67
|
-
if (!admission.ok) return send(res, 'rate_limited', admission.retryAfterSeconds)
|
|
68
|
-
const nonce = req.query.nonce
|
|
69
|
-
if (!isValidRouteProofNonce(nonce)) return send(res, 'bad_nonce')
|
|
70
|
-
let token = ''
|
|
71
|
-
try { token = options.token() } catch { token = '' }
|
|
72
|
-
const serverInstanceId = options.serverInstanceId()
|
|
73
|
-
if (!serverInstanceId || !routeProofTokenUsable(token)) return send(res, 'not_ready', 30)
|
|
74
|
-
res.setHeader('Cache-Control', 'no-store')
|
|
75
|
-
res.json({
|
|
76
|
-
v: ROUTE_PROOF_PROTOCOL,
|
|
77
|
-
serverInstanceId,
|
|
78
|
-
proof: computeRouteProof(token, nonce, serverInstanceId),
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
return router
|
|
83
|
-
}
|