Compare commits

..
34 Commits
Author SHA1 Message Date
Zaid Humaimidi 38e9dfd0e6 1.2.16 2026-03-29 13:59:06 -07:00
Zaid Humaimidi b6102bddbc Changelog for v1.2.16 2026-03-29 13:58:40 -07:00
Zaid Humaimidi e22b0c499e Update npm-shrinkwrap.json 2026-03-29 13:35:03 -07:00
Zaid Humaimidi 969dfaf943 Revert "Fix device ID parsing regression in Mithril 2.3.8"
This reverts commit f9ee39dfd0.
2026-03-29 12:58:36 -07:00
Zaid Humaimidi fb7e973d8b Revert Mithril to v2.2.2
Revert Mithril from v2.3.8 back to v2.2.2 (used in v1.2.13) to fix
regression bugs in the latest version that have not been resolved.
2026-03-29 12:57:58 -07:00
Zaid Humaimidi 944a690d96 1.2.15 2026-03-26 14:12:26 -07:00
Zaid Humaimidi e7facc3bba Changelog for v1.2.15 2026-03-26 14:10:18 -07:00
Zaid Humaimidi aa83b62064 Update dependencies 2026-03-26 13:50:37 -07:00
Zaid Humaimidi f9ee39dfd0 Fix device ID parsing regression in Mithril 2.3.8
Device IDs containing literal % characters (e.g., "202BC1-CODA%2D5519-000000")
are double-encoded in URLs. Mithril 2.3.8 incorrectly double-decodes route
parameters, corrupting device IDs with special characters.

Workaround: Extract device ID directly from window.location.hash and decode
once to get the correct value.

See: https://github.com/MithrilJS/mithril.js/issues/3064
2026-03-26 13:20:19 -07:00
Zaid Humaimidi 087b95388f Fix RCE vulnerability in /api/ping endpoint
This addresses a follow-up to CVE-2021-46704 where the host validation
could be bypassed via domainToASCII stripping path components.

Changes:
- Normalize host with domainToASCII before validation
- Add round-trip check with domainToUnicode to catch path injection
- Switch from exec to execFile to prevent shell injection
- Add -- separator to ping arguments

Credit: JuHwiSang for reporting this vulnerability
2026-03-25 15:00:00 -07:00
Zaid Humaimidi cc723c0b50 1.2.14 2026-03-12 14:26:22 -07:00
Zaid Humaimidi a2a6fd82f3 Changelog for v1.2.14 2026-03-12 14:25:25 -07:00
Zaid Humaimidi 729beba0ed Update dependencies 2026-03-12 12:38:03 -07:00
Zaid Humaimidi 1b14387aa2 Fix covers() for false and contradictions 2026-03-12 10:21:03 -07:00
Zaid Humaimidi 49cdd39617 Fix (de)serialization bug in VersionedMap 2025-02-12 19:05:08 -08:00
Zaid Humaimidi 4be39ed742 Replace deprecated koa middleware 2024-10-04 11:28:04 -07:00
Zaid Humaimidi 8ddb692370 Prevent UI crash when malformed URL is sent 2024-10-04 11:05:07 -07:00
Zaid Humaimidi fc80a7d487 1.2.13 2024-06-06 12:55:38 -07:00
Zaid Humaimidi f34f10edc7 Changelog for v1.2.13 2024-06-06 12:55:12 -07:00
Zaid Humaimidi a416f68b76 Update dependencies 2024-06-06 12:52:53 -07:00
Zaid Humaimidi 2b61c8f7b8 Fix race condition when deleting multiple faults 2024-05-23 13:22:35 -07:00
Zaid Humaimidi 756912eeb1 Fix dynamic evaluation of UI config expressions 2024-04-22 14:14:12 -07:00
Zaid Humaimidi 5c4fc3263b Increase server timeout to 2 mins
To allow enough time for running unindexed queries in large deployments.
2024-04-18 12:16:44 -07:00
Zaid Humaimidi 307b9f57b0 Fix bug in Mongo query construction 2024-04-18 11:51:29 -07:00
Zaid Humaimidi 0f6604ffbd 1.2.12 2024-03-28 12:56:35 -07:00
Zaid Humaimidi f57a26c95b Changelog for v1.2.12 2024-03-28 12:04:31 -07:00
Zaid Humaimidi 60e6d8b175 Fix regression in CSV and JSON streaming 2024-03-27 14:08:35 -07:00
Zaid Humaimidi ab80a62c5c Fix bug preventing XMPP client from connecting 2024-03-27 13:57:09 -07:00
Zaid Humaimidi 3134b63b68 Add requirements.txt for RTD 2024-03-21 13:06:33 -07:00
Zaid Humaimidi 2b1bca7dde Add .readthedocs.yaml file 2024-03-21 12:27:40 -07:00
Zaid Humaimidi fd046ea8e3 1.2.11 2024-03-21 12:03:24 -07:00
Zaid Humaimidi 8091907071 Changelog for v1.2.11 2024-03-21 11:32:43 -07:00
Zaid Humaimidi 872aa1c608 Update dependencies 2024-03-21 11:29:32 -07:00
Zaid Humaimidi cdbfb172c7 Remove certificate validation check
Breaks compatibility with Node 12 and it's not really necessary anyway.
2024-03-20 17:11:14 -07:00
31 changed files with 3397 additions and 3122 deletions

