diff --git a/build/build.ts b/build/build.ts index a56a51c..d33c940 100644 --- a/build/build.ts +++ b/build/build.ts @@ -77,7 +77,8 @@ const externals = [ "yaml", "codemirror", "codemirror/mode/javascript/javascript", - "codemirror/mode/yaml/yaml" + "codemirror/mode/yaml/yaml", + "ipaddr.js" ]; function rmDirSync(dirPath): void { diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index f17ad85..4c606e7 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -164,12 +164,13 @@ FS_ACCESS_LOG_FILE Default: unset -FS_HOSTNAME - The hostname to use when generating the file URL for TR-069 Download - requests. Set this if the hostname where genieacs-fs is listening differs - from the hostname in the ACS URL. +FS_URL_PREFIX + The URL prefix (e.g. 'https://example.com:7657/') to use when generating the + file URL for TR-069 Download requests. Set this if genieacs-fs and + genieacs-cwmp are behind a proxy or running on different servers. - Default: the hostname from the ACS URL + Default: auto generated based on the hostname from the ACS URL, FS_PORT + config, and whether or not SSL is enabled for genieacs-fs. UI_WORKER_PROCESSES The number of worker processes to spawn for genieacs-ui. A value of 0 means diff --git a/lib/config.ts b/lib/config.ts index 8d8ed8d..2ab8765 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -32,7 +32,7 @@ while (!existsSync(`${ROOT_DIR}/package.json`)) { } // For compatibility with v1.1 -let configDir, cwmpSsl, nbiSsl, fsSsl, uiSsl; +let configDir, cwmpSsl, nbiSsl, fsSsl, uiSsl, fsHostname; const options = { EXT_DIR: { type: "path", default: resolve(ROOT_DIR, "config/ext") }, @@ -62,7 +62,7 @@ const options = { FS_INTERFACE: { type: "string", default: "0.0.0.0" }, FS_SSL_CERT: { type: "string", default: "" }, FS_SSL_KEY: { type: "string", default: "" }, - FS_HOSTNAME: { type: "string", default: "" }, + FS_URL_PREFIX: { type: "string", default: "" }, FS_LOG_FILE: { type: "path", default: "" }, FS_ACCESS_LOG_FILE: { type: "path", default: "" }, @@ -76,6 +76,7 @@ const options = { UI_JWT_SECRET: { type: "string", default: "" }, UDP_CONNECTION_REQUEST_PORT: { type: "int", default: 0 }, + FORWARDED_HEADER: { type: "string", default: "" }, DOWNLOAD_TIMEOUT: { type: "int", default: 3600 }, EXT_TIMEOUT: { type: "int", default: 3000 }, @@ -143,6 +144,9 @@ function setConfig(name, value, commandLineArgument = false): boolean { .trim(); } + if (name === "FS_HOSTNAME" || name === "fs-hostname") + fsHostname = fsHostname || String(value).trim(); + // For compatibility with v1.0 if (name === "PRESETS_CACHE_DURATION" || name === "presets-cache-duration") setConfig("MAX_CACHE_TTL", value); @@ -260,6 +264,15 @@ if (["true", "1"].includes(uiSsl)) { setConfig("UI_SSL_KEY", `${d}/cwmp.key`); } +if (fsHostname) { + const FS_PORT = allConfig["FS_PORT"] || 7567; + const FS_SSL = !!allConfig["FS_SSL_CERT"]; + setConfig( + "FS_URL_PREFIX", + (FS_SSL ? "https" : "http") + `://${fsHostname}:${FS_PORT}/` + ); +} + // Defaults for (const [k, v] of Object.entries(options)) if (v["default"] != null) setConfig(k, v["default"]); diff --git a/lib/cwmp.ts b/lib/cwmp.ts index d5352db..43edef2 100644 --- a/lib/cwmp.ts +++ b/lib/cwmp.ts @@ -48,10 +48,10 @@ import { import { IncomingMessage, ServerResponse } from "http"; import { Readable } from "stream"; import { promisify } from "util"; -import { TLSSocket } from "tls"; import { decode, encodingExists } from "iconv-lite"; import { parseXmlDeclaration } from "./xml-parser"; import * as debug from "./debug"; +import { getRequestOrigin } from "./forwarded"; const gzipPromisified = promisify(zlib.gzip); const deflatePromisified = promisify(zlib.deflate); @@ -770,18 +770,18 @@ async function sendAcsRequest( const downloadRequest = acsRequest as SetAcsRequest; downloadRequest.fileSize = 0; if (!downloadRequest.url) { - const FS_PORT = config.get("FS_PORT"); - const FS_SSL = config.get("FS_SSL_CERT"); - let hostname = config.get("FS_HOSTNAME"); - if (!hostname) { - if (sessionContext.httpRequest.headers["host"]) - hostname = sessionContext.httpRequest.headers["host"].split(":")[0]; - else hostname = sessionContext.httpRequest.connection.localAddress; + let prefix = "" + config.get("FS_URL_PREFIX"); + + if (!prefix) { + const FS_PORT = +config.get("FS_PORT"); + const ssl = !!config.get("FS_SSL_CERT"); + const origin = getRequestOrigin(sessionContext.httpRequest); + let hostname = origin.localAddress; + if (origin.host) [hostname] = origin.host.split(":", 1); + prefix = (ssl ? "https" : "http") + `://${hostname}:${FS_PORT}/`; } - downloadRequest.url = FS_SSL ? "https://" : "http://"; - downloadRequest.url += hostname; - if (FS_PORT !== 80) downloadRequest.url += ":" + FS_PORT; - downloadRequest.url += "/" + encodeURI(downloadRequest.fileName); + + downloadRequest.url = prefix + encodeURI(downloadRequest.fileName); const files = localCache.getFiles(sessionContext.cacheSnapshot); if (files[downloadRequest.fileName]) @@ -974,7 +974,7 @@ async function responseUnauthorized( resHeaders["Connection"] = "close"; } else { - if (sessionContext.httpRequest.socket instanceof TLSSocket) { + if (getRequestOrigin(sessionContext.httpRequest).encrypted) { resHeaders["WWW-Authenticate"] = `Basic realm="${REALM}"`; } else { const nonce = crypto.randomBytes(16).toString("hex"); @@ -1347,12 +1347,12 @@ async function listenerAsync( cacheSnapshot, "cwmp.debug", { - remoteAddress: httpResponse.connection.remoteAddress + remoteAddress: getRequestOrigin(httpRequest).remoteAddress }, Date.now(), e => { if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "REMOTE_ADDRESS") - return httpResponse.connection.remoteAddress; + return getRequestOrigin(httpRequest).remoteAddress; return e; } ); @@ -1395,12 +1395,12 @@ async function listenerAsync( cacheSnapshot, "cwmp.debug", { - remoteAddress: httpResponse.connection.remoteAddress + remoteAddress: getRequestOrigin(httpRequest).remoteAddress }, Date.now(), e => { if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "REMOTE_ADDRESS") - return httpResponse.connection.remoteAddress; + return getRequestOrigin(httpRequest).remoteAddress; return e; } ); @@ -1429,12 +1429,12 @@ async function listenerAsync( cacheSnapshot, "cwmp.debug", { - remoteAddress: httpResponse.connection.remoteAddress + remoteAddress: getRequestOrigin(httpRequest).remoteAddress }, Date.now(), e => { if (Array.isArray(e) && e[0] === "FUNC" && e[1] === "REMOTE_ADDRESS") - return httpResponse.connection.remoteAddress; + return getRequestOrigin(httpRequest).remoteAddress; return e; } ); diff --git a/lib/forwarded.ts b/lib/forwarded.ts new file mode 100644 index 0000000..ddd2b3e --- /dev/null +++ b/lib/forwarded.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2013-2019 GenieACS Inc. + * + * 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 { IncomingMessage } from "http"; +import { TLSSocket } from "tls"; +import { parseCIDR, parse, IPv6, IPv4 } from "ipaddr.js"; +import * as config from "./config"; + +interface RequestOrigin { + localAddress: string; + localPort: number; + remoteAddress: string; + remotePort: number; + host: string; + encrypted: boolean; +} + +const FORWARDED_HEADER = "" + config.get("FORWARDED_HEADER"); +const cache = new WeakMap(); +const cidrs: [IPv4 | IPv6, number][] = []; + +for (const str of FORWARDED_HEADER.split(",").map(s => s.trim())) { + try { + cidrs.push(parseCIDR(str)); + } catch (err) { + // Not a valid CIDR format, try parsing as IP + try { + const ip = parse(str); + cidrs.push([ip, ip.toByteArray().length * 8]); + } catch (err) { + // Not a valid IP either, ignore + } + } +} + +function parseForwardedHeader(str: string): { [name: string]: string } { + str = str.toLowerCase(); + const res: { [name: string]: string } = {}; + let keyIdx = 0; + let valueIdx = -1; + let key: string; + for (let i = 0; i < str.length; ++i) { + const char = str.charCodeAt(i); + if (char === 61 /* = */) { + if (keyIdx >= 0) { + key = str.slice(keyIdx, i).trim(); + keyIdx = -1; + valueIdx = i + 1; + } + } else if (char === 59 /* ; */) { + if (valueIdx >= 0) res[key] = str.slice(valueIdx, i).trim(); + valueIdx = -1; + keyIdx = i + 1; + } else if (char === 44 /* , */) { + if (valueIdx >= 0) res[key] = str.slice(valueIdx, i).trim(); + return res; + } else if (char === 34 /* " */) { + if (valueIdx >= 0) { + const quoteIdx = i; + if (!str.slice(valueIdx, quoteIdx).trim()) { + for (i = i + 1; i < str.length; ++i) { + const c = str.charCodeAt(i); + if (c === 92 /* \ */) ++i; + if (c === 34 /* " */) { + res[key] = JSON.parse(str.slice(quoteIdx, i + 1).trim()); + valueIdx = -1; + keyIdx = i + 1; + break; + } + } + } + } + } + } + + if (valueIdx >= 0) res[key] = str.slice(valueIdx).trim(); + + return res; +} + +export function getRequestOrigin(request: IncomingMessage): RequestOrigin { + let origin = cache.get(request); + if (!origin) { + const soc = request.socket; + origin = { + localAddress: soc.localAddress, + localPort: soc.localPort, + remoteAddress: soc.remoteAddress, + remotePort: soc.remotePort, + host: request.headers["host"], + encrypted: !!(request.socket as TLSSocket).encrypted + }; + + // const header = request.headers["forwarded"]; + const header = 'For="[2001:db8:cafe::17]:4711";proto_=https'; + if (header) { + const ip = parse(soc.remoteAddress) as IPv4; + if (cidrs.some(cidr => ip.match(cidr as [IPv4, number]))) { + const parsed = parseForwardedHeader(header); + + if (parsed["proto"] === "https") { + origin.encrypted = true; + origin.localPort = 443; + } else if (parsed["proto"] === "http") { + origin.encrypted = false; + origin.localPort = 80; + } + + if (parsed["host"]) { + origin.host = parsed["host"]; + const [, port] = parsed["host"].split(":", 2); + origin.localPort = +port || origin.localPort; + } + + if (parsed["for"]) { + if (parsed["for"].startsWith("[")) { + const i = parsed["for"].lastIndexOf("]"); + if (i >= 0) { + origin.remoteAddress = parsed["for"].slice(1, i); + origin.remotePort = + parseInt(parsed["for"].slice(i + 2)) || origin.remotePort; + } + } else { + const i = parsed["for"].lastIndexOf(":"); + if (i >= 0) { + origin.remoteAddress = parsed["for"].slice(0, i); + origin.remotePort = + parseInt(parsed["for"].slice(i + 1)) || origin.remotePort; + } else { + origin.remoteAddress = parsed["for"]; + } + } + } + + if (parsed["by"]) { + if (parsed["by"].startsWith("[")) { + const i = parsed["by"].lastIndexOf("]"); + if (i >= 0) { + origin.localAddress = parsed["by"].slice(1, i); + origin.localPort = + parseInt(parsed["by"].slice(i + 2)) || origin.localPort; + } + } else { + const i = parsed["by"].lastIndexOf(":"); + if (i >= 0) { + origin.localAddress = parsed["by"].slice(0, i); + origin.localPort = + parseInt(parsed["by"].slice(i + 1)) || origin.localPort; + } else { + origin.localAddress = parsed["by"]; + } + } + } + } + } + + cache.set(request, origin); + } + return origin; +} diff --git a/lib/fs.ts b/lib/fs.ts index be98836..1eed20c 100644 --- a/lib/fs.ts +++ b/lib/fs.ts @@ -19,11 +19,16 @@ import * as url from "url"; import * as querystring from "querystring"; +import { IncomingMessage, ServerResponse } from "http"; import { GridFSBucket } from "mongodb"; import * as db from "./db"; import * as logger from "./logger"; +import { getRequestOrigin } from "./forwarded"; -export function listener(request, response): void { +export function listener( + request: IncomingMessage, + response: ServerResponse +): void { const urlParts = url.parse(request.url, true); if (request.method === "GET") { const filename = querystring.unescape(urlParts.pathname.substring(1)); @@ -31,7 +36,7 @@ export function listener(request, response): void { const log = { message: "Fetch file", filename: filename, - remoteAddress: request.connection.remoteAddress + remoteAddress: getRequestOrigin(request).remoteAddress }; db.filesCollection.findOne({ _id: filename }, (err, file) => { diff --git a/lib/logger.ts b/lib/logger.ts index ad5b0d9..0a42874 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -21,6 +21,7 @@ import * as fs from "fs"; import * as os from "os"; import * as config from "./config"; +import { getRequestOrigin } from "./forwarded"; const REOPEN_EVERY = 60000; @@ -149,8 +150,9 @@ export function close(): void { export function flatten(details): {} { if (details.sessionContext) { details.deviceId = details.sessionContext.deviceId; - details.remoteAddress = - details.sessionContext.httpRequest.connection.remoteAddress; + details.remoteAddress = getRequestOrigin( + details.sessionContext.httpRequest + ).remoteAddress; delete details.sessionContext; } @@ -198,7 +200,7 @@ export function flatten(details): {} { // For genieacs-ui if (details.context) { - details.remoteAddress = details.context.request.ip; + details.remoteAddress = getRequestOrigin(details.context.req).remoteAddress; if (details.context.state.user) details.user = details.context.state.user.username; delete details.context; diff --git a/lib/session.ts b/lib/session.ts index 5df1137..a07e889 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -50,6 +50,7 @@ import { ScriptResult, Expression } from "./types"; +import { getRequestOrigin } from "./forwarded"; const VALID_PARAM_TYPES = new Set([ "xsd:int", @@ -119,7 +120,7 @@ export function configContextCallback( else if (name === "productClass") name = "DeviceID.ProductClass"; else if (name === "oui") name = "DeviceID.OUI"; else if (name === "remoteAddress") - return sessionContext.httpRequest.connection.remoteAddress; + return getRequestOrigin(sessionContext.httpRequest).remoteAddress; const deviceData = sessionContext.deviceData; const paths = deviceData.paths; @@ -130,7 +131,7 @@ export function configContextCallback( } } else if (exp[0] === "FUNC") { if (exp[1] === "REMOTE_ADDRESS") - return sessionContext.httpRequest.connection.remoteAddress; + return getRequestOrigin(sessionContext.httpRequest).remoteAddress; } return exp; } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index e096d87..e903e47 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -4525,6 +4525,11 @@ } } }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "irregular-plurals": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-2.0.0.tgz", diff --git a/package.json b/package.json index e9b1f37..56debd4 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "iconv-lite": "^0.5.0", + "ipaddr.js": "^1.9.1", "jsonwebtoken": "^8.5.1", "koa": "^2.7.0", "koa-bodyparser": "^4.2.1",