diff --git a/.gitignore b/.gitignore index 5ab68ce..daf3df3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules dist debug -config/auth.js config/config.json config/config-ui.json config/cwmp.key diff --git a/.npmignore b/.npmignore index c4a4602..6474b2f 100644 --- a/.npmignore +++ b/.npmignore @@ -1,7 +1,6 @@ *~ .npmignore -config/auth.js config/config.json config/cwmp.key config/cwmp.crt diff --git a/build/build.ts b/build/build.ts index c172311..832f68f 100644 --- a/build/build.ts +++ b/build/build.ts @@ -103,7 +103,6 @@ async function copyStatic(): Promise { "CHANGELOG.md", "npm-shrinkwrap.json", "config/config-sample.json", - "config/auth-sample.js", "config/ext-sample.js", "public/logo.svg", "public/favicon.png" diff --git a/config/auth-sample.js b/config/auth-sample.js deleted file mode 100644 index aeb48e9..0000000 --- a/config/auth-sample.js +++ /dev/null @@ -1,7 +0,0 @@ -"use strict"; - -function connectionRequest(deviceId, url, username, password, callback) { - return callback(username || deviceId, password || ""); -} - -exports.connectionRequest = connectionRequest; diff --git a/lib/api-functions.ts b/lib/api-functions.ts index b925d9d..0e19bf7 100644 --- a/lib/api-functions.ts +++ b/lib/api-functions.ts @@ -17,188 +17,24 @@ * along with GenieACS. If not, see . */ -import * as crypto from "crypto"; -import * as dgram from "dgram"; -import * as URL from "url"; -import * as http from "http"; -import * as config from "./config"; import * as db from "./db"; import * as common from "./common"; -import * as auth from "./auth"; import * as cache from "./cache"; - -function udpConReq(address, un, key, callback): boolean { - if (!address) return false; - - let [host, port] = address.split(":", 2); - - if (!port) { - port = 80; - } else { - port = parseInt(port); - if (isNaN(port)) return false; - } - - const ts = Math.trunc(Date.now() / 1000); - const id = Math.trunc(Math.random() * 4294967295); - const cn = crypto.randomBytes(8).toString("hex"); - const sig = crypto - .createHmac("sha1", key) - .update(`${ts}${id}${un}${cn}`) - .digest("hex"); - const uri = `http://${address}?ts=${ts}&id=${id}&un=${un}&cn=${cn}&sig=${sig}`; - const message = Buffer.from( - `GET ${uri} HTTP/1.1\r\nHost: ${address}\r\n\r\n` - ); - - const client = dgram.createSocket({ type: "udp4", reuseAddr: true }); - const UDP_CONNECTION_REQUEST_PORT = +config.get( - "UDP_CONNECTION_REQUEST_PORT" - ); - - if (UDP_CONNECTION_REQUEST_PORT) - // When a device is NAT'ed, the UDP Connection Request must originate from - // the same address and port used by the STUN server, in order to traverse - // the firewall. This does require that the Genieacs NBI and STUN server - // are allowed to bind to the same address and port. The STUN server needs - // to open its UDP port with the SO_REUSEADDR option, allowing the NBI to - // also bind to the same port. - client.bind({ port: UDP_CONNECTION_REQUEST_PORT, exclusive: true }); - - let count = 3; - const func = (err: Error): void => { - if (err || --count <= 0) { - client.close(); - return void callback(err); - } - client.send(message, 0, message.length, port, host, func); - }; - client.send(message, 0, message.length, port, host, func); - return true; -} - -function httpConReq( - url, - username, - password, - allowBasicAuth, - timeout, - callback -): void { - const options: http.RequestOptions = URL.parse(url); - if (options.protocol !== "http:") { - return void callback( - new Error("Invalid connection request URL or protocol") - ); - } - options.agent = new http.Agent({ maxSockets: 1 }); - - function statusToError(statusCode): Error { - switch (statusCode) { - case 200: - case 204: - return null; - case 401: - return new Error("Incorrect connection request credentials"); - case 0: - return new Error("Device is offline"); - default: - return new Error(`Unexpected response code from device: ${statusCode}`); - } - } - - const request = http - .get(options, response => { - if (response.statusCode === 401 && response.headers["www-authenticate"]) { - const authHeader = auth.parseAuthHeader( - response.headers["www-authenticate"] - ); - if (authHeader["method"] === "Basic") { - if (!allowBasicAuth) { - request.abort(); - return void callback( - new Error("Basic HTTP authentication not allowed") - ); - } - options.headers = { - Authorization: auth.basic(username || "", password || "") - }; - } else if (authHeader["method"] === "Digest") { - options.headers = { - Authorization: auth.digest( - username || "", - password || "", - options.path, - "GET", - null, - authHeader - ) - }; - } - - const req = http - .get(options, res => { - if (res.statusCode === 0) { - // Workaround for some devices unexpectedly closing the connection - const req2 = http - .get(options, res2 => { - callback(statusToError(res2.statusCode)); - res2.resume(); - }) - .on("error", () => { - req2.abort(); - callback(statusToError(0)); - }) - .on("socket", socket => { - socket.setTimeout(timeout); - socket.on("timeout", () => { - req2.abort(); - }); - }); - } else { - callback(statusToError(res.statusCode)); - } - res.resume(); - }) - .on("error", () => { - req.abort(); - callback(statusToError(0)); - }) - .on("socket", socket => { - socket.setTimeout(timeout); - socket.on("timeout", () => { - req.abort(); - }); - }); - } else { - callback(statusToError(response.statusCode)); - } - // No listener for data so emit resume - response.resume(); - }) - .on("error", () => { - request.abort(); - callback(statusToError(0)); - }) - .on("socket", socket => { - socket.setTimeout(timeout); - socket.on("timeout", () => { - request.abort(); - }); - }); -} +import { + getCurrentSnapshot, + getConfig, + getConfigExpression +} from "./local-cache"; +import { + httpConnectionRequest, + udpConnectionRequest +} from "./connection-request"; +import { Expression } from "./types"; export function connectionRequest(deviceId, callback): void { - const CONNECTION_REQUEST_TIMEOUT = config.get( - "CONNECTION_REQUEST_TIMEOUT", - deviceId - ); - const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = config.get( - "CONNECTION_REQUEST_ALLOW_BASIC_AUTH", - deviceId - ); const options = { projection: { + _deviceId: 1, "Device.ManagementServer.ConnectionRequestURL._value": 1, "Device.ManagementServer.UDPConnectionRequestAddress._value": 1, "Device.ManagementServer.ConnectionRequestUsername._value": 1, @@ -236,52 +72,74 @@ export function connectionRequest(deviceId, callback): void { if (managementServer.ConnectionRequestPassword) password = managementServer.ConnectionRequestPassword._value; - const conReq = (): void => { - const udpSent = udpConReq( - udpConnectionRequestAddress, - username, - password, - err => { - if (err) throw err; - } - ); - - httpConReq( - connectionRequestUrl, - username, - password, - CONNECTION_REQUEST_ALLOW_BASIC_AUTH, - CONNECTION_REQUEST_TIMEOUT, - err => { - if (udpSent) return void callback(); - callback(err); - } - ); + const context = { + id: device["_id"], + serialNumber: device["_deviceId"]["SerialNumber"], + productClass: device["_deviceId"]["ProductClass"], + oui: device["_deviceId"]["OUI"], + username: username || "", + password: password || "" }; - if (config.auth && config.auth.connectionRequest) { - // Callback is optional for backward compatibility - if (config.auth.connectionRequest.length > 4) { - return void config.auth.connectionRequest( - deviceId, - connectionRequestUrl, - username, - password, - (u, p) => { - username = u; - password = p; - conReq(); - } + getCurrentSnapshot() + .then(snapshot => { + const now = Date.now(); + const UDP_CONNECTION_REQUEST_PORT = +getConfig( + snapshot, + "cwmp.udpConnectionRequestAuth", + context, + now ); - } - [username, password] = config.auth.connectionRequest( - deviceId, - connectionRequestUrl, - username, - password - ); - } - conReq(); + const CONNECTION_REQUEST_TIMEOUT = +getConfig( + snapshot, + "cwmp.connectionRequestTimeout", + context, + now + ); + const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = !!getConfig( + snapshot, + "cwmp.connectionRequestAllowBasicAuth", + context, + now + ); + let authExp: Expression = getConfigExpression( + snapshot, + "cwmp.connectionRequestAuth" + ); + + if (authExp === undefined) { + authExp = [ + "FUNC", + "AUTH", + ["PARAM", "username"], + ["PARAM", "password"] + ] as Expression; + } + + let udpProm; + if (udpConnectionRequestAddress) { + udpProm = udpConnectionRequest( + udpConnectionRequestAddress, + authExp, + db.configCollection, + UDP_CONNECTION_REQUEST_PORT + ); + } + + httpConnectionRequest( + connectionRequestUrl, + authExp, + context, + CONNECTION_REQUEST_ALLOW_BASIC_AUTH, + CONNECTION_REQUEST_TIMEOUT + ) + .then(callback) + .catch(err => { + if (udpProm) return void udpProm.then(callback).catch(callback); + callback(err); + }); + }) + .catch(callback); }); } diff --git a/lib/config.ts b/lib/config.ts index 511f12f..8bdb2a5 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -235,11 +235,3 @@ export function getDefault(optionName): string | number | boolean { return val; } - -export let auth; -// Load authentication scripts -try { - auth = require(resolve(allConfig.CONFIG_DIR as string, "auth.js")); -} catch (error) { - // No auth.js exists -} diff --git a/lib/connection-request.ts b/lib/connection-request.ts new file mode 100644 index 0000000..00874d7 --- /dev/null +++ b/lib/connection-request.ts @@ -0,0 +1,198 @@ +/** + * Copyright 2013-2019 Zaid Abdulla + * + * This file is part of GenieACS. + * + * GenieACS is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * GenieACS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with GenieACS. If not, see . + */ + +import * as crypto from "crypto"; +import * as dgram from "dgram"; +import { parse } from "url"; +import * as http from "http"; +import { evaluate } from "./common/expression"; +import { Expression } from "./types"; +import * as auth from "./auth"; + +function extractAuth( + exp: Expression, + context: {}, + now: number, + dflt: any +): [string, string, Expression] { + let username, password; + exp = evaluate(exp, context, now, e => { + if (!username && Array.isArray(e) && e[0] === "FUNC" && e[1] === "AUTH") { + if (!Array.isArray(e[2]) && !Array.isArray(e[3])) { + username = e[2]; + password = e[3]; + } + return dflt; + } + return e; + }); + + return [username, password, exp]; +} + +function httpGet( + options: {}, + timeout +): Promise<{ statusCode: number; headers: {} }> { + return new Promise((resolve, reject) => { + const req = http + .get(options, res => { + res.resume(); + resolve({ statusCode: res.statusCode, headers: res.headers }); + }) + .on("error", () => { + req.abort(); + reject(new Error("Device is offline")); + }) + .on("socket", socket => { + socket.setTimeout(timeout); + socket.on("timeout", () => { + req.abort(); + }); + }); + }); +} + +export async function httpConnectionRequest( + address: string, + authExp: Expression, + context: {}, + allowBasicAuth: boolean, + timeout: number +): Promise { + const now = Date.now(); + const options: http.RequestOptions = parse(address); + if (options.protocol !== "http:") + throw new Error("Invalid connection request URL or protocol"); + + options.agent = new http.Agent({ + maxSockets: 1, + keepAlive: true + }); + + let authHeader: {}; + let username: string; + let password: string; + + while (!authHeader || (username != null && password != null)) { + let opts = options; + if (authHeader) { + if (authHeader["method"] === "Basic") { + if (!allowBasicAuth) + throw new Error("Basic HTTP authentication not allowed"); + + opts = Object.assign( + { + headers: { + Authorization: auth.basic(username || "", password || "") + } + }, + options + ); + } else if (authHeader["method"] === "Digest") { + opts = Object.assign( + { + headers: { + Authorization: auth.digest( + username, + password, + options.path, + "GET", + null, + authHeader + ) + } + }, + options + ); + } else { + throw new Error("Unrecognized auth method"); + } + } + + let res = await httpGet(opts, timeout); + + // Workaround for some devices unexpectedly closing the connection + if (res.statusCode === 0 && authHeader) res = await httpGet(opts, timeout); + if (res.statusCode === 0) throw new Error("Device is offline"); + if (res.statusCode === 200 || res.statusCode === 204) return; + + if (res.statusCode === 401 && res.headers["www-authenticate"]) { + authHeader = auth.parseAuthHeader(res.headers["www-authenticate"]); + [username, password, authExp] = extractAuth(authExp, context, now, false); + } else { + throw new Error( + `Unexpected response code from device: ${res.statusCode}` + ); + } + } + throw new Error("Incorrect connection request credentials"); +} + +export async function udpConnectionRequest( + address: string, + authExp: Expression, + context: {}, + sourcePort: number = 0 +): Promise { + const [host, portStr] = address.split(":", 2); + const port = portStr ? parseInt(portStr) : 80; + const now = Date.now(); + + const client = dgram.createSocket({ type: "udp4", reuseAddr: true }); + // When a device is NAT'ed, the UDP Connection Request must originate from + // the same address and port used by the STUN server, in order to traverse + // the firewall. This does require that the Genieacs NBI and STUN server + // are allowed to bind to the same address and port. The STUN server needs + // to open its UDP port with the SO_REUSEADDR option, allowing the NBI to + // also bind to the same port. + if (sourcePort) client.bind({ port: sourcePort, exclusive: true }); + + let username: string; + let password: string; + + [username, password, authExp] = extractAuth(authExp, context, now, null); + if (username == null) username = ""; + if (password == null) password = ""; + while (username != null && password != null) { + const ts = Math.trunc(now / 1000); + const id = Math.trunc(Math.random() * 4294967295); + const cn = crypto.randomBytes(8).toString("hex"); + const sig = crypto + .createHmac("sha1", password) + .update(`${ts}${id}${username}${cn}`) + .digest("hex"); + const uri = `http://${address}?ts=${ts}&id=${id}&un=${username}&cn=${cn}&sig=${sig}`; + const message = Buffer.from( + `GET ${uri} HTTP/1.1\r\nHost: ${address}\r\n\r\n` + ); + + for (let i = 0; i < 3; ++i) { + await new Promise((resolve, reject) => { + client.send(message, 0, message.length, port, host, (err: Error) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + [username, password, authExp] = extractAuth(authExp, context, now, null); + } + client.close(); +} diff --git a/lib/local-cache.ts b/lib/local-cache.ts index 7d66f79..93e7fc9 100644 --- a/lib/local-cache.ts +++ b/lib/local-cache.ts @@ -530,7 +530,8 @@ export function getConfig( "cwmp.connectionRequestAllowBasicAuth": "CONNECTION_REQUEST_ALLOW_BASIC_AUTH", "cwmp.maxCommitIterations": "MAX_COMMIT_ITERATIONS", - "cwmp.deviceOnlineThreshold": "DEVICE_ONLINE_THRESHOLD" + "cwmp.deviceOnlineThreshold": "DEVICE_ONLINE_THRESHOLD", + "cwmp.udpConnectionRequestPort": "UDP_CONNECTION_REQUEST_PORT" }; if (!(key in snapshot.config)) { @@ -541,6 +542,13 @@ export function getConfig( return expression.evaluate(snapshot.config[key], context, now || Date.now()); } +export function getConfigExpression(snapshotKey, key): Expression { + const snapshot = snapshots.get(snapshotKey); + if (!snapshot) throw new Error("Cache snapshot does not exist"); + + return snapshot.config[key]; +} + export function getUsers(snapshotKey): {} { const snapshot = snapshots.get(snapshotKey); if (!snapshot) throw new Error("Cache snapshot does not exist");