From d173e8905b852db763c14e7e3b501ba7b4e3a7b3 Mon Sep 17 00:00:00 2001 From: fnoop Date: Tue, 7 Apr 2020 11:37:09 +0100 Subject: [PATCH] Test for SSL state before creating SSL api client #173 --- src/App.vue | 94 +++++++----- .../modules/config/ConfigConnections.vue | 75 +++++++-- src/plugins/core/CoreApi.js | 144 +++++++++++------- src/store/modules/core.js | 12 +- 4 files changed, 211 insertions(+), 114 deletions(-) diff --git a/src/App.vue b/src/App.vue index 4fd89f7..aafbb74 100644 --- a/src/App.vue +++ b/src/App.vue @@ -84,6 +84,19 @@ export default { }, deep: true }, + '$store.state.core.apiState': { + handler: function (newValue) { + for (const api of Object.keys(newValue)) { + // If schema has been fetched, proceed to create Status query/subscription + if (newValue[api].schemaready === true) { + this.logDebug(`Creating status query/subscription for API ${this.apis[api].name}`) + this.createQuery('Status', statusQuery, api, null, null, this.processStatusQuery) + this.createSubscription('Status', statusSubscription, api, null, null, this.processStatusSubscription) + } + } + }, + deep: true + }, // Watch discoveries state for any change and process '$store.state.data.discoveries': { handler: function (newValue) { @@ -114,21 +127,45 @@ export default { mounted () { this.logBanner('** Welcome to Maverick Web GCS **') - // Connect to defined APIs - this.createClients() + // Set initital dark theme state + this.$vuetify.theme.dark = this.isDark // Create a default discovery agent if one doesn't exist this.defaultDiscovery() // Create websocket connections for all defined discovery agents this.createDiscoveries() - // Set initital dark theme state - this.$vuetify.theme.dark = this.isDark + // Connect to defined APIs + this.createClients() }, methods: { + createApi (data) { + if (!this.apis[data.uuid] && data.service_type == 'maverick-api') { + this.logInfo(`Creating new API connection from discovered service: ${data.name}`) + let apidata = { + authToken: null, + colorLight: "rgba(166,11,11,0.3)", + colorDark: "rgba(166,11,11,0.9)", + httpEndpoint: data.httpEndpoint, + httpsEndpoint: data.httpsEndpoint ? data.httpsEndpoint : null, + hostname: data.hostname, + key: data.uuid, + name: data.name, + schemaEndpoint: data.schemaEndpoint, + schemasEndpoint: data.schemasEndpoint ? data.schemasEndpoint : null, + wsEndpoint: data.wsEndpoint, + wssEndpoint: data.wssEndpoint ? data.wssEndpoint : null, + websocketsOnly: data.websocketsOnly + } + this.$store.commit('data/addApi', {key: apidata.key, data: apidata}) + if (this.uiSettings.notifyDiscovery) { + this.$toast.info(`Created new API connection from discovery: ${data.name}`) + } + } + }, checkApis () { // If an api hasn't been seen for more than 10 seconds, mark it as dead for (const api in this.apis) { - let lastseen = (this.apistate && this.apistate.hasOwnProperty(api)) ? this.apistate[api].lastseen : 0 + let lastseen = this.$store.state.core.apiSeen.hasOwnProperty(api) ? this.$store.state.core.apiSeen[api] : 0 if (this.appVisible && performance.now() - lastseen > 10000) { // this.logInfo(`deadapi? api: ${api}, timestamp: ${lastseen}`) this.$store.commit('core/setApiState', { api: api, field: 'state', value: false }) @@ -141,16 +178,6 @@ export default { if (!this.$apollo.provider.clients.hasOwnProperty(api)) { this.createClient(api, this.apis[api]) } - this.createQuery('Status', statusQuery, api, null, null, this.processStatusQuery) - this.createSubscription('Status', statusSubscription, api, null, null, this.processStatusSubscription) - } - }, - defaultDiscovery() { - if (!this.$store.state.data.discoveries[window.location.hostname]) { - const ws_url = 'ws://' + window.location.hostname + ':6001' - const wss_url = 'wss://' + window.location.hostname + ':6002' - this.logInfo(`Creating default discovery agent: ${ws_url} :: ${wss_url}`) - this.$store.commit('data/addDiscovery', { key: window.location.hostname, data: { ws_url: ws_url, wss_url: wss_url, host: window.location.hostname } }) } }, createDiscoveries() { @@ -187,35 +214,13 @@ export default { } } }, - createApi (data) { - if (!this.apis[data.uuid] && data.service_type == 'maverick-api') { - this.logInfo(`Creating new API connection from discovered service: ${data.name}`) - let apidata = { - key: data.uuid, - "httpEndpoint": data.httpEndpoint, - "httpsEndpoint": data.httpsEndpoint ? data.httpsEndpoint : null, - "wsEndpoint": data.wsEndpoint, - "wssEndpoint": data.wssEndpoint ? data.wssEndpoint : null, - "schemaEndpoint": data.schemaEndpoint, - "schemasEndpoint": data.schemasEndpoint ? data.schemasEndpoint : null, - "websocketsOnly": data.websocketsOnly, - "name": data.name, - "colorLight": "rgba(166,11,11,0.3)", - "colorDark": "rgba(166,11,11,0.9)", - "authToken": null - } - this.$store.commit('data/addApi', {key: apidata.key, data: apidata}) - if (this.uiSettings.notifyDiscovery) { - this.$toast.info(`Created new API connection from discovery: ${data.name}`) - } - } - }, createVideo (data) { if (!this.$store.state.data.videostreams[data.uuid] && data.service_type == 'webrtc') { this.logInfo(`Creating new Video stream from discovered service: ${data.name}`) let videodata = { key: data.uuid, name: data.name, + hostnamne: data.hostanme, webrtcEndpoint: data.wsEndpoint, enabled: false, action: 'start' @@ -226,6 +231,14 @@ export default { } } }, + defaultDiscovery() { + if (!this.$store.state.data.discoveries[window.location.hostname]) { + const ws_url = 'ws://' + window.location.hostname + ':6001' + const wss_url = 'wss://' + window.location.hostname + ':6002' + this.logInfo(`Creating default discovery agent: ${ws_url} :: ${wss_url}`) + this.$store.commit('data/addDiscovery', { key: window.location.hostname, data: { ws_url: ws_url, wss_url: wss_url, host: window.location.hostname } }) + } + }, /* processX() are async callbacks that process incoming GQL messages @@ -253,7 +266,7 @@ export default { this.createSubscription('MaverickService', maverickServicesSubscription, api, null, null, this.processServiceSubscription) }, 5000) } - if (this.apistate[api].lastseen === null) this.$store.commit('core/setApiState', {api: api, field: 'lastseen', value: performance.now() }) + this.$store.commit('core/setApiSeen', {api: api, lastseen: performance.now() }) if (!(api in this.$store.state.core.statusData)) { this.$store.commit('core/setStatusData', { api: api, message: data.data.Status }) } @@ -263,8 +276,7 @@ export default { const api = key.split('___')[0] // Store the message data and set the api state to active, for subsequent subscription callbacks // if (data.data && this.$store.state.core.apis[api].state !== true) this.$store.commit('data/setApiState', { api: api, value: true }) - // this.$store.commit('core/setApiSeen', { api: api, value: performance.now() }) - this.$store.commit('core/setApiState', {api: api, field: 'lastseen', value: performance.now()}) + this.$store.commit('core/setApiSeen', {api: api, lastseen: performance.now() }) if (data.data && this.$store.state.core.statusData[api] !== data.data.Status) { this.$store.commit('core/setStatusData', { api: api, message: data.data.Status }) } diff --git a/src/components/modules/config/ConfigConnections.vue b/src/components/modules/config/ConfigConnections.vue index f11cd58..5a35041 100644 --- a/src/components/modules/config/ConfigConnections.vue +++ b/src/components/modules/config/ConfigConnections.vue @@ -99,7 +99,10 @@ div v-list-item-content {{ apistate[item.key].icon }} v-icon(v-if="apistate[item.key].icon" color='success') {{ apistate[item.key].icon }} v-icon(v-else color='error') mdi-alert-circle-outline - + v-divider + // img(:src="`https://${item.hostname}/img/misc/onepixel.png`" @error="imgError('http', item)" style="display:none") + // img(:src="`http://${item.hostname}/img/misc/onepixel.png`" @error="imgError('https', item)" style="display:none") + v-dialog(v-model="dialog" max-width="600px") v-card v-card-title.headline(class="primary" primary-title) @@ -157,18 +160,12 @@ export default { return Object.values(this.apis) } }, + mounted () { + for (const api of Object.keys(this.apis)) { + // this.testSsl(api) + } + }, methods: { - lastseen (api) { - let lastseen = (this.apistate && this.apistate.hasOwnProperty(api)) ? this.apistate[api].lastseen : 0 - return (performance.now() - lastseen) - }, - save(apiData) { - this.$store.commit('data/setApiData', {api: apiData.key, data: apiData}) - // If any of the endpoints have changed, destroy and recreate the client - // this.deleteQueries(apiData.key) - // delete this.$apollo.provider.clients[apiData.key] - // this.createClient(apiData.key+'new', apiData) - }, connect(apiData) { this.logDebug('Connecting: ' + apiData.key) if (!(this.$apollo.provider.clients[apiData.key])) { @@ -193,6 +190,22 @@ export default { } this.$store.commit('data/addApi', {key: data.key, data: data}) }, + /* + imgError (protocol, item) { + this.logDebug(`protocol: ${protocol}`) + this.logDebug(item) + if (event.type == "error" && item.hasOwnProperty('hostname')) { + this.logError(`Error connecting to API (${item.name}) over SSL.`) + this.logDebug(event) + } else if (! item.hasOwnProperty('hostname')) { + this.logError(`Error: This API definition (${item.name}) does not have a hostname set. Please update maverick-api.`) + } + }, + */ + lastseen (api) { + let lastseen = (this.$store.state.core.apiSeen.hasOwnProperty(api)) ? this.$store.state.core.apiSeen[api] : 0 + return (performance.now() - lastseen) + }, remove(item) { this.deleteitem = item this.deleteDialog = true @@ -207,6 +220,44 @@ export default { delete this.$apollo.provider.clients[this.deleteitem.key] this.$store.commit('data/removeApi', this.deleteitem.key) this.deleteitem = null + }, + save(apiData) { + this.$store.commit('data/setApiData', {api: apiData.key, data: apiData}) + // If any of the endpoints have changed, destroy and recreate the client + // this.deleteQueries(apiData.key) + // delete this.$apollo.provider.clients[apiData.key] + // this.createClient(apiData.key+'new', apiData) + }, + async testSsl(api) { + // Define an internal method function promise that fetches the image and watches for completion or error + function testImage(imgPath) { + return new Promise((resolve, reject) => { + const testImg = new Image() + testImg.addEventListener("load", () => resolve(testImg)) + testImg.addEventListener("error", err => reject(err)) + testImg.src = imgPath + }) + } + + const item = this.apis[api] + + /* + // http can't be loaded from https link + let httpState = null + let httpLoad = testImage(`http://${item.hostname}/img/misc/onepixel.png`) + .then(img => { httpState = true }) + .catch(err => { this.logDebug('Error loading http image'); httpState = false }) + await httpLoad + */ + + let httpsState = null + let httpsLoad = testImage(`https://${item.hostname}/img/misc/onepixel.png`) + .then(img => { httpsState = true }) + .catch(err => { this.logDebug('Error loading https image'); httpsState = false }) + await httpsLoad + + this.logDebug(`SSL state for ${item.name}: ${httpsState}`) + } } } diff --git a/src/plugins/core/CoreApi.js b/src/plugins/core/CoreApi.js index 438d3cd..5cfcfb4 100644 --- a/src/plugins/core/CoreApi.js +++ b/src/plugins/core/CoreApi.js @@ -106,67 +106,30 @@ const plugin = { this.$store.commit('core/clearGraphqlVerified', api) }, - isApiReady (api) { - try { - return this.apistate[api].state === true && this.apistate[api].schemaready === true - } catch { - return false - } - }, - - verifyQuery (gql, api = this.activeApi, unknownDefault = false) { - let gqlHash = this.hashCode(print(gql)) - let alreadyVerified = this.$store.getters['core/graphqlSchemaVerified'](api, gqlHash) - - if (alreadyVerified !== undefined) { - // query has already been verified for this api - return alreadyVerified - } - - // attempt to validate the query - let graphqlSchema = this.$store.getters['core/graphqlSchema'](api) - if (graphqlSchema === undefined) { - // graphqlSchema has not been fetched for this api, return unknownDefault - return unknownDefault - } - // this.logDebug(graphqlSchema) - let validationErrors = undefined - try { - validationErrors = validate(graphqlSchema, gql) - } catch (err) { - this.logDebug(`Validation error: ${err}`) - return unknownDefault - } - let valid = false - if (validationErrors == undefined || validationErrors.length == 0) { - valid = true - } - this.$store.commit('core/updateGraphqlVerified', {api:api, hash:gqlHash, ret:valid}) - return valid - }, - - hashCode(s) { - let h - for(let i = 0; i < s.length; i++) - h = Math.imul(31, h) + s.charCodeAt(i) | 0 - return h.toString() - }, - - async fetchClientSchema (api, clientdata) { - await this.$store.dispatch("core/fetchSchema", {api: api, schemaEndpoint: clientdata.schemasEndpoint ? clientdata.schemasEndpoint : clientdata.schemaEndpoint}).then(() => { - this.logDebug('Schema fetch has been dispatched for api: ' + api) - }) - }, - // TODO: This method name clashes with graphql createClient, should be renamed async createClient (api, clientdata) { // Add a vuex apis entry this.$store.commit('core/addApiState', api) + + // Check ssl connection + let httpsState = false + let httpsLoad = this.testImage(`https://${this.apis[api].hostname}/img/ssltest.png`) + .then(img => { httpsState = true }) + .catch(err => { httpsState = false }) + await httpsLoad + this.$store.commit('core/setApiState', {api: api, field: 'sslstate', value: httpsState}) + this.logDebug(`SSL test for API host ${this.apis[api].hostname} result: ${httpsState}`) + // Add an apollo client let client = null let schemaFetchPromise = null if (window.location.protocol == 'https:') { if (clientdata.httpsEndpoint && clientdata.wssEndpoint && clientdata.schemasEndpoint) { + if (httpsState === false) { + this.logError(`SSL test for API host ${this.apis[api].hostname} failed, not creating API client`) + return false + } + // Try to create client client = createClient({ httpEndpoint: clientdata.httpsEndpoint, wsEndpoint: clientdata.wssEndpoint, @@ -203,6 +166,7 @@ const plugin = { } // Wait for the fetch to resolve before setting the api state await schemaFetchPromise + this.logDebug(`Completed schema fetch for API ${this.apis[api].name}`) this.$store.commit('core/setApiState', {api: api, field: 'schemaready', value: true}) }, @@ -212,7 +176,7 @@ const plugin = { const queryKey = [api, message, varvalues].join('___') // If a query with the calculated key doesn't exist, and the client appears to exist, then create the query if (!this.$apollo.queries[queryKey] && this.$apollo.provider.clients[api]) { - this.logDebug(`Creating GQL Query: api: ${api}, message: ${message}, queryKey: ${queryKey}, container: ${container}`) + this.logDebug(`Creating GQL Query: api: ${this.apis[api].name}, message: ${message}, queryKey: ${queryKey}, container: ${container}`) // If a callback function has been passed use it as the result processor, otherwise use a default function const resultFunction = (callback instanceof Function) ? callback : function (data, key) { const cbapi = key.split('___')[0] @@ -248,7 +212,7 @@ const plugin = { const varvalues = variables && Object.values(variables) ? Object.values(variables).join('~') : '' const subKey = [api, message, varvalues].join('___') if (!this.$apollo.subscriptions[subKey] && this.$apollo.provider.clients[api]) { - this.logDebug(`Creating GQL Subscription: api: ${api}, message: ${message}, subKey: ${subKey}`) + this.logDebug(`Creating GQL Subscription: api: ${this.apis[api].name}, message: ${message}, subKey: ${subKey}`) // If a callback function has been passed use it as the result processor, otherwise use a default function const resultFunction = (callback instanceof Function) ? callback : function (data, key) { const cbapi = key.split('___')[0] @@ -281,6 +245,27 @@ const plugin = { } }, + async fetchClientSchema (api, clientdata) { + await this.$store.dispatch("core/fetchSchema", {api: api, schemaEndpoint: clientdata.schemasEndpoint ? clientdata.schemasEndpoint : clientdata.schemaEndpoint}).then(() => { + this.logDebug('Schema fetch has been dispatched for api: ' + api) + }) + }, + + hashCode(s) { + let h + for(let i = 0; i < s.length; i++) + h = Math.imul(31, h) + s.charCodeAt(i) | 0 + return h.toString() + }, + + isApiReady (api) { + try { + return this.apistate[api].state === true && this.apistate[api].schemaready === true + } catch { + return false + } + }, + mutateQuery (api, query, variables) { let mutateFields = { client: api, @@ -319,6 +304,24 @@ const plugin = { */ }, + // Define a promise that fetches the image and watches for completion or error + testImage(imgPath) { + return new Promise((resolve, reject) => { + const testImg = new Image() + testImg.addEventListener("load", () => resolve(testImg)) + testImg.addEventListener("error", err => reject(err)) + testImg.src = imgPath + }) + }, + + tickCross (boolean) { + if (boolean) { + return "v-icon(color='green') mdi-check-circle-outline" + } else { + return "v-icon(color='red') mdi-alert-circle-outline" + } + }, + vehicleIcon (vehicleType) { const iconPath = 'img/icons/vehicleIcons/' if (vehicleType === 'Copter' || vehicleType === 'Quadrotor') { @@ -338,12 +341,35 @@ const plugin = { } }, - tickCross (boolean) { - if (boolean) { - return "v-icon(color='green') mdi-check-circle-outline" - } else { - return "v-icon(color='red') mdi-alert-circle-outline" + verifyQuery (gql, api = this.activeApi, unknownDefault = false) { + let gqlHash = this.hashCode(print(gql)) + let alreadyVerified = this.$store.getters['core/graphqlSchemaVerified'](api, gqlHash) + + if (alreadyVerified !== undefined) { + // query has already been verified for this api + return alreadyVerified + } + + // attempt to validate the query + let graphqlSchema = this.$store.getters['core/graphqlSchema'](api) + if (graphqlSchema === undefined) { + // graphqlSchema has not been fetched for this api, return unknownDefault + return unknownDefault } + // this.logDebug(graphqlSchema) + let validationErrors = undefined + try { + validationErrors = validate(graphqlSchema, gql) + } catch (err) { + this.logDebug(`Validation error: ${err}`) + return unknownDefault + } + let valid = false + if (validationErrors == undefined || validationErrors.length == 0) { + valid = true + } + this.$store.commit('core/updateGraphqlVerified', {api:api, hash:gqlHash, ret:valid}) + return valid } } }) diff --git a/src/store/modules/core.js b/src/store/modules/core.js index 80c5cd9..cd806c9 100644 --- a/src/store/modules/core.js +++ b/src/store/modules/core.js @@ -7,6 +7,7 @@ import axios from 'axios' const state = { apiState: {}, + apiSeen: {}, navColor: null, navDrawer: null, navDrawerEnable: true, @@ -89,11 +90,18 @@ const getters = { const mutations = { addApiState (state, api) { - Vue.set(state.apiState, api, {state: false, schemaready: false, auth: false, uuid: null, icon: null, lastseen: null}) + Vue.set(state.apiState, api, {state: false, schemaready: false, auth: false, uuid: null, icon: null}) }, setApiState (state, data) { state.apiState[data.api][data.field] = data.value }, + setApiSeen (state, data) { + if (!state.apiSeen.hasOwnProperty(data.api)) { + Vue.set(state.apiSeen, data.api, data.lastseen) + } else { + state.apiSeen[data.api] = data.lastseen + } + }, setApiUuid (state, data) { state.apiState[data.api].uuid = data.value }, @@ -149,7 +157,7 @@ const mutations = { }, clearGraphqlVerified(state, api) { state.graphqlSchema[api].verified = {} - }, + } } export default {