fritzbox-api
Version:
Straightforward, lightweight and extendable Node.js library to communicate with FRITZ!Box devices
408 lines (345 loc) • 13 kB
JavaScript
// Copyright (c) 2024, Thorsten A. Weintz. All rights reserved.
// Licensed under the MIT license. See LICENSE in the project root for license information.
import { XMLParser } from 'fast-xml-parser';
import HashUtil from './hashUtil.mjs';
import HttpClient from './httpClient.mjs';
/**
* FritzBoxApi is a class to communicate with FRITZ!Box devices.
*
* This class partially supports TR-064 in addition to the web interface API.
*
* Differences:
* - Web API (SID-based): used for /data.lua, login_sid.lua, etc.
* - TR-064 (SOAP-based): used for telephony, device control, etc.
*
* Authentication:
* - Web API → Session ID (SID) via challenge-response (PBKDF2)
* - TR-064 → HTTP Digest Authentication (username + password)
*/
class FritzBoxApi extends HttpClient {
/**
* Default URL of FRITZ!Box device.
*/
url = 'http://fritz.box';
/**
* Object with endpoint routes of FRITZ!Box device.
*/
routes = {
login: '/login_sid.lua?version=2',
data: '/data.lua',
foncalls: '/fon_num/foncalls_list.lua?sid={sid}&csv=',
firmwarecfg: '/cgi-bin/firmwarecfg',
reboot: '/reboot.lua'
};
/**
* Default time in milliseconds till logout.
*/
timeInMsTillLogout = 1200 * 1000;
/**
* Initializes new instance of FritzBoxApi.
*
* @param {object} options Object with configuration values.
*/
constructor(options) {
options = options || {};
if (!options.url) {
options.url = this.url;
}
super(options.url);
this.options = options;
}
/**
* Sets the credentials used for authentication against the FRITZ!Box.
*
* @param {string} username String with username of device.
* @param {string} password String with password of device.
*/
setCredentials(username, password) {
this.options.credentials = { username, password };
}
/**
* Processes authentication by username and password.
*
* @param {string} username String with username of device.
* @param {string} password String with password of device.
* @returns Returns whether authentication succeeded.
*/
async login(username, password) {
this.setCredentials(username, password);
const sessionId = await this.getSessionId();
this.options.credentials.sid = sessionId;
return sessionId != null;
}
/**
* Gets session identifier by username and password.
*
* @param {boolean} renew If true, renew of session identifier is forced.
* @returns Returns string with session identifier.
*/
async getSessionId(renew) {
const { credentials: { username, password, sid } } = this.options;
if (!renew && this.#isValidSid(sid)) return sid;
const { data, status } = await this.get(this.routes.login);
if (status === 200) {
const blockTimeMs = this.#getXmlElementValue(data, 'BlockTime') * 1000;
if (blockTimeMs > 0) {
await this.#sleep(blockTimeMs);
}
const challenge = this.#getXmlElementValue(data, 'Challenge');
if (challenge) {
const sessionId = await this.#calcReqChallengeResp(
challenge, username, password
);
if (sessionId !== '0000000000000000') {
return sessionId;
}
}
}
}
/**
* Gets name of last user.
*
* @returns Returns string with name of last user.
*/
async getLastUser() {
const { data, status } = await this.get(this.routes.login);
if (status === 200) {
return this.#getXmlElementValue(data, 'User', { last: 1 });
}
}
/**
* Fetches data by pid and additional options.
*
* @param {object} options Object with configuration values.
* @returns Returns JSON of responded data.
*/
async getData(options) {
const sid = await this.getSessionId();
if (sid) {
const { data, status } = await this.post(this.routes.data, {
xhr: 1, sid, page: '', xhrId: '', ...options
});
if (status === 200) {
return data;
}
}
}
/**
* Fetches fonbook of FRITZ!Box device.
*
* @param {number} phoneBookId Number with identifier of phone book.
* @returns Returns
*/
async getFonBook(phoneBookId = 0) {
const sid = await this.getSessionId();
if (sid) {
const { data, status } = await this.post(this.routes.firmwarecfg, {
sid,
PhonebookId: phoneBookId,
PhonebookExportName: 'Phonebook',
PhonebookExport: ''
}, true);
if (status === 200) {
const xmlParser = new XMLParser();
return xmlParser.parse(data);
}
}
}
/**
* Fetches foncalls of FRITZ!Box device.
*
* @param {number} skip Number with offset to skip results.
* @param {number} limit Maximum number of results to be returned.
* @returns Returns object with head and body of foncall entries.
*/
async getFonCalls(skip = 0, limit) {
const sid = await this.getSessionId();
if (sid) {
const url = this.routes.foncalls.replace('{sid}', sid);
const { data, status } = await this.get(url);
if (status === 200) {
const rows = data.split(/\r?\n|\r|\n/g);
if (rows.length) {
const sep = rows[0].split('=')[1];
const [head, ...entries] = rows.slice(1).map(row => row.split(sep));
return {
head,
entries: entries.slice(skip, skip + limit || entries.length)
};
}
}
}
return {};
}
/**
* Processes reboot of FRITZ!Box device.
*
* @returns Returns result of reboot process.
*/
async reboot() {
const sid = await this.getSessionId();
if (sid) {
const result = await this.getData({ page: 'reboot', reboot: 1 });
if (result?.data?.reboot === 'ok') {
const { data, status } = await this.post(this.routes.reboot, {
ajax: 1, sid, no_sidrenew: 1, xhr: 1, useajax: 1
});
if (status === 200) {
return data;
}
}
}
}
/**
* Initiates a call to a given phone number via TR-064.
*
* This method directly forwards the provided number to the FRITZ!Box
* dialing service (X_AVM-DE_DialNumber) without any mapping or transformation.
*
* @param {string} number Target phone number or internal code.
* @returns {Promise<object>} TR-064 response.
*/
async callDevice(number) {
if (!number || typeof number !== 'string') {
return { error: 'Invalid phone number' };
}
return this.#tr064(
'urn:dslforum-org:service:X_VoIP:1',
'X_AVM-DE_DialNumber',
{
'NewX_AVM-DE_PhoneNumber': number
}
);
}
/**
* Terminates the currently active TR-064 initiated call.
*
* This will hang up a call that was previously started via
* X_AVM-DE_DialNumber.
*
* @returns {Promise<object>} TR-064 response.
*/
async hangup() {
try {
return await this.#tr064(
'urn:dslforum-org:service:X_VoIP:1',
'X_AVM-DE_DialHangup'
);
} catch (err) {
return { error: `Failed to hang up call: ${err.message}` };
}
}
/**
* Sends a TR-064 SOAP request to the FRITZ!Box.
*
* This method is a low-level helper to communicate with TR-064 services.
* It builds a SOAP envelope and sends it to the corresponding control URL.
*
* @param {string} service URN of the TR-064 service.
* @param {string} action Name of the SOAP action.
* @param {object} params Key-value pairs mapped to XML elements.
* @returns {Promise<object>} HTTP response from the device.
*/
async #tr064(service, action, params = {}) {
const bodyParams = Object.entries(params)
.map(([k, v]) => `<${k}>${v}</${k}>`)
.join('');
const xml = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:${action} xmlns:u="${service}">
${bodyParams}
</u:${action}>
</s:Body>
</s:Envelope>`;
return this.postSoap(
`${this.options.url}:49000/upnp/control/x_voip`,
xml,
this.options.credentials,
{
'SOAPAction': `${service}#${action}`
}
);
}
/**
* Calculates the response for a given challenge.
*
* @param {string} challenge String with current challenge of device.
* @param {string} username String with username of device.
* @param {string} password String with password of device.
* @returns Returns binary value in hexadecimal notation.
*/
async #calcReqChallengeResp(challenge, username, password) {
const calcResp = challenge.startsWith('2$')
? this.#calcPbkdf2Resp : this.#calcMd5Resp;
const response = calcResp(challenge, password);
const { data, status } = await this.post(this.routes.login, {
username, response
});
if (status === 200) {
const sessionId = this.#getXmlElementValue(data, 'SID');
return sessionId;
}
}
/**
* Calculates the response for a given challenge via PBKDF2 algorithm.
*
* @param {string} challenge String with current challenge of device.
* @param {string} password String with password of device.
* @returns Returns binary value in hexadecimal notation.
*/
#calcPbkdf2Resp(challenge, pwd) {
const [_, iter1, salt1, iter2, salt2] = challenge.split('$');
const calcHash = HashUtil.pbkdf2;
const hash1 = calcHash(pwd, salt1, iter1);
const hash2 = calcHash(hash1, salt2, iter2);
return salt2 + '$' + hash2.toString('hex');
}
/**
* Calculates the response for a given challenge via MD5 algorithm.
*
* @param {string} challenge String with current challenge of device.
* @param {string} password String with password of device.
* @returns Returns binary value in hexadecimal notation.
*/
#calcMd5Resp = (challenge, pwd) =>
challenge + '-' + HashUtil.md5(Buffer.from(challenge + '-' + pwd, 'utf-16le'));
/**
* Validates given session identifier.
*
* @param {string} sid String with session identifier.
* @returns Returns whether session identifier is valid.
*/
#isValidSid = (sid) => sid && (!this.reqDate
|| new Date().getTime() - this.reqDate.getTime() < this.timeInMsTillLogout);
/**
* Gets string with XML attributes by keys and values of object.
*
* @param {object} attrs Object with keys and values.
* @returns Returns string with XML attributes.
*/
#getXmlAttributes = (attrs) => attrs ? ` ${
Object.entries(attrs)
.map(([key, value]) => `${key}="${value}"`).join(' ')}` : '';
/**
* Gets string value of XMLElement by name.
*
* @param {string} data String with XML data.
* @param {string} name String with name of the XMLElement.
* @param {object} attrs Object with attributes of the XMLElement.
* @returns Returns string with value of XMLElement.
*/
#getXmlElementValue = (data, name, attrs) =>
data?.match(`<${name}${this.#getXmlAttributes(attrs)}>(.*?)</${name}>`)?.[1];
/**
* Processes asynchronous operation and resolves after given time in milliseconds.
*
* @param {number} ms Number with delay in milliseconds.
* @returns Returns Promise object represents the eventual completion.
*/
#sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Exports @see FritzBoxApi as default class.
*/
export default FritzBoxApi;