@xenon-device-management/xenon 2.1.0 → 2.2.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/lib/package.json +3 -3
- package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-XpewX5nn.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-BQDv3ZCC.js} +1 -1
- package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-C7OmS61B.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-D-rGOBbJ.js → DeviceMosaicView-wl3l2Zlt.js} +1 -1
- package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-fPxujqqE.js} +1 -1
- package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-5bCutWrs.js} +1 -1
- package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-owsBiDT1.js} +1 -1
- package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-DFhqzPSt.js} +1 -1
- package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-Bw-a-E-e.js} +1 -1
- package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-WtByf5bC.js} +1 -1
- package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-FpWWFNgT.js} +1 -1
- package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-Jzn9c7o8.js} +1 -1
- package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-BR7nOhpT.js} +1 -1
- package/lib/public/assets/{Table-ucR4bnmc.js → Table-B_Va3V05.js} +1 -1
- package/lib/public/assets/{activity-K-hdylwq.js → activity-DxRFWvkN.js} +1 -1
- package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-CQf-9_V6.js} +1 -1
- package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-SoE1Kd7N.js} +1 -1
- package/lib/public/assets/{apps-Dez1petz.js → apps-CgGXTlgn.js} +1 -1
- package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-uQ6WIwiC.js} +1 -1
- package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-C9RyfzXS.js} +1 -1
- package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-DFHi_8yG.js} +1 -1
- package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Bii1t4qc.js} +2 -2
- package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-Bnv8Z9hi.js} +1 -1
- package/lib/public/assets/{button-Dd-IhMC1.js → button-_Y78wXiP.js} +1 -1
- package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-D4MqSOc2.js} +1 -1
- package/lib/public/assets/{check-C4Jrjg8f.js → check-uGHCXe-L.js} +1 -1
- package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-Dy1OqpzB.js} +1 -1
- package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-BUsldEli.js} +1 -1
- package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-D3Qq0tKp.js} +1 -1
- package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-B4IOD1DE.js} +1 -1
- package/lib/public/assets/{clock-B8vmwkHj.js → clock-BfYfzvSg.js} +1 -1
- package/lib/public/assets/{copy-BAqnFI-F.js → copy-DZwYE5Va.js} +1 -1
- package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-CRG4Utt5.js} +1 -1
- package/lib/public/assets/{download-B6UEAp_1.js → download-CCDYo_TN.js} +1 -1
- package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-DuLCmVFb.js} +1 -1
- package/lib/public/assets/{funnel-Da90BBbT.js → funnel-CtKNPqzb.js} +1 -1
- package/lib/public/assets/{index-BuIIcmGp.js → index-3uxl3o7K.js} +43 -43
- package/lib/public/assets/{index-MX7IzqyW.js → index-Zy1Op5C1.js} +1 -1
- package/lib/public/assets/{input-Bq9aY-We.js → input-CzTWvUXZ.js} +1 -1
- package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-CI-nYDFM.js} +1 -1
- package/lib/public/assets/{layers-bXjpswQI.js → layers-YZZGFyTX.js} +1 -1
- package/lib/public/assets/{lock-pJ72y5Fy.js → lock-L8tzWgDo.js} +1 -1
- package/lib/public/assets/{login-UOcHALV2.js → login-DBbX-fu8.js} +1 -1
- package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-AJwkOIsE.js} +1 -1
- package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-Di_66F2m.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-Cnab2zdg.js} +1 -1
- package/lib/public/assets/{network-CKVqcyp6.js → network-DiSumY-1.js} +1 -1
- package/lib/public/assets/{overview-BORGkN3N.js → overview-CwE4gXl5.js} +1 -1
- package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-CfR4Sm9V.js} +1 -1
- package/lib/public/assets/{plus-BpFKVdC8.js → plus-DOS8P9CQ.js} +1 -1
- package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-BYpHwQIH.js} +1 -1
- package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-BRVJU_qV.js} +1 -1
- package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-Bi3JvAFZ.js} +1 -1
- package/lib/public/assets/{select-DIZWIoVR.js → select-Cqx7pEou.js} +1 -1
- package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-C0DsncKj.js} +1 -1
- package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-CAhpdilF.js} +1 -1
- package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CrCVeW2m.js} +1 -1
- package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-F_hwYqEt.js} +1 -1
- package/lib/public/assets/{settings-BcRBKUXd.js → settings-C-s5pypL.js} +1 -1
- package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-DY5uzv6A.js} +1 -1
- package/lib/public/assets/{tablet-_sXqZztt.js → tablet-Dli7gwyg.js} +1 -1
- package/lib/public/assets/{teams-DGHT8MKm.js → teams-ui4IhHNC.js} +1 -1
- package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-BV4hSmXJ.js} +1 -1
- package/lib/public/assets/{upload-CIDH9wkj.js → upload-DkGy_J-L.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-BFtMqQOh.js} +1 -1
- package/lib/public/assets/{users-BUE7MoTq.js → users-BU0nXIVe.js} +1 -1
- package/lib/public/assets/{users-7yviqflc.js → users-Nb_rzRmF.js} +1 -1
- package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-CUNzD2kH.js} +1 -1
- package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Ca3UFXZe.js} +1 -1
- package/lib/public/assets/{zap-CWE94qcK.js → zap-B2bH2vKX.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/routers/control.js +5 -142
- package/lib/src/app/routers/nodePhoneControl.js +301 -0
- package/lib/src/app/ws/h264StreamWs.js +30 -4
- package/lib/src/app/ws/logcatWs.js +36 -7
- package/lib/src/app/ws/nodeSocketRelay.js +167 -0
- package/lib/src/data-service/device-store.js +11 -0
- package/lib/src/data-service/deviceClaims.js +18 -0
- package/lib/src/data-service/prisma-store.js +8 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +27 -27
- package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
- package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
- package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
- package/lib/src/middleware/deviceAccessGuard.js +4 -1
- package/lib/src/services/ServerManager.js +13 -0
- package/lib/src/services/device-access/ticketActorAccess.js +3 -1
- package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
- package/lib/test/helpers/own-node-id.js +29 -0
- package/lib/test/setup/scratch-default-database.js +32 -0
- package/lib/test/unit/AndroidDeviceManager.spec.js +48 -0
- package/lib/test/unit/android-h264-service.spec.js +18 -0
- package/lib/test/unit/clipboard-route.spec.js +4 -0
- package/lib/test/unit/control-forward-decision.spec.js +17 -17
- package/lib/test/unit/device-access-guard.spec.js +38 -0
- package/lib/test/unit/hub-node-control-forward.spec.js +318 -1
- package/lib/test/unit/hub-node-race.spec.js +25 -0
- package/lib/test/unit/install-repository-app-team.spec.js +4 -0
- package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
- package/lib/test/unit/logcat-ws.spec.js +8 -0
- package/lib/test/unit/mark-node-busy-loki.spec.js +53 -0
- package/lib/test/unit/node-phone-control.spec.js +131 -0
- package/lib/test/unit/node-socket-relay.spec.js +357 -0
- package/lib/test/unit/stream-leave-route.spec.js +4 -0
- package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
- package/lib/test/unit/stream-stop-recording.spec.js +4 -0
- package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
- package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{c as w,b as k,r as a,j as e,$ as C}from"./index-
|
|
1
|
+
import{c as w,b as k,r as a,j as e,$ as C}from"./index-3uxl3o7K.js";import{l as M,d as R,c as I,a as A,u as D}from"./users-BU0nXIVe.js";import{f as U}from"./time-B8FIgwPE.js";import{P}from"./page-header-CfR4Sm9V.js";import{B as y}from"./button-_Y78wXiP.js";import{I as N}from"./input-CzTWvUXZ.js";import{S as g}from"./select-Cqx7pEou.js";import{P as B}from"./plus-DOS8P9CQ.js";import{T as L}from"./trash-2-BV4hSmXJ.js";import"./utils-CDN07tui.js";/**
|
|
2
2
|
* @license lucide-react v0.555.0 - ISC
|
|
3
3
|
*
|
|
4
4
|
* This source code is licensed under the ISC license.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-
|
|
1
|
+
import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-3uxl3o7K.js";/* empty css */import{F as S}from"./FieldGroup-5bCutWrs.js";import{P as O}from"./page-header-CfR4Sm9V.js";import{T as V}from"./trash-2-BV4hSmXJ.js";import{P as x}from"./plus-DOS8P9CQ.js";import{C as B}from"./circle-x-B4IOD1DE.js";import{C as H}from"./circle-check-D3Qq0tKp.js";import{Z as I}from"./zap-B2bH2vKX.js";import{A as J}from"./activity-DxRFWvkN.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:g.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:s.payloadTemplate?"CUSTOM":"SLACK"}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
|
package/lib/public/index.html
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
Learn how to configure a non-root public URL by running `npm run build`.
|
|
37
37
|
-->
|
|
38
38
|
<title>Xenon</title>
|
|
39
|
-
<script type="module" crossorigin src="/xenon/assets/index-
|
|
39
|
+
<script type="module" crossorigin src="/xenon/assets/index-3uxl3o7K.js"></script>
|
|
40
40
|
<link rel="stylesheet" crossorigin href="/xenon/assets/index-hymrhlEZ.css">
|
|
41
41
|
</head>
|
|
42
42
|
|
|
@@ -68,7 +68,6 @@ const InspectorService_1 = require("../../services/InspectorService");
|
|
|
68
68
|
const StreamTicketService_1 = require("../../services/token/StreamTicketService");
|
|
69
69
|
const appVisibility_1 = require("../../services/device-access/appVisibility");
|
|
70
70
|
const PluginContext_1 = require("../../PluginContext");
|
|
71
|
-
const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
|
|
72
71
|
const streamType_1 = require("./streamType");
|
|
73
72
|
const iosStreamPort_1 = require("./iosStreamPort");
|
|
74
73
|
const androidH264Config_1 = require("./androidH264Config");
|
|
@@ -81,10 +80,10 @@ const deviceTeamGuard_1 = require("../../middleware/deviceTeamGuard");
|
|
|
81
80
|
const controlDevice_1 = require("../../middleware/controlDevice");
|
|
82
81
|
const manualLock_1 = require("../../services/recording/manualLock");
|
|
83
82
|
const streamStartConflict_1 = require("./streamStartConflict");
|
|
83
|
+
const nodePhoneControl_1 = require("./nodePhoneControl");
|
|
84
84
|
const streamLeave_1 = require("./streamLeave");
|
|
85
85
|
const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
|
|
86
86
|
const actor_1 = require("../../services/device-access/actor");
|
|
87
|
-
const hubSessionToken_1 = require("../../gateway/hubSessionToken");
|
|
88
87
|
const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
|
|
89
88
|
const router = (0, express_1.Router)();
|
|
90
89
|
/** True when an in-memory iOS/Android (MJPEG or H.264) stream session exists. */
|
|
@@ -112,76 +111,10 @@ router.use((0, deviceTeamGuard_1.deviceTeamGuard)());
|
|
|
112
111
|
// another user's Appium session. Mounted here so every current and future
|
|
113
112
|
// mutation is covered without per-handler opt-in.
|
|
114
113
|
router.use((0, deviceAccessGuard_1.deviceAccessGuard)());
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'metadata.goog',
|
|
120
|
-
'100.100.100.200', // Alibaba ECS metadata
|
|
121
|
-
'fd00:ec2::254', // AWS IMDSv6
|
|
122
|
-
]);
|
|
123
|
-
/**
|
|
124
|
-
* Build a safe proxy URL from a device's reported host. Strips any path,
|
|
125
|
-
* query, or fragment the host string carried, blocks cloud-metadata targets,
|
|
126
|
-
* and refuses non-http(s) schemes. Returns null if the host is unsafe.
|
|
127
|
-
*/
|
|
128
|
-
function buildProxyUrl(deviceHost, req) {
|
|
129
|
-
let parsed;
|
|
130
|
-
try {
|
|
131
|
-
parsed = new URL(deviceHost);
|
|
132
|
-
}
|
|
133
|
-
catch (_a) {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
137
|
-
return null;
|
|
138
|
-
if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
|
|
139
|
-
return null;
|
|
140
|
-
// Only keep scheme + host + port; discard any attacker-baked path/query/fragment.
|
|
141
|
-
const origin = `${parsed.protocol}//${parsed.host}`;
|
|
142
|
-
// Treat req.originalUrl as a path — reparse to strip any control characters.
|
|
143
|
-
const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
|
|
144
|
-
return `${origin}${forwardPath}`;
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Forward a control action on another server's phone to that server, signed
|
|
148
|
-
* for the caller: the hub's control token (hubSessionToken.ts) names the
|
|
149
|
-
* user, whether they are an admin, the phone and its node. This hub already
|
|
150
|
-
* checked them (the guards above); a node with auth enabled accepts the token
|
|
151
|
-
* for /control on that phone and runs its own ownership guard with that user.
|
|
152
|
-
* Sent with no credential at all, it was refused by every such node. A cloud
|
|
153
|
-
* provider never gets a hub token.
|
|
154
|
-
*/
|
|
155
|
-
function forwardControl(device, req, target) {
|
|
156
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
157
|
-
const actor = (0, actor_1.resolveActor)(req);
|
|
158
|
-
const token = !device.cloud && actor.userId
|
|
159
|
-
? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
|
|
160
|
-
userId: actor.userId,
|
|
161
|
-
isAdmin: actor.isAdmin,
|
|
162
|
-
udid: device.udid,
|
|
163
|
-
host: device.host,
|
|
164
|
-
})
|
|
165
|
-
: null;
|
|
166
|
-
// Sent once: a retry after a slow node's timeout or 5xx could land the
|
|
167
|
-
// same tap twice.
|
|
168
|
-
const config = Object.assign(Object.assign({}, (token ? { headers: { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } } : {})), { retry: false });
|
|
169
|
-
yield InternalHttpClient_1.InternalHttpClient.post(target, req.body, config);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* A phone another server drives, whose /control actions are forwarded there.
|
|
174
|
-
* Decided by the phone's own row (its nodeId, else its exact host against this
|
|
175
|
-
* server's own hosts), never by the Host header the caller used: a substring
|
|
176
|
-
* match on that header forwarded a lab's own phone back to itself, with no
|
|
177
|
-
* login, whenever the lab was reached as `localhost`.
|
|
178
|
-
*/
|
|
179
|
-
function isOtherServersPhone(device) {
|
|
180
|
-
if (!device.host)
|
|
181
|
-
return false;
|
|
182
|
-
const context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
183
|
-
return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
|
|
184
|
-
}
|
|
114
|
+
// Another server's phone (a node's, or a cloud provider's): its actions are
|
|
115
|
+
// sent on to its node, answered here, or refused, before any handler below
|
|
116
|
+
// runs it with this server's own tools. See nodePhoneControl.ts.
|
|
117
|
+
router.use((0, nodePhoneControl_1.nodePhoneControl)());
|
|
185
118
|
function getDeviceInfo(udid) {
|
|
186
119
|
return __awaiter(this, void 0, void 0, function* () {
|
|
187
120
|
const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
|
|
@@ -213,26 +146,12 @@ function getDeviceManagerForPlatform(platform) {
|
|
|
213
146
|
});
|
|
214
147
|
}
|
|
215
148
|
router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
216
|
-
var _a, _b;
|
|
217
149
|
const { udid } = req.params;
|
|
218
150
|
const { x, y } = req.body;
|
|
219
151
|
const device = yield getDeviceInfo(udid);
|
|
220
152
|
if (!device)
|
|
221
153
|
return res.status(404).send('Device not found');
|
|
222
154
|
const manager = yield getDeviceManagerForPlatform(device.platform);
|
|
223
|
-
if (isOtherServersPhone(device)) {
|
|
224
|
-
const target = buildProxyUrl(device.host, req);
|
|
225
|
-
if (!target)
|
|
226
|
-
return res.status(400).send({ error: 'Unsafe device host' });
|
|
227
|
-
logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
|
|
228
|
-
try {
|
|
229
|
-
yield forwardControl(device, req, target);
|
|
230
|
-
return res.status(200).send({ success: true });
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
155
|
if (manager && manager.tap) {
|
|
237
156
|
try {
|
|
238
157
|
yield manager.tap(udid, x, y);
|
|
@@ -257,26 +176,12 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
|
|
|
257
176
|
res.status(400).send('Manager not found or tap not supported');
|
|
258
177
|
}));
|
|
259
178
|
router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
260
|
-
var _a, _b;
|
|
261
179
|
const { udid } = req.params;
|
|
262
180
|
const { x, y, endX, endY, duration = 1000 } = req.body;
|
|
263
181
|
const device = yield getDeviceInfo(udid);
|
|
264
182
|
if (!device)
|
|
265
183
|
return res.status(404).send('Device not found');
|
|
266
184
|
const manager = yield getDeviceManagerForPlatform(device.platform);
|
|
267
|
-
if (isOtherServersPhone(device)) {
|
|
268
|
-
const target = buildProxyUrl(device.host, req);
|
|
269
|
-
if (!target)
|
|
270
|
-
return res.status(400).send({ error: 'Unsafe device host' });
|
|
271
|
-
logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
|
|
272
|
-
try {
|
|
273
|
-
yield forwardControl(device, req, target);
|
|
274
|
-
return res.status(200).send({ success: true });
|
|
275
|
-
}
|
|
276
|
-
catch (err) {
|
|
277
|
-
return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
185
|
if (manager && manager.swipe) {
|
|
281
186
|
try {
|
|
282
187
|
yield manager.swipe(udid, x, y, endX, endY, duration);
|
|
@@ -290,26 +195,12 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
|
|
|
290
195
|
res.status(400).send('Manager not found or swipe not supported');
|
|
291
196
|
}));
|
|
292
197
|
router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
293
|
-
var _a, _b;
|
|
294
198
|
const { udid } = req.params;
|
|
295
199
|
const { text } = req.body;
|
|
296
200
|
const device = yield getDeviceInfo(udid);
|
|
297
201
|
if (!device)
|
|
298
202
|
return res.status(404).send('Device not found');
|
|
299
203
|
const manager = yield getDeviceManagerForPlatform(device.platform);
|
|
300
|
-
if (isOtherServersPhone(device)) {
|
|
301
|
-
const target = buildProxyUrl(device.host, req);
|
|
302
|
-
if (!target)
|
|
303
|
-
return res.status(400).send({ error: 'Unsafe device host' });
|
|
304
|
-
logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
|
|
305
|
-
try {
|
|
306
|
-
yield forwardControl(device, req, target);
|
|
307
|
-
return res.status(200).send({ success: true });
|
|
308
|
-
}
|
|
309
|
-
catch (err) {
|
|
310
|
-
return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
204
|
if (manager && manager.typeText) {
|
|
314
205
|
try {
|
|
315
206
|
yield manager.typeText(udid, text);
|
|
@@ -323,26 +214,12 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
|
|
|
323
214
|
res.status(400).send('Manager not found or typeText not supported');
|
|
324
215
|
}));
|
|
325
216
|
router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
326
|
-
var _a, _b;
|
|
327
217
|
const { udid } = req.params;
|
|
328
218
|
const { keyCode } = req.body;
|
|
329
219
|
const device = yield getDeviceInfo(udid);
|
|
330
220
|
if (!device)
|
|
331
221
|
return res.status(404).send('Device not found');
|
|
332
222
|
const manager = yield getDeviceManagerForPlatform(device.platform);
|
|
333
|
-
if (isOtherServersPhone(device)) {
|
|
334
|
-
const target = buildProxyUrl(device.host, req);
|
|
335
|
-
if (!target)
|
|
336
|
-
return res.status(400).send({ error: 'Unsafe device host' });
|
|
337
|
-
logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
|
|
338
|
-
try {
|
|
339
|
-
yield forwardControl(device, req, target);
|
|
340
|
-
return res.status(200).send({ success: true });
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
223
|
if (manager && manager.pressKey) {
|
|
347
224
|
try {
|
|
348
225
|
yield manager.pressKey(udid, keyCode);
|
|
@@ -426,26 +303,12 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
|
|
|
426
303
|
res.status(400).send('Manager not found or setClipboard not supported');
|
|
427
304
|
}));
|
|
428
305
|
router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
|
|
429
|
-
var _a, _b;
|
|
430
306
|
const { udid } = req.params;
|
|
431
307
|
const { x, y, duration = 1000 } = req.body;
|
|
432
308
|
const device = yield getDeviceInfo(udid);
|
|
433
309
|
if (!device)
|
|
434
310
|
return res.status(404).send('Device not found');
|
|
435
311
|
const manager = yield getDeviceManagerForPlatform(device.platform);
|
|
436
|
-
if (isOtherServersPhone(device)) {
|
|
437
|
-
const target = buildProxyUrl(device.host, req);
|
|
438
|
-
if (!target)
|
|
439
|
-
return res.status(400).send({ error: 'Unsafe device host' });
|
|
440
|
-
logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
|
|
441
|
-
try {
|
|
442
|
-
yield forwardControl(device, req, target);
|
|
443
|
-
return res.status(200).send({ success: true });
|
|
444
|
-
}
|
|
445
|
-
catch (err) {
|
|
446
|
-
return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
312
|
if (manager && manager.touchAndHold) {
|
|
450
313
|
try {
|
|
451
314
|
yield manager.touchAndHold(udid, x, y, duration);
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.NODE_CONTROL_TIMEOUT_MS = exports.ANSWERED_HERE = exports.NODE_FORWARDED_CONTROL = void 0;
|
|
16
|
+
exports.safeNodeOrigin = safeNodeOrigin;
|
|
17
|
+
exports.nodeControlUrl = nodeControlUrl;
|
|
18
|
+
exports.isOtherServersPhone = isOtherServersPhone;
|
|
19
|
+
exports.nodePhoneControl = nodePhoneControl;
|
|
20
|
+
const typedi_1 = require("typedi");
|
|
21
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
22
|
+
const PluginContext_1 = require("../../PluginContext");
|
|
23
|
+
const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
|
|
24
|
+
const actor_1 = require("../../services/device-access/actor");
|
|
25
|
+
const hubSessionToken_1 = require("../../gateway/hubSessionToken");
|
|
26
|
+
const forwardToNode_1 = require("../../gateway/forwardToNode");
|
|
27
|
+
const device_store_1 = require("../../data-service/device-store");
|
|
28
|
+
const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
|
|
29
|
+
const controlDevice_1 = require("../../middleware/controlDevice");
|
|
30
|
+
/**
|
|
31
|
+
* /control on another server's phone: a node's, or a cloud provider's.
|
|
32
|
+
*
|
|
33
|
+
* The handlers in control.ts act on this server's own phones, with this
|
|
34
|
+
* server's adb and go-ios. On a hub they used to run for a node's phone too,
|
|
35
|
+
* against a phone the hub doesn't have: only five actions were sent on to the
|
|
36
|
+
* node. This sits after the team and ownership guards and decides every
|
|
37
|
+
* request for another server's phone before any handler runs, so an action
|
|
38
|
+
* added later is refused for it until someone puts it on a list below.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Sent on to the phone's node, and the node's answer relayed unchanged. Each
|
|
42
|
+
* is about the phone alone. The preview's are too: the node holds the phone
|
|
43
|
+
* for the preview, counts its viewers and releases it, by its own rules and
|
|
44
|
+
* with the hub's user, so none of that is kept here.
|
|
45
|
+
*/
|
|
46
|
+
exports.NODE_FORWARDED_CONTROL = [
|
|
47
|
+
'POST tap',
|
|
48
|
+
'POST swipe',
|
|
49
|
+
'POST text',
|
|
50
|
+
'POST keyevent',
|
|
51
|
+
'POST touchandhold',
|
|
52
|
+
'GET screenshot',
|
|
53
|
+
'GET clipboard',
|
|
54
|
+
'POST clipboard',
|
|
55
|
+
'POST lock',
|
|
56
|
+
'POST unlock',
|
|
57
|
+
'GET display',
|
|
58
|
+
'POST uninstall',
|
|
59
|
+
'GET apps',
|
|
60
|
+
'GET logs',
|
|
61
|
+
'POST shell',
|
|
62
|
+
'GET inspector/snapshot',
|
|
63
|
+
'POST stream/start',
|
|
64
|
+
'GET stream/status',
|
|
65
|
+
'POST stream/leave',
|
|
66
|
+
'POST stream/stop',
|
|
67
|
+
// The MJPEG stream itself: relayed for as long as the viewer watches.
|
|
68
|
+
'GET stream',
|
|
69
|
+
];
|
|
70
|
+
/**
|
|
71
|
+
* Answered by this server's own handler.
|
|
72
|
+
* - appium-session: the session runs through this server, which routes its
|
|
73
|
+
* commands, so its id and base path are this server's.
|
|
74
|
+
* - stream/ticket: the viewer's ticket is this server's, minted after its
|
|
75
|
+
* team check. The stream it opens is relayed from the node: GET stream
|
|
76
|
+
* above, and the H.264 and logcat sockets (src/app/ws/nodeSocketRelay.ts),
|
|
77
|
+
* which get a ticket of the node's own.
|
|
78
|
+
*/
|
|
79
|
+
exports.ANSWERED_HERE = ['GET appium-session', 'POST stream/ticket'];
|
|
80
|
+
// Not on either list, and why, so nobody adds them as a plain forward:
|
|
81
|
+
// - upload-install is a multipart upload;
|
|
82
|
+
// - install-repository-app installs a file from this server's app library,
|
|
83
|
+
// which the node doesn't have;
|
|
84
|
+
// - install takes a path, which names a file on one machine only;
|
|
85
|
+
// - omni-scan and test-locator run this server's AI settings on a
|
|
86
|
+
// screenshot.
|
|
87
|
+
/**
|
|
88
|
+
* A node that hasn't started answering in this long is treated as gone. Once
|
|
89
|
+
* it has, the answer may take as long as it takes: a stream lasts as long as
|
|
90
|
+
* its viewer.
|
|
91
|
+
*/
|
|
92
|
+
exports.NODE_CONTROL_TIMEOUT_MS = 60000;
|
|
93
|
+
// Cloud metadata endpoints — never proxy to these regardless of caller.
|
|
94
|
+
const FORBIDDEN_PROXY_HOSTS = new Set([
|
|
95
|
+
'169.254.169.254', // AWS/Azure/GCP IMDS
|
|
96
|
+
'metadata.google.internal',
|
|
97
|
+
'metadata.goog',
|
|
98
|
+
'100.100.100.200', // Alibaba ECS metadata
|
|
99
|
+
'fd00:ec2::254', // AWS IMDSv6
|
|
100
|
+
]);
|
|
101
|
+
/**
|
|
102
|
+
* The node's origin from the phone's reported host: scheme, host and port
|
|
103
|
+
* only, dropping any path, query or fragment the host string carried. Null
|
|
104
|
+
* for a cloud-metadata target or a scheme other than http(s).
|
|
105
|
+
*/
|
|
106
|
+
function safeNodeOrigin(deviceHost) {
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = new URL(deviceHost);
|
|
110
|
+
}
|
|
111
|
+
catch (_a) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
115
|
+
return null;
|
|
116
|
+
if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
|
|
117
|
+
return null;
|
|
118
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
119
|
+
}
|
|
120
|
+
/** The node's own URL for this request, or null if the host is unsafe. */
|
|
121
|
+
function nodeControlUrl(deviceHost, req) {
|
|
122
|
+
const origin = safeNodeOrigin(deviceHost);
|
|
123
|
+
if (!origin)
|
|
124
|
+
return null;
|
|
125
|
+
const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
|
|
126
|
+
return `${origin}${withoutTicket(forwardPath)}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The path without a `ticket` query parameter. A viewer's ticket is this
|
|
130
|
+
* server's, already spent here, and not the node's business: the node gets
|
|
131
|
+
* the hub's token instead.
|
|
132
|
+
*/
|
|
133
|
+
function withoutTicket(path) {
|
|
134
|
+
const q = path.indexOf('?');
|
|
135
|
+
if (q < 0)
|
|
136
|
+
return path;
|
|
137
|
+
const params = new URLSearchParams(path.slice(q + 1));
|
|
138
|
+
if (!params.has('ticket'))
|
|
139
|
+
return path;
|
|
140
|
+
params.delete('ticket');
|
|
141
|
+
const query = params.toString();
|
|
142
|
+
return query ? `${path.slice(0, q)}?${query}` : path.slice(0, q);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* A phone another server drives. Decided by the phone's own row (its nodeId,
|
|
146
|
+
* else its exact host against this server's own hosts), never by the Host
|
|
147
|
+
* header the caller used: a substring match on that header forwarded a lab's
|
|
148
|
+
* own phone back to itself, with no login, whenever the lab was reached as
|
|
149
|
+
* `localhost`.
|
|
150
|
+
*/
|
|
151
|
+
function isOtherServersPhone(device) {
|
|
152
|
+
if (!device.host)
|
|
153
|
+
return false;
|
|
154
|
+
const context = typedi_1.Container.get(PluginContext_1.PluginContext);
|
|
155
|
+
return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
|
|
156
|
+
}
|
|
157
|
+
function originOf(host) {
|
|
158
|
+
try {
|
|
159
|
+
return new URL(host).origin;
|
|
160
|
+
}
|
|
161
|
+
catch (_a) {
|
|
162
|
+
return host;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function refuse(res, error, message) {
|
|
166
|
+
return res.status(501).json({ success: false, error, message });
|
|
167
|
+
}
|
|
168
|
+
function nodePhoneControl(deps = {}) {
|
|
169
|
+
var _a, _b, _c;
|
|
170
|
+
const findDevice = (_a = deps.findDevice) !== null && _a !== void 0 ? _a : controlDevice_1.findControlDeviceInStore;
|
|
171
|
+
const timeoutMs = (_b = deps.timeoutMs) !== null && _b !== void 0 ? _b : exports.NODE_CONTROL_TIMEOUT_MS;
|
|
172
|
+
const markNodeBusy = (_c = deps.markNodeBusy) !== null && _c !== void 0 ? _c : ((udid, host) => device_store_1.DeviceStoreFactory.getStore().markNodeBusy(udid, host));
|
|
173
|
+
return function (req, res, next) {
|
|
174
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
175
|
+
var _a;
|
|
176
|
+
const action = (0, controlDevice_1.controlAction)(req);
|
|
177
|
+
if (!action)
|
|
178
|
+
return next();
|
|
179
|
+
const udid = (0, controlDevice_1.readControlUdid)(req, res, 'nodePhoneControl');
|
|
180
|
+
if (udid === null)
|
|
181
|
+
return;
|
|
182
|
+
if (!udid)
|
|
183
|
+
return next();
|
|
184
|
+
// The guards' memoized lookup: usually no query of its own.
|
|
185
|
+
let device;
|
|
186
|
+
try {
|
|
187
|
+
device = yield (0, controlDevice_1.lookupControlDevice)(res, udid, findDevice);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
// Falling through would let the handler, with its own lookup, run a
|
|
191
|
+
// node's phone here: fail closed.
|
|
192
|
+
// Answered as every /control lookup failure is.
|
|
193
|
+
logger_1.default.error(`nodePhoneControl: device lookup failed for ${udid}: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
|
|
194
|
+
return res.status(503).json((0, deviceAccessPolicy_1.ownershipUnavailableBody)());
|
|
195
|
+
}
|
|
196
|
+
if (!device || !isOtherServersPhone(device))
|
|
197
|
+
return next();
|
|
198
|
+
const key = `${req.method} ${action}`;
|
|
199
|
+
if (exports.ANSWERED_HERE.includes(key))
|
|
200
|
+
return next();
|
|
201
|
+
if (device.cloud) {
|
|
202
|
+
return refuse(res, 'not_available_for_cloud_phone', "This phone is a cloud provider's. Device control isn't available for it here.");
|
|
203
|
+
}
|
|
204
|
+
const node = originOf(device.host);
|
|
205
|
+
if (!exports.NODE_FORWARDED_CONTROL.includes(key)) {
|
|
206
|
+
return refuse(res, 'not_available_through_hub', `This phone is on node ${node}, and the hub doesn't pass ${action} on to nodes yet.`);
|
|
207
|
+
}
|
|
208
|
+
return forward(req, res, { udid, device, node, timeoutMs, markNodeBusy });
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function forward(req, res, target) {
|
|
213
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
214
|
+
var _a, _b, _c, _d, _e, _f;
|
|
215
|
+
const { udid, device, node, timeoutMs } = target;
|
|
216
|
+
const url = nodeControlUrl(device.host, req);
|
|
217
|
+
if (!url)
|
|
218
|
+
return res.status(400).json({ error: 'Unsafe device host' });
|
|
219
|
+
const actor = (0, actor_1.resolveActor)(req);
|
|
220
|
+
const headers = {
|
|
221
|
+
accept: (_a = req.headers.accept) !== null && _a !== void 0 ? _a : 'application/json',
|
|
222
|
+
'accept-encoding': 'identity',
|
|
223
|
+
};
|
|
224
|
+
// Null when this server can't sign (it warns once): the call goes without,
|
|
225
|
+
// which a node with auth disabled accepts and one with auth enabled refuses.
|
|
226
|
+
const token = actor.userId
|
|
227
|
+
? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
|
|
228
|
+
userId: actor.userId,
|
|
229
|
+
isAdmin: actor.isAdmin,
|
|
230
|
+
udid,
|
|
231
|
+
host: device.host,
|
|
232
|
+
})
|
|
233
|
+
: null;
|
|
234
|
+
if (token)
|
|
235
|
+
headers[hubSessionToken_1.HUB_TOKEN_HEADER] = token;
|
|
236
|
+
let body;
|
|
237
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
238
|
+
body = Buffer.from(JSON.stringify((_b = req.body) !== null && _b !== void 0 ? _b : {}));
|
|
239
|
+
headers['content-type'] = 'application/json';
|
|
240
|
+
}
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
let timedOut = false;
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
timedOut = true;
|
|
245
|
+
controller.abort();
|
|
246
|
+
}, timeoutMs);
|
|
247
|
+
// The caller left: stop waiting for the node on their behalf.
|
|
248
|
+
const onClose = () => {
|
|
249
|
+
if (!res.writableFinished)
|
|
250
|
+
controller.abort();
|
|
251
|
+
};
|
|
252
|
+
res.on('close', onClose);
|
|
253
|
+
logger_1.default.debug(`Forwarding ${req.method} ${(0, controlDevice_1.controlAction)(req)} for ${udid} to node ${node}`);
|
|
254
|
+
try {
|
|
255
|
+
const upstream = yield (0, forwardToNode_1.sendToNode)({
|
|
256
|
+
url,
|
|
257
|
+
method: req.method,
|
|
258
|
+
headers,
|
|
259
|
+
body,
|
|
260
|
+
signal: controller.signal,
|
|
261
|
+
});
|
|
262
|
+
// The node has answered: from here the answer takes as long as it takes.
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
const status = (_c = upstream.statusCode) !== null && _c !== void 0 ? _c : 502;
|
|
265
|
+
if (req.method === 'POST' && (0, controlDevice_1.controlAction)(req) === 'stream/start' && status < 300) {
|
|
266
|
+
// The node holds the phone for the preview now, and says so in its
|
|
267
|
+
// next report, up to an interval away. Until then this server could
|
|
268
|
+
// hand the phone to a session the node would refuse.
|
|
269
|
+
yield target.markNodeBusy(udid, device.host).catch((e) => {
|
|
270
|
+
var _a;
|
|
271
|
+
logger_1.default.warn(`nodePhoneControl: could not mark ${udid} busy for its node: ${(_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e}`);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
yield (0, forwardToNode_1.relayAnswer)(upstream, res, false);
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
if (res.headersSent || res.destroyed) {
|
|
278
|
+
res.destroy();
|
|
279
|
+
}
|
|
280
|
+
else if (timedOut) {
|
|
281
|
+
res.status(504).json({
|
|
282
|
+
success: false,
|
|
283
|
+
error: 'node_timeout',
|
|
284
|
+
message: `Node ${node} didn't answer within ${Math.round(timeoutMs / 1000)} s.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
logger_1.default.warn(`nodePhoneControl: node ${node} unreachable for ${udid}: ${(_d = e === null || e === void 0 ? void 0 : e.message) !== null && _d !== void 0 ? _d : e}`);
|
|
289
|
+
res.status(502).json({
|
|
290
|
+
success: false,
|
|
291
|
+
error: 'node_unreachable',
|
|
292
|
+
message: `Xenon could not reach node ${node} for this phone: ${(_f = (_e = e === null || e === void 0 ? void 0 : e.code) !== null && _e !== void 0 ? _e : e === null || e === void 0 ? void 0 : e.message) !== null && _f !== void 0 ? _f : e}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
res.off('close', onClose);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|