diff --git a/lib/main.js b/lib/main.js index 19624e2..7e761fc 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,28 +1,29 @@ -export * from './exithandler.js' -export * from './mqtt.js' +import './processhandlers.js' +import './mqtt.js' import state from './state.js' import ring from './ring.js' import utils from './utils.js' -import tokenApp from './tokenapp.js' +import webui from './webui.js' import chalk from 'chalk' import isOnline from 'is-online' import debugModule from 'debug' + const debug = debugModule('ring-mqtt') export default new class Main { constructor() { - // Hack to suppress spurious message from push-receiver during startup - console.warn = (data) => { - if (data.includes('PHONE_REGISTRATION_ERROR') || - data.startsWith('Retry...') || - data.includes('Message dropped as it could not be decrypted:') - ) { - return + console.warn = (message) => { + const suppressedMessages = [ + /^Retry\.\.\./, + /PHONE_REGISTRATION_ERROR/, + /Message dropped as it could not be decrypted:/ + ] + + if (!suppressedMessages.some(suppressedMessages => suppressedMessages.test(message))) { + console.error(message) } - console.error(data) } - // Start event listeners utils.event.on('generated_token', (generatedToken) => { this.init(generatedToken) }) @@ -33,34 +34,45 @@ export default new class Main { async init(generatedToken) { if (!state.valid) { await state.init() - tokenApp.setSystemId(state.data.systemId) } - // Is there any usable token? - if (state.data.ring_token || generatedToken) { - // Wait for the network to be online and then attempt to connect to the Ring API using the token - while (!(await isOnline())) { - debug(chalk.yellow('Network is offline, waiting 10 seconds to check again...')) - await utils.sleep(10) - } + const hasToken = state.data.ring_token || generatedToken + if (!hasToken) { + this.handleNoToken() + return + } - if (!await ring.init(state, generatedToken)) { - debug(chalk.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI')) - debug(chalk.red('or wait 60 seconds to automatically retry authentication using the existing token')) - tokenApp.start() - await utils.sleep(60) - if (!ring.client) { - debug(chalk.yellow('Retrying authentication with existing saved token...')) - this.init() - } - } - } else { - if (process.env.RUNMODE === 'addon') { - debug(chalk.red('No refresh token was found in state file, generate a token using the addon Web UI')) - } else { - tokenApp.start() - debug(chalk.red('No refresh token was found in the state file, use the Web UI at http://:55123/ to generate a token.')) + await this.waitForNetwork() + await this.attemptRingConnection(generatedToken) + } + + async waitForNetwork() { + while (!(await isOnline())) { + debug(chalk.yellow('Network is offline, waiting 10 seconds to check again...')) + await utils.sleep(10) + } + } + + async attemptRingConnection(generatedToken) { + if (!await ring.init(state, generatedToken)) { + debug(chalk.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI')) + debug(chalk.red('or wait 60 seconds to automatically retry authentication using the existing token')) + webui.start(state.data.systemId) + await utils.sleep(60) + + if (!ring.client) { + debug(chalk.yellow('Retrying authentication with existing saved token...')) + this.init() } } } -} + + handleNoToken() { + if (process.env.RUNMODE === 'addon') { + debug(chalk.red('No refresh token was found in state file, generate a token using the addon Web UI')) + } else { + webui.start(state.data.systemId) + debug(chalk.red('No refresh token was found in the state file, use the Web UI at http://:55123/ to generate a token.')) + } + } +} \ No newline at end of file diff --git a/lib/exithandler.js b/lib/processhandlers.js similarity index 96% rename from lib/exithandler.js rename to lib/processhandlers.js index 3788345..962af33 100644 --- a/lib/exithandler.js +++ b/lib/processhandlers.js @@ -4,13 +4,12 @@ import ring from './ring.js' import debugModule from 'debug' const debug = debugModule('ring-mqtt') -export default new class ExitHandler { +export default new class ProcessHandlers { constructor() { this.init() } init() { - // Setup Exit Handlers process.on('exit', this.processExit.bind(null, 0)) process.on('SIGINT', this.processExit.bind(null, 0)) process.on('SIGTERM', this.processExit.bind(null, 0)) diff --git a/lib/tokenapp.js b/lib/tokenapp.js deleted file mode 100644 index b3f9b34..0000000 --- a/lib/tokenapp.js +++ /dev/null @@ -1,123 +0,0 @@ -import { RingRestClient } from 'ring-client-api/rest-client' -import utils from './utils.js' -import { dirname } from 'path' -import { fileURLToPath } from 'url' -import express from 'express' -import bodyParser from 'body-parser' -import chalk from 'chalk' -import debugModule from 'debug' -const debug = debugModule('ring-mqtt') - -export default new class TokenApp { - constructor() { - this.app = express() - this.listener = false - this.ringConnected = false - this.systemId = '' - - if (process.env.RUNMODE === 'addon') { - this.start() - } - - utils.event.on('ring_api_state', async (state) => { - if (state === 'connected') { - this.ringConnected = true - - // Only the addon leaves the web UI running all the time - if (process.env.RUNMODE !== 'addon') { - this.stop() - } - } else { - this.ringConnected = false - } - }) - } - - setSystemId(systemId) { - if (systemId) { - this.systemId = systemId - } - } - - // Super simple web service to acquire authentication tokens from Ring - async start() { - if (this.listener) { - return - } - - const webdir = dirname(fileURLToPath(new URL('.', import.meta.url)))+'/web' - let restClient - - this.listener = this.app.listen(55123, () => { - debug('Succesfully started the token generator web UI') - }) - - this.app.use(bodyParser.urlencoded({ extended: false })) - - this.app.get('/', (req, res) => { - res.cookie('systemId', `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${this.systemId.slice(-5)}`, { maxAge: 3600000, encode: String }) - if (this.ringConnected) { - res.sendFile('connected.html', {root: webdir}) - } else { - res.sendFile('account.html', {root: webdir}) - } - }) - - this.app.get(/.*force-token-generation$/, (req, res) => { - res.cookie('systemId', `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${this.systemId.slice(-5)}`, { maxAge: 3600000, encode: String }) - res.sendFile('account.html', {root: webdir}) - }) - - this.app.post(/.*submit-account$/, async (req, res) => { - res.cookie('systemId', `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${this.systemId.slice(-5)}`, { maxAge: 3600000, encode: String }) - const email = req.body.email - const password = req.body.password - restClient = await new RingRestClient({ - email, - password, - controlCenterDisplayName: `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${this.systemId.slice(-5)}`, - systemId: this.systemId - }) - // Check if the user/password was accepted - try { - await restClient.getCurrentAuth() - } catch(error) { - if (restClient.using2fa) { - debug('Username/Password was accepted, waiting for 2FA code to be entered.') - res.sendFile('code.html', {root: webdir}) - } else { - const errmsg = error.message ? error.message : 'Null response, you may be temporarily throttled/blocked. Please shut down ring-mqtt and try again in a few hours.' - debug(chalk.red(errmsg)) - res.cookie('error', errmsg, { maxAge: 1000, encode: String }) - res.sendFile('account.html', {root: webdir}) - } - } - }) - - this.app.post(/.*submit-code$/, async (req, res) => { - res.cookie('systemId', `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${this.systemId.slice(-5)}`, { maxAge: 3600000, encode: String }) - let generatedToken - const code = req.body.code - try { - generatedToken = await restClient.getAuth(code) - } catch { - generatedToken = false - const errormsg = 'The 2FA code was not accepted, please verify the code and try again.' - debug(errormsg) - res.cookie('error', errormsg, { maxAge: 1000, encode: String }) - res.sendFile('code.html', {root: webdir}) - } - if (generatedToken) { - res.sendFile('restart.html', {root: webdir}) - utils.event.emit('generated_token', generatedToken.refresh_token) - } - }) - } - - async stop() { - if (this.listener) { - await this.listener.close() - this.listener = false - } - } -} diff --git a/lib/utils.js b/lib/utils.js index f17937a..bf35261 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -3,66 +3,67 @@ import dns from 'dns' import os from 'os' import { promisify } from 'util' import { EventEmitter } from 'events' -import debugModule from 'debug' -const debug = { - mqtt: debugModule('ring-mqtt'), - attr: debugModule('ring-attr'), - disc: debugModule('ring-disc'), - rtsp: debugModule('ring-rtsp'), - wrtc: debugModule('ring-wrtc') +import debug from 'debug' + +const debuggers = { + mqtt: debug('ring-mqtt'), + attr: debug('ring-attr'), + disc: debug('ring-disc'), + rtsp: debug('ring-rtsp'), + wrtc: debug('ring-wrtc') } -export default new class Utils { +class Utils { + constructor() { + this.event = new EventEmitter() + this.dnsLookup = promisify(dns.lookup) + this.dnsLookupService = promisify(dns.lookupService) + } - constructor() { - this.event = new EventEmitter() - } + config() { + return config.data + } - config() { - return config.data - } + sleep(sec) { + return this.msleep(sec * 1000) + } - // Sleep function (seconds) - sleep(sec) { - return this.msleep(sec*1000) - } + msleep(msec) { + return new Promise(res => setTimeout(res, msec)) + } - // Sleep function (milliseconds) - msleep(msec) { - return new Promise(res => setTimeout(res, msec)) - } + getISOTime(epoch) { + return new Date(epoch).toISOString().slice(0, -5) + 'Z' + } - // Return ISO time from epoch without milliseconds - getISOTime(epoch) { - return new Date(epoch).toISOString().slice(0,-5)+"Z" + async getHostFqdn() { + try { + const ip = await this.getHostIp() + const { hostname } = await this.dnsLookupService(ip, 0) + return hostname + } catch (error) { + console.warn('Failed to resolve FQDN, using os.hostname() instead:', error.message) + return os.hostname() } + } - async getHostFqdn() { - const pLookupService = promisify(dns.lookupService) - try { - return (await pLookupService(await this.getHostIp(), 0)).hostname - } catch { - console.log('Failed to resolve FQDN, using os.hostname() instead') - return os.hostname() - } + async getHostIp() { + try { + const { address } = await this.dnsLookup(os.hostname()) + return address + } catch (error) { + console.warn('Failed to resolve hostname IP address, returning localhost instead:', error.message) + return 'localhost' } + } - async getHostIp() { - try { - const pLookup = promisify(dns.lookup) - return (await pLookup(os.hostname())).address - } catch { - console.log('Failed to resolve hostname IP address, returning localhost instead') - return 'localhost' - } - } + isNumeric(num) { + return !isNaN(parseFloat(num)) && isFinite(num) + } - isNumeric(num) { - return !isNaN(parseFloat(num)) && isFinite(num); - } - - debug(message, debugType) { - debugType = debugType ? debugType : 'mqtt' - debug[debugType](message) - } + debug(message, debugType = 'mqtt') { + debuggers[debugType]?.(message) + } } + +export default new Utils() \ No newline at end of file diff --git a/lib/webui.js b/lib/webui.js new file mode 100644 index 0000000..67b80ac --- /dev/null +++ b/lib/webui.js @@ -0,0 +1,118 @@ +import { RingRestClient } from 'ring-client-api/rest-client' +import utils from './utils.js' +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import express from 'express' +import bodyParser from 'body-parser' +import chalk from 'chalk' +import debugModule from 'debug' + +const PORT = 55123 +const COOKIE_MAX_AGE = 3600000 +const debug = debugModule('ring-mqtt') + +class WebUI { + constructor() { + this.app = express() + this.listener = null + this.ringConnected = false + this.webdir = dirname(fileURLToPath(new URL('.', import.meta.url))) + '/web' + + if (process.env.RUNMODE === 'addon') { + this.start() + } + + this.initializeEventListeners() + } + + initializeEventListeners() { + utils.event.on('ring_api_state', async (state) => { + this.ringConnected = state === 'connected' + + if (this.ringConnected && process.env.RUNMODE !== 'addon') { + await this.stop() + } + }) + } + + async handleAccountSubmission(req, res, restClient) { + try { + await restClient.getCurrentAuth() + } catch (error) { + if (restClient.using2fa) { + debug('Username/Password was accepted, waiting for 2FA code to be entered.') + return res.sendFile('code.html', { root: this.webdir }) + } + const errorMessage = error.message || 'Null response, you may be temporarily throttled/blocked. Please shut down ring-mqtt and try again in a few hours.' + debug(chalk.red(errorMessage)) + res.cookie('error', errorMessage, { maxAge: 1000 }) + return res.sendFile('account.html', { root: this.webdir }) + } + } + + async handleCodeSubmission(req, res, restClient) { + try { + const generatedToken = await restClient.getAuth(req.body.code) + if (generatedToken) { + utils.event.emit('generated_token', generatedToken.refresh_token) + return res.sendFile('restart.html', { root: this.webdir }) + } + } catch (error) { + const errorMessage = error.message || 'The 2FA code was not accepted, please verify the code and try again.' + debug(chalk.red(errorMessage)) + res.cookie('error', errorMessage, { maxAge: 1000 }) + return res.sendFile('code.html', { root: this.webdir }) + } + } + + setupRoutes() { + this.app.use(bodyParser.urlencoded({ extended: false })) + + this.app.get(['/', /.*force-token-generation$/], (req, res) => { + res.cookie('displayName', this.displayName, { maxAge: COOKIE_MAX_AGE }) + const template = this.ringConnected ? 'connected.html' : 'account.html' + res.sendFile(template, { root: this.webdir }) + }) + + let restClient + this.app.post(/.*submit-account$/, async (req, res) => { + res.cookie('displayName', this.displayName, { maxAge: COOKIE_MAX_AGE }) + restClient = new RingRestClient({ + email: req.body.email, + password: req.body.password, + controlCenterDisplayName: this.displayName, + systemId: this.systemId + }) + await this.handleAccountSubmission(req, res, restClient) + }) + + this.app.post(/.*submit-code$/, async (req, res) => { + res.cookie('displayName', this.displayName, { maxAge: COOKIE_MAX_AGE }) + await this.handleCodeSubmission(req, res, restClient) + }) + } + + async start(systemId) { + if (this.listener) { + return + } + + this.systemId = systemId + this.displayName = `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${systemId.slice(-5)}` + + this.setupRoutes() + + this.listener = this.app.listen(PORT, () => { + debug('Successfully started the ring-mqtt web UI') + }) + } + + async stop() { + if (this.listener) { + await this.listener.close() + this.listener = null + } + } +} + +export default new WebUI() \ No newline at end of file diff --git a/ring-mqtt.js b/ring-mqtt.js index 4c456df..4d9d19f 100644 --- a/ring-mqtt.js +++ b/ring-mqtt.js @@ -1,2 +1,2 @@ #!/usr/bin/env node -export * from './lib/main.js' +import './lib/main.js' diff --git a/web/account.html b/web/account.html index a9120e1..2729b07 100644 --- a/web/account.html +++ b/web/account.html @@ -1,92 +1,200 @@ - + - - + .container h3 { + margin-top: 0; + } + + .display-name { + margin: calc(var(--spacing) / 2) 0; + padding: 0; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; + } + + .display-name span { + color: var(--success-color); + font-weight: 500; + } + + .error-message { + background-color: var(--error-color); + color: white; + padding: calc(var(--spacing) / 2); + border-radius: 4px; + margin: var(--spacing) 0; + } + + .form-group { + margin-bottom: var(--spacing); + } + + .form-group label { + display: block; + margin-bottom: calc(var(--spacing) / 2); + font-weight: 500; + } + + .form-control { + width: 100%; + padding: calc(var(--spacing) / 2); + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; + } + + .form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(71, 169, 230, 0.2); + } + + .password-toggle { + display: flex; + align-items: center; + gap: calc(var(--spacing) / 2); + margin: calc(var(--spacing) / 2) 0; + user-select: none; + } + + .btn { + background-color: var(--primary-color); + color: white; + padding: calc(var(--spacing) / 2) var(--spacing); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; + width: 100%; + } + + .btn:hover { + background-color: var(--primary-hover); + } + + .btn:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(71, 169, 230, 0.4); + } + + @media (max-width: 600px) { + body { + padding: calc(var(--spacing) / 2); + } + + .container { + padding: var(--spacing); + } + } + +

Ring-MQTT Authentication

+
Device Name:
+ -

Acquire Refresh Token

-

-Please enter your Ring account information below and the 2FA code on the following page to generate the refresh token required for this application to access the Ring API.
-

-

Login

-
-
- - - - - Show Password -

- -
-
- +
+

Ring Account Info

+
+
+ + +
+
+ + +
+ + +
+
+ +
+
+ + - + \ No newline at end of file diff --git a/web/code.html b/web/code.html index c16b647..00c93f1 100644 --- a/web/code.html +++ b/web/code.html @@ -1,67 +1,168 @@ - + - - + h3 { + margin: 0 0 var(--spacing) 0; + } + + .display-name { + margin: calc(var(--spacing) / 2) 0; + padding: 0; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; + } + + .display-name span { + color: var(--success-color); + font-weight: 500; + } + + .error-message { + background-color: var(--error-color); + color: white; + padding: calc(var(--spacing) / 2); + border-radius: 4px; + margin: var(--spacing) 0; + } + + .form-group { + margin-bottom: var(--spacing); + } + + .form-group label { + display: block; + margin-bottom: calc(var(--spacing) / 2); + font-weight: 500; + } + + .form-control { + width: 100%; + padding: calc(var(--spacing) / 2); + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + transition: border-color 0.2s; + } + + .form-control:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(71, 169, 230, 0.2); + } + + .btn { + background-color: var(--primary-color); + color: white; + padding: calc(var(--spacing) / 2) var(--spacing); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; + width: 100%; + } + + .btn:hover { + background-color: var(--primary-hover); + } + + .btn:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(71, 169, 230, 0.4); + } + + @media (max-width: 600px) { + body { + padding: calc(var(--spacing) / 2); + } + + .container { + padding: var(--spacing); + } + } + +

