initial commit

This commit is contained in:
Daniel Sommer 2024-06-12 10:26:04 +02:00
commit ba64de5dab
14 changed files with 648 additions and 0 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules/
.vscode/
*.md
.gitignore
config.json.example

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
yarn.lock
package-lock.json

15
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"runtimeVersion": "20",
"request": "launch",
"name": "api",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/fritzfix.js"
}
]
}

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:current-alpine
LABEL version="1.0.0" \
author="Daniel Sommer <daniel.sommer@velvettear.de>" \
license="MIT"
MAINTAINER Daniel Sommer <daniel.sommer@velvettear.de>
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"]

20
LICENSE.md Normal file
View file

@ -0,0 +1,20 @@
# MIT License
**Copyright (c) 2024 Daniel Sommer \<daniel.sommer@velvettear.de\>**
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.**

21
README.md Normal file
View file

@ -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`

24
config.json.example Normal file
View file

@ -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"
}
}

116
fritzfix.js Normal file
View file

@ -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);
}

45
libs/draytek.js Normal file
View file

@ -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,
};

153
libs/fritzbox.js Normal file
View file

@ -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,
};

137
libs/logger.js Normal file
View file

@ -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
};

50
libs/notify.js Normal file
View file

@ -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,
};

16
libs/timediff.js Normal file
View file

@ -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
}

22
package.json Normal file
View file

@ -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 <daniel.sommer@velvettear.de>",
"license": "MIT",
"dependencies": {
"moment": "^2.30.1",
"selenium-webdriver": "^4.21.0",
"ws": "^8.5.0"
}
}