@gotcos/glasses-server 7.0.27 → 7.0.29
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/CHANGELOG.md +63 -0
- package/docs/web-key-verifier-contract.md +1 -1
- package/package.json +1 -1
- package/server/index.ts +29 -6
- package/server/lib/tailnet-self-name.ts +57 -10
- package/server/lib/web-key-signer-app.ts +25 -6
- package/server/lib/web-key-signer-tls.ts +790 -0
- package/server/routes/web-key.ts +31 -2
- package/server/web-control/.manifest.json +4 -4
- package/server/web-control/app.css +54 -0
- package/server/web-control/app.js +317 -36
- package/server/web-control/index.html +44 -5
- package/server/web-control/theme.js +169 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
+
## 7.0.29 - Control Web: on the folded Duo, the panes sit in a side rail at the right edge, and Back is at thumb height
|
|
2
|
+
|
|
3
|
+
2026-11-12. 7.0.28 plus Control Web changes: a side rail for the folded Duo only, and small Sessions list changes for every screen.
|
|
4
|
+
|
|
5
|
+
- **A side rail on the folded Duo.** Today, Messages, Speakers, Meetings, Memories, Threads, Sessions, Work, Dictation and Settings are one tap away in a slim rail at the right edge of the page, with the current one marked. Before, they sat behind the pane name's menu at the top left. The rail is the same list, in the same order, with the same icons and dots, as the rail on a wide screen.
|
|
6
|
+
- **Back at thumb height.** Where the top bar showed Back (a session or a message you are reading), Back now sits at the foot of the rail instead. If the panes do not fit, they scroll inside the rail and Back stays put.
|
|
7
|
+
- **One way to switch panes.** While the rail shows, the pane name at the top left stays as a label, without its arrow and its menu.
|
|
8
|
+
- **The page never reloads by itself over what you typed.** Since 7.0.28 the page reloads itself when part of it failed to load and your Mac answers again. Now, while a text box holds words, you are typing in one, or a message is on its way, it shows one line instead: "Part of this page did not load." with a Reload button, and adds "Send or copy what you have typed first. A reload clears it." when a box holds words. Only your tap reloads. If the browser keeps no record of earlier reloads (storage blocked), it shows the same line rather than reloading. This is for every screen size.
|
|
9
|
+
- **Words you typed are kept when you leave a session.** Back, the rail or another pane no longer empties the reply box: open the session again in the same tab and your unsent words are there. Sent words do not come back, and words from one session never land in another's box. This is for every screen size (it was lost before on every size too).
|
|
10
|
+
- **Folding or turning the phone updates it.** The page checks the screen again whenever its size or orientation changes, so opening or folding the Duo shows or hides the rail without a reload.
|
|
11
|
+
- **The reply box never cuts its words.** Where the rail leaves the reply box too narrow for "Message this session (not an answer)", it says "Message (not an answer)", and an empty box is always tall enough for its words.
|
|
12
|
+
- **Buttons are 44 points on your phone.** Under Safari's Page Zoom the page is laid out wider than the screen draws it, so the rail sizes its buttons from the zoom: 52 px at 85 percent, 44 px at 100.
|
|
13
|
+
- **Who gets it:** a touch screen, a page under 600 px wide, and a screen whose long side is less than 1.6 times its short side. The folded Duo is 1.46. Every iPhone is 1.78 or more, so none gets the rail, in the Home Screen app or a Safari tab, keyboard up or down. An iPhone SE (375 by 667) keeps today's layout. Nothing changes at 600 px and wider, so the open Duo is unchanged.
|
|
14
|
+
- **What it costs:** the rail takes 58 of 449 layout px at 85 percent zoom (49 points), 50 of 382 at 100. A long session title shows about one character fewer at 85 percent and seven fewer at 100. Wide tables in a session still scroll inside their own box, never the page.
|
|
15
|
+
- The page's existing layout report says whether the rail showed and its button size, so the phone tells the Mac what it saw.
|
|
16
|
+
- **Sessions: Pinned folds.** The Pinned heading folds and unfolds like a folder, with its count, and this browser remembers the choice. It starts open, so nothing changes until you fold it. A pinned session that is working still shows under Working now, and one that is waiting or failed under Needs you, folded or not; it goes back under Pinned when it is idle. Needs you and Working now never fold. This is for every screen size.
|
|
17
|
+
- **Sessions: sending into a session that just changed.** Before, a busy session stopped almost every message with "This thread changed on your Mac" and Refresh, Continue anyway and Fork, and Refresh did not help. Now, when you press Send and the session changed only in ways you had already seen, your message goes through by itself. When a reply arrived that you had not seen (one that landed below where you were reading counts as not seen), or the session is now waiting on a question, the reply is shown, your words stay in the box, and it says so: "A new reply arrived while you were writing. Your message is not sent yet." with "Send it anyway" and "Send to a copy". It sends by itself at most once. A message waiting to send, an answer to a question card, and Today's panel work as before. This is for every screen size.
|
|
18
|
+
- **Sessions: Remove from folder.** A session in a folder now has Remove from folder in its menu, right under Move to folder. Before, the only way out was Move to folder, then No folder at the bottom of that list, which still works. This is for every screen size.
|
|
19
|
+
- Tested in a browser (WebKit) at the Duo's folded sizes (449 by 797 at 85 percent zoom, 382 by 678, and a shorter Safari tab), an iPhone SE, 16 and Pro Max (the Pro Max also in a tab), and the open Duo, in light and dark, on the Sessions list, a session, Today, Messages and with the keyboard up. Where the rail does not show, every element sits where it did in 7.0.28. Not yet tested on a real device.
|
|
20
|
+
|
|
21
|
+
**Default-on for every install:** yes, with no switch: the rail only on a screen of that shape; the folding Pinned heading everywhere, open until folded; Remove from folder, the kept reply words, the changed-session send and no reload over typed words everywhere.
|
|
22
|
+
|
|
23
|
+
### If you roll back
|
|
24
|
+
|
|
25
|
+
- To 7.0.28: the folded Duo goes back to the pane menu at the top left and Back in the top bar; Pinned no longer folds; Remove from folder goes (No folder still works); words in the reply box are lost on Back again; a changed session stops every send with the old box; the page can reload itself over what you typed again. The only thing stored is the Pinned fold choice, in the browser, which 7.0.28 does not read. Unsent words are kept in the tab's memory only.
|
|
26
|
+
- Further back: as in 7.0.28 below.
|
|
27
|
+
|
|
28
|
+
## 7.0.28 - Control Web: the sign-in screen is styled even before the page can reach your Mac, and the page fixes itself when it can; page errors are reported; phone approvals get a trusted certificate (off by default)
|
|
29
|
+
|
|
30
|
+
2026-11-11. 7.0.27 plus two Control Web changes, and server code for phone approvals that stays off unless you turn it on.
|
|
31
|
+
|
|
32
|
+
- **No more unstyled "Connect this browser" page.** Opening the web app before your phone could reach your Mac (Tailscale still connecting, or the Mac waking) could draw the page without its stylesheets: a plain black or white page with default text and buttons, and the COS mark drawn large twice. It stayed that way, because nothing asked for the stylesheets again. Now the sign-in and "not answering" screens carry their own styling inside the page, so they look right from the first moment. While a stylesheet or script is missing, the page checks after 1, 2, 4 and 8 seconds, then every 30 seconds, and as soon as your Mac answers it loads what was missing, reloading itself if a script was missing (never more than twice in 5 minutes). The "not answering" screen also checks again after 1, 2, 4 and 8 seconds before settling on every 30 seconds.
|
|
33
|
+
- **Page errors are reported.** A script error or an unhandled failure in Control Web now sends one short, numbers-only report to your Mac (what kind, the file, the line), at most 10 per page load. It never includes what you typed, a message or any text from the page. A page that had to bring back its styling reports that too, the same way.
|
|
34
|
+
- Tested in a browser with the page's files really blocked and then unblocked, at the open and folded phone sizes, in light and dark: 7.0.27 shows the unstyled page and stays broken, and this build shows the styled screen and recovers within seconds. The Sessions list, the reader and the reply box are unchanged.
|
|
35
|
+
|
|
36
|
+
**Phone approvals: the phone's signer page gets a publicly trusted certificate (off by default).** Every change sits behind the two phone-approval switches, `COS_WEB_KEY_AUTHORITY` and `COS_WEB_KEY_ANCHOR_BOOTSTRAP`, both off by default.
|
|
37
|
+
|
|
38
|
+
- **Both switches off (every install by default): nothing changes.** No Tailscale call, no new file, no new listener, no new status field. 3141 and 3143 serve exactly what they served before.
|
|
39
|
+
- **Only `COS_WEB_KEY_ANCHOR_BOOTSTRAP` on (the Mac-key setup):** if this Mac's tailnet name was missed at startup, the authenticated `/api/web-key/status` read looks it up again. That is one DNS lookup through Tailscale, at most once per 30 seconds, waiting at most 2 seconds. Before, it answered `origin: null` until a restart.
|
|
40
|
+
- **With `COS_WEB_KEY_AUTHORITY` on:**
|
|
41
|
+
- **The certificate.** The signer page on 3144 gets a certificate for this Mac's own tailnet name from Tailscale's HTTPS Certificates (Let's Encrypt). The server fetches it, keeps it in `web-key-tls/` in its data folder (key 0600, folder 0700), and shows it only to a connection that names exactly that host. It renews when fewer than 30 days remain. Any other name, no name, or an IP keeps the existing local certificate. 3143 never changes.
|
|
42
|
+
- **What `trusted` means.** `signerTls.trusted` means **publicly trusted**. The certificate chains, link by link, to one of Node's bundled public root certificates. It is inside its validity window, notBefore included. Its key matches the certificate (the public keys are compared, because a mismatched key type is not otherwise caught). A pair that fails any of these is never served and never stops the server from fetching the real one. Each refusal is named: `cert_untrusted`, `cert_not_yet_valid`, `cert_invalid`, `cert_expired`.
|
|
43
|
+
- **The fetch works on macOS.** Every Tailscale command the server ran used to answer "The Tailscale GUI failed to start", because a process started by the server's LaunchAgent has no SHLVL. The certificate commands now run with a small fixed env: HOME, PATH, TMPDIR, USER and LANG when set, plus SHLVL=1. They never see the server's own env. If that answer ever comes back, the status says `tailscale_cli_gui_unavailable`. The status call asks without peers, so a large tailnet cannot overflow it; an overflow says `tailscale_output_too_large`.
|
|
44
|
+
- **Bounded against Let's Encrypt's limit.** At most 3 `tailscale cert` calls per UTC day, so up to 21 in a week, and up to 6 within minutes across midnight UTC. Each call is counted on disk, in `web-key-tls/fetch-state.json`, before it runs, and the count survives a restart. If that file is missing or unreadable, the count starts again at 0. Over the cap the status says `cert_daily_cap`.
|
|
45
|
+
- **Turning on HTTPS Certificates is picked up within seconds.** An authenticated status read re-checks at once (at most every 15 s) when the last check said HTTPS Certificates was off or the name was unknown. It no longer waits out a backoff of up to an hour.
|
|
46
|
+
- **A certificate put in that folder by hand** serves at the next connection or status read, with no restart. It passes exactly the same checks. The folder and both files must be owned by this user and must not be writable by group or others; the folder must be a real folder and each file a regular file, not a link, each at most 64 KiB. Read permission for group or others is not checked. A file that breaks a rule is refused, and the status names why in `signerTls.storedRefused`.
|
|
47
|
+
- **After a tailnet rename,** 3144 no longer answers the old name with the old certificate.
|
|
48
|
+
- **`/api/web-key/status` reports `signerTls`:** trusted, reason, host, expiry, the last error, the last and next check, and `storedRefused`.
|
|
49
|
+
- **Needs before it can work:** Tailscale HTTPS Certificates turned on for the tailnet, in the Tailscale admin console. The Mac's name then appears in public certificate logs. Real issuance is limited to 5 per week for the same name.
|
|
50
|
+
- **Not changed:** Tailscale's link check (tailscale-nudge) has the same SHLVL gap and is left as it is. Fixing it changes behaviour for everyone and needs Miles's go.
|
|
51
|
+
- **Not yet tested on a real device:** a real `tailscale cert`, iPhone Safari against the Let's Encrypt certificate, and COS Control's signer check against it.
|
|
52
|
+
|
|
53
|
+
**Default-on for every install:** the two Control Web changes, yes, with no switch. The phone-approval code, no: both of its switches are off by default.
|
|
54
|
+
|
|
55
|
+
### If you roll back
|
|
56
|
+
|
|
57
|
+
- To 7.0.27: an open that cannot reach your Mac can show the unstyled page again until you close and reopen it, and page errors are no longer reported. With the phone-approval switches off, nothing else changes and nothing is stored.
|
|
58
|
+
- Phone approvals, if you had turned them on:
|
|
59
|
+
- **Both switches off:** nothing to undo.
|
|
60
|
+
- **Bootstrap on:** a startup miss of the tailnet name sticks until a restart again.
|
|
61
|
+
- **Authority on:** 3144 goes back to the local certificate, so iPhone Safari refuses the signer page. `web-key-tls/` (the pair and `fetch-state.json`) stays on disk, unread.
|
|
62
|
+
- To 7.0.18: as in 7.0.27 below.
|
|
63
|
+
|
|
1
64
|
## 7.0.27 - Control Web: the Sessions list fits its column at every width
|
|
2
65
|
|
|
3
66
|
2026-11-11. 7.0.26 plus one Control Web layout fix.
|
|
@@ -47,7 +47,7 @@ canonical, so one message has exactly one signable form.
|
|
|
47
47
|
`signer_origin_required` unless the request reached the 3144 listener (socket local port). A page on 3143
|
|
48
48
|
can neither make a key nor fetch a handoff.
|
|
49
49
|
- **rpId:** `selfDnsName`, the full lower-case MagicDNS host, no trailing dot (for example
|
|
50
|
-
`
|
|
50
|
+
`example-mac.tail00000a.ts.net`; `ts.net` is a public suffix). The verifier derives it as
|
|
51
51
|
the host of `envelope.origin`.
|
|
52
52
|
- **aud:** `hex(sha256(anchor SPKI DER || utf8(lower(rpId))))`. The verifier computes it from ITS
|
|
53
53
|
pinned anchor, never from a key in the answer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.29",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -245,6 +245,7 @@ import { webControlOffnetResponse } from './lib/web-control-offnet-page.js'
|
|
|
245
245
|
import { createWebControlStatic } from './lib/web-control-static.js'
|
|
246
246
|
import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
|
|
247
247
|
import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
|
|
248
|
+
import { createSignerTlsForBoot, signerHttpsOptions, SIGNER_TLS_DIR, type SignerTls } from './lib/web-key-signer-tls.js'
|
|
248
249
|
import { isManagedRuntime, managedServerVersion } from './lib/managed-runtime.js'
|
|
249
250
|
import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
|
|
250
251
|
import {
|
|
@@ -440,6 +441,10 @@ const webKeySignHandoffs = new WebKeySignHandoffs()
|
|
|
440
441
|
const webKeySignerIntegrity: WebControlIntegrity | null = webKeyAuthorityEnabled()
|
|
441
442
|
? new WebControlIntegrity(path.join(path.dirname(fileURLToPath(import.meta.url)), 'web-signer'), { log: line => console.error(line.replace('[web-control-integrity]', '[web-signer-integrity]').replace('/control is', 'the 3144 signer is')) })
|
|
442
443
|
: null
|
|
444
|
+
// B2 (QA 2026-11-09): the 3144 signer's trusted Tailscale certificate (lib/web-key-signer-tls.ts). Built in the
|
|
445
|
+
// listener loop below ONLY when the 3144 listener is (flag on at boot, HTTPS available); otherwise null, and no
|
|
446
|
+
// Tailscale call, file or listener change happens. 3143 is never given SNI selection.
|
|
447
|
+
let webKeySignerTls: SignerTls | null = null
|
|
443
448
|
// Review W6: enrollment starts only from a device on COS_WEB_KEY_DEVICES (empty: none).
|
|
444
449
|
const webKeyEnrollments = new WebKeyEnrollments(webKeyRegistry, { deviceAllowed: deviceAllowlistFrom() })
|
|
445
450
|
// The ports a COS Control Web cookie is honoured on. Flag off: exactly 6.99.29's [3141, 3143].
|
|
@@ -490,8 +495,10 @@ const pairingState = new PairingState({
|
|
|
490
495
|
// address as a paired phone. Notes only, never answers; noteRequest never throws.
|
|
491
496
|
const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
|
|
492
497
|
// COS Control Web: this Mac's MagicDNS name, from the nudger when it has one and otherwise
|
|
493
|
-
// from a reverse lookup at MagicDNS (the CLI
|
|
494
|
-
//
|
|
498
|
+
// from a reverse lookup at MagicDNS (the nudger's Tailscale CLI calls fail under this LaunchAgent:
|
|
499
|
+
// node passes its child no SHLVL, so the macOS CLI answers "The Tailscale GUI failed to start";
|
|
500
|
+
// probe 2026-11-10, see lib/tailnet-self-name.ts and lib/web-key-signer-tls.ts signerTlsCliEnv).
|
|
501
|
+
// Learned at boot so the first phone sign-in is not refused.
|
|
495
502
|
const tailnetSelf = installTailnetSelfName({ nudgerName: () => tailscaleNudger.currentSelfDnsName() })
|
|
496
503
|
if (webControlEnabled()) void tailnetSelf.refresh()
|
|
497
504
|
// Browser sessions sign this Mac's session epoch; "Sign out every browser" bumps it.
|
|
@@ -1002,8 +1009,12 @@ app.use('/api', createWebKeyRouter({
|
|
|
1002
1009
|
isWebRequest: req => g2Provenance.originOf(req).origin.client === 'web',
|
|
1003
1010
|
apiToken: () => API_TOKEN,
|
|
1004
1011
|
selfDnsName: webKeySelfDnsName,
|
|
1012
|
+
// 7.0.14: a boot-time miss of the tailnet name is looked up again (bounded) instead of answering `origin: null`.
|
|
1013
|
+
refreshSelfDnsName: () => tailnetSelf.refreshIfMissing(),
|
|
1005
1014
|
expectedOrigin: () => webKeyAuthority.deps.expectedOrigin(),
|
|
1006
1015
|
integrityStatus: () => ({ ...(webControlIntegrity?.status() ?? { enabled: false }), signer: webKeySignerIntegrity?.status() ?? null }),
|
|
1016
|
+
// SR-04: an authenticated status read may start one bounded re-check (lib/web-key-signer-tls.ts statusRead).
|
|
1017
|
+
signerTls: () => webKeySignerTls?.statusRead() ?? null,
|
|
1007
1018
|
// Phase 3: the phone's key steps and the handoff claim answer only on the 3144 signer listener.
|
|
1008
1019
|
isSignerRequest: req => req.socket?.localPort === WEB_KEY_SIGNER_PORT,
|
|
1009
1020
|
handoffs: webKeySignHandoffs,
|
|
@@ -1600,6 +1611,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
1600
1611
|
stopMorningBriefScheduler()
|
|
1601
1612
|
stopRoutineScheduler()
|
|
1602
1613
|
tailscaleNudger.stop()
|
|
1614
|
+
webKeySignerTls?.stop()
|
|
1603
1615
|
await workReviewRuntime?.close()
|
|
1604
1616
|
dropP2.stop()
|
|
1605
1617
|
workIntakeStore?.close()
|
|
@@ -1742,16 +1754,27 @@ if (!httpsAvailable) console.log('[COS API] No certs found — HTTPS disabled (d
|
|
|
1742
1754
|
for (const entry of listenerPlan({ httpsAvailable, webKeyAuthority: webKeyAuthorityEnabled(), httpPort: PORT, httpsPort: HTTPS_PORT, signerPort: WEB_KEY_SIGNER_PORT })) {
|
|
1743
1755
|
// Phase 3: 3144 serves the signer page (verified bytes only) and an allowlist of /api routes,
|
|
1744
1756
|
// never the big app (lib/web-key-signer-app.ts). It exists only with the flag on.
|
|
1745
|
-
const
|
|
1746
|
-
|
|
1757
|
+
const signer = entry.label === 'HTTPS signer'
|
|
1758
|
+
const handler = signer
|
|
1759
|
+
? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()), refreshOrigin: () => tailnetSelf.refreshIfMissing() })
|
|
1747
1760
|
: app
|
|
1748
|
-
|
|
1749
|
-
|
|
1761
|
+
// B2: on 3144 only, the trusted Tailscale pair for exactly this Mac's tailnet name (SNI), mkcert for anything else.
|
|
1762
|
+
if (signer) {
|
|
1763
|
+
webKeySignerTls = createSignerTlsForBoot({ authorityEnabled: webKeyAuthorityEnabled(), signerListener: true }, {
|
|
1764
|
+
dir: dataPath(SIGNER_TLS_DIR),
|
|
1765
|
+
selfDnsName: () => tailnetSelf.refresh(),
|
|
1766
|
+
})
|
|
1767
|
+
}
|
|
1768
|
+
const mkcertPair = entry.tls ? { cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) } : null
|
|
1769
|
+
const server = mkcertPair
|
|
1770
|
+
? withRequestTimeout(createHttpsServer(signer ? signerHttpsOptions(mkcertPair, webKeySignerTls) : mkcertPair, handler))
|
|
1750
1771
|
: withRequestTimeout(createHttpServer(app))
|
|
1751
1772
|
listeners.push({ server, port: entry.port, host: BIND_HOST, label: entry.label })
|
|
1752
1773
|
}
|
|
1753
1774
|
|
|
1754
1775
|
listenRequiredServers(listeners).then(() => {
|
|
1776
|
+
// B2: the boot check of the 3144 certificate, then daily (null unless the signer listener exists).
|
|
1777
|
+
webKeySignerTls?.start()
|
|
1755
1778
|
const serverInstanceId = initializeServerInstanceId()
|
|
1756
1779
|
const startupAdmissionsOpen = maintenanceAdmissionsOpen()
|
|
1757
1780
|
if (listeners.some(listener => listener.label === 'HTTPS')) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// This Mac's own Tailscale MagicDNS name, for COS Control Web's origin checks.
|
|
2
2
|
//
|
|
3
3
|
// WHY NOT THE TAILSCALE CLI. Under the server's LaunchAgent the `tailscale status --json` call
|
|
4
|
-
// the nudger makes
|
|
5
|
-
//
|
|
4
|
+
// the nudger makes fails: node passes its child no SHLVL, and the macOS CLI then answers "The
|
|
5
|
+
// Tailscale GUI failed to start" instead of the JSON (probe 2026-11-10; server.log's "Tailscale did
|
|
6
|
+
// not answer for 120s" is the event's age, not a hang). So the nudger never learns Self.DNSName and a phone at
|
|
6
7
|
// http://<mac>.<tailnet>.ts.net:3141/control was refused by CORS on every write (6.99.1).
|
|
7
8
|
// MagicDNS itself answers a reverse lookup of this Mac's own 100.x address at 100.100.100.100
|
|
8
9
|
// in about a millisecond, with no CLI involved. The nudger's answer still wins when it has one.
|
|
@@ -10,6 +11,13 @@
|
|
|
10
11
|
// EXACT NAME ONLY. The answer is normalised by normalizeTailscaleDnsName (lowercase, one
|
|
11
12
|
// trailing dot dropped, must end in .ts.net) and compared for equality; never a suffix match.
|
|
12
13
|
// No answer means no name: the tailnet NAME is refused and the tailnet IP URL keeps working.
|
|
14
|
+
//
|
|
15
|
+
// A MISS IS NOT FINAL (7.0.14). The boot lookup can miss (a startup race), and readers that only
|
|
16
|
+
// call currentSelfDnsName() then saw null forever: /api/web-key/status answered `origin: null` (COS
|
|
17
|
+
// Control hid Phone approvals) and the 3144 signer refused every phone write `origin_refused`, until
|
|
18
|
+
// some other route happened to call refresh(). refreshIfMissing() is for exactly those readers: with
|
|
19
|
+
// a name known it returns it and looks nothing up; with none it joins the lookup in flight or starts
|
|
20
|
+
// ONE, at most once per TAILNET_NAME_ON_DEMAND_MIN_MS, and waits at most a bounded time.
|
|
13
21
|
|
|
14
22
|
import { Resolver } from 'node:dns/promises'
|
|
15
23
|
import { networkInterfaces } from 'node:os'
|
|
@@ -18,6 +26,10 @@ import { normalizeTailscaleDnsName, reachableIpv4Addresses } from './network-pol
|
|
|
18
26
|
export const TAILNET_NAME_HIT_TTL_MS = 10 * 60_000
|
|
19
27
|
export const TAILNET_NAME_MISS_TTL_MS = 60_000
|
|
20
28
|
export const TAILNET_REVERSE_TIMEOUT_MS = 1_500
|
|
29
|
+
/** refreshIfMissing(): while the name is unknown, at most one lookup it starts per this window. */
|
|
30
|
+
export const TAILNET_NAME_ON_DEMAND_MIN_MS = 30_000
|
|
31
|
+
/** refreshIfMissing(): the longest a request waits for that lookup (the lookup itself is bounded by the resolver timeout). */
|
|
32
|
+
export const TAILNET_NAME_ON_DEMAND_WAIT_MS = 2_000
|
|
21
33
|
const MAGIC_DNS_RESOLVER = '100.100.100.100'
|
|
22
34
|
|
|
23
35
|
export interface TailnetSelfNameDeps {
|
|
@@ -37,6 +49,13 @@ export interface TailnetSelfName {
|
|
|
37
49
|
refresh(): Promise<string | null>
|
|
38
50
|
/** `SelfDnsSource` for isAllowedCorsOrigin: same as refresh(). */
|
|
39
51
|
queryStatus(): Promise<string | null>
|
|
52
|
+
/**
|
|
53
|
+
* For a request that must not act on a stale miss (7.0.14). A name known now: that name, no lookup.
|
|
54
|
+
* None: joins the lookup in flight, or starts one unless this method started one in the last
|
|
55
|
+
* TAILNET_NAME_ON_DEMAND_MIN_MS (then null at once). Waits at most `waitMs`; a slower lookup keeps
|
|
56
|
+
* running and caches its answer for the next reader. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
refreshIfMissing(waitMs?: number): Promise<string | null>
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
function defaultTailscaleIpv4(): string | null {
|
|
@@ -62,13 +81,8 @@ export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSe
|
|
|
62
81
|
|
|
63
82
|
const currentSelfDnsName = (): string | null => fromNudger() ?? cached?.name ?? null
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (known) return Promise.resolve(known)
|
|
68
|
-
const at = now()
|
|
69
|
-
if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
|
|
70
|
-
return Promise.resolve(cached.name)
|
|
71
|
-
}
|
|
84
|
+
// The one lookup (single-flight): every caller below shares the promise in flight.
|
|
85
|
+
const lookup = (): Promise<string | null> => {
|
|
72
86
|
if (inflight) return inflight
|
|
73
87
|
inflight = (async () => {
|
|
74
88
|
let name: string | null = null
|
|
@@ -88,7 +102,40 @@ export function createTailnetSelfName(deps: TailnetSelfNameDeps = {}): TailnetSe
|
|
|
88
102
|
return inflight
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
const refresh = (): Promise<string | null> => {
|
|
106
|
+
const known = fromNudger()
|
|
107
|
+
if (known) return Promise.resolve(known)
|
|
108
|
+
const at = now()
|
|
109
|
+
if (cached && at - cached.at < (cached.name ? TAILNET_NAME_HIT_TTL_MS : TAILNET_NAME_MISS_TTL_MS)) {
|
|
110
|
+
return Promise.resolve(cached.name)
|
|
111
|
+
}
|
|
112
|
+
return lookup()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// When refreshIfMissing() last STARTED a lookup. Its own clock: the boot lookup and refresh()'s TTL
|
|
116
|
+
// do not count, so the first reader after a boot miss looks again at once.
|
|
117
|
+
let onDemandAt: number | null = null
|
|
118
|
+
const refreshIfMissing = (waitMs: number = TAILNET_NAME_ON_DEMAND_WAIT_MS): Promise<string | null> => {
|
|
119
|
+
const known = currentSelfDnsName()
|
|
120
|
+
if (known) return Promise.resolve(known)
|
|
121
|
+
let pending: Promise<string | null>
|
|
122
|
+
if (inflight) {
|
|
123
|
+
pending = inflight
|
|
124
|
+
} else {
|
|
125
|
+
const at = now()
|
|
126
|
+
if (onDemandAt !== null && at - onDemandAt < TAILNET_NAME_ON_DEMAND_MIN_MS) return Promise.resolve(null)
|
|
127
|
+
onDemandAt = at
|
|
128
|
+
pending = lookup()
|
|
129
|
+
}
|
|
130
|
+
const wait = Math.max(0, waitMs)
|
|
131
|
+
return new Promise<string | null>(resolve => {
|
|
132
|
+
const timer = setTimeout(() => resolve(null), wait)
|
|
133
|
+
timer.unref?.()
|
|
134
|
+
pending.then(name => { clearTimeout(timer); resolve(name) }, () => { clearTimeout(timer); resolve(null) })
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { currentSelfDnsName, refresh, queryStatus: refresh, refreshIfMissing }
|
|
92
139
|
}
|
|
93
140
|
|
|
94
141
|
let singleton: TailnetSelfName | null = null
|
|
@@ -92,6 +92,13 @@ export interface WebKeySignerDeps {
|
|
|
92
92
|
api: (req: IncomingMessage, res: ServerResponse) => void
|
|
93
93
|
/** `https://<MagicDNS name>:3144` (lib/web-key-origin.ts), or null when unknown. */
|
|
94
94
|
origin: () => string | null
|
|
95
|
+
/**
|
|
96
|
+
* 7.0.14: called (and awaited) only when origin() is null on an /api request, before the origin
|
|
97
|
+
* check, so a boot-time miss of the tailnet name does not refuse the phone `origin_refused` until
|
|
98
|
+
* something else refreshes it (lib/tailnet-self-name.ts refreshIfMissing: bounded, single-flight,
|
|
99
|
+
* rate-limited). Absent: the null origin stands, as before.
|
|
100
|
+
*/
|
|
101
|
+
refreshOrigin?: () => Promise<unknown>
|
|
95
102
|
/** How often the signer's files are re-checked. 0: never on a timer (tests call integrity.check()). */
|
|
96
103
|
recheckMs?: number
|
|
97
104
|
}
|
|
@@ -128,13 +135,25 @@ export function createWebKeySignerHandler(deps: WebKeySignerDeps): ((req: Incomi
|
|
|
128
135
|
const method = String(req.method ?? 'GET').toUpperCase()
|
|
129
136
|
|
|
130
137
|
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
138
|
+
const decide = (expected: string | null): void => {
|
|
139
|
+
const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
|
|
140
|
+
// Another origin's page never drives this one. A write must name this origin outright.
|
|
141
|
+
if (origin !== undefined && origin !== expected) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
|
|
142
|
+
if (method !== 'GET' && method !== 'HEAD' && (expected === null || origin !== expected)) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
|
|
143
|
+
if (!signerApiAllowed(method === 'HEAD' ? 'GET' : method, pathname)) { json(res, 404, { ok: false, error: 'not_found' }); return }
|
|
144
|
+
deps.api(req, res)
|
|
145
|
+
}
|
|
131
146
|
const expected = deps.origin()
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
147
|
+
if (expected !== null || !deps.refreshOrigin) { decide(expected); return }
|
|
148
|
+
// Name unknown: one bounded lookup, then the same checks against whatever it found (null still refuses).
|
|
149
|
+
let refreshing: Promise<unknown>
|
|
150
|
+
try { refreshing = Promise.resolve(deps.refreshOrigin()) } catch { refreshing = Promise.resolve() }
|
|
151
|
+
const after = (): void => {
|
|
152
|
+
let found: string | null = null
|
|
153
|
+
try { found = deps.origin() } catch { found = null }
|
|
154
|
+
decide(found)
|
|
155
|
+
}
|
|
156
|
+
void refreshing.then(after, after)
|
|
138
157
|
return
|
|
139
158
|
}
|
|
140
159
|
|