No files matched your search

+46
View File
@@ -1,5 +1,51 @@
# Change Log # Change Log
## 1.2.16 (2026-03-29)
- Fix regression in v1.2.15 that broke the device search component in the UI by
pinning Mithril dependency to v2.2.2 due to multiple unresolved regressions in
later versions.
## 1.2.15 (2026-03-26)
- Fix critical RCE vulnerability in /api/ping endpoint (follow-up to
CVE-2021-46704). Credit: JuHwiSang.
- Fix device IDs containing special characters (e.g., %) not being handled
correctly in the UI due to a regression in Mithril 2.3.8.
## 1.2.14 (2026-03-12)
- Prevent UI crash when a malformed URL is sent to the server.
- Fix potential edge-case bugs in expression evaluation and session
serialization.
## 1.2.13 (2024-06-06)
- Increase connection timeout for UI and NBI from 30 to 120 seconds to avoid
timeouts when running unindexed queries in large deployments.
- Fix race condition causing 503 error when deleting multiple faults at once.
- Fix some UI config options not being evaluated as dynamic expressions.
- Fix an issue where certain edge-case query expressions were not being
correctly converted to MongoDB queries, resulting in inaccurate search
results.
## 1.2.12 (2024-03-28)
- Fix broken XMPP support in the previous release.
- Fix regression causing CSV downloads to be buffered in memory before being
streamed to the client.
## 1.2.11 (2024-03-21)
- Resolved an issue from the previous release that caused incompatibility with
Node.js versions 12 through 15.
## 1.2.10 (2024-03-18) ## 1.2.10 (2024-03-18)
- Add support for XMPP connection requests. Use the environment variables - Add support for XMPP connection requests. Use the environment variables
+1 -1
View File
@@ -64,7 +64,7 @@ if (!cluster.worker) {
port: SERVICE_PORT, port: SERVICE_PORT,
host: SERVICE_ADDRESS, host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null, ssl: key && cert ? { key, cert } : null,
timeout: 30000, timeout: 120000,
}; };
// Need this for Node < 15 // Need this for Node < 15
+1 -1
View File
@@ -64,7 +64,7 @@ if (!cluster.worker) {
port: SERVICE_PORT, port: SERVICE_PORT,
host: SERVICE_ADDRESS, host: SERVICE_ADDRESS,
ssl: key && cert ? { key, cert } : null, ssl: key && cert ? { key, cert } : null,
timeout: 30000, timeout: 120000,
}; };
// Need this for Node < 15 // Need this for Node < 15
+2
View File
@@ -176,3 +176,5 @@ NaN
CIDR CIDR
XMPP XMPP
PEM PEM
JID
unindexed
+18
View File
@@ -0,0 +1,18 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-22.04
tools:
python: "3.12"
sphinx:
configuration: docs/conf.py
formats: all
python:
install:
- requirements: docs/requirements.txt
+2 -2
View File
@@ -19,7 +19,7 @@ import json
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = 'GenieACS Documentation' project = 'GenieACS Documentation'
copyright = '2019, GenieACS Inc.' copyright = '2024, GenieACS Inc.'
author = 'GenieACS Inc.' author = 'GenieACS Inc.'
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
@@ -58,7 +58,7 @@ html_theme_options = {
# Add any paths that contain custom static files (such as style sheets) here, # Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static'] # html_static_path = ['_static']
master_doc = 'index' master_doc = 'index'
+1
View File
@@ -0,0 +1 @@
sphinx_rtd_theme==2.0.0
+4 -4
View File
@@ -2,13 +2,13 @@ import { PermissionSet, Expression } from "../types.ts";
import { evaluate, or } from "./expression/util.ts"; import { evaluate, or } from "./expression/util.ts";
export default class Authorizer { export default class Authorizer {
private declare permissionSets: PermissionSet[]; declare private permissionSets: PermissionSet[];
private declare validatorCache: WeakMap< declare private validatorCache: WeakMap<
any, any,
(mutationType, mutation, any) => boolean (mutationType, mutation, any) => boolean
>; >;
private declare hasAccessCache: Map<string, boolean>; declare private hasAccessCache: Map<string, boolean>;
private declare getFilterCache: Map<string, Expression>; declare private getFilterCache: Map<string, Expression>;
public constructor(permissionSets: PermissionSet[]) { public constructor(permissionSets: PermissionSet[]) {
this.permissionSets = permissionSets; this.permissionSets = permissionSets;
+3 -3
View File
@@ -9,8 +9,8 @@ const TWO = BI.BigInt(2);
const NEGATIVE_ONE = BI.BigInt(-1); const NEGATIVE_ONE = BI.BigInt(-1);
class Indeterminates { class Indeterminates {
public declare map: Map<string, number>; declare public map: Map<string, number>;
public declare sortedKeys: string[]; declare public sortedKeys: string[];
public constructor(str?: string) { public constructor(str?: string) {
this.map = new Map(); this.map = new Map();
@@ -98,7 +98,7 @@ function findGcd(a: BI.bigint, b: BI.bigint): BI.bigint {
} }
class Polynomial { class Polynomial {
public declare terms: Term[]; declare public terms: Term[];
public constructor(terms: Term[]) { public constructor(terms: Term[]) {
this.terms = terms; this.terms = terms;
+1 -1
View File
@@ -955,7 +955,7 @@ export function covers(expr1: Expression, expr2: Expression): boolean {
expr2 = normalize(expr2); expr2 = normalize(expr2);
if (!expr2) return true; if (!expr2) return true;
expr1 = normalize(expr1); expr1 = normalize(expr1);
if (!Array.isArray(expr1)) return !!expr1; if (!Array.isArray(expr1) && expr1) return true;
const synt1 = Clause.fromExpression(expr1); const synt1 = Clause.fromExpression(expr1);
const synt2 = Clause.fromExpression(expr2); const synt2 = Clause.fromExpression(expr2);
+3 -3
View File
@@ -1,9 +1,9 @@
import Path from "./path.ts"; import Path from "./path.ts";
export default class PathSet { export default class PathSet {
private declare lengthIndex: Set<Path>[]; declare private lengthIndex: Set<Path>[];
private declare fragmentIndex: Map<string, Set<Path>>[]; declare private fragmentIndex: Map<string, Set<Path>>[];
private declare stringIndex: Map<string, Path>; declare private stringIndex: Map<string, Path>;
public constructor() { public constructor() {
this.lengthIndex = []; this.lengthIndex = [];
+5 -5
View File
@@ -30,11 +30,11 @@ function legalChar(c: number): boolean {
} }
export default class Path { export default class Path {
public declare readonly segments: Segments; declare public readonly segments: Segments;
public declare readonly wildcard: number; declare public readonly wildcard: number;
public declare readonly alias: number; declare public readonly alias: number;
protected declare _string: string; declare protected _string: string;
protected declare _stringIndex: number[]; declare protected _stringIndex: number[];
protected static parseAliasValue( protected static parseAliasValue(
pattern: string, pattern: string,
+19 -7
View File
@@ -578,6 +578,8 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
toQuery(minterms: Minterm[]): Filter<unknown> { toQuery(minterms: Minterm[]): Filter<unknown> {
const or = []; const or = [];
const ejsonOps = ["$oid", "$date", "$regularExpression"];
for (const minterm of minterms) { for (const minterm of minterms) {
const query = {}; const query = {};
loop: for (const clause of minterm) { loop: for (const clause of minterm) {
@@ -588,8 +590,6 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
if (Object.keys(q).length !== 1) if (Object.keys(q).length !== 1)
throw new Error("Invalid query expression"); throw new Error("Invalid query expression");
const [param, value] = Object.entries(q)[0]; const [param, value] = Object.entries(q)[0];
if (Object.getPrototypeOf(value).constructor !== Object)
throw new Error("Invalid query expression");
const dests = [query, ...(query["$and"] ?? [])]; const dests = [query, ...(query["$and"] ?? [])];
for (const dest of dests) { for (const dest of dests) {
if (!(param in dest)) { if (!(param in dest)) {
@@ -599,16 +599,28 @@ class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
let src = value; let src = value;
let dst = dest[param]; let dst = dest[param];
if (Object.getPrototypeOf(src).constructor !== Object) continue;
if (Object.getPrototypeOf(dst).constructor !== Object) continue;
let srcKeys = Object.keys(src);
let dstKeys = Object.keys(dst);
if (src["$not"] && dst["$not"]) { if ([...srcKeys, ...dstKeys].every((k) => k === "$not")) {
src = src["$not"]; src = src["$not"];
dst = dst["$not"]; dst = dst["$not"];
if (Object.getPrototypeOf(src).constructor !== Object) continue;
if (Object.getPrototypeOf(dst).constructor !== Object) continue;
srcKeys = Object.keys(src);
dstKeys = Object.keys(dst);
} }
if (!Object.keys(src).some((k) => k in dst)) { if (srcKeys.some((k) => dstKeys.includes(k))) continue;
Object.assign(dst, src);
continue loop; // Don't mix regular operators with EJSON special operators
} if (srcKeys.some((k) => ejsonOps.includes(k))) continue;
if (dstKeys.some((k) => ejsonOps.includes(k))) continue;
Object.assign(dst, src);
continue loop;
} }
query["$and"] ??= []; query["$and"] ??= [];
+1 -1
View File
@@ -3,7 +3,7 @@ interface Instance {
} }
export default class InstanceSet { export default class InstanceSet {
private declare set: Set<Instance>; declare private set: Set<Instance>;
public constructor() { public constructor() {
this.set = new Set(); this.set = new Set();
+16 -7
View File
@@ -1,6 +1,6 @@
import { platform } from "node:os"; import { platform } from "node:os";
import { exec } from "node:child_process"; import { execFile } from "node:child_process";
import { domainToASCII } from "node:url"; import { domainToASCII, domainToUnicode } from "node:url";
export interface PingResult { export interface PingResult {
packetsTransmitted: number; packetsTransmitted: number;
@@ -16,9 +16,16 @@ function isValidHost(host: string): boolean {
// Valid chars in IPv4, IPv6, domain names // Valid chars in IPv4, IPv6, domain names
if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true; if (/^[a-zA-Z0-9\-.:[\]-]+$/.test(host)) return true;
const lowercase = host.toLowerCase();
const ascii = domainToASCII(lowercase);
// Check if input is an IDN convert to Punycode // Check if input is an IDN convert to Punycode
// Can't merge with above because domainToASCII doesn't accept IP addresses // Can't merge with above because domainToASCII doesn't accept IP addresses
return /^[a-zA-Z0-9\-.:[\]-]+$/.test(domainToASCII(host)); if (!/^[a-zA-Z0-9\-.:[\]-]+$/.test(ascii)) return false;
// Round-trip to ensure the original host matches its normalized form.
// This prevents bypasses where domainToASCII strips paths (e.g. "example.com/;cmd").
return lowercase === domainToUnicode(ascii);
} }
export function parsePing(osPlatform: string, stdout: string): PingResult { export function parsePing(osPlatform: string, stdout: string): PingResult {
@@ -73,24 +80,26 @@ export function ping(
): void { ): void {
// Validate input to prevent possible remote code execution // Validate input to prevent possible remote code execution
// Credit to Alex Hordijk for reporting this vulnerability // Credit to Alex Hordijk for reporting this vulnerability
// Credit to JuHwiSang for reporting a follow-up vulnerability
if (!isValidHost(host)) return callback(new Error("Invalid host")); if (!isValidHost(host)) return callback(new Error("Invalid host"));
host = host.replace("[", "").replace("]", ""); host = host.replace("[", "").replace("]", "");
let cmd: string; let args: string[];
switch (platform()) { switch (platform()) {
case "linux": case "linux":
cmd = `ping -w 1 -i 0.2 -c 3 ${host}`; args = ["-w", "1", "-i", "0.2", "-c", "3", "--", host];
break; break;
case "freebsd": case "freebsd":
// Send a single packet because on FreeBSD only superuser can send // Send a single packet because on FreeBSD only superuser can send
// packets that are only 200 ms apart. // packets that are only 200 ms apart.
cmd = `ping -t 1 -c 3 ${host}`; args = ["-t", "1", "-c", "3", "--", host];
break; break;
default: default:
return callback(new Error("Platform not supported")); return callback(new Error("Platform not supported"));
} }
exec(cmd, (err, stdout) => { // Use execFile instead of exec to avoid shell interpretation of the host
execFile("ping", args, (err, stdout) => {
if (err) return callback(err); if (err) return callback(err);
const parsed: PingResult = parsePing(platform(), stdout); const parsed: PingResult = parsePing(platform(), stdout);
return callback(err, parsed, stdout); return callback(err, parsed, stdout);
-15
View File
@@ -3,7 +3,6 @@ import * as http from "node:http";
import * as https from "node:https"; import * as https from "node:https";
import { Socket } from "node:net"; import { Socket } from "node:net";
import * as path from "node:path"; import * as path from "node:path";
import { X509Certificate, createPrivateKey } from "node:crypto";
import { ROOT_DIR } from "./config.ts"; import { ROOT_DIR } from "./config.ts";
let server: http.Server | https.Server; let server: http.Server | https.Server;
@@ -67,13 +66,6 @@ function getValidPrivKeys(value: string): Buffer[] {
const buf = str.startsWith("-----BEGIN ") const buf = str.startsWith("-----BEGIN ")
? Buffer.from(str) ? Buffer.from(str)
: readFileSync(path.resolve(ROOT_DIR, str)); : readFileSync(path.resolve(ROOT_DIR, str));
try {
createPrivateKey(buf);
} catch (err) {
throw new Error(`Invalid private key: ${err.message}`);
}
return buf; return buf;
}); });
} }
@@ -84,13 +76,6 @@ function getValidCerts(value: string): Buffer[] {
const buf = str.startsWith("-----BEGIN ") const buf = str.startsWith("-----BEGIN ")
? Buffer.from(str) ? Buffer.from(str)
: readFileSync(path.resolve(ROOT_DIR, str)); : readFileSync(path.resolve(ROOT_DIR, str));
try {
new X509Certificate(buf);
} catch (err) {
throw new Error(`Invalid certificate: ${err.message}`);
}
return buf; return buf;
}); });
} }
+15 -10
View File
@@ -1,10 +1,10 @@
import { constants } from "node:zlib"; import { constants } from "node: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";
import koaStatic from "koa-static"; import koaSend from "koa-send";
import koaCompress from "koa-compress"; import koaCompress from "koa-compress";
import koaBodyParser from "koa-bodyparser"; import koaBodyParser from "@koa/bodyparser";
import koaJwt from "koa-jwt"; import koaJwt from "koa-jwt";
import * as config from "./config.ts"; import * as config from "./config.ts";
import api from "./ui/api.ts"; import api from "./ui/api.ts";
@@ -18,12 +18,6 @@ import { version as VERSION } from "../package.json";
import memoize from "./common/memoize.ts"; import memoize from "./common/memoize.ts";
import { APP_JS, APP_CSS, FAVICON_PNG } from "../build/assets.ts"; import { APP_JS, APP_CSS, FAVICON_PNG } from "../build/assets.ts";
declare module "koa" {
interface Request {
body: any;
}
}
const koa = new Koa(); const koa = new Koa();
const router = new Router(); const router = new Router();
@@ -262,6 +256,17 @@ koa.use(
); );
koa.use(router.routes()); koa.use(router.routes());
koa.use(koaStatic(config.ROOT_DIR + "/public")); koa.use(async (ctx, next) => {
await next();
if (ctx.method !== "HEAD" && ctx.method !== "GET") return;
if (ctx.body != null || ctx.status !== 404) return;
if (/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(ctx.path)) return;
try {
await koaSend(ctx, ctx.path, { root: config.ROOT_DIR + "/public" });
} catch (err) {
if (err.status !== 404) throw err;
}
});
export const listener = koa.callback(); export const listener = koa.callback();
+67 -65
View File
@@ -1,5 +1,5 @@
import { PassThrough } from "node:stream"; import { Readable } from "node:stream";
import Router from "koa-router"; import Router from "@koa/router";
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import * as db from "./db.ts"; import * as db from "./db.ts";
import * as apiFunctions from "../api-functions.ts"; import * as apiFunctions from "../api-functions.ts";
@@ -88,10 +88,9 @@ router.get(`/devices/:id.csv`, async (ctx) => {
.replace(/[:.]/g, "")}.csv`, .replace(/[:.]/g, "")}.csv`,
); );
ctx.body = new PassThrough(); const lines: string[] = [
ctx.body.write( "Parameter,Object,Object timestamp,Writable,Writable timestamp,Value,Value type,Value timestamp,Notification,Notification timestamp,Access list,Access list timestamp",
"Parameter,Object,Object timestamp,Writable,Writable timestamp,Value,Value type,Value timestamp,Notification,Notification timestamp,Access list,Access list timestamp\n", ];
);
for (const k of Object.keys(device).sort()) { for (const k of Object.keys(device).sort()) {
const p = device[k]; const p = device[k];
@@ -118,9 +117,9 @@ router.get(`/devices/:id.csv`, async (ctx) => {
p.accessList ? p.accessList.join(", ") : "", p.accessList ? p.accessList.join(", ") : "",
new Date(p.accessListTimestamp).toJSON(), new Date(p.accessListTimestamp).toJSON(),
]; ];
ctx.body.write(row.map((r) => (r != null ? r : "")).join(",") + "\n"); lines.push(row.map((r) => (r != null ? r : "")).join(","));
} }
ctx.body.end(); ctx.body = lines.join("\n");
logger.accessInfo(log); logger.accessInfo(log);
}); });
@@ -201,16 +200,17 @@ for (const [resource, flags] of Object.entries(resources)) {
]); ]);
} }
ctx.body = new PassThrough();
ctx.type = "application/json";
let c = 0;
ctx.body.write("[\n");
for await (const obj of db.query(resource, filter, options))
ctx.body.write((c++ ? "," : "") + JSON.stringify(obj) + "\n");
ctx.body.end("]");
logger.accessInfo(log); logger.accessInfo(log);
ctx.type = "application/json";
ctx.body = Readable.from(
(async function* () {
let c = 0;
yield "[\n";
for await (const obj of db.query(resource, filter, options))
yield (c++ ? "," : "") + JSON.stringify(obj) + "\n";
yield "]";
})(),
);
}); });
// CSV download // CSV download
@@ -260,60 +260,62 @@ for (const [resource, flags] of Object.entries(resources)) {
]); ]);
} }
ctx.body = new PassThrough(); logger.accessInfo(log);
ctx.type = "text/csv"; ctx.type = "text/csv";
ctx.attachment( ctx.attachment(
`${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv`, `${resource}-${new Date(now).toISOString().replace(/[:.]/g, "")}.csv`,
); );
ctx.body.write( ctx.body = Readable.from(
Object.keys(columns).map((k) => `"${k.replace(/"/, '""')}"`) + "\n", (async function* () {
yield Object.keys(columns).map((k) => `"${k.replace(/"/, '""')}"`) +
"\n";
for await (const obj of db.query(resource, filter, options)) {
const arr = Object.values(columns).map((exp) => {
const v = evaluate(exp, obj, null, (e) => {
if (Array.isArray(e)) {
if (e[0] === "PARAM") {
if (resource === "devices") {
if (e[1] === "Tags") {
const tags = [];
for (const p in obj)
if (p.startsWith("Tags."))
tags.push(decodeTag(p.slice(5)));
return tags.join(", ");
}
if (e === exp) {
const p = obj[e[1]];
if (
p &&
p.value &&
p.value[1] === "xsd:dateTime" &&
typeof p.value[0] === "number"
)
return new Date(p.value[0]).toJSON();
}
} else if (resource === "faults") {
if (e[1] === "detail") return yamlStringify(obj["detail"]);
}
} else if (e[0] === "FUNC") {
if (e[1] === "DATE_STRING") {
if (e[2] && !Array.isArray(e[2]))
return new Date(e[2]).toJSON();
}
}
}
return e;
});
if (Array.isArray(v) || v == null) return "";
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
return v;
});
yield arr.join(",") + "\n";
}
})(),
); );
for await (const obj of db.query(resource, filter, options)) {
const arr = Object.values(columns).map((exp) => {
const v = evaluate(exp, obj, null, (e) => {
if (Array.isArray(e)) {
if (e[0] === "PARAM") {
if (resource === "devices") {
if (e[1] === "Tags") {
const tags = [];
for (const p in obj)
if (p.startsWith("Tags.")) tags.push(decodeTag(p.slice(5)));
return tags.join(", ");
}
if (e === exp) {
const p = obj[e[1]];
if (
p &&
p.value &&
p.value[1] === "xsd:dateTime" &&
typeof p.value[0] === "number"
)
return new Date(p.value[0]).toJSON();
}
} else if (resource === "faults") {
if (e[1] === "detail") return yamlStringify(obj["detail"]);
}
} else if (e[0] === "FUNC") {
if (e[1] === "DATE_STRING") {
if (e[2] && !Array.isArray(e[2]))
return new Date(e[2]).toJSON();
}
}
}
return e;
});
if (Array.isArray(v) || v == null) return "";
if (typeof v === "string") return `"${v.replace(/"/g, '""')}"`;
return v;
});
ctx.body.write(arr.join(",") + "\n");
}
ctx.body.end();
logger.accessInfo(log);
}); });
router.head(`/${resource}/:id`, async (ctx) => { router.head(`/${resource}/:id`, async (ctx) => {
+9 -5
View File
@@ -7,10 +7,10 @@ interface Revisions<V> {
} }
export default class VersionedMap<K, V> { export default class VersionedMap<K, V> {
private declare _sizeDiff: number[]; declare private _sizeDiff: number[];
private declare _revision: number; declare private _revision: number;
private declare map: Map<K, (V | symbol)[]>; declare private map: Map<K, (V | symbol)[]>;
public declare dirty: number; declare public dirty: number;
public constructor() { public constructor() {
this._sizeDiff = [0]; this._sizeDiff = [0];
@@ -112,6 +112,8 @@ export default class VersionedMap<K, V> {
prev = v; prev = v;
} }
if (prev === NONEXISTENT && !res.delete) return null;
return res; return res;
} }
@@ -122,6 +124,8 @@ export default class VersionedMap<K, V> {
del, del,
); );
if (!mutations) return;
const revisions = []; const revisions = [];
let prev: V | symbol = NONEXISTENT; let prev: V | symbol = NONEXISTENT;
@@ -149,7 +153,7 @@ export default class VersionedMap<K, V> {
public *diff(): IterableIterator<[K, V, V]> { public *diff(): IterableIterator<[K, V, V]> {
for (const [key, revisions] of this.map) { for (const [key, revisions] of this.map) {
if (revisions.length === 1) continue; if (revisions.length <= 1) continue;
let first = revisions[0]; let first = revisions[0];
let last = revisions[revisions.length - 1]; let last = revisions[revisions.length - 1];
if (first === NONEXISTENT && last === NONEXISTENT) continue; if (first === NONEXISTENT && last === NONEXISTENT) continue;
+36 -42
View File
@@ -321,50 +321,44 @@ export default class XmppClient extends EventEmitter {
this._iqStanzaCallbacks = new Map(); this._iqStanzaCallbacks = new Map();
} }
static connect(opts: XmppClientOptions): Promise<XmppClient> { static async connect(opts: XmppClientOptions): Promise<XmppClient> {
return new Promise((resolve, reject) => { function connectSocket(host: string, port: number): Promise<net.Socket> {
let socket = new net.Socket(); return new Promise((resolve, reject) => {
socket.on("error", reject); const socket = new net.Socket();
socket.on("error", reject);
socket.connect( socket.connect(port, host, () => {
opts.port || 5222,
opts.host,
void async function () {
socket.removeListener("error", reject); socket.removeListener("error", reject);
try { resolve(socket);
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(); let socket = await connectSocket(opts.host, opts.port || 5222);
client._socket = socket; try {
client._host = opts.host; let status = 1;
client._username = opts.username; while (status) {
client._resource = opts.resource; if (status === STATUS_STARTTLS)
socket.on("data", client._onData.bind(client)); socket = await upgradeTls(socket, opts.host);
socket.on("error", client._onError.bind(client)); status = await xmppStream(
if (opts.timeout) socket,
socket.setTimeout(opts.timeout, client.close.bind(client)); init(socket, opts.host, opts.username, opts.password, opts.resource),
resolve(client); );
} catch (err) { }
socket.destroy(); } catch (err) {
reject(err); socket.destroy();
} throw err;
}, }
);
}); 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));
return client;
} }
close(): void { close(): void {
+3038 -2896
View File
File diff suppressed because it is too large. Load diff
+24 -26
View File
@@ -1,6 +1,6 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.2.10", "version": "1.2.16",
"description": "A TR-069 Auto Configuration Server (ACS)", "description": "A TR-069 Auto Configuration Server (ACS)",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -26,44 +26,42 @@
}, },
"dependencies": { "dependencies": {
"@breejs/later": "^4.2.0", "@breejs/later": "^4.2.0",
"@koa/bodyparser": "^5.1.2",
"@koa/router": "^13.1.1",
"bson": "^4.7.2", "bson": "^4.7.2",
"espresso-iisojs": "^1.0.8", "espresso-iisojs": "^1.0.8",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"ipaddr.js": "^2.0.1", "ipaddr.js": "^2.3.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.3",
"koa": "^2.15.1", "koa": "^2.16.4",
"koa-bodyparser": "^4.3.0", "koa-compress": "^5.2.1",
"koa-compress": "^5.1.0",
"koa-jwt": "^4.0.3", "koa-jwt": "^4.0.3",
"koa-router": "^12.0.0", "koa-send": "^5.0.1",
"koa-static": "^5.0.0",
"mongodb": "^4.16.0", "mongodb": "^4.16.0",
"parsimmon": "^1.18.1", "parsimmon": "^1.18.1",
"seedrandom": "^3.0.5" "seedrandom": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/codemirror": "^5.60.15", "@types/codemirror": "^5.60.17",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.15.0", "@types/koa": "^2.15.0",
"@types/koa-compress": "^4.0.6", "@types/koa-compress": "^4.0.7",
"@types/koa-router": "^7.4.8",
"@types/koa-static": "^4.0.4",
"@types/mithril": "^2.2.6", "@types/mithril": "^2.2.6",
"@types/node": "^20.11.29", "@types/node": "^20.19.37",
"@types/parsimmon": "^1.10.9", "@types/parsimmon": "^1.10.9",
"@types/seedrandom": "^3.0.8", "@types/seedrandom": "^3.0.8",
"@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.3.1", "@typescript-eslint/parser": "^7.18.0",
"codemirror": "^5.65.16", "codemirror": "^5.65.21",
"esbuild": "^0.20.2", "esbuild": "^0.27.4",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.2",
"mithril": "^2.2.2", "mithril": "2.2.2",
"prettier": "^3.2.5", "prettier": "^3.8.1",
"sql.js": "^1.10.2", "sql.js": "^1.14.1",
"svgo": "^3.2.0", "svgo": "^3.3.3",
"typescript": "^5.4.2", "typescript": "^5.9.3",
"yaml": "^1.10.2" "yaml": "^1.10.3"
}, },
"engines": { "engines": {
"node": ">=12.13.0" "node": ">=12.13.0"
+35 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import initSqlJs from "sql.js/dist/sql-asm.js"; import initSqlJs from "sql.js/dist/sql-asm.js";
import { minimize, unionDiff } from "../lib/common/expression/synth.ts"; import { covers, minimize, unionDiff } from "../lib/common/expression/synth.ts";
import { parse, stringify } from "../lib/common/expression/parser.ts"; import { parse, stringify } from "../lib/common/expression/parser.ts";
const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"]; const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"];
@@ -115,3 +115,37 @@ void test("unionDiff", async () => {
assert.strictEqual(setsEqual(res4, diffSet), true); assert.strictEqual(setsEqual(res4, diffSet), true);
} }
}); });
void test("covers", async () => {
assert.strictEqual(covers(false, false), true);
assert.strictEqual(covers(false, parse("decimal > 5 AND decimal < 3")), true);
assert.strictEqual(covers(parse("true"), parse("decimal > 0")), true);
assert.strictEqual(covers(parse("true"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("false")), true);
assert.strictEqual(covers(parse("false"), parse("decimal > 0")), false);
assert.strictEqual(covers(parse("decimal >= 0"), parse("decimal > 0")), true);
assert.strictEqual(
covers(parse("decimal > 0"), parse("decimal >= 0")),
false,
);
const cases = [
["decimal >= 0", "decimal > 0"],
["decimal > 0", "decimal > 5"],
["string IS NOT NULL", "string = 'a'"],
["true", "decimal > 0"],
];
for (const [c1, c2] of cases) {
const res1 = await query(c1);
const res2 = await query(c2);
const coversResult = covers(parse(c1), parse(c2));
const actuallyCovers = Array.from(res2).every((r) => res1.has(r));
assert.strictEqual(
coversResult,
actuallyCovers,
`covers(${c1}, ${c2}) should match actual coverage`,
);
}
});
+2 -1
View File
@@ -11,7 +11,8 @@
"strictFunctionTypes": true, "strictFunctionTypes": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true "allowImportingTsExtensions": true,
"skipLibCheck": true
}, },
"include": [ "include": [
"./bin/*.ts", "./bin/*.ts",
+7 -7
View File
@@ -4,13 +4,13 @@ type AutocompleteCallback = (
) => void; ) => void;
export default class Autocomplete { export default class Autocomplete {
private declare callback: AutocompleteCallback; declare private callback: AutocompleteCallback;
private declare element: HTMLInputElement; declare private element: HTMLInputElement;
private declare hideTimeout: NodeJS.Timeout; declare private hideTimeout: NodeJS.Timeout;
private declare visible: boolean; declare private visible: boolean;
private declare default: string; declare private default: string;
private declare selection: number; declare private selection: number;
private declare container: HTMLElement; declare private container: HTMLElement;
public constructor(className: string, callback: AutocompleteCallback) { public constructor(className: string, callback: AutocompleteCallback) {
this.callback = callback; this.callback = callback;
+6 -2
View File
@@ -27,10 +27,14 @@ const component: ClosureComponent = (): Component => {
cx: "0.5em", cx: "0.5em",
cy: "0.5em", cy: "0.5em",
r: "0.4em", r: "0.4em",
fill: slice["color"], fill: evaluateExpression(slice["color"], null),
}), }),
); );
return m("span.overview-dot", dot, `${slice["label"]}`); return m(
"span.overview-dot",
dot,
evaluateExpression(slice["label"], null),
);
} }
} }
return null; return null;
+1 -1
View File
@@ -79,7 +79,7 @@ export function structureConfig(config: Config[]): any {
if (idx >= 0 && idx < MAX_ARRAY_SIZE && String(idx) === key) { if (idx >= 0 && idx < MAX_ARRAY_SIZE && String(idx) === key) {
const pos = Math.floor(idx / MAX_BITS); const pos = Math.floor(idx / MAX_BITS);
if (!indexes[pos]) indexes[pos] = 0; if (!indexes[pos]) indexes[pos] = 0;
indexes[pos] |= 1 << idx % MAX_BITS; indexes[pos] |= 1 << (idx % MAX_BITS);
} else { } else {
indexes = []; indexes = [];
break; break;
+5 -1
View File
@@ -39,7 +39,11 @@ export const component: ClosureComponent = (): Component => {
for (const c of Object.values(conf)) { for (const c of Object.values(conf)) {
cmps.push( cmps.push(
m.context({ device: dev.value[0], deviceQuery: dev }, c["type"], c), m.context(
{ device: dev.value[0], deviceQuery: dev },
store.evaluateExpression(c["type"], null) as string,
c,
),
); );
} }
+9 -2
View File
@@ -26,7 +26,10 @@ const memoizedGetSortable = memoize((p) => {
const getDownloadUrl = memoize((filter, indexParameters) => { const getDownloadUrl = memoize((filter, indexParameters) => {
const columns = {}; const columns = {};
for (const p of indexParameters) columns[p.label] = stringify(p.parameter); for (const p of indexParameters)
columns[store.evaluateExpression(p.label, null) as string] = stringify(
p.parameter,
);
return `api/devices.csv?${m.buildQueryString({ return `api/devices.csv?${m.buildQueryString({
filter: stringify(filter), filter: stringify(filter),
columns: JSON.stringify(columns), columns: JSON.stringify(columns),
@@ -282,7 +285,11 @@ export const component: ClosureComponent = (): Component => {
}; };
const attrs = {}; const attrs = {};
attrs["attributes"] = attributes; attrs["attributes"] = attributes.map((a) => ({
...a,
label: store.evaluateExpression(a.label, null),
type: store.evaluateExpression(a.type, null),
}));
attrs["data"] = devs.value; attrs["data"] = devs.value;
attrs["total"] = count.value; attrs["total"] = count.value;
attrs["showMoreCallback"] = showMore; attrs["showMoreCallback"] = showMore;
+16 -10
View File
@@ -46,6 +46,18 @@ const unpackSmartQuery = memoize((query) => {
}); });
}); });
async function deleteFaults(faults: Iterable<string>): Promise<void> {
const proms: Map<string, Promise<void>> = new Map();
for (const f of faults) {
const deviceId = f.split(":", 1)[0];
let p = proms.get(deviceId);
if (p == null) p = store.deleteResource("faults", f);
else p = p.then(() => store.deleteResource("faults", f));
proms.set(deviceId, p);
}
await Promise.all(proms.values());
}
export function init( export function init(
args: Record<string, unknown>, args: Record<string, unknown>,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
@@ -154,16 +166,10 @@ export const component: ClosureComponent = (): Component => {
if (!confirm(`Deleting ${selected.size} faults. Are you sure?`)) if (!confirm(`Deleting ${selected.size} faults. Are you sure?`))
return; return;
Promise.all( const c = selected.size;
Array.from(selected).map((id) => deleteFaults(selected)
store.deleteResource("faults", id), .then(() => {
), notifications.push("success", `${c} faults deleted`);
)
.then((res) => {
notifications.push(
"success",
`${res.length} faults deleted`,
);
store.setTimestamp(Date.now()); store.setTimestamp(Date.now());
}) })
.catch((err) => { .catch((err) => {
+4 -3
View File
@@ -2,6 +2,7 @@ import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components.ts"; import { m } from "./components.ts";
import { stringify } from "../lib/common/expression/parser.ts"; import { stringify } from "../lib/common/expression/parser.ts";
import memoize from "../lib/common/memoize.ts"; import memoize from "../lib/common/memoize.ts";
import * as store from "./store.ts";
const memoizedStringify = memoize(stringify); const memoizedStringify = memoize(stringify);
@@ -24,9 +25,9 @@ function drawChart(chartData): Children {
legend.push( legend.push(
m(".legend-line", [ m(".legend-line", [
m("span.color", { m("span.color", {
style: `background-color: ${slice["color"]} !important;`, style: `background-color: ${store.evaluateExpression(slice["color"], null)} !important;`,
}), }),
`${slice["label"]}: `, `${store.evaluateExpression(slice["label"], null)}: `,
m( m(
"a", "a",
{ {
@@ -57,7 +58,7 @@ function drawChart(chartData): Children {
paths.push( paths.push(
m("path", { m("path", {
d: sketch, d: sketch,
fill: slice["color"], fill: store.evaluateExpression(slice["color"], null),
}), }),
); );