Ring-MQTT Two-Factor Authentication

+
Device Name:
+ -

Enter 2FA Code

-

-


-
-
- - - -
-
- +
+
+
+ + +
+ +
+
+ + - + \ No newline at end of file diff --git a/web/connected.html b/web/connected.html index b1222df..47872a7 100644 --- a/web/connected.html +++ b/web/connected.html @@ -1,46 +1,114 @@ - + - - + h3 { + margin: 0 0 var(--spacing) 0; + } + + .display-name { + margin: calc(var(--spacing) / 2) 0; + padding: 0; + border-radius: 4px; + display: flex; + align-items: center; + gap: 8px; + } + + .display-name span { + color: var(--success-color); + font-weight: 500; + } + + .container { + margin-top: var(--spacing); + padding: var(--spacing) 0; + } + + .message { + margin: var(--spacing) 0; + } + + .btn { + background-color: var(--primary-color); + color: white; + padding: calc(var(--spacing) / 2) var(--spacing); + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + transition: background-color 0.2s; + min-width: 200px; + } + + .btn:hover { + background-color: var(--primary-hover); + } + + .btn:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(71, 169, 230, 0.4); + } + + @media (max-width: 600px) { + body { + padding: calc(var(--spacing) / 2); + } + } + -

Ring Device Addon Connected

-

-It appears that this addon is already authenticated and connected to the Ring API, no additional action is required. -If you wish to force reauthentication, for example, to change the account used by this addon, clicking the button below will restart the authentication process. -
-
- -
-
- +

Ring-MQTT Addon Connected

+
Device Name:
+
+ It appears that ring-mqtt is already connected to the Ring API, no additional action is required. +
+
+ If you wish to force reauthentication, for example, to change the account used by this addon, clicking the button below will restart the authentication process. +
+
+
+ +
+
+ + - + \ No newline at end of file diff --git a/web/restart.html b/web/restart.html index 903f44e..7e29d6a 100644 --- a/web/restart.html +++ b/web/restart.html @@ -1,23 +1,97 @@ - + - - + + + Ring-MQTT Web Authenticator + -

Refresh Token Generation Complete

-

-The Home Assistant ring-mqtt Add-on will now automatically connect using the generated token. No additional configuration steps are required, please check the addon logs to monitor progress. - +

Ring-MQTT Authentication Complete

+
Device Name:
+
+ Authentication with Ring was successful and ring-mqtt will now attempt to connect to Ring servers. No additional steps are required, please review the ring-mqtt logs to monitor progress. +
+ + - + \ No newline at end of file