Compare commits

...
31 Commits
Author SHA1 Message Date
Zaid Humaimidi b5f689b014 Fix XMPP stream not getting closed properly 2023-02-06 14:48:00 -08:00
Zaid Abdulla ccfbcddeda Always bind resource to XMPP stream
The XMPP server may not flag the bind feature as required but we're
still required to bind resource to the stream in order to be able to
send IQ stanzas to other clients.
2021-01-05 18:05:19 -08:00
Zaid Abdulla 081201cd2f Only send UDP con req if STUNEnable is true
Because even when STUN is not enabled, the param UDPConnectionRequestAddress
will still hold IP of the listening interface which we can't reach.
2020-12-08 02:08:25 -08:00
Zaid Abdulla 0e0380917f XMPP fixes 2020-12-08 02:08:25 -08:00
Zaid Abdulla 79ede00ef2 Implement XMPP connection request 2020-12-08 02:08:25 -08:00
Zaid Abdulla 82ed742a67 Convenience function to send IQ stanza messages 2020-12-08 02:08:25 -08:00
Zaid Abdulla 53de5d01e7 Initial XmppClient implementation 2020-12-08 02:08:25 -08:00
Zaid Abdulla d29448e3a7 Fix parsing XML attrs with colon, equal chars 2020-12-08 02:07:34 -08:00
Zaid Abdulla a91dbe5021 Handle multi-line XML declaration 2020-12-08 02:06:55 -08:00
Zaid Abdulla 63cd8dd2de Save fault when session terminates unsuccessfully 2020-12-08 00:27:29 -08:00
Zaid Abdulla bded263adf Refactor HTTP connection requests 2020-12-04 15:33:35 -08:00
Zaid Abdulla fa1e40e370 Read session lock to detect task completion 2020-11-30 18:11:51 -08:00
Zaid Abdulla 30bea8ac57 Save CPE changes in case of session error/timeout 2020-11-19 14:18:05 -08:00
Zaid Abdulla d787f66e3f Reimplement locking w/o relying on hostInfo cmd
Because the hostInfo MongoDB command may not be allowed in some
environments. Also, in sharded deployments the clock skew may differ
between different shards so using hostInfo is not enough. This new
implementation simply assumes the clocks are in sync within a certain
range and throws an error if clocks are found to be out of sync.
2020-11-18 15:06:49 -08:00
Zaid Abdulla a9b0e3a1f7 Use a single MongoDB client 2020-11-18 00:01:41 -08:00
Zaid Abdulla bd55d6f512 Better support for LIKE op in query minimization 2020-11-16 15:23:38 -08:00
Zaid Abdulla e64001cddc Accept primitive expressions in filter component 2020-11-16 15:20:48 -08:00
Zaid Abdulla 60d8dcb682 1.2.3 2020-10-26 23:40:41 -07:00
Zaid Abdulla e0d648ea3e Changelog for v1.2.3 2020-10-26 23:33:36 -07:00
Zaid Abdulla 6ba67c3fb6 Update dependencies 2020-10-26 23:18:37 -07:00
Zaid Abdulla 04a7c08a20 Fix typos 2020-10-26 15:29:03 -07:00
Zaid Abdulla 92091b52a2 More deprecation fixes 2020-10-26 15:28:22 -07:00
Zaid Abdulla a43bba2405 Fix deprecations 2020-10-25 23:34:05 -07:00
Zaid Abdulla c8a6eb4c72 Show confirm close alert for unsaved changes 2020-10-25 13:37:35 -07:00
Zaid Abdulla 36798cfb5f Fix confusing 404 for unauthorized requests 2020-10-24 10:24:19 -07:00
Zaid Abdulla c52ff6f6ec Support Unicode characters in tags 2020-10-20 15:44:11 -07:00
Zaid Abdulla dfab724515 Add new config cwmp.skipWritableCheck
Some CPEs incorrectly report writable parameters as non-writable. This
config option lets us bypass the check and attempt to modify the
parameter even if it reported as read-only.
2020-10-20 13:35:34 -07:00
Zaid Abdulla 4fd919ccfa Add more XML validations 2020-10-19 13:34:08 -07:00
Zaid Abdulla 62849e8081 Fix connection timeout being set incorrectly 2020-10-18 12:52:01 -07:00
Zaid Abdulla 9f4b421b59 Add Node types to dev dependencies 2020-10-17 20:09:32 -07:00
Zaid Abdulla 44a2fe11ee Fix invalid expression after minimization
In rare edge cases where IS NULL is used the minimization process can
generate an expression that's not equivilant to the original one.
2020-10-16 17:33:29 -07:00
47 changed files with 1944 additions and 3541 deletions

No files matched your search

