commit ba64de5dab0045d4ba5a3625d858c964de634a8a Author: velvettear Date: Wed Jun 12 10:26:04 2024 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bbac68b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +.vscode/ +*.md +.gitignore +config.json.example \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b07f03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +yarn.lock +package-lock.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..eb488e8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "runtimeVersion": "20", + "request": "launch", + "name": "api", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/fritzfix.js" + } + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d415e1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:current-alpine + +LABEL version="1.0.0" \ + author="Daniel Sommer " \ + license="MIT" + +MAINTAINER Daniel Sommer + +ENV LANG=C.UTF-8 + +RUN mkdir -p /opt/fritzfix && chown -R node:node /opt/fritzfix + +WORKDIR /opt/fritzfix + +COPY --chown=node:node . . + +USER node + +RUN npm install + +ENTRYPOINT ["node", "fritzfix.js"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..76620f8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +# MIT License +**Copyright (c) 2024 Daniel Sommer \** + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..726349c --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# fritzfix + +a custom tool for a very special, personal use case. + +## description + +every time the router goes down or reboots all the phones connected to a fritzbox stop working until the fritzbox itself is rebooted. + +this tool regularly checks if the router is responding to http requests. +if the router does not respond the tool will wait for it to come back online and then execute a reboot via selenium (headless browser in a docker container). + +also some notifications are sent to my local ntfy instance. + +## build / usage + +- clone the repository: `git clone https://git.velvettear.de/velvettear/fritzfix.git` +- enter the repository: `cd fritzfix` +- create and modify a config file: `cp config.json.example config.json && vim config.json` +- build an image: `docker build --no-cache docker.registry.velvettear.de/fritzfix:latest .` +- push the image: `docker push docker.registry.velvettear.de/fritzfix:latest` +- run it: `docker run --rm -it docker.registry.velvettear.de/fritzfix:latest` \ No newline at end of file diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..e4ce746 --- /dev/null +++ b/config.json.example @@ -0,0 +1,24 @@ +{ + "fritzbox": { + "url": "http://192.168.1.1", + "user": "admin", + "password": "secretPassword" + }, + "draytek": "http://192.168.178.1", + "selenium": "http://192.168.1.2:4444", + "notify": { + "url": "http://192.168.178.2:7000/fritzfix", + "user": "ntfyUser", + "password": "ntfyPassword" + }, + "timeouts": { + "request": 10000, + "webui": 30000, + "reboot": 3000000 + }, + "interval": 10000, + "log": { + "level": "debug", + "timestamp": "DD.MM.YYYY HH:mm:ss:SS" + } +} \ No newline at end of file diff --git a/fritzfix.js b/fritzfix.js new file mode 100644 index 0000000..6b14def --- /dev/null +++ b/fritzfix.js @@ -0,0 +1,116 @@ +const packageJSON = require("./package.json"); +const configJSON = require("./config.json"); +const logger = require("./libs/logger.js"); +const fritzbox = require("./libs/fritzbox.js"); +const draytek = require("./libs/draytek.js"); +const notification = require("./libs/notify.js"); +const timediff = require("./libs/timediff.js"); + +const INTERRUPTS = ["beforeExit", "SIGINT", "SIGTERM"]; + +global.appName = packageJSON.name; +global.appVersion = packageJSON.version; +global.config = configJSON; + +let offlineTimestamp; +let rebootError; + +main(); + +async function main() { + handleExit(); + try { + logger.initialize(configJSON); + logger.info( + "launching " + global.appName + " " + global.appVersion + "..." + ); + mainLoop(); + } catch (err) { + await exit(1, err); + } +} + +async function mainLoop() { + const isOnline = await draytek.isOnline(); + if (!isOnline) { + if (offlineTimestamp) { + logger.warn( + "draytek seems to be still not responding after " + + timediff.inSeconds(offlineTimestamp) + + " seconds!" + ); + } else { + offlineTimestamp = Date.now(); + const msg = "draytek seems to be not responding!"; + logger.warn(msg); + await notification.send( + "draytek offline!", + msg + "\nwaiting for it to come back online...", + "max", + "scream" + ); + } + } else { + if (offlineTimestamp) { + const msg = + "draytek is responding again after " + + timediff.inSeconds(offlineTimestamp) + + " seconds!"; + logger.info(msg); + if (!rebootError) { + notification.send( + "rebooting fritzbox...", + msg + "\nrebooting fritzbox now...", + undefined, + "roll_eyes" + ); + } + const err = await fritzbox.reboot(); + if (err) { + rebootError = err; + notification.send("error rebooting fritzbox!", err, "max", "sob"); + } else { + notification.send( + "fritzbox rebooted!", + "successfully rebooted device, the phones should now work again!", + "max", + "partying_face" + ); + offlineTimestamp = undefined; + rebootError = undefined; + } + } + } + setTimeout(() => { + mainLoop(); + }, configJSON.interval); +} + +async function handleExit() { + for (var index = 0; index < INTERRUPTS.length; index++) { + process.on(INTERRUPTS[index], async (code) => { + await exit(code); + }); + } +} + +async function exit(code, err) { + await fritzbox.quit(); + if (code === undefined) { + code = 0; + if (err) { + code = 1; + } + } + if (err) { + logger.error(err); + logger.error( + global.appName + " " + global.appVersion + " ended due to an error" + ); + } else { + logger.info( + global.appName + " " + global.appVersion + " shutting down gracefully" + ); + } + process.exit(code); +} \ No newline at end of file diff --git a/libs/draytek.js b/libs/draytek.js new file mode 100644 index 0000000..dc1c6b0 --- /dev/null +++ b/libs/draytek.js @@ -0,0 +1,45 @@ +const logger = require("./logger.js"); +const timediff = require("./timediff.js"); + +async function isOnline() { + const url = global.config.draytek; + let online = false; + logger.info("checking if draytek is responding on url '" + url + "'..."); + const timestamp = Date.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(async function () { + logger.debug( + "request to draytek timed out after " + + timediff.inSeconds(timestamp) + + " seconds!" + ); + controller.abort(); + }, global.config.timeouts.request); + + const result = await fetch(url, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + logger.info( + "draytek responded with status code '" + + result.status + + "' after " + + timediff.inSeconds(timestamp) + + " seconds!" + ); + online = result.status === 200; + } catch (err) { + if (err.name !== "AbortError") { + logger.error( + "encountered an error checking if draytek is responding (" + err + ")" + ); + } + } + return online; +} + +// exports +module.exports = { + isOnline, +}; diff --git a/libs/fritzbox.js b/libs/fritzbox.js new file mode 100644 index 0000000..1648dae --- /dev/null +++ b/libs/fritzbox.js @@ -0,0 +1,153 @@ +const logger = require("./logger.js"); +const timediff = require("./timediff.js"); +const selenium = require("selenium-webdriver"); +const firefox = require("selenium-webdriver/firefox"); + +let webdriver; +let defaultTimeout; +let rebootTimeout; + +async function initializeDriver() { + logger.debug("initializing the webdriver..."); + defaultTimeout = global.config.timeouts.webui; + rebootTimeout = global.config.timeouts.reboot; + let seleniumUrl = global.config.selenium; + if (!seleniumUrl.endsWith("/")) { + seleniumUrl += "/"; + } + webdriver = await new selenium.Builder() + .forBrowser(selenium.Browser.FIREFOX) + .usingServer(seleniumUrl + "wd/hub") + .setFirefoxOptions( + new firefox.Options().addArguments( + "--headless", + "--private", + "--purgecaches" + ) + ) + .withCapabilities( + selenium.Capabilities.firefox().set("acceptInsecureCerts", true) + ) + .build(); + await webdriver + .manage() + .setTimeouts({ + // implicit: defaultTimeout, + pageLoad: defaultTimeout, + // script: defaultTimeout, + }); + logger.debug("webdriver was successfully initialized!"); +} + +async function login() { + logger.debug("opening url '" + global.config.fritzbox.url + "'..."); + await webdriver.get(global.config.fritzbox.url); + logger.debug("entering credentials..."); + try { + const selection = new selenium.Select( + await webdriver.findElement(selenium.By.id("uiViewUser")) + ); + await selection.selectByValue(global.config.fritzbox.user); + logger.debug("set specified username"); + } catch (ignored) {} + await webdriver + .findElement(selenium.By.id("uiPass")) + .sendKeys(global.config.fritzbox.password, selenium.Key.RETURN); + logger.debug("set specified password"); + logger.info("logging in..."); +} + +async function reboot() { + logger.info("initializing reboot..."); + try { + await initializeDriver(); + await login(); + await clickElementById("sys"); + await clickElementById("mSave"); + await clickElementById("reboot"); + await clickElementByName("reboot"); + + await webdriver + .wait( + selenium.until.elementLocated(selenium.By.className("waitimg")), + defaultTimeout + ) + .then(function () { + logger.debug("reboot has been initiated via web ui"); + }); + + await webdriver + .wait( + selenium.until.elementLocated(selenium.By.className("waitimg")), + defaultTimeout + ) + .then(function () { + logger.info("fritzbox is rebooting now..."); + }); + + const timestamp = Date.now(); + await webdriver + .wait( + selenium.until.elementLocated(selenium.By.id("loginForm")), + rebootTimeout + ) + .then(function () { + logger.info( + "fritzbox has been successfully rebooted after " + + timediff.inSeconds(timestamp) + + " seconds!" + ); + }); + } catch (err) { + logger.error("encountered an error executing the reboot (" + err + ")"); + return err; + } finally { + await quit(); + } +} + +async function clickElementById(element) { + if (element === undefined || element.length === 0) { + return; + } + logger.debug("waiting for element with id '" + element + "'..."); + await webdriver.wait( + selenium.until.elementLocated(selenium.By.id(element)), + defaultTimeout + ); + logger.debug("clicking on element with id '" + element + "'..."); + await webdriver.findElement(selenium.By.id(element)).click(); +} + +async function clickElementByName(element) { + if (element === undefined || element.length === 0) { + return; + } + logger.debug("waiting for element with name '" + element + "'..."); + await webdriver.wait( + selenium.until.elementLocated(selenium.By.name(element)), + defaultTimeout + ); + logger.debug("clicking on element with name '" + element + "'..."); + await webdriver.findElement(selenium.By.name(element)).click(); +} + +async function quit() { + if (webdriver === undefined) { + return; + } + try { + logger.debug("closing the webdriver..."); + webdriver.quit(); + logger.debug("webdriver was successfully closed!"); + webdriver = undefined; + } catch (err) { + logger.error("encountered an error closing the webdriver (" + err + ")"); + } +} + +// exports +module.exports = { + reboot, + quit, +}; diff --git a/libs/logger.js b/libs/logger.js new file mode 100644 index 0000000..2358e91 --- /dev/null +++ b/libs/logger.js @@ -0,0 +1,137 @@ +const moment = require('moment'); + +// constants +const LOG_PREFIX_DEBUG = 'debug'; +const LOG_PREFIX_INFO = 'info'; +const LOG_PREFIX_WARNING = 'warning'; +const LOG_PREFIX_ERROR = 'error'; +const LOGLEVEL_DEBUG = 0; +const LOGLEVEL_INFO = 1; +const LOGLEVEL_WARNING = 2; +const LOGLEVEL_ERROR = 3; + +var loglevel = getLogLevel(); +var timestamp = getTimestamp(); + +function initialize() { + return new Promise((resolve, reject) => { + if (global.config == undefined) { + reject('could not initialize logger, config is undefined'); + } + loglevel = getLogLevel(); + timestamp = getTimestamp(); + resolve(); + }); +} + +// get the loglevel +function getLogLevel() { + if (global.config?.log?.level == undefined) { + return LOGLEVEL_INFO; + } + switch (global.config.log.level) { + case LOG_PREFIX_DEBUG: + case LOGLEVEL_DEBUG: + return LOGLEVEL_DEBUG; + case LOG_PREFIX_INFO: + case LOGLEVEL_INFO: + return LOGLEVEL_INFO; + case LOG_PREFIX_WARNING: + case LOGLEVEL_WARNING: + return LOGLEVEL_WARNING; + case LOG_PREFIX_ERROR: + case LOGLEVEL_ERROR: + return LOGLEVEL_ERROR; + default: + return LOGLEVEL_INFO; + } +} + +// get the timestamp format +function getTimestamp() { + if (global.config?.log?.format != undefined) { + return global.config.log.format; + } + return "DD.MM.YYYY HH:mm:ss:SS"; +} + +// prefix log with 'info' +function info(message) { + if (loglevel > LOGLEVEL_INFO) { + return; + } + trace(message); +} + +// prefix log with 'info' +function warn(message) { + if (loglevel > LOGLEVEL_WARNING) { + return; + } + trace(message, 'warning'); +} + +// prefix log with 'debug' +function debug(message) { + if (loglevel > LOGLEVEL_DEBUG) { + return; + } + trace(message, 'debug'); +} + +// prefix log with 'error' +function error(message) { + if (loglevel > LOGLEVEL_ERROR) { + return; + } + if (message.stack) { + trace(message.stack, 'error'); + return; + } + if (message.errors !== undefined) { + for (let index = 0; index < message.errors.length; index++) { + trace(message.errors[index], 'error'); + } + return; + } + if (message.message) { + trace(message.message, 'error'); + return; + } + trace(message, 'error'); +} + +// default logging function +function trace(message, prefix) { + if (message === undefined || message === null || message.length === 0) { + return; + } + if (prefix === undefined || prefix === null || prefix.length === 0) { + prefix = 'info'; + } + let print; + switch (prefix) { + case 'error': + print = console.error; + break; + case 'debug': + print = console.debug; + break; + case 'warning': + print = console.warn; + break; + default: + print = console.log; + } + message = moment().format(timestamp) + ' | ' + prefix + ' > ' + message; + print(message); +} + +// exports +module.exports = { + initialize, + info, + warn, + debug, + error +}; \ No newline at end of file diff --git a/libs/notify.js b/libs/notify.js new file mode 100644 index 0000000..d35742f --- /dev/null +++ b/libs/notify.js @@ -0,0 +1,50 @@ +const logger = require("./logger.js"); + +async function send(title, message, priority, ...tags) { + if (title?.length === 0 || message?.length === 0) { + return; + } + const url = global.config.notify.url; + if (url?.length === 0) { + return; + } + const timestamp = Date.now(); + logger.debug("sending notification to '" + url + "'..."); + const headers = new Headers(); + headers.set("Authorization", "Basic " + + Buffer.from( + global.config.notify.user + ":" + global.config.notify.password + ).toString("base64")); + headers.set("Title", title); + if (priority) { + headers.set("Priority", priority); + } + if (tags) { + headers.set("Tags", tags); + } + try { + const result = await fetch(url, { + method: "POST", + headers: headers, + body: message, + }); + if (result.status !== 200) { + logger.warn( + "notification could not be sent (status code: " + result.status + ")" + ); + return; + } + logger.debug( + "successfully sent notification after " + + (Date.now() - timestamp) / 1000 + + " seconds!" + ); + } catch (err) { + logger.error("encountered an error sending notification (" + err + ")"); + } +} + +// exports +module.exports = { + send, +}; diff --git a/libs/timediff.js b/libs/timediff.js new file mode 100644 index 0000000..c6174c8 --- /dev/null +++ b/libs/timediff.js @@ -0,0 +1,16 @@ +function inMilliseconds(timestamp) { + if (!timestamp) { + return 0; + } + return Date.now() - timestamp; +} + +function inSeconds(timestamp) { + return inMilliseconds(timestamp) / 1000; +} + +// exports +module.exports = { + inMilliseconds, + inSeconds +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea79fcb --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "fritzfix", + "version": "0.0.1", + "description": "", + "main": "fritzfix.js", + "scripts": {}, + "repository": { + "type": "git", + "url": "https://git.velvettear.de/velvettear/fritzfix.git" + }, + "keywords": [ + "fritzbox", + "remote control" + ], + "author": "Daniel Sommer ", + "license": "MIT", + "dependencies": { + "moment": "^2.30.1", + "selenium-webdriver": "^4.21.0", + "ws": "^8.5.0" + } +}