@tequila99/githuman 0.9.0 → 0.10.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/README.md +40 -9
- package/dist/cli/config.js +35 -2
- package/dist/cli/index.js +2 -1
- package/dist/cli/server-runtime.js +14 -11
- package/dist/cli/startup-message.js +5 -4
- package/dist/server/app.js +48 -5
- package/dist/server/db/migrations.js +19 -0
- package/dist/server/errors/devices.js +7 -0
- package/dist/server/errors/http.js +1 -0
- package/dist/server/hooks/access/local-request.js +21 -0
- package/dist/server/hooks/access/require-access.js +93 -0
- package/dist/server/hooks/agents/local-origin.js +9 -26
- package/dist/server/hooks/notes/same-origin.js +7 -34
- package/dist/server/hooks/terminal/local-origin.js +8 -22
- package/dist/server/repositories/device.repo.js +74 -0
- package/dist/server/routes/agent.js +11 -2
- package/dist/server/routes/devices.js +130 -0
- package/dist/server/routes/events.js +7 -1
- package/dist/server/routes/notes.js +3 -3
- package/dist/server/routes/terminal.js +10 -2
- package/dist/server/services/device-connections.js +79 -0
- package/dist/server/services/device.service.js +222 -0
- package/dist/server/utils/address-key.js +33 -0
- package/dist/server/utils/allowed-hosts.js +38 -0
- package/dist/server/utils/device-name.js +27 -0
- package/dist/server/utils/lan-addresses.js +35 -0
- package/dist/server/utils/request-origin.js +105 -0
- package/dist/shared/devices/constants.js +16 -0
- package/dist/shared/devices/name.js +12 -0
- package/dist/shared/devices/schemas.js +67 -0
- package/dist/shared/devices/types.js +1 -0
- package/dist/shared/events/types.js +2 -1
- package/dist/shared/network/address-url.js +4 -0
- package/dist/shared/utils/date.js +3 -0
- package/dist/web/assets/AccessPage-CAjilkAc.js +1 -0
- package/dist/web/assets/AccessPage-DntcW7f8.css +1 -0
- package/dist/web/assets/{ClosePopup-lpH_1Ju2.js → ClosePopup-p9VBZw3j.js} +1 -1
- package/dist/web/assets/{CreateButton-Dnpwobd9.js → CreateButton-B7-yiqQx.js} +2 -2
- package/dist/web/assets/CreateReviewFabButton-BJOAdPG1.js +1 -0
- package/dist/web/assets/IndexPage-B0itnrts.js +1 -0
- package/dist/web/assets/MainLayout-CP6Bz7DA.js +6 -0
- package/dist/web/assets/{MainLayout-DM30ynlR.css → MainLayout-vDaYlRjL.css} +1 -1
- package/dist/web/assets/{NoteVisualEditor-Bj13_oEl.js → NoteVisualEditor-oWUBSuDR.js} +2 -2
- package/dist/web/assets/NotesPage-Ou5XG64h.js +4 -0
- package/dist/web/assets/PanelResize-D1cRotAi.js +1 -0
- package/dist/web/assets/{PdfPreview-C7HomxEg.js → PdfPreview-DIgRK8Vh.js} +1 -1
- package/dist/web/assets/QExpansionItem-BouBPgdb.js +4 -0
- package/dist/web/assets/{QFab-C-YZWgmW.js → QFab-B5iz2kQR.js} +1 -1
- package/dist/web/assets/QForm-tsecXm87.js +1 -0
- package/dist/web/assets/QResizeObserver-gIKjGVQ7.js +1 -0
- package/dist/web/assets/{QScrollArea-BSSZoaV5.js → QScrollArea-BrOkfO1I.js} +1 -1
- package/dist/web/assets/{QSelect-2iOBkp1K.js → QSelect-B5EBNlJ4.js} +1 -1
- package/dist/web/assets/QSpace-CqqgY-zX.js +1 -0
- package/dist/web/assets/{QVirtualScroll-CZJMBpmR.js → QVirtualScroll-BoXSHM25.js} +1 -1
- package/dist/web/assets/ReviewDetailPage-C3NBR_xR.js +1 -0
- package/dist/web/assets/{ReviewsPage-RyST0FlH.js → ReviewsPage-Bkinx_Bm.js} +1 -1
- package/dist/web/assets/StatusSwitcher-8QUHj5Rq.js +1 -0
- package/dist/web/assets/{TerminalView-BAzJgUTq.js → TerminalView-elu1dIYU.js} +2 -2
- package/dist/web/assets/{abnfDiagram-O67JEVCF-BNZQ7duT.js → abnfDiagram-O67JEVCF-BMF1j0ZS.js} +1 -1
- package/dist/web/assets/access-store-Y2QO4AK-.js +1 -0
- package/dist/web/assets/{agent-store-DP6Vrw6L.js → agent-store-1i-UjEzZ.js} +46 -46
- package/dist/web/assets/architecture-7GRP2DOG-DcZ4-ijA.js +1 -0
- package/dist/web/assets/{architectureDiagram-NJMV4G6O-Bkj0kfFn.js → architectureDiagram-NJMV4G6O-Bz4u-gZC.js} +1 -1
- package/dist/web/assets/{blockDiagram-BEXU5L5S-CXO3zN2c.js → blockDiagram-BEXU5L5S-qQW37qVK.js} +1 -1
- package/dist/web/assets/{c4Diagram-YGBWAQC7-BHfMeZNf.js → c4Diagram-YGBWAQC7-BBwd76CZ.js} +1 -1
- package/dist/web/assets/channel-Cgf35HIt.js +1 -0
- package/dist/web/assets/{chunk-3FUC2YCW-z1WbH7Gi.js → chunk-3FUC2YCW-BnhnfUz2.js} +1 -1
- package/dist/web/assets/{chunk-5DYCD2WN-K9HPch97.js → chunk-5DYCD2WN-DdK6RzN0.js} +1 -1
- package/dist/web/assets/{chunk-742MDFTN-Bhjp9fC-.js → chunk-742MDFTN-e_JaY6d5.js} +1 -1
- package/dist/web/assets/{chunk-7INBJB4K-Do0EmU14.js → chunk-7INBJB4K-CQjEZWay.js} +1 -1
- package/dist/web/assets/{chunk-7M6MHVWA-veeEjsOh.js → chunk-7M6MHVWA-Dhx8Ycf8.js} +1 -1
- package/dist/web/assets/{chunk-7PRAP22T-CPqB5tCK.js → chunk-7PRAP22T-DDWxOzU1.js} +1 -1
- package/dist/web/assets/{chunk-GTNCS2PH-Civ1V52P.js → chunk-GTNCS2PH-B-ZscolZ.js} +1 -1
- package/dist/web/assets/{chunk-GWA4HPMP-C7LzAlyZ.js → chunk-GWA4HPMP-CboS0KEV.js} +1 -1
- package/dist/web/assets/{chunk-MBY4JIJT-DfmJy0o_.js → chunk-MBY4JIJT-CiI_FkjG.js} +1 -1
- package/dist/web/assets/{chunk-NETBCI7D-BZIKmMvL.js → chunk-NETBCI7D-D9ennL0_.js} +1 -1
- package/dist/web/assets/{chunk-O7XYJQB3-DcHeu6b7.js → chunk-O7XYJQB3-y25ZXdWk.js} +1 -1
- package/dist/web/assets/{chunk-UA2S7LBM-0SR2og6x.js → chunk-UA2S7LBM-BRHq7OMm.js} +1 -1
- package/dist/web/assets/{chunk-WEXAMYUT-CJMSoM-Z.js → chunk-WEXAMYUT-CQu7edQh.js} +1 -1
- package/dist/web/assets/{chunk-Z7XXMR3K-q6fce66x.js → chunk-Z7XXMR3K-B3dBNvo3.js} +1 -1
- package/dist/web/assets/{chunk-ZIGJFQKS-DsPzen4Y.js → chunk-ZIGJFQKS-CbyA33sL.js} +1 -1
- package/dist/web/assets/{classDiagram-v2-NBCMYWYE-Cpm_bvzb.js → classDiagram-v2-NBCMYWYE-CgFqOQ8k.js} +1 -1
- package/dist/web/assets/{cynefin-OW5HDTMX-ZTr5XZMW.js → cynefin-OW5HDTMX-DJ9N0QYa.js} +1 -1
- package/dist/web/assets/{cynefinDiagram-VND7K2PF-ByUmUx58.js → cynefinDiagram-VND7K2PF-DIWysrbV.js} +1 -1
- package/dist/web/assets/{dagre-6A5THRUB-mQOy0E3r.js → dagre-6A5THRUB-Dt2w_dKa.js} +1 -1
- package/dist/web/assets/devices-store-CtEl_8An.js +1 -0
- package/dist/web/assets/{diagram-22UHCM2B-BW0LPtyk.js → diagram-22UHCM2B-DYd638CD.js} +1 -1
- package/dist/web/assets/{diagram-3UASUU5V-jo148ec5.js → diagram-3UASUU5V-B0a-K-41.js} +1 -1
- package/dist/web/assets/{diagram-ATOU4E4O-CJt4F0i4.js → diagram-ATOU4E4O-oH2KoPKR.js} +1 -1
- package/dist/web/assets/{diagram-CDSNMT55-DEShJv56.js → diagram-CDSNMT55-B5DGBpGB.js} +1 -1
- package/dist/web/assets/{diagram-MLGK6HIB-DOFOADfs.js → diagram-MLGK6HIB-C-bsgsc0.js} +1 -1
- package/dist/web/assets/{diagram-MPIPVDR6-ux537BMl.js → diagram-MPIPVDR6-BrlKNYLc.js} +1 -1
- package/dist/web/assets/{dist-Bu9yRqTU.js → dist-B8irqXQ_.js} +1 -1
- package/dist/web/assets/{dist-CLv-grWO.js → dist-B9umI5Mp.js} +1 -1
- package/dist/web/assets/{dist-BbdrgL5V.js → dist-Bf4bAu7y.js} +1 -1
- package/dist/web/assets/{dist-BThMRW5U.js → dist-Bi2OWZ35.js} +1 -1
- package/dist/web/assets/{dist-CK1va5C_.js → dist-Bso6nKGK.js} +1 -1
- package/dist/web/assets/{dist-DL0dBIkt.js → dist-C-lnpukg.js} +1 -1
- package/dist/web/assets/{dist-D1EUrS6y.js → dist-CNHlSxJl.js} +1 -1
- package/dist/web/assets/{dist-Bo7anpPj.js → dist-CfIeEdHm.js} +1 -1
- package/dist/web/assets/{dist-Bvnostf5.js → dist-CxiJzNsC.js} +1 -1
- package/dist/web/assets/{dist-7xWvOKEv.js → dist-DMe9nqcB.js} +1 -1
- package/dist/web/assets/{dist-BWa3ecCV.js → dist-Dz9Y2yQi.js} +1 -1
- package/dist/web/assets/{ebnfDiagram-ZINNZB2B-DEybuZle.js → ebnfDiagram-ZINNZB2B-BEpTgssQ.js} +1 -1
- package/dist/web/assets/{elk-276RUBZZ-CZXZRpGb.js → elk-276RUBZZ-lzj6IMyM.js} +1 -1
- package/dist/web/assets/{erDiagram-OPXOYQCR-BgRqGAt5.js → erDiagram-OPXOYQCR-CHOMf2bq.js} +1 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-CyXHXiX_.js +1 -0
- package/dist/web/assets/flowDiagram-KWPJA3E3-C4espOge.js +1 -0
- package/dist/web/assets/{ganttDiagram-FUAMR5RP-CBJwX1gk.js → ganttDiagram-FUAMR5RP-TumKIsrB.js} +1 -1
- package/dist/web/assets/{gitGraph-4MIJSDKK-Dw-XA3G1.js → gitGraph-4MIJSDKK-H-1qnbZy.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-X574FWY7-BpGccoWp.js → gitGraphDiagram-X574FWY7-DRIXoFhM.js} +1 -1
- package/dist/web/assets/i18n-xttkoi4H.js +1 -0
- package/dist/web/assets/index-BQi8n0xV.js +2 -0
- package/dist/web/assets/{info-A6RAGUB7-Dj5RfJsg.js → info-A6RAGUB7-W0vgdn7h.js} +1 -1
- package/dist/web/assets/{infoDiagram-VRGFBTTK-DyVSH991.js → infoDiagram-VRGFBTTK-Dkb5juPW.js} +1 -1
- package/dist/web/assets/init-app-DlPQG9dK.js +1 -0
- package/dist/web/assets/{ishikawaDiagram-OU5B5YK6-Cu0n2oqT.js → ishikawaDiagram-OU5B5YK6-BHH8nvlH.js} +1 -1
- package/dist/web/assets/{journeyDiagram-ZHPQQLJL-Bz1Ot6Zg.js → journeyDiagram-ZHPQQLJL-DDyceAFf.js} +1 -1
- package/dist/web/assets/{kanban-definition-PNTS6WVX-C42vuY7H.js → kanban-definition-PNTS6WVX-DKE8pxuA.js} +1 -1
- package/dist/web/assets/{line-C5WKurbS.js → line-Cw6FtNmb.js} +1 -1
- package/dist/web/assets/{markdown-DBTEhbhQ.js → markdown-CIA-pqWB.js} +2 -2
- package/dist/web/assets/{mermaid-parser.core-B7l8qh8B.js → mermaid-parser.core-CWHLi3M4.js} +3 -3
- package/dist/web/assets/{mermaid.core-Drs2mjGg.js → mermaid.core-DbTjxMTH.js} +4 -4
- package/dist/web/assets/{mindmap-definition-NLK3R4M7-BV1FNwCn.js → mindmap-definition-NLK3R4M7-B3MzGWwm.js} +1 -1
- package/dist/web/assets/{packet-AYTQ26CC-BPmUY1Ig.js → packet-AYTQ26CC-B5qqn_tj.js} +1 -1
- package/dist/web/assets/{pegDiagram-GJSIUBJH-D6NPxPdr.js → pegDiagram-GJSIUBJH-DtcoRpgi.js} +1 -1
- package/dist/web/assets/{pie-WAS4IAKB-CTp2jNAc.js → pie-WAS4IAKB-BP-vRsnM.js} +1 -1
- package/dist/web/assets/{pieDiagram-5QR66LMP-BVkABrg7.js → pieDiagram-5QR66LMP-CYy6L6IC.js} +1 -1
- package/dist/web/assets/pinia-CJGnEjj3.js +1 -0
- package/dist/web/assets/{quadrantDiagram-O4NWA36T-LZCjVd08.js → quadrantDiagram-O4NWA36T-Hi2aHxKi.js} +1 -1
- package/dist/web/assets/{radar-RG4KPBEZ-CCiUrmsg.js → radar-RG4KPBEZ-BumlhyD5.js} +1 -1
- package/dist/web/assets/{railroad-74A4TZTK-CZn8VJbK.js → railroad-74A4TZTK-CROMzUe6.js} +1 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-C5G3FxcO.js +1 -0
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-BrTMLAtv.js +1 -0
- package/dist/web/assets/railroad-peg-WCYAUIDC-CXVDExl0.js +1 -0
- package/dist/web/assets/{railroadDiagram-XR7U4H2S-nopy-mdw.js → railroadDiagram-XR7U4H2S-De5-oSrU.js} +1 -1
- package/dist/web/assets/{requirementDiagram-PLB6GJNP-BOeMzf-x.js → requirementDiagram-PLB6GJNP-CBqteg6M.js} +1 -1
- package/dist/web/assets/{safe-storage-aRoyeFxv.js → safe-storage-BACMX75F.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-IPEJSGJF-Bbk0AQUe.js → sankeyDiagram-IPEJSGJF-DfKF1Ck-.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-PO4LG4MO-ZpubjKFc.js → sequenceDiagram-PO4LG4MO-D-sVWt3S.js} +1 -1
- package/dist/web/assets/single-flight-DLCbzLI3.js +1 -0
- package/dist/web/assets/{stateDiagram-v2-GCMORJYK-DNjDh9yZ.js → stateDiagram-v2-GCMORJYK-DruoQvcn.js} +1 -1
- package/dist/web/assets/{swimlanes-2SLR337P-C-_UzZF_.js → swimlanes-2SLR337P-BxFZLbJA.js} +1 -1
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-C3zKO6Gm.js +8 -0
- package/dist/web/assets/theme-B01ZDSW1.js +1 -0
- package/dist/web/assets/{timeline-definition-EJHVYXUP-BiaJyHdJ.js → timeline-definition-EJHVYXUP-BixIZZx_.js} +1 -1
- package/dist/web/assets/{treeView-Q6P3EWNA-CENjlerX.js → treeView-Q6P3EWNA-Nzi6VzwD.js} +1 -1
- package/dist/web/assets/{treemap-WGGIJYW6-CBpdf3L2.js → treemap-WGGIJYW6-DmAPjB44.js} +1 -1
- package/dist/web/assets/{use-add-agent-context-SApxn9fW.js → use-add-agent-context-BE_z8acF.js} +1 -1
- package/dist/web/assets/{use-app-theme-DP8Ymwwb.js → use-app-theme-C1KrIJLx.js} +1 -1
- package/dist/web/assets/use-countdown-2uTxlnFE.js +1 -0
- package/dist/web/assets/{use-panel-width-CTXD_csP.js → use-panel-width-DFOmK5Az.js} +1 -1
- package/dist/web/assets/use-server-events-BrBCbeHu.js +1 -0
- package/dist/web/assets/{usecaseDiagram-POWQR4AR-CcJ83_Ve.js → usecaseDiagram-POWQR4AR-BlNkvrZH.js} +1 -1
- package/dist/web/assets/{vennDiagram-UO4OBE2U-DBjzpbFr.js → vennDiagram-UO4OBE2U-BFjSeLM4.js} +1 -1
- package/dist/web/assets/{wardley-WFR3VGLG-DmlbUWUM.js → wardley-WFR3VGLG-DTs7R1tQ.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-VNRHLVJA-CWSZCqf4.js → wardleyDiagram-VNRHLVJA-BNMmP3TU.js} +1 -1
- package/dist/web/assets/window-store-CIFm3O3S.js +1 -0
- package/dist/web/assets/{xychartDiagram-PMCCYNJV-CMnNSaZv.js → xychartDiagram-PMCCYNJV-Bi24Uotp.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +3 -1
- package/dist/web/assets/CreateReviewFabButton-KvKPEJzI.js +0 -1
- package/dist/web/assets/IndexPage-DE-IkAhK.js +0 -1
- package/dist/web/assets/MainLayout-wWA8ckBg.js +0 -6
- package/dist/web/assets/NotesPage-FidBg83M.js +0 -4
- package/dist/web/assets/PanelResize-DY0Nzmql.js +0 -1
- package/dist/web/assets/QForm-DpEhYVCa.js +0 -4
- package/dist/web/assets/QResizeObserver-DeRFR_WF.js +0 -1
- package/dist/web/assets/QSpace-B5NneHAd.js +0 -1
- package/dist/web/assets/ReviewDetailPage-BI1iAhSf.js +0 -1
- package/dist/web/assets/StatusSwitcher-DAeNzNP7.js +0 -1
- package/dist/web/assets/app-info-store-u6ZVFjv5.js +0 -1
- package/dist/web/assets/architecture-7GRP2DOG-BSdnjp9Z.js +0 -1
- package/dist/web/assets/channel-DmoOM6AB.js +0 -1
- package/dist/web/assets/eventmodeling-NTZA5JFV-ZuRq6Sae.js +0 -1
- package/dist/web/assets/flowDiagram-KWPJA3E3-DRyHAdLb.js +0 -1
- package/dist/web/assets/i18n-sfJvmpVb.js +0 -1
- package/dist/web/assets/index-B2CzafCr.js +0 -2
- package/dist/web/assets/init-app-DSS44aRI.js +0 -1
- package/dist/web/assets/pinia-BVWaJ8NX.js +0 -1
- package/dist/web/assets/railroad-abnf-HS5TGJTU-B-9tG9pW.js +0 -1
- package/dist/web/assets/railroad-ebnf-LZEXJU2U-D7lUyGOq.js +0 -1
- package/dist/web/assets/railroad-peg-WCYAUIDC-ja94t32K.js +0 -1
- package/dist/web/assets/swimlanesDiagram-TC7HE7FX-CVSZNvS2.js +0 -8
- package/dist/web/assets/theme-CeQhGDjh.js +0 -1
- package/dist/web/assets/use-server-events-C13Sw-rU.js +0 -1
- package/dist/web/assets/window-store-DNnKUhxs.js +0 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function rowToRecord(row) {
|
|
2
|
+
return {
|
|
3
|
+
id: row.id,
|
|
4
|
+
name: row.name,
|
|
5
|
+
secretHash: row.secret_hash,
|
|
6
|
+
state: row.state,
|
|
7
|
+
code: row.code,
|
|
8
|
+
host: row.host,
|
|
9
|
+
address: row.address,
|
|
10
|
+
createdAt: row.created_at,
|
|
11
|
+
expiresAt: row.expires_at,
|
|
12
|
+
lastSeenAt: row.last_seen_at
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Reads the server id, or stores the one from `create` on first use. */
|
|
16
|
+
export function ensureServerId(db, create) {
|
|
17
|
+
const row = db.prepare('SELECT id FROM server_identity LIMIT 1').get();
|
|
18
|
+
if (row)
|
|
19
|
+
return row.id;
|
|
20
|
+
const id = create();
|
|
21
|
+
db.prepare('INSERT INTO server_identity (id) VALUES (?)').run(id);
|
|
22
|
+
return id;
|
|
23
|
+
}
|
|
24
|
+
export function insertDevice(db, device) {
|
|
25
|
+
db.prepare(`INSERT INTO devices (id, name, secret_hash, state, code, host, address, created_at, expires_at, last_seen_at)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(device.id, device.name, device.secretHash, device.state, device.code, device.host, device.address, device.createdAt, device.expiresAt, device.lastSeenAt);
|
|
27
|
+
}
|
|
28
|
+
export function findDeviceById(db, id) {
|
|
29
|
+
const row = db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
|
|
30
|
+
return row ? rowToRecord(row) : undefined;
|
|
31
|
+
}
|
|
32
|
+
export function findDeviceBySecretHash(db, secretHash) {
|
|
33
|
+
const row = db
|
|
34
|
+
.prepare('SELECT * FROM devices WHERE secret_hash = ?')
|
|
35
|
+
.get(secretHash);
|
|
36
|
+
return row ? rowToRecord(row) : undefined;
|
|
37
|
+
}
|
|
38
|
+
export function listDevices(db) {
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare('SELECT * FROM devices ORDER BY created_at')
|
|
41
|
+
.all();
|
|
42
|
+
return rows.map(rowToRecord);
|
|
43
|
+
}
|
|
44
|
+
export function countPendingDevices(db) {
|
|
45
|
+
const row = db
|
|
46
|
+
.prepare("SELECT COUNT(*) AS count FROM devices WHERE state = 'pending'")
|
|
47
|
+
.get();
|
|
48
|
+
return row.count;
|
|
49
|
+
}
|
|
50
|
+
/** Ids of records whose time is over. ISO times in UTC compare as text. */
|
|
51
|
+
export function expiredDeviceIds(db, nowIso, state) {
|
|
52
|
+
const sql = state === undefined
|
|
53
|
+
? 'SELECT id FROM devices WHERE expires_at <= ?'
|
|
54
|
+
: 'SELECT id FROM devices WHERE expires_at <= ? AND state = ?';
|
|
55
|
+
const params = state === undefined ? [nowIso] : [nowIso, state];
|
|
56
|
+
const rows = db.prepare(sql).all(...params);
|
|
57
|
+
return rows.map(row => row.id);
|
|
58
|
+
}
|
|
59
|
+
export function updateDevice(db, id, fields) {
|
|
60
|
+
const columns = {
|
|
61
|
+
name: fields.name,
|
|
62
|
+
state: fields.state,
|
|
63
|
+
address: fields.address,
|
|
64
|
+
expires_at: fields.expiresAt,
|
|
65
|
+
last_seen_at: fields.lastSeenAt
|
|
66
|
+
};
|
|
67
|
+
const entries = Object.entries(columns).filter((entry) => entry[1] !== undefined);
|
|
68
|
+
if (entries.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
db.prepare(`UPDATE devices SET ${entries.map(([column]) => `${column} = ?`).join(', ')} WHERE id = ?`).run(...entries.map(([, value]) => value), id);
|
|
71
|
+
}
|
|
72
|
+
export function deleteDevice(db, id) {
|
|
73
|
+
return db.prepare('DELETE FROM devices WHERE id = ?').run(id).changes > 0;
|
|
74
|
+
}
|
|
@@ -21,7 +21,7 @@ const AGENT_ERROR_RESPONSES = {
|
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
23
|
export async function agentRoutes(app, opts) {
|
|
24
|
-
const { registry, db, repositoryPath, eventBus } = opts;
|
|
24
|
+
const { registry, db, repositoryPath, eventBus, connections } = opts;
|
|
25
25
|
const typedApp = app.withTypeProvider();
|
|
26
26
|
const fileIndex = createFileIndex(repositoryPath, eventBus);
|
|
27
27
|
app.addHook('onRequest', requireLocalOrigin);
|
|
@@ -237,6 +237,11 @@ export async function agentRoutes(app, opts) {
|
|
|
237
237
|
if (!reply.sse.isConnected) {
|
|
238
238
|
return;
|
|
239
239
|
}
|
|
240
|
+
// No `await` between this check of the device and the subscription.
|
|
241
|
+
const release = connections.track(request, () => reply.sse.close());
|
|
242
|
+
if (!release) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
240
245
|
const unsubscribe = registry.subscribe(message => {
|
|
241
246
|
if (message.type === 'event') {
|
|
242
247
|
safeSend(reply.sse, {
|
|
@@ -262,8 +267,12 @@ export async function agentRoutes(app, opts) {
|
|
|
262
267
|
// have run then, so the listener is removed here.
|
|
263
268
|
if (!reply.sse.isConnected) {
|
|
264
269
|
unsubscribe();
|
|
270
|
+
release();
|
|
265
271
|
return;
|
|
266
272
|
}
|
|
267
|
-
reply.sse.onClose(
|
|
273
|
+
reply.sse.onClose(() => {
|
|
274
|
+
unsubscribe();
|
|
275
|
+
release();
|
|
276
|
+
});
|
|
268
277
|
});
|
|
269
278
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { BadRequestError } from "../errors/http.js";
|
|
2
|
+
import { clearDeviceCookie, deviceSecret, requireLocalRequest, setDeviceCookie } from "../hooks/access/require-access.js";
|
|
3
|
+
import { machineAddresses } from "../utils/lan-addresses.js";
|
|
4
|
+
import { clientAddress } from "../utils/request-origin.js";
|
|
5
|
+
import { AccessSchema, ApproveDeviceBody, CreatePairingRequestBody, DeviceListSchema, DeviceParams, DeviceSchema, LanAddressesSchema, RenameDeviceBody } from "../../shared/devices/schemas.js";
|
|
6
|
+
import { ERROR_RESPONSES, NoContentSchema } from "../../shared/http/schemas.js";
|
|
7
|
+
// OpenAPI group of these routes.
|
|
8
|
+
const TAGS = ['devices'];
|
|
9
|
+
// The secret hash and the pairing code never reach the operator's browser:
|
|
10
|
+
// the code is shown on the device only (ADR 0080).
|
|
11
|
+
function toDevice({ secretHash: _secretHash, code: _code, ...device }) {
|
|
12
|
+
return device;
|
|
13
|
+
}
|
|
14
|
+
function pendingAccess(device, now) {
|
|
15
|
+
return {
|
|
16
|
+
status: 'pending',
|
|
17
|
+
pairing: {
|
|
18
|
+
code: device.code,
|
|
19
|
+
expiresAt: device.expiresAt,
|
|
20
|
+
remainingMs: Math.max(0, Date.parse(device.expiresAt) - now)
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function deviceRoutes(app, opts) {
|
|
25
|
+
const { devices, onPairingRequest } = opts;
|
|
26
|
+
const typedApp = app.withTypeProvider();
|
|
27
|
+
typedApp.get('/api/access', {
|
|
28
|
+
schema: {
|
|
29
|
+
tags: TAGS,
|
|
30
|
+
summary: 'Access of this browser',
|
|
31
|
+
response: { 200: AccessSchema, ...ERROR_RESPONSES }
|
|
32
|
+
}
|
|
33
|
+
}, async (request, reply) => {
|
|
34
|
+
const access = request.access;
|
|
35
|
+
if (access?.local)
|
|
36
|
+
return { status: 'local' };
|
|
37
|
+
const device = access?.device ?? { status: 'none' };
|
|
38
|
+
if (device.status === 'device')
|
|
39
|
+
return { status: 'device' };
|
|
40
|
+
if (device.status === 'pending') {
|
|
41
|
+
return pendingAccess(device.device, devices.now());
|
|
42
|
+
}
|
|
43
|
+
// A stale secret blocks nothing, but a new request should start clean.
|
|
44
|
+
if (deviceSecret(request, devices.serverId) !== undefined) {
|
|
45
|
+
clearDeviceCookie(reply, devices.serverId);
|
|
46
|
+
}
|
|
47
|
+
return { status: 'none' };
|
|
48
|
+
});
|
|
49
|
+
typedApp.post('/api/devices/requests', {
|
|
50
|
+
schema: {
|
|
51
|
+
tags: TAGS,
|
|
52
|
+
summary: 'Request access for this browser',
|
|
53
|
+
body: CreatePairingRequestBody,
|
|
54
|
+
response: { 200: AccessSchema, ...ERROR_RESPONSES }
|
|
55
|
+
}
|
|
56
|
+
}, async (request, reply) => {
|
|
57
|
+
if (request.access?.local) {
|
|
58
|
+
throw new BadRequestError('This machine needs no pairing.');
|
|
59
|
+
}
|
|
60
|
+
const { device, secret } = devices.createRequest({
|
|
61
|
+
secret: deviceSecret(request, devices.serverId),
|
|
62
|
+
...(request.body.name === undefined ? {} : { name: request.body.name }),
|
|
63
|
+
...(request.headers['user-agent'] === undefined
|
|
64
|
+
? {}
|
|
65
|
+
: { userAgent: request.headers['user-agent'] }),
|
|
66
|
+
address: clientAddress(request.socket.remoteAddress, request.headers['x-forwarded-for']),
|
|
67
|
+
host: request.headers.host ?? ''
|
|
68
|
+
});
|
|
69
|
+
if (secret === undefined)
|
|
70
|
+
return { status: 'device' };
|
|
71
|
+
setDeviceCookie(reply, devices.serverId, secret);
|
|
72
|
+
onPairingRequest?.(toDevice(device));
|
|
73
|
+
return pendingAccess(device, devices.now());
|
|
74
|
+
});
|
|
75
|
+
// Device management: only this machine may see, approve or revoke devices.
|
|
76
|
+
app.register(async (local) => {
|
|
77
|
+
local.addHook('onRequest', requireLocalRequest);
|
|
78
|
+
const typedLocal = local.withTypeProvider();
|
|
79
|
+
typedLocal.get('/api/devices', {
|
|
80
|
+
schema: {
|
|
81
|
+
tags: TAGS,
|
|
82
|
+
summary: 'List pending and trusted devices',
|
|
83
|
+
response: { 200: DeviceListSchema, ...ERROR_RESPONSES }
|
|
84
|
+
}
|
|
85
|
+
}, async () => devices.list().map(toDevice));
|
|
86
|
+
typedLocal.post('/api/devices/:id/approve', {
|
|
87
|
+
schema: {
|
|
88
|
+
tags: TAGS,
|
|
89
|
+
summary: 'Approve a pairing request',
|
|
90
|
+
params: DeviceParams,
|
|
91
|
+
body: ApproveDeviceBody,
|
|
92
|
+
response: {
|
|
93
|
+
200: { ...DeviceSchema, description: 'The trusted device.' },
|
|
94
|
+
...ERROR_RESPONSES
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, async (request) => toDevice(devices.approve(request.params.id, request.body.code)));
|
|
98
|
+
typedLocal.patch('/api/devices/:id', {
|
|
99
|
+
schema: {
|
|
100
|
+
tags: TAGS,
|
|
101
|
+
summary: 'Rename a device',
|
|
102
|
+
params: DeviceParams,
|
|
103
|
+
body: RenameDeviceBody,
|
|
104
|
+
response: {
|
|
105
|
+
200: { ...DeviceSchema, description: 'The renamed device.' },
|
|
106
|
+
...ERROR_RESPONSES
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}, async (request) => toDevice(devices.rename(request.params.id, request.body.name)));
|
|
110
|
+
typedLocal.delete('/api/devices/:id', {
|
|
111
|
+
schema: {
|
|
112
|
+
tags: TAGS,
|
|
113
|
+
summary: 'Reject a pairing request or revoke a device',
|
|
114
|
+
params: DeviceParams,
|
|
115
|
+
response: { 204: NoContentSchema, ...ERROR_RESPONSES }
|
|
116
|
+
}
|
|
117
|
+
}, async (request, reply) => {
|
|
118
|
+
devices.revoke(request.params.id);
|
|
119
|
+
reply.code(204);
|
|
120
|
+
return null;
|
|
121
|
+
});
|
|
122
|
+
typedLocal.get('/api/devices/lan-addresses', {
|
|
123
|
+
schema: {
|
|
124
|
+
tags: TAGS,
|
|
125
|
+
summary: 'LAN addresses of this machine for a QR code',
|
|
126
|
+
response: { 200: LanAddressesSchema, ...ERROR_RESPONSES }
|
|
127
|
+
}
|
|
128
|
+
}, async () => machineAddresses());
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { safeSend } from "../utils/sse.js";
|
|
2
2
|
import { EventStreamSchema } from "../../shared/events/schemas.js";
|
|
3
3
|
export async function eventRoutes(app, opts) {
|
|
4
|
-
const { eventBus, instanceId } = opts;
|
|
4
|
+
const { eventBus, instanceId, connections } = opts;
|
|
5
5
|
const hello = { instanceId };
|
|
6
6
|
app.get('/api/events', {
|
|
7
7
|
sse: true,
|
|
@@ -22,6 +22,11 @@ export async function eventRoutes(app, opts) {
|
|
|
22
22
|
if (!reply.sse.isConnected) {
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
+
// No `await` between this check of the device and the subscription.
|
|
26
|
+
const release = connections.track(request, () => reply.sse.close());
|
|
27
|
+
if (!release) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
25
30
|
const unsubscribe = eventBus.subscribe(event => {
|
|
26
31
|
safeSend(reply.sse, {
|
|
27
32
|
event: event.type,
|
|
@@ -30,6 +35,7 @@ export async function eventRoutes(app, opts) {
|
|
|
30
35
|
});
|
|
31
36
|
reply.sse.onClose(() => {
|
|
32
37
|
unsubscribe();
|
|
38
|
+
release();
|
|
33
39
|
});
|
|
34
40
|
});
|
|
35
41
|
}
|
|
@@ -7,14 +7,14 @@ import { NoteRecordSchema, NoteIdQuery, NoteDescriptorSchema, NoteResolveBody, N
|
|
|
7
7
|
import { ERROR_RESPONSES, NoContentSchema } from "../../shared/http/schemas.js";
|
|
8
8
|
import { BadRequestError } from "../errors/http.js";
|
|
9
9
|
import { withRequestSignal } from "../utils/request-abort.js";
|
|
10
|
-
import {
|
|
11
|
-
export async function notesRoutes(app, { service,
|
|
10
|
+
import { requireNotesOrigin } from "../hooks/notes/same-origin.js";
|
|
11
|
+
export async function notesRoutes(app, { service, externalAssets = new NotesExternalAssetsService() }) {
|
|
12
12
|
await app.register(multipart, {
|
|
13
13
|
attachFieldsToBody: true,
|
|
14
14
|
limits: { fileSize: MAX_NOTE_ASSET_BYTES, files: 1, fields: 0, parts: 1 }
|
|
15
15
|
});
|
|
16
16
|
const typed = app.withTypeProvider();
|
|
17
|
-
app.addHook('onRequest',
|
|
17
|
+
app.addHook('onRequest', requireNotesOrigin);
|
|
18
18
|
app.post('/api/notes/assets', {
|
|
19
19
|
preValidation: async (request) => {
|
|
20
20
|
if (!request.isMultipart()) {
|
|
@@ -49,9 +49,17 @@ export async function terminalRoutes(app, options) {
|
|
|
49
49
|
throw new ForbiddenError('Terminal connection token is invalid or expired.');
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
}, socket => {
|
|
52
|
+
}, (socket, request) => {
|
|
53
|
+
// No `await` between this check of the device and the attach.
|
|
54
|
+
const release = options.deviceConnections.track(request, () => socket.terminate());
|
|
55
|
+
if (!release) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
53
58
|
const connection = attachTerminalSocket(socket, options.registry, {
|
|
54
|
-
onDisconnect: () =>
|
|
59
|
+
onDisconnect: () => {
|
|
60
|
+
connections.delete(connection);
|
|
61
|
+
release();
|
|
62
|
+
}
|
|
55
63
|
});
|
|
56
64
|
connections.add(connection);
|
|
57
65
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open streams and sockets of trusted devices (ADR 0079, item 13). A revoke
|
|
3
|
+
* in this process closes the connections in the same synchronous step.
|
|
4
|
+
* A revoke by another process and an expiry close them at the next
|
|
5
|
+
* `recheck`.
|
|
6
|
+
*
|
|
7
|
+
* Between the access hook and the registration of a stream there are
|
|
8
|
+
* `await` steps, and the device can lose access in them. So `track` checks
|
|
9
|
+
* the device again and adds the connection without an `await` between the
|
|
10
|
+
* two: `node:sqlite` is synchronous, and JS runs one step at a time.
|
|
11
|
+
*/
|
|
12
|
+
export class DeviceConnections {
|
|
13
|
+
byDevice = new Map();
|
|
14
|
+
devices;
|
|
15
|
+
constructor(devices) {
|
|
16
|
+
this.devices = devices;
|
|
17
|
+
devices.onRemoved(id => this.closeDevice(id));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Registers `close` for the device of this request. Returns the release
|
|
21
|
+
* function for a normal close. Returns undefined when the device has no
|
|
22
|
+
* access any more: then `close` was already called. A local request has
|
|
23
|
+
* no device, so its release function does nothing.
|
|
24
|
+
*/
|
|
25
|
+
track(request, close) {
|
|
26
|
+
const access = request.access;
|
|
27
|
+
if (!access || access.local)
|
|
28
|
+
return () => { };
|
|
29
|
+
const id = access.device.status === 'device' ? access.device.device.id : undefined;
|
|
30
|
+
if (id === undefined || !this.devices.isTrusted(id)) {
|
|
31
|
+
close();
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
let closers = this.byDevice.get(id);
|
|
35
|
+
if (!closers) {
|
|
36
|
+
closers = new Set();
|
|
37
|
+
this.byDevice.set(id, closers);
|
|
38
|
+
}
|
|
39
|
+
closers.add(close);
|
|
40
|
+
return () => {
|
|
41
|
+
closers.delete(close);
|
|
42
|
+
if (closers.size === 0 && this.byDevice.get(id) === closers) {
|
|
43
|
+
this.byDevice.delete(id);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Closes the connections of devices that lost access without a call of
|
|
49
|
+
* the local service: another server process on the same database revoked
|
|
50
|
+
* them, or their time is over. The listener of the local service cannot
|
|
51
|
+
* see such a change.
|
|
52
|
+
*/
|
|
53
|
+
recheck() {
|
|
54
|
+
// Deleting the current key during Map iteration is safe.
|
|
55
|
+
for (const id of this.byDevice.keys()) {
|
|
56
|
+
if (!this.devices.isTrusted(id))
|
|
57
|
+
this.closeDevice(id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Closes every connection of the device. A repeated call does nothing. */
|
|
61
|
+
closeDevice(id) {
|
|
62
|
+
const closers = this.byDevice.get(id);
|
|
63
|
+
if (!closers)
|
|
64
|
+
return;
|
|
65
|
+
this.byDevice.delete(id);
|
|
66
|
+
for (const close of closers) {
|
|
67
|
+
try {
|
|
68
|
+
close();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// One broken connection must not keep the others open.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Number of open connections of the device; for tests. */
|
|
76
|
+
count(id) {
|
|
77
|
+
return this.byDevice.get(id)?.size ?? 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
import { DEVICE_IDLE_TTL_MS, DEVICE_TOUCH_INTERVAL_MS, MAX_PAIRING_REQUESTS_PER_MINUTE, MAX_PENDING_REQUESTS, PAIRING_CODE_LENGTH, PAIRING_REQUEST_INTERVAL_MS, PAIRING_REQUEST_TTL_MS } from "../../shared/devices/constants.js";
|
|
3
|
+
import { cleanDeviceName } from "../../shared/devices/name.js";
|
|
4
|
+
import { toIsoString } from "../../shared/utils/date.js";
|
|
5
|
+
import { DeviceNotFoundError, PairingConflictError, PairingExpiredError, PairingRateLimitError } from "../errors/devices.js";
|
|
6
|
+
import { BadRequestError } from "../errors/http.js";
|
|
7
|
+
import { countPendingDevices, deleteDevice, ensureServerId, expiredDeviceIds, findDeviceById, findDeviceBySecretHash, insertDevice, listDevices, updateDevice } from "../repositories/device.repo.js";
|
|
8
|
+
import { deviceNameFromUserAgent } from "../utils/device-name.js";
|
|
9
|
+
import { addressKey } from "../utils/address-key.js";
|
|
10
|
+
import { sha256Hex } from "../utils/hash.js";
|
|
11
|
+
// Random bytes in a device secret: 256 bits, 43 base64url characters.
|
|
12
|
+
const SECRET_BYTES = 32;
|
|
13
|
+
// A cookie value of another length is not a secret of this server.
|
|
14
|
+
const SECRET_LENGTH = 43;
|
|
15
|
+
// Window of the global request limit.
|
|
16
|
+
const RATE_WINDOW_MS = 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* Pairing requests and trusted devices (ADR 0079). The secret lives only in
|
|
19
|
+
* the browser cookie; the database keeps its SHA-256. A random 256-bit
|
|
20
|
+
* secret cannot be found from response times, so a lookup by the hash
|
|
21
|
+
* index is safe without a constant-time compare.
|
|
22
|
+
*/
|
|
23
|
+
export class DeviceService {
|
|
24
|
+
/** Random id in the cookie name: instances for other repositories keep their own cookie. */
|
|
25
|
+
serverId;
|
|
26
|
+
/** Clock of the service; routes use it for the time left of a request. */
|
|
27
|
+
now;
|
|
28
|
+
onChange;
|
|
29
|
+
lastRequestAt = new Map();
|
|
30
|
+
recentRequests = [];
|
|
31
|
+
removeListeners = new Set();
|
|
32
|
+
db;
|
|
33
|
+
constructor(db, options = {}) {
|
|
34
|
+
this.db = db;
|
|
35
|
+
this.now = options.now ?? Date.now;
|
|
36
|
+
this.onChange = options.onChange ?? (() => { });
|
|
37
|
+
this.serverId = ensureServerId(db, () => randomBytes(6).toString('base64url'));
|
|
38
|
+
}
|
|
39
|
+
/** Calls `listener` with the id of each removed device. Returns the unsubscribe function. */
|
|
40
|
+
onRemoved(listener) {
|
|
41
|
+
this.removeListeners.add(listener);
|
|
42
|
+
return () => this.removeListeners.delete(listener);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Creates a pairing request. A browser that is already trusted gets its
|
|
46
|
+
* device back and no new request. A new request from the same address
|
|
47
|
+
* or with the same cookie replaces the old one.
|
|
48
|
+
*/
|
|
49
|
+
createRequest(input) {
|
|
50
|
+
const current = this.access(input.secret, input.address);
|
|
51
|
+
if (current.status === 'device') {
|
|
52
|
+
return { device: current.device };
|
|
53
|
+
}
|
|
54
|
+
this.removeExpiredPending();
|
|
55
|
+
this.checkRate(addressKey(input.address));
|
|
56
|
+
if (current.status === 'pending') {
|
|
57
|
+
this.remove(current.device.id);
|
|
58
|
+
}
|
|
59
|
+
const key = addressKey(input.address);
|
|
60
|
+
for (const device of listDevices(this.db)) {
|
|
61
|
+
if (device.state === 'pending' && addressKey(device.address) === key) {
|
|
62
|
+
this.remove(device.id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A full list drops the oldest request: otherwise a few addresses could
|
|
66
|
+
// keep every slot and lock the real device out.
|
|
67
|
+
while (countPendingDevices(this.db) >= MAX_PENDING_REQUESTS) {
|
|
68
|
+
const oldest = listDevices(this.db).find(device => device.state === 'pending');
|
|
69
|
+
if (!oldest)
|
|
70
|
+
break;
|
|
71
|
+
this.remove(oldest.id);
|
|
72
|
+
}
|
|
73
|
+
const now = this.now();
|
|
74
|
+
this.lastRequestAt.set(key, now);
|
|
75
|
+
this.recentRequests.push(now);
|
|
76
|
+
const secret = randomBytes(SECRET_BYTES).toString('base64url');
|
|
77
|
+
const device = {
|
|
78
|
+
id: randomUUID(),
|
|
79
|
+
name: cleanDeviceName(input.name) || deviceNameFromUserAgent(input.userAgent),
|
|
80
|
+
secretHash: sha256Hex(secret),
|
|
81
|
+
state: 'pending',
|
|
82
|
+
code: randomInt(0, 10 ** PAIRING_CODE_LENGTH)
|
|
83
|
+
.toString()
|
|
84
|
+
.padStart(PAIRING_CODE_LENGTH, '0'),
|
|
85
|
+
host: input.host,
|
|
86
|
+
address: input.address,
|
|
87
|
+
createdAt: toIsoString(now),
|
|
88
|
+
expiresAt: toIsoString(now + PAIRING_REQUEST_TTL_MS),
|
|
89
|
+
lastSeenAt: toIsoString(now)
|
|
90
|
+
};
|
|
91
|
+
insertDevice(this.db, device);
|
|
92
|
+
this.onChange();
|
|
93
|
+
return { device, secret };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Access of the browser with this cookie secret. An expired record never
|
|
97
|
+
* gives access. A trusted device records activity at most once per
|
|
98
|
+
* `DEVICE_TOUCH_INTERVAL_MS`.
|
|
99
|
+
*/
|
|
100
|
+
access(secret, address) {
|
|
101
|
+
if (secret?.length !== SECRET_LENGTH)
|
|
102
|
+
return { status: 'none' };
|
|
103
|
+
const device = findDeviceBySecretHash(this.db, sha256Hex(secret));
|
|
104
|
+
const now = this.now();
|
|
105
|
+
if (!device || Date.parse(device.expiresAt) <= now) {
|
|
106
|
+
return { status: 'none' };
|
|
107
|
+
}
|
|
108
|
+
if (device.state === 'pending') {
|
|
109
|
+
return { status: 'pending', device };
|
|
110
|
+
}
|
|
111
|
+
if (now - Date.parse(device.lastSeenAt) < DEVICE_TOUCH_INTERVAL_MS) {
|
|
112
|
+
return { status: 'device', device, renewed: false };
|
|
113
|
+
}
|
|
114
|
+
const fields = {
|
|
115
|
+
lastSeenAt: toIsoString(now),
|
|
116
|
+
expiresAt: toIsoString(now + DEVICE_IDLE_TTL_MS),
|
|
117
|
+
...(address === undefined ? {} : { address })
|
|
118
|
+
};
|
|
119
|
+
updateDevice(this.db, device.id, fields);
|
|
120
|
+
return { status: 'device', device: { ...device, ...fields }, renewed: true };
|
|
121
|
+
}
|
|
122
|
+
/** True when the device exists, is trusted and its time is not over. */
|
|
123
|
+
isTrusted(id) {
|
|
124
|
+
const device = findDeviceById(this.db, id);
|
|
125
|
+
return (device?.state === 'trusted' && Date.parse(device.expiresAt) > this.now());
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Makes a pending request trusted. The code proves that the operator saw
|
|
129
|
+
* this request: a replaced request has another id, and a matching id with
|
|
130
|
+
* another code is a conflict.
|
|
131
|
+
*/
|
|
132
|
+
approve(id, code) {
|
|
133
|
+
const device = findDeviceById(this.db, id);
|
|
134
|
+
if (!device) {
|
|
135
|
+
throw new DeviceNotFoundError('The pairing request does not exist. It was replaced, rejected or removed.');
|
|
136
|
+
}
|
|
137
|
+
if (device.state === 'trusted') {
|
|
138
|
+
throw new PairingConflictError('The device is already trusted.');
|
|
139
|
+
}
|
|
140
|
+
const now = this.now();
|
|
141
|
+
if (Date.parse(device.expiresAt) <= now) {
|
|
142
|
+
throw new PairingExpiredError('The pairing request has expired.');
|
|
143
|
+
}
|
|
144
|
+
if (device.code !== code) {
|
|
145
|
+
throw new PairingConflictError('The code does not match the pairing request.');
|
|
146
|
+
}
|
|
147
|
+
const fields = {
|
|
148
|
+
state: 'trusted',
|
|
149
|
+
lastSeenAt: toIsoString(now),
|
|
150
|
+
expiresAt: toIsoString(now + DEVICE_IDLE_TTL_MS)
|
|
151
|
+
};
|
|
152
|
+
updateDevice(this.db, id, fields);
|
|
153
|
+
this.onChange();
|
|
154
|
+
return { ...device, ...fields };
|
|
155
|
+
}
|
|
156
|
+
rename(id, name) {
|
|
157
|
+
const device = findDeviceById(this.db, id);
|
|
158
|
+
if (!device) {
|
|
159
|
+
throw new DeviceNotFoundError('The device does not exist.');
|
|
160
|
+
}
|
|
161
|
+
const trimmed = cleanDeviceName(name);
|
|
162
|
+
if (!trimmed) {
|
|
163
|
+
throw new BadRequestError('The device name is empty.');
|
|
164
|
+
}
|
|
165
|
+
updateDevice(this.db, id, { name: trimmed });
|
|
166
|
+
this.onChange();
|
|
167
|
+
return { ...device, name: trimmed };
|
|
168
|
+
}
|
|
169
|
+
/** Rejects a request or revokes a device. */
|
|
170
|
+
revoke(id) {
|
|
171
|
+
if (!this.remove(id)) {
|
|
172
|
+
throw new DeviceNotFoundError('The device does not exist.');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Devices with time left. Expired pending requests are removed first. */
|
|
176
|
+
list() {
|
|
177
|
+
this.removeExpiredPending();
|
|
178
|
+
const now = this.now();
|
|
179
|
+
return listDevices(this.db).filter(device => Date.parse(device.expiresAt) > now);
|
|
180
|
+
}
|
|
181
|
+
/** Removes every expired record through the revoke path. Returns their ids. */
|
|
182
|
+
removeExpired() {
|
|
183
|
+
const ids = expiredDeviceIds(this.db, toIsoString(this.now()));
|
|
184
|
+
for (const id of ids)
|
|
185
|
+
this.remove(id);
|
|
186
|
+
return ids;
|
|
187
|
+
}
|
|
188
|
+
removeExpiredPending() {
|
|
189
|
+
for (const id of expiredDeviceIds(this.db, toIsoString(this.now()), 'pending')) {
|
|
190
|
+
this.remove(id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Deletes the record and tells listeners synchronously, so that open
|
|
195
|
+
* connections close in the same step (ADR 0079, item 13). A repeated call
|
|
196
|
+
* returns false and changes nothing.
|
|
197
|
+
*/
|
|
198
|
+
remove(id) {
|
|
199
|
+
if (!deleteDevice(this.db, id))
|
|
200
|
+
return false;
|
|
201
|
+
for (const listener of this.removeListeners)
|
|
202
|
+
listener(id);
|
|
203
|
+
this.onChange();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
checkRate(key) {
|
|
207
|
+
const now = this.now();
|
|
208
|
+
const last = this.lastRequestAt.get(key);
|
|
209
|
+
if (last !== undefined && now - last < PAIRING_REQUEST_INTERVAL_MS) {
|
|
210
|
+
throw new PairingRateLimitError('Pairing requests from this address are too frequent. Wait a few seconds.');
|
|
211
|
+
}
|
|
212
|
+
this.recentRequests = this.recentRequests.filter(time => now - time < RATE_WINDOW_MS);
|
|
213
|
+
if (this.recentRequests.length >= MAX_PAIRING_REQUESTS_PER_MINUTE) {
|
|
214
|
+
throw new PairingRateLimitError('Too many pairing requests. Try again in a minute.');
|
|
215
|
+
}
|
|
216
|
+
for (const [storedKey, time] of this.lastRequestAt) {
|
|
217
|
+
if (now - time >= PAIRING_REQUEST_INTERVAL_MS) {
|
|
218
|
+
this.lastRequestAt.delete(storedKey);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
2
|
+
// An IPv6 client owns a whole /64 and can take any address inside it.
|
|
3
|
+
const IPV6_PREFIX_GROUPS = 4;
|
|
4
|
+
const IPV4_MAPPED_PREFIX = '::ffff:';
|
|
5
|
+
/** Expands an IPv6 address into 8 groups. The address must be valid. */
|
|
6
|
+
function ipv6Groups(address) {
|
|
7
|
+
const [head = '', tail] = address.split('::');
|
|
8
|
+
const headGroups = head === '' ? [] : head.split(':');
|
|
9
|
+
if (tail === undefined)
|
|
10
|
+
return headGroups;
|
|
11
|
+
const tailGroups = tail === '' ? [] : tail.split(':');
|
|
12
|
+
const gap = 8 - headGroups.length - tailGroups.length;
|
|
13
|
+
return [...headGroups, ...Array(gap).fill('0'), ...tailGroups];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Key that limits pairing requests of one client. An IPv6 address maps to
|
|
17
|
+
* its /64 prefix, an IPv4-mapped address to the IPv4 address. Any other
|
|
18
|
+
* text stays as it is.
|
|
19
|
+
*/
|
|
20
|
+
export function addressKey(address) {
|
|
21
|
+
const plain = address.split('%')[0] ?? address;
|
|
22
|
+
if (plain.toLowerCase().startsWith(IPV4_MAPPED_PREFIX)) {
|
|
23
|
+
const mapped = plain.slice(IPV4_MAPPED_PREFIX.length);
|
|
24
|
+
if (isIPv4(mapped))
|
|
25
|
+
return mapped;
|
|
26
|
+
}
|
|
27
|
+
if (!isIPv6(plain))
|
|
28
|
+
return address;
|
|
29
|
+
const groups = ipv6Groups(plain.toLowerCase())
|
|
30
|
+
.slice(0, IPV6_PREFIX_GROUPS)
|
|
31
|
+
.map(group => parseInt(group, 16).toString(16));
|
|
32
|
+
return `${groups.join(':')}::/64`;
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { machineAddresses } from "./lan-addresses.js";
|
|
2
|
+
import { parseHost } from "./request-origin.js";
|
|
3
|
+
// DHCP can change an address; a short cache keeps the lookup cheap per request.
|
|
4
|
+
const ADDRESS_CACHE_MS = 5000;
|
|
5
|
+
/**
|
|
6
|
+
* `Host` values that a LAN request may use (ADR 0079): addresses of this
|
|
7
|
+
* machine and the exact names from `--allowed-host`. A page of a rebound
|
|
8
|
+
* hostile domain sends its own name, so it cannot reach the API or start
|
|
9
|
+
* a pairing request.
|
|
10
|
+
*/
|
|
11
|
+
export class AllowedHosts {
|
|
12
|
+
names;
|
|
13
|
+
addresses;
|
|
14
|
+
now;
|
|
15
|
+
cached;
|
|
16
|
+
constructor(names = [], addresses = machineAddresses, now = Date.now) {
|
|
17
|
+
this.names = new Set(names.map(name => name.toLowerCase()));
|
|
18
|
+
this.addresses = addresses;
|
|
19
|
+
this.now = now;
|
|
20
|
+
}
|
|
21
|
+
allows(host) {
|
|
22
|
+
const hostname = parseHost(host)?.hostname;
|
|
23
|
+
if (hostname === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
|
|
26
|
+
return this.names.has(bare) || this.machineHosts().has(bare);
|
|
27
|
+
}
|
|
28
|
+
machineHosts() {
|
|
29
|
+
const now = this.now();
|
|
30
|
+
if (!this.cached || now - this.cached.at >= ADDRESS_CACHE_MS) {
|
|
31
|
+
this.cached = {
|
|
32
|
+
at: now,
|
|
33
|
+
hosts: new Set(this.addresses().map(address => address.toLowerCase()))
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return this.cached.hosts;
|
|
37
|
+
}
|
|
38
|
+
}
|