+19
View File
@@ -1,6 +1,24 @@
# Change Log # Change Log
## 1.2.3 (2020-10-26)
- New config option 'cwmp.skipWritableCheck' for when some CPEs incorrectly
report writable parameters as non-writable. When set to true, the scripts will
no longer respect the 'writable' attribute of the CPE parameters and will send
a SetParamteerValues, AddObject, or DeleteObject request anyway.
- Tags no longer restrict what characters are allowed. Any character other than
alphanumeric characters, hyphen, or underscore is now encoded in the data
model (i.e. Tags.\<tag>) using its hex value preceded by "0x".
- Ask for a confirmation before closing a pop-up dialog with unsaved changes.
- Better XML validation to avoid crashes caused by invalid CPE requests.
- Fix confusing 404 error message when the user attempts to modify a resource
when they don't have the necessary permissions.
- Fix a rare issue where genieacs-cwmp stops accepting new connections after
running for a few weeks.
- Fix exception when IS NULL operator is used in certain situations.
## 1.2.2 (2020-10-03) ## 1.2.2 (2020-10-03)
- Added button to push files to selected devices from device listing page. - Added button to push files to selected devices from device listing page.
- A few minor UI improvements. - A few minor UI improvements.
- Fix exception that can happen and persist after a Download request. - Fix exception that can happen and persist after a Download request.
@@ -9,6 +27,7 @@
from v1.1. from v1.1.
## 1.2.1 (2020-09-08) ## 1.2.1 (2020-09-08)
- Fix bug causing faults to not be displayed in the UI. - Fix bug causing faults to not be displayed in the UI.
- Fix bug where deleting objects does not get reflected immediately in the UI. - Fix bug where deleting objects does not get reflected immediately in the UI.
- Improve conversion between filters written in the expression format and - Improve conversion between filters written in the expression format and
+13 -14
View File
@@ -24,7 +24,6 @@ import * as server from "../lib/server";
import * as cwmp from "../lib/cwmp"; import * as cwmp from "../lib/cwmp";
import * as db from "../lib/db"; import * as db from "../lib/db";
import * as extensions from "../lib/extensions"; import * as extensions from "../lib/extensions";
import * as cache from "../lib/cache";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("cwmp", VERSION); logger.init("cwmp", VERSION);
@@ -36,7 +35,6 @@ function exitWorkerGracefully(): void {
setTimeout(exitWorkerUngracefully, 5000).unref(); setTimeout(exitWorkerUngracefully, 5000).unref();
Promise.all([ Promise.all([
db.disconnect(), db.disconnect(),
cache.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ]).catch(exitWorkerUngracefully);
@@ -77,9 +75,16 @@ if (!cluster.worker) {
cluster.stop(); cluster.stop();
}); });
} else { } else {
const ssl = { const key = config.get("CWMP_SSL_KEY") as string;
key: config.get("CWMP_SSL_KEY") as string, const cert = config.get("CWMP_SSL_CERT") as string;
cert: config.get("CWMP_SSL_CERT") as string,
const options = {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
onConnection: cwmp.onConnection,
timeout: 30000,
keepAliveTimeout: 0,
}; };
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
@@ -92,16 +97,10 @@ if (!cluster.worker) {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
const initPromise = Promise.all([db.connect(), cache.connect()]) const initPromise = db
.connect()
.then(() => { .then(() => {
server.start( server.start(options, cwmp.listener);
SERVICE_PORT,
SERVICE_ADDRESS,
ssl,
cwmp.listener,
cwmp.onConnection,
0
);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
+13 -11
View File
@@ -23,7 +23,6 @@ import * as cluster from "../lib/cluster";
import * as server from "../lib/server"; import * as server from "../lib/server";
import { listener } from "../lib/fs"; import { listener } from "../lib/fs";
import * as db from "../lib/db"; import * as db from "../lib/db";
import * as cache from "../lib/cache";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("fs", VERSION); logger.init("fs", VERSION);
@@ -33,11 +32,9 @@ const SERVICE_PORT = config.get("FS_PORT") as number;
function exitWorkerGracefully(): void { function exitWorkerGracefully(): void {
setTimeout(exitWorkerUngracefully, 5000).unref(); setTimeout(exitWorkerUngracefully, 5000).unref();
Promise.all([ Promise.all([db.disconnect(), cluster.worker.disconnect()]).catch(
db.disconnect(), exitWorkerUngracefully
cache.disconnect(), );
cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully);
} }
function exitWorkerUngracefully(): void { function exitWorkerUngracefully(): void {
@@ -73,9 +70,13 @@ if (!cluster.worker) {
cluster.stop(); cluster.stop();
}); });
} else { } else {
const ssl = { const key = config.get("FS_SSL_KEY") as string;
key: config.get("FS_SSL_KEY") as string, const cert = config.get("FS_SSL_CERT") as string;
cert: config.get("FS_SSL_CERT") as string, const options = {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
timeout: 30000,
}; };
let stopping = false; let stopping = false;
@@ -96,9 +97,10 @@ if (!cluster.worker) {
listener(req, res); listener(req, res);
}; };
const initPromise = Promise.all([db.connect(), cache.connect()]) const initPromise = db
.connect()
.then(() => { .then(() => {
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener); server.start(options, _listener);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
+10 -7
View File
@@ -24,7 +24,6 @@ import * as server from "../lib/server";
import { listener } from "../lib/nbi"; import { listener } from "../lib/nbi";
import * as db from "../lib/db"; import * as db from "../lib/db";
import * as extensions from "../lib/extensions"; import * as extensions from "../lib/extensions";
import * as cache from "../lib/cache";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("nbi", VERSION); logger.init("nbi", VERSION);
@@ -36,7 +35,6 @@ function exitWorkerGracefully(): void {
setTimeout(exitWorkerUngracefully, 5000).unref(); setTimeout(exitWorkerUngracefully, 5000).unref();
Promise.all([ Promise.all([
db.disconnect(), db.disconnect(),
cache.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ]).catch(exitWorkerUngracefully);
@@ -77,9 +75,13 @@ if (!cluster.worker) {
cluster.stop(); cluster.stop();
}); });
} else { } else {
const ssl = { const key = config.get("NBI_SSL_KEY") as string;
key: config.get("NBI_SSL_KEY") as string, const cert = config.get("NBI_SSL_CERT") as string;
cert: config.get("NBI_SSL_CERT") as string, const options = {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
timeout: 30000,
}; };
let stopping = false; let stopping = false;
@@ -100,9 +102,10 @@ if (!cluster.worker) {
listener(req, res); listener(req, res);
}; };
const initPromise = Promise.all([db.connect(), cache.connect()]) const initPromise = db
.connect()
.then(() => { .then(() => {
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener); server.start(options, _listener);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
+12 -13
View File
@@ -23,9 +23,7 @@ import * as cluster from "../lib/cluster";
import * as server from "../lib/server"; import * as server from "../lib/server";
import { listener } from "../lib/ui"; import { listener } from "../lib/ui";
import * as extensions from "../lib/extensions"; import * as extensions from "../lib/extensions";
import * as db from "../lib/ui/db"; import * as db from "../lib/db";
import * as db2 from "../lib/db";
import * as cache from "../lib/cache";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("ui", VERSION); logger.init("ui", VERSION);
@@ -37,8 +35,6 @@ function exitWorkerGracefully(): void {
setTimeout(exitWorkerUngracefully, 5000).unref(); setTimeout(exitWorkerUngracefully, 5000).unref();
Promise.all([ Promise.all([
db.disconnect(), db.disconnect(),
db2.disconnect(),
cache.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ]).catch(exitWorkerUngracefully);
@@ -79,9 +75,13 @@ if (!cluster.worker) {
cluster.stop(); cluster.stop();
}); });
} else { } else {
const ssl = { const key = config.get("UI_SSL_KEY") as string;
key: config.get("UI_SSL_KEY") as string, const cert = config.get("UI_SSL_CERT") as string;
cert: config.get("UI_SSL_CERT") as string, const options = {
port: SERVICE_PORT,
host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null,
timeout: 30000,
}; };
let stopping = false; let stopping = false;
@@ -99,14 +99,13 @@ if (!cluster.worker) {
const _listener = (req, res): void => { const _listener = (req, res): void => {
if (stopping) res.setHeader("Connection", "close"); if (stopping) res.setHeader("Connection", "close");
listener(req, res).catch((err) => { listener(req, res);
throw err;
});
}; };
const initPromise = Promise.all([db2.connect(), cache.connect()]) const initPromise = db
.connect()
.then(() => { .then(() => {
server.start(SERVICE_PORT, SERVICE_ADDRESS, ssl, _listener); server.start(options, _listener);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
+11 -5
View File
@@ -19,14 +19,13 @@
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import * as fs from "fs";
import { promisify } from "util";
import { rollup, WarningHandler } from "rollup"; import { rollup, WarningHandler } from "rollup";
import rollupJson from "@rollup/plugin-json"; import rollupJson from "@rollup/plugin-json";
import typescript from "@rollup/plugin-typescript"; import typescript from "@rollup/plugin-typescript";
import { terser } from "rollup-plugin-terser"; import { terser } from "rollup-plugin-terser";
import nodeResolve from "@rollup/plugin-node-resolve"; import nodeResolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs"; import commonjs from "@rollup/plugin-commonjs";
import webpack from "webpack"; import webpack, { Configuration } from "webpack";
import postcss from "postcss"; import postcss from "postcss";
import postcssImport from "postcss-import"; import postcssImport from "postcss-import";
import postcssPresetEnv from "postcss-preset-env"; import postcssPresetEnv from "postcss-preset-env";
@@ -52,6 +51,8 @@ const externals = [
"tls", "tls",
"http", "http",
"https", "https",
"net",
"events",
"zlib", "zlib",
"crypto", "crypto",
"util", "util",
@@ -340,8 +341,8 @@ async function generateFrontendJs(): Promise<void> {
file: outputFile, file: outputFile,
}); });
const webpackConf = { const webpackConf: Configuration = {
mode: MODE, mode: MODE as "development" | "production",
entry: outputFile, entry: outputFile,
resolve: { resolve: {
aliasFields: ["module"], aliasFields: ["module"],
@@ -362,8 +363,13 @@ async function generateFrontendJs(): Promise<void> {
}, },
}; };
const stats = await promisify(webpack)(webpackConf); return new Promise((resolve, reject) => {
webpack(webpackConf, (err, stats) => {
if (err) return reject(err);
process.stdout.write(stats.toString({ colors: true }) + "\n"); process.stdout.write(stats.toString({ colors: true }) + "\n");
resolve();
});
});
} }
async function generateIconsSprite(): Promise<void> { async function generateIconsSprite(): Promise<void> {
+67 -25
View File
@@ -29,22 +29,31 @@ import {
import { import {
httpConnectionRequest, httpConnectionRequest,
udpConnectionRequest, udpConnectionRequest,
xmppConnectionRequest,
} from "./connection-request"; } from "./connection-request";
import { Expression, Task } from "./types"; import { Expression, Task } from "./types";
import { flattenDevice } from "./mongodb-functions"; import { flattenDevice } from "./mongodb-functions";
import { evaluate } from "./common/expression"; import { evaluate } from "./common/expression";
import * as config from "../lib/config";
const XMPP_CONFIGURED = !!config.get("XMPP_JID");
export async function connectionRequest( export async function connectionRequest(
deviceId: string, deviceId: string,
device?: Record<string, { value?: [boolean | number | string, string] }> device?: Record<string, { value?: [boolean | number | string, string] }>
): Promise<void> { ): Promise<string> {
if (!device) { if (!device) {
const res = await db.devicesCollection.findOne({ _id: deviceId }); const res = await db.devicesCollection.findOne({ _id: deviceId });
if (!res) throw new Error("No such device"); if (!res) throw new Error("No such device");
device = flattenDevice(res); device = flattenDevice(res);
} }
let connectionRequestUrl, udpConnectionRequestAddress, username, password; let connectionRequestUrl,
udpConnectionRequestAddress,
stunEnable,
connReqJabberId,
username,
password;
if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) { if (device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"]) {
connectionRequestUrl = (device[ connectionRequestUrl = (device[
@@ -55,6 +64,12 @@ export async function connectionRequest(
"InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress" "InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress"
] || {} ] || {}
).value || [""])[0]; ).value || [""])[0];
stunEnable = ((
device["InternetGatewayDevice.ManagementServer.STUNEnable"] || {}
).value || [""])[0];
connReqJabberId = ((
device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || {}
).value || [""])[0];
username = (( username = ((
device[ device[
"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername" "InternetGatewayDevice.ManagementServer.ConnectionRequestUsername"
@@ -72,6 +87,10 @@ export async function connectionRequest(
udpConnectionRequestAddress = (( udpConnectionRequestAddress = ((
device["Device.ManagementServer.UDPConnectionRequestAddress"] || {} device["Device.ManagementServer.UDPConnectionRequestAddress"] || {}
).value || [""])[0]; ).value || [""])[0];
stunEnable = ((device["Device.ManagementServer.STUNEnable"] || {})
.value || [""])[0];
connReqJabberId = ((device["Device.ManagementServer.ConnReqJabberID"] || {})
.value || [""])[0];
username = (( username = ((
device["Device.ManagementServer.ConnectionRequestUsername"] || {} device["Device.ManagementServer.ConnectionRequestUsername"] || {}
).value || [""])[0]; ).value || [""])[0];
@@ -146,7 +165,7 @@ export async function connectionRequest(
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback); const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback);
let udpProm; let udpProm;
if (udpConnectionRequestAddress) { if (udpConnectionRequestAddress && +stunEnable) {
udpProm = udpConnectionRequest( udpProm = udpConnectionRequest(
udpConnectionRequestAddress, udpConnectionRequestAddress,
authExp, authExp,
@@ -156,8 +175,18 @@ export async function connectionRequest(
); );
} }
try { let status;
await httpConnectionRequest(
if (connReqJabberId && XMPP_CONFIGURED) {
status = await xmppConnectionRequest(
connReqJabberId,
authExp,
CONNECTION_REQUEST_TIMEOUT,
debug,
deviceId
);
} else {
status = await httpConnectionRequest(
connectionRequestUrl, connectionRequestUrl,
authExp, authExp,
CONNECTION_REQUEST_ALLOW_BASIC_AUTH, CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
@@ -165,34 +194,47 @@ export async function connectionRequest(
debug, debug,
deviceId deviceId
); );
} catch (err) {
if (!udpProm) throw err; if (udpProm) {
await udpProm; await udpProm;
return "";
} }
} }
export async function watchTask( return status;
}
export async function awaitSessionStart(
deviceId: string, deviceId: string,
taskId: string, lastInform: number,
timeout: number timeout: number
): Promise<string> { ): Promise<boolean> {
await new Promise((resolve) => setTimeout(resolve, 500)); const now = Date.now();
const device = await db.devicesCollection.findOne(
const task = await db.tasksCollection.findOne( { _id: deviceId },
{ _id: taskId }, { projection: { _lastInform: 1 } }
{ projection: { _id: 1 } }
); );
if (!task) return "completed"; const li = (device["_lastInform"] as Date).getTime();
if (li > lastInform) return true;
const token = await cache.get(`cwmp_session_${deviceId}`);
if (token) return true;
if (timeout < 500) return false;
await new Promise((resolve) => setTimeout(resolve, 500));
timeout -= Date.now() - now;
return awaitSessionStart(deviceId, lastInform, timeout);
}
const q = { _id: `${deviceId}:task_${taskId}` }; export async function awaitSessionEnd(
const fault = await db.faultsCollection.findOne(q, { deviceId: string,
projection: { _id: 1 }, timeout: number
}); ): Promise<boolean> {
if (fault) return "fault"; const now = Date.now();
const token = await cache.get(`cwmp_session_${deviceId}`);
if ((timeout -= 500) <= 0) return "timeout"; if (!token) return true;
if (timeout < 500) return false;
return watchTask(deviceId, taskId, timeout); await new Promise((resolve) => setTimeout(resolve, 500));
timeout -= Date.now() - now;
return awaitSessionEnd(deviceId, timeout);
} }
function sanitizeTask(task): void { function sanitizeTask(task): void {
+67 -91
View File
@@ -17,126 +17,102 @@
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>. * along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { MongoClient, Collection } from "mongodb"; import { Collection } from "mongodb";
import { onConnect } from "./db";
import * as config from "./config"; import * as config from "./config";
const CLOCK_SKEW_TOLERANCE = 30000;
const MAX_CACHE_TTL = +config.get("MAX_CACHE_TTL"); const MAX_CACHE_TTL = +config.get("MAX_CACHE_TTL");
let clientPromise: Promise<MongoClient>; let cacheCollection: Collection;
let mongoCollection: Collection;
let mongoTimeOffset = 0;
export async function connect(): Promise<void> { onConnect(async (db) => {
const MONGODB_CONNECTION_URL = "" + config.get("MONGODB_CONNECTION_URL"); cacheCollection = db.collection("cache");
clientPromise = MongoClient.connect(MONGODB_CONNECTION_URL, { await cacheCollection.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
useNewUrlParser: true,
useUnifiedTopology: true,
});
const db = (await clientPromise).db();
mongoCollection = db.collection("cache");
await mongoCollection.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
const now = Date.now();
const res = await db.command({ hostInfo: 1 });
mongoTimeOffset = res.system.currentTime.getTime() - now;
}
export async function disconnect(): Promise<void> {
if (clientPromise) await (await clientPromise).close();
}
export async function get(key: string | string[]): Promise<any> {
const expire = new Date(Date.now() - mongoTimeOffset);
if (Array.isArray(key)) {
const res = await mongoCollection.find({ _id: { $in: key } }).toArray();
const indices = {};
key.forEach((v, i) => {
indices[v] = i;
}); });
const values = []; export async function get(key: string): Promise<string> {
res.forEach((r) => { const res = await cacheCollection.findOne({ _id: key });
if (r["expire"] > expire) values[indices[r["_id"]]] = r["value"]; if (res) return res["value"];
});
return values;
} else {
const res = await mongoCollection.findOne({ _id: { $in: [key] } });
if (res && res["expire"] > expire) return res["value"];
return null; return null;
} }
}
export async function del(key: string | string[]): Promise<void> { export async function del(key: string): Promise<void> {
if (Array.isArray(key)) await cacheCollection.deleteOne({ _id: key });
await mongoCollection.deleteMany({ _id: { $in: key } });
else await mongoCollection.deleteOne({ _id: key });
} }
export async function set( export async function set(
key: string, key: string,
value: string | number, value: string,
ttl: number = MAX_CACHE_TTL ttl: number = MAX_CACHE_TTL
): Promise<void> { ): Promise<void> {
const expire = new Date(Date.now() - mongoTimeOffset + ttl * 1000); const timestamp = new Date();
await mongoCollection.replaceOne( const expire = new Date(
timestamp.getTime() + CLOCK_SKEW_TOLERANCE + ttl * 1000
);
await cacheCollection.replaceOne(
{ _id: key }, { _id: key },
{ _id: key, value: value, expire: expire }, { _id: key, value, expire, timestamp },
{ upsert: true } { upsert: true }
); );
} }
export async function pop(key: string): Promise<any> { export async function pop(key: string): Promise<any> {
const res = await mongoCollection.findOneAndDelete({ _id: key }); const res = await cacheCollection.findOneAndDelete({ _id: key });
if (res && res["value"]) return res["value"]["value"];
if (
res &&
res["value"] &&
+res["value"]["expire"] - (Date.now() - mongoTimeOffset)
)
return res["value"]["value"];
return null; return null;
} }
export async function lock( export async function acquireLock(
lockName: string, lockName: string,
ttl: number ttl: number,
): Promise<(extendTtl?: number) => Promise<void>> { timeout = 0,
const token = Math.random().toString(36).slice(2); token = Math.random().toString(36).slice(2)
): Promise<string> {
try {
const now = Date.now();
const r = await cacheCollection.findOneAndUpdate(
{ _id: lockName, token },
{
$set: {
value: token,
expire: new Date(now + ttl + CLOCK_SKEW_TOLERANCE),
},
$currentDate: { timestamp: true },
},
{ upsert: true, returnOriginal: false }
);
const v = r.value;
if (Math.abs(v["timestamp"].getTime() - now) > CLOCK_SKEW_TOLERANCE)
throw new Error("Database clock skew too great");
} catch (err) {
if (err.code === 11000) {
if (timeout > 0) {
return new Promise((resolve, reject) => {
const w = 50 + Math.random() * 50;
setTimeout(() => {
acquireLock(lockName, ttl, timeout - w, token).then(
resolve,
reject
);
}, w);
});
}
throw new Error("Failed to acquire lock");
}
throw err;
}
async function unlockOrExtend(extendTtl?: number): Promise<void> { return token;
if (!extendTtl) { }
const res = await mongoCollection.deleteOne({
export async function releaseLock(
lockName: string,
token: string
): Promise<void> {
const res = await cacheCollection.deleteOne({
_id: lockName, _id: lockName,
value: token, value: token,
}); });
if (res["result"]["n"] !== 1) throw new Error("Lock expired"); if (res["result"]["n"] !== 1) throw new Error("Lock expired");
} else {
const expire = new Date(Date.now() - mongoTimeOffset + extendTtl * 1000);
const res = await mongoCollection.updateOne(
{ _id: lockName, value: token },
{ expire: expire }
);
if (res["result"]["n"] !== 1) throw new Error("Lock expired");
}
}
const expireTest = new Date(Date.now() - mongoTimeOffset);
const expireSet = new Date(Date.now() - mongoTimeOffset + ttl * 1000);
try {
await mongoCollection.updateOne(
{ _id: lockName, expire: { $lte: expireTest } },
{ $set: { value: token, expire: expireSet } },
{ upsert: true }
);
} catch (err) {
if (err && err.code === 11000) {
await new Promise((resolve) => setTimeout(resolve, 200));
return lock(lockName, ttl);
}
}
return unlockOrExtend;
} }
+14
View File
@@ -47,3 +47,17 @@ export function generateDeviceId(
export function escapeRegExp(str: string): string { export function escapeRegExp(str: string): string {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&"); return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
} }
export function encodeTag(tag: string): string {
return encodeURIComponent(tag)
.replace(
/[!~*'()]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
)
.replace(/0x(?=[0-9A-Z]{2})/g, "0%78")
.replace(/%/g, "0x");
}
export function decodeTag(tag: string): string {
return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, "%"));
}
+11 -3
View File
@@ -362,6 +362,10 @@ class VarSynth extends BoolExprSynth {
exp = exp.slice(); exp = exp.slice();
exp[0] = ">"; exp[0] = ">";
this.negate = true; this.negate = true;
} else if (op === "NOT LIKE") {
exp = exp.slice();
exp[0] = "LIKE";
this.negate = true;
} }
} }
this.expStr = JSON.stringify(exp); this.expStr = JSON.stringify(exp);
@@ -809,6 +813,7 @@ function sopToExpression(
if (Array.isArray(expr) && expr[0] === "NOT" && Array.isArray(expr[1])) { if (Array.isArray(expr) && expr[0] === "NOT" && Array.isArray(expr[1])) {
const e: Expression[] = expr[1]; const e: Expression[] = expr[1];
if (e[0] === "IS NULL") expr = ["IS NOT NULL", ...e.slice(1)]; if (e[0] === "IS NULL") expr = ["IS NOT NULL", ...e.slice(1)];
else if (e[0] === "LIKE") expr = ["NOT LIKE", ...e.slice(1)];
else if (e[0] === "=") expr = ["<>", ...e.slice(1)]; else if (e[0] === "=") expr = ["<>", ...e.slice(1)];
else if (e[0] === "<>") expr = ["=", ...e.slice(1)]; else if (e[0] === "<>") expr = ["=", ...e.slice(1)];
else if (e[0] === ">") expr = ["<=", ...e.slice(1)]; else if (e[0] === ">") expr = ["<=", ...e.slice(1)];
@@ -830,7 +835,6 @@ function sopToExpression(
function generateDcSetAndIsNull( function generateDcSetAndIsNull(
variables: Map<number, Expression> variables: Map<number, Expression>
): { dcSet: number[][]; isNull: Map<number, number> } { ): { dcSet: number[][]; isNull: Map<number, number> } {
// TODO support LIKE and NOT LIKE operators
const relations: Map< const relations: Map<
string, string,
{ op: string; rhs: boolean | number | string; var: number }[] { op: string; rhs: boolean | number | string; var: number }[]
@@ -843,6 +847,11 @@ function generateDcSetAndIsNull(
let r = relations.get(lhs); let r = relations.get(lhs);
if (!r) relations.set(lhs, (r = [])); if (!r) relations.set(lhs, (r = []));
r.push({ op: op, rhs: e[2], var: n }); r.push({ op: op, rhs: e[2], var: n });
} else if (op === "LIKE" && !Array.isArray(e[2]) && !Array.isArray(e[3])) {
const lhs = JSON.stringify(e[1]);
let r = relations.get(lhs);
if (!r) relations.set(lhs, (r = []));
r.push({ op: "", rhs: null, var: n });
} else { } else {
const lhs = JSON.stringify(e); const lhs = JSON.stringify(e);
let r = relations.get(lhs); let r = relations.get(lhs);
@@ -1019,8 +1028,7 @@ function boolExprSynthToExpression(boolExpr: BoolExprSynth): Expression {
const { dcSet, isNull } = generateDcSetAndIsNull(variables); const { dcSet, isNull } = generateDcSetAndIsNull(variables);
minterms = sanitizeMinterms(minterms, isNull); minterms = sanitizeMinterms(minterms, isNull);
const canRaise = (idx: number, set: Set<number>): boolean => const canRaise = getCanRaiseCallback(isNull);
!isNull.has(idx >> 2) || !(idx & 1) || !set.has(idx ^ 3);
minterms = espresso(minterms, dcSet, { canRaise }); minterms = espresso(minterms, dcSet, { canRaise });
return sopToExpression(minterms, variables); return sopToExpression(minterms, variables);
} }
+3
View File
@@ -101,6 +101,9 @@ const options = {
// Should probably never be changed // Should probably never be changed
DEVICE_ONLINE_THRESHOLD: { type: "int", default: 4000 }, DEVICE_ONLINE_THRESHOLD: { type: "int", default: 4000 },
XMPP_JID: { type: "string", default: "" },
XMPP_PASSWORD: { type: "string", default: "" },
}; };
const allConfig: { [name: string]: string | number } = {}; const allConfig: { [name: string]: string | number } = {};
+120 -23
View File
@@ -26,6 +26,10 @@ import { Expression } from "./types";
import * as auth from "./auth"; import * as auth from "./auth";
import * as extensions from "./extensions"; import * as extensions from "./extensions";
import * as debug from "./debug"; import * as debug from "./debug";
import XmppClient from "./xmpp-client";
import * as config from "../lib/config";
import { encodeEntities, parseAttrs, Element } from "./xml-parser";
import * as logger from "../lib/logger";
async function extractAuth( async function extractAuth(
exp: Expression, exp: Expression,
@@ -62,7 +66,6 @@ async function extractAuth(
function httpGet( function httpGet(
options: http.RequestOptions, options: http.RequestOptions,
timeout,
_debug: boolean, _debug: boolean,
deviceId: string deviceId: string
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> { ): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> {
@@ -77,15 +80,12 @@ function httpGet(
} }
}) })
.on("error", (err) => { .on("error", (err) => {
req.abort(); req.destroy();
reject(new Error("Device is offline")); reject(err);
if (_debug) debug.outgoingHttpRequestError(req, deviceId, options, err); if (_debug) debug.outgoingHttpRequestError(req, deviceId, options, err);
}) })
.on("socket", (socket) => { .on("timeout", () => {
socket.setTimeout(timeout); req.destroy();
socket.on("timeout", () => {
req.abort();
});
}); });
}); });
} }
@@ -97,14 +97,15 @@ export async function httpConnectionRequest(
timeout: number, timeout: number,
_debug: boolean, _debug: boolean,
deviceId: string deviceId: string
): Promise<void> { ): Promise<string> {
const options: http.RequestOptions = parse(address); const options: http.RequestOptions = parse(address);
if (options.protocol !== "http:") if (options.protocol !== "http:")
throw new Error("Invalid connection request URL or protocol"); return "Invalid connection request URL or protocol";
options.agent = new http.Agent({ options.agent = new http.Agent({
maxSockets: 1, maxSockets: 1,
keepAlive: true, keepAlive: true,
timeout: timeout,
}); });
let authHeader: Record<string, string>; let authHeader: Record<string, string>;
@@ -115,8 +116,7 @@ export async function httpConnectionRequest(
let opts = options; let opts = options;
if (authHeader) { if (authHeader) {
if (authHeader["method"] === "Basic") { if (authHeader["method"] === "Basic") {
if (!allowBasicAuth) if (!allowBasicAuth) return "Basic HTTP authentication not allowed";
throw new Error("Basic HTTP authentication not allowed");
opts = Object.assign( opts = Object.assign(
{ {
@@ -143,17 +143,34 @@ export async function httpConnectionRequest(
options options
); );
} else { } else {
throw new Error("Unrecognized auth method"); return "Unrecognized auth method";
} }
} }
let res = await httpGet(opts, timeout, _debug, deviceId); let res: { statusCode: number; headers: http.IncomingHttpHeaders };
try {
res = await httpGet(opts, _debug, deviceId);
} catch (err) {
// Workaround for some devices unexpectedly closing the connection // Workaround for some devices unexpectedly closing the connection
if (res.statusCode === 0 && authHeader) if (authHeader) {
res = await httpGet(opts, timeout, _debug, deviceId); try {
if (res.statusCode === 0) throw new Error("Device is offline"); res = await httpGet(opts, _debug, deviceId);
if (res.statusCode === 200 || res.statusCode === 204) return; } catch (err) {
return `Connection request error: ${err.message}`;
}
}
if (err["code"] === "ECONNRESET" || err["code"] === "ECONNREFUSED")
return "Device is offline";
return `Connection request error: ${err.message}`;
}
if (res.statusCode === 200 || res.statusCode === 204) return "";
// When a Connection Request is received for the Virtual CWMP Device and the Proxied
// Device is offline the CPE Proxier MUST respond with an HTTP 503 failure
if (res.statusCode === 503) return "Device is offline";
if (res.statusCode === 401 && res.headers["www-authenticate"]) { if (res.statusCode === 401 && res.headers["www-authenticate"]) {
authHeader = auth.parseWwwAuthenticateHeader( authHeader = auth.parseWwwAuthenticateHeader(
@@ -161,12 +178,11 @@ export async function httpConnectionRequest(
); );
[username, password, authExp] = await extractAuth(authExp, false); [username, password, authExp] = await extractAuth(authExp, false);
} else { } else {
throw new Error( return `Connection request error: Unexpected status code ${res.statusCode}`;
`Unexpected response code from device: ${res.statusCode}`
);
} }
} }
throw new Error("Incorrect connection request credentials");
return "Connection request error: Incorrect connection request credentials";
} }
export async function udpConnectionRequest( export async function udpConnectionRequest(
@@ -222,3 +238,84 @@ export async function udpConnectionRequest(
} }
client.close(); client.close();
} }
const XMPP_JID = config.get("XMPP_JID") as string;
const XMPP_PASSWORD = config.get("XMPP_PASSWORD") as string;
const XMPP_RESOURCE = crypto.randomBytes(8).toString("hex");
let xmppClient: XmppClient;
function xmppClientOnError(err: Error): void {
xmppClient = null;
logger.error({
message: "XMPP exception",
exception: err,
pid: process.pid,
});
}
function xmppClientOnClose(): void {
xmppClient = null;
}
export async function xmppConnectionRequest(
jid: string,
authExp: Expression,
timeout: number,
_debug: boolean,
deviceId: string
): Promise<string> {
if (!xmppClient) {
const [host, username] = XMPP_JID.split("@").reverse();
xmppClient = await XmppClient.connect({
host,
username,
resource: XMPP_RESOURCE,
password: XMPP_PASSWORD,
timeout: 120000,
});
xmppClient.on("error", xmppClientOnError);
xmppClient.on("close", xmppClientOnClose);
xmppClient.unref();
}
let username: string;
let password: string;
[username, password, authExp] = await extractAuth(authExp, null);
while (username != null && password != null) {
const msg = `<connectionRequest xmlns="urn:broadband-forum-org:cwmp:xmppConnReq-1-0"><username>${encodeEntities(
username
)}</username><password>${encodeEntities(
password
)}</password></connectionRequest>`;
let res: Element, rawRes: string, rawReq: string;
try {
({ res, rawRes, rawReq } = await xmppClient.sendIqStanza(
XMPP_JID,
jid,
"get",
msg,
timeout
));
} catch (err) {
return err.message;
}
if (_debug) {
debug.outgoingXmppStanza(deviceId, rawReq);
debug.incomingXmppStanza(deviceId, rawRes);
}
const attrs = parseAttrs(res.attrs);
const type = attrs.find((a) => a.name === "type");
if (type && type.value === "result") return "";
const error = res.children.find((c) => c.name === "error");
if (!error || !error.children[0])
return "Unexpected XMPP connection request response";
if (error.children[0].name === "service-unavailable")
return "Device is offline";
if (error.children[0].name !== "not-authorized")
return "Unexpected XMPP connection request response";
[username, password, authExp] = await extractAuth(authExp, null);
}
return "Incorrect connection request credentials";
}
+105 -33
View File
@@ -90,9 +90,7 @@ async function authenticate(
} }
if (authentication && authentication.method === "Digest") { if (authentication && authentication.method === "Digest") {
const sessionNonce = sessionsNonces.get( const sessionNonce = sessionsNonces.get(sessionContext.httpRequest.socket);
sessionContext.httpRequest.connection
);
if ( if (
!sessionNonce || !sessionNonce ||
@@ -185,7 +183,7 @@ async function writeResponse(
} }
const httpResponse = sessionContext.httpResponse; const httpResponse = sessionContext.httpResponse;
const connection = httpResponse.connection; const connection = httpResponse.socket;
httpResponse.setHeader("Content-Length", Buffer.byteLength(data)); httpResponse.setHeader("Content-Length", Buffer.byteLength(data));
httpResponse.writeHead(res.code, res.headers); httpResponse.writeHead(res.code, res.headers);
@@ -198,17 +196,23 @@ async function writeResponse(
sessionContext: sessionContext, sessionContext: sessionContext,
message: "Connection dropped", message: "Connection dropped",
}); });
await endSession(sessionContext);
} else if (close) { } else if (close) {
const isNew = await endSession(sessionContext); session.clearProvisions(sessionContext);
if (isNew) { await endSession(sessionContext);
logger.accessInfo({
sessionContext: sessionContext,
message: "New device registered",
});
}
} else { } else {
sessionContext.lastActivity = Date.now(); const now = Date.now();
sessionContext.lastActivity = now;
currentSessions.set(connection, sessionContext); currentSessions.set(connection, sessionContext);
if (now >= sessionContext.extendLock) {
sessionContext.extendLock = now + 10000;
await cache.acquireLock(
`cwmp_session_${sessionContext.deviceId}`,
sessionContext.timeout + 10000,
0,
sessionContext.sessionId
);
}
} }
} }
@@ -728,9 +732,20 @@ async function nextRpc(sessionContext: SessionContext): Promise<void> {
return nextRpc(sessionContext); return nextRpc(sessionContext);
} }
async function endSession(sessionContext: SessionContext): Promise<boolean> { async function endSession(sessionContext: SessionContext): Promise<void> {
let saveCache = sessionContext.cacheUntil != null; let saveCache = sessionContext.cacheUntil != null;
if (sessionContext.provisions.length) {
const fault = {
code: "session_terminated",
message: "The TR-069 session was unsuccessfully terminated",
timestamp: sessionContext.timestamp,
};
recordFault(sessionContext, fault);
// No need to save retryNow
for (const f of Object.values(sessionContext.faults)) delete f.retryNow;
}
const promises = []; const promises = [];
promises.push( promises.push(
@@ -793,7 +808,16 @@ async function endSession(sessionContext: SessionContext): Promise<boolean> {
} }
await Promise.all(promises); await Promise.all(promises);
return sessionContext.new; await cache.releaseLock(
`cwmp_session_${sessionContext.deviceId}`,
sessionContext.sessionId
);
if (sessionContext.new) {
logger.accessInfo({
sessionContext: sessionContext,
message: "New device registered",
});
}
} }
async function sendAcsRequest( async function sendAcsRequest(
@@ -842,8 +866,11 @@ async function sendAcsRequest(
return writeResponse(sessionContext, res); return writeResponse(sessionContext, res);
} }
async function getSession(connection, sessionId): Promise<SessionContext> { async function getSession(
const sessionContext = currentSessions.get(connection); connection: Socket,
sessionId: string
): Promise<SessionContext> {
let sessionContext = currentSessions.get(connection);
if (sessionContext) { if (sessionContext) {
currentSessions.delete(connection); currentSessions.delete(connection);
return sessionContext; return sessionContext;
@@ -855,7 +882,9 @@ async function getSession(connection, sessionId): Promise<SessionContext> {
const sessionContextString = await cache.pop(`session_${sessionId}`); const sessionContextString = await cache.pop(`session_${sessionId}`);
if (!sessionContextString) return null; if (!sessionContextString) return null;
return session.deserialize(sessionContextString); sessionContext = await session.deserialize(sessionContextString);
connection.setTimeout(sessionContext.timeout);
return sessionContext;
} }
// Only needed to prevent tree shaking from removing the remoteAddress // Only needed to prevent tree shaking from removing the remoteAddress
@@ -875,15 +904,21 @@ export function onConnection(socket: Socket): void {
const now = Date.now(); const now = Date.now();
const lastActivity = sessionContext.lastActivity; const lastActivity = sessionContext.lastActivity;
const timeoutMsg = logger.flatten({ const timeoutMsg = {
sessionContext: sessionContext, sessionContext: sessionContext,
message: "Session timeout", message: "Session timeout",
sessionTimestamp: sessionContext.timestamp, sessionTimestamp: sessionContext.timestamp,
}); };
const timeout = const timeout =
sessionContext.lastActivity + sessionContext.timeout * 1000 - now; sessionContext.lastActivity + sessionContext.timeout * 1000 - now;
if (timeout <= 0) return void logger.accessError(timeoutMsg);
if (timeout <= 0) {
logger.accessError(timeoutMsg);
// TODO it's possible that lock would have already been expired
await endSession(sessionContext);
return;
}
setTimeout(async () => { setTimeout(async () => {
const sessionContextString = await cache.get( const sessionContextString = await cache.get(
@@ -891,8 +926,10 @@ export function onConnection(socket: Socket): void {
); );
if (!sessionContextString) return; if (!sessionContextString) return;
const _sessionContext = await session.deserialize(sessionContextString); const _sessionContext = await session.deserialize(sessionContextString);
if (_sessionContext.lastActivity === lastActivity) if (_sessionContext.lastActivity === lastActivity) {
logger.accessError(timeoutMsg); logger.accessError(timeoutMsg);
await endSession(sessionContext);
}
}, timeout + 1000).unref(); }, timeout + 1000).unref();
if (sessionContext.state === 0) return; if (sessionContext.state === 0) return;
@@ -988,7 +1025,7 @@ async function reportBadState(sessionContext: SessionContext): Promise<void> {
sessionContext: sessionContext, sessionContext: sessionContext,
}); });
const httpResponse = sessionContext.httpResponse; const httpResponse = sessionContext.httpResponse;
currentSessions.delete(httpResponse.connection); currentSessions.delete(httpResponse.socket);
const body = "Bad session state"; const body = "Bad session state";
httpResponse.setHeader("Content-Length", Buffer.byteLength(body)); httpResponse.setHeader("Content-Length", Buffer.byteLength(body));
httpResponse.writeHead(400, { Connection: "close" }); httpResponse.writeHead(400, { Connection: "close" });
@@ -1015,14 +1052,14 @@ async function responseUnauthorized(
resHeaders["WWW-Authenticate"] = `Basic realm="${REALM}"`; resHeaders["WWW-Authenticate"] = `Basic realm="${REALM}"`;
} else { } else {
const nonce = crypto.randomBytes(16).toString("hex"); const nonce = crypto.randomBytes(16).toString("hex");
sessionsNonces.set(sessionContext.httpRequest.connection, nonce); sessionsNonces.set(sessionContext.httpRequest.socket, nonce);
let d = `Digest realm="${REALM}"`; let d = `Digest realm="${REALM}"`;
d += ',qop="auth,auth-int"'; d += ',qop="auth,auth-int"';
d += `,nonce="${nonce}"`; d += `,nonce="${nonce}"`;
resHeaders["WWW-Authenticate"] = d; resHeaders["WWW-Authenticate"] = d;
} }
currentSessions.set(sessionContext.httpRequest.connection, sessionContext); currentSessions.set(sessionContext.httpRequest.socket, sessionContext);
} }
const httpResponse = sessionContext.httpResponse; const httpResponse = sessionContext.httpResponse;
@@ -1042,7 +1079,6 @@ async function processRequest(
): Promise<void> { ): Promise<void> {
for (const w of parseWarnings) { for (const w of parseWarnings) {
w.sessionContext = sessionContext; w.sessionContext = sessionContext;
w.rpc = rpc;
logger.accessWarn(w); logger.accessWarn(w);
} }
@@ -1070,6 +1106,8 @@ async function processRequest(
); );
} }
sessionContext.httpRequest.socket.setTimeout(sessionContext.timeout * 1000);
if (sessionContext.debug) { if (sessionContext.debug) {
debug.incomingHttpRequest( debug.incomingHttpRequest(
sessionContext.httpRequest, sessionContext.httpRequest,
@@ -1088,6 +1126,37 @@ async function processRequest(
} }
} }
try {
sessionContext.extendLock = sessionContext.timestamp + 10000;
await cache.acquireLock(
`cwmp_session_${sessionContext.deviceId}`,
sessionContext.timeout + 10000,
0,
sessionContext.sessionId
);
} catch (err) {
logger.accessError({
message: "CPE already in session",
sessionContext: sessionContext,
});
const _body = "CPE already in session";
sessionContext.httpResponse.setHeader(
"Content-Length",
Buffer.byteLength(_body)
);
sessionContext.httpResponse.writeHead(400, { Connection: "close" });
if (sessionContext.debug) {
debug.outgoingHttpResponse(
sessionContext.httpResponse,
sessionContext.deviceId,
_body
);
}
sessionContext.httpResponse.end(_body);
return;
}
sessionContext.state = 1; sessionContext.state = 1;
sessionContext.authState = 2; sessionContext.authState = 2;
@@ -1116,6 +1185,7 @@ async function processRequest(
sessionContext.authState = 1; sessionContext.authState = 1;
return responseUnauthorized(sessionContext, false); return responseUnauthorized(sessionContext, false);
} else { } else {
await endSession(sessionContext);
return responseUnauthorized(sessionContext, true); return responseUnauthorized(sessionContext, true);
} }
} }
@@ -1210,13 +1280,13 @@ export function listener(
stats.concurrentRequests -= 1; stats.concurrentRequests -= 1;
}) })
.catch((err) => { .catch((err) => {
currentSessions.delete(httpResponse.connection); currentSessions.delete(httpResponse.socket);
stats.concurrentRequests -= 1; stats.concurrentRequests -= 1;
setTimeout(() => { setTimeout(() => {
throw err; throw err;
}); });
try { try {
httpResponse.connection.unref(); httpResponse.socket.unref();
httpResponse.writeHead(500, { Connection: "close" }); httpResponse.writeHead(500, { Connection: "close" });
httpResponse.end(`${err.name}: ${err.message}`); httpResponse.end(`${err.name}: ${err.message}`);
} catch (err) { } catch (err) {
@@ -1227,7 +1297,7 @@ export function listener(
function decodeString(buffer: Buffer, charset: string): string { function decodeString(buffer: Buffer, charset: string): string {
try { try {
return buffer.toString(charset); return buffer.toString(charset as BufferEncoding);
} catch (err) { } catch (err) {
if (encodingExists(charset)) return decode(buffer, charset); if (encodingExists(charset)) return decode(buffer, charset);
} }
@@ -1312,9 +1382,9 @@ async function listenerAsync(
// Request aborted // Request aborted
if (!body) return; if (!body) return;
const newConnection = !currentSessions.has(httpRequest.connection); const newConnection = !currentSessions.has(httpRequest.socket);
const sessionContext = await getSession(httpRequest.connection, sessionId); const sessionContext = await getSession(httpRequest.socket, sessionId);
if (sessionContext) { if (sessionContext) {
sessionContext.httpRequest = httpRequest; sessionContext.httpRequest = httpRequest;
@@ -1339,6 +1409,7 @@ async function listenerAsync(
); );
} }
httpResponse.end(_body); httpResponse.end(_body);
await endSession(sessionContext);
return; return;
} }
if (newConnection && sessionContext.authState !== 1) if (newConnection && sessionContext.authState !== 1)
@@ -1352,7 +1423,7 @@ async function listenerAsync(
return; return;
} }
let charset; let charset: string;
if (httpRequest.headers["content-type"]) { if (httpRequest.headers["content-type"]) {
const m = httpRequest.headers["content-type"].match( const m = httpRequest.headers["content-type"].match(
/charset=['"]?([^'"\s]+)/i /charset=['"]?([^'"\s]+)/i
@@ -1389,6 +1460,7 @@ async function listenerAsync(
); );
debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, msg); debug.outgoingHttpResponse(httpResponse, sessionContext.deviceId, msg);
} }
await endSession(sessionContext);
} else { } else {
const cacheSnapshot = await localCache.getCurrentSnapshot(); const cacheSnapshot = await localCache.getCurrentSnapshot();
const d = !!localCache.getConfig( const d = !!localCache.getConfig(
@@ -1414,7 +1486,7 @@ async function listenerAsync(
} }
const parseWarnings = []; const parseWarnings = [];
let rpc; let rpc: SoapMessage;
try { try {
rpc = soap.request( rpc = soap.request(
bodyStr, bodyStr,
@@ -1445,6 +1517,7 @@ async function listenerAsync(
error.message error.message
); );
} }
await endSession(sessionContext);
} else { } else {
const cacheSnapshot = await localCache.getCurrentSnapshot(); const cacheSnapshot = await localCache.getCurrentSnapshot();
const d = !!localCache.getConfig( const d = !!localCache.getConfig(
@@ -1519,7 +1592,6 @@ async function listenerAsync(
_sessionContext.httpRequest = httpRequest; _sessionContext.httpRequest = httpRequest;
_sessionContext.httpResponse = httpResponse; _sessionContext.httpResponse = httpResponse;
_sessionContext.sessionId = crypto.randomBytes(8).toString("hex"); _sessionContext.sessionId = crypto.randomBytes(8).toString("hex");
httpRequest.connection.setTimeout(_sessionContext.timeout * 1000);
const { const {
tasks: dueTasks, tasks: dueTasks,
+15 -8
View File
@@ -17,9 +17,9 @@
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>. * along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { MongoClient, ObjectID, Collection } from "mongodb"; import { MongoClient, ObjectID, Collection, Db } from "mongodb";
import { get } from "./config"; import { get } from "./config";
import { escapeRegExp } from "./common"; import { encodeTag, escapeRegExp } from "./common";
import { parse } from "./common/expression-parser"; import { parse } from "./common/expression-parser";
import { import {
DeviceData, DeviceData,
@@ -45,7 +45,6 @@ export let tasksCollection: Collection,
configCollection: Collection; configCollection: Collection;
let clientPromise: Promise<MongoClient>; let clientPromise: Promise<MongoClient>;
export let client: MongoClient;
function compareAccessLists(list1: string[], list2: string[]): boolean { function compareAccessLists(list1: string[], list2: string[]): boolean {
if (list1.length !== list2.length) return false; if (list1.length !== list2.length) return false;
@@ -53,15 +52,24 @@ function compareAccessLists(list1: string[], list2: string[]): boolean {
return true; return true;
} }
const onConnectCallbacks: ((db: Db) => Promise<void>)[] = [];
export function onConnect(callback: (db: Db) => Promise<void>): void {
onConnectCallbacks.push(callback);
}
export async function connect(): Promise<void> { export async function connect(): Promise<void> {
clientPromise = MongoClient.connect("" + get("MONGODB_CONNECTION_URL"), { clientPromise = MongoClient.connect("" + get("MONGODB_CONNECTION_URL"), {
useNewUrlParser: true, useNewUrlParser: true,
useUnifiedTopology: true, useUnifiedTopology: true,
}); });
client = await clientPromise; const client = await clientPromise;
const db = client.db(); const db = client.db();
await Promise.all(onConnectCallbacks.map((c) => c(db)));
}
onConnect(async (db) => {
tasksCollection = db.collection("tasks"); tasksCollection = db.collection("tasks");
await tasksCollection.createIndex({ device: 1, timestamp: 1 }); await tasksCollection.createIndex({ device: 1, timestamp: 1 });
@@ -76,7 +84,7 @@ export async function connect(): Promise<void> {
permissionsCollection = db.collection("permissions"); permissionsCollection = db.collection("permissions");
usersCollection = db.collection("users"); usersCollection = db.collection("users");
configCollection = db.collection("config"); configCollection = db.collection("config");
} });
export async function disconnect(): Promise<void> { export async function disconnect(): Promise<void> {
if (clientPromise) await (await clientPromise).close(); if (clientPromise) await (await clientPromise).close();
@@ -234,10 +242,9 @@ export async function fetchDevice(
]); ]);
} }
for (let t of v as string[]) { for (const t of v as string[]) {
t = t.replace(/[^a-zA-Z0-9-]+/g, "_");
res.push([ res.push([
Path.parse("Tags." + t), Path.parse("Tags." + encodeTag(t)),
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
+39 -5
View File
@@ -49,7 +49,7 @@ export function incomingHttpRequest(
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
const con = httpRequest.connection; const con = httpRequest.socket;
const msg = { const msg = {
event: "incoming HTTP request", event: "incoming HTTP request",
timestamp: now, timestamp: now,
@@ -77,7 +77,7 @@ export function outgoingHttpResponse(
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
const con = httpResponse.connection; const con = httpResponse.socket;
const msg = { const msg = {
event: "outgoing HTTP response", event: "outgoing HTTP response",
timestamp: now, timestamp: now,
@@ -104,7 +104,7 @@ export function outgoingHttpRequest(
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
const con = httpRequest.connection; const con = httpRequest.socket;
const msg = { const msg = {
event: "outgoing HTTP request", event: "outgoing HTTP request",
timestamp: now, timestamp: now,
@@ -160,13 +160,13 @@ export function incomingHttpResponse(
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
const con = httpResponse.connection; const con = httpResponse.socket;
const msg = { const msg = {
event: "incoming HTTP response", event: "incoming HTTP response",
timestamp: now, timestamp: now,
remoteAddress: con.remoteAddress, remoteAddress: con.remoteAddress,
deviceId: deviceId, deviceId: deviceId,
connection: getConnectionTimestamp(httpResponse.connection), connection: getConnectionTimestamp(httpResponse.socket),
statusCode: httpResponse.statusCode, statusCode: httpResponse.statusCode,
headers: httpResponse.headers, headers: httpResponse.headers,
body: body, body: body,
@@ -202,3 +202,37 @@ export function outgoingUdpMessage(
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n"); appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
else throw new Error(`Unrecognized DEBUG_FORMAT option`); else throw new Error(`Unrecognized DEBUG_FORMAT option`);
} }
export function outgoingXmppStanza(deviceId: string, body: string): void {
if (!DEBUG_FILE) return;
const now = new Date();
const msg = {
event: "outgoing XMPP stanza",
timestamp: now,
deviceId: deviceId,
body: body,
};
if (DEBUG_FORMAT === "yaml")
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
else if (DEBUG_FORMAT === "json")
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
}
export function incomingXmppStanza(deviceId: string, body: string): void {
if (!DEBUG_FILE) return;
const now = new Date();
const msg = {
event: "incoming XMPP stanza",
timestamp: now,
deviceId: deviceId,
body: body,
};
if (DEBUG_FORMAT === "yaml")
appendFileSync(DEBUG_FILE, "---\n" + yaml.stringify(msg));
else if (DEBUG_FORMAT === "json")
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
}
+12 -5
View File
@@ -20,11 +20,19 @@
import * as url from "url"; import * as url from "url";
import * as querystring from "querystring"; import * as querystring from "querystring";
import { IncomingMessage, ServerResponse } from "http"; import { IncomingMessage, ServerResponse } from "http";
import { GridFSBucket } from "mongodb"; import { Collection, GridFSBucket } from "mongodb";
import * as db from "./db"; import { onConnect } from "./db";
import * as logger from "./logger"; import * as logger from "./logger";
import { getRequestOrigin } from "./forwarded"; import { getRequestOrigin } from "./forwarded";
let filesCollection: Collection;
let filesBucket: GridFSBucket;
onConnect(async (db) => {
filesCollection = db.collection("fs.files");
filesBucket = new GridFSBucket(db);
});
export function listener( export function listener(
request: IncomingMessage, request: IncomingMessage,
response: ServerResponse response: ServerResponse
@@ -39,7 +47,7 @@ export function listener(
remoteAddress: getRequestOrigin(request).remoteAddress, remoteAddress: getRequestOrigin(request).remoteAddress,
}; };
db.filesCollection.findOne({ _id: filename }, (err, file) => { filesCollection.findOne({ _id: filename }, (err, file) => {
if (err) throw err; if (err) throw err;
if (!file) { if (!file) {
@@ -55,8 +63,7 @@ export function listener(
"Content-Length": file.length, "Content-Length": file.length,
}); });
const bucket = new GridFSBucket(db.client.db()); const downloadStream = filesBucket.openDownloadStreamByName(filename);
const downloadStream = bucket.openDownloadStreamByName(filename);
downloadStream.pipe(response); downloadStream.pipe(response);
logger.accessInfo(log); logger.accessInfo(log);
+2 -2
View File
@@ -398,7 +398,7 @@ async function refresh(): Promise<void> {
return; return;
} }
const unlockOrExtend = await cache.lock("presets_hash_lock", 3); const lockToken = await cache.acquireLock("presets_hash_lock", 4000, 4000);
const res = await Promise.all([ const res = await Promise.all([
fetchPresets(), fetchPresets(),
@@ -432,7 +432,7 @@ async function refresh(): Promise<void> {
currentSnapshot = computeHash(snapshot); currentSnapshot = computeHash(snapshot);
snapshots.set(currentSnapshot, snapshot); snapshots.set(currentSnapshot, snapshot);
await cache.set("presets_hash", currentSnapshot, 300); await cache.set("presets_hash", currentSnapshot, 300);
await unlockOrExtend(0); await cache.releaseLock("presets_hash_lock", lockToken);
nextRefresh = now + (REFRESH - (now % REFRESH)); nextRefresh = now + (REFRESH - (now % REFRESH));
} }
+9 -5
View File
@@ -21,6 +21,7 @@ import { ObjectID } from "mongodb";
import { map, parse, stringify, parseList } from "./common/expression-parser"; import { map, parse, stringify, parseList } from "./common/expression-parser";
import { likePatternToRegExp } from "./common/expression"; import { likePatternToRegExp } from "./common/expression";
import { Expression, Fault, Task } from "./types"; import { Expression, Fault, Task } from "./types";
import { decodeTag, encodeTag } from "./common";
const isArray = Array.isArray; const isArray = Array.isArray;
@@ -43,7 +44,7 @@ export function processDeviceFilter(filter: Expression): Expression {
exp[1][0] === "PARAM" && exp[1][0] === "PARAM" &&
exp[1][1].startsWith("Tags.") exp[1][1].startsWith("Tags.")
) { ) {
const t = exp[1][1].slice(5); const t = decodeTag(exp[1][1].slice(5));
if (exp[0] === "IS NULL") return ["_tags", t, false]; if (exp[0] === "IS NULL") return ["_tags", t, false];
else if (exp[0] === "IS NOT NULL") return ["_tags", t, true]; else if (exp[0] === "IS NOT NULL") return ["_tags", t, true];
else if (exp[0] === "=" && exp[2] === true) return ["_tags", t, true]; else if (exp[0] === "=" && exp[2] === true) return ["_tags", t, true];
@@ -406,7 +407,7 @@ export function flattenDevice(device: Record<string, unknown>): FlatDevice {
}; };
for (const t of tree as string[]) { for (const t of tree as string[]) {
output[`Tags.${t}`] = { output[`Tags.${encodeTag(t)}`] = {
value: [true, "xsd:boolean"], value: [true, "xsd:boolean"],
valueTimestamp: timestamp, valueTimestamp: timestamp,
writable: true, writable: true,
@@ -510,11 +511,11 @@ export function convertOldPrecondition(
if (op === "$ne") { if (op === "$ne") {
if (typeof v["$ne"] !== "string") if (typeof v["$ne"] !== "string")
throw new Error("Only string values are allowed for _tags"); throw new Error("Only string values are allowed for _tags");
conjs.push(["IS NULL", ["PARAM", `Tags.${val}`]]); conjs.push(["IS NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
} else if (op === "$eq") { } else if (op === "$eq") {
if (typeof v["$eq"] !== "string") if (typeof v["$eq"] !== "string")
throw new Error("Only string values are allowed for _tags"); throw new Error("Only string values are allowed for _tags");
conjs.push(["IS NOT NULL", ["PARAM", `Tags.${val}`]]); conjs.push(["IS NOT NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
} else { } else {
throw new Error(`Invalid tag query`); throw new Error(`Invalid tag query`);
} }
@@ -522,7 +523,10 @@ export function convertOldPrecondition(
if (conjs.length === 1) expressions.push(conjs[0]); if (conjs.length === 1) expressions.push(conjs[0]);
else if (conjs.length > 1) expressions.push(["AND", ...conjs]); else if (conjs.length > 1) expressions.push(["AND", ...conjs]);
} else { } else {
expressions.push(["IS NOT NULL", ["PARAM", `Tags.${v}`]]); expressions.push([
"IS NOT NULL",
["PARAM", `Tags.${encodeTag(v as string)}`],
]);
} }
} else if (k.startsWith("Tags.")) { } else if (k.startsWith("Tags.")) {
let exists: boolean; let exists: boolean;
+135 -86
View File
@@ -18,11 +18,11 @@
*/ */
import * as url from "url"; import * as url from "url";
import * as mongodb from "mongodb"; import { Collection, GridFSBucket, ObjectId } from "mongodb";
import * as querystring from "querystring"; import * as querystring from "querystring";
import * as vm from "vm"; import * as vm from "vm";
import * as config from "./config"; import * as config from "./config";
import * as db from "./db"; import { onConnect } from "./db";
import * as query from "./query"; import * as query from "./query";
import * as apiFunctions from "./api-functions"; import * as apiFunctions from "./api-functions";
import { IncomingMessage, ServerResponse } from "http"; import { IncomingMessage, ServerResponse } from "http";
@@ -43,6 +43,23 @@ const PROVISIONS_REGEX = /^\/provisions\/([a-zA-Z0-9\-_%]+)\/?$/;
const VIRTUAL_PARAMETERS_REGEX = /^\/virtual_parameters\/([a-zA-Z0-9\-_%]+)\/?$/; const VIRTUAL_PARAMETERS_REGEX = /^\/virtual_parameters\/([a-zA-Z0-9\-_%]+)\/?$/;
const FAULTS_REGEX = /^\/faults\/([a-zA-Z0-9\-_%:]+)\/?$/; const FAULTS_REGEX = /^\/faults\/([a-zA-Z0-9\-_%:]+)\/?$/;
const collections = {
tasks: null as Collection,
devices: null as Collection,
presets: null as Collection,
objects: null as Collection,
"fs.files": null as Collection,
provisions: null as Collection,
virtualParameters: null as Collection,
faults: null as Collection,
};
let filesBucket: GridFSBucket;
onConnect(async (db) => {
for (const k of Object.keys(collections)) collections[k] = db.collection(k);
filesBucket = new GridFSBucket(db);
});
function throwError(err: Error, httpResponse?: ServerResponse): never { function throwError(err: Error, httpResponse?: ServerResponse): never {
if (httpResponse) { if (httpResponse) {
httpResponse.writeHead(500, { Connection: "close" }); httpResponse.writeHead(500, { Connection: "close" });
@@ -85,7 +102,7 @@ export function listener(
if (request.method === "PUT") { if (request.method === "PUT") {
const preset = JSON.parse(body.toString()); const preset = JSON.parse(body.toString());
preset._id = presetName; preset._id = presetName;
db.presetsCollection.replaceOne( collections.presets.replaceOne(
{ _id: presetName }, { _id: presetName },
preset, preset,
{ upsert: true }, { upsert: true },
@@ -106,7 +123,7 @@ export function listener(
} }
); );
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
db.presetsCollection.deleteOne({ _id: presetName }, (err) => { collections.presets.deleteOne({ _id: presetName }, (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
cache cache
@@ -132,7 +149,7 @@ export function listener(
if (request.method === "PUT") { if (request.method === "PUT") {
const object = JSON.parse(body.toString()); const object = JSON.parse(body.toString());
object._id = objectName; object._id = objectName;
db.objectsCollection.replaceOne( collections.objects.replaceOne(
{ _id: objectName }, { _id: objectName },
object, object,
{ upsert: true }, { upsert: true },
@@ -153,7 +170,7 @@ export function listener(
} }
); );
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
db.objectsCollection.deleteOne({ _id: objectName }, (err) => { collections.objects.deleteOne({ _id: objectName }, (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
cache cache
@@ -190,7 +207,7 @@ export function listener(
return; return;
} }
db.provisionsCollection.replaceOne( collections.provisions.replaceOne(
{ _id: provisionName }, { _id: provisionName },
object, object,
{ upsert: true }, { upsert: true },
@@ -211,7 +228,7 @@ export function listener(
} }
); );
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
db.provisionsCollection.deleteOne({ _id: provisionName }, (err) => { collections.provisions.deleteOne({ _id: provisionName }, (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
cache cache
@@ -248,7 +265,7 @@ export function listener(
return; return;
} }
db.virtualParametersCollection.replaceOne( collections.virtualParameters.replaceOne(
{ _id: virtualParameterName }, { _id: virtualParameterName },
object, object,
{ upsert: true }, { upsert: true },
@@ -269,7 +286,7 @@ export function listener(
} }
); );
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
db.virtualParametersCollection.deleteOne( collections.virtualParameters.deleteOne(
{ _id: virtualParameterName }, { _id: virtualParameterName },
(err) => { (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
@@ -296,7 +313,7 @@ export function listener(
const deviceId = querystring.unescape(r[1]); const deviceId = querystring.unescape(r[1]);
const tag = querystring.unescape(r[2]); const tag = querystring.unescape(r[2]);
if (request.method === "POST") { if (request.method === "POST") {
db.devicesCollection.updateOne( collections.devices.updateOne(
{ _id: deviceId }, { _id: deviceId },
{ $addToSet: { _tags: tag } }, { $addToSet: { _tags: tag } },
(err) => { (err) => {
@@ -306,7 +323,7 @@ export function listener(
} }
); );
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
db.devicesCollection.updateOne( collections.devices.updateOne(
{ _id: deviceId }, { _id: deviceId },
{ $pull: { _tags: tag } }, { $pull: { _tags: tag } },
(err) => { (err) => {
@@ -327,12 +344,12 @@ export function listener(
); );
const deviceId = faultId.split(":", 1)[0]; const deviceId = faultId.split(":", 1)[0];
const channel = faultId.slice(deviceId.length + 1); const channel = faultId.slice(deviceId.length + 1);
db.faultsCollection.deleteOne({ _id: faultId }, (err) => { collections.faults.deleteOne({ _id: faultId }, (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
if (channel.startsWith("task_")) { if (channel.startsWith("task_")) {
const objId = new mongodb.ObjectID(channel.slice(5)); const objId = new ObjectId(channel.slice(5));
return void db.tasksCollection.deleteOne({ _id: objId }, (err) => { return void collections.tasks.deleteOne({ _id: objId }, (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
cache cache
@@ -375,47 +392,106 @@ export function listener(
task.device = deviceId; task.device = deviceId;
apiFunctions apiFunctions
.insertTasks(task) .insertTasks(task)
.then(() => { .then(async () => {
cache const lastInform = Date.now();
.del(`${deviceId}_tasks_faults_operations`)
.then(() => { const socketTimeout: number = request.socket["timeout"];
if (urlParts.query.connection_request != null) {
apiFunctions // Disable socket timeout while waiting for session
.connectionRequest(deviceId) if (socketTimeout) request.socket.setTimeout(0);
.then(() => {
const taskTimeout = const notInSession = await apiFunctions.awaitSessionEnd(
deviceId,
30000
);
await cache.del(`${deviceId}_tasks_faults_operations`);
if (urlParts.query.connection_request == null) {
if (socketTimeout) request.socket.setTimeout(socketTimeout);
response.writeHead(202, {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
return;
}
if (!notInSession) {
if (socketTimeout) request.socket.setTimeout(socketTimeout);
response.writeHead(202, "Task queued but not processed", {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
return;
}
const status = await apiFunctions.connectionRequest(deviceId);
if (status) {
if (socketTimeout) request.socket.setTimeout(socketTimeout);
response.writeHead(202, status, {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
return;
}
const onlineThreshold =
(urlParts.query.timeout && (urlParts.query.timeout &&
parseInt(urlParts.query.timeout as string)) || parseInt(urlParts.query.timeout as string)) ||
(config.get( (config.get("DEVICE_ONLINE_THRESHOLD", deviceId) as number);
"DEVICE_ONLINE_THRESHOLD",
deviceId
) as number);
apiFunctions const sessionStarted = await apiFunctions.awaitSessionStart(
.watchTask(deviceId, task._id, taskTimeout) deviceId,
.then((status) => { lastInform,
if (status === "timeout") { onlineThreshold
response.writeHead( );
202, if (!sessionStarted) {
"Task queued but not processed", if (socketTimeout) request.socket.setTimeout(socketTimeout);
{ response.writeHead(202, "Task queued but not processed", {
"Content-Type": "application/json", "Content-Type": "application/json",
});
response.end(JSON.stringify(task));
return;
}
const sessionEnded = await apiFunctions.awaitSessionEnd(
deviceId,
120000
);
if (!sessionEnded) {
if (socketTimeout) request.socket.setTimeout(socketTimeout);
response.writeHead(202, "Task queued but not processed", {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
return;
}
const prom1 = collections.tasks.findOne(
{ _id: task._id },
{ projection: { _id: 1 } }
);
const prom2 = collections.faults.findOne(
{ _id: `${deviceId}:task_${task._id}` },
{
projection: { _id: 1 },
} }
); );
response.end(JSON.stringify(task));
} else if (status === "fault") {
db.tasksCollection.findOne(
{ _id: task._id },
(err, task2) => {
if (err)
return void throwError(err, response);
const [t, f] = await Promise.all([prom1, prom2]);
// Restore socket timeout
if (socketTimeout) request.socket.setTimeout(socketTimeout);
if (f) {
response.writeHead(202, "Task faulted", { response.writeHead(202, "Task faulted", {
"Content-Type": "application/json", "Content-Type": "application/json",
}); });
response.end(JSON.stringify(task2)); response.end(JSON.stringify(t || task));
} } else if (t) {
); response.writeHead(202, "Task queued but not processed", {
"Content-Type": "application/json",
});
response.end(JSON.stringify(t));
} else { } else {
response.writeHead(200, { response.writeHead(200, {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -428,31 +504,6 @@ export function listener(
throwError(err, response); throwError(err, response);
}); });
}); });
})
.catch((err) => {
response.writeHead(202, err.message, {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
});
} else {
response.writeHead(202, {
"Content-Type": "application/json",
});
response.end(JSON.stringify(task));
}
})
.catch((err) => {
setTimeout(() => {
throwError(err, response);
});
});
})
.catch((err) => {
setTimeout(() => {
throwError(err, response);
});
});
} else if (urlParts.query.connection_request != null) { } else if (urlParts.query.connection_request != null) {
// No task, send connection request only // No task, send connection request only
apiFunctions apiFunctions
@@ -479,8 +530,8 @@ export function listener(
const action = r[2]; const action = r[2];
if (!action || action === "/") { if (!action || action === "/") {
if (request.method === "DELETE") { if (request.method === "DELETE") {
db.tasksCollection.findOne( collections.tasks.findOne(
{ _id: new mongodb.ObjectID(taskId) }, { _id: new ObjectId(taskId) },
{ projection: { device: 1 } }, { projection: { device: 1 } },
(err, task) => { (err, task) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
@@ -492,12 +543,12 @@ export function listener(
} }
const deviceId = task.device; const deviceId = task.device;
db.tasksCollection.deleteOne( collections.tasks.deleteOne(
{ _id: new mongodb.ObjectID(taskId) }, { _id: new ObjectId(taskId) },
(err) => { (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
db.faultsCollection.deleteOne( collections.faults.deleteOne(
{ _id: `${deviceId}:task_${taskId}` }, { _id: `${deviceId}:task_${taskId}` },
(err) => { (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
@@ -525,14 +576,14 @@ export function listener(
} }
} else if (action === "/retry") { } else if (action === "/retry") {
if (request.method === "POST") { if (request.method === "POST") {
db.tasksCollection.findOne( collections.tasks.findOne(
{ _id: new mongodb.ObjectID(taskId) }, { _id: new ObjectId(taskId) },
{ projection: { device: 1 } }, { projection: { device: 1 } },
(err, task) => { (err, task) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
const deviceId = task.device; const deviceId = task.device;
db.faultsCollection.deleteOne( collections.faults.deleteOne(
{ _id: `${deviceId}:task_${taskId}` }, { _id: `${deviceId}:task_${taskId}` },
(err) => { (err) => {
if (err) return void throwError(err, response); if (err) return void throwError(err, response);
@@ -571,9 +622,8 @@ export function listener(
productClass: request.headers.productclass, productClass: request.headers.productclass,
version: request.headers.version, version: request.headers.version,
}; };
const bucket = new mongodb.GridFSBucket(db.client.db()); filesBucket.delete((filename as unknown) as ObjectId, () => {
bucket.delete((filename as unknown) as mongodb.ObjectId, () => { const uploadStream = filesBucket.openUploadStreamWithId(
const uploadStream = bucket.openUploadStreamWithId(
filename, filename,
filename, filename,
{ {
@@ -591,8 +641,7 @@ export function listener(
}); });
}); });
} else if (request.method === "DELETE") { } else if (request.method === "DELETE") {
const bucket = new mongodb.GridFSBucket(db.client.db()); filesBucket.delete((filename as unknown) as ObjectId, (err) => {
bucket.delete((filename as unknown) as mongodb.ObjectId, (err) => {
if (err) { if (err) {
if (err.message.startsWith("FileNotFound")) { if (err.message.startsWith("FileNotFound")) {
response.writeHead(404); response.writeHead(404);
@@ -669,7 +718,7 @@ export function listener(
return; return;
} }
const collection = db[`${collectionName}Collection`]; const collection = collections[collectionName];
if (!collection) { if (!collection) {
response.writeHead(404); response.writeHead(404);
response.end("404 Not Found"); response.end("404 Not Found");
@@ -693,7 +742,7 @@ export function listener(
break; break;
case "tasks": case "tasks":
q = query.sanitizeQueryTypes(q, { q = query.sanitizeQueryTypes(q, {
_id: (v) => new mongodb.ObjectID(v as string), _id: (v) => new ObjectId(v as string),
timestamp: (v) => new Date(v as number), timestamp: (v) => new Date(v as number),
retries: Number, retries: Number,
}); });
+23 -15
View File
@@ -52,35 +52,43 @@ function closeServer(timeout, callback): void {
}); });
} }
interface ServerOptions {
port?: number;
host?: string;
ssl?: { key: string; cert: string };
timeout?: number;
keepAliveTimeout?: number;
onConnection?: (socket: Socket) => void;
}
export function start( export function start(
port: number, options: ServerOptions,
networkInterface: string, _listener: http.RequestListener
ssl: { key: string; cert: string },
_listener: http.RequestListener,
onConnection?: (socket: Socket) => void,
keepAliveTimeout = -1
): void { ): void {
listener = _listener; listener = _listener;
if (ssl && ssl.key && ssl.cert) { if (options.ssl) {
const options = { const opts = {
key: ssl.key key: options.ssl.key
.split(":") .split(":")
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))), .map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
cert: ssl.cert cert: options.ssl.cert
.split(":") .split(":")
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))), .map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
}; };
server = https.createServer(options, listener); server = https.createServer(opts, listener);
if (onConnection != null) server.on("secureConnection", onConnection); if (options.onConnection)
server.on("secureConnection", options.onConnection);
} else { } else {
server = http.createServer(listener); server = http.createServer(listener);
if (onConnection != null) server.on("connection", onConnection); if (options.onConnection) server.on("connection", options.onConnection);
} }
if (keepAliveTimeout >= 0) server.keepAliveTimeout = keepAliveTimeout; server.timeout = options.timeout || 0;
server.listen(port, networkInterface); if (options.keepAliveTimeout != null)
server.keepAliveTimeout = options.keepAliveTimeout;
server.listen({ port: options.port, host: options.host });
} }
export function stop(): Promise<void> { export function stop(): Promise<void> {
+19 -5
View File
@@ -1734,20 +1734,35 @@ function generateSetRpcRequest(
const deviceData = sessionContext.deviceData; const deviceData = sessionContext.deviceData;
const SKIP_WRITABLE_CHECK = !!localCache.getConfig(
sessionContext.cacheSnapshot,
"cwmp.skipWritableCheck",
{},
sessionContext.timestamp,
(e) => configContextCallback(sessionContext, e)
);
const canWrite = (attrs: Attributes): boolean =>
SKIP_WRITABLE_CHECK || (attrs.writable && !!attrs.writable[1]);
// Delete instance // Delete instance
for (const instances of syncState.instancesToDelete.values()) { for (const instances of syncState.instancesToDelete.values()) {
const instance = instances.values().next().value; for (const instance of instances) {
if (instance && sessionContext.deviceData.attributes.has(instance)) { const attrs = sessionContext.deviceData.attributes.get(instance);
if (attrs && canWrite(attrs)) {
instances.delete(instance);
return { return {
name: "DeleteObject", name: "DeleteObject",
objectName: instance.toString() + ".", objectName: instance.toString() + ".",
}; };
} }
} }
}
// Create instance // Create instance
for (const [param, instances] of syncState.instancesToCreate) { for (const [param, instances] of syncState.instancesToCreate) {
if (sessionContext.deviceData.attributes.has(param)) { const attrs = sessionContext.deviceData.attributes.get(param);
if (attrs && canWrite(attrs)) {
const instance = instances.values().next().value; const instance = instances.values().next().value;
if (instance) { if (instance) {
instances.delete(instance); instances.delete(instance);
@@ -1791,7 +1806,7 @@ function generateSetRpcRequest(
syncState.spv.delete(k); syncState.spv.delete(k);
const attrs = sessionContext.deviceData.attributes.get(k); const attrs = sessionContext.deviceData.attributes.get(k);
const curVal = attrs.value ? attrs.value[1] : null; const curVal = attrs.value ? attrs.value[1] : null;
if (curVal && attrs.writable && attrs.writable[1]) { if (curVal && canWrite(attrs)) {
const val = v.slice() as [string | number | boolean, string]; const val = v.slice() as [string | number | boolean, string];
if (!val[1]) val[1] = curVal[1]; if (!val[1]) val[1] = curVal[1];
device.sanitizeParameterValue(val); device.sanitizeParameterValue(val);
@@ -2609,7 +2624,6 @@ export async function rpcResponse(
timestamp + 1, timestamp + 1,
{ {
object: [timestamp + 1, 0], object: [timestamp + 1, 0],
writable: [timestamp + 1, 1],
value: [ value: [
timestamp + 1, timestamp + 1,
p.slice(1) as [string | number | boolean, string], p.slice(1) as [string | number | boolean, string],
+150 -14
View File
@@ -127,7 +127,8 @@ function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
if (parsed == null) { if (parsed == null) {
warnings.push({ warnings.push({
message: "Invalid writable attribute", message: "Missing or invalid XML node",
element: "Writable",
parameter: param, parameter: param,
}); });
parsed = false; parsed = false;
@@ -167,7 +168,8 @@ function parameterValueList(
let valueType = getValueType(valueElement.attrs); let valueType = getValueType(valueElement.attrs);
if (!valueType) { if (!valueType) {
warnings.push({ warnings.push({
message: "Invalid value type attribute", message: "Missing or invalid XML node",
attribute: "type",
parameter: param, parameter: param,
}); });
valueType = "xsd:string"; valueType = "xsd:string";
@@ -179,7 +181,8 @@ function parameterValueList(
parsed = parseBool(value); parsed = parseBool(value);
if (parsed == null) { if (parsed == null) {
warnings.push({ warnings.push({
message: "Invalid value attribute", message: "Missing or invalid XML node",
element: "Value",
parameter: param, parameter: param,
}); });
parsed = value; parsed = value;
@@ -188,7 +191,8 @@ function parameterValueList(
parsed = parseInt(value); parsed = parseInt(value);
if (isNaN(parsed)) { if (isNaN(parsed)) {
warnings.push({ warnings.push({
message: "Invalid value attribute", message: "Missing or invalid XML node",
element: "Value",
parameter: param, parameter: param,
}); });
parsed = value; parsed = value;
@@ -197,7 +201,8 @@ function parameterValueList(
parsed = Date.parse(value); parsed = Date.parse(value);
if (isNaN(parsed)) { if (isNaN(parsed)) {
warnings.push({ warnings.push({
message: "Invalid value attribute", message: "Missing or invalid XML node",
element: "Value",
parameter: param, parameter: param,
}); });
parsed = value; parsed = value;
@@ -232,7 +237,8 @@ function parameterAttributeList(xml: Element): [Path, number, string[]][] {
let notification = parseInt(notificationElement.text); let notification = parseInt(notificationElement.text);
if (isNaN(notification)) { if (isNaN(notification)) {
warnings.push({ warnings.push({
message: "Invalid notification attribute", message: "Missing or invalid XML node",
element: "Notification",
parameter: param, parameter: param,
}); });
notification = 0; notification = 0;
@@ -331,7 +337,10 @@ function SetParameterValuesResponse(xml: Element): SetParameterValuesResponse {
} }
if (!(status >= 0)) { if (!(status >= 0)) {
warnings.push({ message: "Invalid SetParameterValuesResponse status" }); warnings.push({
message: "Missing or invalid XML node",
element: "Status",
});
status = 0; status = 0;
} }
@@ -392,8 +401,14 @@ function AddObjectResponse(xml: Element): AddObjectResponse {
} }
} }
if (!/^[0-9]+$/.test(instanceNumber))
throw new Error("Missing or invalid instance number");
if (!(status >= 0)) { if (!(status >= 0)) {
warnings.push({ message: "Invalid AddObjectResponse status" }); warnings.push({
message: "Missing or invalid XML node",
element: "Status",
});
status = 0; status = 0;
} }
@@ -424,7 +439,10 @@ function DeleteObjectResponse(xml: Element): DeleteObjectResponse {
} }
if (!(status >= 0)) { if (!(status >= 0)) {
warnings.push({ message: "Invalid DeleteObjectResponse status" }); warnings.push({
message: "Missing or invalid XML node",
element: "Status",
});
status = 0; status = 0;
} }
@@ -495,10 +513,29 @@ function DownloadResponse(xml: Element): DownloadResponse {
} }
if (!(status >= 0)) { if (!(status >= 0)) {
warnings.push({ message: "Invalid DownloadResponse status" }); warnings.push({
message: "Missing or invalid XML node",
element: "Status",
});
status = 0; status = 0;
} }
if (startTime == null || isNaN(startTime)) {
warnings.push({
message: "Missing or invalid XML node",
element: "StartTime",
});
startTime = Date.parse("0001-01-01T00:00:00Z");
}
if (completeTime == null || isNaN(completeTime)) {
warnings.push({
message: "Missing or invalid XML node",
element: "CompleteTime",
});
completeTime = Date.parse("0001-01-01T00:00:00Z");
}
return { return {
name: "DownloadResponse", name: "DownloadResponse",
status: status, status: status,
@@ -537,6 +574,30 @@ function Inform(xml: Element): InformRequest {
} }
} }
if (!deviceId || !deviceId.SerialNumber || !deviceId.OUI)
throw new Error("Missing or invalid DeviceId element");
if (!parameterList) {
warnings.push({
message: "Missing or invalid XML node",
element: "ParameterList",
});
parameterList = [];
}
if (!evnt) {
warnings.push({ message: "Missing or invalid XML node", element: "Event" });
evnt = [];
}
if (retryCount == null || isNaN(retryCount)) {
warnings.push({
message: "Missing or invalid XML node",
element: "RetryCount",
});
retryCount = 0;
}
return { return {
name: "Inform", name: "Inform",
parameterList: parameterList, parameterList: parameterList,
@@ -563,7 +624,10 @@ function GetRPCMethodsResponse(methodResponse): string {
} }
function TransferComplete(xml: Element): TransferCompleteRequest { function TransferComplete(xml: Element): TransferCompleteRequest {
let commandKey, _faultStruct, startTime, completeTime; let commandKey: string,
_faultStruct: FaultStruct,
startTime: number,
completeTime: number;
for (const c of xml.children) { for (const c of xml.children) {
switch (c.localName) { switch (c.localName) {
case "CommandKey": case "CommandKey":
@@ -581,6 +645,38 @@ function TransferComplete(xml: Element): TransferCompleteRequest {
} }
} }
if (commandKey == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "CommandKey",
});
commandKey = "";
}
if (!_faultStruct) {
warnings.push({
message: "Missing or invalid XML node",
element: "FaultStruct",
});
_faultStruct = { faultCode: "0", faultString: "" };
}
if (startTime == null || isNaN(startTime)) {
warnings.push({
message: "Missing or invalid XML node",
element: "StartTime",
});
startTime = Date.parse("0001-01-01T00:00:00Z");
}
if (completeTime == null || isNaN(completeTime)) {
warnings.push({
message: "Missing or invalid XML node",
element: "CompleteTime",
});
completeTime = Date.parse("0001-01-01T00:00:00Z");
}
return { return {
name: "TransferComplete", name: "TransferComplete",
commandKey: commandKey, commandKey: commandKey,
@@ -606,7 +702,12 @@ function RequestDownloadResponse(): string {
} }
function faultStruct(xml: Element): FaultStruct { function faultStruct(xml: Element): FaultStruct {
let faultCode, faultString, setParameterValuesFault: SpvFault[], pn, fc, fs; let faultCode: string,
faultString: string,
setParameterValuesFault: SpvFault[],
pn: string,
fc: string,
fs: string;
for (const c of xml.children) { for (const c of xml.children) {
switch (c.localName) { switch (c.localName) {
case "FaultCode": case "FaultCode":
@@ -617,6 +718,7 @@ function faultStruct(xml: Element): FaultStruct {
break; break;
case "SetParameterValuesFault": case "SetParameterValuesFault":
setParameterValuesFault = setParameterValuesFault || []; setParameterValuesFault = setParameterValuesFault || [];
pn = fc = fs = null;
for (const cc of c.children) { for (const cc of c.children) {
switch (cc.localName) { switch (cc.localName) {
case "ParameterName": case "ParameterName":
@@ -638,11 +740,27 @@ function faultStruct(xml: Element): FaultStruct {
} }
} }
if (faultCode == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "FaultCode",
});
faultCode = "";
}
if (faultString == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "FaultString",
});
faultString = "";
}
return { faultCode, faultString, setParameterValuesFault }; return { faultCode, faultString, setParameterValuesFault };
} }
function fault(xml: Element): CpeFault { function fault(xml: Element): CpeFault {
let faultCode, faultString, detail; let faultCode: string, faultString: string, detail: FaultStruct;
for (const c of xml.children) { for (const c of xml.children) {
switch (c.localName) { switch (c.localName) {
case "faultcode": case "faultcode":
@@ -657,7 +775,25 @@ function fault(xml: Element): CpeFault {
} }
} }
return { faultCode, faultString, detail }; if (!detail) throw new Error("Missing detail element");
if (faultCode == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "faultcode",
});
faultCode = "Client";
}
if (faultString == null) {
warnings.push({
message: "Missing or invalid XML node",
element: "faultstring",
});
faultString = "CWMP fault";
}
return { faultCode, faultString, detail } as CpeFault;
} }
export function request( export function request(
+3 -2
View File
@@ -163,6 +163,7 @@ export interface SessionContext {
cacheUntil?: number; cacheUntil?: number;
syncState?: SyncState; syncState?: SyncState;
lastActivity?: number; lastActivity?: number;
extendLock?: number;
rpcRequest?: AcsRequest; rpcRequest?: AcsRequest;
operationsTouched?: { [commandKey: string]: 1 | 0 }; operationsTouched?: { [commandKey: string]: 1 | 0 };
provisionsRet?: any[]; provisionsRet?: any[];
@@ -289,8 +290,8 @@ export interface FaultStruct {
} }
export interface CpeFault { export interface CpeFault {
faultCode: string; faultCode: "Client" | "Server";
faultString: string; faultString: "CWMP fault";
detail?: FaultStruct; detail?: FaultStruct;
} }
+3 -3
View File
@@ -17,7 +17,7 @@
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>. * along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Z_SYNC_FLUSH } from "zlib"; import { constants } from "zlib";
import Koa from "koa"; import Koa from "koa";
import Router from "koa-router"; import Router from "koa-router";
import * as jwt from "jsonwebtoken"; import * as jwt from "jsonwebtoken";
@@ -254,10 +254,10 @@ router.get("/", async (ctx) => {
koa.use( koa.use(
koaCompress({ koaCompress({
gzip: { gzip: {
flush: Z_SYNC_FLUSH, flush: constants.Z_SYNC_FLUSH,
}, },
deflate: { deflate: {
flush: Z_SYNC_FLUSH, flush: constants.Z_SYNC_FLUSH,
}, },
}) })
); );
+34 -11
View File
@@ -24,7 +24,8 @@ import { getUsers } from "../local-cache";
import { hashPassword } from "../auth"; import { hashPassword } from "../auth";
import { import {
insertTasks, insertTasks,
watchTask, awaitSessionEnd,
awaitSessionStart,
connectionRequest, connectionRequest,
deleteDevice, deleteDevice,
} from "../api-functions"; } from "../api-functions";
@@ -104,23 +105,45 @@ export async function postTasks(
const statuses = tasks.map((t) => { const statuses = tasks.map((t) => {
return { _id: t._id, status: "pending" }; return { _id: t._id, status: "pending" };
}); });
const lastInform = Date.now();
await del(`${deviceId}_tasks_faults_operations`); const notInSession = await awaitSessionEnd(deviceId, 30000);
if (!notInSession) {
try {
await connectionRequest(deviceId, device);
} catch (err) {
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id)))); await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
return { return {
connectionRequest: err.message, connectionRequest: "Session took too long to complete",
tasks: statuses, tasks: statuses,
}; };
} }
const sample = tasks[tasks.length - 1]; await del(`${deviceId}_tasks_faults_operations`);
// Waiting for session to finish or timeout const status = await connectionRequest(deviceId, device);
await watchTask(deviceId, sample._id, timeout);
if (status) {
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
return {
connectionRequest: status,
tasks: statuses,
};
}
const sessionStarted = await awaitSessionStart(deviceId, lastInform, timeout);
if (!sessionStarted) {
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
return {
connectionRequest: "No contact from CPE",
tasks: statuses,
};
}
const sessionEnded = await awaitSessionEnd(deviceId, 120000);
if (!sessionEnded) {
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectID(t._id))));
return {
connectionRequest: "Session took too long to complete",
tasks: statuses,
};
}
const promises = []; const promises = [];
for (const s of statuses) { for (const s of statuses) {
+39 -30
View File
@@ -31,6 +31,7 @@ import { del } from "../cache";
import Authorizer from "../common/authorizer"; import Authorizer from "../common/authorizer";
import { ping } from "../ping"; import { ping } from "../ping";
import * as url from "url"; import * as url from "url";
import { decodeTag } from "../common";
const router = new Router(); const router = new Router();
export default router; export default router;
@@ -85,7 +86,7 @@ router.get(`/devices/:id.csv`, async (ctx) => {
if (!authorizer.hasAccess("devices", 2)) { if (!authorizer.hasAccess("devices", 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 404); return void (ctx.status = 403);
} }
const res = await db.query("devices", filter); const res = await db.query("devices", filter);
@@ -126,7 +127,7 @@ router.get(`/devices/:id.csv`, async (ctx) => {
}); });
for (const [resource, flags] of Object.entries(resources)) { for (const [resource, flags] of Object.entries(resources)) {
router.head(`/${resource}`, async (ctx, next) => { router.head(`/${resource}`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
let filter: Expression = authorizer.getFilter(resource, 1); let filter: Expression = authorizer.getFilter(resource, 1);
if (ctx.request.query.filter) if (ctx.request.query.filter)
@@ -141,7 +142,7 @@ for (const [resource, flags] of Object.entries(resources)) {
if (!authorizer.hasAccess(resource, 1)) { if (!authorizer.hasAccess(resource, 1)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
@@ -162,7 +163,7 @@ for (const [resource, flags] of Object.entries(resources)) {
logger.accessInfo(log); logger.accessInfo(log);
}); });
router.get(`/${resource}`, async (ctx, next) => { router.get(`/${resource}`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const options: QueryOptions = {}; const options: QueryOptions = {};
let filter: Expression = authorizer.getFilter(resource, 2); let filter: Expression = authorizer.getFilter(resource, 2);
@@ -190,7 +191,7 @@ for (const [resource, flags] of Object.entries(resources)) {
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
@@ -216,7 +217,7 @@ for (const [resource, flags] of Object.entries(resources)) {
}); });
// CSV download // CSV download
router.get(`/${resource}.csv`, async (ctx, next) => { router.get(`/${resource}.csv`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const options: QueryOptions = { projection: {} }; const options: QueryOptions = { projection: {} };
let filter: Expression = authorizer.getFilter(resource, 2); let filter: Expression = authorizer.getFilter(resource, 2);
@@ -238,7 +239,7 @@ for (const [resource, flags] of Object.entries(resources)) {
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const columns: Record<string, Expression> = JSON.parse( const columns: Record<string, Expression> = JSON.parse(
@@ -280,7 +281,7 @@ for (const [resource, flags] of Object.entries(resources)) {
if (e[1] === "Tags") { if (e[1] === "Tags") {
const tags = []; const tags = [];
for (const p in obj) for (const p in obj)
if (p.startsWith("Tags.")) tags.push(p.slice(5)); if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
return tags.join(", "); return tags.join(", ");
} }
@@ -307,7 +308,7 @@ for (const [resource, flags] of Object.entries(resources)) {
logger.accessInfo(log); logger.accessInfo(log);
}); });
router.head(`/${resource}/:id`, async (ctx, next) => { router.head(`/${resource}/:id`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: `Count ${resource}`, message: `Count ${resource}`,
@@ -322,18 +323,18 @@ for (const [resource, flags] of Object.entries(resources)) {
]); ]);
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const res = await db.query(resource, filter);
if (!res.length) return void next(); if (!res.length) return void (ctx.status = 404);
logger.accessInfo(log); logger.accessInfo(log);
ctx.body = ""; ctx.body = "";
}); });
router.get(`/${resource}/:id`, async (ctx, next) => { router.get(`/${resource}/:id`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: `Query ${resource}`, message: `Query ${resource}`,
@@ -348,19 +349,19 @@ for (const [resource, flags] of Object.entries(resources)) {
]); ]);
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const res = await db.query(resource, filter);
if (!res.length) return void next(); if (!res.length) return void (ctx.status = 404);
logger.accessInfo(log); logger.accessInfo(log);
ctx.body = res[0]; ctx.body = res[0];
}); });
if (flags & RESOURCE_DELETE) { if (flags & RESOURCE_DELETE) {
router.delete(`/${resource}/:id`, async (ctx, next) => { router.delete(`/${resource}/:id`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: `Delete ${resource}`, message: `Delete ${resource}`,
@@ -375,10 +376,10 @@ for (const [resource, flags] of Object.entries(resources)) {
]); ]);
if (!authorizer.hasAccess(resource, 3)) { if (!authorizer.hasAccess(resource, 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const res = await db.query(resource, filter);
if (!res.length) return void next(); if (!res.length) return void (ctx.status = 404);
const validate = authorizer.getValidator(resource, res[0]); const validate = authorizer.getValidator(resource, res[0]);
if (!validate("delete")) { if (!validate("delete")) {
@@ -395,7 +396,7 @@ for (const [resource, flags] of Object.entries(resources)) {
} }
if (flags & RESOURCE_PUT) { if (flags & RESOURCE_PUT) {
router.put(`/${resource}/:id`, async (ctx, next) => { router.put(`/${resource}/:id`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const id = ctx.params.id; const id = ctx.params.id;
@@ -407,7 +408,7 @@ for (const [resource, flags] of Object.entries(resources)) {
if (!authorizer.hasAccess(resource, 3)) { if (!authorizer.hasAccess(resource, 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const obj = ctx.request.body; const obj = ctx.request.body;
@@ -434,7 +435,7 @@ for (const [resource, flags] of Object.entries(resources)) {
} }
} }
router.put("/files/:id", async (ctx, next) => { router.put("/files/:id", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const resource = "files"; const resource = "files";
const id = ctx.params.id; const id = ctx.params.id;
@@ -448,7 +449,7 @@ router.put("/files/:id", async (ctx, next) => {
if (!authorizer.hasAccess(resource, 3)) { if (!authorizer.hasAccess(resource, 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const metadata = { const metadata = {
@@ -477,7 +478,7 @@ router.put("/files/:id", async (ctx, next) => {
ctx.body = ""; ctx.body = "";
}); });
router.post("/devices/:id/tasks", async (ctx, next) => { router.post("/devices/:id/tasks", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: "Commit tasks", message: "Commit tasks",
@@ -493,10 +494,10 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
]); ]);
if (!authorizer.hasAccess("devices", 3)) { if (!authorizer.hasAccess("devices", 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const devices = await db.query("devices", filter); const devices = await db.query("devices", filter);
if (!devices.length) return void next(); if (!devices.length) return void (ctx.status = 404);
const device = devices[0]; const device = devices[0];
const validate = authorizer.getValidator("devices", device); const validate = authorizer.getValidator("devices", device);
@@ -530,6 +531,11 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
} }
) as number; ) as number;
const socketTimeout: number = ctx.socket["timeout"];
// Disable socket timeout while waiting for postTasks()
if (socketTimeout) ctx.socket.setTimeout(0);
const res = await apiFunctions.postTasks( const res = await apiFunctions.postTasks(
ctx.params.id, ctx.params.id,
ctx.request.body, ctx.request.body,
@@ -537,6 +543,9 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
device device
); );
// Restore socket timeout
if (socketTimeout) ctx.socket.setTimeout(socketTimeout);
log.tasks = res.tasks.map((t) => t._id).join(","); log.tasks = res.tasks.map((t) => t._id).join(",");
logger.accessInfo(log); logger.accessInfo(log);
@@ -545,7 +554,7 @@ router.post("/devices/:id/tasks", async (ctx, next) => {
ctx.body = res.tasks; ctx.body = res.tasks;
}); });
router.post("/devices/:id/tags", async (ctx, next) => { router.post("/devices/:id/tags", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: "Update tags", message: "Update tags",
@@ -561,10 +570,10 @@ router.post("/devices/:id/tags", async (ctx, next) => {
]); ]);
if (!authorizer.hasAccess("devices", 3)) { if (!authorizer.hasAccess("devices", 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const res = await db.query("devices", filter); const res = await db.query("devices", filter);
if (!res.length) return void next(); if (!res.length) return void (ctx.status = 404);
const validate = authorizer.getValidator("devices", res[0]); const validate = authorizer.getValidator("devices", res[0]);
if (!validate("tags", ctx.request.body)) { if (!validate("tags", ctx.request.body)) {
@@ -600,7 +609,7 @@ router.get("/ping/:host", async (ctx) => {
}); });
}); });
router.put("/users/:id/password", async (ctx, next) => { router.put("/users/:id/password", async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const username = ctx.params.id; const username = ctx.params.id;
const log = { const log = {
@@ -625,7 +634,7 @@ router.put("/users/:id/password", async (ctx, next) => {
} }
} else if (!authorizer.hasAccess("users", 3)) { } else if (!authorizer.hasAccess("users", 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void next(); return void (ctx.status = 403);
} }
const filter = and(authorizer.getFilter("users", 3), [ const filter = and(authorizer.getFilter("users", 3), [
@@ -634,7 +643,7 @@ router.put("/users/:id/password", async (ctx, next) => {
username, username,
]); ]);
const res = await db.query("users", filter); const res = await db.query("users", filter);
if (!res.length) return void next(); if (!res.length) return void (ctx.status = 404);
const newPassword = ctx.request.body.newPassword; const newPassword = ctx.request.body.newPassword;
if (ctx.state.user) { if (ctx.state.user) {
+22 -137
View File
@@ -17,85 +17,24 @@
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>. * along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { MongoClient, GridFSBucket, ObjectID } from "mongodb"; import { Db, GridFSBucket, ObjectID } from "mongodb";
import { Script } from "vm"; import { Script } from "vm";
import * as config from "../config"; import { onConnect } from "../db";
import * as mongodbFunctions from "../mongodb-functions"; import * as mongodbFunctions from "../mongodb-functions";
import * as expression from "../common/expression"; import * as expression from "../common/expression";
import { QueryOptions, Expression } from "../types"; import { QueryOptions, Expression } from "../types";
import { Readable } from "stream"; import { Readable } from "stream";
import { minimize } from "../common/boolean-expression"; import { minimize } from "../common/boolean-expression";
const CACHE_TTL = 300000;
let clientPromise: Promise<MongoClient>;
const RESOURCE_COLLECTION = { const RESOURCE_COLLECTION = {
files: "fs.files", files: "fs.files",
}; };
function ensureIndexes(client): void { let db: Db;
client
.db()
.collection("cache")
.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
}
function getClient(): Promise<MongoClient> { onConnect(async (_db) => {
if (!clientPromise) { db = _db;
clientPromise = new Promise((resolve, reject) => {
const CONNECTION_URL = "" + config.get("MONGODB_CONNECTION_URL");
MongoClient.connect(
CONNECTION_URL,
{ useNewUrlParser: true, useUnifiedTopology: true },
(err, client) => {
if (err) return void reject(err);
ensureIndexes(client);
resolve(client);
}
);
}); });
}
return clientPromise;
}
export function cache<T>(
key: string,
valueGetter: () => Promise<T>,
ttl: number
): Promise<T> {
return new Promise((resolve, reject) => {
getClient()
.then((client) => {
const collection = client.db().collection("cache");
collection.findOne({ _id: key }, (err, doc) => {
if (err) return void reject(err);
if (doc != null) return void resolve(JSON.parse(doc.value) as T);
valueGetter()
.then((res) => {
const expire = Date.now() + (ttl || CACHE_TTL);
const cacheDoc = {
_id: key,
value: JSON.stringify(res),
expire: new Date(expire),
};
collection.updateOne(
{ _id: key },
{ $set: cacheDoc },
{ upsert: true },
(err) => {
if (err) reject(err);
else resolve(res);
}
);
})
.catch(reject);
});
})
.catch(reject);
});
}
export function query( export function query(
resource: string, resource: string,
@@ -123,16 +62,12 @@ export function query(
} }
q = mongodbFunctions.filterToMongoQuery(filter); q = mongodbFunctions.filterToMongoQuery(filter);
} else if (filter != null && !filter) { } else if (!filter) {
return Promise.resolve([]); return Promise.resolve([]);
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
.then((client) => {
const collection = client
.db()
.collection(RESOURCE_COLLECTION[resource] || resource);
const cursor = collection.find(q); const cursor = collection.find(q);
if (options.projection) { if (options.projection) {
let projection = options.projection; let projection = options.projection;
@@ -182,8 +117,7 @@ export function query(
} else { } else {
cursor.forEach( cursor.forEach(
(doc) => { (doc) => {
if (resource === "devices") if (resource === "devices") doc = mongodbFunctions.flattenDevice(doc);
doc = mongodbFunctions.flattenDevice(doc);
else if (resource === "faults") else if (resource === "faults")
doc = mongodbFunctions.flattenFault(doc); doc = mongodbFunctions.flattenFault(doc);
else if (resource === "tasks") else if (resource === "tasks")
@@ -200,8 +134,6 @@ export function query(
} }
); );
} }
})
.catch(reject);
}); });
} }
@@ -218,73 +150,48 @@ export function count(resource: string, filter: Expression): Promise<number> {
else if (resource === "faults") else if (resource === "faults")
filter = mongodbFunctions.processFaultsFilter(filter); filter = mongodbFunctions.processFaultsFilter(filter);
q = mongodbFunctions.filterToMongoQuery(filter); q = mongodbFunctions.filterToMongoQuery(filter);
} else if (filter != null && !filter) { } else if (!filter) {
return Promise.resolve(0); return Promise.resolve(0);
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
.then((client) => {
const collection = client
.db()
.collection(RESOURCE_COLLECTION[resource] || resource);
collection.find(q).count((err, c) => { collection.find(q).count((err, c) => {
if (err) reject(err); if (err) reject(err);
else resolve(c); else resolve(c);
}); });
})
.catch(reject);
}); });
} }
export function updateDeviceTags( export async function updateDeviceTags(
deviceId: string, deviceId: string,
tags: Record<string, boolean> tags: Record<string, boolean>
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => {
const add = []; const add = [];
const pull = []; const pull = [];
const regex = /^[0-9a-zA-Z_]+$/;
for (let [tag, onOff] of Object.entries(tags)) { for (let [tag, onOff] of Object.entries(tags)) {
tag = tag.trim(); tag = tag.trim();
if (onOff) { if (onOff) add.push(tag);
if (!tag.match(regex)) else pull.push(tag);
return void reject(new Error(`Invalid tag '${tag}'`));
add.push(tag);
} else {
pull.push(tag);
} }
}
getClient()
.then((client) => {
const collection = client.db().collection("devices");
const collection = db.collection("devices");
const object = {}; const object = {};
if (add && add.length) object["$addToSet"] = { _tags: { $each: add } }; if (add && add.length) object["$addToSet"] = { _tags: { $each: add } };
if (pull && pull.length) object["$pullAll"] = { _tags: pull }; if (pull && pull.length) object["$pullAll"] = { _tags: pull };
collection.updateOne({ _id: deviceId }, object, (err) => {
if (err) return void reject(err); await collection.updateOne({ _id: deviceId }, object);
resolve();
});
})
.catch(reject);
});
} }
function putResource(resource, id, object): Promise<void> { function putResource(resource, id, object): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
.then((client) => {
const collection = client
.db()
.collection(RESOURCE_COLLECTION[resource] || resource);
collection.replaceOne({ _id: id }, object, { upsert: true }, (err) => { collection.replaceOne({ _id: id }, object, { upsert: true }, (err) => {
if (err) return void reject(err); if (err) return void reject(err);
resolve(); resolve();
}); });
})
.catch(reject);
}); });
} }
@@ -293,17 +200,11 @@ function deleteResource(
id: string | ObjectID id: string | ObjectID
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
.then((client) => {
const collection = client
.db()
.collection(RESOURCE_COLLECTION[resource] || resource);
collection.deleteOne({ _id: id }, (err) => { collection.deleteOne({ _id: id }, (err) => {
if (err) return void reject(err); if (err) return void reject(err);
resolve(); resolve();
}); });
})
.catch(reject);
}); });
} }
@@ -380,9 +281,7 @@ export function putUser(
object: Record<string, unknown> object: Record<string, unknown>
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const collection = db.collection("users");
.then((client) => {
const collection = client.db().collection("users");
// update instead of replace to keep the password if not set by user // update instead of replace to keep the password if not set by user
collection.updateOne( collection.updateOne(
{ _id: id }, { _id: id },
@@ -393,8 +292,6 @@ export function putUser(
resolve(); resolve();
} }
); );
})
.catch(reject);
}); });
} }
@@ -408,9 +305,7 @@ export function putFile(
contentStream: Readable contentStream: Readable
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const bucket = new GridFSBucket(db);
.then((client) => {
const bucket = new GridFSBucket(client.db());
const uploadStream = bucket.openUploadStreamWithId(filename, filename, { const uploadStream = bucket.openUploadStreamWithId(filename, filename, {
metadata: metadata, metadata: metadata,
}); });
@@ -418,22 +313,16 @@ export function putFile(
contentStream.on("error", reject); contentStream.on("error", reject);
uploadStream.on("finish", resolve); uploadStream.on("finish", resolve);
contentStream.pipe(uploadStream); contentStream.pipe(uploadStream);
})
.catch(reject);
}); });
} }
export function deleteFile(filename: string): Promise<void> { export function deleteFile(filename: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
getClient() const bucket = new GridFSBucket(db);
.then((client) => {
const bucket = new GridFSBucket(client.db());
bucket.delete(filename as any, (err) => { bucket.delete(filename as any, (err) => {
if (err) return void reject(err); if (err) return void reject(err);
resolve(); resolve();
}); });
})
.catch(reject);
}); });
} }
@@ -444,7 +333,3 @@ export function deleteFault(id: string): Promise<void> {
export function deleteTask(id: ObjectID): Promise<void> { export function deleteTask(id: ObjectID): Promise<void> {
return deleteResource("tasks", id); return deleteResource("tasks", id);
} }
export async function disconnect(): Promise<void> {
if (clientPromise) await (await clientPromise).close();
}
+6 -3
View File
@@ -53,12 +53,13 @@ export interface Element {
} }
export function parseXmlDeclaration(buffer: Buffer): Attribute[] { export function parseXmlDeclaration(buffer: Buffer): Attribute[] {
for (const enc of ["utf16le", "utf8", "latin1", "ascii"]) { const encodings: BufferEncoding[] = ["utf16le", "utf8", "latin1", "ascii"];
for (const enc of encodings) {
let str = buffer.toString(enc, 0, 150); let str = buffer.toString(enc, 0, 150);
if (str.startsWith("<?xml")) { if (str.startsWith("<?xml")) {
str = str.split("\n")[0].trim(); str = str.slice(0, str.indexOf("?>"));
try { try {
return parseAttrs(str.slice(5, -2)); return parseAttrs(str.slice(5));
} catch (err) { } catch (err) {
// Ignore // Ignore
} }
@@ -103,10 +104,12 @@ export function parseAttrs(string: string): Attribute[] {
continue; continue;
case CHAR_COLON: case CHAR_COLON:
if (state) continue;
if (idx >= colonIdx) colonIdx = i; if (idx >= colonIdx) colonIdx = i;
continue; continue;
case CHAR_EQUAL: case CHAR_EQUAL:
if (state) continue;
if (name) throw new Error(`Unexpected character at ${i}`); if (name) throw new Error(`Unexpected character at ${i}`);
name = string.slice(idx, i).trim(); name = string.slice(idx, i).trim();
// TODO validate name // TODO validate name
+481
View File
@@ -0,0 +1,481 @@
/**
* Copyright 2013-2020 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 <http://www.gnu.org/licenses/>.
*/
import * as net from "net";
import * as tls from "tls";
import { EventEmitter } from "events";
import { createHash, createHmac, randomBytes } from "crypto";
import { parseXml, Element, parseAttrs } from "./xml-parser";
function encodeBase64(str: string): string {
return Buffer.from(str).toString("base64");
}
function decodeBase64(str: string): string {
return Buffer.from(str, "base64").toString();
}
function detectStreamTag(data: string): number {
const i1 = data.indexOf("<stream:stream");
if (i1 < 0) throw new Error("Cannot detect opening stream tag");
const i2 = data.indexOf(">", i1);
if (i2 < 0) throw new Error("Cannot detect opening stream tag");
return i2 + 1;
}
function xmppStream<T>(
socket: net.Socket,
callback: Generator<void, T, Element>
): Promise<T> {
return new Promise((resolve, reject) => {
const onError = (err: Error): void => {
socket.removeListener("error", onError);
reject(err);
};
socket.on("error", onError);
const onData = (chunk: Buffer): void => {
try {
const str = chunk.toString("utf8");
const xml = parseXml(str);
const { value, done } = callback.next(xml.children[0]);
if (done) {
socket.removeListener("error", onError);
socket.removeListener("data", onData);
resolve(value as T);
}
} catch (err) {
socket.removeListener("error", onError);
socket.removeListener("data", onData);
reject(err);
}
};
socket.once("data", (chunk: Buffer) => {
try {
const str = chunk.toString("utf8");
const i = detectStreamTag(str);
const streamTagStr = str.slice(0, i);
const xml = parseXml(streamTagStr + "</stream:stream>");
callback.next(xml.children[0]);
chunk = chunk.slice(Buffer.byteLength(streamTagStr));
if (chunk.length) onData(chunk);
socket.on("data", onData);
} catch (err) {
socket.removeListener("error", onError);
socket.removeListener("data", onData);
reject(err);
}
});
try {
callback.next();
} catch (err) {
socket.removeListener("error", onError);
socket.removeListener("data", onData);
reject(err);
}
});
}
const INT_1 = Buffer.from([0, 0, 0, 1]);
const saltedPasswordCache = {
password: "",
iterationCount: 0,
saltBase64: "",
salted: Buffer.allocUnsafe(0),
};
function saltPassword(
password: string,
saltBase64: string,
iteractionCount: number
): Buffer {
if (
password === saltedPasswordCache.password &&
saltBase64 === saltedPasswordCache.saltBase64 &&
iteractionCount === saltedPasswordCache.iterationCount
)
return saltedPasswordCache.salted;
const hi = createHmac("sha1", password)
.update(Buffer.concat([Buffer.from(saltBase64, "base64"), INT_1]))
.digest();
let hi2: Buffer = hi;
for (let i = 1; i < iteractionCount; ++i) {
hi2 = createHmac("sha1", password).update(hi2).digest();
for (const [j, b] of hi2.entries()) hi[j] ^= b;
}
saltedPasswordCache.saltBase64 = saltBase64;
saltedPasswordCache.password = password;
saltedPasswordCache.iterationCount = iteractionCount;
saltedPasswordCache.salted = hi;
return hi;
}
function* loginPlain(
socket: net.Socket,
username: string,
password: string
): Generator<void, void, Element> {
socket.write(
`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="PLAIN">${encodeBase64(
`\x00${username}\x00${password}`
)}</auth>`
);
const res1 = yield;
if (
res1.name === "failure" &&
res1.children.some((c) => c.name === "not-authorized")
)
throw new Error("Not authorized");
if (res1.name !== "success")
throw new Error(`Unexpected response ${res1.name}`);
}
function* loginScram(
socket: net.Socket,
username: string,
password: string
): Generator<void, void, Element> {
const cnonce = randomBytes(8).toString("base64");
const gs2Header = "n,,";
const clientFirstMessageBare = `n=${username},r=${cnonce}`;
const clientFirstMessage = gs2Header + clientFirstMessageBare;
socket.write(
`<auth xmlns="urn:ietf:params:xml:ns:xmpp-sasl" mechanism="SCRAM-SHA-1">${encodeBase64(
clientFirstMessage
)}</auth>`
);
const res1 = yield;
if (res1.name !== "challenge")
throw new Error(`Unexpected element ${res1.name}`);
const serverFirstMessage = decodeBase64(res1.text);
let iterationCount: number;
let saltBase64: string;
let nonce: string;
for (const s of serverFirstMessage.split(",")) {
if (s.startsWith("i=")) iterationCount = parseInt(s.slice(2));
else if (s.startsWith("s=")) saltBase64 = s.slice(2);
else if (s.startsWith("r=")) nonce = s.slice(2);
}
if (iterationCount == null || isNaN(iterationCount))
throw new Error("Invalid iteration count");
if (saltBase64 == null) throw new Error("Missing salt");
if (nonce == null) throw new Error("Missing nonce");
const saltedPassword = saltPassword(password, saltBase64, iterationCount);
const clientKey = createHmac("sha1", saltedPassword)
.update("Client Key")
.digest();
const storedKey = createHash("sha1").update(clientKey).digest();
const clientFinalMessageWithoutProof = `c=${encodeBase64(
gs2Header
)},r=${nonce}`;
const authMessage = `${clientFirstMessageBare},${serverFirstMessage},${clientFinalMessageWithoutProof}`;
const clientSignature = createHmac("sha1", storedKey)
.update(authMessage)
.digest();
const clientProof = Buffer.from(clientKey);
for (const [i, b] of clientSignature.entries()) clientProof[i] ^= b;
const clientFinalMessage = `${clientFinalMessageWithoutProof},p=${clientProof.toString(
"base64"
)}`;
socket.write(
`<response xmlns="urn:ietf:params:xml:ns:xmpp-sasl">${encodeBase64(
clientFinalMessage
)}</response>`
);
const res2 = yield;
if (
res2.name === "failure" &&
res2.children.some((c) => c.name === "not-authorized")
)
throw new Error("Not authorized");
if (res2.name !== "success")
throw new Error(`Unexpected response ${res2.name}`);
const serverKey = createHmac("sha1", saltedPassword)
.update("Server Key")
.digest();
const serverSignature = createHmac("sha1", serverKey)
.update(authMessage)
.digest("base64");
if (!decodeBase64(res2.text).endsWith(serverSignature))
throw new Error("Invalid server signature");
}
function* starttls(socket: net.Socket): Generator<void, void, Element> {
socket.write("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
const res1 = yield;
if (res1.name !== "proceed") throw new Error("Failed to initiate STARTTLS");
}
function* bind(
socket: net.Socket,
resource: string
): Generator<void, void, Element> {
const id = randomBytes(8).toString("base64");
socket.write(
`<iq id='${id}' type='set'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'><resource>${resource}</resource></bind></iq>`
);
const res1 = yield;
if (res1.name !== "iq") throw new Error(`Unexpected element ${res1.name}`);
const attrs1 = parseAttrs(res1.attrs);
const idAttr = attrs1.find((a) => a.name === "id");
if (!idAttr || idAttr.value !== id) throw new Error("Invalid ID");
const typeAttr = attrs1.find((a) => a.name === "type");
if (!typeAttr) throw new Error("Missing type attribute");
if (typeAttr.value !== "result") throw new Error("Cannot bind to resource");
}
const STATUS_RESTART_STREAM = 1;
const STATUS_STARTTLS = 2;
function* init(
socket: net.Socket,
host: string,
username: string,
password: string,
resource: string
): Generator<void, number, Element> {
socket.write(
`<?xml version='1.0'?><stream:stream from='${username}@${host}' to='${host}' version='1.0' xml:lang='en' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams'>`
);
const open = yield;
if (open.name !== "stream:stream")
throw new Error(`Unexpected element ${open.name}`);
const features = yield;
if (features.name !== "stream:features")
throw new Error(`Unexpected element ${features.name}`);
for (const feature of features.children) {
if (feature.name === "starttls") {
if (feature.children.some((c) => c.name === "required")) {
yield* starttls(socket);
return STATUS_STARTTLS;
}
} else if (feature.name === "mechanisms") {
const mechanisms: Set<string> = new Set(
feature.children.map((c) => c.text)
);
if (mechanisms.has("PLAIN")) {
yield* loginPlain(socket, username, password);
return STATUS_RESTART_STREAM;
} else if (mechanisms.has("SCRAM-SHA-1")) {
yield* loginScram(socket, username, password);
return STATUS_RESTART_STREAM;
} else {
throw new Error("No supported SASL method");
}
} else if (feature.name === "bind") {
yield* bind(socket, resource);
return 0;
}
}
return 0;
}
function upgradeTls(socket: net.Socket, host: string): Promise<tls.TLSSocket> {
return new Promise((resolve, reject) => {
socket.on("error", reject);
const newSocket = tls.connect({ socket, host }, () => {
socket.removeListener("error", reject);
resolve(newSocket);
});
});
}
interface XmppClientOptions {
host: string;
port?: number;
username?: string;
password?: string;
resource?: string;
timeout?: number;
}
export default class XmppClient extends EventEmitter {
private _socket: net.Socket;
private _host: string;
private _username: string;
private _resource: string;
private _iqStanzaCallbacks: Map<
string,
(err: Error, r?: { rawRes: string; res: Element }) => void
>;
private constructor() {
super();
this._socket = null;
this._host = null;
this._username = null;
this._resource = null;
this._iqStanzaCallbacks = new Map();
}
static connect(opts: XmppClientOptions): Promise<XmppClient> {
return new Promise((resolve, reject) => {
let socket = new net.Socket();
socket.on("error", reject);
socket.connect(opts.port || 5222, opts.host, async () => {
socket.removeListener("error", reject);
try {
let status = 1;
while (status) {
if (status === STATUS_STARTTLS)
socket = await upgradeTls(socket, opts.host);
status = await xmppStream(
socket,
init(
socket,
opts.host,
opts.username,
opts.password,
opts.resource
)
);
}
const client = new XmppClient();
client._socket = socket;
client._host = opts.host;
client._username = opts.username;
client._resource = opts.resource;
socket.on("data", client._onData.bind(client));
socket.on("error", client._onError.bind(client));
if (opts.timeout)
socket.setTimeout(opts.timeout, client.close.bind(client));
resolve(client);
} catch (err) {
socket.destroy();
reject(err);
}
});
});
}
close(): void {
this._socket.end("</stream:stream>");
this._socket.removeAllListeners("data");
this._socket.removeAllListeners("error");
this.emit("close");
}
ref(): void {
this._socket.ref();
}
unref(): void {
this._socket.unref();
}
get host(): string {
return this._host;
}
get username(): string {
return this._username;
}
get resource(): string {
return this._resource;
}
private _onData(chunk: Buffer): void {
try {
let close = false;
let str = chunk.toString("utf8");
if (str.endsWith("</stream:stream>")) {
str = str.slice(0, -16);
close = true;
}
const xml = parseXml(str);
const idx = xml.children.map((c) => c.bodyIndex);
for (const [i, c] of xml.children.entries()) {
const s = str.slice(idx[i], idx[i + 1]);
if (c.name === "iq") {
const attrs = parseAttrs(c.attrs);
const id = attrs.find((a) => a.name === "id");
if (id) {
const cb = this._iqStanzaCallbacks.get(id.value);
if (cb) cb(null, { rawRes: s, res: c });
}
}
this.emit("stanza", c, s);
}
if (close) {
this._socket.removeAllListeners("data");
this._socket.removeAllListeners("error");
this.emit("close");
}
} catch (err) {
this._socket.removeAllListeners("data");
this._socket.removeAllListeners("error");
this.emit("error", err);
}
}
private _onError(err: Error): void {
this._socket.end();
this.emit("error", err);
for (const cb of this._iqStanzaCallbacks.values()) cb(err);
}
send(msg: string): void {
this._socket.write(msg);
}
sendIqStanza(
from: string,
to: string,
type: string,
body: string,
timeout = 3000
): Promise<{ rawReq: string; rawRes: string; res: Element }> {
return new Promise((resolve, reject) => {
const id = randomBytes(8).toString("base64");
const rawReq = `<iq from="${from}" to="${to}" id="${id}" type="${type}">${body}</iq>`;
this.send(rawReq);
const t = setTimeout(() => {
this._iqStanzaCallbacks.delete(id);
reject(
new Error("Did not receive IQ stanza response in a timely manner")
);
}, timeout);
this._iqStanzaCallbacks.set(id, (err, r) => {
this._iqStanzaCallbacks.delete(id);
clearTimeout(t);
if (err) reject(err);
else resolve(Object.assign(r, { rawReq }));
});
});
}
}
+295 -2884
View File
File diff suppressed because it is too large. Load diff
+18 -17
View File
@@ -1,6 +1,6 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.2.2", "version": "1.2.3",
"description": "A TR-069 Auto Configuration Server (ACS)", "description": "A TR-069 Auto Configuration Server (ACS)",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -37,41 +37,42 @@
"yaml": "^1.10.0" "yaml": "^1.10.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^15.1.0", "@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-node-resolve": "^10.0.0",
"@rollup/plugin-typescript": "^6.0.0", "@rollup/plugin-typescript": "^6.0.0",
"@types/jsonwebtoken": "^8.5.0", "@types/jsonwebtoken": "^8.5.0",
"@types/koa": "^2.11.4", "@types/koa": "^2.11.6",
"@types/koa-compress": "^4.0.1", "@types/koa-compress": "^4.0.1",
"@types/koa-router": "^7.4.1", "@types/koa-router": "^7.4.1",
"@types/koa-static": "^4.0.1", "@types/koa-static": "^4.0.1",
"@types/mithril": "^2.0.4", "@types/mithril": "^2.0.5",
"@types/mongodb": "^3.5.27", "@types/mongodb": "^3.5.31",
"@types/node": "^14.14.5",
"@types/parsimmon": "^1.10.3", "@types/parsimmon": "^1.10.3",
"@types/seedrandom": "^2.4.28", "@types/seedrandom": "^2.4.28",
"@types/svgo": "^1.3.3", "@types/svgo": "^1.3.3",
"@typescript-eslint/eslint-plugin": "^4.3.0", "@typescript-eslint/eslint-plugin": "^4.6.0",
"@typescript-eslint/parser": "^4.3.0", "@typescript-eslint/parser": "^4.6.0",
"ava": "^3.13.0", "ava": "^3.13.0",
"codemirror": "^5.58.1", "codemirror": "^5.58.2",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"eslint": "^7.10.0", "eslint": "^7.12.1",
"eslint-config-prettier": "^6.12.0", "eslint-config-prettier": "^6.14.0",
"esm": "^3.2.25", "esm": "^3.2.25",
"jsbi": "^3.1.4", "jsbi": "^3.1.4",
"mithril": "^2.0.4", "mithril": "^2.0.4",
"postcss": "^8.1.1", "postcss": "^8.1.4",
"postcss-import": "^12.0.1", "postcss-import": "^13.0.0",
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"prettier": "^2.1.2", "prettier": "^2.1.2",
"rollup": "^2.28.2", "rollup": "^2.32.1",
"rollup-plugin-terser": "^7.0.2", "rollup-plugin-terser": "^7.0.2",
"source-map-loader": "^1.1.0", "source-map-loader": "^1.1.1",
"svgo": "^1.3.2", "svgo": "^1.3.2",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typescript": "^4.0.3", "typescript": "^4.0.5",
"webpack": "^4.44.2" "webpack": "^5.2.0"
}, },
"engines": { "engines": {
"node": ">=12.13.0" "node": ">=12.13.0"
+2 -1
View File
@@ -22,6 +22,7 @@ import { m } from "../components";
import * as notifications from "../notifications"; import * as notifications from "../notifications";
import * as store from "../store"; import * as store from "../store";
import { getIcon } from "../icons"; import { getIcon } from "../icons";
import { decodeTag } from "../../lib/common";
const component: ClosureComponent = (): Component => { const component: ClosureComponent = (): Component => {
return { return {
@@ -32,7 +33,7 @@ const component: ClosureComponent = (): Component => {
const tags = []; const tags = [];
for (const p of Object.keys(device)) for (const p of Object.keys(device))
if (p.startsWith("Tags.")) tags.push(p.slice(5)); if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
tags.sort(); tags.sort();
+27 -12
View File
@@ -147,8 +147,8 @@ function renderTable(confsResponse, searchString): Children {
{ {
title: "Edit config value", title: "Edit config value",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -176,8 +176,13 @@ function renderTable(confsResponse, searchString): Children {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
getIcon("edit") getIcon("edit")
@@ -244,8 +249,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new config", title: "Create new config",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -274,8 +279,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New config" "New config"
@@ -307,8 +317,8 @@ export const component: ClosureComponent = (): Component => {
"button", "button",
{ {
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
uiConfigComponent, uiConfigComponent,
Object.assign( Object.assign(
{ {
@@ -338,8 +348,13 @@ export const component: ClosureComponent = (): Component => {
attrs attrs
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
`Edit ${sub.name}` `Edit ${sub.name}`
+4 -4
View File
@@ -49,10 +49,10 @@ function mval(val): Children {
} }
function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children { function renderStagingSpv(task: StageTask, queueFunc, cancelFunc): Children {
function keydown(e): void { function keydown(e: KeyboardEvent): void {
if (e.keyCode === 13) queueFunc(); if (e.key === "Enter") queueFunc();
else if (e.keyCode === 27) cancelFunc(); else if (e.key === "Escape") cancelFunc();
else e.redraw = false; else e["redraw"] = false;
} }
let input; let input;
+1 -1
View File
@@ -51,7 +51,7 @@ export function loadCodeMirror(): Promise<void> {
]; ];
Promise.all(promises) Promise.all(promises)
.then((modules) => { .then((modules) => {
codeMirror = modules[0]; codeMirror = modules[0].default;
resolve(); resolve();
}) })
.catch((err) => { .catch((err) => {
+9 -4
View File
@@ -221,8 +221,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new file", title: "Create new file",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -251,8 +251,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"
+6 -2
View File
@@ -44,8 +44,12 @@ const getAutocomplete = memoize((resource) => {
function validateQuery(q: Expression): void { function validateQuery(q: Expression): void {
q = normalize(q); q = normalize(q);
if (Array.isArray(q) && q[0] === "CASE") q = ["AND", ...q.slice(1)]; if (Array.isArray(q) && q[0] === "CASE") {
filterToMongoQuery(q); q = q.slice(1).filter((a) => Array.isArray(a));
if (q.length > 1) q = ["AND", ...q];
else q = q[0];
}
if (Array.isArray(q)) filterToMongoQuery(q);
} }
function parseFilter(resource, f): Expression { function parseFilter(resource, f): Expression {
+14 -5
View File
@@ -20,16 +20,24 @@
import m, { Children } from "mithril"; import m, { Children } from "mithril";
type OverlayCallback = () => Children; type OverlayCallback = () => Children;
type CloseCallback = () => boolean;
let overlayCallback: OverlayCallback = null; let overlayCallback: OverlayCallback = null;
let closeCallback: CloseCallback = null;
export function open(callback: OverlayCallback): void { export function open(
callback: OverlayCallback,
closeCb: CloseCallback = null
): void {
overlayCallback = callback; overlayCallback = callback;
closeCallback = closeCb;
} }
export function close(callback: OverlayCallback): boolean { export function close(callback: OverlayCallback, force = true): boolean {
if (callback === overlayCallback) { if (callback === overlayCallback) {
if (!force && closeCallback && !closeCallback()) return false;
overlayCallback = null; overlayCallback = null;
closeCallback = null;
return true; return true;
} }
@@ -43,7 +51,7 @@ export function render(): Children {
{ {
tabindex: 0, tabindex: 0,
onclick: () => { onclick: () => {
close(overlayCallback); close(overlayCallback, false);
}, },
style: "opacity: 0", style: "opacity: 0",
oncreate: (vnode) => { oncreate: (vnode) => {
@@ -75,9 +83,10 @@ export function render(): Children {
} }
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (overlayCallback && e.keyCode === 27 && close(overlayCallback)) m.redraw(); if (overlayCallback && e.key === "Escape" && close(overlayCallback, false))
m.redraw();
}); });
window.addEventListener("popstate", () => { window.addEventListener("popstate", () => {
if (close(overlayCallback)) m.redraw(); if (close(overlayCallback, false)) m.redraw();
}); });
+9 -4
View File
@@ -285,8 +285,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new permission", title: "Create new permission",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -315,8 +315,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"
+26 -15
View File
@@ -287,16 +287,8 @@ export const component: ClosureComponent = (): Component => {
"a", "a",
{ {
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
if (!preset.provision) { const comp = m(
return m(
"div",
{ style: "margin:20px" },
"This UI only supports presets with a single 'provision' configuraiton. If this preset was originally created from the old UI (genieacs-gui), you must edit it there."
);
}
return m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -326,8 +318,22 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
cb = (): Children => {
if (!preset.provision) {
return m(
"div",
{ style: "margin:20px" },
"This UI only supports presets with a single 'provision' configuraiton. If this preset was originally created from the old UI (genieacs-gui), you must edit it there."
);
}
return comp;
}; };
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"Show" "Show"
@@ -343,8 +349,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new preset", title: "Create new preset",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -373,8 +379,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"
+18 -8
View File
@@ -207,8 +207,8 @@ export const component: ClosureComponent = (): Component => {
"a", "a",
{ {
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -238,8 +238,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"Show" "Show"
@@ -255,8 +260,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new provision", title: "Create new provision",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -285,8 +290,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"
+7
View File
@@ -56,6 +56,7 @@ function createField(current, attr, focus): Children {
: null, : null,
onchange: (e) => { onchange: (e) => {
current.object[attr.id] = e.target.value; current.object[attr.id] = e.target.value;
current.modified = true;
e.redraw = false; e.redraw = false;
}, },
}, },
@@ -80,6 +81,7 @@ function createField(current, attr, focus): Children {
if (e.target.checked) currentSelected.add(op); if (e.target.checked) currentSelected.add(op);
else currentSelected.delete(op); else currentSelected.delete(op);
current.object[attr.id] = Array.from(currentSelected); current.object[attr.id] = Array.from(currentSelected);
current.modified = true;
e.redraw = false; e.redraw = false;
}, },
}; };
@@ -98,6 +100,7 @@ function createField(current, attr, focus): Children {
}, },
onChange: (value) => { onChange: (value) => {
current.object[attr.id] = value; current.object[attr.id] = value;
current.modified = true;
}, },
}; };
return m(codeEditorComponent, attrs); return m(codeEditorComponent, attrs);
@@ -112,6 +115,7 @@ function createField(current, attr, focus): Children {
: null, : null,
onchange: (e) => { onchange: (e) => {
current.object[attr.id] = e.target.files; current.object[attr.id] = e.target.files;
current.modified = true;
e.redraw = false; e.redraw = false;
}, },
}); });
@@ -132,6 +136,7 @@ function createField(current, attr, focus): Children {
: null, : null,
oninput: (e) => { oninput: (e) => {
current.object[attr.id] = e.target.value; current.object[attr.id] = e.target.value;
current.modified = true;
e.redraw = false; e.redraw = false;
}, },
onkeypress: (e) => { onkeypress: (e) => {
@@ -158,6 +163,7 @@ function createField(current, attr, focus): Children {
: null, : null,
oninput: (e) => { oninput: (e) => {
current.object[attr.id] = e.target.value; current.object[attr.id] = e.target.value;
current.modified = true;
e.redraw = false; e.redraw = false;
}, },
}); });
@@ -174,6 +180,7 @@ const component: ClosureComponent = (): Component => {
vnode.state["current"] = { vnode.state["current"] = {
isNew: !base["_id"], isNew: !base["_id"],
object: Object.assign({}, base), object: Object.assign({}, base),
modified: false,
}; };
} }
+2 -1
View File
@@ -19,6 +19,7 @@
import config from "./config"; import config from "./config";
import { Expression } from "../lib/types"; import { Expression } from "../lib/types";
import { encodeTag } from "../lib/common";
const resources = { const resources = {
devices: {}, devices: {},
@@ -138,7 +139,7 @@ function queryMac(param, value): Expression {
} }
function queryTag(tag: string): Expression { function queryTag(tag: string): Expression {
const t = tag.replace(/[^a-zA-Z0-9-]+/g, "_"); const t = encodeTag(tag);
return ["IS NOT NULL", ["PARAM", `Tags.${t}`]]; return ["IS NOT NULL", ["PARAM", `Tags.${t}`]];
} }
+8 -3
View File
@@ -173,10 +173,11 @@ export async function xhrRequest(
// https://mithril.js.org/request.html#error-handling // https://mithril.js.org/request.html#error-handling
if (xhr.status !== 304 && Math.floor(xhr.status / 100) !== 2) { if (xhr.status !== 304 && Math.floor(xhr.status / 100) !== 2) {
if (xhr.status === 403) throw new Error("Not authorized");
const err = new Error(); const err = new Error();
err["message"] = err["message"] =
xhr.status === 0 xhr.status === 0
? "Server is unrachable" ? "Server is unreachable"
: `Unexpected response status code ${xhr.status}`; : `Unexpected response status code ${xhr.status}`;
err["code"] = xhr.status; err["code"] = xhr.status;
err["response"] = xhr.responseText; err["response"] = xhr.responseText;
@@ -385,8 +386,9 @@ function _fulfill(
filter: memoizedStringify(filter), filter: memoizedStringify(filter),
}), }),
extract: (xhr) => { extract: (xhr) => {
if (xhr.status === 403) throw new Error("Not authorized");
if (!xhr.status) { if (!xhr.status) {
throw new Error("Server is unrachable"); throw new Error("Server is unreachable");
} else if (xhr.status !== 200) { } else if (xhr.status !== 200) {
throw new Error( throw new Error(
`Unexpected response status code ${xhr.status}` `Unexpected response status code ${xhr.status}`
@@ -605,6 +607,8 @@ export function postTasks(
url: `api/devices/${encodeURIComponent(deviceId)}/tasks`, url: `api/devices/${encodeURIComponent(deviceId)}/tasks`,
body: tasks, body: tasks,
extract: (xhr) => { extract: (xhr) => {
if (xhr.status === 403) throw new Error("Not authorized");
if (!xhr.status) throw new Error("Server is unreachable");
if (xhr.status !== 200) throw new Error(xhr.response); if (xhr.status !== 200) throw new Error(xhr.response);
const connectionRequestStatus = xhr.getResponseHeader( const connectionRequestStatus = xhr.getResponseHeader(
"Connection-Request" "Connection-Request"
@@ -675,7 +679,8 @@ export function resourceExists(resource: string, id: string): Promise<number> {
filter: memoizedStringify(filter), filter: memoizedStringify(filter),
}), }),
extract: (xhr) => { extract: (xhr) => {
if (!xhr.status) throw new Error("Server is unrachable"); if (xhr.status === 403) throw new Error("Not authorized");
if (!xhr.status) throw new Error("Server is unreachable");
else if (xhr.status !== 200) else if (xhr.status !== 200)
throw new Error(`Unexpected response status code ${xhr.status}`); throw new Error(`Unexpected response status code ${xhr.status}`);
return +xhr.getResponseHeader("x-total-count"); return +xhr.getResponseHeader("x-total-count");
+2 -6
View File
@@ -102,12 +102,7 @@ const component: ClosureComponent = (): Component => {
const yamlString = const yamlString =
config && Object.values(config).length config && Object.values(config).length
? yaml.stringify(config, { ? yaml.stringify(config, { schema: "failsafe" })
schema: "failsafe",
tags: (args) => {
return args;
},
})
: ""; : "";
const attrs = { const attrs = {
@@ -120,6 +115,7 @@ const component: ClosureComponent = (): Component => {
}, },
onChange: (value) => { onChange: (value) => {
vnode.state["updatedYaml"] = value; vnode.state["updatedYaml"] = value;
vnode.state["modified"] = true;
}, },
}; };
+21 -10
View File
@@ -237,9 +237,8 @@ export const component: ClosureComponent = (): Component => {
"a", "a",
{ {
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
const children = [ const comp = m(
m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -274,9 +273,10 @@ export const component: ClosureComponent = (): Component => {
attributes: attributes, attributes: attributes,
} }
) )
), );
];
cb = () => {
const children: Children = [comp];
if (canWrite) { if (canWrite) {
children.push(m("hr")); children.push(m("hr"));
const _attrs = { const _attrs = {
@@ -292,7 +292,13 @@ export const component: ClosureComponent = (): Component => {
return children; return children;
}; };
overlay.open(cb);
overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"Show" "Show"
@@ -317,8 +323,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new user", title: "Create new user",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -347,8 +353,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"
+18 -8
View File
@@ -207,8 +207,8 @@ export const component: ClosureComponent = (): Component => {
"a", "a",
{ {
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -238,8 +238,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"Show" "Show"
@@ -255,8 +260,8 @@ export const component: ClosureComponent = (): Component => {
{ {
title: "Create new virtual parameter", title: "Create new virtual parameter",
onclick: () => { onclick: () => {
const cb = (): Children => { let cb: () => Children = null;
return m( const comp = m(
putFormComponent, putFormComponent,
Object.assign( Object.assign(
{ {
@@ -285,8 +290,13 @@ export const component: ClosureComponent = (): Component => {
formData formData
) )
); );
}; cb = () => comp;
overlay.open(cb); overlay.open(
cb,
() =>
!comp.state["current"]["modified"] ||
confirm("You have unsaved changes. Close anyway?")
);
}, },
}, },
"New" "New"