initial commit
This commit is contained in:
commit
ba64de5dab
14 changed files with 648 additions and 0 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.vscode/
|
||||
*.md
|
||||
.gitignore
|
||||
config.json.example
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
yarn.lock
|
||||
package-lock.json
|
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal 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
21
Dockerfile
Normal 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
20
LICENSE.md
Normal 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
21
README.md
Normal 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
24
config.json.example
Normal 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
116
fritzfix.js
Normal 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
45
libs/draytek.js
Normal 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
153
libs/fritzbox.js
Normal 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
137
libs/logger.js
Normal 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
50
libs/notify.js
Normal 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
16
libs/timediff.js
Normal 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
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue