Compare commits

...
77 Commits
Author SHA1 Message Date
rodrigo d4e1b297ae implementação de mudança de interface
Signed-off-by: Rodrigo Bertoldo de Sousa Martins <rodrigo.sousa.577@gmail.com>
2026-03-30 09:53:11 -03:00
Zaid Humaimidi d445aa03cf refactor: extract seed provision scripts to dedicated directory
Move bootstrap, default, and inform scripts from inline template
strings in lib/init.ts to standalone files under seed/, loaded via
an esbuild text-loader plugin. Add type-checking (provisions.d.ts,
tsconfig.json) and ESLint config for the seed scripts. Set tsconfig
module to Preserve to support import attributes.
2026-03-13 13:48:45 -07:00
Zaid Humaimidi f39e2ce7cf feat: add LIKE-Compare DC set relationships
Detect contradictions between LIKE patterns and comparison operators
(=, <, >) on the same field. For prefix patterns like 'abc%', use
range bounds to identify impossible combinations such as
(string < 'abc') AND (string LIKE 'abc%').
2026-03-13 13:15:52 -07:00
Zaid Humaimidi 6f3ce6f7c8 refactor: replace array-based expressions with typed class hierarchy
Replace the old Lisp-like array expression representation
(["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in
an abstract Expression class (Literal, Parameter, Binary, Unary,
FunctionCall, Conditional). This gives compile-time type safety
to expression construction, evaluation, and pattern matching.

Key changes:
- New lib/common/expression.ts defines the class hierarchy with
  map/evaluate/toString methods
- Rewrite parser as hand-rolled recursive descent, removing the
  parsimmon dependency
- Extend Path with a colon separator to unify parameter identity
  and attributes into a single address space (e.g.
  "Device.WiFi.SSID.1.Name:writable"). The colon field tracks
  how many trailing segments are attribute qualifiers. PathSet
  rewritten with separate param/attr segment indexes
- Flatten device attribute representation in the UI to
  colon-delimited keys mirroring the new path syntax
2026-03-12 14:58:20 -07:00
Zaid Humaimidi 75908e624f build: migrate eslint to v10 flat config and upgrade typescript
Replace .eslintrc.json with eslint.config.js using the new flat config
format required by ESLint 10. Swap @typescript-eslint/eslint-plugin and
@typescript-eslint/parser for the unified typescript-eslint package.
Upgrade TypeScript to 5.9 and @types/node to 25. Apply automatic code
fixes: remove unused catch bindings and preserve error cause in auth
header parsing.
2026-03-12 14:58:19 -07:00
Zaid Humaimidi 4a191afdec feat(ui): redesign UI with Tailwind CSS, CodeMirror 6, and new layout
- Replace all hand-written CSS with Tailwind utility classes
- Upgrade CodeMirror 5 to CodeMirror 6 (scoped @codemirror/* packages)
- Redesign layout with responsive sidebar navigation using JSX/Mithril
- Flatten admin routes (e.g. /admin/presets -> /presets)
- Add "remember me" login option with 2-week persistent cookie
- Add delete confirmation dialogs across admin pages
- Upgrade esbuild from 0.21 to 0.27 and update browser targets
- Add Inter and Roboto Mono variable fonts with subset script
2026-03-12 14:55:10 -07:00
Zaid Humaimidi a7256620e0 chore: bump version to 1.3.0-dev 2026-03-12 14:28:07 -07:00
Zaid Humaimidi bef840ce0a docs: add ARCHITECTURE.md and AGENTS.md, expand CONTRIBUTING.md
Add ARCHITECTURE.md with comprehensive codebase map covering service
boundaries, expression system, session engine, sandbox, database layer,
and architectural invariants. Add AGENTS.md as a concise guide for AI
coding agents. Expand CONTRIBUTING.md with detailed code style, naming,
import, testing, and git workflow conventions.
2026-03-12 14:26:46 -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
Zaid Humaimidi c6fe696300 1.2.10 2024-03-18 19:32:26 -07:00
Zaid Humaimidi 8d7d4de386 Changelog for v1.2.10 2024-03-18 19:29:55 -07:00
Zaid Humaimidi 3701c73b00 Update dependencies 2024-03-18 19:17:47 -07:00
Zaid Humaimidi 6e501990e0 Update author in package.json 2024-03-18 18:56:09 -07:00
Zaid Humaimidi 450fc6fe53 Fix lock expired errors 2024-03-11 11:04:49 -07:00
Divya Jagadish 6962cedba9 Fix exception caused by invalid con req URL 2024-03-09 13:14:38 -08:00
Zaid Humaimidi 227a005400 Fix exception when URL has two trailing slashes 2024-03-07 13:27:06 -08:00
Divya Jagadish 24cd3f5670 Support cert and key as string env variable 2024-03-03 13:48:00 -08:00
Divya Jagadish 052706877a Fix bug in Change Password 2024-03-02 11:31:47 -08:00
Zaid Humaimidi 867e7e768d Fix crash with certain validator expressions 2024-03-01 09:47:12 -08:00
Zaid Humaimidi 1c61504a68 Fix regression in f3068c5 2024-03-01 09:43:52 -08:00
Zaid Humaimidi effd574f4e XMPP connection request 2024-02-27 11:41:03 -08:00
Zaid Humaimidi f3068c5355 Improve pagination/sorting 2024-02-24 04:26:40 -08:00
Zaid Humaimidi f8e6e93602 Better version metadata string 2023-12-19 13:11:59 -08:00
Zaid Humaimidi e6c8d4f3f2 Build script improvements 2023-12-18 23:57:44 -08:00
Zaid Humaimidi 0dee9a5f00 Remove license headers and update imports 2023-12-18 19:13:59 -08:00
Zaid Humaimidi bfbbdb12ca Fix label issue in some components 2023-12-18 11:33:49 -08:00
Zaid Humaimidi ddb240d542 Migrate from Ava test framework to node:test 2023-12-17 22:26:52 -08:00
Zaid Humaimidi 0c9c027a77 Update linting 2023-12-16 19:08:13 -08:00
Zaid Humaimidi 4f2dfb4f48 Drop Rollup in favor of esbuild 2023-12-12 22:27:58 -08:00
Zaid Humaimidi 342210faf1 Utilize locks to prevent certain edge case issues 2023-12-06 11:10:23 -08:00
Zaid Humaimidi f4596db9f7 Remove caching of tasks, faults and operations
No longer seems to provide significant performance benefit to justify the
drawback of the added code complexity and the additional DB write query.
2023-11-23 10:34:47 -08:00
Zaid Humaimidi d122b224b0 Resolve issues from DB lock collection migration 2023-11-16 15:58:26 -08:00
Zaid Humaimidi 7a142a4c08 Close log streams on process shutdown 2023-11-14 01:41:25 -08:00
Zaid Humaimidi 4cb143cc0a Encode event codes containing illegal chars 2023-11-13 15:18:24 -08:00
Zaid Humaimidi 8d11e1cdd9 Support conditional requests in genieacs-fs 2023-11-13 13:25:29 -08:00
Zaid Humaimidi f7e75caf2c Support Range header in genieacs-fs 2023-11-10 15:15:24 -08:00
Divya Jagadish 02665faa9f Move locks to their own DB collection 2023-10-08 12:14:06 -07:00
Zaid Humaimidi c3954c277f Refactor db and local-cache code
Separated cwmp and ui code into distinct files to enhance clarity and
maintainability with varied internal data representations.
2023-09-29 14:55:44 -07:00
Clément Hampaï 493f1d2f37 Support HEAD query on fs endpoint
Some CPEs query the FS endpoint with a `HEAD` request first before downloading
the image using a `GET`.
2023-08-30 18:14:59 -07:00
Zaid Humaimidi 39bb937f9d Fix oversight in commit 58488eb 2023-08-29 12:20:53 -07:00
Zaid Humaimidi 35b193f058 Fix bug in handling of 9005 faults 2023-08-28 18:21:19 -07:00
Zaid Humaimidi 4b0d1786dd Strip extraneous properties from task objects 2023-08-28 18:19:23 -07:00
Zaid Humaimidi ea956fbc9c 404 when updating tags of non-existing devices 2023-08-28 18:17:22 -07:00
Zaid Humaimidi a0ad6ec3f1 Fix crash with certain validator expressions 2023-08-28 18:16:40 -07:00
Zaid Humaimidi 32dda9f194 Add indexable smart query types 2023-08-14 23:45:37 -07:00
Divya Jagadish c7022a2735 Add show option in permissions page 2023-08-11 15:37:43 -07:00
Zaid Humaimidi a54b2c8cd6 Handle unhandledRejection events
Because in Nodejs 12-14 unhandled rejections don't trigger uncaughtException
event by default so the process doesn't exit in case of errors.
2023-08-09 19:51:40 -07:00
Zaid Humaimidi 7b8a49a7ba Refactor request listeners to use async functions 2023-08-09 19:51:40 -07:00
Zaid Humaimidi 58488ebc3d Refactor DB code to use promises 2023-08-09 19:51:38 -07:00
Zaid Humaimidi 3169e829e7 Refactor expression to Mongo query translation
The previous approach was simplistic and could result in inefficient queries
in certain edge cases, notably causing MongoDB to not utilize existing indexes
when querying by last inform timestamps given upper and lower bounds. The new
implementation leverages our existing expression minimizer code and can
therefore generate more efficient queries or at least queries that can take
advantage of existing indexes.
2023-08-09 19:48:49 -07:00
Zaid Humaimidi 947f96299d Prevent premature close error in small files 2023-04-19 12:01:53 -07:00
Dan Morphis ee8dc3ae12 Update installation-guide.rst
Remove the JWT line from the env file and add a line to automatically generate and append a secure JWT secret.
2023-01-22 17:41:55 -08:00
Zaid Abdulla d1c01e0848 Fix FS drops connection if download is too slow
Only happens in HTTPS because seemingly somewhere in the stack up to
50mb of data is being buffered potentially triggering a timeout at a
higher level if not drained fast enough.

This commit also includes other streams related improvements.
2022-11-01 09:13:36 -07:00
Oliver Kraitschy d53e10f62d Fix port in FS URL example 2022-10-30 10:21:35 -07:00
193 changed files with 23762 additions and 23378 deletions

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# Dependências e artefatos de build
node_modules/
dist/
# Arquivos de ambiente (contêm segredos)
.env
# Logs e debug
logs/
*.log
# Artefatos de teste
test/*.js
# Docs geradas
docs/_build/
# Dados de desenvolvimento
.git/
.github/
# IDEs
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
+69
View File
@@ -0,0 +1,69 @@
# ============================================================
# GenieACS — variáveis de ambiente
# Copie este arquivo para .env e ajuste os valores
# NUNCA faça commit do .env com dados reais em repositórios
# públicos.
# ============================================================
# ── MongoDB ─────────────────────────────────────────────────
# Quando usando docker-compose o host é o nome do serviço "mongo"
GENIEACS_MONGODB_CONNECTION_URL=mongodb://mongo:27017/genieacs
# ── Extensões ───────────────────────────────────────────────
GENIEACS_EXT_DIR=/opt/genieacs/config/ext
GENIEACS_EXT_TIMEOUT=3000
# ── Logs ────────────────────────────────────────────────────
# json | simple
GENIEACS_LOG_FORMAT=json
GENIEACS_ACCESS_LOG_FORMAT=json
# Para gravar logs em arquivo (opcional, senão vai para stdout/stderr)
# GENIEACS_CWMP_LOG_FILE=/opt/genieacs/logs/cwmp.log
# GENIEACS_CWMP_ACCESS_LOG_FILE=/opt/genieacs/logs/cwmp-access.log
# GENIEACS_NBI_LOG_FILE=/opt/genieacs/logs/nbi.log
# GENIEACS_NBI_ACCESS_LOG_FILE=/opt/genieacs/logs/nbi-access.log
# GENIEACS_FS_LOG_FILE=/opt/genieacs/logs/fs.log
# GENIEACS_FS_ACCESS_LOG_FILE=/opt/genieacs/logs/fs-access.log
# GENIEACS_UI_LOG_FILE=/opt/genieacs/logs/ui.log
# GENIEACS_UI_ACCESS_LOG_FILE=/opt/genieacs/logs/ui-access.log
# ── Debug (opcional) ────────────────────────────────────────
# GENIEACS_DEBUG_FILE=/opt/genieacs/logs/debug.yaml
# GENIEACS_DEBUG_FORMAT=yaml
# ── Worker processes (0 = 1 por CPU core) ───────────────────
GENIEACS_CWMP_WORKER_PROCESSES=1
GENIEACS_NBI_WORKER_PROCESSES=1
GENIEACS_FS_WORKER_PROCESSES=1
GENIEACS_UI_WORKER_PROCESSES=1
# ── Portas ──────────────────────────────────────────────────
GENIEACS_CWMP_PORT=7547
GENIEACS_NBI_PORT=7557
GENIEACS_FS_PORT=7567
GENIEACS_UI_PORT=3000
# ── Interface de bind (:: = todas as interfaces) ────────────
GENIEACS_CWMP_INTERFACE=::
GENIEACS_NBI_INTERFACE=::
GENIEACS_FS_INTERFACE=::
GENIEACS_UI_INTERFACE=::
# ── UI — JWT Secret (OBRIGATÓRIO trocar em produção!) ───────
# Mínimo 32 chars, máximo 64 chars
GENIEACS_UI_JWT_SECRET=mude-isto-em-producao-use-uma-string-aleatoria-longa
# ── FS — URL prefix (configure se CWMP e FS estiverem em ────
# hosts/IPs diferentes ou atrás de proxy)
# GENIEACS_FS_URL_PREFIX=http://seu-ip-publico:7567/
# ── SSL (opcional — passe o caminho para cert/key PEM) ──────
# GENIEACS_CWMP_SSL_CERT=/opt/genieacs/certs/cwmp.crt
# GENIEACS_CWMP_SSL_KEY=/opt/genieacs/certs/cwmp.key
# GENIEACS_NBI_SSL_CERT=/opt/genieacs/certs/nbi.crt
# GENIEACS_NBI_SSL_KEY=/opt/genieacs/certs/nbi.key
# GENIEACS_FS_SSL_CERT=/opt/genieacs/certs/fs.crt
# GENIEACS_FS_SSL_KEY=/opt/genieacs/certs/fs.key
# GENIEACS_UI_SSL_CERT=/opt/genieacs/certs/ui.crt
# GENIEACS_UI_SSL_KEY=/opt/genieacs/certs/ui.key
-38
View File
@@ -1,38 +0,0 @@
{
"env": {
"es6": true,
"node": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"no-shadow": ["error", { "allow": ["err"] }],
"handle-callback-err": "error",
"prefer-arrow-callback": "error",
"curly": ["error", "multi-or-nest", "consistent"],
"no-buffer-constructor": "error",
"prefer-const": ["error", { "destructuring": "all" }],
"eqeqeq": ["error", "always", { "null": "ignore" }],
"no-return-await": "error",
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-use-before-define": [
"error",
{ "functions": false }
],
"@typescript-eslint/explicit-function-return-type": [
"error",
{ "allowExpressions": true }
],
"@typescript-eslint/no-floating-promises": "error",
"require-atomic-updates": 0,
"no-prototype-builtins": 0
}
}
+50
View File
@@ -0,0 +1,50 @@
GitHub Copilot Instructions: GenieACS -> UniFi UI Theme
You are an expert senior software engineer and frontend architect focused on UI/UX modernization, maintainability, and clean architecture. Your primary goal is to help modify the GenieACS user interface to closely resemble the UniFi Controller, while strictly ensuring the codebase remains easy to synchronize (via merge or rebase) with the original GenieACS upstream repository for bug fixes and core updates.
1. Core Principles (Non-Negotiable)
Sync-Friendly Architecture (Crucial): Write code defensively to prevent future merge conflicts with the original GenieACS project. Prefer extending, overriding, or wrapping components rather than heavily modifying deeply ingrained original core files.
Visual Fidelity (UniFi): Implement the clean, dashboard-centric, modern aesthetic of the UniFi Controller. Use comparable color palettes, typography, spacing, and network topology layouts.
Safety First: Never modify the underlying TR-069 communication logic, backend models, or database schemas unless explicitly requested. The focus is strictly on the presentation layer (View).
Minimal Footprint: When generating files, aim to isolate custom UI assets. Keep custom CSS/SCSS and new UI components in distinct directories or files whenever possible.
2. GenieACS Customization & Upstream Integration
CRITICAL: Whenever the user requests a modification to a page or component, you MUST prioritize the following strategies to keep the fork maintainable:
Style Overrides: Prefer creating custom stylesheets (e.g., unifi-theme.scss) to override existing GenieACS classes instead of changing inline styles or the original CSS files directly.
Component Wrapping: If an existing UI component needs significant structural changes, create a new "UniFi-styled" wrapper component that consumes the original GenieACS state/props, rather than gutting the original component.
Clear Demarcation: When modifying an original GenieACS file is unavoidable, clearly mark the beginning and end of the custom code with comments (e.g., // --- BEGIN UNIFI THEME --- and // --- END UNIFI THEME ---). This makes resolving Git merge conflicts significantly easier.
3. Code Generation Rules
A. Refactoring & Modification
Context Awareness: Read the surrounding code to understand the existing state management and API calls. Do not break the data flow between the GenieACS backend and the frontend.
Preserve Logic: When changing how data is displayed (e.g., changing a simple table to a UniFi-style card grid), ensure all original data points remain accessible or are intentionally hidden via CSS, rather than deleting the data-fetching logic.
B. New Features (UI Components)
Modularity: Build reusable UI components (Buttons, Cards, Navbars) that match the UniFi design system, so they can be applied globally across the GenieACS interface.
Responsiveness: Ensure all new UI elements are fully responsive, as network management dashboards are often viewed on various screen sizes.
C. Testing
Prioritize visual regression checks and ensure that frontend unit tests (if applicable) are updated to reflect structural DOM changes. Ensure the connection to the GenieACS API remains mocked and functional in tests.
4. Response Format
Plan First: Before generating code, briefly outline your strategy. Specifically state which files will be modified and how you plan to implement the change while minimizing upstream merge conflicts.
Complete Code: Output the full, functional block of code needed for the file. Do not use placeholders like // ... rest of code unless the file is massive and the change is highly localized.
Git Context: If a change requires creating a new file to override an old one, explain how to integrate it into the project's build system.
5. "Do No Harm" Checklist
Before outputting the final response, ask yourself:
Does this modification break the core GenieACS device management logic?
Will this change cause a massive merge conflict if the upstream repository updates this specific component? (If yes, rethink the approach).
Does the resulting UI genuinely reflect the UniFi Controller design language?
+1
View File
@@ -2,3 +2,4 @@
node_modules node_modules
dist dist
docs/_build docs/_build
.env
+82
View File
@@ -0,0 +1,82 @@
# AGENTS.md — GenieACS
GenieACS is a TR-069 Auto Configuration Server for remote management of CPE
devices (routers, modems, gateways). TypeScript codebase compiled with esbuild,
backed by MongoDB.
## Architecture Overview
Four services share a single MongoDB instance:
- **CWMP** (port 7547) — TR-069 protocol handler; manages device sessions
- **NBI** (port 7557) — Northbound REST API for external consumers
- **FS** (port 7567) — File server for firmware/config (GridFS-backed)
- **UI** (port 3000) — Web interface (Koa backend + Mithril.js SPA frontend)
Key subsystems: expression engine (`lib/common/expression/`) compiles a
Lisp-like DSL used for queries, config, and authorization; session engine
(`lib/session.ts`) drives CWMP interactions via declarations rather than
imperative RPCs; sandbox (`lib/sandbox.ts`) runs user-defined provision scripts
in `vm.Script` with deterministic replay.
Read `ARCHITECTURE.md` for a full map of the codebase when working on unfamiliar
areas. It covers service boundaries, the expression pipeline, the path system,
the CWMP session state machine, the database layer, and architectural
invariants.
## Project Structure
- `lib/` — Core server-side logic
- `lib/common/` — Shared code (runs in both Node.js and browser)
- `lib/db/` — MongoDB database layer
- `lib/ui/` — UI backend helpers
- `ui/` — Frontend SPA (Mithril.js)
- `bin/` — Service entry points (5 executables)
- `build/` — Build scripts (esbuild pipeline)
- `test/` — Unit tests (node:test)
- `docs/` — User docs (Sphinx/reStructuredText)
- `public/` — Static assets (favicon, logo)
## Build / Lint / Test Commands
```bash
npm run build # Production build (esbuild pipeline -> dist/)
NODE_ENV=development npm run build # Dev build (no minification)
npm run lint # Prettier + ESLint + tsc --noEmit in parallel
npm test # Compile tests with esbuild, run with node --test
```
### Running a Single Test File
```bash
esbuild --log-level=warning --bundle --platform=node --target=node18 \
--packages=external --sourcemap=inline --outdir=test test/path.ts \
&& node --test --enable-source-maps test/path.js \
&& rm test/path.js
```
### Running a Single Test Case
```bash
esbuild --log-level=warning --bundle --platform=node --target=node18 \
--packages=external --sourcemap=inline --outdir=test test/path.ts \
&& node --test --enable-source-maps --test-name-pattern="^parse$" test/path.js \
&& rm test/path.js
```
### Lint Sub-commands
```bash
prettier --prose-wrap always --write .
eslint 'bin/*.ts' 'lib/**/*.ts' 'ui/**/*.ts' 'test/**/*.ts' 'build/**/*.ts'
tsc --noEmit
```
## Before Committing
Read `CONTRIBUTING.md` and ensure your changes comply with it. In particular:
- Run `npm run lint` and `npm test` and fix any failures.
- Follow the code style, naming, import, and comment conventions documented
there.
- Use the Conventional Commits format for commit messages.
+479
View File
@@ -0,0 +1,479 @@
# Architecture
This document describes the high-level architecture of GenieACS. If you want to
familiarize yourself with the codebase, you are in the right place.
## Bird's Eye View
GenieACS is a TR-069 Auto Configuration Server (ACS). It manages CPE devices
(routers, modems, gateways) using the CWMP protocol (TR-069). The system
consists of four network-facing services that share a MongoDB database:
```
+------------+
CPE Devices --->| CWMP (7547)|-+
+------------+ |
+-----------+ | +---------+
CPE Devices --->| FS (7567) |--+---->| MongoDB |
+-----------+ | +---------+
+-----------+ |
OSS / Scripts ->| NBI (7557)|--+
+-----------+ |
+-----------+ |
Administrators->| UI (3000) |--+
+-----------+
```
- **CWMP** -- The core TR-069 protocol server. CPE devices connect here to
report their state and receive configuration instructions via SOAP/XML over
HTTP.
- **NBI** -- Northbound Interface. A REST API for external systems (OSS/BSS,
scripts, automation) to manage devices, tasks, presets, and configuration
programmatically.
- **FS** -- File Server. Serves firmware images and configuration files to CPE
devices during download operations.
- **UI** -- Web interface. A Koa-based backend serving a Mithril.js single-page
application for administrators to browse devices, manage configuration, and
trigger operations.
All four services follow the same process model: a primary process forks
configurable worker processes via Node.js `cluster` (see `cluster.ts`). Workers
connect to MongoDB and start an HTTP(S) server.
## Code Map
```
bin/ Service entry points (5 executables)
lib/ All backend logic
common/ Shared utilities (expression engine, Path, errors)
expression.ts Expression class hierarchy (base + subclasses)
expression/ Expression parser, evaluator, normalizer, minimizer
cwmp/ CWMP-service-specific DB and caching
db/ Database layer (MongoDB collections, query synthesis)
ui/ UI-service-specific API, DB, and caching
types/ (empty, reserved)
ui/ Frontend SPA (Mithril.js)
components/ Reusable UI components (parameter, tags, ping, etc.)
css/ Stylesheets (vanilla CSS)
icons/ SVG icons (compiled into a sprite)
test/ Unit tests (Node.js native test runner)
build/ Build scripts (esbuild-based)
public/ Static assets (logo, favicon)
```
### Entry Points (`bin/`)
Each file in `bin/` bootstraps one service. They are structurally identical:
initialize logging, read config, fork workers in the primary process, connect to
MongoDB and start the HTTP server in each worker.
- `genieacs-cwmp.ts` -- CWMP service. Unique in that it disables HTTP keep-alive
(`keepAliveTimeout: 0`) and provides custom `onConnection` / `onClientError`
hooks for TR-069 session lifecycle management.
- `genieacs-nbi.ts` -- NBI service. Straightforward REST API server.
- `genieacs-fs.ts` -- File server. The leanest service; does not use the
extensions subsystem.
- `genieacs-ui.ts` -- UI service. Wraps the Koa application as the HTTP
listener.
- `genieacs-ext.ts` -- **Not a service.** This is a child process worker spawned
by the extensions subsystem. It communicates with its parent via IPC,
executing user-defined extension scripts in isolation.
### The Expression System (`lib/common/expression.ts`, `lib/common/expression/`)
The `Expression` class (defined in `lib/common/expression.ts`) is the most
important abstraction in the codebase. It uses a typed class hierarchy:
`Expression.Literal` (wraps `string | number | boolean | null`),
`Expression.Parameter` (wraps a `Path`), `Expression.Binary` (operator +
left/right), `Expression.Unary` (operator + operand), `Expression.FunctionCall`
(name + args), and `Expression.Conditional` (condition/then/otherwise). For
example, in the SQL-like text syntax:
```
Device.ModelName = "BrandX" AND Events.Inform > 1000
```
Expressions are used pervasively:
- **Query/filter language** -- Database queries for devices, faults, presets,
etc. are all represented as expressions and compiled to MongoDB filters.
- **Configuration values** -- Config entries and preset preconditions are
expressions, enabling dynamic evaluation.
- **Authorization** -- Permission filters and validators are expressions.
- **Pagination cursors** -- Keyset pagination boundaries are expressed as filter
expressions.
The expression pipeline flows through four modules:
1. `parser.ts` -- Parses a SQL-like text syntax into the AST using a hand-rolled
recursive descent parser with a `Cursor`-based scanner. Also provides
`stringifyExpression()` for serialization. The `map()` / `mapAsync()`
tree-walking primitives are abstract methods on the `Expression` class, and
`stringify()` is now `Expression.toString()`.
2. `normalize.ts` -- Algebraic normalization using exact rational polynomial
arithmetic over native `bigint` values. The `Polynomial` class extends
`Expression` as an intermediate representation. Ensures equivalent
expressions have the same canonical form (e.g., `a + 2 > b` and `a > b - 2`
normalize identically).
3. `synth.ts` -- Boolean logic minimization via the Espresso algorithm
(espresso-iisojs). Converts expressions to minimal sum-of-products form with
three-valued logic (true/false/null). Handles domain-specific constraints
like comparison ordering and LIKE pattern relationships.
4. `evaluate.ts` -- Runtime evaluation. The `reduce()` function evaluates
operators on literal values and supports partial evaluation (returns a
reduced expression if some values are unknown). Parameter resolution is
handled by the `Expression.evaluate()` method (in `lib/common/expression.ts`)
which calls `reduce()` after mapping children via a user-supplied callback.
`pagination.ts` implements cursor-based pagination by generating filter
expressions from sort-key bookmarks.
### The Path System (`lib/common/path.ts`, `lib/common/path-set.ts`)
`Path` represents a TR-069 parameter path (e.g., `Device.WiFi.SSID.1.Name`).
Paths can contain wildcards (`*`) and alias expressions (`[key:value]`) for
query-based addressing.
Key design decisions:
- **Cached** -- `Path.parse()` caches instances in a two-generation LRU cache
rotated every 120 seconds. The constructor is public (used directly by the
parser and by methods like `slice()`, `concat()`, and `stripAlias()`).
- **Bitmask encoding** -- The `wildcard` and `alias` fields are bitfields for
O(1) segment-type checking. This limits paths to 32 segments. A `colon` field
tracks the number of attribute path segments (after a `:` separator), enabling
`paramLength` and `attrLength` accessors.
- **Immutable** -- Segment arrays are `Object.freeze()`-d.
`PathSet` is a multi-indexed collection of paths supporting pattern-matching
queries. It maintains separate `paramSegmentIndex` and `attrSegmentIndex` arrays
(one `Map<string, Set<Path>>` per position), plus a `stringIndex` map. The
`find()` method takes bitmasks to control which segments require exact matches
vs. wildcard compatibility, then uses set intersection across the smallest index
sets. A higher-level `findCompat()` method computes the appropriate bitmasks for
superset/subset matching.
### CWMP Protocol Layer (`lib/cwmp.ts`, `lib/soap.ts`, `lib/xml-parser.ts`)
`xml-parser.ts` is a custom single-pass XML parser (no DOM). It scans
character-by-character with bitwise state flags, building a tree of elements
with namespace support. Does not support CDATA.
`soap.ts` handles both parsing CPE SOAP messages and generating ACS SOAP
responses. It dispatches on the SOAP body's method name to type-specific parsers
for Inform, TransferComplete, GetParameterNamesResponse, etc. Supports CWMP
versions 1.0 through 1.4.
`cwmp.ts` is the HTTP-level CWMP request handler. It manages the session state
machine:
1. **State 0** -- Expects an Inform. Authenticates the device (Basic or Digest
auth, configurable via expression). Acquires a distributed lock. Loads device
data from MongoDB. Sends InformResponse.
2. **State 1** -- Waits for the CPE to send an empty POST (ready for ACS RPCs).
Processes any TransferComplete messages.
3. **State 2** -- The ACS drives RPCs (GetParameterNames, GetParameterValues,
SetParameterValues, AddObject, DeleteObject, Download, Reboot, FactoryReset).
The CPE responds to each.
Session persistence across TCP disconnects: when a socket closes mid-session,
the entire `SessionContext` is serialized to Redis (the MongoDB `cache`
collection) and restored when the CPE reconnects (identified by a session
cookie).
### Session Engine (`lib/session.ts`)
The session engine implements the **declaration-driven data fetching** pattern.
Rather than issuing RPCs imperatively, provisions create `Declaration` objects
stating what paths and attributes they need to read or write. The engine then:
1. Processes all declarations into a `SyncState` -- a structured plan of which
parameters to refresh, which values to set, which instances to create/delete,
etc.
2. Generates the minimal set of CWMP RPCs needed to fulfill the plan.
3. After each RPC response, updates `DeviceData` and re-evaluates.
4. Iterates until all declarations are satisfied.
The preset system (`applyPresets` in `cwmp.ts`) implements a policy engine:
presets are rules with precondition expressions that, when matched, contribute
provisions to the session. After provisions execute, if device data changed,
presets are re-evaluated (up to 4 cycles to prevent infinite loops).
### Provisions and Virtual Parameters
**Provisions** are the unit of configuration intent. Built-in provisions
(`default-provisions.ts`) include `refresh`, `value`, `tag`, `reboot`, `reset`,
`download`, and `instances`. Custom provisions are user-defined JavaScript
scripts stored in the `provisions` MongoDB collection.
**Virtual parameters** are scripts that present computed/derived values as if
they were real device parameters under the `VirtualParameters.*` namespace. They
run in two phases: a "get" phase reads real parameters and returns a computed
value; a "set" phase translates a desired value into real parameter changes.
Virtual parameters can reference other virtual parameters (up to depth 8).
### Sandbox (`lib/sandbox.ts`)
The sandbox provides a secure execution environment for provision and virtual
parameter scripts using `vm.Script` with a 50ms timeout. It uses a
**replay-based execution model**:
1. A script runs and calls `declare()` to request data.
2. When `commit()` is called, the script throws a sentinel symbol and exits.
3. The engine fetches the requested data via CWMP RPCs.
4. The script is **re-run from the beginning** with the fetched data available.
5. Earlier `declare()` calls return cached results; the script progresses
further.
6. This repeats until the script completes without throwing.
The sandbox API: `declare(path, timestamps, values)` returns a
`ParameterWrapper` proxy; `clear(path, timestamp, attributes)` invalidates
cached data; `ext(...args)` calls external extensions (results are cached per
revision to survive replays); `commit()` explicitly triggers a fetch cycle.
`Math.random()` is replaced with a seeded PRNG for determinism.
### Device Data Model (`lib/types.ts`, `lib/device.ts`)
`DeviceData` is the in-memory working copy of a device's parameter tree during a
session:
- `paths: PathSet` -- All known parameter paths.
- `timestamps: VersionedMap<Path, number>` -- When each path was last refreshed.
- `attributes: VersionedMap<Path, Attributes>` -- Per-path attributes (object,
writable, value, notification, accessList), each paired with a timestamp.
- `trackers` / `changes` -- Change tracking for re-evaluation.
`VersionedMap` (in `versioned-map.ts`) provides multi-revision snapshots,
enabling the sandbox replay model where scripts may be re-run at different
revision levels.
`device.ts` handles setting and clearing parameter data with invariant
enforcement (e.g., if `value` is set, `object` is forced to 0; parent paths are
ensured to exist). The `unpack()` function resolves wildcards and alias paths
against concrete device data.
### NBI (`lib/nbi.ts`)
A raw Node.js HTTP listener (no framework) with regex-based URL routing.
Endpoints include CRUD for presets, provisions, virtual parameters, objects, and
files; device task management (with optional synchronous execution via
connection request); fault management; generic collection querying; and ping.
The NBI uses MongoDB-style JSON queries directly. For the `devices` collection,
`query.ts` expands user-friendly queries by auto-appending `._value` to
parameter paths and generating multi-type interpretations (string, number, date,
regex) for filter values.
### File Server (`lib/fs.ts`)
A minimal HTTP file server reading from MongoDB GridFS. Supports GET/HEAD with
full HTTP caching (ETag, Last-Modified, If-None-Match, If-Modified-Since) and
Range requests (HTTP 206) for partial content downloads. Files are cached
in-memory via memoization.
### UI Backend (`lib/ui.ts`, `lib/ui/`)
A Koa application with JWT authentication, role-based authorization, and a rich
CRUD API under `/api/`. The root route serves an HTML shell that bootstraps the
Mithril.js SPA with injected config, user info, and hashed asset filenames.
`lib/ui/api.ts` defines generic CRUD endpoints for all resource types (devices,
presets, provisions, files, config, users, permissions, faults, tasks) with
authorization checks at every level. Specialized endpoints handle file
upload/download, synchronous task execution, tag management, password changes,
and CSV export.
`lib/ui/db.ts` translates between the UI's flat parameter representation and
MongoDB's nested document structure. The `flattenDevice()` function is the key
transformation: it recursively walks the nested device document and produces a
flat key-value map with colon-delimited attribute keys (e.g.,
`"Device.WiFi.SSID.1.Name" -> value`,
`"Device.WiFi.SSID.1.Name:type" -> "xsd:string"`,
`"Device.WiFi.SSID.1.Name:writable" -> true`). The `FlatDevice` type is
`Record<string, Value>` where `Value = string | number | boolean | null`.
`lib/ui/local-cache.ts` caches permissions, users, and config in-process with
hash-based revision tracking. The `getConfig()` function uses typed overloads --
it takes a typed default value (`string`, `number`, or `boolean`) and an
expression evaluation callback, returning a value of the same type as the
default.
### Database Layer (`lib/db/`)
`db/db.ts` is the single entry point to MongoDB. It manages 14 collections:
| Collection | Purpose |
| ------------------- | -------------------------------- |
| `devices` | CPE device parameter trees |
| `presets` | Configuration rules |
| `provisions` | Provision scripts |
| `virtualParameters` | Virtual parameter scripts |
| `objects` | Generic objects |
| `tasks` | Queued device management tasks |
| `faults` | Error records with retry state |
| `operations` | In-flight async operations |
| `files` (GridFS) | Firmware images and config files |
| `permissions` | RBAC rules |
| `users` | User accounts |
| `config` | Key-value configuration |
| `cache` | Distributed cache (TTL index) |
| `locks` | Distributed locks (TTL index) |
`db/synth.ts` is the sophisticated expression-to-MongoDB query compiler. It
normalizes expressions, converts them to a Boolean satisfiability representation
using the `Clause` hierarchy, minimizes via Espresso, and emits MongoDB
`$and`/`$or`/`$not` filter objects. This is used by the UI backend.
`cwmp/db.ts` handles CWMP-specific persistence: loading the nested device
document into `DeviceData` (`fetchDevice`), diffing changes back into MongoDB
update operations (`saveDevice`), and managing faults, tasks, and operations.
### Query Systems
There are two separate query paths:
1. **NBI queries** (`lib/query.ts`) -- Processes MongoDB-style JSON queries from
external API consumers. Expands parameter names, generates multi-type value
interpretations, and passes through to MongoDB directly.
2. **UI/Expression queries** (`lib/db/synth.ts`) -- Compiles the internal
expression language into optimized MongoDB filters via Boolean minimization.
This is the more sophisticated path, used by the UI backend.
### Frontend (`ui/`)
A Mithril.js SPA with hash-based routing (`#!/`). Key modules:
- `app.ts` -- Route definitions. The `pagify()` function wraps each page into a
`RouteResolver` that handles initialization, error boundaries, and data
fulfillment.
- `layout.ts` -- Top-level layout (header, navigation, content, overlay).
- `store.ts` -- Centralized data store. Implements a query-based reactive cache
with deduplication and incremental fetching. The `fulfill()` method (called
after every render) batches pending queries, computes filter diffs via
`unionDiff()`, and fetches only missing data. Connection monitoring polls
every 3 seconds for server health, clock skew, and config changes.
- `components.ts` -- Component registry with a context propagation system. The
proxied `m()` function resolves string component names and the `m.context()`
API passes data (like the current device object) down the component tree
without explicit prop threading.
- `smart-query.ts` -- Translates user-friendly `Label: value` searches into
filter expressions with type-aware matching (string, number, timestamp, MAC
address, tag).
- `task-queue.ts` -- Two-stage task pipeline: staging (user configures tasks)
then queue (tasks are committed and executed via the backend).
- `dynamic-loader.ts` -- Lazy loading of heavy libraries (CodeMirror, YAML) via
dynamic `import()`.
### Build System (`build/`)
The build is a self-bootstrapping esbuild pipeline (`npm run build` pipes
`build/build.ts` through esbuild then node). It produces:
- **Backend binaries** -- 5 entry points bundled for Node.js 12+ with shebang
banners, executable permissions, and `.js` extension stripped.
- **Frontend bundle** -- `ui/app.ts` bundled for browsers (ESM, code-split) with
content-hashed filenames.
- **CSS** -- `ui/css/app.css` bundled and minified with content-hashed output.
- **SVG sprite** -- All icons in `ui/icons/` optimized via SVGO and combined
into a single sprite.
`build/assets.ts` is a compile-time bridge: at rest it contains placeholder
filenames; during build, the `assetsPlugin` replaces them with actual
content-hashed names so both backend and frontend reference the correct assets.
Build metadata includes the date and a hash derived from the git state, appended
to the package version.
## Cross-Cutting Concerns
### Multi-Level Caching
```
memoize.ts In-process function cache (2-4 min, two-generation rotation)
|
local-cache.ts In-process snapshot cache (5s refresh, hash-based revisions)
|
cache.ts MongoDB-backed distributed cache (configurable TTL)
|
MongoDB TTL index auto-expiration
```
Each service has its own `local-cache` that periodically checks the distributed
cache for staleness. Distributed locks (`lock.ts`) coordinate rebuilds so only
one worker does the expensive computation.
### Configuration (`lib/config.ts`)
Three-tier priority: CLI args > environment variables (`GENIEACS_*`) > config
file (`config.json`) > defaults. Supports per-device overrides by appending
`-OUI-ProductClass-SerialNumber` to option names.
### Distributed Locking (`lib/lock.ts`)
MongoDB-based mutual exclusion using upsert + duplicate key detection. TTL
indexes prevent deadlocks from crashed processes. Clock skew tolerance of 30
seconds.
### Authentication
- **CWMP devices** -- HTTP Basic or Digest auth, configurable per-device via
expressions (`auth.ts`).
- **UI users** -- JWT tokens in cookies. Passwords hashed with PBKDF2-SHA512
(10000 iterations). Role-based authorization via `Authorizer` with
expression-based filters and validators.
### Connection Requests (`lib/connection-request.ts`)
Three methods to ask a CPE device to initiate a CWMP session:
- **HTTP** -- Standard TR-069 connection request with Digest/Basic auth.
- **UDP** -- For NAT traversal (STUN-based) with HMAC-SHA1 signed messages.
- **XMPP** -- TR-069 Annex K via a full XMPP client (`xmpp-client.ts`).
### Extensions (`lib/extensions.ts`)
User-defined scripts executed in long-lived child processes (`genieacs-ext.ts`)
communicating via IPC. Processes are lazily spawned per script name and reused.
Each request gets a unique ID; responses are matched by ID with a configurable
timeout.
### Logging (`lib/logger.ts`)
Dual-stream structured logging (application + access logs). Supports simple and
JSON formats, systemd journal integration, and automatic log rotation detection.
Protocol traces can be written to a debug file in YAML or JSON format
(`debug.ts`).
### Three-Valued Logic
The system consistently implements SQL-style three-valued logic
(true/false/null). This is visible in expression evaluation, the `Clause`
hierarchy's separate true/false/null methods, and the 2-bit minterm encoding in
the Boolean minimizer. NULL means "unknown" and propagates through operations
following SQL semantics.
## Architectural Invariants
- **The expression system does not depend on any service-specific code.** The
`lib/common/expression/` modules are pure and shared across all services.
- **The sandbox is deterministic across replays.** `Math.random()` is seeded
from the device ID, `Date.now()` is controlled, and extension results are
cached. A script re-run with the same inputs produces the same outputs.
- **Services share no in-process state.** All cross-process coordination goes
through MongoDB (the `cache` and `locks` collections). Each worker process is
independent.
- **Device data is never mutated in place during a session.** `VersionedMap`
provides revision-based snapshots. The sandbox writes to new revisions;
earlier revisions remain readable for re-evaluation.
- **CWMP session exclusivity.** A distributed lock (`cwmp_session_<deviceId>`)
ensures only one session exists per device at a time. The lock is refreshed
periodically and released at session end.
- **The UI backend never queries MongoDB with raw user input.** All queries go
through the expression-to-MongoDB compiler (`db/synth.ts`), which validates
and normalizes expressions before generating filters.
+65
View File
@@ -1,5 +1,70 @@
# Change Log # Change Log
## 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)
- Add support for XMPP connection requests. Use the environment variables
`XMPP_JID` and `XMPP_PASSWORD` to configure the XMPP connection for the ACS.
- The environment variables `CWMP_SSL_CERT` and `CWMP_SSL_KEY`, as well as their
counterparts for UI, NBI, and FS, now accept PEM-encoded certificates and keys
in addition to file paths.
- The UI no longer requires users to refresh the page after modifying presets,
provisions, or virtual parameters. Refreshing is now only necessary for
changes to users, permissions, or UI configurations.
- Improved conversion of GenieACS expressions into MongoDB queries for more
optimized queries and better index utilization.
- Refactor UI pagination and sorting to fix issues from the previous approach,
especially with sorting by rapidly changing parameters such as 'last inform'
time.
- The file server now supports HEAD requests, the Range header, and conditional
requests.
- Addressed an issue causing file server disconnections for slow clients over
HTTPS.
- Fix bug preventing users from changing their own passwords.
- Introduced a new 'locks' collection for database locking, replacing the
previous use of the 'cache' collection for this purpose.
- Various other minor fixes and enhancements.
## 1.2.9 (2022-08-22) ## 1.2.9 (2022-08-22)
- New config option `cwmp.skipRootGpn` to enable a workaround for some - New config option `cwmp.skipRootGpn` to enable a workaround for some
+334 -21
View File
@@ -1,31 +1,348 @@
# Have a question? # Contributing to GenieACS
Please ask in the [forum] instead of opening a Github Issue. The Github issue ## Questions and Support
tracker is strictly for bug reports.
# Issues Please use the [forum](https://forum.genieacs.com) for questions, help requests,
and general discussion. GitHub Issues are reserved for confirmed bug reports.
We use GitHub issues to track bugs. Please ensure your bug description is clear ## Issues
and has sufficient instructions to be able to reproduce the issue.
If you face interoperability issues with your CPE, more often than not it's a We use GitHub Issues to track bugs. When filing a bug report:
device related issue. Please consult the [forum] before submitting an Issue.
# Pull Requests - Provide a clear description of the problem.
- Include steps to reproduce the issue.
- Note the GenieACS version, Node.js version, and MongoDB version.
- Include relevant log output if applicable.
Pull requests are welcome. If you have a fix for a bug, go ahead and create a If you face interoperability issues with a CPE, it is more often than not a
pull request. For other kinds of enhancements, please discuss in the [forum] device-specific issue. Please consult the [forum](https://forum.genieacs.com)
first. before opening an Issue.
Please follow the guidelines [here][commit-guidelines] when committing your ## Pull Requests
changes.
Pull requests are welcome. For bug fixes, go ahead and open a PR directly. For
new features or significant changes, please discuss in the
[forum](https://forum.genieacs.com) first to ensure alignment with the project's
direction.
When submitting a PR:
- Keep changes focused. One PR should address one concern.
- Run `npm run lint` and `npm test` before submitting.
- Update documentation in `docs/` if your change affects user-facing behavior.
- Add tests where appropriate (see [Testing](#testing)).
- Write a clear PR description explaining what the change does and why.
## Code Style
### Naming Conventions
- **Variables and functions**: `camelCase`
- **Classes**: `PascalCase`
- **Interfaces and type aliases**: `PascalCase`, without an `I` or `T` prefix
- **Module-level constants**: `UPPER_SNAKE_CASE`
- **Private class members**: prefixed with underscore (`_name`, `_cache`)
Choose descriptive, meaningful names. A longer clear name is better than a short
ambiguous one.
### Imports
Organize imports in three groups, in this order:
1. Node.js built-in modules, using the `node:` prefix
2. External packages
3. Local project modules
Use `.ts` extensions for all local imports.
```typescript
import { readFileSync } from "node:fs";
import * as http from "node:http";
import { Collection } from "mongodb";
import { connect, disconnect } from "./db.ts";
import Path from "./common/path.ts";
```
### Functions and Exports
Use the `function` keyword for all named and exported function declarations.
Arrow functions should only be used for callbacks and short inline expressions.
```typescript
// Named/exported functions use function keyword
export function processRequest(req: Request): Response {
// ...
}
// Arrow functions for callbacks
items.filter((item) => item.active);
promise.then((result) => {
// ...
});
```
### TypeScript
- All function declarations must have explicit return types. This is enforced by
ESLint.
- Using `any` is permitted where full typing would be impractical, but prefer
more specific types when reasonable.
- Prefer `Record<string, T>` over `{ [key: string]: T }` for mapped types.
- Use type assertions (`as`) sparingly and only when you have stronger type
knowledge than the compiler.
### Comments
Code should be self-documenting. Use comments sparingly and only when the "why"
is not obvious from the code itself. When you do comment, use inline `//` style.
Do not use JSDoc (`/** */`) or block comments (`/* */`).
```typescript
// Good: explains a non-obvious reason
// Escapes everything except alphanumerics and underscore
function escapeRegExp(str: string): string {
/* ... */
}
// Good: links to an external reference
// Source: http://stackoverflow.com/a/6969486
// Good: flags a known limitation
// TODO support "MD5-sess" algorithm directive
// Bad: restates what the code does
// Loop through each item and increment the counter
for (const item of items) {
counter++;
}
```
### Error Handling
Use `== null` (not `=== null || === undefined`) for null/undefined checks.
ESLint is configured to allow this.
## Testing
### When to Write Tests
Tests are most valuable for pure logic: parsers, data transformations,
algorithms, and utility functions where inputs and outputs are well defined. A
regression test accompanying a bug fix is also worthwhile to prevent the same
issue from resurfacing.
Not every change needs a test. Use judgment and consider the likelihood and cost
of breakage. Avoid writing tests that duplicate coverage already provided by
existing tests, and resist the urge to test trivial code just for the sake of
coverage numbers.
### Conventions
Tests use the Node.js built-in test runner (`node:test`) and assertion module
(`node:assert`). No external test libraries or mocking frameworks are used.
```typescript
import test from "node:test";
import assert from "node:assert";
void test("parseValue returns correct type for integer strings", () => {
const result = parseValue("42");
assert.strictEqual(result, 42);
});
void test("parseValue throws on invalid input", () => {
assert.throws(() => parseValue(""), new Error("empty value"));
});
```
Key conventions:
- Prefix `test()` calls with `void` to satisfy the `no-floating-promises` lint
rule.
- Keep tests flat. Do not nest `describe` blocks.
- Use descriptive test names that state what is being tested and the expected
outcome.
- Use `assert.strictEqual()` for value comparisons and
`assert.deepStrictEqual()` for objects and arrays.
Test files live in the `test/` directory and are named after the module they
test (e.g. `test/path.ts` tests `lib/common/path.ts`).
## Dependencies
This project deliberately keeps its dependency footprint small. Before adding a
new dependency:
- Prefer Node.js built-in APIs when they can do the job.
- Consider whether the functionality is simple enough to implement directly.
- Justify the addition in your PR description.
Do not add development tool dependencies (linter plugins, editor integrations,
etc.) without prior discussion.
## File Organization
| Directory | Contents |
| ------------- | -------------------------------------------- |
| `lib/` | Core server-side application code |
| `lib/common/` | Code shared between server and browser |
| `lib/db/` | Database layer (MongoDB) |
| `lib/ui/` | UI backend helpers |
| `ui/` | Frontend code (Mithril.js SPA) |
| `bin/` | Service entry points |
| `build/` | Build tooling |
| `test/` | Test files |
| `docs/` | User documentation (Sphinx/reStructuredText) |
| `public/` | Static assets (favicon, logo) |
Place new code in the directory that matches its purpose. Server-side logic
belongs in `lib/`, code that must run in both Node.js and the browser belongs in
`lib/common/`, and frontend-only code belongs in `ui/`.
## Documentation
User documentation lives in the `docs/` directory as reStructuredText files
built with Sphinx and published to
[docs.genieacs.com](https://docs.genieacs.com).
When your change affects user-facing behavior:
- Update the relevant documentation in `docs/`.
- If adding a new feature, consider whether it warrants a new page or a section
in an existing page.
- Keep documentation concise and practical. Match the existing tone: direct,
factual, no filler.
Documentation changes should be included in the same PR as the code change, not
submitted separately.
### ARCHITECTURE.md
`ARCHITECTURE.md` describes the high-level architecture of the project: the
service boundaries, major subsystems, data flow, and key invariants. It is aimed
at contributors who need a mental map of the codebase.
This file has a different update cadence than `docs/`. It should be revisited a
few times a year rather than kept in lockstep with every code change. When you
do update it, follow these principles:
- **Only describe things that are unlikely to change frequently.** Module
responsibilities, service boundaries, key data structures, and architectural
invariants belong here. Implementation details, function signatures, and
config option lists do not.
- **Name important files, modules, and types but do not link them.** Links go
stale. Encourage the reader to use symbol search to find named entities; this
also helps them discover related, similarly named things.
- **Keep it short.** Every recurring contributor will read it. A shorter
document is less likely to become stale and more likely to be maintained.
- **Describe the "what" and "where", not the "how".** This is a map of the
country, not an atlas of its states. Pull detailed explanations into inline
code comments or separate documents.
- **Call out architectural invariants explicitly.** Important invariants are
often expressed as the _absence_ of something (e.g., "the expression system
does not depend on service-specific code") and are hard to discover by reading
code alone.
## Git Workflow
### Commit Messages
This project follows the
[Conventional Commits](https://www.conventionalcommits.org/) format:
<type>(<scope>): <subject>
[optional body]
- Use the imperative mood, present tense: "Fix bug", not "Fixed bug" or "Fixes
bug".
- Do not capitalize the first letter of the subject (the type prefix handles
visual structure).
- Do not end the subject line with a period.
- Keep the subject line under 72 characters.
- When more context is helpful, add a body separated from the subject by a blank
line, wrapped at 72 characters. The goal is to provide enough information for
someone scanning the commit history to find a specific change (e.g. for
troubleshooting or rebasing) or to draft changelog entries for a release.
Don't be verbose, but don't be cryptic either.
#### Types
| Type | When to use |
| ---------- | --------------------------------------------------------------------------- |
| `fix` | Bug fixes |
| `feat` | New features or capabilities |
| `refactor` | Code restructuring with no behavior change |
| `test` | Adding or updating tests |
| `docs` | Documentation changes (`docs/`, `CONTRIBUTING.md`, `README.md`) |
| `build` | Build system, dependencies, esbuild config, `package.json` scripts |
| `chore` | Maintenance tasks that don't fit above (`.gitignore`, tooling config, etc.) |
#### Scopes
Scope is optional. Use it when it adds useful context; omit it when the change
is cross-cutting or the subject already makes it obvious.
| Scope | Covers |
| ------ | ----------------------------------- |
| `cwmp` | CWMP service (including extensions) |
| `nbi` | Northbound REST API service |
| `fs` | File service |
| `ui` | Web UI (frontend and backend) |
| `db` | Database layer |
If a change touches multiple scopes, either pick the primary one or omit the
scope entirely.
#### Examples
feat(nbi): add bulk device delete endpoint
fix(cwmp): handle missing ParameterKey in InformResponse
refactor(db): replace raw queries with parameterized calls
fix(ui): correct parameter table sort order
test: add XML parser edge case coverage
docs: update provisioning guide for new API
build: upgrade esbuild to v0.20
fix(cwmp): increase server timeout to 2 mins
To allow enough time for running unindexed queries in large
deployments.
### Branches
- `master` is the main development branch.
- Create a feature or fix branch for your work and open a PR against `master`.
- Use concise, descriptive branch names in lowercase with hyphens:
`fix-race-condition`, `support-xmpp-requests`.
## Changelog
The changelog (`CHANGELOG.md`) is maintained for each release and is written for
users and system administrators, not developers.
- Write entries as clear, user-facing prose. Do not copy commit messages
verbatim.
- Each entry should describe what changed and, when helpful, why it matters.
- Group entries under a version heading with the release date:
`## 1.2.13 (2024-06-06)`.
- Start each entry with a verb: "Fix", "Add", "Improve", "Remove", etc.
- Include enough context that a user can understand the impact without reading
the code.
You do not need to update the changelog in your PR. The maintainer will add
changelog entries during the release process.
## Contributor License Agreement ## Contributor License Agreement
By submitting a pull request to this repository, you acknowledge that, while By submitting a pull request to this repository, you acknowledge that, while
maintaining copyright, you grant Zaid Abdulla a perpetual, worldwide, non- maintaining copyright, you grant GenieACS Inc. a perpetual, worldwide,
exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare non-exclusive, no-charge, royalty-free, irrevocable license to reproduce,
derivative works of, publicly display, publicly perform, sublicense, and prepare derivative works of, publicly display, publicly perform, sublicense, and
distribute your contributions and such derivative works under the AGPLv3 license distribute your contributions and such derivative works under the AGPLv3 license
or any other license terms, including, but not limited to, proprietary or or any other license terms, including, but not limited to, proprietary or
commercial license terms. commercial license terms.
@@ -33,7 +350,3 @@ commercial license terms.
You confirm that you own or have rights to distribute and sublicense the source You confirm that you own or have rights to distribute and sublicense the source
code contained therein, and that your content does not infringe upon the code contained therein, and that your content does not infringe upon the
intellectual property rights of a third party. intellectual property rights of a third party.
[commit-guidelines]:
http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html
[forum]: https://forum.genieacs.com
+68
View File
@@ -0,0 +1,68 @@
# =========================================================
# GenieACS — UniFi Theme Fork
# Multi-stage build:
# Stage 1 (builder): instala devDeps + compila o projeto
# Stage 2 (runtime): imagem mínima só com dist/ + prodDeps
# =========================================================
# ── Stage 1: builder ─────────────────────────────────────
FROM node:20-alpine AS builder
# Dependências de sistema necessárias para o build
# (git é exigido pelo build.ts para calcular o metadata de versão)
RUN apk add --no-cache git python3 make g++
WORKDIR /build
# Copiar manifests primeiro para cache de camadas do npm install
COPY package.json npm-shrinkwrap.json ./
# Instalar TODAS as dependências (incluindo devDependencies)
RUN npm ci --fund=false --audit=false
# Copiar o restante do fonte
COPY . .
# Compilar o projeto (gera dist/)
RUN npm run build
# ── Stage 2: runtime ─────────────────────────────────────
FROM node:20-alpine AS runtime
# Timezone e utilitários mínimos
RUN apk add --no-cache tzdata dumb-init
WORKDIR /opt/genieacs
# Copiar artefatos compilados
COPY --from=builder /build/dist ./
# Instalar apenas dependências de produção a partir do dist/
RUN npm ci --omit=dev --fund=false --audit=false
# Criar diretórios de configuração e extensões
RUN mkdir -p \
/opt/genieacs/config/ext \
/opt/genieacs/logs \
&& addgroup -S genieacs \
&& adduser -S -G genieacs genieacs \
&& chown -R genieacs:genieacs /opt/genieacs
USER genieacs
# Variáveis de ambiente padrão (sobrescreva no compose ou .env)
ENV GENIEACS_MONGODB_CONNECTION_URL=mongodb://mongo:27017/genieacs \
GENIEACS_EXT_DIR=/opt/genieacs/config/ext \
GENIEACS_LOG_FORMAT=json \
GENIEACS_ACCESS_LOG_FORMAT=json \
GENIEACS_UI_JWT_SECRET=change-me-in-production \
GENIEACS_CWMP_WORKER_PROCESSES=1 \
GENIEACS_NBI_WORKER_PROCESSES=1 \
GENIEACS_FS_WORKER_PROCESSES=1 \
GENIEACS_UI_WORKER_PROCESSES=1
# Expor todas as portas dos serviços
EXPOSE 3000 7547 7557 7567
# dumb-init garante propagação correta de sinais
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+5 -1
View File
@@ -1,5 +1,9 @@
# GenieACS # GenieACS
**This is the development branch for GenieACS v1.3. It is unstable and not ready
for production use. For the latest stable release, see the
[v1.2 branch](https://github.com/genieacs/genieacs/tree/v1.2).**
GenieACS is a high performance Auto Configuration Server (ACS) for remote GenieACS is a high performance Auto Configuration Server (ACS) for remote
management of TR-069 enabled devices. It utilizes a declarative and fault management of TR-069 enabled devices. It utilizes a declarative and fault
tolerant configuration engine for automating complex provisioning scenarios at tolerant configuration engine for automating complex provisioning scenarios at
@@ -72,5 +76,5 @@ For commercial support options, please visit
## License ## License
Copyright 2013-2021 GenieACS Inc. GenieACS is released under the Copyright 2013-2026 GenieACS Inc. GenieACS is released under the
[AGPLv3 license terms](https://raw.githubusercontent.com/genieacs/genieacs/master/LICENSE). [AGPLv3 license terms](https://raw.githubusercontent.com/genieacs/genieacs/master/LICENSE).
-4
View File
@@ -1,4 +0,0 @@
export default {
extensions: ["ts"],
require: ["esm", "ts-node/register/transpile-only"],
};
+28 -33
View File
@@ -1,29 +1,10 @@
/** import * as config from "../lib/config.ts";
* Copyright 2013-2019 GenieACS Inc. import * as logger from "../lib/logger.ts";
* import * as cluster from "../lib/cluster.ts";
* This file is part of GenieACS. import * as server from "../lib/server.ts";
* import * as cwmp from "../lib/cwmp.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as db from "../lib/db/db.ts";
* it under the terms of the GNU Affero General Public License as import * as extensions from "../lib/extensions.ts";
* 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 config from "../lib/config";
import * as logger from "../lib/logger";
import * as cluster from "../lib/cluster";
import * as server from "../lib/server";
import * as cwmp from "../lib/cwmp";
import * as db from "../lib/db";
import * as extensions from "../lib/extensions";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("cwmp", VERSION); logger.init("cwmp", VERSION);
@@ -37,11 +18,13 @@ function exitWorkerGracefully(): void {
db.disconnect(), db.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ])
.then(logger.close)
.catch(exitWorkerUngracefully);
} }
function exitWorkerUngracefully(): void { function exitWorkerUngracefully(): void {
extensions.killAll().finally(() => { void extensions.killAll().finally(() => {
process.exit(1); process.exit(1);
}); });
} }
@@ -86,8 +69,14 @@ if (!cluster.worker) {
onClientError: cwmp.onClientError, onClientError: cwmp.onClientError,
timeout: 30000, timeout: 30000,
keepAliveTimeout: 0, keepAliveTimeout: 0,
requestTimeout: cwmp.REQUEST_TIMEOUT,
}; };
// Need this for Node < 15
process.on("unhandledRejection", (err) => {
throw err;
});
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return; if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return;
logger.error({ logger.error({
@@ -95,7 +84,7 @@ if (!cluster.worker) {
exception: err, exception: err,
pid: process.pid, pid: process.pid,
}); });
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop(false).then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
const initPromise = db const initPromise = db
@@ -110,14 +99,20 @@ if (!cluster.worker) {
}); });
process.on("SIGINT", () => { process.on("SIGINT", () => {
initPromise.finally(() => { void initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server
.stop(false)
.then(exitWorkerGracefully)
.catch(exitWorkerUngracefully);
}); });
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
initPromise.finally(() => { void initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server
.stop(false)
.then(exitWorkerGracefully)
.catch(exitWorkerUngracefully);
}); });
}); });
} }
+9 -22
View File
@@ -1,23 +1,4 @@
/** import { Fault } from "../lib/types.ts";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { Fault } from "../lib/types";
const jobs = new Set(); const jobs = new Set();
const fileName = process.argv[2]; const fileName = process.argv[2];
@@ -41,12 +22,12 @@ function errorToFault(err: Error): Fault {
fault.detail["stack"] = err.stack; fault.detail["stack"] = err.stack;
// Trim the stack trace // Trim the stack trace
const stackTrimIndex = fault.detail["stack"].match( const stackTrimIndex = fault.detail["stack"].match(
/\s+at\s[^\s]+\s\(.*genieacs-ext:.+\)/ /\s+at\s[^\s]+\s\(.*genieacs-ext:.+\)/,
); );
if (stackTrimIndex) { if (stackTrimIndex) {
fault.detail["stack"] = fault.detail["stack"].slice( fault.detail["stack"] = fault.detail["stack"].slice(
0, 0,
stackTrimIndex.index stackTrimIndex.index,
); );
} }
} }
@@ -54,6 +35,11 @@ function errorToFault(err: Error): Fault {
return fault; return fault;
} }
// Need this for Node < 15
process.on("unhandledRejection", (err) => {
throw err;
});
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
const fault = errorToFault(err); const fault = errorToFault(err);
jobs.forEach((jobId) => { jobs.forEach((jobId) => {
@@ -69,6 +55,7 @@ process.on("message", (message) => {
if (!script) { if (!script) {
const cwd = process.env["GENIEACS_EXT_DIR"]; const cwd = process.env["GENIEACS_EXT_DIR"];
process.chdir(cwd); process.chdir(cwd);
// eslint-disable-next-line @typescript-eslint/no-require-imports
script = require(`${cwd}/${fileName}`); script = require(`${cwd}/${fileName}`);
} }
+16 -40
View File
@@ -1,28 +1,9 @@
/** import * as config from "../lib/config.ts";
* Copyright 2013-2019 GenieACS Inc. import * as logger from "../lib/logger.ts";
* import * as cluster from "../lib/cluster.ts";
* This file is part of GenieACS. import * as server from "../lib/server.ts";
* import { listener } from "../lib/fs.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as db from "../lib/db/db.ts";
* 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 config from "../lib/config";
import * as logger from "../lib/logger";
import * as cluster from "../lib/cluster";
import * as server from "../lib/server";
import { listener } from "../lib/fs";
import * as db from "../lib/db";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("fs", VERSION); logger.init("fs", VERSION);
@@ -32,9 +13,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([db.disconnect(), cluster.worker.disconnect()]).catch( Promise.all([db.disconnect(), cluster.worker.disconnect()])
exitWorkerUngracefully .then(logger.close)
); .catch(exitWorkerUngracefully);
} }
function exitWorkerUngracefully(): void { function exitWorkerUngracefully(): void {
@@ -79,7 +60,10 @@ if (!cluster.worker) {
timeout: 30000, timeout: 30000,
}; };
let stopping = false; // Need this for Node < 15
process.on("unhandledRejection", (err) => {
throw err;
});
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return; if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return;
@@ -88,19 +72,13 @@ if (!cluster.worker) {
exception: err, exception: err,
pid: process.pid, pid: process.pid,
}); });
stopping = true;
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
const _listener = (req, res): void => {
if (stopping) res.setHeader("Connection", "close");
void listener(req, res);
};
const initPromise = db const initPromise = db
.connect() .connect()
.then(() => { .then(() => {
server.start(options, _listener); server.start(options, listener);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
@@ -109,15 +87,13 @@ if (!cluster.worker) {
}); });
process.on("SIGINT", () => { process.on("SIGINT", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
+19 -41
View File
@@ -1,29 +1,10 @@
/** import * as config from "../lib/config.ts";
* Copyright 2013-2019 GenieACS Inc. import * as logger from "../lib/logger.ts";
* import * as cluster from "../lib/cluster.ts";
* This file is part of GenieACS. import * as server from "../lib/server.ts";
* import { listener } from "../lib/nbi.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as db from "../lib/db/db.ts";
* it under the terms of the GNU Affero General Public License as import * as extensions from "../lib/extensions.ts";
* 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 config from "../lib/config";
import * as logger from "../lib/logger";
import * as cluster from "../lib/cluster";
import * as server from "../lib/server";
import { listener } from "../lib/nbi";
import * as db from "../lib/db";
import * as extensions from "../lib/extensions";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("nbi", VERSION); logger.init("nbi", VERSION);
@@ -37,11 +18,13 @@ function exitWorkerGracefully(): void {
db.disconnect(), db.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ])
.then(logger.close)
.catch(exitWorkerUngracefully);
} }
function exitWorkerUngracefully(): void { function exitWorkerUngracefully(): void {
extensions.killAll().finally(() => { void extensions.killAll().finally(() => {
process.exit(1); process.exit(1);
}); });
} }
@@ -81,10 +64,13 @@ 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,
}; };
let stopping = false; // Need this for Node < 15
process.on("unhandledRejection", (err) => {
throw err;
});
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return; if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return;
@@ -93,19 +79,13 @@ if (!cluster.worker) {
exception: err, exception: err,
pid: process.pid, pid: process.pid,
}); });
stopping = true;
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
const _listener = (req, res): void => {
if (stopping) res.setHeader("Connection", "close");
listener(req, res);
};
const initPromise = db const initPromise = db
.connect() .connect()
.then(() => { .then(() => {
server.start(options, _listener); server.start(options, listener);
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
@@ -114,15 +94,13 @@ if (!cluster.worker) {
}); });
process.on("SIGINT", () => { process.on("SIGINT", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
+19 -41
View File
@@ -1,29 +1,10 @@
/** import * as config from "../lib/config.ts";
* Copyright 2013-2019 GenieACS Inc. import * as logger from "../lib/logger.ts";
* import * as cluster from "../lib/cluster.ts";
* This file is part of GenieACS. import * as server from "../lib/server.ts";
* import { listener } from "../lib/ui.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as extensions from "../lib/extensions.ts";
* it under the terms of the GNU Affero General Public License as import * as db from "../lib/db/db.ts";
* 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 config from "../lib/config";
import * as logger from "../lib/logger";
import * as cluster from "../lib/cluster";
import * as server from "../lib/server";
import { listener } from "../lib/ui";
import * as extensions from "../lib/extensions";
import * as db from "../lib/db";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
logger.init("ui", VERSION); logger.init("ui", VERSION);
@@ -37,11 +18,13 @@ function exitWorkerGracefully(): void {
db.disconnect(), db.disconnect(),
extensions.killAll(), extensions.killAll(),
cluster.worker.disconnect(), cluster.worker.disconnect(),
]).catch(exitWorkerUngracefully); ])
.then(logger.close)
.catch(exitWorkerUngracefully);
} }
function exitWorkerUngracefully(): void { function exitWorkerUngracefully(): void {
extensions.killAll().finally(() => { void extensions.killAll().finally(() => {
process.exit(1); process.exit(1);
}); });
} }
@@ -81,10 +64,13 @@ 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,
}; };
let stopping = false; // Need this for Node < 15
process.on("unhandledRejection", (err) => {
throw err;
});
process.on("uncaughtException", (err) => { process.on("uncaughtException", (err) => {
if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return; if ((err as NodeJS.ErrnoException).code === "ERR_IPC_DISCONNECTED") return;
@@ -93,19 +79,13 @@ if (!cluster.worker) {
exception: err, exception: err,
pid: process.pid, pid: process.pid,
}); });
stopping = true;
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
const _listener = (req, res): void => {
if (stopping) res.setHeader("Connection", "close");
listener(req, res);
};
const initPromise = db const initPromise = db
.connect() .connect()
.then(() => { .then(() => {
server.start(options, _listener); server.start(options, async (req, res) => listener(req, res));
}) })
.catch((err) => { .catch((err) => {
setTimeout(() => { setTimeout(() => {
@@ -114,15 +94,13 @@ if (!cluster.worker) {
}); });
process.on("SIGINT", () => { process.on("SIGINT", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
stopping = true; void initPromise.finally(() => {
initPromise.finally(() => {
server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully); server.stop().then(exitWorkerGracefully).catch(exitWorkerUngracefully);
}); });
}); });
+5
View File
@@ -0,0 +1,5 @@
export const APP_JS = "app.js";
export const APP_CSS = "app.css";
export const ICONS_SVG = "icons.svg";
export const LOGO_SVG = "logo.svg";
export const FAVICON_PNG = "favicon.png";
+279 -234
View File
@@ -1,78 +1,43 @@
/** import path from "node:path";
* Copyright 2013-2019 GenieACS Inc. import fs from "node:fs";
* import { createHash } from "node:crypto";
* This file is part of GenieACS. import { promisify } from "node:util";
* import { exec } from "node:child_process";
* GenieACS is free software: you can redistribute it and/or modify import * as esbuild from "esbuild";
* 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 path from "path";
import * as fs from "fs";
import { rollup, WarningHandler } from "rollup";
import rollupJson from "@rollup/plugin-json";
import typescript from "@rollup/plugin-typescript";
import { terser } from "rollup-plugin-terser";
import nodeResolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import postcss from "postcss";
import postcssImport from "postcss-import";
import postcssPresetEnv from "postcss-preset-env";
import postcssColorMod from "postcss-color-mod-function";
import cssnano from "cssnano";
import { optimize } from "svgo"; import { optimize } from "svgo";
import * as xmlParser from "../lib/xml-parser"; import * as xmlParser from "../lib/xml-parser.ts";
const fsAsync = {
readdir: promisify(fs.readdir),
readFile: promisify(fs.readFile),
writeFile: promisify(fs.writeFile),
copyFile: promisify(fs.copyFile),
rename: promisify(fs.rename),
chmod: promisify(fs.chmod),
lstat: promisify(fs.lstat),
exists: promisify(fs.exists),
rmdir: promisify(fs.rmdir),
unlink: promisify(fs.unlink),
mkdir: promisify(fs.mkdir),
};
const execAsync = promisify(exec);
const MODE = process.env["NODE_ENV"] || "production"; const MODE = process.env["NODE_ENV"] || "production";
const BUILD_METADATA = new Date() const INPUT_DIR = process.cwd();
.toISOString() const OUTPUT_DIR = path.join(INPUT_DIR, "dist");
.split(".")[0]
.replace(/[^0-9]/g, "");
const INPUT_DIR = path.resolve(__dirname, ".."); async function rmDir(dirPath: string): Promise<void> {
const OUTPUT_DIR = path.resolve(__dirname, "../dist"); if (!(await fsAsync.exists(dirPath))) return;
const files = await fsAsync.readdir(dirPath);
const builtins = [
"path",
"fs",
"cluster",
"os",
"tls",
"http",
"https",
"zlib",
"crypto",
"util",
"vm",
"querystring",
"child_process",
"dgram",
"url",
"readline",
"stream",
];
function rmDirSync(dirPath): void {
if (!fs.existsSync(dirPath)) return;
const files = fs.readdirSync(dirPath);
for (const file of files) { for (const file of files) {
const filePath = `${dirPath}/${file}`; const filePath = path.join(dirPath, file);
if (fs.statSync(filePath).isFile()) fs.unlinkSync(filePath); if ((await fsAsync.lstat(filePath)).isDirectory()) await rmDir(filePath);
else rmDirSync(filePath); else await fsAsync.unlink(filePath);
} }
fs.rmdirSync(dirPath); await fsAsync.rmdir(dirPath);
} }
// For lockfileVersion = 1 // For lockfileVersion = 1
@@ -103,6 +68,82 @@ function xmlTostring(xml): string {
: `<${xml.name} ${xml.attrs}>${children.join("")}</${xml.name}>`; : `<${xml.name} ${xml.attrs}>${children.join("")}</${xml.name}>`;
} }
function assetHash(buffer: Buffer | string): string {
return createHash("md5").update(buffer).digest("hex").slice(0, 8);
}
const ASSETS = {} as {
APP_JS?: string;
APP_CSS?: string;
ICONS_SVG?: string;
LOGO_SVG?: string;
FAVICON_PNG?: string;
};
const assetsPlugin = {
name: "assets",
setup(build) {
build.onLoad({ filter: /\/build\/assets.ts$/ }, () => {
const lines = Object.entries(ASSETS).map(
([k, v]) => `export const ${k} = ${JSON.stringify(v)};`,
);
return { contents: lines.join("\n") };
});
},
} as esbuild.Plugin;
const seedPlugin = {
name: "seed",
setup(build) {
build.onLoad({ filter: /\/seed\// }, (args) => {
if (args.with?.["type"] !== "text") return undefined;
let contents = fs.readFileSync(args.path, "utf8");
// Strip TypeScript directives that are only needed for type-checking
contents = contents.replace(/^\s*\/\/\s*@ts-.*\n/gm, "");
return { contents, loader: "text" };
});
},
} as esbuild.Plugin;
const packageDotJsonPlugin = {
name: "packageDotJson",
setup(build) {
const sourcePath = path.join(INPUT_DIR, "package.json");
build.onResolve({ filter: /\/package.json$/ }, (args) => {
const p = path.join(args.resolveDir, args.path);
if (p !== sourcePath) return undefined;
return { path: path.join(OUTPUT_DIR, "package.json") };
});
},
} as esbuild.Plugin;
const inlineDepsPlugin = {
name: "inlineDeps",
setup(build) {
const deps = ["espresso-iisojs", "@codemirror", "mithril", "yaml"];
const depFiles = new Set();
build.onResolve({ filter: /^[^.]/ }, async (args) => {
if (args.pluginData === "inlineDeps") return undefined;
if (
depFiles.has(args.importer) ||
deps.some((d) => args.path.startsWith(d))
) {
const res = await build.resolve(args.path, {
importer: args.importer,
namespace: args.namespace,
resolveDir: args.resolveDir,
kind: args.kind,
with: args.with,
pluginData: "inlineDeps",
});
depFiles.add(res.path);
return res;
}
return { sideEffects: false, external: true };
});
},
} as esbuild.Plugin;
function generateSymbol(id: string, svgStr: string): string { function generateSymbol(id: string, svgStr: string): string {
const xml = xmlParser.parseXml(svgStr); const xml = xmlParser.parseXml(svgStr);
const svg = xml.children[0]; const svg = xml.children[0];
@@ -120,43 +161,60 @@ function generateSymbol(id: string, svgStr: string): string {
return `<symbol id="icon-${id}" ${viewBox}>${symbolBody}</symbol>`; return `<symbol id="icon-${id}" ${viewBox}>${symbolBody}</symbol>`;
} }
async function init(): Promise<string[]> { async function getBuildMetadata(): Promise<string> {
// Delete any old output directory const date = new Date().toISOString().slice(2, 10).replaceAll("-", "");
rmDirSync(OUTPUT_DIR);
// Create output directory layout const [commit, diff, newFiles] = await Promise.all([
fs.mkdirSync(OUTPUT_DIR); execAsync("git rev-parse HEAD"),
fs.mkdirSync(OUTPUT_DIR + "/bin"); execAsync("git diff HEAD"),
fs.mkdirSync(OUTPUT_DIR + "/public"); execAsync("git ls-files --others --exclude-standard"),
]).then((res) => res.map((r) => r.stdout.trim()));
// Create package.json if (!diff && !newFiles) return date + commit.slice(0, 4);
const packageJson = JSON.parse(
fs.readFileSync(path.resolve(INPUT_DIR, "package.json")).toString() const hash = createHash("md5");
hash.update(commit).update(diff).update(newFiles);
for (const file of newFiles.split("\n").filter((f) => f))
hash.update(await fsAsync.readFile(file));
return date + hash.digest("hex").slice(0, 4);
}
async function init(): Promise<void> {
const [buildMetadata, packageJsonFile, npmShrinkwrapFile] = await Promise.all(
[
getBuildMetadata(),
fsAsync.readFile(path.join(INPUT_DIR, "package.json")),
fsAsync.readFile(path.join(INPUT_DIR, "npm-shrinkwrap.json")),
],
); );
const packageJson = JSON.parse(packageJsonFile.toString());
delete packageJson["devDependencies"]; delete packageJson["devDependencies"];
delete packageJson["private"]; delete packageJson["private"];
packageJson["scripts"] = { delete packageJson["scripts"];
install: packageJson["scripts"].install, packageJson["version"] = `${packageJson["version"]}+${buildMetadata}`;
configure: packageJson["scripts"].configure,
};
packageJson["version"] = `${packageJson["version"]}+${BUILD_METADATA}`;
fs.writeFileSync(
path.resolve(OUTPUT_DIR, "package.json"),
JSON.stringify(packageJson, null, 2)
);
// Create npm-shrinkwrap.json const npmShrinkwrap = JSON.parse(npmShrinkwrapFile.toString());
const npmShrinkwrapJson = JSON.parse( npmShrinkwrap["version"] = packageJson["version"];
fs.readFileSync(path.resolve(INPUT_DIR, "npm-shrinkwrap.json")).toString() stripDevDeps(npmShrinkwrap);
); stripDevDeps2(npmShrinkwrap);
npmShrinkwrapJson["version"] = packageJson["version"];
stripDevDeps(npmShrinkwrapJson); await rmDir(OUTPUT_DIR);
stripDevDeps2(npmShrinkwrapJson);
fs.writeFileSync( await fsAsync.mkdir(OUTPUT_DIR);
path.resolve(OUTPUT_DIR, "npm-shrinkwrap.json"),
JSON.stringify(npmShrinkwrapJson, null, 2) await Promise.all([
); fsAsync.mkdir(path.join(OUTPUT_DIR, "bin")),
return Object.keys(packageJson["dependencies"]); fsAsync.mkdir(path.join(OUTPUT_DIR, "public")),
fsAsync.writeFile(
path.join(OUTPUT_DIR, "package.json"),
JSON.stringify(packageJson, null, 2),
),
fsAsync.writeFile(
path.join(OUTPUT_DIR, "npm-shrinkwrap.json"),
JSON.stringify(npmShrinkwrap, null, 2),
),
]);
} }
async function copyStatic(): Promise<void> { async function copyStatic(): Promise<void> {
@@ -168,149 +226,139 @@ async function copyStatic(): Promise<void> {
"public/favicon.png", "public/favicon.png",
]; ];
for (const file of files) { const [logo, favicon] = await Promise.all([
fs.copyFileSync( fsAsync.readFile(path.join(INPUT_DIR, "public/logo.svg")),
path.resolve(INPUT_DIR, file), fsAsync.readFile(path.join(INPUT_DIR, "public/favicon.png")),
path.resolve(OUTPUT_DIR, file) ]);
);
} ASSETS.LOGO_SVG = `logo-${assetHash(logo)}.svg`;
ASSETS.FAVICON_PNG = `favicon-${assetHash(favicon)}.png`;
const filenames = {} as Record<string, string>;
filenames["public/logo.svg"] = path.join("public", ASSETS.LOGO_SVG);
filenames["public/favicon.png"] = path.join("public", ASSETS.FAVICON_PNG);
await Promise.all(
files.map((f) =>
fsAsync.copyFile(
path.join(INPUT_DIR, f),
path.join(OUTPUT_DIR, filenames[f] || f),
),
),
);
} }
async function generateCss(): Promise<void> { async function generateCss(): Promise<void> {
const cssInPath = path.resolve(INPUT_DIR, "ui/css/app.css"); const tailwindPlugin = {
const cssOutPath = path.resolve(OUTPUT_DIR, "public/app.css"); name: "tailwind",
const cssIn = fs.readFileSync(cssInPath); setup(build) {
const cssOut = await postcss([ build.onLoad({ filter: /\/ui\/css\/app.css$/ }, async (args) => {
postcssImport, const res = await execAsync(`npx @tailwindcss/cli -i ${args.path}`);
postcssPresetEnv({ return { loader: "css", contents: res.stdout };
stage: 3, });
features: { },
"nesting-rules": true, } as esbuild.Plugin;
},
}), const res = await esbuild.build({
postcssColorMod, bundle: true,
...(MODE === "production" ? [cssnano] : []), absWorkingDir: INPUT_DIR,
]).process(cssIn, { from: cssInPath, to: cssOutPath }); minify: MODE === "production",
fs.writeFileSync(cssOutPath, cssOut.css); sourcemap: "linked",
sourcesContent: false,
entryPoints: ["ui/css/app.css"],
entryNames: "[dir]/[name]-[hash]",
outfile: path.join(OUTPUT_DIR, "public/app.css"),
plugins: [tailwindPlugin],
loader: {
".woff2": "dataurl",
},
target: ["chrome111", "safari16.4", "firefox128"],
metafile: true,
});
for (const [k, v] of Object.entries(res.metafile.outputs)) {
if (v.entryPoint === "ui/css/app.css") {
ASSETS.APP_CSS = path.relative(
path.join(OUTPUT_DIR, "public"),
path.join(INPUT_DIR, k),
);
break;
}
}
} }
async function generateBackendJs(externals: string[]): Promise<void> { async function generateBackendJs(): Promise<void> {
for (const bin of [ const services = [
"genieacs-cwmp", "genieacs-cwmp",
"genieacs-ext", "genieacs-ext",
"genieacs-nbi", "genieacs-nbi",
"genieacs-fs", "genieacs-fs",
"genieacs-ui", "genieacs-ui",
]) { ];
const inputFile = path.resolve(INPUT_DIR, `bin/${bin}.ts`);
const outputFile = path.resolve(OUTPUT_DIR, `bin/${bin}`);
const bundle = await rollup({
input: inputFile,
external: [...builtins, ...externals],
acorn: {
allowHashBang: true,
},
treeshake: {
propertyReadSideEffects: false,
moduleSideEffects: false,
},
plugins: [
rollupJson({ preferConst: true }),
{
name: "",
resolveId: (importee, importer) => {
if (importee.endsWith("/package.json")) {
const p = path.resolve(path.dirname(importer), importee);
if (p === path.resolve(INPUT_DIR, "package.json"))
return path.resolve(OUTPUT_DIR, "package.json");
}
return null;
},
},
typescript({
tsconfig: "./tsconfig.json",
include: [`bin/${bin}.ts`, "lib/**/*.ts"],
}),
MODE === "production" ? terser() : null,
],
});
await bundle.write({ await esbuild.build({
format: "cjs", bundle: true,
preferConst: true, absWorkingDir: INPUT_DIR,
sourcemap: "inline", minify: MODE === "production",
sourcemapExcludeSources: true, sourcemap: "inline",
banner: "#!/usr/bin/env node", sourcesContent: false,
file: outputFile, platform: "node",
}); target: "node12.13.0",
packages: "external",
banner: { js: "#!/usr/bin/env node" },
entryPoints: services.map((s) => `bin/${s}.ts`),
outdir: path.join(OUTPUT_DIR, "bin"),
plugins: [packageDotJsonPlugin, assetsPlugin, seedPlugin],
});
for (const bin of services) {
const p = path.join(OUTPUT_DIR, "bin", bin);
await fsAsync.rename(`${p}.js`, p);
// Mark as executable // Mark as executable
const mode = fs.statSync(outputFile).mode; const mode = (await fsAsync.lstat(p)).mode;
fs.chmodSync(outputFile, mode | 73); await fsAsync.chmod(p, mode | 73);
} }
} }
async function generateFrontendJs(externals: string[]): Promise<void> { async function generateFrontendJs(): Promise<void> {
const inputFile = path.resolve(INPUT_DIR, "ui/app.ts"); const res = await esbuild.build({
const outputDir = path.resolve(OUTPUT_DIR, "public"); bundle: true,
absWorkingDir: INPUT_DIR,
const inlineDeps = ["parsimmon", "espresso-iisojs"]; splitting: true,
const bundle = await rollup({ minify: MODE === "production",
input: inputFile, sourcemap: "linked",
external: [ sourcesContent: false,
...builtins, platform: "browser",
...externals.filter((e) => !inlineDeps.includes(e)), format: "esm",
], target: ["chrome111", "safari16.4", "firefox128"],
plugins: [ entryPoints: ["ui/app.ts"],
rollupJson({ preferConst: true }), entryNames: "[dir]/[name]-[hash]",
{ outdir: path.join(OUTPUT_DIR, "public"),
name: "", plugins: [packageDotJsonPlugin, inlineDepsPlugin, assetsPlugin],
resolveId: function (importee, importer) { metafile: true,
if (importee.endsWith("/package.json")) {
const p = path.resolve(path.dirname(importer), importee);
if (p === path.resolve(INPUT_DIR, "package.json"))
return path.resolve(OUTPUT_DIR, "package.json");
}
return null;
},
},
typescript({ tsconfig: "./tsconfig.json" }),
nodeResolve(),
commonjs(),
MODE === "production" ? terser() : null,
],
preserveEntrySignatures: false,
treeshake: {
propertyReadSideEffects: false,
moduleSideEffects: false,
},
onwarn: ((warning, warn) => {
// Ignore circular dependency warnings
if (warning.code !== "CIRCULAR_DEPENDENCY") warn(warning);
}) as WarningHandler,
}); });
await bundle.write({ for (const [k, v] of Object.entries(res.metafile.outputs)) {
manualChunks: (id) => { for (const imp of v.imports)
if (id.includes("node_modules/codemirror")) return "codemirror"; if (imp.external) throw new Error(`External import found: ${imp.path}`);
else if (id.includes("node_modules/yaml")) return "yaml";
return "app"; if (v.entryPoint === "ui/app.ts") {
}, ASSETS.APP_JS = path.relative(
preferConst: true, path.join(OUTPUT_DIR, "public"),
format: "es", path.join(INPUT_DIR, k),
sourcemap: true, );
sourcemapExcludeSources: true, }
dir: outputDir, }
});
} }
async function generateIconsSprite(): Promise<void> { async function generateIconsSprite(): Promise<void> {
const symbols = []; const symbols = [] as string[];
const iconsDir = path.resolve(INPUT_DIR, "ui/icons"); const iconsDir = path.join(INPUT_DIR, "ui/icons");
for (const file of fs.readdirSync(iconsDir)) { for (const file of await fsAsync.readdir(iconsDir)) {
const id = path.parse(file).name; const id = path.parse(file).name;
const filePath = path.join(iconsDir, file); const filePath = path.join(iconsDir, file);
const { data } = await optimize(fs.readFileSync(filePath).toString(), { const src = (await fsAsync.readFile(filePath)).toString();
const { data } = await optimize(src, {
plugins: [ plugins: [
{ {
name: "preset-default", name: "preset-default",
@@ -324,28 +372,25 @@ async function generateIconsSprite(): Promise<void> {
}); });
symbols.push(generateSymbol(id, data)); symbols.push(generateSymbol(id, data));
} }
fs.writeFileSync( const data = `<svg xmlns="http://www.w3.org/2000/svg">${symbols.join(
path.resolve(OUTPUT_DIR, "public/icons.svg"), "",
`<svg xmlns="http://www.w3.org/2000/svg">${symbols.join("")}</svg>` )}</svg>`;
ASSETS.ICONS_SVG = `icons-${assetHash(data)}.svg`;
await fsAsync.writeFile(
path.join(OUTPUT_DIR, "public", ASSETS.ICONS_SVG),
data,
); );
} }
init() init()
.then((externals) => { .then(() =>
Promise.all([ Promise.all([
copyStatic(), Promise.all([generateIconsSprite(), copyStatic()]).then(
generateFrontendJs,
),
generateCss(), generateCss(),
generateIconsSprite(), ]).then(generateBackendJs),
generateBackendJs(externals), )
generateFrontendJs(externals),
])
.then(() => {
// Ignore
})
.catch((err) => {
process.stderr.write(err.stack + "\n");
});
})
.catch((err) => { .catch((err) => {
process.stderr.write(err.stack + "\n"); process.stderr.write(err.stack + "\n");
}); });
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
cd "$(dirname "$0")"
declare -A FONTS=(
[InterVariable]=https://rsms.me/inter/font-files/InterVariable.woff2
[InterVariable-Italic]=https://rsms.me/inter/font-files/InterVariable-Italic.woff2
[RobotoMono]=https://raw.githubusercontent.com/googlefonts/RobotoMono/main/fonts/variable/RobotoMono%5Bwght%5D.ttf
)
for name in "${!FONTS[@]}"
do
url="${FONTS[$name]}"
echo "Downloading $url"
TMP=$(mktemp)
curl "$url" -s --output "$TMP"
pyftsubset "$TMP" --output-file="../ui/css/$name.woff2" --flavor=woff2 --unicodes="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" --layout-features+="tnum"
done
+55
View File
@@ -0,0 +1,55 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execPromise = promisify(exec);
async function runEslint(): Promise<string> {
const CMD =
"eslint 'bin/*.ts' 'lib/**/*.ts' 'ui/**/*.ts' 'test/**/*.ts' 'build/**/*.ts' 'seed/*'";
const env = {
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
...process.env,
};
try {
const { stdout, stderr } = await execPromise(CMD, { env });
if (stderr) throw new Error(stderr);
return stdout;
} catch (err) {
if (err.killed || err.signal || err.stderr || err.code !== 1) throw err;
return err.stdout;
}
}
async function runTsc(): Promise<string> {
const CMD = "tsc --noEmit && tsc -p seed/tsconfig.json";
const env = {
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
...process.env,
};
const { stdout, stderr } = await execPromise(CMD, { env });
if (stderr) throw new Error(stderr);
return stdout;
}
async function runPrettier(): Promise<string> {
const CMD = "prettier --prose-wrap always --write .";
const env = {
...(process.stdout.isTTY && { FORCE_COLOR: "1" }),
...process.env,
};
const { stdout, stderr } = await execPromise(CMD, { env });
if (stderr) throw new Error(stderr);
return stdout;
}
async function runAll(): Promise<void> {
const prom1 = runPrettier();
const prom2 = runEslint();
const prom3 = runTsc();
console.log(await prom1);
console.log(await prom2);
console.log(await prom3);
}
runAll().catch(console.error);
+4
View File
@@ -174,3 +174,7 @@ skipRootGpn
GPN GPN
NaN NaN
CIDR CIDR
XMPP
PEM
JID
unindexed
+134
View File
@@ -0,0 +1,134 @@
services:
# ── MongoDB ──────────────────────────────────────────────────────────────
mongo:
image: mongo:6
container_name: genieacs-mongo
restart: unless-stopped
volumes:
- mongo_data:/data/db
- mongo_config:/data/configdb
networks:
- genieacs
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ── CWMP — TR-069 protocol handler (porta 7547) ──────────────────────────
cwmp:
build:
context: .
dockerfile: Dockerfile
target: runtime
image: genieacs-unifi:latest
container_name: genieacs-cwmp
restart: unless-stopped
command: ["node", "bin/genieacs-cwmp"]
ports:
- "7547:7547"
env_file: .env
environment:
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
depends_on:
mongo:
condition: service_healthy
volumes:
- ext_dir:/opt/genieacs/config/ext
- logs:/opt/genieacs/logs
networks:
- genieacs
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7547/ || true"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
# ── NBI — Northbound REST API (porta 7557) ───────────────────────────────
nbi:
image: genieacs-unifi:latest
container_name: genieacs-nbi
restart: unless-stopped
command: ["node", "bin/genieacs-nbi"]
ports:
- "7557:7557"
env_file: .env
environment:
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
depends_on:
mongo:
condition: service_healthy
cwmp:
condition: service_started
volumes:
- ext_dir:/opt/genieacs/config/ext
- logs:/opt/genieacs/logs
networks:
- genieacs
# ── FS — File Server / GridFS (porta 7567) ───────────────────────────────
fs:
image: genieacs-unifi:latest
container_name: genieacs-fs
restart: unless-stopped
command: ["node", "bin/genieacs-fs"]
ports:
- "7567:7567"
env_file: .env
environment:
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
depends_on:
mongo:
condition: service_healthy
cwmp:
condition: service_started
volumes:
- ext_dir:/opt/genieacs/config/ext
- logs:/opt/genieacs/logs
networks:
- genieacs
# ── UI — Web Interface (porta 3000) ──────────────────────────────────────
ui:
image: genieacs-unifi:latest
container_name: genieacs-ui
restart: unless-stopped
command: ["node", "bin/genieacs-ui"]
ports:
- "3000:3000"
env_file: .env
environment:
GENIEACS_MONGODB_CONNECTION_URL: mongodb://mongo:27017/genieacs
depends_on:
mongo:
condition: service_healthy
nbi:
condition: service_started
volumes:
- ext_dir:/opt/genieacs/config/ext
- logs:/opt/genieacs/logs
networks:
- genieacs
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
mongo_data:
driver: local
mongo_config:
driver: local
ext_dir:
driver: local
logs:
driver: local
networks:
genieacs:
driver: bridge
+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 -1
View File
@@ -165,7 +165,7 @@ FS_ACCESS_LOG_FILE
Default: unset Default: unset
FS_URL_PREFIX FS_URL_PREFIX
The URL prefix (e.g. 'https://example.com:7657/') to use when generating the The URL prefix (e.g. 'https://example.com:7567/') to use when generating the
file URL for TR-069 Download requests. Set this if genieacs-fs and file URL for TR-069 Download requests. Set this if genieacs-fs and
genieacs-cwmp are behind a proxy or running on different servers. genieacs-cwmp are behind a proxy or running on different servers.
+1 -1
View File
@@ -16,7 +16,7 @@ function latlong(args, callback) {
.get("http://api.open-notify.org/iss-now.json", (res) => { .get("http://api.open-notify.org/iss-now.json", (res) => {
if (res.statusCode !== 200) if (res.statusCode !== 200)
return callback( return callback(
new Error(`Request failed (status code: ${res.statusCode})`) new Error(`Request failed (status code: ${res.statusCode})`),
); );
let rawData = ""; let rawData = "";
+6 -1
View File
@@ -75,7 +75,12 @@ Configure systemd
GENIEACS_DEBUG_FILE=/var/log/genieacs/genieacs-debug.yaml GENIEACS_DEBUG_FILE=/var/log/genieacs/genieacs-debug.yaml
NODE_OPTIONS=--enable-source-maps NODE_OPTIONS=--enable-source-maps
GENIEACS_EXT_DIR=/opt/genieacs/ext GENIEACS_EXT_DIR=/opt/genieacs/ext
GENIEACS_UI_JWT_SECRET=secret
Generate a secure JWT secret and append to :file:`/opt/genieacs/genieacs.env`:
.. code:: bash
node -e "console.log(\"GENIEACS_UI_JWT_SECRET=\" + require('crypto').randomBytes(128).toString('hex'))" >> /opt/genieacs/genieacs.env
Set file ownership and permissions: Set file ownership and permissions:
+1
View File
@@ -0,0 +1 @@
sphinx_rtd_theme==2.0.0
+77
View File
@@ -0,0 +1,77 @@
import { defineConfig } from "@eslint/config-helpers";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import globals from "globals";
export default defineConfig(
{
ignores: ["dist/", "test/*.js"],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
languageOptions: {
globals: {
...globals.es2022,
...globals.node,
},
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-shadow": ["error", { allow: ["err"] }],
"handle-callback-err": "error",
"prefer-arrow-callback": "error",
"no-buffer-constructor": "error",
"prefer-const": ["error", { destructuring: "all" }],
eqeqeq: ["error", "always", { null: "ignore" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-use-before-define": [
"error",
{ functions: false },
],
"@typescript-eslint/explicit-function-return-type": [
"error",
{ allowExpressions: true },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"no-prototype-builtins": "off",
},
},
{
files: ["ui/**/*.ts", "ui/**/*.tsx"],
languageOptions: {
globals: {
...globals.browser,
},
},
},
{
files: ["seed/*.js"],
languageOptions: {
parserOptions: {
project: null,
},
globals: {
declare: "readonly",
clear: "readonly",
commit: "readonly",
ext: "readonly",
log: "readonly",
args: "readonly",
},
},
rules: {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-shadow": "off",
"no-shadow": ["error", { allow: ["err", "total"] }],
},
},
);
+272 -133
View File
@@ -1,151 +1,168 @@
/** import { ObjectId } from "mongodb";
* Copyright 2013-2019 GenieACS Inc. import { collections } from "./db/db.ts";
*
* 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 db from "./db";
import * as common from "./common";
import * as cache from "./cache";
import { import {
getCurrentSnapshot, deleteConfig,
deleteFault as dbDeleteFault,
deleteFile,
deletePermission,
deletePreset,
deleteProvision,
deleteTask,
deleteUser,
deleteVirtualParameter,
putConfig,
putPermission,
putPreset,
putProvision,
putUser,
putVirtualParameter,
} from "./ui/db.ts";
import * as common from "./util.ts";
import * as cache from "./cache.ts";
import { acquireLock, getToken, releaseLock } from "./lock.ts";
import {
getRevision,
getConfig, getConfig,
getConfigExpression, getConfigExpression,
} from "./local-cache"; getUsers,
} from "./ui/local-cache.ts";
import { import {
httpConnectionRequest, httpConnectionRequest,
udpConnectionRequest, udpConnectionRequest,
} from "./connection-request"; xmppConnectionRequest,
import { Expression, Task } from "./types"; } from "./connection-request.ts";
import { flattenDevice } from "./mongodb-functions"; import { Task } from "./types.ts";
import { evaluate } from "./common/expression"; import Expression, { Value } from "./common/expression.ts";
import { hashPassword } from "./auth.ts";
import { flattenDevice } from "./ui/db.ts";
import { ResourceLockedError } from "./common/errors.ts";
import * as config from "../lib/config.ts";
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>,
): Promise<string> { ): Promise<string> {
if (!device) { if (!device) {
const res = await db.devicesCollection.findOne({ _id: deviceId }); const res = await collections.devices.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 =
"InternetGatewayDevice.ManagementServer.ConnectionRequestURL" device["InternetGatewayDevice.ManagementServer.ConnectionRequestURL"] ||
].value || [""])[0]; "";
udpConnectionRequestAddress = (( udpConnectionRequestAddress =
device[ device[
"InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress" "InternetGatewayDevice.ManagementServer.UDPConnectionRequestAddress"
] || {} ] || "";
).value || [""])[0]; stunEnable =
username = (( device["InternetGatewayDevice.ManagementServer.STUNEnable"] || "";
connReqJabberId =
device["InternetGatewayDevice.ManagementServer.ConnReqJabberID"] || "";
username =
device[ device[
"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername" "InternetGatewayDevice.ManagementServer.ConnectionRequestUsername"
] || {} ] || "";
).value || [""])[0]; password =
password = ((
device[ device[
"InternetGatewayDevice.ManagementServer.ConnectionRequestPassword" "InternetGatewayDevice.ManagementServer.ConnectionRequestPassword"
] || {} ] || "";
).value || [""])[0];
} else { } else {
connectionRequestUrl = (device[ connectionRequestUrl =
"Device.ManagementServer.ConnectionRequestURL" device["Device.ManagementServer.ConnectionRequestURL"] || "";
].value || [""])[0]; udpConnectionRequestAddress =
udpConnectionRequestAddress = (( device["Device.ManagementServer.UDPConnectionRequestAddress"] || "";
device["Device.ManagementServer.UDPConnectionRequestAddress"] || {} stunEnable = device["Device.ManagementServer.STUNEnable"] || "";
).value || [""])[0]; connReqJabberId = device["Device.ManagementServer.ConnReqJabberID"] || "";
username = (( username =
device["Device.ManagementServer.ConnectionRequestUsername"] || {} device["Device.ManagementServer.ConnectionRequestUsername"] || "";
).value || [""])[0]; password =
password = (( device["Device.ManagementServer.ConnectionRequestPassword"] || "";
device["Device.ManagementServer.ConnectionRequestPassword"] || {} }
).value || [""])[0]; let remoteAddress;
try {
remoteAddress = new URL(connectionRequestUrl).hostname;
} catch {
return "Invalid connection request URL";
} }
const remoteAddress = new URL(connectionRequestUrl).hostname; const snapshot = await getRevision();
const now = Date.now();
const evalCallback = (exp): Expression => { const evalCallback = (exp: Expression): Expression => {
if (!Array.isArray(exp)) return exp; if (exp instanceof Expression.Parameter) {
if (exp[0] === "PARAM" && typeof exp[1] === "string") { let name = exp.path.toString();
let name = exp[1];
if (name === "id") name = "DeviceID.ID"; if (name === "id") name = "DeviceID.ID";
else if (name === "serialNumber") name = "DeviceID.SerialNumber"; else if (name === "serialNumber") name = "DeviceID.SerialNumber";
else if (name === "productClass") name = "DeviceID.ProductClass"; else if (name === "productClass") name = "DeviceID.ProductClass";
else if (name === "oui") name = "DeviceID.OUI"; else if (name === "oui") name = "DeviceID.OUI";
else if (name === "remoteAddress") return remoteAddress; else if (name === "remoteAddress")
else if (name === "username") return username; return new Expression.Literal(remoteAddress);
else if (name === "password") return password; else if (name === "username") return new Expression.Literal(username);
else if (name === "password") return new Expression.Literal(password);
const p = device[name]; return new Expression.Literal(device[name] ?? null);
if (p?.value) return p.value[0]; } else if (exp instanceof Expression.FunctionCall) {
} else if (exp[0] === "FUNC") { if (exp.name === "NOW") return new Expression.Literal(now);
if (exp[1] === "REMOTE_ADDRESS") return remoteAddress; else if (exp.name === "REMOTE_ADDRESS")
else if (exp[1] === "USERNAME") return username; return new Expression.Literal(remoteAddress);
else if (exp[1] === "PASSWORD") return password; else if (exp.name === "USERNAME") return new Expression.Literal(username);
else if (exp.name === "PASSWORD") return new Expression.Literal(password);
} }
return exp; return exp;
}; };
const snapshot = await getCurrentSnapshot(); const configCallback = (exp: Expression): Expression.Literal => {
const now = Date.now(); const e = evalCallback(exp);
const UDP_CONNECTION_REQUEST_PORT = +getConfig( if (e instanceof Expression.Literal) return e;
return new Expression.Literal(null);
};
const UDP_CONNECTION_REQUEST_PORT = getConfig(
snapshot, snapshot,
"cwmp.udpConnectionRequestPort", "cwmp.udpConnectionRequestPort",
{}, 0,
now, configCallback,
evalCallback
); );
const CONNECTION_REQUEST_TIMEOUT = +getConfig( const CONNECTION_REQUEST_TIMEOUT = getConfig(
snapshot, snapshot,
"cwmp.connectionRequestTimeout", "cwmp.connectionRequestTimeout",
{}, 2000,
now, configCallback,
evalCallback
); );
const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = !!getConfig( const CONNECTION_REQUEST_ALLOW_BASIC_AUTH = getConfig(
snapshot, snapshot,
"cwmp.connectionRequestAllowBasicAuth", "cwmp.connectionRequestAllowBasicAuth",
{}, false,
now, configCallback,
evalCallback
); );
let authExp: Expression = getConfigExpression( let authExp: Expression = getConfigExpression(
snapshot, snapshot,
"cwmp.connectionRequestAuth" "cwmp.connectionRequestAuth",
); );
if (authExp === undefined) { if (!authExp) {
authExp = [ authExp = new Expression.FunctionCall("AUTH", [
"FUNC", new Expression.FunctionCall("USERNAME", []),
"AUTH", new Expression.FunctionCall("PASSWORD", []),
["PARAM", "username"], ]);
["PARAM", "password"],
] as Expression;
} }
authExp = evaluate(authExp, {}, now, evalCallback); authExp = authExp.evaluate(evalCallback);
const debug = !!getConfig(snapshot, "cwmp.debug", {}, now, evalCallback); const debug = getConfig(snapshot, "cwmp.debug", false, configCallback);
let udpProm = Promise.resolve(false); let udpProm = Promise.resolve(false);
if (udpConnectionRequestAddress) { if (udpConnectionRequestAddress && +stunEnable) {
try { try {
const u = new URL("udp://" + udpConnectionRequestAddress); const u = new URL("udp://" + udpConnectionRequestAddress);
udpProm = udpConnectionRequest( udpProm = udpConnectionRequest(
@@ -154,24 +171,36 @@ export async function connectionRequest(
authExp, authExp,
UDP_CONNECTION_REQUEST_PORT, UDP_CONNECTION_REQUEST_PORT,
debug, debug,
deviceId deviceId,
).then( ).then(
() => true, () => true,
() => false () => false,
); );
} catch (err) { } catch {
// Ignore invalid address // Ignore invalid address
} }
} }
const status = await httpConnectionRequest( let status;
connectionRequestUrl,
authExp, if (connReqJabberId && XMPP_CONFIGURED) {
CONNECTION_REQUEST_ALLOW_BASIC_AUTH, status = await xmppConnectionRequest(
CONNECTION_REQUEST_TIMEOUT, connReqJabberId,
debug, authExp,
deviceId CONNECTION_REQUEST_TIMEOUT,
); debug,
deviceId,
);
} else {
status = await httpConnectionRequest(
connectionRequestUrl,
authExp,
CONNECTION_REQUEST_ALLOW_BASIC_AUTH,
CONNECTION_REQUEST_TIMEOUT,
debug,
deviceId,
);
}
if (await udpProm) return ""; if (await udpProm) return "";
@@ -181,17 +210,17 @@ export async function connectionRequest(
export async function awaitSessionStart( export async function awaitSessionStart(
deviceId: string, deviceId: string,
lastInform: number, lastInform: number,
timeout: number timeout: number,
): Promise<boolean> { ): Promise<boolean> {
const now = Date.now(); const now = Date.now();
const device = await db.devicesCollection.findOne( const device = await collections.devices.findOne(
{ _id: deviceId }, { _id: deviceId },
{ projection: { _lastInform: 1 } } { projection: { _lastInform: 1 } },
); );
const li = (device["_lastInform"] as Date).getTime(); const li = (device["_lastInform"] as Date).getTime();
if (li > lastInform) return true; if (li > lastInform) return true;
const token = await cache.get(`cwmp_session_${deviceId}`); const token = await getToken(`cwmp_session_${deviceId}`);
if (token) return true; if (token?.startsWith("cwmp_session_")) return true;
if (timeout < 500) return false; if (timeout < 500) return false;
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
timeout -= Date.now() - now; timeout -= Date.now() - now;
@@ -200,11 +229,11 @@ export async function awaitSessionStart(
export async function awaitSessionEnd( export async function awaitSessionEnd(
deviceId: string, deviceId: string,
timeout: number timeout: number,
): Promise<boolean> { ): Promise<boolean> {
const now = Date.now(); const now = Date.now();
const token = await cache.get(`cwmp_session_${deviceId}`); const token = await getToken(`cwmp_session_${deviceId}`);
if (!token) return true; if (!token?.startsWith("cwmp_session_")) return true;
if (timeout < 500) return false; if (timeout < 500) return false;
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
timeout -= Date.now() - now; timeout -= Date.now() - now;
@@ -295,8 +324,8 @@ function sanitizeTask(task): void {
!task.provisions.every((arr) => !task.provisions.every((arr) =>
arr.every( arr.every(
(s) => (s) =>
s == null || ["boolean", "number", "string"].includes(typeof s) s == null || ["boolean", "number", "string"].includes(typeof s),
) ),
) )
) )
throw new Error("Invalid 'provisions' property"); throw new Error("Invalid 'provisions' property");
@@ -322,30 +351,140 @@ export async function insertTasks(tasks: any[]): Promise<Task[]> {
for (const task of tasks) { for (const task of tasks) {
sanitizeTask(task); sanitizeTask(task);
if (task.uniqueKey) { if (task.uniqueKey) {
await db.tasksCollection.deleteOne({ await collections.tasks.deleteOne({
device: task.device, device: task.device,
uniqueKey: task.uniqueKey, uniqueKey: task.uniqueKey,
}); });
} }
} }
await db.tasksCollection.insertMany(tasks); await collections.tasks.insertMany(tasks);
for (const task of tasks) task._id = task._id.toString();
return tasks; return tasks;
} }
export async function deleteDevice(deviceId: string): Promise<void> { export async function deleteDevice(deviceId: string): Promise<void> {
await Promise.all([ const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);
db.tasksCollection.deleteMany({ device: deviceId }), if (!token) throw new ResourceLockedError("Device is in session");
db.devicesCollection.deleteOne({ _id: deviceId }), try {
db.faultsCollection.deleteMany({ await Promise.all([
_id: { collections.tasks.deleteMany({ device: deviceId }),
$regex: `^${common.escapeRegExp(deviceId)}\\:`, collections.devices.deleteOne({ _id: deviceId }),
}, collections.faults.deleteMany({
}), _id: {
db.operationsCollection.deleteMany({ $regex: `^${common.escapeRegExp(deviceId)}\\:`,
_id: { },
$regex: `^${common.escapeRegExp(deviceId)}\\:`, }),
}, collections.operations.deleteMany({
}), _id: {
cache.del(`${deviceId}_tasks_faults_operations`), $regex: `^${common.escapeRegExp(deviceId)}\\:`,
]); },
}),
]);
} finally {
await releaseLock(`cwmp_session_${deviceId}`, token);
}
}
export async function deleteFault(id: string): Promise<void> {
const deviceId = id.split(":", 1)[0];
const channel = id.slice(deviceId.length + 1);
const token = await acquireLock(`cwmp_session_${deviceId}`, 5000);
if (!token) throw new ResourceLockedError("Device is in session");
try {
const proms = [dbDeleteFault(id)];
if (channel.startsWith("task_"))
proms.push(deleteTask(new ObjectId(channel.slice(5))));
await Promise.all(proms);
} finally {
await releaseLock(`cwmp_session_${deviceId}`, token);
}
}
export async function deleteResource(
resource: string,
id: string,
): Promise<void> {
if (resource === "devices") {
await deleteDevice(id);
} else if (resource === "files") {
await deleteFile(id);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "faults") {
await deleteFault(id);
} else if (resource === "provisions") {
await deleteProvision(id);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "presets") {
await deletePreset(id);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "virtualParameters") {
await deleteVirtualParameter(id);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "config") {
await deleteConfig(id);
await Promise.all([
cache.del("ui-local-cache-hash"),
cache.del("cwmp-local-cache-hash"),
]);
} else if (resource === "permissions") {
await deletePermission(id);
await cache.del("ui-local-cache-hash");
} else if (resource === "users") {
await deleteUser(id);
await cache.del("ui-local-cache-hash");
} else {
throw new Error(`Unknown resource ${resource}`);
}
}
// TODO Implement validation
export async function putResource(
resource: string,
id: string,
data: any,
): Promise<void> {
if (resource === "presets") {
await putPreset(id, data);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "provisions") {
await putProvision(id, data);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "virtualParameters") {
await putVirtualParameter(id, data);
await cache.del("cwmp-local-cache-hash");
} else if (resource === "config") {
await putConfig(id, data);
await Promise.all([
cache.del("ui-local-cache-hash"),
cache.del("cwmp-local-cache-hash"),
]);
} else if (resource === "permissions") {
await putPermission(id, data);
await cache.del("ui-local-cache-hash");
} else if (resource === "users") {
delete data["password"];
delete data["salt"];
await putUser(id, data);
await cache.del("ui-local-cache-hash");
} else {
throw new Error(`Unknown resource ${resource}`);
}
}
export function authLocal(
snapshot: string,
username: string,
password: string,
): Promise<boolean> {
return new Promise((resolve, reject) => {
const users = getUsers(snapshot);
const user = users[username];
if (!user?.password) return void resolve(null);
hashPassword(password, user.salt)
.then((hash) => {
if (hash === user.password) resolve(true);
else resolve(false);
})
.catch(reject);
});
} }
+7 -26
View File
@@ -1,23 +1,4 @@
/** import { createHash, randomBytes, pbkdf2 } from "node:crypto";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { createHash, randomBytes, pbkdf2 } from "crypto";
function parseHeaderFeilds(str: string): Record<string, string> { function parseHeaderFeilds(str: string): Record<string, string> {
const res = {}; const res = {};
@@ -44,7 +25,7 @@ function parseHeaderFeilds(str: string): Record<string, string> {
try { try {
value = JSON.parse(value); value = JSON.parse(value);
} catch (error) { } catch (error) {
throw new Error("Unable to parse auth header"); throw new Error("Unable to parse auth header", { cause: error });
} }
} }
res[name.trim()] = value; res[name.trim()] = value;
@@ -63,7 +44,7 @@ export function parseAuthorizationHeader(authHeader: string): {
// Inspired by https://github.com/jshttp/basic-auth // Inspired by https://github.com/jshttp/basic-auth
const USER_PASS_REGEX = /^([^:]*):(.*)$/; const USER_PASS_REGEX = /^([^:]*):(.*)$/;
const creds = USER_PASS_REGEX.exec( const creds = USER_PASS_REGEX.exec(
Buffer.from(authHeader.slice(method.length + 1), "base64").toString() Buffer.from(authHeader.slice(method.length + 1), "base64").toString(),
); );
if (!creds) throw new Error("Unable to parse auth header"); if (!creds) throw new Error("Unable to parse auth header");
@@ -77,7 +58,7 @@ export function parseAuthorizationHeader(authHeader: string): {
} }
export function parseWwwAuthenticateHeader( export function parseWwwAuthenticateHeader(
authHeader: string authHeader: string,
): Record<string, string> { ): Record<string, string> {
authHeader = authHeader.trim(); authHeader = authHeader.trim();
const method = authHeader.split(" ", 1)[0]; const method = authHeader.split(" ", 1)[0];
@@ -100,7 +81,7 @@ export function digest(
qop?: string | Buffer, qop?: string | Buffer,
body?: string | Buffer, body?: string | Buffer,
cnonce?: string | Buffer, cnonce?: string | Buffer,
nc?: string | Buffer nc?: string | Buffer,
): string { ): string {
const ha1 = createHash("md5"); const ha1 = createHash("md5");
ha1.update(username).update(":").update(realm).update(":").update(password); ha1.update(username).update(":").update(realm).update(":").update(password);
@@ -141,7 +122,7 @@ export function solveDigest(
uri: string | Buffer, uri: string | Buffer,
httpMethod: string | Buffer, httpMethod: string | Buffer,
body: string | Buffer, body: string | Buffer,
authHeader: Record<string, string> authHeader: Record<string, string>,
): string { ): string {
const cnonce = randomBytes(8).toString("hex"); const cnonce = randomBytes(8).toString("hex");
const nc = "00000001"; const nc = "00000001";
@@ -163,7 +144,7 @@ export function solveDigest(
qop, qop,
body, body,
cnonce, cnonce,
nc nc,
); );
let authString = `Digest username="${username}"`; let authString = `Digest username="${username}"`;
+9 -79
View File
@@ -1,105 +1,35 @@
/** import { collections } from "./db/db.ts";
* Copyright 2013-2019 GenieACS Inc. import * as config from "./config.ts";
*
* 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 { Collection } from "mongodb";
import { onConnect } from "./db";
import * as config from "./config";
import { Cache } from "./mongodb-types";
const CLOCK_SKEW_TOLERANCE = 30000; const CLOCK_SKEW_TOLERANCE = 30000;
const MAX_CACHE_TTL = +config.get("MAX_CACHE_TTL"); const MAX_CACHE_TTL = +config.get("MAX_CACHE_TTL");
let cacheCollection: Collection<Cache>;
onConnect(async (db) => {
cacheCollection = db.collection("cache");
await cacheCollection.createIndex({ expire: 1 }, { expireAfterSeconds: 0 });
});
export async function get(key: string): Promise<string> { export async function get(key: string): Promise<string> {
const res = await cacheCollection.findOne({ _id: key }); const res = await collections.cache.findOne({ _id: key });
return res?.value; return res?.value;
} }
export async function del(key: string): Promise<void> { export async function del(key: string): Promise<void> {
await cacheCollection.deleteOne({ _id: key }); await collections.cache.deleteOne({ _id: key });
} }
export async function set( export async function set(
key: string, key: string,
value: string, value: string,
ttl: number = MAX_CACHE_TTL ttl: number = MAX_CACHE_TTL,
): Promise<void> { ): Promise<void> {
const timestamp = new Date(); const timestamp = new Date();
const expire = new Date( const expire = new Date(
timestamp.getTime() + CLOCK_SKEW_TOLERANCE + ttl * 1000 timestamp.getTime() + CLOCK_SKEW_TOLERANCE + ttl * 1000,
); );
await cacheCollection.replaceOne( await collections.cache.replaceOne(
{ _id: key }, { _id: key },
{ value, expire, timestamp }, { value, expire, timestamp },
{ upsert: true } { upsert: true },
); );
} }
export async function pop(key: string): Promise<string> { export async function pop(key: string): Promise<string> {
const res = await cacheCollection.findOneAndDelete({ _id: key }); const res = await collections.cache.findOneAndDelete({ _id: key });
return res.value?.value; return res.value?.value;
} }
export async function acquireLock(
lockName: string,
ttl: number,
timeout = 0,
token = Math.random().toString(36).slice(2)
): Promise<string> {
try {
const now = Date.now();
const r = await cacheCollection.findOneAndUpdate(
{ _id: lockName, value: token },
{
$set: {
expire: new Date(now + ttl + CLOCK_SKEW_TOLERANCE),
},
$currentDate: { timestamp: true },
},
{ upsert: true, returnDocument: "after" }
);
if (Math.abs(r.value.timestamp.getTime() - now) > CLOCK_SKEW_TOLERANCE)
throw new Error("Database clock skew too great");
} catch (err) {
if (err.code !== 11000) throw err;
if (!(timeout > 0)) return null;
const w = 50 + Math.random() * 50;
await new Promise((resolve) => setTimeout(resolve, w));
return acquireLock(lockName, ttl, timeout - w, token);
}
return token;
}
export async function releaseLock(
lockName: string,
token: string
): Promise<void> {
const res = await cacheCollection.deleteOne({
_id: lockName,
value: token,
});
if (res.deletedCount !== 1) throw new Error("Lock expired");
}
+4 -23
View File
@@ -1,25 +1,6 @@
/** import cluster, { Worker } from "node:cluster";
* Copyright 2013-2019 GenieACS Inc. import { cpus } from "node:os";
* import * as logger from "./logger.ts";
* 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 cluster, { Worker } from "cluster";
import { cpus } from "os";
import * as logger from "./logger";
let respawnTimestamp = 0; let respawnTimestamp = 0;
let crashes: number[] = []; let crashes: number[] = [];
@@ -92,7 +73,7 @@ function restartWorker(worker, code, signal): void {
export function start( export function start(
workerCount: number, workerCount: number,
servicePort: number, servicePort: number,
serviceAddress: string serviceAddress: string,
): void { ): void {
cluster.on("listening", (worker, address) => { cluster.on("listening", (worker, address) => {
if ( if (
+36 -56
View File
@@ -1,33 +1,14 @@
/** import { PermissionSet } from "../types.ts";
* Copyright 2013-2019 GenieACS Inc. import Expression from "./expression.ts";
*
* 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 { PermissionSet, Expression } from "../types";
import { evaluate, or } from "./expression";
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;
@@ -62,12 +43,12 @@ export default class Authorizer {
if (this.getFilterCache.has(cacheKey)) if (this.getFilterCache.has(cacheKey))
return this.getFilterCache.get(cacheKey); return this.getFilterCache.get(cacheKey);
let filter: Expression = null; let filter: Expression = new Expression.Literal(false);
for (const permissionSet of this.permissionSets) { for (const permissionSet of this.permissionSets) {
for (const perm of permissionSet) { for (const perm of permissionSet) {
if (perm[resourceType]) { if (perm[resourceType]) {
if (perm[resourceType].access >= access) if (perm[resourceType].access >= access)
filter = or(filter, perm[resourceType].filter); filter = Expression.or(filter, perm[resourceType].filter);
} }
} }
} }
@@ -78,12 +59,12 @@ export default class Authorizer {
public getValidator( public getValidator(
resourceType: string, resourceType: string,
resource: unknown resource: unknown,
): (mutationType: string, mutation?: any, args?: any) => boolean { ): (mutationType: string, mutation?: any, args?: any) => boolean {
if (this.validatorCache.has(resource)) if (this.validatorCache.has(resource))
return this.validatorCache.get(resource); return this.validatorCache.get(resource);
const validators: Expression[] = []; let validators: Expression = new Expression.Literal(false);
for (const permissionSet of this.permissionSets) { for (const permissionSet of this.permissionSets) {
for (const perm of permissionSet) { for (const perm of permissionSet) {
@@ -92,17 +73,15 @@ export default class Authorizer {
perm[resourceType].access >= 3 && perm[resourceType].access >= 3 &&
perm[resourceType].validate perm[resourceType].validate
) )
validators.push(perm[resourceType].validate); validators = Expression.or(validators, perm[resourceType].validate);
} }
} }
const validator = ( const validator = (
mutationType: string, mutationType: string,
mutation: any, mutation: any,
any: any any: any,
): boolean => { ): boolean => {
if (!validators.length) return false;
const object = { const object = {
mutationType, mutationType,
mutation, mutation,
@@ -111,32 +90,33 @@ export default class Authorizer {
options: any, options: any,
}; };
const valueFunction = (paramName): any => { const now = Date.now();
const entry = paramName.split(".", 1)[0]; const res = validators.evaluate((exp) => {
paramName = paramName.slice(entry.length + 1); if (exp instanceof Expression.Literal) return exp;
let value = null; if (exp instanceof Expression.Parameter) {
if (["mutation", "options"].includes(entry)) { if (exp.path.colon) return new Expression.Literal(null);
value = object[entry]; const entry = exp.path.segments[0] as string;
for (const seg of paramName.split(".")) { const paramName = exp.path.slice(1);
// typeof null is "object" let value = null;
if (value != null && typeof value !== "object") value = null; if (["mutation", "options"].includes(entry)) {
else value = value[seg]; value = object[entry];
if (value == null) break; for (const seg of paramName.segments) {
if (value == null) break;
if (typeof value !== "object") value = null;
else value = value[seg as string];
}
} else if (object[entry]) {
if (paramName.length) value = object[entry][paramName.toString()];
else value = object[entry];
} }
} else if (object[entry]) { return new Expression.Literal(value);
if (paramName) value = object[entry][paramName]; } else if (exp instanceof Expression.FunctionCall) {
else value = object[entry]; if (exp.name === "NOW") return new Expression.Literal(now);
} }
return new Expression.Literal(null);
}).value;
return value; return !!res;
};
const res = evaluate(
validators.length > 1 ? ["OR", validators] : validators[0],
valueFunction,
Date.now()
);
return !Array.isArray(res) && !!res;
}; };
this.validatorCache.set(resource, validator); this.validatorCache.set(resource, validator);
-106
View File
@@ -1,106 +0,0 @@
/**
* 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/>.
*/
export function and(a: bigint, b: bigint): bigint {
return a & b;
}
export function or(a: bigint, b: bigint): bigint {
return a | b;
}
export function xor(a: bigint, b: bigint): bigint {
return a ^ b;
}
export function not(a: bigint): bigint {
return ~a;
}
export function lshift(a: bigint, b: bigint): bigint {
return a << b;
}
export function rshift(a: bigint, b: bigint): bigint {
return a >> b;
}
export function add(a: bigint, b: bigint): bigint {
return a + b;
}
export function sub(a: bigint, b: bigint): bigint {
return a - b;
}
export function mul(a: bigint, b: bigint): bigint {
return a * b;
}
export function div(a: bigint, b: bigint): bigint {
return a / b;
}
export function exp(a: bigint, b: bigint): bigint {
return a ** b;
}
export function rem(a: bigint, b: bigint): bigint {
return a % b;
}
export function toNumber(a: bigint): number {
return Number(a);
}
export function eq(a: bigint, b: bigint): boolean {
return a === b;
}
export function ne(a: bigint, b: bigint): boolean {
return a !== b;
}
export function lt(a: bigint, b: bigint): boolean {
return a < b;
}
export function lte(a: bigint, b: bigint): boolean {
return a <= b;
}
export function gt(a: bigint, b: bigint): boolean {
return a > b;
}
export function gte(a: bigint, b: bigint): boolean {
return a >= b;
}
export function asUintN(a: number, b: bigint): bigint {
return BigInt.asUintN(a, b);
}
export function asIntN(a: number, b: bigint): bigint {
return BigInt.asIntN(a, b);
}
const _BigInt = BigInt;
type _bigint = bigint;
export { _BigInt as BigInt, _bigint as bigint };
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
export default function debounce<T>( export default function debounce<T>(
func: (args: T[]) => void, func: (args: T[]) => void,
timeout: number timeout: number,
): (arg: T) => void { ): (arg: T) => void {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
let args: T[] = []; let args: T[] = [];
+6
View File
@@ -0,0 +1,6 @@
export class ResourceLockedError extends Error {
constructor(message: string) {
super(message);
this.name = "ResourceLockedError";
}
}
-481
View File
@@ -1,481 +0,0 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import parsimmon from "parsimmon";
import { Expression } from "../types";
// Turn escaped characters into real ones (e.g. "\\n" becomes "\n").
function interpretEscapes(str): string {
const escapes = {
b: "\b",
f: "\f",
n: "\n",
r: "\r",
t: "\t",
};
return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/g, (_, escape) => {
const type = escape.charAt(0);
const hex = escape.slice(1);
if (type === "u") return String.fromCharCode(parseInt(hex, 16));
if (escapes.hasOwnProperty(type)) return escapes[type];
return type;
});
}
export function map(
exp: Expression,
callback: (e: Expression) => Expression
): Expression {
if (!Array.isArray(exp)) return callback(exp);
let clone;
for (let i = 1; i < exp.length; ++i) {
const sub = map(exp[i], callback);
if (sub !== exp[i]) {
clone = clone || exp.slice();
clone[i] = sub;
}
}
return callback(clone || exp);
}
export async function mapAsync(
exp: Expression,
callback: (e: Expression) => Promise<Expression>
): Promise<Expression> {
if (!Array.isArray(exp)) return callback(exp);
let clone;
for (let i = 1; i < exp.length; ++i) {
const sub = await mapAsync(exp[i], callback);
if (sub !== exp[i]) {
clone = clone || exp.slice();
clone[i] = sub;
}
}
return callback(clone || exp);
}
function binaryLeft(
operatorsParser: parsimmon.Parser<string>,
nextParser: parsimmon.Parser<Expression>
): parsimmon.Parser<Expression> {
return parsimmon.seqMap(
nextParser,
parsimmon.seq(operatorsParser, nextParser).many(),
(first, rest) =>
rest.reduce((acc, ch) => {
const [op, another] = ch;
if (Array.isArray(acc) && op === acc[0]) return acc.concat([another]);
return [op, acc, another];
}, first)
);
}
const lang = parsimmon.createLanguage({
ComparisonOperator: function () {
return parsimmon
.alt(
parsimmon.string(">="),
parsimmon.string("<>"),
parsimmon.string("<="),
parsimmon.string("="),
parsimmon.string(">"),
parsimmon.string("<")
)
.skip(parsimmon.optWhitespace);
},
LikeOperator: function () {
return parsimmon
.alt(
parsimmon.regexp(/like/i).result("LIKE").desc("LIKE"),
parsimmon
.regexp(/not\s+like/i)
.result("NOT LIKE")
.desc("NOT LIKE")
)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace);
},
IsNullOperator: function () {
return parsimmon
.alt(
parsimmon
.regexp(/is\s+null/i)
.result("IS NULL")
.desc("IS NULL"),
parsimmon
.regexp(/is\s+not\s+null/i)
.result("IS NOT NULL")
.desc("IS NOT NULL")
)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace);
},
NotOperator: function () {
return parsimmon
.regexp(/not/i)
.result("NOT")
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("NOT");
},
AndOperator: function () {
return parsimmon
.regexp(/and/i)
.result("AND")
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("AND");
},
OrOperator: function () {
return parsimmon
.regexp(/or/i)
.result("OR")
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("OR");
},
Parameter: function (r) {
return parsimmon
.alt(
parsimmon.regexp(/[a-zA-Z0-9_.*-]+/),
r.Expression.wrap(
parsimmon.string("{").skip(parsimmon.optWhitespace),
parsimmon.string("}")
)
)
.atLeast(1)
.map((x) => ["PARAM", x.length > 1 ? ["||"].concat(x) : x[0]])
.skip(parsimmon.optWhitespace)
.desc("parameter");
},
StringValueSql: function () {
return parsimmon
.regexp(/'([^']*)'/, 1)
.atLeast(1)
.skip(parsimmon.optWhitespace)
.map((s) => s.join("'"))
.desc("string");
},
StringValueJs: function () {
return parsimmon
.regexp(/"((?:\\.|.)*?)"/, 1)
.skip(parsimmon.optWhitespace)
.map(interpretEscapes)
.desc("string");
},
NumberValue: function () {
return parsimmon
.regexp(/-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?/)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.map(Number)
.desc("number");
},
BooleanValue: function () {
return parsimmon
.alt(
parsimmon.regexp(/true/i).result(true).desc("TRUE"),
parsimmon.regexp(/false/i).result(false).desc("FALSE")
)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace);
},
NullValue: function () {
return parsimmon
.regexp(/null/i)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.result(null)
.desc("NULL");
},
FuncValue: function (r) {
return parsimmon.seqMap(
parsimmon
.regexp(/([a-zA-Z0-9_]+)/, 1)
.skip(parsimmon.optWhitespace)
.desc("function"),
r.ExpressionList.wrap(
parsimmon.string("(").skip(parsimmon.optWhitespace),
parsimmon.string(")").skip(parsimmon.optWhitespace)
),
(f, args) => ["FUNC", f.toUpperCase()].concat(args)
);
},
WhenPair: function (r) {
return parsimmon.seq(
parsimmon
.regexp(/when/i)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("WHEN")
.then(r.Expression),
parsimmon
.regexp(/then/i)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("THEN")
.then(r.Expression)
);
},
CaseStatement: function (r) {
return parsimmon.seqMap(
parsimmon
.regexp(/case/i)
.result("CASE")
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("CASE"),
r.WhenPair.many(),
parsimmon
.regexp(/else/i)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
.skip(parsimmon.optWhitespace)
.desc("ELSE")
.then(r.Expression)
.map((e) => [[true, e]])
.fallback(null)
.skip(
parsimmon
.regex(/end/i)
.notFollowedBy(parsimmon.regexp(/[a-zA-Z0-9_]/))
)
.skip(parsimmon.optWhitespace),
(...arr) => arr.flat(2)
);
},
Value: function (r) {
return parsimmon.alt(
r.NullValue,
r.BooleanValue,
r.NumberValue,
r.StringValueSql,
r.StringValueJs,
r.FuncValue,
r.CaseStatement
);
},
ValueExpression: function (r) {
return binaryLeft(
parsimmon.string("||").skip(parsimmon.optWhitespace),
binaryLeft(
parsimmon
.alt(parsimmon.string("+"), parsimmon.string("-"))
.skip(parsimmon.optWhitespace),
binaryLeft(
parsimmon
.alt(
parsimmon.string("*"),
parsimmon.string("/"),
parsimmon.string("%")
)
.skip(parsimmon.optWhitespace),
parsimmon.alt(
r.Value,
r.Parameter,
r.Expression.wrap(
parsimmon.string("(").skip(parsimmon.optWhitespace),
parsimmon.string(")").skip(parsimmon.optWhitespace)
)
)
)
)
);
},
Comparison: function (r) {
return parsimmon.alt(
parsimmon.seqMap(r.ValueExpression, r.IsNullOperator, (p, o) => [o, p]),
parsimmon.seqMap(
r.ValueExpression,
r.ComparisonOperator,
r.ValueExpression,
(p, o, v) => [o, p, v]
),
parsimmon.seqMap(
r.ValueExpression,
r.LikeOperator,
r.ValueExpression.skip(
parsimmon
.regexp(/escape/i)
.result("ESCAPE")
.skip(parsimmon.whitespace)
.desc("ESCAPE")
),
r.ValueExpression,
(a, b, c, d) => [b, a, c, d]
),
parsimmon.seqMap(
r.ValueExpression,
r.LikeOperator,
r.ValueExpression,
(a, b, c) => [b, a, c]
)
);
},
ExpressionList: function (r) {
return r.Expression.sepBy(
parsimmon.string(",").skip(parsimmon.optWhitespace)
);
},
Expression: function (r) {
function unary(
operatorsParser: parsimmon.Parser<string>,
nextParser: parsimmon.Parser<Expression>
): parsimmon.Parser<Expression> {
return parsimmon.seq(operatorsParser, nextParser).or(nextParser);
}
return binaryLeft(
r.OrOperator,
binaryLeft(
r.AndOperator,
unary(r.NotOperator, r.Comparison.or(r.ValueExpression))
)
).trim(parsimmon.optWhitespace);
},
});
export function parse(str: string): Expression {
if (!str) return null;
return lang.Expression.tryParse(str);
}
export function parseList(str: string): Expression[] {
if (!str) return [];
return lang.ExpressionList.tryParse(str);
}
export function stringify(exp: Expression, level = 0): string {
if (!Array.isArray(exp)) return JSON.stringify(exp);
const opLevels = {
OR: 10,
AND: 11,
NOT: 12,
"=": 20,
"<>": 20,
">": 20,
">=": 20,
"<": 20,
"<=": 20,
LIKE: 20,
"NOT LIKE": 20,
"IS NULL": 20,
"IS NOT NULL": 20,
"||": 30,
"+": 31,
"-": 31,
"*": 32,
"/": 32,
"%": 32,
};
const op = exp[0].toUpperCase();
function wrap(e): string {
if (opLevels[op] <= level) return `(${e})`;
else return e;
}
if (op === "FUNC") {
return wrap(
`${exp[1]}(${exp
.slice(2)
.map((e) => stringify(e))
.join(", ")})`
);
} else if (op === "PARAM") {
if (typeof exp[1] === "string") {
return wrap(exp[1]);
} else if (Array.isArray(exp[1]) && exp[1][0] === "||") {
return wrap(
exp[1]
.slice(1)
.map((p) => {
if (typeof p === "string") return p;
else return `{${stringify(p)}}`;
})
.join("")
);
} else {
return wrap(`{${stringify(exp[1])}}`);
}
} else if (op === "IS NULL" || op === "IS NOT NULL") {
return wrap(`${stringify(exp[1], opLevels[op])} ${op}`);
} else if (op === "LIKE" || op === "NOT LIKE") {
if (exp[3]) {
return wrap(
`${stringify(exp[1], opLevels[op])} ${op} ${stringify(
exp[2],
opLevels[op]
)} ESCAPE ${stringify(exp[3], opLevels[op])}`
);
} else {
return wrap(
`${stringify(exp[1], opLevels[op])} ${op} ${stringify(
exp[2],
opLevels[op]
)}`
);
}
} else if (op === "CASE") {
const parts: string[] = ["CASE"];
for (let i = 1; i < exp.length - 1; i += 2) {
if (!Array.isArray(exp[i]) && exp[i]) {
if (exp[i + 1] != null) parts.push("ELSE", stringify(exp[i + 1]));
break;
}
parts.push("WHEN", stringify(exp[i]), "THEN", stringify(exp[i + 1]));
}
parts.push("END");
return parts.join(" ");
} else if (op in opLevels) {
const parts = exp.slice(1).map((e, i) => {
return stringify(e, opLevels[exp[0]] + Math.min(i - 1, 0));
});
if (op === "NOT") return wrap(`${op} ${parts[0]}`);
else return wrap(parts.join(` ${op} `));
} else {
throw new Error(`Unrecognized operator ${exp[0]}`);
}
}
export function parseLikePattern(pat: string, esc: string): string[] {
const chars = pat.split("");
for (let i = 0; i < chars.length; ++i) {
const c = chars[i];
if (c === esc) {
chars[i] = chars[i + 1] || "";
chars[i + 1] = "";
} else if (c === "_") {
chars[i] = "\\_";
} else if (c === "%") {
chars[i] = "\\%";
while (chars[i + 1] === "%") chars[++i] = "";
}
}
return chars.filter((c) => c);
}
+272 -393
View File
@@ -1,412 +1,291 @@
/** import {
* Copyright 2013-2019 GenieACS Inc. Cursor,
* parseExpression,
* This file is part of GenieACS. stringifyExpression,
* } from "./expression/parser.ts";
* GenieACS is free software: you can redistribute it and/or modify import Path from "./path.ts";
* it under the terms of the GNU Affero General Public License as import { reduce } from "./expression/evaluate.ts";
* 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 { map, mapAsync, parseLikePattern } from "./expression-parser"; export type Value = string | number | boolean | null;
import { Expression } from "../types";
const isArray = Array.isArray; export abstract class Expression {
private _string: string;
const regExpCache = new WeakMap<any, RegExp>(); abstract map(fn: (e: Expression, i: number) => Expression): Expression;
abstract mapAsync(
fn: (e: Expression, i: number) => Promise<Expression>,
): Promise<Expression>;
const REDUCE_SKIP = {}; toString(): string {
function reduce(exp, callback): Expression { if (!this._string) this._string = stringifyExpression(this);
let loop = true; return this._string;
while (loop) { }
loop = false;
for (let i = 2; i < exp.length; ++i) { evaluate<T extends Expression>(fn: (e: Expression) => T = (e) => e as T): T {
const res = callback(exp[i - 1], exp[i], i - 2); return fn(reduce(this.map((e) => e.evaluate(fn))));
if (res !== REDUCE_SKIP) { }
loop = true;
exp = exp.slice(); async evaluateAsync<T extends Expression>(
exp.splice(i - 1, 2, res); fn: (e: Expression) => Promise<T>,
): Promise<T> {
return await fn(
reduce(await this.mapAsync(async (e) => await e.evaluateAsync(fn))),
);
}
static parse(input: string): Expression {
const cursor = new Cursor(input);
const exp = parseExpression(cursor);
if (cursor.charCode) throw new Error("Unexpected character");
return exp;
}
static and(left: Expression, right: Expression): Expression {
// Flatten same-operator tree into operands
const operands: Expression[] = [];
const stack: Expression[] = [right, left];
while (stack.length) {
const e = stack.pop()!;
if (e instanceof Expression.Binary && e.operator === "AND")
stack.push(e.right, e.left);
else operands.push(e);
}
// Fold literals using three-valued AND logic
let folded: boolean | null = true;
let i = 0;
while (i < operands.length) {
const e = operands[i];
if (e instanceof Expression.Literal) {
operands.splice(i, 1);
if (e.value == null) folded = folded === false ? false : null;
else if (!e.value) return new Expression.Literal(false);
// truthy is identity for AND; discard
} else {
i++;
} }
} }
// Rebuild: folded value + remaining non-literal operands
if (!operands.length) return new Expression.Literal(folded);
let result = operands.reduce((a, b) => new Expression.Binary("AND", a, b));
if (folded === null)
result = new Expression.Binary(
"AND",
new Expression.Literal(null),
result,
);
return result;
} }
if (exp.length === 2) return exp[1];
return exp;
}
export function likePatternToRegExp(pat: string, esc = "", flags = ""): RegExp { static or(left: Expression, right: Expression): Expression {
const convChars = { // Flatten same-operator tree into operands
"-": "\\-", const operands: Expression[] = [];
"/": "\\/", const stack: Expression[] = [right, left];
"\\": "\\/", while (stack.length) {
"^": "\\^", const e = stack.pop()!;
$: "\\$", if (e instanceof Expression.Binary && e.operator === "OR")
"*": "\\*", stack.push(e.right, e.left);
"+": "\\+", else operands.push(e);
"?": "\\?", }
".": "\\.",
"(": "\\(",
")": "\\)",
"|": "\\|",
"[": "\\[",
"]": "\\]",
"{": "\\{",
"}": "\\}",
"\\%": ".*",
"\\_": ".",
};
let chars = parseLikePattern(pat, esc);
if (!chars.length) return new RegExp("^$", flags);
chars = chars.map((c) => convChars[c] || c);
chars[0] = chars[0] === ".*" ? "" : "^" + chars[0];
const l = chars.length - 1;
chars[l] = [".*", ""].includes(chars[l]) ? "" : chars[l] + "$";
return new RegExp(chars.join(""), flags);
}
function compare( // Fold literals using three-valued OR logic
a: boolean | number | string, let folded: boolean | null = false;
b: boolean | number | string let i = 0;
): number { while (i < operands.length) {
if (typeof a === "boolean") a = +a; const e = operands[i];
if (typeof b === "boolean") b = +b; if (e instanceof Expression.Literal) {
if (typeof a !== typeof b) return typeof a === "string" ? 1 : -1; operands.splice(i, 1);
return a > b ? 1 : a < b ? -1 : 0; if (e.value == null) folded = folded === true ? true : null;
} else if (e.value) return new Expression.Literal(true);
// falsy is identity for OR; discard
} else {
i++;
}
}
function toNumber(a: boolean | number | string): number { // Rebuild: folded value + remaining non-literal operands
switch (typeof a) { if (!operands.length) return new Expression.Literal(folded);
case "number": let result = operands.reduce((a, b) => new Expression.Binary("OR", a, b));
return a; if (folded === null)
case "boolean": result = new Expression.Binary(
return +a; "OR",
case "string": new Expression.Literal(null),
return parseFloat(a) || 0; result,
);
return result;
} }
} }
function toString(a: boolean | number | string): string { // eslint-disable-next-line @typescript-eslint/no-namespace
switch (typeof a) { export namespace Expression {
case "string": export class Literal extends Expression {
return a; constructor(public readonly value: Value) {
case "number": super();
return a.toString(); }
case "boolean":
return (+a).toString(); map(): Literal {
return this;
}
async mapAsync(): Promise<Literal> {
return this;
}
}
export class Parameter extends Expression {
constructor(public readonly path: Path) {
super();
}
map(): Parameter {
return this;
}
async mapAsync(): Promise<Parameter> {
return this;
}
toString(): string {
return this.path.toString();
}
}
export class Unary extends Expression {
constructor(
public readonly operator: string,
public readonly operand: Expression,
) {
super();
}
map(fn: (e: Expression, i: number) => Expression): Unary {
const operand = fn(this.operand, 0);
if (operand === this.operand) return this;
return Reflect.construct(this.constructor, [this.operator, operand]);
}
async mapAsync(
fn: (e: Expression, i: number) => Promise<Expression>,
): Promise<Unary> {
const operand = await fn(this.operand, 0);
if (operand === this.operand) return this;
return Reflect.construct(this.constructor, [this.operator, operand]);
}
}
export class Binary extends Expression {
constructor(
public readonly operator: string,
public readonly left: Expression,
public readonly right: Expression,
) {
super();
}
map(fn: (e: Expression, i: number) => Expression): Binary {
const left = fn(this.left, 0);
const right = fn(this.right, 1);
if (left === this.left && right === this.right) return this;
return Reflect.construct(this.constructor, [this.operator, left, right]);
}
async mapAsync(
fn: (e: Expression, i: number) => Promise<Expression>,
): Promise<Binary> {
const left = await fn(this.left, 0);
const right = await fn(this.right, 1);
if (left === this.left && right === this.right) return this;
return Reflect.construct(this.constructor, [this.operator, left, right]);
}
}
export class FunctionCall extends Expression {
constructor(
public readonly name: string,
public readonly args: Expression[],
) {
super();
}
map(fn: (e: Expression, i: number) => Expression): FunctionCall {
const args = this.args.map(fn);
if (args.every((arg, i) => arg === this.args[i])) return this;
return new FunctionCall(this.name, args);
}
async mapAsync(
fn: (e: Expression, i: number) => Promise<Expression>,
): Promise<FunctionCall> {
const args = await Promise.all(this.args.map(fn));
if (args.every((arg, i) => arg === this.args[i])) return this;
return new FunctionCall(this.name, args);
}
}
export class Conditional extends Expression {
constructor(
public readonly condition: Expression,
public readonly then: Expression,
public readonly otherwise: Expression,
) {
super();
}
map(fn: (e: Expression, i: number) => Expression): Conditional {
const condition = fn(this.condition, 0);
const then = fn(this.then, 1);
const otherwise = fn(this.otherwise, 2);
if (
condition === this.condition &&
then === this.then &&
otherwise === this.otherwise
)
return this;
return new Conditional(condition, then, otherwise);
}
async mapAsync(
fn: (e: Expression, i: number) => Promise<Expression>,
): Promise<Conditional> {
const condition = await fn(this.condition, 0);
const then = await fn(this.then, 1);
const otherwise = await fn(this.otherwise, 2);
if (
condition === this.condition &&
then === this.then &&
otherwise === this.otherwise
)
return this;
return new Conditional(condition, then, otherwise);
}
} }
} }
export function evaluateCallback(exp: Expression): Expression { export function extractPaths(exp: Expression): Path[] {
if (!Array.isArray(exp)) return exp; if (exp instanceof Expression.Parameter) return [exp.path];
if (exp[0] === "CASE") { const paths: Path[] = [];
for (let i = 1; i < exp.length; i += 2) { exp.map((e) => {
if (Array.isArray(exp[i])) return exp; if (e instanceof Expression.Parameter) paths.push(e.path);
if (exp[i]) return exp[i + 1]; else paths.push(...extractPaths(e));
}
return null;
} else if (exp[0] === "FUNC") {
if (exp[1] === "COALESCE") {
const args: Expression[] = [];
for (let i = 2; i < exp.length; ++i) {
const e = exp[i];
if (e == null) continue;
args.push(e);
if (!Array.isArray(e)) break;
}
if (!args.length) return null;
if (args.length === 1) return args[0];
return ["FUNC", "COALESCE", ...args];
} else if (exp[1] === "UPPER") {
if (exp[2] == null) return null;
if (!isArray(exp[2])) return toString(exp[2]).toUpperCase();
} else if (exp[1] === "LOWER") {
if (exp[2] == null) return null;
if (!isArray(exp[2])) return toString(exp[2]).toLowerCase();
} else if (exp[1] === "ROUND") {
const n = exp[2];
const p = exp.length > 3 ? exp[3] : 0;
if (n == null || p == null) return null;
if (!isArray(n) && !isArray(p)) {
const d = 10 ** p;
const m = n * d * (1 + Number.EPSILON);
return Math.round(m) / d;
}
}
} else if (exp[0] === "PARAM") {
if (exp[1] == null) return null;
} else if (exp[0] === "AND") {
for (let i = 1; i < exp.length; ++i)
if (!Array.isArray(exp[i]) && exp[i] != null && !exp[i]) return false;
const args: Expression[] = [];
for (let i = 1; i < exp.length; ++i) {
const ee = exp[i];
if (ee == null) return null;
if (Array.isArray(ee)) {
if (ee[0] === "AND") args.push(...ee.slice(1));
else args.push(ee);
}
}
if (!args.length) return true;
if (args.length === 1) args.push(true);
return ["AND", ...args];
} else if (exp[0] === "OR") {
const args: Expression[] = [];
for (let i = 1; i < exp.length; ++i) {
const ee = exp[i];
if (Array.isArray(ee)) {
if (ee[0] === "OR") args.push(...ee.slice(1));
else args.push(ee);
} else if (ee) {
return true;
}
}
if (!args.length) return exp.some((ee) => ee == null) ? null : false;
if (args.length === 1) args.push(false);
return ["OR", ...args];
} else if (exp[0] === "NOT") {
if (exp[1] == null) return null;
if (!isArray(exp[1])) return !exp[1];
else if (exp[1][0] === "NOT") return exp[1][1];
} else if (exp[0] === "IS NULL") {
if (isArray(exp[1])) return exp;
else if (exp[1] == null) return true;
else return false;
} else if (exp[0] === "IS NOT NULL") {
if (isArray(exp[1])) return exp;
else if (exp[1] != null) return true;
else return false;
} else if (exp[0] === "LIKE") {
if (isArray(exp[1]) || isArray(exp[2]) || isArray(exp[3])) return exp;
else if (
exp[1] == null ||
exp[2] == null ||
((exp as any[]).length >= 4 && exp[3] == null)
)
return null;
let r = regExpCache.get(exp as any[]);
if (!r) {
r = likePatternToRegExp(exp[2], exp[3]);
regExpCache.set(exp as any[], r);
}
return r.test(exp[1]);
} else if (exp[0] === "NOT LIKE") {
if (isArray(exp[1]) || isArray(exp[2]) || isArray(exp[3])) return exp;
else if (
exp[1] == null ||
exp[2] == null ||
((exp as any[]).length >= 4 && exp[3] == null)
)
return null;
let r = regExpCache.get(exp as any[]);
if (!r) {
r = likePatternToRegExp(exp[2], exp[3]);
regExpCache.set(exp as any[], r);
}
return !r.test(exp[1]);
} else if (exp[0] === "=") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) === 0;
} else if (exp[0] === "<>") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) !== 0;
} else if (exp[0] === ">") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) > 0;
} else if (exp[0] === ">=") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) >= 0;
} else if (exp[0] === "<") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) < 0;
} else if (exp[0] === "<=") {
if (exp[1] == null || exp[2] == null) return null;
if (isArray(exp[1]) || isArray(exp[2])) return exp;
return compare(exp[1], exp[2]) <= 0;
} else if (exp[0] === "*") {
return reduce(exp, (a, b) => {
if (a == null || b == null) return null;
if (!isArray(a) && !isArray(b)) return toNumber(a) * toNumber(b);
return REDUCE_SKIP;
});
} else if (exp[0] === "/") {
return reduce(exp, (a, b, i) => {
if (a == null || b == null) return null;
if (isArray(a) || isArray(b)) return REDUCE_SKIP;
const n1 = toNumber(a);
const n2 = toNumber(b);
if (i !== 0) return n1 * n2;
if (n2 === 0) return null;
return n1 / n2;
});
} else if (exp[0] === "+") {
return reduce(exp, (a, b) => {
if (a == null || b == null) return null;
if (!isArray(a) && !isArray(b)) return toNumber(a) + toNumber(b);
return REDUCE_SKIP;
});
} else if (exp[0] === "-") {
return reduce(exp, (a, b, i) => {
if (a == null || b == null) return null;
if (!isArray(a) && !isArray(b))
return i === 0 ? toNumber(a) - toNumber(b) : toNumber(a) + toNumber(b);
return REDUCE_SKIP;
});
} else if (exp[0] === "%") {
return reduce(exp, (a, b, i) => {
if (a == null || b == null) return null;
if (isArray(a) || isArray(b) || i !== 0) return REDUCE_SKIP;
const n1 = toNumber(a);
const n2 = Math.trunc(toNumber(b));
if (n2 === 0) return null;
return n1 % n2;
});
} else if (exp[0] === "||") {
return reduce(exp, (a, b) => {
if (a == null || b == null) return null;
if (!isArray(a) && !isArray(b)) return toString(a) + toString(b);
return REDUCE_SKIP;
});
}
return exp;
}
export function evaluate(
exp: Expression,
obj: Record<string, unknown> | ((e: string) => Expression),
now: number,
cb?: (e: Expression) => Expression
): string | number | boolean | null;
export function evaluate(
exp: Expression,
obj?: Record<string, unknown> | ((e: string) => Expression),
now?: number,
cb?: (e: Expression) => Expression
): Expression;
export function evaluate(
exp: Expression,
obj?: Record<string, unknown> | ((e: string) => Expression),
now?: number,
cb?: (e: Expression) => Expression
): Expression {
return map(exp, (e) => {
if (cb) e = cb(e);
if (!isArray(e)) return e;
if (e[0] === "FUNC" && e[1] === "NOW") {
if (now) return now;
} else if (e[0] === "PARAM") {
if (e[1] == null) return null;
if (obj && !isArray(e[1])) {
let v;
if (typeof obj === "function") v = obj(e[1]);
else v = obj[e[1]];
if (v == null) return null;
if (typeof v === "object") v = v.value ? v.value[0] : null;
return v;
}
}
return evaluateCallback(e);
});
}
export async function evaluateAsync(
exp: Expression,
obj: Record<string, unknown>,
now: number,
cb?: (e: Expression) => Promise<Expression>
): Promise<string | number | boolean | null>;
export async function evaluateAsync(
exp: Expression,
obj?: Record<string, unknown>,
now?: number,
cb?: (e: Expression) => Promise<Expression>
): Promise<Expression>;
export async function evaluateAsync(
exp: Expression,
obj?: Record<string, unknown>,
now?: number,
cb?: (e: Expression) => Promise<Expression>
): Promise<Expression> {
return mapAsync(exp, async (e) => {
if (cb) e = await cb(e);
if (!isArray(e)) return e;
if (e[0] === "FUNC") {
if (e[1] === "NOW") if (now) return now;
} else if (e[0] === "PARAM") {
if (e[1] == null) return null;
if (obj && !isArray(e[1])) {
let v = obj[e[1]];
if (v == null) return null;
if (typeof v === "object") v = v["value"] ? v["value"][0] : null;
return v as Expression;
}
}
return evaluateCallback(e);
});
}
export function and(exp1: Expression, exp2: Expression): Expression {
if (exp1 != null && !exp1) return false;
if (exp2 != null && !exp2) return false;
if (!Array.isArray(exp1) && !Array.isArray(exp2)) {
if (exp1 == null || exp2 == null) return null;
return true;
}
if (!Array.isArray(exp2) && exp1[0] === "AND") return exp1;
if (!Array.isArray(exp1) && exp2[0] === "AND") return exp2;
const res: Expression = ["AND"];
if (Array.isArray(exp1) && exp1[0] === "AND") res.push(...exp1.slice(1));
else res.push(exp1);
if (Array.isArray(exp2) && exp2[0] === "AND") res.push(...exp2.slice(1));
else res.push(exp2);
return res;
}
export function or(exp1: Expression, exp2: Expression): Expression {
if (!Array.isArray(exp1) && exp1) return true;
if (!Array.isArray(exp2) && exp2) return true;
if (!Array.isArray(exp1) && !Array.isArray(exp2)) {
if (exp1 == null || exp2 == null) return null;
return false;
}
if (!Array.isArray(exp2) && exp1[0] === "OR") return exp1;
if (!Array.isArray(exp1) && exp2[0] === "OR") return exp2;
const res: Expression = ["OR"];
if (Array.isArray(exp1) && exp1[0] === "OR") res.push(...exp1.slice(1));
else res.push(exp1);
if (Array.isArray(exp2) && exp2[0] === "OR") res.push(...exp2.slice(1));
else res.push(exp2);
return res;
}
export function extractParams(exp: Expression): Expression[] {
const params = new Set<Expression>();
map(exp, (e) => {
if (isArray(e) && e[0] === "PARAM") params.add(e[1]);
return e; return e;
}); });
return Array.from(params); return paths;
} }
export function parseList(input: string): Expression[] {
const CHAR_COMMA = 44;
const res: Expression[] = [];
const cursor = new Cursor(input);
cursor.skipwhitespace();
if (!cursor.charCode) return res;
res.push(parseExpression(cursor));
while (cursor.charCode === CHAR_COMMA) {
cursor.step();
res.push(parseExpression(cursor));
}
if (cursor.charCode) throw new Error("Unexpected character");
return res;
}
export default Expression;
+218
View File
@@ -0,0 +1,218 @@
import Expression from "../expression.ts";
import { likePatternToRegExp } from "./parser.ts";
function compare(
a: boolean | number | string,
b: boolean | number | string,
): number {
if (typeof a === "boolean") a = +a;
if (typeof b === "boolean") b = +b;
if (typeof a !== typeof b) return typeof a === "string" ? 1 : -1;
return a > b ? 1 : a < b ? -1 : 0;
}
function toNumber(a: boolean | number | string): number {
switch (typeof a) {
case "number":
return a;
case "boolean":
return +a;
case "string":
return parseFloat(a) || 0;
}
}
function toString(a: boolean | number | string): string {
switch (typeof a) {
case "string":
return a;
case "number":
return a.toString();
case "boolean":
return (+a).toString();
}
}
const regExpCache: WeakMap<Expression.Literal, RegExp> = new WeakMap();
export function reduce(exp: Expression): Expression {
if (exp instanceof Expression.Literal) return exp;
if (exp instanceof Expression.Unary) {
if (exp.operator === "NOT") {
if (exp.operand instanceof Expression.Literal) {
if (exp.operand.value == null) return exp.operand;
return new Expression.Literal(!exp.operand.value);
} else if (exp.operand instanceof Expression.Unary) {
if (exp.operand.operator === "NOT") return exp.operand.operand;
}
} else if (exp.operator === "IS NULL") {
if (exp.operand instanceof Expression.Literal) {
return new Expression.Literal(exp.operand.value == null);
}
} else if (exp.operator === "IS NOT NULL") {
if (exp.operand instanceof Expression.Literal) {
return new Expression.Literal(exp.operand.value != null);
}
}
} else if (exp instanceof Expression.Binary) {
if (exp.operator === "AND") {
return Expression.and(exp.left, exp.right);
} else if (exp.operator === "OR") {
return Expression.or(exp.left, exp.right);
} else if (["=", ">", "<", "<>", ">=", "<="].includes(exp.operator)) {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const c = compare(exp.left.value, exp.right.value);
switch (exp.operator) {
case "=":
return new Expression.Literal(c === 0);
case ">":
return new Expression.Literal(c > 0);
case "<":
return new Expression.Literal(c < 0);
case "<>":
return new Expression.Literal(c !== 0);
case ">=":
return new Expression.Literal(c >= 0);
case "<=":
return new Expression.Literal(c <= 0);
}
}
} else if (["+", "-", "*", "/"].includes(exp.operator)) {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const a = toNumber(exp.left.value);
const b = toNumber(exp.right.value);
switch (exp.operator) {
case "+":
return new Expression.Literal(a + b);
case "-":
return new Expression.Literal(a - b);
case "*":
return new Expression.Literal(a * b);
case "/":
return new Expression.Literal(a / b);
}
}
} else if (exp.operator === "%") {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const a = toNumber(exp.left.value);
const b = Math.trunc(toNumber(exp.right.value));
if (b === 0) return new Expression.Literal(null);
return new Expression.Literal(a % b);
}
} else if (exp.operator === "||") {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const a = toString(exp.left.value);
const b = toString(exp.right.value);
return new Expression.Literal(a + b);
}
} else if (exp.operator === "LIKE") {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const s = toString(exp.left.value);
let r = regExpCache.get(exp.right);
if (!r) {
r = likePatternToRegExp(toString(exp.right.value));
regExpCache.set(exp.right, r);
}
return new Expression.Literal(r.test(s));
}
} else if (exp.operator === "NOT LIKE") {
if (exp.left instanceof Expression.Literal && exp.left.value == null)
return exp.left;
if (exp.right instanceof Expression.Literal && exp.right.value == null)
return exp.right;
if (
exp.left instanceof Expression.Literal &&
exp.right instanceof Expression.Literal
) {
const s = toString(exp.left.value);
let r = regExpCache.get(exp.right);
if (!r) {
r = likePatternToRegExp(toString(exp.right.value));
regExpCache.set(exp.right, r);
}
return new Expression.Literal(!r.test(s));
}
}
} else if (exp instanceof Expression.FunctionCall) {
if (exp.name === "COALESCE") {
const args = exp.args.filter(
(arg) => !(arg instanceof Expression.Literal && arg.value == null),
);
if (!args.length) return new Expression.Literal(null);
if (args.length === 1) return args[0];
if (args[0] instanceof Expression.Literal) return args[0];
if (args.length !== exp.args.length)
return new Expression.FunctionCall("COALESCE", args);
} else if (exp.name === "UPPER") {
if (exp.args[0] instanceof Expression.Literal) {
if (exp.args[0].value == null) return exp.args[0];
const a = toString(exp.args[0].value);
return new Expression.Literal(a.toUpperCase());
}
} else if (exp.name === "LOWER") {
if (exp.args[0] instanceof Expression.Literal) {
if (exp.args[0].value == null) return exp.args[0];
const a = toString(exp.args[0].value);
return new Expression.Literal(a.toLowerCase());
}
} else if (exp.name === "ROUND") {
let p = 0;
if (exp.args.length > 1) {
if (exp.args[1] instanceof Expression.Literal) {
if (exp.args[1].value == null) return exp.args[1];
p = Math.trunc(toNumber(exp.args[1].value));
}
}
if (exp.args[0] instanceof Expression.Literal) {
if (exp.args[0].value == null) return exp.args[0];
const n = toNumber(exp.args[0].value);
const d = 10 ** p;
const m = n * d * (1 + Number.EPSILON);
return new Expression.Literal(Math.round(m) / d);
}
}
} else if (exp instanceof Expression.Conditional) {
if (exp.condition instanceof Expression.Literal) {
if (exp.condition.value) return exp.then;
return exp.otherwise;
}
}
return exp;
}
+490
View File
@@ -0,0 +1,490 @@
import Expression from "../expression.ts";
import { reduce } from "./evaluate.ts";
class Indeterminates {
declare public map: Map<Expression, number>;
declare public sortedKeys: Expression[];
public constructor(exp?: Expression) {
this.map = new Map();
if (exp) {
this.map.set(exp, 1);
this.sortedKeys = [exp];
} else {
this.sortedKeys = [];
}
}
public reciprocal(): Indeterminates {
const res = new Indeterminates();
res.sortedKeys = this.sortedKeys;
res.map = new Map();
for (const [k, v] of this.map) res.map.set(k, 0 - v);
return res;
}
public static multiply(
indeterminates1: Indeterminates,
indeterminates2: Indeterminates,
): Indeterminates {
const res = new Indeterminates();
res.sortedKeys = indeterminates1.sortedKeys.slice();
res.map = new Map(indeterminates1.map);
const strMap: Map<string, Expression> = new Map();
for (const k of res.map.keys()) strMap.set(k.toString(), k);
for (const [key, val] of indeterminates2.map) {
const k = strMap.get(key.toString());
if (!k) {
res.map.set(key, val);
res.sortedKeys.push(key);
} else {
const v2 = val + res.map.get(k);
if (!v2) {
res.map.delete(k);
res.sortedKeys = res.sortedKeys.filter((s) => s !== k);
} else {
res.map.set(k, v2);
}
}
}
res.sortedKeys.sort((a, b) => {
const str1 = a.toString();
const str2 = b.toString();
if (str1.length !== str2.length) return str2.length - str1.length;
else if (str1 > str2) return 1;
else if (str1 < str2) return -1;
return 0;
});
return res;
}
public static compare(a: Indeterminates, b: Indeterminates): number {
if (a.sortedKeys.length !== b.sortedKeys.length)
return b.sortedKeys.length - a.sortedKeys.length;
for (let i = 0; i < a.sortedKeys.length; ++i) {
const k1 = a.sortedKeys[i];
const w1 = a.map.get(k1);
const k2 = b.sortedKeys[i];
const w2 = b.map.get(k2);
if (w1 !== w2) return w2 - w1;
if (k1.toString().length > k2.toString().length) return -1;
else if (k1.toString().length < k2.toString().length) return 1;
else if (k1.toString() > k2.toString()) return 1;
else if (k1.toString() < k2.toString()) return -1;
}
return 0;
}
}
interface Term {
indeterminates: Indeterminates;
coefficientNumerator: bigint;
coefficientDenominator: bigint;
}
function findGcd(a: bigint, b: bigint): bigint {
while (b !== 0n) {
const t = b;
b = a % b;
a = t;
}
return a;
}
class Polynomial extends Expression {
declare public terms: Term[];
public constructor(terms: Term[]) {
super();
this.terms = terms;
}
map(): Polynomial {
return this;
}
async mapAsync(): Promise<Polynomial> {
return this;
}
public static simplifyTerms(terms: Term[]): Term[] {
const ts = terms
.slice()
.sort((a: Term, b: Term) =>
Indeterminates.compare(a.indeterminates, b.indeterminates),
);
for (let i = 1; i < ts.length; ++i) {
const t1 = ts[i - 1];
const t2 = ts[i];
if (Indeterminates.compare(t1.indeterminates, t2.indeterminates) === 0) {
const numerator =
t1.coefficientNumerator * t2.coefficientDenominator +
t2.coefficientNumerator * t1.coefficientDenominator;
const denominator =
t1.coefficientDenominator * t2.coefficientDenominator;
const gcd = findGcd(numerator, denominator);
ts[i] = {
indeterminates: t2.indeterminates,
coefficientNumerator: numerator / gcd,
coefficientDenominator: denominator / gcd,
};
ts[i - 1] = {
indeterminates: t1.indeterminates,
coefficientNumerator: 0n,
coefficientDenominator: t1.coefficientDenominator,
};
}
}
return ts.filter((v) => v.coefficientNumerator !== 0n);
}
public static fromIndeterminate(indeterminate: Expression): Polynomial {
const indeterminates = new Indeterminates(indeterminate);
const terms = [
{
indeterminates: indeterminates,
coefficientNumerator: 1n,
coefficientDenominator: 1n,
},
];
return new Polynomial(terms);
}
public static fromConstant(constant: number): Polynomial {
const [int, frac] = Math.abs(constant).toString(2).split(".", 2);
let numerator = BigInt("0b" + int);
if (constant < 0) numerator = numerator / -1n;
let denominator = 1n;
if (frac) {
denominator = 2n ** BigInt(frac.length);
numerator = numerator * denominator + BigInt("0b" + frac);
}
const terms = [
{
indeterminates: new Indeterminates(),
coefficientNumerator: numerator,
coefficientDenominator: denominator,
},
];
return new Polynomial(terms);
}
public negation(): Polynomial {
const terms = this.terms.map((t) => ({
indeterminates: t.indeterminates,
coefficientNumerator: t.coefficientNumerator * -1n,
coefficientDenominator: t.coefficientDenominator,
}));
return new Polynomial(terms);
}
public reciprocal(): Polynomial {
const terms = this.terms.map((t) => ({
indeterminates: t.indeterminates.reciprocal(),
coefficientNumerator: t.coefficientDenominator,
coefficientDenominator: t.coefficientNumerator,
}));
return new Polynomial(terms);
}
public constant(): Polynomial {
const terms = this.terms.filter((t) => !t.indeterminates.sortedKeys.length);
return new Polynomial(terms);
}
public add(rhs: Polynomial): Polynomial {
return new Polynomial(
Polynomial.simplifyTerms(this.terms.concat(rhs.terms)),
);
}
public subtract(rhs: Polynomial): Polynomial {
return this.add(rhs.negation());
}
public multiply(rhs: Polynomial): Polynomial {
const terms: Term[] = [];
for (const t1 of this.terms) {
for (const t2 of rhs.terms) {
const numerator = t1.coefficientNumerator * t2.coefficientNumerator;
const denominator =
t1.coefficientDenominator * t2.coefficientDenominator;
const gcd = findGcd(numerator, denominator);
terms.push({
indeterminates: Indeterminates.multiply(
t1.indeterminates,
t2.indeterminates,
),
coefficientNumerator: numerator / gcd,
coefficientDenominator: denominator / gcd,
});
}
}
return new Polynomial(Polynomial.simplifyTerms(terms));
}
public divide(rhs: Polynomial): Polynomial {
return this.multiply(rhs.reciprocal());
}
public toExpression(): Expression {
const add: Expression[] = [];
for (const t of this.terms) {
const coefficient =
Number(t.coefficientNumerator) / Number(t.coefficientDenominator);
const mul: Expression[] = [];
if (t.indeterminates.sortedKeys.length) {
for (const k of t.indeterminates.sortedKeys) {
const w = t.indeterminates.map.get(k);
for (let i = Math.abs(w); i > 0; --i) {
if (w > 0) mul.push(k);
else
mul.push(
new Expression.Binary("/", new Expression.Literal(1), k),
);
}
}
if (coefficient !== 1) mul.push(new Expression.Literal(coefficient));
while (mul.length > 1) {
const r = mul.pop();
const l = mul.pop();
mul.push(new Expression.Binary("*", l, r));
}
add.push(mul[0]);
} else {
add.push(new Expression.Literal(coefficient));
}
}
while (add.length > 1) {
const r = add.pop();
const l = add.pop();
add.push(new Expression.Binary("+", l, r));
}
if (!add.length) return new Expression.Literal(0);
return add[0];
}
}
const SWAPPED_OPS = {
"=": "=",
"<>": "<>",
">": "<",
">=": "<=",
"<": ">",
"<=": ">=",
};
function cartesianProduct<T>(arrays: T[][]): T[][] {
return arrays.reduce<T[][]>(
(acc, cur) => acc.flatMap((a) => cur.map((item) => [...a, item])),
[[]],
);
}
function toPolynomial(e: Expression): Polynomial {
if (e instanceof Polynomial) return e;
if (e instanceof Expression.Literal) {
if (e.value == null) return null;
if (typeof e.value === "number") return Polynomial.fromConstant(e.value);
if (typeof e.value === "string")
return Polynomial.fromConstant(parseFloat(e.value) || 0);
if (typeof e.value === "boolean") return Polynomial.fromConstant(+e.value);
}
return Polynomial.fromIndeterminate(e);
}
function fromPolynomial(e: Expression): Expression {
if (e instanceof Polynomial) return e.toExpression();
if (e instanceof Expression.Conditional) return e.map(fromPolynomial);
return e;
}
function normalizeCallback(exp: Expression): Expression {
if (exp instanceof Expression.FunctionCall) {
if (exp.name === "COALESCE") {
let e: Expression = new Expression.Literal(null);
for (let i = exp.args.length - 1; i >= 0; --i) {
e = new Expression.Conditional(
normalizeCallback(new Expression.Unary("IS NOT NULL", exp.args[i])),
exp.args[i],
e,
);
}
return normalizeCallback(e);
}
}
if (exp instanceof Expression.Conditional) {
let e = exp;
if (e.condition instanceof Polynomial)
e = new Expression.Conditional(
e.condition.toExpression(),
e.then,
e.otherwise,
);
if (e.then instanceof Expression.Conditional) {
e = new Expression.Conditional(
Expression.and(e.condition, e.then.condition),
e.then.then,
new Expression.Conditional(e.condition, e.then.otherwise, e.otherwise),
);
}
return e;
}
const combs: [Expression, Expression][][] = [];
const callback: (e: Expression) => Expression = (e) => {
if (e instanceof Expression.Conditional) {
combs[combs.length - 1].push([e.condition, e.then]);
callback(e.otherwise);
} else {
combs[combs.length - 1].push([new Expression.Literal(true), e]);
}
return e;
};
exp.map((e) => {
combs.push([]);
callback(e);
return e;
});
if (combs.some((a) => a.length > 1)) {
let res: Expression = new Expression.Literal(null);
for (const p of cartesianProduct(combs).reverse()) {
let condition: Expression = new Expression.Literal(true);
const e = reduce(
normalizeCallback(
exp.map((_, i) => {
condition = Expression.and(condition, p[i][0]);
return p[i][1];
}),
),
);
if (!(condition instanceof Expression.Literal))
res = new Expression.Conditional(condition, e, res);
else if (condition.value) res = e;
}
return res;
}
if (exp instanceof Expression.Binary) {
if (["+", "-", "*", "/"].includes(exp.operator)) {
const lhs = toPolynomial(exp.left);
const rhs = toPolynomial(exp.right);
if (lhs == null || rhs == null) return new Expression.Literal(null);
if (exp.operator === "+") return lhs.add(rhs);
if (exp.operator === "-") return lhs.subtract(rhs);
if (exp.operator === "*") return lhs.multiply(rhs);
if (exp.operator === "/") return lhs.divide(rhs);
} else if ([">", ">=", "<", "<=", "=", "<>"].includes(exp.operator)) {
let lhs: Polynomial, rhs: Polynomial;
if (exp.left instanceof Polynomial) lhs = exp.left;
else if (exp.left instanceof Expression.Literal) {
if (exp.left.value == null) return exp.left;
else if (typeof exp.left.value === "number")
lhs = Polynomial.fromConstant(exp.left.value);
}
if (exp.right instanceof Polynomial) rhs = exp.right;
else if (exp.right instanceof Expression.Literal) {
if (exp.right.value == null) return exp.right;
else if (typeof exp.right.value === "number")
rhs = Polynomial.fromConstant(exp.right.value);
}
if (lhs || rhs) {
if (!lhs) lhs = Polynomial.fromIndeterminate(exp.left);
if (!rhs) rhs = Polynomial.fromIndeterminate(exp.right);
lhs = lhs.subtract(rhs);
rhs = lhs.constant().negation();
lhs = lhs.add(rhs);
if (!lhs.terms.length) {
const l = lhs.toExpression() as Expression.Literal;
const r = rhs.toExpression() as Expression.Literal;
if (exp.operator === "=")
return new Expression.Literal(l.value === r.value);
else if (exp.operator === "<>")
return new Expression.Literal(l.value !== r.value);
else if (exp.operator === ">")
return new Expression.Literal(l.value > r.value);
else if (exp.operator === ">=")
return new Expression.Literal(l.value >= r.value);
else if (exp.operator === "<")
return new Expression.Literal(l.value < r.value);
else if (exp.operator === "<=")
return new Expression.Literal(l.value <= r.value);
else throw new Error("Invalid operator");
}
let flipOp = 1;
const n = lhs.terms[0].coefficientNumerator;
const d = lhs.terms[0].coefficientDenominator;
if (n < 0n || d < 0n) flipOp *= -1;
const reciprocal = new Polynomial([
{
indeterminates: new Indeterminates(),
coefficientNumerator: d,
coefficientDenominator: n,
},
]);
lhs = lhs.multiply(reciprocal);
rhs = rhs.multiply(reciprocal);
const keys = lhs.terms[0].indeterminates.sortedKeys;
let invert = lhs.terms[0].indeterminates.map.get(keys[0]) < 0 ? -1 : 0;
for (const t of lhs.terms)
for (const v of t.indeterminates.map.values()) invert += v;
if (invert < 0) {
flipOp *= -1;
lhs = lhs.reciprocal();
rhs = rhs.reciprocal();
}
if (flipOp > 0) exp = new Expression.Binary(exp.operator, lhs, rhs);
else exp = new Expression.Binary(SWAPPED_OPS[exp.operator], lhs, rhs);
}
}
}
// Restore polynomial expressions
exp = exp.map(fromPolynomial);
return exp;
}
export default function normalize(exp: Expression): Expression {
return fromPolynomial(exp.evaluate(normalizeCallback));
}
+160
View File
@@ -0,0 +1,160 @@
import { complement } from "espresso-iisojs";
import Expression from "../expression.ts";
import normalize from "./normalize.ts";
import { Clause, SynthContext } from "./synth.ts";
import Path from "../path.ts";
type Bookmark = Record<string, null | boolean | number | string>;
type Minterm = number[];
export function toBookmark(
sort: Record<string, number>,
row: unknown,
): Bookmark {
const bookmark: Bookmark = {};
for (const param of Object.keys(sort)) {
let v = row[param];
if (v != null && typeof v === "object") v = v.value?.[0];
bookmark[param] = v;
}
return bookmark;
}
export function bookmarkToExpression(
bookmark: Bookmark,
sort: Record<string, number>,
): Expression {
return Object.entries(sort)
.reverse()
.reduce(
(cur, kv) => {
const [param, asc] = kv;
const p = new Expression.Parameter(Path.parse(param));
const b = new Expression.Literal(bookmark[param]);
if (asc < 0) {
if (bookmark[param] == null) {
return Expression.or(
new Expression.Unary("IS NOT NULL", p),
Expression.and(new Expression.Unary("IS NULL", p), cur),
);
}
return new Expression.Binary(
"OR",
new Expression.Binary(">", p, b),
new Expression.Binary("AND", new Expression.Binary("=", p, b), cur),
);
} else {
let f: Expression = new Expression.Unary("IS NULL", p);
if (bookmark[param] == null) return Expression.and(f, cur);
f = Expression.or(f, new Expression.Binary("<", p, b));
return Expression.or(
f,
Expression.and(new Expression.Binary("=", p, b), cur),
);
}
},
new Expression.Literal(true) as Expression,
);
}
function getCover(
context: SynthContext,
minterm: Minterm,
allSort: [string, number][],
): Minterm[] {
if (!allSort.length) return [[]];
const [param, sort] = allSort[0];
const cov: Minterm = [];
const nextCov: Minterm = [];
if (sort > 0) {
const lhs = new Clause.Exp(new Expression.Parameter(Path.parse(param)));
const isNull = context.getVar(new Clause.IsNull(lhs));
cov.push((isNull << 2) ^ 2);
}
for (const m of minterm) {
const clause = context.getClause(m >>> 2);
if (clause instanceof Clause.IsNull) {
if (!(clause.operand instanceof Clause.Exp)) continue;
if (!(clause.operand.exp instanceof Expression.Parameter)) continue;
if (clause.operand.exp.path.toString() !== param) continue;
nextCov.push(m);
if (sort < 0 && m & 1) cov.push(m, m ^ 1);
} else if (clause instanceof Clause.Compare) {
if (!(clause.lhs instanceof Clause.Exp)) continue;
if (!(clause.lhs.exp instanceof Expression.Parameter)) continue;
if (clause.lhs.exp.path.toString() !== param) continue;
if (!(m & 1) && sort > 0) continue;
nextCov.push(m);
const negate = (m ^ (m >> 1)) & 1;
if (sort > 0) {
if (
(clause.op === "=" && !negate) ||
(clause.op === ">" && !negate) ||
(clause.op === "<" && negate)
) {
const c = new Clause.Compare(clause.lhs, ">", clause.rhs);
const v = context.getVar(c);
cov.push((v << 2) ^ 3);
}
} else if (sort < 0) {
if (
(clause.op === "=" && !negate) ||
(clause.op === ">" && negate) ||
(clause.op === "<" && !negate)
) {
const c = new Clause.Compare(clause.lhs, "<", clause.rhs);
const v = context.getVar(c);
cov.push((v << 2) ^ 0);
}
}
}
}
const next = getCover(context, minterm, allSort.slice(1));
return [cov, ...next.map((n) => [...nextCov, ...n])];
}
export function paginate(
fetched: Expression,
toFetch: Expression,
sort: Record<string, number>,
): [Expression, Expression] {
fetched = normalize(fetched);
if (fetched instanceof Expression.Literal && !fetched.value)
return [new Expression.Literal(false), toFetch];
toFetch = normalize(toFetch);
if (toFetch instanceof Expression.Literal && !toFetch.value)
return [new Expression.Literal(false), toFetch];
const synth1 = Clause.fromExpression(fetched);
const synth2 = Clause.fromExpression(toFetch);
const context = new SynthContext();
const expr1Minterms = synth1.getMinterms(context, 0b100);
const expr2MintermsC = synth2.getMinterms(context, 0b011);
const gaps = context.sanitizeMinterms(
complement([
...expr1Minterms,
...expr2MintermsC,
...context.getDcSet([...expr1Minterms, ...expr2MintermsC]),
]),
);
const cover = gaps.flatMap((m) => getCover(context, m, Object.entries(sort)));
const minterms1 = context.minimize(complement([...cover, ...expr2MintermsC]));
const minterms2 = context.minimize(
complement([...minterms1, ...expr2MintermsC]),
);
return [context.toExpression(minterms1), context.toExpression(minterms2)];
}
+557
View File
@@ -0,0 +1,557 @@
import Path from "../path.ts";
import Expression from "../expression.ts";
export class Cursor {
input: string;
pos: number;
boundryStack: number[][];
boundry: number[];
charCode: number;
constructor(input: string) {
this.input = input;
this.pos = 0;
this.boundryStack = [];
this.boundry = [];
this.charCode = input.charCodeAt(0) || 0;
}
fork(): Cursor {
const cursor = new Cursor(this.input);
cursor.pos = this.pos;
cursor.boundryStack = this.boundryStack.slice();
cursor.boundry = this.boundry;
cursor.charCode = this.charCode;
return cursor;
}
sync(cursor: Cursor): void {
this.pos = cursor.pos;
this.boundryStack = cursor.boundryStack.slice();
this.boundry = cursor.boundry;
this.charCode = cursor.charCode;
}
read(cur: Cursor): string {
return this.input.slice(this.pos, cur.pos);
}
step(): Cursor {
if (this.charCode === 0) return this;
++this.pos;
this.charCode = this.input.charCodeAt(this.pos) || 0;
if (this.boundry.includes(this.charCode)) this.charCode = 0;
return this;
}
walk(callback: (charCode: number) => boolean): Cursor {
while (this.charCode && callback(this.charCode)) this.step();
return this;
}
descend(chars: number[], override = true): void {
this.boundryStack.push(this.boundry);
if (override) this.boundry = chars;
else this.boundry = [...this.boundry, ...chars];
this.charCode = this.input.charCodeAt(this.pos) || 0;
if (this.boundry.includes(this.charCode)) this.charCode = 0;
}
ascend(): void {
if (!this.boundryStack.length) throw new Error("Unmatched boundry");
this.boundry = this.boundryStack.pop();
this.charCode = this.input.charCodeAt(this.pos) || 0;
}
skipwhitespace(): Cursor {
return this.walk((c) => c <= 32);
}
}
const CHAR_SINGLE_QUOTE = 39;
const CHAR_DOUBLE_QUOTE = 34;
const CHAR_OPEN_PAREN = 40;
const CHAR_CLOSE_PAREN = 41;
const CHAR_COMMA = 44;
const CHAR_PERIOD = 46;
const CHAR_COLON = 58;
const CHAR_OPEN_BRACKET = 91;
const CHAR_BACKSLASH = 92;
const CHAR_CLOSE_BRACKET = 93;
const BINARY_OPERATORS = [
">=",
"<=",
"<>",
"=",
">",
"<",
"LIKE",
"NOT LIKE",
"AND",
"OR",
"*",
"/",
"%",
"||",
"-",
"+",
];
const PRECEDENCE = {
OR: 10,
AND: 11,
NOT: 12,
"=": 20,
"<>": 20,
">": 20,
">=": 20,
"<": 20,
"<=": 20,
LIKE: 20,
"NOT LIKE": 20,
"IS NULL": 20,
"IS NOT NULL": 20,
"||": 30,
"+": 31,
"-": 31,
"*": 32,
"/": 32,
"%": 32,
};
function* range(s: number, e: number): Generator<number> {
for (let i = s; i < e; i++) yield i;
}
const PATH_CHARS = new Set<number>([
...range(65, 91),
...range(97, 123),
...range(48, 58),
95,
45,
42,
123,
125,
]);
function findOperator(cursor: Cursor): string {
cursor.skipwhitespace();
let found = "";
let foundCursor = cursor;
let operators = [...BINARY_OPERATORS, "IS NULL", "IS NOT NULL"];
for (let i = 0; operators.length && cursor.charCode; ++i) {
let c = cursor.charCode;
cursor.step();
if (c >= 97 && c <= 122) c -= 32;
if (c <= 32) {
cursor.skipwhitespace();
c = 32;
}
operators = operators.filter((o) => {
if (o.charCodeAt(i) !== c) return false;
if (o.length === i + 1) {
found = o;
foundCursor = cursor.fork();
}
return true;
});
}
if (found) cursor.sync(foundCursor);
return found;
}
// Turn escaped characters into real ones (e.g. "\\n" becomes "\n").
function interpretEscapes(str): string {
const escapes = {
b: "\b",
f: "\f",
n: "\n",
r: "\r",
t: "\t",
};
return str.replace(/\\(u[0-9a-fA-F]{4}|[^u])/g, (_, escape) => {
const type = escape.charAt(0);
const hex = escape.slice(1);
if (type === "u") return String.fromCharCode(parseInt(hex, 16));
if (escapes.hasOwnProperty(type)) return escapes[type];
return type;
});
}
export function parseExpression(cursor: Cursor, presedence = 0): Expression {
cursor.skipwhitespace();
const char = cursor.charCode;
let lhs: Expression;
if (char === CHAR_OPEN_PAREN) {
cursor.step();
cursor.descend([CHAR_CLOSE_PAREN]);
lhs = parseExpression(cursor, 0);
cursor.ascend();
cursor.skipwhitespace();
if (cursor.charCode !== CHAR_CLOSE_PAREN)
throw new Error("Expected ')'" + cursor.pos + " " + cursor.charCode);
cursor.step();
} else if (char === CHAR_DOUBLE_QUOTE) {
cursor.step();
const cursor2 = cursor.fork();
for (
cursor2.descend([]);
cursor2.charCode !== CHAR_DOUBLE_QUOTE;
cursor2.step()
) {
if (!cursor2.charCode) throw new Error("Unterminated string");
if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();
}
cursor2.ascend();
const str = cursor.read(cursor2);
cursor.sync(cursor2);
cursor.step();
return new Expression.Literal(interpretEscapes(str));
} else if (char === CHAR_SINGLE_QUOTE) {
cursor.step();
const cursor2 = cursor.fork();
for (
cursor2.descend([]);
cursor2.charCode !== CHAR_SINGLE_QUOTE;
cursor2.step()
) {
if (!cursor2.charCode) throw new Error("Unterminated string");
if (cursor2.charCode === CHAR_BACKSLASH) cursor2.step();
}
cursor2.ascend();
const str = cursor.read(cursor2);
cursor.sync(cursor2);
cursor.step();
return new Expression.Literal(str.replaceAll("''", "'"));
} else {
const cursor2 = cursor.fork();
cursor.walk(
(c) => c > 32 && c !== CHAR_OPEN_PAREN && c !== CHAR_OPEN_BRACKET,
);
const token = cursor2.read(cursor);
if (!token) throw new Error("Invalid expression");
if (/^true$/i.test(token)) lhs = new Expression.Literal(true);
else if (/^false$/i.test(token)) lhs = new Expression.Literal(false);
else if (/^null$/i.test(token)) lhs = new Expression.Literal(null);
else if (/^-?(0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?$/.test(token))
lhs = new Expression.Literal(Number(token));
else if (/^not$/i.test(token)) {
lhs = new Expression.Unary(
"NOT",
parseExpression(cursor, PRECEDENCE["NOT"]),
);
} else if (/^case$/i.test(token)) {
const pairs: [Expression, Expression][] = [];
for (;;) {
cursor.skipwhitespace();
const whenStr = cursor.fork().read(cursor.walk((c) => c > 32));
if (/^else$/i.test(whenStr)) {
lhs = parseExpression(cursor);
continue;
}
if (/^end$/i.test(whenStr)) break;
else if (lhs) throw new Error("Expected END");
if (!/^when$/i.test(whenStr)) throw new Error("Expected WHEN");
const condition = parseExpression(cursor);
cursor.skipwhitespace();
const thenStr = cursor.fork().read(cursor.walk((c) => c > 32));
if (!/^then$/i.test(thenStr)) throw new Error("Expected THEN");
const then = parseExpression(cursor);
pairs.push([condition, then]);
}
if (!lhs) lhs = new Expression.Literal(null);
while (pairs.length) {
const [condition, then] = pairs.pop();
lhs = new Expression.Conditional(condition, then, lhs);
}
} else if (cursor.charCode === CHAR_OPEN_PAREN) {
cursor.step();
cursor.descend([CHAR_CLOSE_PAREN]);
cursor.skipwhitespace();
const args = [] as Expression[];
while (cursor.charCode) {
cursor.descend([CHAR_COMMA], false);
const e = parseExpression(cursor);
args.push(e);
cursor.ascend();
cursor.skipwhitespace();
if ((cursor.charCode as number) !== CHAR_COMMA) break;
cursor.step();
}
cursor.ascend();
cursor.step();
lhs = new Expression.FunctionCall(token, args);
} else {
const p = parsePath(cursor2);
lhs = new Expression.Parameter(p);
cursor.sync(cursor2);
}
}
for (;;) {
cursor.skipwhitespace();
const cursor2 = cursor.fork();
const op = findOperator(cursor2);
const p = PRECEDENCE[op];
if (p <= presedence) return lhs;
if (op === "IS NULL") {
lhs = new Expression.Unary("IS NULL", lhs);
} else if (op === "IS NOT NULL") {
lhs = new Expression.Unary("IS NOT NULL", lhs);
} else if (BINARY_OPERATORS.includes(op)) {
lhs = new Expression.Binary(op, lhs, parseExpression(cursor2, p));
} else if (op) throw new Error("Unrecognized operator: " + op);
else break;
cursor.sync(cursor2);
}
return lhs;
}
// Parse old-format alias value: unquoted, double-quoted, or single-quoted.
// Returns the parsed string value. Cursor is advanced past the value.
function parseOldAliasValue(cursor: Cursor): string {
cursor.walk((c) => c <= 32); // skip leading whitespace
const c = cursor.charCode;
if (c === CHAR_DOUBLE_QUOTE) {
// Double-quoted: use JSON.parse semantics (handles \", \n, \uXXXX etc.)
// Descend with no boundaries so commas/brackets inside quotes are not
// treated as terminators.
const start = cursor.pos;
cursor.step();
cursor.descend([]);
while (cursor.charCode) {
if (cursor.charCode === CHAR_BACKSLASH) {
cursor.step();
if (!cursor.charCode) break;
} else if (cursor.charCode === CHAR_DOUBLE_QUOTE) {
cursor.ascend();
cursor.step();
return JSON.parse(cursor.input.slice(start, cursor.pos)) as string;
}
cursor.step();
}
throw new Error("Unterminated string");
}
if (c === CHAR_SINGLE_QUOTE) {
// Single-quoted: strip quotes, no escape processing
cursor.step();
cursor.descend([]);
const start = cursor.pos;
while (cursor.charCode && cursor.charCode !== CHAR_SINGLE_QUOTE)
cursor.step();
if (!cursor.charCode) throw new Error("Unterminated string");
const value = cursor.input.slice(start, cursor.pos);
cursor.ascend();
cursor.step();
return value;
}
// Unquoted: read until , or ] (respecting boundary), trim result
const start = cursor.pos;
cursor.walk(() => true);
return cursor.input.slice(start, cursor.pos).trim();
}
// Parse old-format alias content: key:value,key:value,...
// Cursor should be positioned at the start of bracket content
// (after '[', with ']' set as boundary).
// Returns an Expression: Binary("AND", ...) chain of Binary("=", param, literal).
// Empty content returns Literal(true).
function parseOldAlias(cursor: Cursor): Expression {
cursor.skipwhitespace();
if (!cursor.charCode) return new Expression.Literal(true);
let result: Expression | null = null;
for (;;) {
cursor.skipwhitespace();
// Parse key as a path (supports nested aliases via parsePath).
// Add colon and comma as boundaries so the path stops there.
cursor.descend([CHAR_COLON, CHAR_COMMA], false);
const keyPath = parsePath(cursor);
cursor.ascend();
// After ascend, check for the colon separator
cursor.skipwhitespace();
if ((cursor.charCode as number) !== CHAR_COLON)
throw new Error("Expected ':'");
cursor.step();
// Parse value (reads until boundary , or ])
cursor.descend([CHAR_COMMA], false);
const value = parseOldAliasValue(cursor);
cursor.ascend();
const pair = new Expression.Binary(
"=",
new Expression.Parameter(keyPath),
new Expression.Literal(value),
);
result = result ? new Expression.Binary("AND", result, pair) : pair;
cursor.skipwhitespace();
if ((cursor.charCode as number) !== CHAR_COMMA) break;
cursor.step();
}
return result;
}
export function parsePath(cur: Cursor): Path {
const segments: (string | Expression)[] = [];
let colon = 0;
const cur2 = cur.fork();
for (;;) {
let char = cur2.charCode;
let exp: Expression;
if (char === CHAR_OPEN_BRACKET) {
if (cur2.pos !== cur.pos) throw new Error("Invalid path");
cur2.step();
cur2.descend([CHAR_CLOSE_BRACKET]);
// Try parsing as expression first
const cur3 = cur2.fork();
let err: unknown;
try {
exp = parseExpression(cur3);
} catch (e) {
err = e;
}
// Fall back to old alias format if not a valid expression or produced
// a bare Parameter (old format like [a:1] mis-parsed as a path).
if (err || exp instanceof Expression.Parameter) {
exp = parseOldAlias(cur2);
} else {
cur2.sync(cur3);
}
cur2.ascend();
char = cur2.charCode;
if (char !== CHAR_CLOSE_BRACKET) throw new Error("Expected ']'");
char = cur2.step().charCode;
}
if (!PATH_CHARS.has(char)) {
if (cur2.pos <= cur.pos) throw new Error("Invalid path");
if (char === CHAR_COLON) {
if (colon) throw new Error("Multiple colons");
colon = segments.length + 1;
}
if (exp) segments.push(exp);
else segments.push(cur.read(cur2));
if (char !== CHAR_PERIOD && char !== CHAR_COLON) break;
cur2.step();
cur.sync(cur2);
continue;
}
if (exp) throw new Error("Invalid path");
cur2.step();
}
cur.sync(cur2);
if (colon) colon = segments.length - colon;
return new Path(segments, colon);
}
export function stringifyExpression(exp: Expression, level = 0): string {
function wrap(e: string, op: string): string {
if (PRECEDENCE[op] <= level) return `(${e})`;
else return e;
}
if (exp instanceof Expression.Literal) {
if (exp.value == null) return "NULL";
if (exp.value === true) return "TRUE";
if (exp.value === false) return "FALSE";
return JSON.stringify(exp.value);
} else if (exp instanceof Expression.Unary) {
if (exp.operator === "NOT") {
return wrap(
`NOT ${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])}`,
"NOT",
);
} else if (exp.operator === "IS NULL" || exp.operator === "IS NOT NULL") {
return wrap(
`${stringifyExpression(exp.operand, PRECEDENCE[exp.operator])} ${exp.operator}`,
exp.operator,
);
}
} else if (exp instanceof Expression.Binary) {
const op = exp.operator;
if (!(op in PRECEDENCE)) throw new Error("Invalid operator");
return wrap(
`${stringifyExpression(exp.left, PRECEDENCE[op] - 1)} ${exp.operator} ${stringifyExpression(exp.right, PRECEDENCE[op])}`,
op,
);
} else if (exp instanceof Expression.Parameter) {
return exp.path.toString();
} else if (exp instanceof Expression.FunctionCall) {
return `${exp.name}(${exp.args.map((a) => stringifyExpression(a)).join(", ")})`;
} else if (exp instanceof Expression.Conditional) {
let str = `CASE WHEN ${stringifyExpression(exp.condition)} THEN ${stringifyExpression(exp.then)}`;
if (
exp.otherwise instanceof Expression.Literal &&
exp.otherwise.value == null
)
str += " END";
else if (exp.otherwise instanceof Expression.Conditional)
str += stringifyExpression(exp.otherwise).slice(4);
else str += ` ELSE ${stringifyExpression(exp.otherwise)} END`;
return str;
}
throw new Error("Invalid expression");
}
export function parseLikePattern(pat: string, esc: string): string[] {
const chars = pat.split("");
for (let i = 0; i < chars.length; ++i) {
const c = chars[i];
if (c === esc) {
chars[i] = chars[i + 1] || "";
chars[i + 1] = "";
} else if (c === "_") {
chars[i] = "\\_";
} else if (c === "%") {
chars[i] = "\\%";
while (chars[i + 1] === "%") chars[++i] = "";
}
}
return chars.filter((c) => c);
}
export function likePatternToRegExp(pat: string, esc = "", flags = ""): RegExp {
const convChars = {
"-": "\\-",
"/": "\\/",
"\\": "\\/",
"^": "\\^",
$: "\\$",
"*": "\\*",
"+": "\\+",
"?": "\\?",
".": "\\.",
"(": "\\(",
")": "\\)",
"|": "\\|",
"[": "\\[",
"]": "\\]",
"{": "\\{",
"}": "\\}",
"\\%": ".*",
"\\_": ".",
};
let chars = parseLikePattern(pat, esc);
if (!chars.length) return new RegExp("^$", flags);
chars = chars.map((c) => convChars[c] || c);
chars[0] = chars[0] === ".*" ? "" : "^" + chars[0];
const l = chars.length - 1;
chars[l] = [".*", ""].includes(chars[l]) ? "" : chars[l] + "$";
return new RegExp(chars.join(""), flags);
}
File diff suppressed because it is too large. Load diff
-19
View File
@@ -1,22 +1,3 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
let cache1 = new Map(); let cache1 = new Map();
let cache2 = new Map(); let cache2 = new Map();
const keys = new WeakMap(); const keys = new WeakMap();
+70 -85
View File
@@ -1,59 +1,32 @@
/** import Path from "./path.ts";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import Path from "./path";
export default class PathSet { export default class PathSet {
private declare lengthIndex: Set<Path>[]; private paramSegmentIndex: Map<string, Set<Path>>[] = [];
private declare fragmentIndex: Map<string, Set<Path>>[]; private attrSegmentIndex: Map<string, Set<Path>>[] = [];
private declare stringIndex: Map<string, Path>; private stringIndex: Map<string, Path> = new Map();
public constructor() { public constructor() {}
this.lengthIndex = [];
this.fragmentIndex = [];
this.stringIndex = new Map();
}
public get depth(): number { public add(pathStr: string): Path {
return this.lengthIndex.length; let path: Path = this.get(pathStr);
} if (path) return path;
path = Path.parse(pathStr);
public add(path: Path): Path {
if (path.alias) throw new Error("PathSet does not support aliased paths"); if (path.alias) throw new Error("PathSet does not support aliased paths");
const p = this.get(path);
if (p) return p;
this.stringIndex.set(path.toString(), path); this.stringIndex.set(path.toString(), path);
while (this.lengthIndex.length <= path.length) { while (this.paramSegmentIndex.length < path.paramLength)
this.lengthIndex.push(new Set()); this.paramSegmentIndex.push(new Map());
// fragmentIndex is one less than lengthIndex
if (this.lengthIndex.length > 1) this.fragmentIndex.push(new Map());
}
const lengthIndex = this.lengthIndex[path.length]; while (this.attrSegmentIndex.length < path.attrLength)
lengthIndex.add(path); this.attrSegmentIndex.push(new Map());
for (let i = 0; i < path.length; ++i) { for (let i = 0; i < path.length; ++i) {
const fragment = path.segments[i] as string; const fragment = path.segments[i] as string;
const fragmentIndex = this.fragmentIndex[i]; const fragmentIndex =
i < path.paramLength
? this.paramSegmentIndex[i]
: this.attrSegmentIndex[i - path.paramLength];
let fragmentIndexSet = fragmentIndex.get(fragment); let fragmentIndexSet = fragmentIndex.get(fragment);
if (!fragmentIndexSet) { if (!fragmentIndexSet) {
@@ -67,64 +40,76 @@ export default class PathSet {
return path; return path;
} }
public get(path: Path): Path { public get(path: string): Path {
return this.stringIndex.get(path.toString()) || null; return this.stringIndex.get(path);
} }
public find( public findCompat(
path: Path, path: Path,
superset = false, superset = false,
subset = false, subset = false,
depth: number = path.length depth = path.length,
): Path[] { ): Path[] {
if (path.attrLength)
throw new Error("findCompat() does not support attribute paths");
depth = Math.min(31, depth);
const paramMask = (1 << path.paramLength) - 1;
let mask = ((0b10 << depth) - 1) & ~paramMask;
if (superset) mask |= ~path.wildcard & paramMask;
if (subset) mask |= path.wildcard;
return this.find(path, mask, 1);
}
public find(path: Path, paramMask: number, attrMask: number): Path[] {
if (path.alias) throw new Error("PathSet does not support aliased paths"); if (path.alias) throw new Error("PathSet does not support aliased paths");
if (path.paramLength > this.paramSegmentIndex.length) return [];
if (path.attrLength > this.attrSegmentIndex.length) return [];
const len = path.length; const emptySet: Set<Path> = new Set();
if (!superset && depth === len && (!subset || !path.wildcard)) { const indexes: [Set<Path>, Set<Path>][] = [];
const p = this.get(path);
return p ? [p] : []; const paramLengthMask = (1 << path.paramLength) - 1;
const attrLengthMask = (1 << path.attrLength) - 1;
const mask = (paramMask & paramLengthMask) | (attrMask << path.paramLength);
for (const [i, s] of path.segments.entries()) {
const b = 1 << i;
const m = mask & b;
const w = b & path.wildcard;
if (w && m) continue;
const idxSet =
i < path.paramLength
? this.paramSegmentIndex[i]
: this.attrSegmentIndex[i - path.paramLength];
const idx1 = idxSet.get(s as string) || emptySet;
let idx2 = emptySet;
if (m) idx2 = idxSet.get("*") || emptySet;
indexes.push([idx1, idx2]);
} }
const lengthIndex = this.lengthIndex.slice(len, depth + 1); indexes.sort((a, b) => a[0].size + a[1].size - (b[0].size + b[1].size));
if (!lengthIndex.length) return [];
let res; let res: Path[];
for (let i = len - 1; i >= 0; --i) {
let fragmentIndexSet2;
const fragmentIndex = this.fragmentIndex[i];
if ((path.wildcard >> i) & 1) { if (!indexes.length) res = [...this.stringIndex.values()];
if (subset) continue; else res = [...indexes[0][0], ...indexes[0][1]];
} else if (superset) {
fragmentIndexSet2 = fragmentIndex.get("*");
}
const fragment = path.segments[i] as string; const paramCover = ~paramLengthMask & paramMask;
const fragmentIndexSet1 = fragmentIndex.get(fragment); const attrCover = ~attrLengthMask & attrMask;
if (!fragmentIndexSet1) { res = res.filter(
if (!fragmentIndexSet2) return []; (p) =>
if (!res) res = [...fragmentIndexSet2]; (1 << p.paramLength) & paramCover && (1 << p.attrLength) & attrCover,
else res = res.filter((r) => fragmentIndexSet2.has(r)); );
} else if (!fragmentIndexSet2) {
if (!res) res = [...fragmentIndexSet1]; for (let i = 1; i < indexes.length; ++i) {
else res = res.filter((r) => fragmentIndexSet1.has(r)); const [idx1, idx2] = indexes[i];
} else { res = res.filter((p) => idx1.has(p) || idx2.has(p));
if (!res) {
res = [...fragmentIndexSet1, ...fragmentIndexSet2];
} else {
res = res.filter(
(r) => fragmentIndexSet1.has(r) || fragmentIndexSet2.has(r)
);
}
}
if (!res.length) return res;
} }
if (!res) res = [].concat(...lengthIndex.map((a) => [...a]));
else res = res.filter((r) => lengthIndex.some((a) => a.has(r)));
return res; return res;
} }
} }
+79 -193
View File
@@ -1,181 +1,31 @@
/** import Expression from "./expression.ts";
* Copyright 2013-2019 GenieACS Inc. import { Cursor, parsePath } from "./expression/parser.ts";
*
* 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/>.
*/
type Segments = (string | Alias)[]; type Segments = (string | Expression)[];
type Alias = [Path, string][];
let cache1 = new Map<string, Path>(); let cache1 = new Map<string, Path>();
let cache2 = new Map<string, Path>(); let cache2 = new Map<string, Path>();
const CHAR_SPACE = 32;
const CHAR_BACKSLASH = 92;
const CHAR_COLON = 58;
const CHAR_QUOTE = 34;
const CHAR_OPEN_BRACKET = 91;
const CHAR_CLOSE_BRACKET = 93;
const CHAR_ASTERISK = 42;
const CHAR_COMMA = 44;
const CHAR_DOT = 46;
function charCodeAt(str: string, index: number): number {
if (index >= str.length) return 0;
return str.charCodeAt(index);
}
function legalChar(c: number): boolean {
return (
(c >= 97 && c <= 122) ||
(c >= 65 && c <= 90) ||
(c >= 48 && c <= 57) ||
c === 95 ||
c === 45
);
}
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 colon: number;
public declare readonly alias: number; declare public readonly wildcard: number;
protected declare _string: string; declare public readonly alias: number;
protected declare _stringIndex: number[]; declare protected _string: string;
declare protected _stringIndex: number[];
protected static parseAliasValue( constructor(segments: Segments, colon: number) {
pattern: string, if (!(colon <= segments.length)) throw new Error("Invalid path");
index: number if (segments.length > 32) throw new Error("Path too long");
): { index: number; value: string } {
let i = index;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
if (charCodeAt(pattern, i) === CHAR_QUOTE) {
for (let j = i + 1; j < pattern.length; ++j) {
if (
pattern.charCodeAt(j) === CHAR_QUOTE &&
pattern.charCodeAt(j - 1) !== CHAR_BACKSLASH
) {
try {
++j;
const v = JSON.parse(pattern.slice(i, j));
return { index: j, value: v };
} catch {
return { index, value: null };
}
}
}
return { index, value: null };
}
for (; i < pattern.length; ++i) {
const c = pattern.charCodeAt(i);
if (c === CHAR_CLOSE_BRACKET || c === CHAR_COMMA) break;
}
return { index: i, value: pattern.slice(index, i).trim() };
}
protected static parseAliasPath(
pattern: string,
index: number
): { index: number; path: Path } {
let i = index;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
const { index: idx, segments } = Path.parsePath(pattern, i);
if (idx === i) return { index, path: null };
i = idx;
while (charCodeAt(pattern, i) === CHAR_SPACE) ++i;
if (charCodeAt(pattern, i) !== CHAR_COLON) return { index, path: null };
return { index: i + 1, path: new Path(segments) };
}
protected static parseAlias(
pattern: string,
index: number
): { index: number; alias: Alias } {
const alias: Alias = [];
let i = index;
for (;;) {
let path: Path;
let value: string;
({ index: i, path } = Path.parseAliasPath(pattern, i));
if (!path) break;
({ index: i, value } = Path.parseAliasValue(pattern, i));
if (value == null) break;
alias.push([path, value]);
index = i;
if (charCodeAt(pattern, index) !== CHAR_COMMA) break;
++i;
}
// Ensure identical expressions have idential string representation
alias.sort((a, b) => {
if (a[0].toString() > b[0].toString()) return 1;
else if (a[0].toString() < b[0].toString()) return -1;
else if (a[1] > b[1]) return 1;
else if (a[1] < b[1]) return -1;
else return 0;
});
Object.freeze(alias);
return { index, alias };
}
protected static parsePath(
pattern: string,
index: number
): { index: number; segments: Segments } {
const segments = [];
let segStart = index;
let wildcard = -1;
for (let i = index; i <= pattern.length; ++i) {
const c = charCodeAt(pattern, i);
if (c === CHAR_OPEN_BRACKET && i === segStart) {
const { index: idx, alias } = Path.parseAlias(pattern, i + 1);
if (charCodeAt(pattern, idx) !== CHAR_CLOSE_BRACKET) break;
segments.push(alias);
index = i = idx + 1;
segStart = i + 1;
} else if (c === CHAR_ASTERISK && i === segStart) {
wildcard = i + 1;
} else if (!legalChar(c) || i === wildcard) {
if (i === segStart) break;
const s = pattern.slice(segStart, i);
segments.push(s);
index = i;
if (c !== CHAR_DOT) break;
segStart = i + 1;
}
}
Object.freeze(segments); Object.freeze(segments);
return { index, segments };
}
protected constructor(segments: Segments) {
let alias = 0; let alias = 0;
let wildcard = 0; let wildcard = 0;
const arr = segments.map((s, i) => { const arr = segments.map((s, i) => {
if (Array.isArray(s)) { if (s instanceof Expression) {
alias |= 1 << i; alias |= 1 << i;
const parts = s.map( return `[${s.toString()}]`;
(al) => `${al[0].toString()}:${JSON.stringify(al[1])}`
);
return `[${parts.join(",")}]`;
} else if (s === "*") { } else if (s === "*") {
wildcard |= 1 << i; wildcard |= 1 << i;
} }
@@ -186,23 +36,27 @@ export default class Path {
const stringIndex = arr.map((s, i) => (offset += s.length) + i); const stringIndex = arr.map((s, i) => (offset += s.length) + i);
this.segments = segments; this.segments = segments;
this.colon = colon;
this.wildcard = wildcard; this.wildcard = wildcard;
this.alias = alias; this.alias = alias;
this._string = arr.join("."); if (!colon) this._string = arr.join(".");
else
this._string =
arr.slice(0, -colon).join(".") + ":" + arr.slice(-colon).join(".");
this._stringIndex = stringIndex; this._stringIndex = stringIndex;
} }
public static parse(str: string): Path { public static parse(input: string): Path {
let path = cache1.get(str); let path = cache1.get(input);
if (!path) { if (!path) {
path = cache2.get(str); path = cache2.get(input);
if (!path) { if (!path) {
const { index, segments } = Path.parsePath(str, 0); const cursor = new Cursor(input);
if (index < str.length) throw new Error("Invalid parameter path"); path = parsePath(cursor);
path = new Path(segments); if (cursor.charCode) throw new Error("Unexpected character");
if (path.toString() !== str) cache1.set(path.toString(), path); if (path.toString() !== input) cache1.set(path.toString(), path);
} }
cache1.set(str, path); cache1.set(input, path);
} }
return path; return path;
} }
@@ -211,6 +65,14 @@ export default class Path {
return this.segments.length; return this.segments.length;
} }
public get paramLength(): number {
return this.segments.length - this.colon;
}
public get attrLength(): number {
return this.colon;
}
public toString(): string { public toString(): string {
return this._string; return this._string;
} }
@@ -219,25 +81,27 @@ export default class Path {
if (start < 0) start = Math.max(0, this.segments.length + start); if (start < 0) start = Math.max(0, this.segments.length + start);
if (end < 0) end = Math.max(0, this.segments.length + end); if (end < 0) end = Math.max(0, this.segments.length + end);
let str; if (start >= end) return Path.root;
if (start >= end) {
str = ""; let i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0;
} else { // Include the ":" when slicing exactly at the colon boundary
const i1 = start > 0 ? this._stringIndex[start - 1] + 1 : 0; if (this.colon && start === this.segments.length - this.colon) --i1;
const i2 = const i2 =
end <= this.segments.length end <= this.segments.length
? this._stringIndex[end - 1] ? this._stringIndex[end - 1]
: this._string.length; : this._string.length;
str = this._string.slice(i1, i2); const str = this._string.slice(i1, i2);
}
let path = cache1.get(str); let path = cache1.get(str);
if (!path) { if (!path) {
path = cache2.get(str); path = cache2.get(str);
if (!path) { if (!path) {
const segments = this.segments.slice(start, end); const segments = this.segments.slice(start, end);
Object.freeze(segments); const colon =
path = new Path(segments); start <= this.segments.length - this.colon
? Math.max(0, this.colon - this.segments.length + end)
: 0;
path = new Path(segments, colon);
} }
cache1.set(str, path); cache1.set(str, path);
} }
@@ -249,15 +113,28 @@ export default class Path {
if (!path2._string) return this; if (!path2._string) return this;
else if (!this._string) return path2; else if (!this._string) return path2;
const str = `${this._string}.${path2._string}`; if (this.colon && path2.colon && path2.colon < path2.segments.length)
throw new Error("Invalid path");
const colon = this.colon ? this.colon + path2.segments.length : path2.colon;
let str;
if (this.colon && path2.colon === path2.segments.length) {
// Right is all-colon; strip its ":" prefix and join with "."
str = `${this._string}.${path2._string.slice(1)}`;
} else if (path2.colon === path2.segments.length) {
// Left has no colon; right is all-colon; concatenate directly
str = `${this._string}${path2._string}`;
} else {
str = `${this._string}.${path2._string}`;
}
let path = cache1.get(str); let path = cache1.get(str);
if (!path) { if (!path) {
path = cache2.get(str); path = cache2.get(str);
if (!path) { if (!path) {
const segments = this.segments.concat(path2.segments); const segments = this.segments.concat(path2.segments);
Object.freeze(segments); path = new Path(segments, colon);
path = new Path(segments);
} }
cache1.set(str, path); cache1.set(str, path);
} }
@@ -267,21 +144,30 @@ export default class Path {
public stripAlias(): Path { public stripAlias(): Path {
if (!this.alias) return this; if (!this.alias) return this;
const segments = this.segments.map((s) => (Array.isArray(s) ? "*" : s)); const segments = this.segments.map((s) =>
const str = segments.join("."); s instanceof Expression ? "*" : s,
);
let str: string;
if (!this.colon) str = segments.join(".");
else
str =
segments.slice(0, -this.colon).join(".") +
":" +
segments.slice(-this.colon).join(".");
let path = cache1.get(str); let path = cache1.get(str);
if (!path) { if (!path) {
path = cache2.get(str); path = cache2.get(str);
if (!path) { if (!path) {
Object.freeze(segments); path = new Path(segments, this.colon);
path = new Path(segments);
} }
cache1.set(str, path); cache1.set(str, path);
} }
return path; return path;
} }
static root = new Path([], 0);
} }
const interval = setInterval(() => { const interval = setInterval(() => {
+6 -6
View File
@@ -15,7 +15,7 @@ const STRING_RESERVED = new Set([
function isPrintable(str: string): boolean { function isPrintable(str: string): boolean {
return !/[^\t\n\x20-\x7e\x85\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u.test( return !/[^\t\n\x20-\x7e\x85\u{a0}-\u{d7ff}\u{e000}-\u{fffd}\u{10000}-\u{10ffff}]/u.test(
str str,
); );
} }
@@ -99,7 +99,7 @@ function stringifyString(str: string, res: string[], prefix1, prefix2): void {
if (!isFolded) { if (!isFolded) {
res.push( res.push(
`${prefix1}|${idt}${chmp}`, `${prefix1}|${idt}${chmp}`,
...lines.map((l) => (l ? prefix2 + l : l)) ...lines.map((l) => (l ? prefix2 + l : l)),
); );
return; return;
} }
@@ -129,7 +129,7 @@ function stringifyAny(
obj: unknown, obj: unknown,
res: string[], res: string[],
prefix1 = "", prefix1 = "",
prefix2 = "" prefix2 = "",
): void { ): void {
if (obj == null) { if (obj == null) {
res.push(`${prefix1}null`); res.push(`${prefix1}null`);
@@ -183,7 +183,7 @@ function stringifyAny(
entries[0][1], entries[0][1],
res, res,
prefix1 + `${stringifyKey(entries[0][0])}: `, prefix1 + `${stringifyKey(entries[0][0])}: `,
prefix2 + INDENTATION prefix2 + INDENTATION,
); );
prefix1 = prefix2; prefix1 = prefix2;
prefix2 = prefix2 + INDENTATION; prefix2 = prefix2 + INDENTATION;
@@ -192,7 +192,7 @@ function stringifyAny(
entries[i][1], entries[i][1],
res, res,
prefix1 + `${stringifyKey(entries[i][0])}: `, prefix1 + `${stringifyKey(entries[i][0])}: `,
prefix2 prefix2,
); );
} }
} else { } else {
@@ -204,7 +204,7 @@ function stringifyAny(
entries[i][1], entries[i][1],
res, res,
prefix1 + `${stringifyKey(entries[i][0])}: `, prefix1 + `${stringifyKey(entries[i][0])}: `,
prefix2 prefix2,
); );
} }
} }
+7 -23
View File
@@ -1,24 +1,5 @@
/** import { resolve } from "node:path";
* Copyright 2013-2019 GenieACS Inc. import { readFileSync, existsSync } from "node:fs";
*
* 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 { resolve } from "path";
import { readFileSync, existsSync } from "fs";
// Find project root directory // Find project root directory
export let ROOT_DIR = resolve(__dirname, ".."); export let ROOT_DIR = resolve(__dirname, "..");
@@ -101,6 +82,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 } = {};
@@ -245,7 +229,7 @@ if (fsHostname) {
const FS_SSL = !!allConfig["FS_SSL_CERT"]; const FS_SSL = !!allConfig["FS_SSL_CERT"];
setConfig( setConfig(
"FS_URL_PREFIX", "FS_URL_PREFIX",
(FS_SSL ? "https" : "http") + `://${fsHostname}:${FS_PORT}/` (FS_SSL ? "https" : "http") + `://${fsHostname}:${FS_PORT}/`,
); );
} }
@@ -255,7 +239,7 @@ for (const [k, v] of Object.entries(options))
export function get( export function get(
optionName: string, optionName: string,
deviceId?: string deviceId?: string,
): string | number | boolean { ): string | number | boolean {
if (!deviceId) return allConfig[optionName]; if (!deviceId) return allConfig[optionName];
+124 -56
View File
@@ -1,61 +1,48 @@
/** import * as crypto from "node:crypto";
* Copyright 2013-2019 GenieACS Inc. import * as dgram from "node:dgram";
* import * as http from "node:http";
* This file is part of GenieACS. import Expression, { Value } from "./common/expression.ts";
* import * as auth from "./auth.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as extensions from "./extensions.ts";
* it under the terms of the GNU Affero General Public License as import * as debug from "./debug.ts";
* published by the Free Software Foundation, either version 3 of the import XmppClient from "./xmpp-client.ts";
* License, or (at your option) any later version. import * as config from "../lib/config.ts";
* import { encodeEntities, parseAttrs, Element } from "./xml-parser.ts";
* GenieACS is distributed in the hope that it will be useful, import * as logger from "../lib/logger.ts";
* 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 crypto from "crypto";
import * as dgram from "dgram";
import { URL } from "url";
import * as http from "http";
import { evaluateAsync } from "./common/expression";
import { Expression } from "./types";
import * as auth from "./auth";
import * as extensions from "./extensions";
import * as debug from "./debug";
async function extractAuth( async function extractAuth(
exp: Expression, exp: Expression,
dflt: any dflt: Value,
): Promise<[string, string, Expression]> { ): Promise<[string, string, Expression]> {
let username, password; let username: string, password: string;
const _exp = await evaluateAsync( const _exp = await exp.evaluateAsync(
exp,
{},
0,
async (e: Expression): Promise<Expression> => { async (e: Expression): Promise<Expression> => {
if (!username && Array.isArray(e) && e[0] === "FUNC") { if (e instanceof Expression.Parameter)
if (e[1] === "EXT") { return new Expression.Literal(null);
if (typeof e[2] !== "string" || typeof e[3] !== "string") return null; if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(0);
if (!username) {
if (e.name === "EXT") {
if (!e.args.every((a) => a instanceof Expression.Literal))
return new Expression.Literal(null);
const args = e.args.map((a) => a.value.toString());
if (typeof args[0] !== "string" || typeof args[1] !== "string")
return new Expression.Literal(null);
for (let i = 4; i < e.length; i++) const { fault, value } = await extensions.run(args);
if (Array.isArray(e[i])) return null; if (fault) return new Expression.Literal(null);
return new Expression.Literal(value);
const { fault, value } = await extensions.run(e.slice(2)); } else if (e.name === "AUTH") {
return fault ? null : value; if (e.args.every((a) => a instanceof Expression.Literal)) {
} else if (e[1] === "AUTH") { username = `${e.args[0].value ?? ""}`;
if (!Array.isArray(e[2]) && !Array.isArray(e[3])) { password = `${e.args[1].value ?? ""}`;
username = e[2] || ""; }
password = e[3] || ""; return new Expression.Literal(dflt);
} }
return dflt;
} }
} }
return e; return e;
} },
); );
return [username, password, _exp]; return [username, password, _exp];
} }
@@ -64,7 +51,7 @@ function httpGet(
url: URL, url: URL,
options: http.RequestOptions, options: http.RequestOptions,
_debug: boolean, _debug: boolean,
deviceId: string deviceId: string,
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> { ): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = http const req = http
@@ -94,7 +81,7 @@ export async function httpConnectionRequest(
allowBasicAuth: boolean, allowBasicAuth: boolean,
timeout: number, timeout: number,
_debug: boolean, _debug: boolean,
deviceId: string deviceId: string,
): Promise<string> { ): Promise<string> {
const url = new URL(address); const url = new URL(address);
if (url.protocol !== "http:") if (url.protocol !== "http:")
@@ -120,7 +107,7 @@ export async function httpConnectionRequest(
Authorization: auth.basic(username || "", password || ""), Authorization: auth.basic(username || "", password || ""),
}, },
}, },
options options,
); );
} else if (authHeader["method"] === "Digest") { } else if (authHeader["method"] === "Digest") {
opts = Object.assign( opts = Object.assign(
@@ -132,11 +119,11 @@ export async function httpConnectionRequest(
url.pathname + url.search, url.pathname + url.search,
"GET", "GET",
null, null,
authHeader authHeader,
), ),
}, },
}, },
options options,
); );
} else { } else {
return "Unrecognized auth method"; return "Unrecognized auth method";
@@ -171,9 +158,9 @@ export async function httpConnectionRequest(
if (res.statusCode === 401 && res.headers["www-authenticate"]) { if (res.statusCode === 401 && res.headers["www-authenticate"]) {
try { try {
authHeader = auth.parseWwwAuthenticateHeader( authHeader = auth.parseWwwAuthenticateHeader(
res.headers["www-authenticate"] res.headers["www-authenticate"],
); );
} catch (err) { } catch {
return "Connection request error: Error parsing www-authenticate header"; return "Connection request error: Error parsing www-authenticate header";
} }
[username, password, authExp] = await extractAuth(authExp, false); [username, password, authExp] = await extractAuth(authExp, false);
@@ -191,7 +178,7 @@ export async function udpConnectionRequest(
authExp: Expression, authExp: Expression,
sourcePort = 0, sourcePort = 0,
_debug: boolean, _debug: boolean,
deviceId: string deviceId: string,
): Promise<void> { ): Promise<void> {
const now = Date.now(); const now = Date.now();
@@ -237,3 +224,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";
}
+308 -410
View File
File diff suppressed because it is too large. Load diff
+67 -209
View File
@@ -1,51 +1,15 @@
/** import { ObjectId } from "mongodb";
* Copyright 2013-2019 GenieACS Inc. import { decodeTag, encodeTag, escapeRegExp } from "../util.ts";
*
* 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 { MongoClient, ObjectId, Collection, Db } from "mongodb";
import { get } from "./config";
import { decodeTag, encodeTag, escapeRegExp } from "./common";
import { parse } from "./common/expression-parser";
import { import {
DeviceData, DeviceData,
Attributes, Attributes,
SessionFault, SessionFault,
Task, Task,
Operation, Operation,
Expression, } from "../types.ts";
} from "./types"; import { collections } from "../db/db.ts";
import Path from "./common/path"; import { optimizeProjection } from "../db/util.ts";
import * as MongoTypes from "./mongodb-types"; import * as MongoTypes from "../db/types.ts";
export let tasksCollection: Collection<MongoTypes.Task>,
devicesCollection: Collection,
presetsCollection: Collection,
objectsCollection: Collection,
provisionsCollection: Collection,
virtualParametersCollection: Collection,
faultsCollection: Collection<MongoTypes.Fault>,
filesCollection: Collection,
operationsCollection: Collection<MongoTypes.Operation>,
permissionsCollection: Collection,
usersCollection: Collection,
configCollection: Collection<MongoTypes.Config>;
let clientPromise: Promise<MongoClient>;
const INVALID_PATH_SUFFIX = "__invalid"; const INVALID_PATH_SUFFIX = "__invalid";
@@ -55,89 +19,27 @@ 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> {
clientPromise = MongoClient.connect("" + get("MONGODB_CONNECTION_URL"));
const client = await clientPromise;
const db = client.db();
await Promise.all(onConnectCallbacks.map((c) => c(db)));
}
onConnect(async (db) => {
tasksCollection = db.collection("tasks");
await tasksCollection.createIndex({ device: 1, timestamp: 1 });
devicesCollection = db.collection("devices");
presetsCollection = db.collection("presets");
objectsCollection = db.collection("objects");
filesCollection = db.collection("fs.files");
provisionsCollection = db.collection("provisions");
virtualParametersCollection = db.collection("virtualParameters");
faultsCollection = db.collection("faults");
operationsCollection = db.collection("operations");
permissionsCollection = db.collection("permissions");
usersCollection = db.collection("users");
configCollection = db.collection("config");
});
export async function disconnect(): Promise<void> {
if (clientPromise) await (await clientPromise).close();
}
// Optimize projection by removing overlaps
// This can modify the object
export function optimizeProjection(obj: { [path: string]: 1 }): {
[path: string]: 1;
} {
if (obj[""]) return { "": obj[""] };
const keys = Object.keys(obj).sort();
if (keys.length <= 1) return obj;
for (let i = 1; i < keys.length; ++i) {
const a = keys[i - 1];
const b = keys[i];
if (b.startsWith(a)) {
if (b.charAt(a.length) === "." || b.charAt(a.length - 1) === ".") {
delete obj[b];
keys.splice(i--, 1);
}
}
}
return obj;
}
export async function fetchDevice( export async function fetchDevice(
id: string, id: string,
timestamp: number timestamp: number,
): Promise<[Path, number, Attributes?][]> { ): Promise<[string, number, Attributes?][]> {
const res: [Path, number, Attributes?][] = [ const res: [string, number, Attributes?][] = [
["Events", timestamp, { object: [timestamp, 1], writable: [timestamp, 0] }],
[ [
Path.parse("Events"), "DeviceID",
timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] },
],
[
Path.parse("DeviceID"),
timestamp, timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] }, { object: [timestamp, 1], writable: [timestamp, 0] },
], ],
]; ];
const device = await devicesCollection.findOne({ _id: id }); const device = (await collections.devices.findOne({ _id: id })) as any;
if (!device) return null; if (!device) return null;
function storeParams( function storeParams(
obj, obj,
path: string, path: string,
pathLength: number, pathLength: number,
ts: number ts: number,
): void { ): void {
if (obj["_timestamp"]) obj["_timestamp"] = +obj["_timestamp"]; if (obj["_timestamp"]) obj["_timestamp"] = +obj["_timestamp"];
if (obj["_attributesTimestamp"]) if (obj["_attributesTimestamp"])
@@ -170,8 +72,8 @@ export async function fetchDevice(
attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]]; attrs.accessList = [obj["_attributesTimestamp"] || 1, obj["_accessList"]];
try { try {
res.push([Path.parse(path), t, attrs]); res.push([path, t, attrs]);
} catch (err) { } catch {
// The path parser is now more strict so we might be in a situation where // The path parser is now more strict so we might be in a situation where
// the database contains invalid paths from before this change So here we // the database contains invalid paths from before this change So here we
// encode the invalid characters. // encode the invalid characters.
@@ -179,7 +81,7 @@ export async function fetchDevice(
splits[splits.length - 1] = splits[splits.length - 1] =
encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX; encodeTag(splits[splits.length - 1]) + INVALID_PATH_SUFFIX;
path = splits.join("."); path = splits.join(".");
res.push([Path.parse(path), t, attrs]); res.push([path, t, attrs]);
return; return;
} }
@@ -191,17 +93,17 @@ export async function fetchDevice(
} }
if (obj["_object"] && obj["_timestamp"]) if (obj["_object"] && obj["_timestamp"])
res.push([Path.parse(path + ".*"), obj["_timestamp"]]); res.push([path + ".*", obj["_timestamp"]]);
} }
const ts: number = device["_timestamp"] || 0; const ts: number = +device["_timestamp"] || 0;
if (ts) res.push([Path.parse("*"), ts]); if (ts) res.push(["*", ts]);
for (const [k, v] of Object.entries(device)) { for (const [k, v] of Object.entries(device)) {
switch (k) { switch (k) {
case "_lastInform": case "_lastInform":
res.push([ res.push([
Path.parse("Events.Inform"), "Events.Inform",
+v, +v,
{ {
object: [+v, 0], object: [+v, 0],
@@ -212,7 +114,7 @@ export async function fetchDevice(
break; break;
case "_lastBoot": case "_lastBoot":
res.push([ res.push([
Path.parse("Events.1_BOOT"), "Events.1_BOOT",
+v, +v,
{ {
object: [+v, 0], object: [+v, 0],
@@ -223,7 +125,7 @@ export async function fetchDevice(
break; break;
case "_lastBootstrap": case "_lastBootstrap":
res.push([ res.push([
Path.parse("Events.0_BOOTSTRAP"), "Events.0_BOOTSTRAP",
+v, +v,
{ {
object: [+v, 0], object: [+v, 0],
@@ -235,7 +137,7 @@ export async function fetchDevice(
case "_registered": case "_registered":
// Use current timestamp for registered event attribute timestamps // Use current timestamp for registered event attribute timestamps
res.push([ res.push([
Path.parse("Events.Registered"), "Events.Registered",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -246,7 +148,7 @@ export async function fetchDevice(
break; break;
case "_id": case "_id":
res.push([ res.push([
Path.parse("DeviceID.ID"), "DeviceID.ID",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -258,7 +160,7 @@ export async function fetchDevice(
case "_tags": case "_tags":
if ((v as string[]).length) { if ((v as string[]).length) {
res.push([ res.push([
Path.parse("Tags"), "Tags",
timestamp, timestamp,
{ object: [timestamp, 1], writable: [timestamp, 0] }, { object: [timestamp, 1], writable: [timestamp, 0] },
]); ]);
@@ -266,7 +168,7 @@ export async function fetchDevice(
for (const t of v as string[]) { for (const t of v as string[]) {
res.push([ res.push([
Path.parse("Tags." + encodeTag(t)), "Tags." + encodeTag(t),
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -279,7 +181,7 @@ export async function fetchDevice(
case "_deviceId": case "_deviceId":
if (v["_Manufacturer"] != null) { if (v["_Manufacturer"] != null) {
res.push([ res.push([
Path.parse("DeviceID.Manufacturer"), "DeviceID.Manufacturer",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -291,7 +193,7 @@ export async function fetchDevice(
if (v["_OUI"] != null) { if (v["_OUI"] != null) {
res.push([ res.push([
Path.parse("DeviceID.OUI"), "DeviceID.OUI",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -303,7 +205,7 @@ export async function fetchDevice(
if (v["_ProductClass"] != null) { if (v["_ProductClass"] != null) {
res.push([ res.push([
Path.parse("DeviceID.ProductClass"), "DeviceID.ProductClass",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -315,7 +217,7 @@ export async function fetchDevice(
if (v["_SerialNumber"] != null) { if (v["_SerialNumber"] != null) {
res.push([ res.push([
Path.parse("DeviceID.SerialNumber"), "DeviceID.SerialNumber",
timestamp, timestamp,
{ {
object: [timestamp, 0], object: [timestamp, 0],
@@ -336,7 +238,7 @@ export async function saveDevice(
deviceId: string, deviceId: string,
deviceData: DeviceData, deviceData: DeviceData,
isNew: boolean, isNew: boolean,
sessionTimestamp: number sessionTimestamp: number,
): Promise<void> { ): Promise<void> {
const update = { $set: {}, $unset: {}, $addToSet: {}, $pull: {} }; const update = { $set: {}, $unset: {}, $addToSet: {}, $pull: {} };
@@ -350,7 +252,7 @@ export async function saveDevice(
) )
continue; continue;
const parent = deviceData.paths.get(diff[0].slice(0, -1)); const parent = deviceData.paths.get(diff[0].slice(0, -1).toString());
// Param timestamps may be greater than session timestamp to track revisions // Param timestamps may be greater than session timestamp to track revisions
if (diff[2] > sessionTimestamp) diff[2] = sessionTimestamp; if (diff[2] > sessionTimestamp) diff[2] = sessionTimestamp;
@@ -450,7 +352,7 @@ export async function saveDevice(
if (!update["$addToSet"]["_tags"]) if (!update["$addToSet"]["_tags"])
update["$addToSet"]["_tags"] = { $each: [] }; update["$addToSet"]["_tags"] = { $each: [] };
update["$addToSet"]["_tags"]["$each"].push( update["$addToSet"]["_tags"]["$each"].push(
decodeTag(path.segments[1] as string) decodeTag(path.segments[1] as string),
); );
} else { } else {
if (!update["$pull"]["_tags"]) { if (!update["$pull"]["_tags"]) {
@@ -459,7 +361,7 @@ export async function saveDevice(
}; };
} }
update["$pull"]["_tags"]["$in"].push( update["$pull"]["_tags"]["$in"].push(
decodeTag(path.segments[1] as string) decodeTag(path.segments[1] as string),
); );
} }
} }
@@ -472,7 +374,10 @@ export async function saveDevice(
if (pathStr.endsWith(INVALID_PATH_SUFFIX)) { if (pathStr.endsWith(INVALID_PATH_SUFFIX)) {
const splits = pathStr.split("."); const splits = pathStr.split(".");
splits[splits.length - 1] = decodeTag( splits[splits.length - 1] = decodeTag(
splits[splits.length - 1].slice(0, 0 - INVALID_PATH_SUFFIX.length) splits[splits.length - 1].slice(
0,
0 - INVALID_PATH_SUFFIX.length,
),
); );
pathStr = splits.join("."); pathStr = splits.join(".");
} }
@@ -494,7 +399,7 @@ export async function saveDevice(
Number.isInteger(value2 as number) Number.isInteger(value2 as number)
) { ) {
update["$set"][path.toString() + "._value"] = new Date( update["$set"][path.toString() + "._value"] = new Date(
value2 as number value2 as number,
); );
} else { } else {
update["$set"][path.toString() + "._value"] = value2; update["$set"][path.toString() + "._value"] = value2;
@@ -506,7 +411,7 @@ export async function saveDevice(
if (valueTimestamp2 !== valueTimestamp1) { if (valueTimestamp2 !== valueTimestamp1) {
update["$set"][path.toString() + "._timestamp"] = new Date( update["$set"][path.toString() + "._timestamp"] = new Date(
valueTimestamp2 valueTimestamp2,
); );
} }
@@ -621,23 +526,27 @@ export async function saveDevice(
delete update["$pull"]; delete update["$pull"];
} }
const result = await devicesCollection.updateOne({ _id: deviceId }, update, { const result = await collections.devices.updateOne(
upsert: isNew, { _id: deviceId },
}); update,
{
upsert: isNew,
},
);
if (!result.matchedCount && !result.upsertedCount) if (!result.matchedCount && !result.upsertedCount)
throw new Error(`Device ${deviceId} not found in database`); throw new Error(`Device ${deviceId} not found in database`);
if (update2) { if (update2) {
await devicesCollection.updateOne({ _id: deviceId }, update2); await collections.devices.updateOne({ _id: deviceId }, update2);
return; return;
} }
} }
export async function getFaults( export async function getFaults(
deviceId: string deviceId: string,
): Promise<{ [channel: string]: SessionFault }> { ): Promise<{ [channel: string]: SessionFault }> {
const res = await faultsCollection const res = await collections.faults
.find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\:` } }) .find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\:` } })
.toArray(); .toArray();
@@ -662,7 +571,7 @@ export async function getFaults(
export async function saveFault( export async function saveFault(
deviceId: string, deviceId: string,
channel: string, channel: string,
fault: SessionFault fault: SessionFault,
): Promise<void> { ): Promise<void> {
const id = `${deviceId}:${channel}`; const id = `${deviceId}:${channel}`;
const f: MongoTypes.Fault = { const f: MongoTypes.Fault = {
@@ -677,21 +586,23 @@ export async function saveFault(
...(fault.expiry && { expiry: new Date(fault.expiry) }), ...(fault.expiry && { expiry: new Date(fault.expiry) }),
provisions: JSON.stringify(fault.provisions), provisions: JSON.stringify(fault.provisions),
}; };
await faultsCollection.replaceOne({ _id: id }, f, { upsert: true }); await collections.faults.replaceOne({ _id: id }, f, { upsert: true });
} }
export async function deleteFault( export async function deleteFault(
deviceId: string, deviceId: string,
channel: string channel: string,
): Promise<void> { ): Promise<void> {
await faultsCollection.deleteOne({ _id: `${deviceId}:${channel}` }); await collections.faults.deleteOne({ _id: `${deviceId}:${channel}` });
} }
export async function getDueTasks( export async function getDueTasks(
deviceId: string, deviceId: string,
timestamp: number timestamp: number,
): Promise<[Task[], number]> { ): Promise<[Task[], number]> {
const cur = tasksCollection.find({ device: deviceId }).sort({ timestamp: 1 }); const cur = collections.tasks
.find({ device: deviceId })
.sort({ timestamp: 1 });
const tasks = [] as Task[]; const tasks = [] as Task[];
for await (const t of cur) { for await (const t of cur) {
@@ -736,7 +647,7 @@ export async function getDueTasks(
q = { _id: { $in: [t["file"], new ObjectId(t["file"])] } }; q = { _id: { $in: [t["file"], new ObjectId(t["file"])] } };
else q = { _id: t["file"] }; else q = { _id: t["file"] };
const res = await filesCollection.find(q).toArray(); const res = await collections.files.find(q).toArray();
if (res[0]) { if (res[0]) {
if (!task.fileType) task.fileType = res[0].metadata.fileType; if (!task.fileType) task.fileType = res[0].metadata.fileType;
@@ -751,17 +662,17 @@ export async function getDueTasks(
export async function clearTasks( export async function clearTasks(
deviceId: string, deviceId: string,
taskIds: string[] taskIds: string[],
): Promise<void> { ): Promise<void> {
await tasksCollection.deleteMany({ await collections.tasks.deleteMany({
_id: { $in: taskIds.map((id) => new ObjectId(id)) }, _id: { $in: taskIds.map((id) => new ObjectId(id)) },
}); });
} }
export async function getOperations( export async function getOperations(
deviceId: string deviceId: string,
): Promise<{ [commandKey: string]: Operation }> { ): Promise<{ [commandKey: string]: Operation }> {
const res = await operationsCollection const res = await collections.operations
.find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\:` } }) .find({ _id: { $regex: `^${escapeRegExp(deviceId)}\\:` } })
.toArray(); .toArray();
@@ -791,7 +702,7 @@ export async function getOperations(
export async function saveOperation( export async function saveOperation(
deviceId: string, deviceId: string,
commandKey: string, commandKey: string,
operation: Operation operation: Operation,
): Promise<void> { ): Promise<void> {
const id = `${deviceId}:${commandKey}`; const id = `${deviceId}:${commandKey}`;
const o: MongoTypes.Operation = { const o: MongoTypes.Operation = {
@@ -803,67 +714,14 @@ export async function saveOperation(
retries: JSON.stringify(operation.retries), retries: JSON.stringify(operation.retries),
args: JSON.stringify(operation.args), args: JSON.stringify(operation.args),
}; };
await operationsCollection.replaceOne({ _id: id }, o, { await collections.operations.replaceOne({ _id: id }, o, {
upsert: true, upsert: true,
}); });
} }
export async function deleteOperation( export async function deleteOperation(
deviceId: string, deviceId: string,
commandKey: string commandKey: string,
): Promise<void> { ): Promise<void> {
await operationsCollection.deleteOne({ _id: `${deviceId}:${commandKey}` }); await collections.operations.deleteOne({ _id: `${deviceId}:${commandKey}` });
}
export async function getPresets(): Promise<Record<string, any>[]> {
return presetsCollection.find().toArray();
}
export async function getObjects(): Promise<Record<string, any>[]> {
return objectsCollection.find().toArray();
}
export async function getProvisions(): Promise<Record<string, any>[]> {
return provisionsCollection.find().toArray();
}
export async function getVirtualParameters(): Promise<Record<string, any>[]> {
return virtualParametersCollection.find().toArray();
}
export function getFiles(): Promise<Record<string, any>[]> {
return filesCollection.find().toArray();
}
export async function getConfig(): Promise<
{ id: string; value: Expression }[]
> {
const res = await configCollection.find().toArray();
return res.map((c) => ({
id: c["_id"],
value: parse(c["value"]),
}));
}
interface Permission {
role: string;
resource: string;
access: number;
filter: string;
validate: string;
}
export async function getPermissions(): Promise<Permission[]> {
return permissionsCollection.find().toArray() as unknown as Permission[];
}
interface User {
_id: string;
password: string;
salt: string;
roles: string;
}
export async function getUsers(): Promise<User[]> {
return usersCollection.find().toArray() as unknown as User[];
} }
+373
View File
@@ -0,0 +1,373 @@
import * as vm from "node:vm";
import * as crypto from "node:crypto";
import { collections } from "../db/db.ts";
import { convertOldPrecondition } from "../db/util.ts";
import * as logger from "../logger.ts";
import * as scheduling from "../scheduling.ts";
import Expression, { Value } from "../common/expression.ts";
import {
Preset,
Provisions,
VirtualParameters,
Files,
Config,
} from "../types.ts";
import { LocalCache } from "../local-cache.ts";
interface Snapshot {
presets: Preset[];
provisions: Provisions;
virtualParameters: VirtualParameters;
files: Files;
config: Config;
}
function flattenObject<T extends Record<keyof T, unknown>>(
src: T,
prefix = "",
dst = {} as T,
): T {
for (const k of Object.keys(src)) {
const v = src[k];
if (typeof v === "object" && !Array.isArray(v))
flattenObject(v as T, `${prefix}${k}.`, dst);
else dst[`${prefix}${k}`] = v;
}
return dst;
}
async function fetchPresets(): Promise<[string, Preset[]]> {
const res = await collections.presets.find().toArray();
let objects = await collections.objects.find().toArray();
res.sort((a, b) => (a._id > b._id ? 1 : -1));
objects.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto
.createHash("md5")
.update(JSON.stringify(res))
.update(JSON.stringify(objects))
.digest("hex");
objects = objects.map((obj) => {
// Flatten object
obj = flattenObject(obj);
// If no keys are defined, consider all parameters as keys to keep the
// same behavior from v1.0
if (!obj["_keys"]?.length)
obj["_keys"] = Object.keys(obj).filter((k) => !k.startsWith("_"));
return obj;
});
res.sort((a, b) => {
if (a["weight"] === b["weight"])
return a["_id"] > b["_id"] ? 1 : a["_id"] < b["_id"] ? -1 : 0;
else return a["weight"] - b["weight"];
});
const presets = [] as Preset[];
for (const preset of res) {
let schedule: { md5: string; duration: number; schedule: any } = null;
if (preset["schedule"]) {
const parts = preset["schedule"].trim().split(/\s+/);
schedule = {
md5: crypto.createHash("md5").update(preset["schedule"]).digest("hex"),
duration: null,
schedule: null,
};
try {
schedule.duration = +parts.shift() * 1000;
schedule.schedule = scheduling.parseCron(parts.join(" "));
} catch {
logger.warn({
message: "Invalid preset schedule",
preset: preset["_id"],
schedule: preset["schedule"],
});
schedule.schedule = false;
}
}
const events = preset["events"] || {};
let precondition: Expression = new Expression.Literal(true);
if (preset["precondition"]) {
try {
precondition = Expression.parse(preset["precondition"]);
} catch {
precondition = convertOldPrecondition(
JSON.parse(preset["precondition"]),
);
}
// Simplify expression
precondition = precondition.evaluate((e) => e);
}
const _provisions: Preset["provisions"] = [];
// Generate provisions from the old configuration format
for (const c of preset.configurations) {
switch (c.type) {
case "age":
_provisions.push([
"refresh",
new Expression.Literal(c.name),
new Expression.Literal(+c.age),
]);
break;
case "value":
_provisions.push([
"value",
new Expression.Literal(c.name),
new Expression.Literal(c.value),
]);
break;
case "add_tag":
_provisions.push([
"tag",
new Expression.Literal(c.tag),
new Expression.Literal(true),
]);
break;
case "delete_tag":
_provisions.push([
"tag",
new Expression.Literal(c.tag),
new Expression.Literal(false),
]);
break;
case "provision":
_provisions.push([
c.name,
...(c.args || []).map((a) => Expression.parse(a)),
]);
break;
case "add_object":
for (const obj of objects) {
if (obj["_id"] === c.object) {
const alias = obj["_keys"]
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push([
"instances",
new Expression.Literal(p),
new Expression.Literal(1),
]);
for (const k in obj) {
if (!k.startsWith("_") && !(obj["_keys"].indexOf(k) !== -1))
_provisions.push([
"value",
new Expression.Literal(`${p}.${k}`),
new Expression.Literal(obj[k]),
]);
}
}
}
break;
case "delete_object":
for (const obj of objects) {
if (obj["_id"] === c.object) {
const alias = obj["_keys"]
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push([
"instances",
new Expression.Literal(p),
new Expression.Literal(0),
]);
}
}
break;
default: {
const exhaustiveCheck: never = c;
throw new Error(
`Unknown configuration type ${exhaustiveCheck["type"]}`,
);
}
}
}
presets.push({
name: preset["_id"],
channel: (preset["channel"] as string) || "default",
schedule: schedule,
events: events,
precondition: precondition,
provisions: _provisions,
});
}
return [h, presets];
}
async function fetchProvisions(): Promise<[string, Provisions]> {
const res = await collections.provisions.find().toArray();
res.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto.createHash("md5").update(JSON.stringify(res)).digest("hex");
const provisions = {};
for (const r of res) {
provisions[r._id] = {};
provisions[r._id].md5 = crypto
.createHash("md5")
.update(r.script)
.digest("hex");
provisions[r._id].script = new vm.Script(
`"use strict";(function(){\n${r.script}\n})();`,
{ filename: r._id, lineOffset: -1 },
);
}
return [h, provisions];
}
async function fetchVirtualParameters(): Promise<[string, VirtualParameters]> {
const res = await collections.virtualParameters.find().toArray();
res.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto.createHash("md5").update(JSON.stringify(res)).digest("hex");
const virtualParameters = {};
for (const r of res) {
virtualParameters[r._id] = {};
virtualParameters[r._id].md5 = crypto
.createHash("md5")
.update(r.script)
.digest("hex");
virtualParameters[r._id].script = new vm.Script(
`"use strict";(function(){\n${r.script}\n})();`,
{ filename: r._id, lineOffset: -1 },
);
}
return [h, virtualParameters];
}
async function fetchFiles(): Promise<[string, Files]> {
const res = await collections.files.find().toArray();
res.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto.createHash("md5").update(JSON.stringify(res)).digest("hex");
const files = {};
for (const r of res) {
const id = r.filename || r._id.toString();
files[id] = {};
files[id].length = r.length;
}
return [h, files];
}
async function fetchConfig(): Promise<[string, Config]> {
const conf = await collections.config.find().toArray();
conf.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto.createHash("md5").update(JSON.stringify(conf)).digest("hex");
const _config = {};
for (const c of conf) {
// Evaluate expressions to simplify them
_config[c._id] = Expression.parse(c.value).evaluate((e) => e);
}
return [h, _config];
}
const localCache = new LocalCache<Snapshot>("cwmp-local-cache-hash", refresh);
async function refresh(): Promise<[string, Snapshot]> {
const res = await Promise.all([
fetchPresets(),
fetchProvisions(),
fetchVirtualParameters(),
fetchFiles(),
fetchConfig(),
]);
const h = crypto.createHash("md5");
for (const r of res) h.update(r[0]);
const snapshot = {
presets: res[0][1],
provisions: res[1][1],
virtualParameters: res[2][1],
files: res[3][1],
config: res[4][1],
};
return [h.digest("hex"), snapshot];
}
export async function getRevision(): Promise<string> {
return await localCache.getRevision();
}
export function getPresets(revision: string): Preset[] {
return localCache.get(revision).presets;
}
export function getProvisions(revision: string): Provisions {
return localCache.get(revision).provisions;
}
export function getVirtualParameters(revision: string): VirtualParameters {
return localCache.get(revision).virtualParameters;
}
export function getFiles(revision: string): Files {
return localCache.get(revision).files;
}
export function getConfig(
revision: string,
key: string,
dflt: string,
fn: (e: Expression) => Expression.Literal,
): string;
export function getConfig(
revision: string,
key: string,
dflt: number,
fn: (e: Expression) => Expression.Literal,
): number;
export function getConfig(
revision: string,
key: string,
dflt: boolean,
fn: (e: Expression) => Expression.Literal,
): boolean;
export function getConfig(
revision: string,
key: string,
dflt: Value,
fn: (e: Expression) => Expression.Literal,
): Value {
const snapshot = localCache.get(revision);
if (!snapshot) throw new Error("Cache snapshot does not exist");
const e = snapshot.config[key];
if (!e) return dflt;
const v = e.evaluate(fn).value;
if (typeof v !== typeof dflt) return dflt;
return v;
}
export function getConfigExpression(
snapshotKey: string,
key: string,
): Expression {
const snapshot = localCache.get(snapshotKey);
return snapshot.config[key];
}
+57
View File
@@ -0,0 +1,57 @@
import { MongoClient, Collection, GridFSBucket } from "mongodb";
import { get } from "../config.ts";
import * as MongoTypes from "./types.ts";
export let filesBucket: GridFSBucket;
export const collections = {
devices: null as Collection<MongoTypes.Device>,
presets: null as Collection<MongoTypes.Preset>,
objects: null as Collection<MongoTypes.Object>,
provisions: null as Collection<MongoTypes.Provision>,
virtualParameters: null as Collection<MongoTypes.VirtualParameter>,
faults: null as Collection<MongoTypes.Fault>,
tasks: null as Collection<MongoTypes.Task>,
files: null as Collection<MongoTypes.File>,
operations: null as Collection<MongoTypes.Operation>,
permissions: null as Collection<MongoTypes.Permission>,
users: null as Collection<MongoTypes.User>,
config: null as Collection<MongoTypes.Config>,
cache: null as Collection<MongoTypes.Cache>,
locks: null as Collection<MongoTypes.Lock>,
};
let clientPromise: Promise<MongoClient>;
export async function connect(): Promise<void> {
clientPromise = MongoClient.connect("" + get("MONGODB_CONNECTION_URL"));
const client = await clientPromise;
const db = client.db();
collections.tasks = db.collection("tasks");
collections.devices = db.collection("devices");
collections.presets = db.collection("presets");
collections.objects = db.collection("objects");
collections.files = db.collection("fs.files");
collections.provisions = db.collection("provisions");
collections.virtualParameters = db.collection("virtualParameters");
collections.faults = db.collection("faults");
collections.operations = db.collection("operations");
collections.permissions = db.collection("permissions");
collections.users = db.collection("users");
collections.config = db.collection("config");
collections.cache = db.collection("cache");
collections.locks = db.collection("locks");
filesBucket = new GridFSBucket(db);
await Promise.all([
collections.tasks.createIndex({ device: 1, timestamp: 1 }),
collections.cache.createIndex({ expire: 1 }, { expireAfterSeconds: 0 }),
collections.locks.createIndex({ expire: 1 }, { expireAfterSeconds: 0 }),
]);
}
export async function disconnect(): Promise<void> {
if (clientPromise != null) await (await clientPromise).close();
}
+660
View File
@@ -0,0 +1,660 @@
import { Filter } from "mongodb";
import { EJSON } from "bson";
import { complement } from "espresso-iisojs";
import { parseLikePattern } from "../common/expression/parser.ts";
import Expression from "../common/expression.ts";
import { decodeTag } from "../util.ts";
import {
SynthContextBase,
likeDisjoint,
likeImplies,
Clause,
} from "../common/expression/synth.ts";
import normalize from "../common/expression/normalize.ts";
type Minterm = number[];
function getParam(exp: Expression, collection: string): string {
if (!(exp instanceof Expression.Parameter))
throw new Error("Left-hand operand must be a parameter");
const p = exp.path.toString();
if (collection === "devices") {
if (p === "DeviceID.ID") return "_id";
else if (p === "DeviceID") return "_deviceId";
else if (p.startsWith("DeviceID.")) return "_deviceId._" + p.slice(9);
else if (p === "Events.Inform") return "_lastInform";
else if (p === "Events.Registered") return "_registered";
else if (p === "Events.0_BOOTSTRAP") return "_lastBootstrap";
else if (p === "Events.1_BOOT") return "_lastBoot";
else if (!p.endsWith("._value") && !p.startsWith("Tags."))
return `${p}._value`;
}
return p;
}
function getTypes(parameter: string, collection: string): string[] {
if (collection === "devices") {
if (parameter === "_id") return ["string"];
if (parameter === "_lastInform") return ["date"];
if (parameter === "_registered") return ["date"];
if (parameter === "_lastBootstrap") return ["date"];
if (parameter === "_lastBoot") return ["date"];
if (parameter.startsWith("_deviceId.")) return ["string"];
if (parameter === "Reboot._value") return ["date"];
if (parameter === "FactoryReset._value") return ["date"];
if (parameter.endsWith("_timestamp")) return ["date"];
if (parameter.startsWith("Downloads.")) {
if (parameter.endsWith("Download._value")) return ["date"];
if (parameter.endsWith("Time._value")) return ["date"];
if (parameter.endsWith("Name._value")) return ["string"];
if (parameter.endsWith("Type._value")) return ["string"];
}
if (parameter.endsWith("_value"))
return ["bool", "number", "date", "string"];
} else if (collection === "tasks") {
if (parameter === "_id") return ["oid"];
if (parameter === "timestamp") return ["date"];
if (parameter === "expiry") return ["date"];
if (parameter === "name") return ["string"];
if (parameter === "device") return ["string"];
} else if (collection === "faults") {
if (parameter === "_id") return ["string"];
if (parameter === "timestamp") return ["date"];
if (parameter === "expiry") return ["date"];
if (parameter === "code") return ["string"];
if (parameter === "retries") return ["number"];
if (parameter === "channel") return ["string"];
if (parameter === "device") return ["string"];
if (parameter === "message") return ["string"];
} else if (collection === "users") {
if (parameter === "_id") return ["string"];
if (parameter === "roles") return ["string"];
else if (parameter === "password")
throw new Error("Cannot query restricted parameters");
else if (parameter === "salt")
throw new Error("Cannot query restricted parameters");
} else if (collection === "config") {
if (parameter === "_id") return ["string"];
else if (parameter === "value") return ["string"];
} else if (collection === "files") {
if (parameter === "_id") return ["string"];
if (parameter === "metadata.fileType") return ["string"];
if (parameter === "metadata.oui") return ["string"];
if (parameter === "metadata.productClass") return ["string"];
if (parameter === "metadata.version") return ["string"];
} else if (collection === "permissions") {
if (parameter === "_id") return ["string"];
if (parameter === "role") return ["string"];
if (parameter === "resource") return ["devices"];
if (parameter === "access") return ["number"];
if (parameter === "filter") return ["string"];
if (parameter === "validate") return ["string"];
} else if (collection === "presets") {
if (parameter === "_id") return ["string"];
if (parameter === "weight") return ["number"];
if (parameter === "channel") return ["string"];
if (parameter === "precondition") return ["string"];
if (parameter.startsWith("events.")) return ["bool"];
} else if (collection === "provisions") {
if (parameter === "_id") return ["string"];
if (parameter === "script") return ["string"];
} else if (collection === "virtualParameters") {
if (parameter === "_id") return ["string"];
if (parameter === "script") return ["string"];
}
if (parameter === "_id") return ["oid", "string"];
return ["bool", "number", "date", "string"];
}
function roundOid(oid: string, roundUp: boolean): string {
const match = (oid.match(/^[0-9a-f]*/)?.[0] ?? "").slice(0, 24);
let num = BigInt("0x0" + match);
let lastChar = 0;
if (oid.length > match.length) lastChar += oid.charCodeAt(match.length);
if (match.length < 24) lastChar -= 48;
if (lastChar > 0 && roundUp) num++;
num <<= BigInt(4 * (24 - match.length));
if (lastChar < 0 && !roundUp) --num;
const str = num.toString(16);
if (str.length > 24 || str.startsWith("-")) return null;
return str.padStart(24, "0");
}
function groupBy<T, K>(
input: T[],
callback: (item: T) => K,
): Iterable<[K, T[]]> {
const groups = new Map<K, T[]>();
for (const item of input) {
const key = callback(item);
let arr = groups.get(key);
if (!arr) groups.set(key, (arr = []));
arr.push(item);
}
return groups.entries();
}
abstract class MongoClause {
abstract readonly parameter: string;
abstract toQuery(truthy: boolean): Filter<unknown>;
toString(): string {
return JSON.stringify(this.toQuery(true));
}
}
class MongoClauseArray extends MongoClause {
constructor(
public readonly parameter: string,
public readonly value: string,
) {
super();
}
toQuery(truthy: boolean): Filter<unknown> {
if (truthy) return { [this.parameter]: { $eq: this.value } };
else return { [this.parameter]: { $ne: this.value } };
}
}
class MongoClauseCompare<T> extends MongoClause {
constructor(
public readonly parameter: string,
public readonly op: "$eq" | "$gt" | "$lt" | "$gte" | "$lte",
public readonly value: T,
public readonly type: "" | "bool" | "number" | "string" | "date" | "oid",
) {
super();
}
toQuery(truthy: boolean): Filter<unknown> {
let v: any = this.value;
if (this.type === "date" && typeof v === "number")
v = { $date: new Date(v).toISOString() };
if (this.type === "oid" && typeof v === "string") v = { $oid: v };
if (truthy) return { [this.parameter]: { [this.op]: v } };
else if (this.op === "$eq") return { [this.parameter]: { $ne: v } };
else return { [this.parameter]: { $not: { [this.op]: v } } };
}
}
class MongoClauseType extends MongoClause {
constructor(
public readonly parameter: string,
public readonly type: string,
) {
super();
}
toQuery(truthy: boolean): Filter<unknown> {
if (truthy) return { [this.parameter]: { $type: this.type } };
else return { [this.parameter]: { $not: { $type: this.type } } };
}
}
class MongoClauseLike extends MongoClause {
readonly pattern: string[];
constructor(
public readonly parameter: string,
pat: string,
esc: string,
public readonly caseSensitive: boolean,
) {
super();
this.pattern = parseLikePattern(pat, esc);
}
toQuery(truthy: boolean): Filter<unknown> {
const convChars = {
"-": "\\-",
"/": "\\/",
"\\": "\\/",
"^": "\\^",
$: "\\$",
"*": "\\*",
"+": "\\+",
"?": "\\?",
".": "\\.",
"(": "\\(",
")": "\\)",
"|": "\\|",
"[": "\\[",
"]": "\\]",
"{": "\\{",
"}": "\\}",
"\\%": ".*",
"\\_": ".",
};
const chars = this.pattern.map((c) => convChars[c] ?? c);
chars[0] = chars[0] === ".*" ? "" : "^" + chars[0];
const l = chars.length - 1;
chars[l] = [".*", ""].includes(chars[l]) ? "" : chars[l] + "$";
const pattern = chars.join("");
const options = this.caseSensitive ? "s" : "is";
if (truthy) {
return { [this.parameter]: { $regularExpression: { options, pattern } } };
} else {
return {
[this.parameter]: {
$not: { $regularExpression: { options, pattern } },
},
};
}
}
}
class MongoSynthContext extends SynthContextBase<Clause, MongoClause> {
constructor(private readonly collection: string) {
super();
}
getMinterms(clause: Clause, res: number): number[][] {
res = res & 0b111;
if (res & (res - 1)) return complement(this.getMinterms(clause, ~res));
const exp = clause.expression();
if (res === 0b001) {
const minterms: number[][] = [];
for (const dep of clause.getNullables()) {
const e = dep.operand.expression();
const param = getParam(e, this.collection);
if (param.startsWith("Tags.") && this.collection === "devices") {
const t = decodeTag(param.slice(5));
const c = new MongoClauseArray("_tags", t);
minterms.push([this.getVar(c) << 1]);
continue;
}
const c = new MongoClauseCompare(param, "$eq", null, "");
minterms.push([(this.getVar(c) << 1) ^ 1]);
}
return minterms;
}
if (!(exp instanceof Expression.Binary))
throw new Error("Invalid query expression");
const truthy = res === 0b100;
if ([">", "<", "="].includes(exp.operator)) {
const param = getParam(exp.left, this.collection);
if (!(exp.right instanceof Expression.Literal))
throw new Error(`Right-hand operand must be a literal value`);
let rhs = exp.right.value;
if (typeof rhs === "boolean") rhs = +rhs;
if (
param.startsWith("Tags.") &&
this.collection === "devices" &&
exp.operator === "="
) {
const t = decodeTag(param.slice(5));
const c = new MongoClauseArray("_tags", t);
if (typeof rhs === "string") rhs = 2;
if (exp.operator === "=") {
if ((rhs === 1) !== truthy) return [];
} else if (exp.operator === ">") {
if ((truthy && rhs >= 1) || (!truthy && rhs < 1)) return [];
} else if (exp.operator === "<") {
if ((truthy && rhs <= 1) || (!truthy && rhs > 1)) return [];
}
return [[(this.getVar(c) << 1) ^ 1]];
}
let op: "$gt" | "$lt" | "$eq" | "$gte" | "$lte";
if (exp.operator === "=") op = "$eq";
else if (exp.operator === ">") op = truthy ? "$gt" : "$lte";
else if (exp.operator === "<") op = truthy ? "$lt" : "$gte";
const possibleTypes = new Set(getTypes(param, this.collection));
const clauses: MongoClause[] = [];
if (typeof rhs === "number") {
if (possibleTypes.has("number"))
clauses.push(new MongoClauseCompare(param, op, rhs, "number"));
if (possibleTypes.has("date"))
clauses.push(new MongoClauseCompare(param, op, rhs, "date"));
if (possibleTypes.has("bool") && (rhs === 0 || rhs === 1))
clauses.push(new MongoClauseCompare(param, op, !!rhs, "bool"));
} else if (typeof rhs === "string") {
if (possibleTypes.has("string"))
clauses.push(new MongoClauseCompare(param, op, rhs, "string"));
if (possibleTypes.has("oid")) {
const oid = roundOid(rhs, op === "$lt" || op === "$lte");
if (oid && (op !== "$eq" || oid === rhs))
clauses.push(new MongoClauseCompare(param, op, oid, "oid"));
}
}
if (op === "$eq" && !truthy) {
clauses.push(new MongoClauseCompare(param, "$eq", null, ""));
return [clauses.map((c) => this.getVar(c) << 1)];
}
// In the following clauses we could use $type operator, but we want the
// final query to use comparison operators to check for type because the
// $type operator in MongoDB doesn't use indexes
if (typeof rhs === "number") {
if (possibleTypes.has("bool")) {
if (
(rhs > 1 && (op === "$lt" || op === "$lte")) ||
(rhs < 0 && (op === "$gt" || op === "$gte"))
)
clauses.push(new MongoClauseCompare(param, "$gte", false, "bool"));
}
if (op === "$gt" || op === "$gte") {
if (possibleTypes.has("string"))
clauses.push(new MongoClauseCompare(param, "$gte", "", "string"));
if (possibleTypes.has("oid")) {
clauses.push(
new MongoClauseCompare(
param,
"$gte",
"000000000000000000000000",
"oid",
),
);
}
}
} else if (typeof rhs === "string") {
if (op === "$lt" || op === "$lte") {
if (possibleTypes.has("bool"))
clauses.push(new MongoClauseCompare(param, "$gte", false, "bool"));
if (possibleTypes.has("number")) {
clauses.push(new MongoClauseCompare(param, "$gte", 0, "number"));
clauses.push(new MongoClauseCompare(param, "$lt", 0, "number"));
}
if (possibleTypes.has("date")) {
clauses.push(new MongoClauseCompare(param, "$gte", 0, "date"));
clauses.push(new MongoClauseCompare(param, "$lt", 0, "date"));
}
}
}
return clauses.map((c) => [(this.getVar(c) << 1) ^ 1]);
} else if (exp.operator === "LIKE") {
if (
!(
exp.right instanceof Expression.Literal &&
typeof exp.right.value === "string"
)
)
throw new Error("Right-hand operand of 'LIKE' must be a string");
const pat = exp.right.value;
let p = exp.left;
let caseSensitive = true;
if (
p instanceof Expression.FunctionCall &&
["UPPER", "LOWER"].includes(p.name)
) {
if (p.name === "UPPER" && pat !== pat.toUpperCase())
return truthy ? [] : [[]];
if (p.name === "LOWER" && pat !== pat.toLowerCase())
return truthy ? [] : [[]];
caseSensitive = false;
p = p.args[0];
}
const param = getParam(p, this.collection);
const c = new MongoClauseLike(param, pat, null, caseSensitive);
if (truthy) return [[(this.getVar(c) << 1) ^ 1]];
const typeClause = new MongoClauseCompare(param, "$gte", "", "string");
const r = [[this.getVar(c) << 1, (this.getVar(typeClause) << 1) ^ 1]];
return r;
} else {
throw new Error("Invalid query expression");
}
}
getDcSet(minterms: Minterm[]): number[][] {
const dcSet: number[][] = [];
const vars = new Set(minterms.flat().map((l) => l >> 1));
const clauses = Array.from(vars).map((v) => this.getClause(v));
for (const [parameter, clauses2] of groupBy(clauses, (c) => c.parameter)) {
const comparisons = clauses2.filter(
(c) => c instanceof MongoClauseCompare && c.value !== null,
) as MongoClauseCompare<unknown>[];
for (const [type, clauses3] of groupBy(comparisons, (c) => c.type)) {
const isType = this.getVar(new MongoClauseType(parameter, type));
const values = new Set(clauses3.map((c) => c.value));
const valuesSorted = Array.from(values).sort((a, b) =>
a > b ? 1 : -1,
);
for (const [i, v] of valuesSorted.entries()) {
const eq = this.getVar(
new MongoClauseCompare(parameter, "$eq", v, type),
);
const gt = this.getVar(
new MongoClauseCompare(parameter, "$gt", v, type),
);
const lt = this.getVar(
new MongoClauseCompare(parameter, "$lt", v, type),
);
const gte = this.getVar(
new MongoClauseCompare(parameter, "$gte", v, type),
);
const lte = this.getVar(
new MongoClauseCompare(parameter, "$lte", v, type),
);
if (type === "bool") {
if (v === false) dcSet.push([(lt << 1) ^ 1]);
else if (v === true) dcSet.push([(gt << 1) ^ 1]);
} else if (type === "string") {
if (v === "") dcSet.push([(lt << 1) ^ 1]);
} else if (type === "oid") {
if (v < "000000000000000000000000") dcSet.push([(lte << 1) ^ 1]);
else if (v === "000000000000000000000000")
dcSet.push([(lt << 1) ^ 1]);
else if (v > "ffffffffffffffffffffffff")
dcSet.push([(gte << 1) ^ 1]);
else if (v === "ffffffffffffffffffffffff")
dcSet.push([(gt << 1) ^ 1]);
}
dcSet.push([(lt << 1) ^ 1, (gte << 1) ^ 1]);
dcSet.push([(gt << 1) ^ 1, (gte << 1) ^ 0]);
dcSet.push([(eq << 1) ^ 0, (lt << 1) ^ 0, (lte << 1) ^ 1]);
dcSet.push([(eq << 1) ^ 1, (gte << 1) ^ 0]);
dcSet.push([(eq << 1) ^ 1, (gt << 1) ^ 1]);
if (i === 0) {
dcSet.push([(isType << 1) ^ 0, (gte << 1) ^ 1]);
dcSet.push([(isType << 1) ^ 0, (lt << 1) ^ 1]);
} else {
const gt2 = this.getVar(
new MongoClauseCompare(
parameter,
"$gt",
valuesSorted[i - 1],
type,
),
);
const lte2 = this.getVar(
new MongoClauseCompare(
parameter,
"$lte",
valuesSorted[i - 1],
type,
),
);
dcSet.push([(gt2 << 1) ^ 0, (gte << 1) ^ 1]);
dcSet.push([(gt2 << 1) ^ 0, (lte2 << 1) ^ 0, (lt << 1) ^ 1]);
}
if (i === valuesSorted.length - 1)
dcSet.push([(isType << 1) ^ 1, (gt << 1) ^ 0, (lte << 1) ^ 0]);
}
}
const likes = clauses2.filter(
(c) => c instanceof MongoClauseLike,
) as MongoClauseLike[];
if (likes.length) {
const isType = this.getVar(new MongoClauseType(parameter, "string"));
for (let i1 = 0; i1 < likes.length; ++i1) {
const l1 = likes[i1];
let p1 = l1.pattern;
dcSet.push([(this.getVar(l1) << 1) ^ 1, (isType << 1) ^ 0]);
for (let i2 = i1 + 1; i2 < likes.length; ++i2) {
const l2 = likes[i2];
let p2 = l2.pattern;
if (!l1.caseSensitive || !l2.caseSensitive) {
p1 = p1.map((c) => c.toLowerCase());
p2 = p2.map((c) => c.toLowerCase());
}
if (likeDisjoint(p1, p2)) {
dcSet.push([
(this.getVar(l1) << 1) ^ 1,
(this.getVar(l2) << 1) ^ 1,
]);
} else if (
(!l1.caseSensitive || l2.caseSensitive) &&
likeImplies(p1, p2)
) {
dcSet.push([
(this.getVar(l1) << 1) ^ 0,
(this.getVar(l2) << 1) ^ 1,
]);
} else if (
(!l2.caseSensitive || l1.caseSensitive) &&
likeImplies(p2, p1)
) {
dcSet.push([
(this.getVar(l1) << 1) ^ 1,
(this.getVar(l2) << 1) ^ 0,
]);
}
}
}
}
const isNull = this.getVar(
new MongoClauseCompare(parameter, "$eq", null, ""),
);
const types = getTypes(parameter, this.collection).map((t) =>
this.getVar(new MongoClauseType(parameter, t)),
);
dcSet.push([(isNull << 1) ^ 0, ...types.map((t) => (t << 1) ^ 0)]);
for (let i = 0; i < types.length; ++i) {
const t1 = types[i];
dcSet.push([(t1 << 1) ^ 1, (isNull << 1) ^ 1]);
for (let j = i + 1; j < types.length; ++j) {
const t2 = types[j];
dcSet.push([(t1 << 1) ^ 1, (t2 << 1) ^ 1]);
}
}
if (parameter === "_id") dcSet.push([(isNull << 1) ^ 1]);
}
return dcSet;
}
canRaise(i: number, s: Set<number>): boolean {
if (!(i & 1)) return true;
const c = this.getClause(i >> 1);
if (c instanceof MongoClauseCompare) {
for (const j of s) {
if (j === i) continue;
const c2 = this.getClause(j >> 1);
if (c2.parameter !== c.parameter) continue;
if (!(j & 1)) return false;
if (c2 instanceof MongoClauseType) return false;
}
}
return true;
}
toQuery(minterms: Minterm[]): Filter<unknown> {
const or = [];
const ejsonOps = ["$oid", "$date", "$regularExpression"];
for (const minterm of minterms) {
const query = {};
loop: for (const clause of minterm) {
if (!minterm.length) return {};
const negate = !!(clause & 1);
const c = this.getClause(clause >> 1);
const q = c.toQuery(negate);
if (Object.keys(q).length !== 1)
throw new Error("Invalid query expression");
const [param, value] = Object.entries(q)[0];
const dests = [query, ...(query["$and"] ?? [])];
for (const dest of dests) {
if (!(param in dest)) {
dest[param] = value;
continue loop;
}
let src = value;
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 ([...srcKeys, ...dstKeys].every((k) => k === "$not")) {
src = src["$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 (srcKeys.some((k) => dstKeys.includes(k))) continue;
// 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"].push({ [param]: value });
}
or.push(query);
}
if (or.length === 1) return or[0];
return { $or: or };
}
}
export function toMongoQuery(
exp: Expression,
resource: string,
): Filter<unknown> | false {
exp = normalize(exp);
const clause = Clause.fromExpression(normalize(exp));
const context = new MongoSynthContext(resource);
const minterms = clause.getMinterms(context, 0b100);
const minimized = context.minimize(minterms);
if (!minimized.length) return false;
return EJSON.deserialize(context.toQuery(minimized));
}
export function validQuery(exp: Expression, resource: string): void {
const clause = Clause.fromExpression(normalize(exp));
const context = new MongoSynthContext(resource);
clause.getMinterms(context, 0b100);
}
+79 -2
View File
@@ -1,5 +1,6 @@
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import { Expression, FaultStruct } from "./types"; import { FaultStruct } from "../types.ts";
import { Value } from "../common/expression.ts";
export interface Fault { export interface Fault {
_id: string; _id: string;
@@ -25,6 +26,7 @@ interface TaskBase {
timestamp?: Date; timestamp?: Date;
expiry?: Date; expiry?: Date;
name: string; name: string;
device: string;
} }
interface TaskGetParameterValues extends TaskBase { interface TaskGetParameterValues extends TaskBase {
@@ -70,7 +72,7 @@ interface TaskDeleteObject extends TaskBase {
interface TaskProvisions extends TaskBase { interface TaskProvisions extends TaskBase {
name: "provisions"; name: "provisions";
provisions?: [string, ...Expression[]][]; provisions?: [string, ...Value[]][];
} }
export type Task = export type Task =
@@ -105,3 +107,78 @@ export interface Cache {
timestamp: Date; timestamp: Date;
expire: Date; expire: Date;
} }
export interface Device {
_id: string;
_lastInform: Date;
_registered: Date;
_tags?: string[];
_timestamp?: Date;
}
type Configuration =
| { type: "age"; name: string; age: number }
| { type: "value"; name: string; value: boolean | number | string }
| { type: "add_tag"; tag: string }
| { type: "delete_tag"; tag: string }
| { type: "add_object"; name: string; object: string }
| { type: "delete_object"; name: string; object: string }
| { type: "provision"; name: string; args?: string[] };
export interface Preset {
_id: string;
weight: number;
channel: string;
events: Record<string, boolean>;
configurations: Configuration[];
}
export interface Object {
_id: string;
}
export interface Provision {
_id: string;
script: string;
}
export interface VirtualParameter {
_id: string;
script: string;
}
export interface File {
_id: string;
length: number;
filename: string;
uploadDate: Date;
metadata?: {
fileType?: string;
oui?: string;
productClass?: string;
version?: string;
};
}
export interface Permission {
_id: string;
role: string;
resource: string;
access: 1 | 2 | 3;
filter?: string;
validate?: string;
}
export interface User {
_id: string;
password: string;
roles: string;
salt: string;
}
export interface Lock {
_id: string;
value: string;
timestamp: Date;
expire: Date;
}
+183
View File
@@ -0,0 +1,183 @@
import Expression, { Value } from "../common/expression.ts";
import Path from "../common/path.ts";
import { encodeTag } from "../util.ts";
// Optimize projection by removing overlaps
// This can modify the object
export function optimizeProjection(obj: { [path: string]: 1 }): {
[path: string]: 1;
} {
if (obj[""]) return { "": obj[""] };
const keys = Object.keys(obj).sort();
if (keys.length <= 1) return obj;
for (let i = 1; i < keys.length; ++i) {
const a = keys[i - 1];
const b = keys[i];
if (b.startsWith(a)) {
if (b.charAt(a.length) === "." || b.charAt(a.length - 1) === ".") {
delete obj[b];
keys.splice(i--, 1);
}
}
}
return obj;
}
export function convertOldPrecondition(q: Record<string, unknown>): Expression {
function recursive(_query): Expression {
let res: Expression = new Expression.Literal(true);
for (const [k, v] of Object.entries(_query)) {
if (k[0] === "$") {
if (k === "$and") {
for (const vv of Object.values(v))
res = Expression.and(res, recursive(vv));
} else if (k === "$or") {
let or: Expression = new Expression.Literal(false);
for (const vv of Object.values(v))
or = Expression.or(or, recursive(vv));
res = Expression.and(res, or);
} else {
throw new Error(`Operator ${k} not supported`);
}
} else if (k === "_tags") {
if (typeof v === "object") {
if (Array.isArray(v)) throw new Error(`Invalid type`);
for (const [op, val] of Object.entries(v)) {
if (op === "$ne") {
if (typeof v["$ne"] !== "string")
throw new Error("Only string values are allowed for _tags");
res = Expression.and(
res,
new Expression.Unary(
"IS NULL",
new Expression.Parameter(
Path.parse(`Tags.${encodeTag(val)}`),
),
),
);
} else if (op === "$eq") {
if (typeof v["$eq"] !== "string")
throw new Error("Only string values are allowed for _tags");
res = Expression.and(
res,
new Expression.Unary(
"IS NOT NULL",
new Expression.Parameter(
Path.parse(`Tags.${encodeTag(val)}`),
),
),
);
} else {
throw new Error(`Invalid tag query`);
}
}
} else {
res = Expression.and(
res,
new Expression.Unary(
"IS NOT NULL",
new Expression.Parameter(
Path.parse(`Tags.${encodeTag(v as string)}`),
),
),
);
}
} else if (k.startsWith("Tags.")) {
let exists: boolean;
if (typeof v === "boolean") exists = v;
else if (v.hasOwnProperty("$eq")) exists = !!v["$eq"];
else if (v.hasOwnProperty("$ne")) exists = !v["$ne"];
else if (v.hasOwnProperty("$exists")) exists = !!v["$exists"];
else throw new Error(`Invalid tag query`);
res = Expression.and(
res,
new Expression.Unary(
exists ? "IS NOT NULL" : "IS NULL",
new Expression.Parameter(Path.parse(k)),
),
);
} else if (typeof v === "object") {
if (Array.isArray(v)) throw new Error(`Invalid type`);
for (const [kk, vv] of Object.entries(v)) {
if (kk === "$eq") {
res = Expression.and(
res,
new Expression.Binary(
"=",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(vv),
),
);
} else if (kk === "$ne") {
const p = new Expression.Parameter(Path.parse(k));
res = Expression.and(
res,
Expression.or(
new Expression.Binary("<>", p, new Expression.Literal(vv)),
new Expression.Unary("IS NULL", p),
),
);
} else if (kk === "$lt") {
res = Expression.and(
res,
new Expression.Binary(
"<",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(vv),
),
);
} else if (kk === "$lte") {
res = Expression.and(
res,
new Expression.Binary(
"<=",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(vv),
),
);
} else if (kk === "$gt") {
res = Expression.and(
res,
new Expression.Binary(
">",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(vv),
),
);
} else if (kk === "$gte") {
res = Expression.and(
res,
new Expression.Binary(
">=",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(vv),
),
);
} else {
throw new Error(`Operator ${kk} not supported`);
}
if (!["string", "number", "boolean"].includes(typeof vv))
throw new Error(`Invalid value for ${kk} operator`);
}
} else {
res = Expression.and(
res,
new Expression.Binary(
"=",
new Expression.Parameter(Path.parse(k)),
new Expression.Literal(v as Value),
),
);
}
}
return res;
}
// empty filter
if (!Object.keys(q).length) return new Expression.Literal(true);
return recursive(q);
}
+46 -31
View File
@@ -1,28 +1,9 @@
/** import { IncomingMessage, ServerResponse, ClientRequest } from "node:http";
* Copyright 2013-2019 GenieACS Inc. import { Socket } from "node:net";
* import { appendFileSync } from "node:fs";
* This file is part of GenieACS. import { stringify } from "./common/yaml.ts";
* import * as config from "./config.ts";
* GenieACS is free software: you can redistribute it and/or modify import { getSocketEndpoints } from "./server.ts";
* 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 { IncomingMessage, ServerResponse, ClientRequest } from "http";
import { Socket } from "net";
import { appendFileSync } from "fs";
import { stringify } from "./common/yaml";
import * as config from "./config";
import { getSocketEndpoints } from "./server";
const DEBUG_FILE = "" + config.get("DEBUG_FILE"); const DEBUG_FILE = "" + config.get("DEBUG_FILE");
const DEBUG_FORMAT = "" + config.get("DEBUG_FORMAT"); const DEBUG_FORMAT = "" + config.get("DEBUG_FORMAT");
@@ -41,7 +22,7 @@ function getConnectionTimestamp(connection: Socket): Date {
export function incomingHttpRequest( export function incomingHttpRequest(
httpRequest: IncomingMessage, httpRequest: IncomingMessage,
deviceId: string, deviceId: string,
body: string body: string,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -70,7 +51,7 @@ export function incomingHttpRequest(
export function outgoingHttpResponse( export function outgoingHttpResponse(
httpResponse: ServerResponse, httpResponse: ServerResponse,
deviceId: string, deviceId: string,
body: string body: string,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -99,7 +80,7 @@ export function outgoingHttpRequest(
deviceId: string, deviceId: string,
method: "GET" | "PUT" | "POST" | "DELETE", method: "GET" | "PUT" | "POST" | "DELETE",
url: URL, url: URL,
body: string body: string,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -129,7 +110,7 @@ export function outgoingHttpRequestError(
deviceId: string, deviceId: string,
method: "GET" | "PUT" | "POST" | "DELETE", method: "GET" | "PUT" | "POST" | "DELETE",
url: URL, url: URL,
err: Error err: Error,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -156,7 +137,7 @@ export function outgoingHttpRequestError(
export function incomingHttpResponse( export function incomingHttpResponse(
httpResponse: IncomingMessage, httpResponse: IncomingMessage,
deviceId: string, deviceId: string,
body: string body: string,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -183,7 +164,7 @@ export function outgoingUdpMessage(
remoteAddress: string, remoteAddress: string,
deviceId: string, deviceId: string,
remotePort: number, remotePort: number,
body: string body: string,
): void { ): void {
if (!DEBUG_FILE) return; if (!DEBUG_FILE) return;
const now = new Date(); const now = new Date();
@@ -219,3 +200,37 @@ export function clientError(remoteAddress: string, err: Error): void {
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" + 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" + stringify(msg));
else if (DEBUG_FORMAT === "json")
appendFileSync(DEBUG_FILE, JSON.stringify(msg) + "\n");
else throw new Error(`Unrecognized DEBUG_FORMAT option`);
}
+13 -32
View File
@@ -1,34 +1,15 @@
/** import Path from "./common/path.ts";
* Copyright 2013-2019 GenieACS Inc. import * as config from "./config.ts";
* import * as device from "./device.ts";
* This file is part of GenieACS. import * as scheduling from "./scheduling.ts";
* import { SessionContext, Declaration } from "./types.ts";
* 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 Path from "./common/path";
import * as config from "./config";
import * as device from "./device";
import * as scheduling from "./scheduling";
import { SessionContext, Declaration } from "./types";
const MAX_DEPTH = +config.get("MAX_DEPTH"); const MAX_DEPTH = +config.get("MAX_DEPTH");
export function refresh( export function refresh(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if ( if (
(provision.length !== 2 || typeof provision[1] !== "string") && (provision.length !== 2 || typeof provision[1] !== "string") &&
@@ -84,7 +65,7 @@ export function refresh(
export function value( export function value(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if ( if (
provision.length < 3 || provision.length < 3 ||
@@ -127,7 +108,7 @@ export function value(
export function tag( export function tag(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if ( if (
provision.length !== 3 || provision.length !== 3 ||
@@ -151,7 +132,7 @@ export function tag(
export function reboot( export function reboot(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if (provision.length !== 1) throw new Error("Invalid arguments"); if (provision.length !== 1) throw new Error("Invalid arguments");
@@ -170,7 +151,7 @@ export function reboot(
export function reset( export function reset(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if (provision.length !== 1) throw new Error("Invalid arguments"); if (provision.length !== 1) throw new Error("Invalid arguments");
@@ -189,7 +170,7 @@ export function reset(
export function download( export function download(
sessionContext: SessionContext, sessionContext: SessionContext,
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[] declarations: Declaration[],
): boolean { ): boolean {
if ( if (
(provision.length !== 3 || (provision.length !== 3 ||
@@ -234,7 +215,7 @@ export function instances(
provision: (string | number | boolean)[], provision: (string | number | boolean)[],
declarations: Declaration[], declarations: Declaration[],
startRevision: number, startRevision: number,
endRevision: number endRevision: number,
): boolean { ): boolean {
if (provision.length !== 3 || typeof provision[1] !== "string") if (provision.length !== 3 || typeof provision[1] !== "string")
throw new Error("Invalid arguments"); throw new Error("Invalid arguments");
@@ -260,7 +241,7 @@ export function instances(
const unpacked = device.unpack( const unpacked = device.unpack(
sessionContext.deviceData, sessionContext.deviceData,
path, path,
startRevision + 1 startRevision + 1,
); );
count = Math.max(0, unpacked.length + count); count = Math.max(0, unpacked.length + count);
} }
+55 -45
View File
@@ -1,30 +1,12 @@
/** import Expression, { Value } from "./common/expression.ts";
* Copyright 2013-2019 GenieACS Inc. import Path from "./common/path.ts";
*
* 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 Path from "./common/path";
import { import {
DeviceData, DeviceData,
Declaration, Declaration,
Clear, Clear,
AttributeTimestamps, AttributeTimestamps,
Attributes, Attributes,
} from "./types"; } from "./types.ts";
const CHANGE_FLAGS = { const CHANGE_FLAGS = {
object: 2, object: 2,
@@ -43,7 +25,7 @@ function parseBool(v): boolean {
} }
export function sanitizeParameterValue( export function sanitizeParameterValue(
parameterValue: [string | number | boolean, string] parameterValue: [string | number | boolean, string],
): [string | number | boolean, string] { ): [string | number | boolean, string] {
if (parameterValue[0] != null) { if (parameterValue[0] != null) {
switch (parameterValue[1]) { switch (parameterValue[1]) {
@@ -87,7 +69,7 @@ export function sanitizeParameterValue(
export function getAliasDeclarations( export function getAliasDeclarations(
path: Path, path: Path,
timestamp: number, timestamp: number,
attrGet = null attrGet = null,
): Declaration[] { ): Declaration[] {
const stripped = path.stripAlias(); const stripped = path.stripAlias();
let decs: Declaration[] = [ let decs: Declaration[] = [
@@ -103,13 +85,13 @@ export function getAliasDeclarations(
if (path.alias) { if (path.alias) {
for (const [i, alias] of path.segments.entries()) { for (const [i, alias] of path.segments.entries()) {
if (Array.isArray(alias)) { if (alias instanceof Expression) {
const parent = stripped.slice(0, i + 1); const parent = stripped.slice(0, i + 1);
for (const [p] of alias as [Path, string][]) { for (const [p] of expressionToAlias(alias)) {
decs = decs.concat( decs = decs.concat(
getAliasDeclarations(parent.concat(p), timestamp, { getAliasDeclarations(parent.concat(p), timestamp, {
value: timestamp, value: timestamp,
}) }),
); );
} }
} }
@@ -119,24 +101,42 @@ export function getAliasDeclarations(
return decs; return decs;
} }
export function expressionToAlias(exp: Expression): [Path, Value][] {
if (exp instanceof Expression.Literal && exp.value === true) return [];
if (exp instanceof Expression.Binary) {
if (exp.operator === "AND")
return [...expressionToAlias(exp.left), ...expressionToAlias(exp.right)];
else if (exp.operator === "=") {
if (
exp.left instanceof Expression.Parameter &&
exp.right instanceof Expression.Literal
)
return [[exp.left.path, exp.right.value]];
}
}
throw new Error("Invalid alias expression");
}
export function unpack( export function unpack(
deviceData: DeviceData, deviceData: DeviceData,
path: Path, path: Path,
revision?: number revision?: number,
): Path[] { ): Path[] {
let allMatches = [] as Path[]; let allMatches = [] as Path[];
if (!path.alias) { if (!path.alias) {
for (const p of deviceData.paths.find(path, false, true)) for (const p of deviceData.paths.findCompat(path, false, true))
if (deviceData.attributes.has(p, revision)) allMatches.push(p); if (deviceData.attributes.has(p, revision)) allMatches.push(p);
} else { } else {
const wildcardPath = path.stripAlias(); const wildcardPath = path.stripAlias();
for (const p of deviceData.paths.find(wildcardPath, false, true)) for (const p of deviceData.paths.findCompat(wildcardPath, false, true))
if (deviceData.attributes.has(p, revision)) allMatches.push(p); if (deviceData.attributes.has(p, revision)) allMatches.push(p);
for (let i = path.length - 1; i >= 0; --i) { for (let i = path.length - 1; i >= 0; --i) {
if (path.alias & (1 << i)) { if (path.alias & (1 << i)) {
for (const [param, val] of path.segments[i] as [Path, string][]) { for (const [param, val] of expressionToAlias(
path.segments[i] as Expression,
)) {
const p = wildcardPath.slice(0, i + 1).concat(param); const p = wildcardPath.slice(0, i + 1).concat(param);
const unpacked = unpack(deviceData, p, revision); const unpacked = unpack(deviceData, p, revision);
const filtered: Path[] = []; const filtered: Path[] = [];
@@ -194,7 +194,7 @@ export function clear(
path: Path, path: Path,
timestamp: number, timestamp: number,
attributes: AttributeTimestamps, attributes: AttributeTimestamps,
changeFlags = 0 changeFlags = 0,
): void { ): void {
const changeTrackers = {}; const changeTrackers = {};
@@ -208,11 +208,11 @@ export function clear(
attributes.value = attributes.object; attributes.value = attributes.object;
} }
for (const p of deviceData.paths.find( for (const p of deviceData.paths.findCompat(
path, path,
true, true,
true, true,
descendantsTimestamp ? 99 : path.length descendantsTimestamp ? 99 : path.length,
)) { )) {
const tracker = deviceData.trackers.get(p); const tracker = deviceData.trackers.get(p);
for (const k in tracker) changeTrackers[k] |= tracker[k]; for (const k in tracker) changeTrackers[k] |= tracker[k];
@@ -271,12 +271,12 @@ function compareEquality(a, b): boolean {
export function set( export function set(
deviceData: DeviceData, deviceData: DeviceData,
path: Path, pathStr: string,
timestamp: number, timestamp: number,
attributes: Attributes, attributes: Attributes,
toClear?: Clear[] toClear?: Clear[],
): Clear[] { ): Clear[] {
path = deviceData.paths.add(path); const path = deviceData.paths.add(pathStr);
const currentTimestamp = deviceData.timestamps.get(path); const currentTimestamp = deviceData.timestamps.get(path);
@@ -315,7 +315,7 @@ export function set(
else if ( else if (
!compareEquality( !compareEquality(
attributes[attrName][1], attributes[attrName][1],
currentAttributes[attrName][1] currentAttributes[attrName][1],
) )
) )
changeFlags |= CHANGE_FLAGS[attrName]; changeFlags |= CHANGE_FLAGS[attrName];
@@ -331,10 +331,10 @@ export function set(
if (path.length > 1) { if (path.length > 1) {
toClear = set( toClear = set(
deviceData, deviceData,
path.slice(0, path.length - 1), path.slice(0, path.length - 1).toString(),
timestamp, timestamp,
{ object: [timestamp, 1] }, { object: [timestamp, 1] },
toClear toClear,
); );
} }
} }
@@ -345,7 +345,12 @@ export function set(
deviceData.attributes.delete(path); deviceData.attributes.delete(path);
changeFlags |= 1; changeFlags |= 1;
} else if (path.wildcard) { } else if (path.wildcard) {
for (const p of deviceData.paths.find(path, false, true, path.length)) { for (const p of deviceData.paths.findCompat(
path,
false,
true,
path.length,
)) {
if (timestamp > deviceData.timestamps.get(p)) { if (timestamp > deviceData.timestamps.get(p)) {
toClear = toClear || []; toClear = toClear || [];
toClear.push([p, timestamp]); toClear.push([p, timestamp]);
@@ -362,7 +367,12 @@ export function set(
toClear = toClear || []; toClear = toClear || [];
toClear.push([path, 0, { object: attributes.object[0] }, changeFlags]); toClear.push([path, 0, { object: attributes.object[0] }, changeFlags]);
} else { } else {
for (const p of deviceData.paths.find(path, true, false, path.length)) { for (const p of deviceData.paths.findCompat(
path,
true,
false,
path.length,
)) {
const tracker = deviceData.trackers.get(p); const tracker = deviceData.trackers.get(p);
for (const k in tracker) for (const k in tracker)
if (tracker[k] & changeFlags) deviceData.changes.add(k); if (tracker[k] & changeFlags) deviceData.changes.add(k);
@@ -375,11 +385,11 @@ export function set(
export function track( export function track(
deviceData: DeviceData, deviceData: DeviceData,
path: Path, pathStr: string,
marker: string, marker: string,
attributes?: string[] attributes?: string[],
): void { ): void {
path = deviceData.paths.add(path); const path = deviceData.paths.add(pathStr);
let f = 1; let f = 1;
if (attributes) if (attributes)
@@ -396,7 +406,7 @@ export function track(
export function clearTrackers( export function clearTrackers(
deviceData: DeviceData, deviceData: DeviceData,
tracker: string | string[] tracker: string | string[],
): void { ): void {
if (Array.isArray(tracker)) { if (Array.isArray(tracker)) {
for (const v of deviceData.trackers.values()) for (const v of deviceData.trackers.values())
+8 -27
View File
@@ -1,29 +1,10 @@
/** import { spawn, ChildProcess } from "node:child_process";
* Copyright 2013-2019 GenieACS Inc. import * as crypto from "node:crypto";
* import readline from "node:readline";
* This file is part of GenieACS. import * as config from "./config.ts";
* import { Fault } from "./types.ts";
* GenieACS is free software: you can redistribute it and/or modify import { ROOT_DIR } from "./config.ts";
* it under the terms of the GNU Affero General Public License as import * as logger from "./logger.ts";
* 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 { spawn, ChildProcess } from "child_process";
import * as crypto from "crypto";
import * as config from "./config";
import { Fault } from "./types";
import { ROOT_DIR } from "./config";
import * as logger from "./logger";
import readline from "readline";
const TIMEOUT = +config.get("EXT_TIMEOUT"); const TIMEOUT = +config.get("EXT_TIMEOUT");
@@ -123,6 +104,6 @@ export async function killAll(): Promise<void> {
Object.entries(processes).map(([k, p]) => { Object.entries(processes).map(([k, p]) => {
delete processes[k]; delete processes[k];
return kill(p); return kill(p);
}) }),
); );
} }
+6 -25
View File
@@ -1,27 +1,8 @@
/** import { IncomingMessage } from "node:http";
* Copyright 2013-2019 GenieACS Inc. import { TLSSocket } from "node:tls";
*
* 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 { IncomingMessage } from "http";
import { TLSSocket } from "tls";
import { parseCIDR, parse, IPv6, IPv4 } from "ipaddr.js"; import { parseCIDR, parse, IPv6, IPv4 } from "ipaddr.js";
import * as config from "./config"; import * as config from "./config.ts";
import { getSocketEndpoints } from "./server"; import { getSocketEndpoints } from "./server.ts";
interface RequestOrigin { interface RequestOrigin {
localAddress: string; localAddress: string;
@@ -39,12 +20,12 @@ const cidrs: [IPv4 | IPv6, number][] = [];
for (const str of FORWARDED_HEADER.split(",").map((s) => s.trim())) { for (const str of FORWARDED_HEADER.split(",").map((s) => s.trim())) {
try { try {
cidrs.push(parseCIDR(str)); cidrs.push(parseCIDR(str));
} catch (err) { } catch {
// Not a valid CIDR format, try parsing as IP // Not a valid CIDR format, try parsing as IP
try { try {
const ip = parse(str); const ip = parse(str);
cidrs.push([ip, ip.toByteArray().length * 8]); cidrs.push([ip, ip.toByteArray().length * 8]);
} catch (err) { } catch {
// Not a valid IP either, ignore // Not a valid IP either, ignore
} }
} }
+169 -63
View File
@@ -1,63 +1,90 @@
/** import * as url from "node:url";
* Copyright 2013-2019 GenieACS Inc. import { IncomingMessage, ServerResponse } from "node:http";
* import { PassThrough, pipeline, Readable } from "node:stream";
* This file is part of GenieACS. import { createHash } from "node:crypto";
* import { filesBucket, collections } from "./db/db.ts";
* GenieACS is free software: you can redistribute it and/or modify import * as logger from "./logger.ts";
* it under the terms of the GNU Affero General Public License as import { getRequestOrigin } from "./forwarded.ts";
* published by the Free Software Foundation, either version 3 of the import memoize from "./common/memoize.ts";
* 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 url from "url";
import { IncomingMessage, ServerResponse } from "http";
import { Collection, GridFSBucket } from "mongodb";
import { onConnect } from "./db";
import * as logger from "./logger";
import { getRequestOrigin } from "./forwarded";
import memoize from "./common/memoize";
let filesCollection: Collection;
let filesBucket: GridFSBucket;
onConnect(async (db) => {
filesCollection = db.collection("fs.files");
filesBucket = new GridFSBucket(db);
});
const getFile = memoize( const getFile = memoize(
(uploadDate: number, size: number, filename: string): Promise<Buffer> => { async (
return new Promise((resolve, reject) => { etag: string,
const buffer = Buffer.allocUnsafe(size); size: number,
let i = 0; filename: string,
const downloadStream = filesBucket.openDownloadStreamByName(filename); ): Promise<Iterable<Buffer>> => {
downloadStream.on("error", reject); const chunks: Buffer[] = [];
downloadStream.on("data", (data: Buffer) => { // Using for-await over the download stream can throw ERR_STREAM_PREMATURE_CLOSE
data.copy(buffer, i); // for very small files. Possibly a bug in MongoDB driver or Nodejs itself.
i += data.length; // Using a PassThrough stream to avoid this.
}); const downloadStream = pipeline(
downloadStream.on("end", () => { filesBucket.openDownloadStreamByName(filename),
if (i !== size) reject(new Error("File size mismatch")); new PassThrough(),
else resolve(buffer); (err) => {
}); if (err) throw err;
}); },
} );
for await (const chunk of downloadStream) chunks.push(chunk);
// Node 12-14 don't throw error when stream is closed prematurely.
// However, we don't need to check for that since we're checking file size.
if (size !== chunks.reduce((a, b) => a + b.length, 0))
throw new Error("File size mismatch");
return chunks;
},
); );
async function* partialContent(
chunks: Iterable<Buffer>,
start: number,
end: number,
): AsyncIterable<Buffer> {
let bytesToSkip = start;
let bytesToRead = end - start;
for (let chunk of chunks) {
if (bytesToRead <= 0) return;
if (bytesToSkip >= chunk.length) {
bytesToSkip -= chunk.length;
continue;
}
chunk = chunk.subarray(bytesToSkip, bytesToSkip + bytesToRead);
bytesToRead -= chunk.length;
bytesToSkip = 0;
yield chunk;
}
}
function generateETag(file: {
_id: string;
uploadDate: Date;
length: number;
}): string {
const hash = createHash("md5");
hash.update(`${file._id}-${file.uploadDate.getTime()}-${file.length}`);
return hash.digest("hex");
}
function matchEtag(etag: string, header: string): boolean {
for (let t of header.split(",")) {
t = t.trim();
if (t.startsWith("W/")) t = t.substring(2);
try {
t = JSON.parse(t);
} catch {
// Ignore
}
if (t === "*") return true;
if (etag === t) return true;
}
return false;
}
export async function listener( export async function listener(
request: IncomingMessage, request: IncomingMessage,
response: ServerResponse response: ServerResponse,
): Promise<void> { ): Promise<void> {
if (request.method !== "GET") { if (request.method !== "GET" && request.method !== "HEAD") {
response.writeHead(405, { Allow: "GET" }); response.writeHead(405, { Allow: "GET, HEAD" });
response.end("405 Method Not Allowed"); response.end("405 Method Not Allowed");
return; return;
} }
@@ -69,9 +96,10 @@ export async function listener(
message: "Fetch file", message: "Fetch file",
filename: filename, filename: filename,
remoteAddress: getRequestOrigin(request).remoteAddress, remoteAddress: getRequestOrigin(request).remoteAddress,
method: request.method,
}; };
const file = await filesCollection.findOne({ _id: filename }); const file = await collections.files.findOne({ _id: filename });
if (!file) { if (!file) {
response.writeHead(404); response.writeHead(404);
@@ -81,17 +109,95 @@ export async function listener(
return; return;
} }
const buffer = await getFile( logger.accessInfo(log);
file["uploadDate"].getTime(),
file.length,
filename
);
response.writeHead(200, { const etag = generateETag(file);
const lastModified = file["uploadDate"];
lastModified.setMilliseconds(0);
let status = 200;
let start = 0;
let end = file.length;
if (request.headers["if-match"])
if (!matchEtag(etag, request.headers["if-match"])) status = 412;
if (request.headers["if-unmodified-since"]) {
const d = new Date(request.headers["if-unmodified-since"]);
if (lastModified > d) status = 412;
}
if (request.headers["if-none-match"]) {
if (matchEtag(etag, request.headers["if-none-match"])) status = 304;
} else if (request.headers["if-modified-since"]) {
const d = new Date(request.headers["if-modified-since"]);
if (lastModified <= d) status = 304;
}
if (request.headers.range && status === 200) {
const match = request.headers.range.match(/^bytes=(\d*)-(\d*)$/);
status = 416;
if (match && (match[1] || match[2])) {
if (match[2]) end = parseInt(match[2]) + 1;
if (match[1]) start = parseInt(match[1]);
else start = file.length - parseInt(match[2]);
if (start < end && end <= file.length) status = 206;
}
if (request.headers["if-range"]) {
const h = request.headers["if-range"] as string;
const d = new Date(h);
if (!matchEtag(etag, h) && !(lastModified <= d)) {
status = 200;
start = 0;
end = file.length;
}
}
}
if (status === 412) {
response.writeHead(412);
response.end();
return;
}
if (status === 304) {
response.writeHead(304, {
ETag: etag,
"Last-Modified": lastModified.toUTCString(),
});
response.end();
return;
}
if (status === 416) {
response.writeHead(416, {
"Content-Range": `bytes */${file.length}`,
"Content-Length": "0",
});
response.end();
return;
}
response.writeHead(status, {
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": file.length, "Content-Length": end - start,
"Accept-Ranges": "bytes",
ETag: etag,
"Last-Modified": lastModified.toUTCString(),
...(status === 206 && {
"Content-Range": `bytes ${start}-${end - 1}/${file.length}`,
}),
}); });
response.end(buffer); if (request.method === "HEAD") {
logger.accessInfo(log); response.end();
return;
}
const chunks = await getFile(etag, file.length, filename);
pipeline(Readable.from(partialContent(chunks, start, end)), response, () => {
// Ignore errors resulting from client disconnecting
});
} }
+2 -21
View File
@@ -1,23 +1,4 @@
import Path from "./common/path"; import Path from "./common/path.ts";
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
const WILDCARD_MULTIPLIER = 2; const WILDCARD_MULTIPLIER = 2;
const UNDISCOVERED_DEPTH = 7; const UNDISCOVERED_DEPTH = 7;
@@ -31,7 +12,7 @@ const UNDISCOVERED_DEPTH = 7;
// point has been discovered. // point has been discovered.
export function estimateGpnCount( export function estimateGpnCount(
gpnPatterns: [Path, number][], gpnPatterns: [Path, number][],
depth = 0 depth = 0,
): number { ): number {
const children: { [segment: string]: [Path, number][] } = {}; const children: { [segment: string]: [Path, number][] } = {};
const wildcardChildren: [Path, number][] = []; const wildcardChildren: [Path, number][] = [];
+41 -91
View File
@@ -1,26 +1,17 @@
/** import { getRevision, getUiConfig, getUsers } from "./ui/local-cache.ts";
* Copyright 2013-2019 GenieACS Inc. import { generateSalt, hashPassword } from "./auth.ts";
* import { collections } from "./db/db.ts";
* This file is part of GenieACS. import {
* putConfig,
* GenieACS is free software: you can redistribute it and/or modify putPermission,
* it under the terms of the GNU Affero General Public License as putPreset,
* published by the Free Software Foundation, either version 3 of the putProvision,
* License, or (at your option) any later version. putUser,
* } from "./ui/db.ts";
* GenieACS is distributed in the hope that it will be useful, import { del } from "./cache.ts";
* but WITHOUT ANY WARRANTY; without even the implied warranty of import BOOTSTRAP_SCRIPT from "../seed/bootstrap.js" with { type: "text" };
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the import DEFAULT_SCRIPT from "../seed/default.js" with { type: "text" };
* GNU Affero General Public License for more details. import INFORM_SCRIPT from "../seed/inform.js" with { type: "text" };
*
* 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 localCache from "./local-cache";
import { generateSalt, hashPassword } from "./auth";
import * as db from "./ui/db";
import { del } from "./cache";
interface Status { interface Status {
users: boolean; users: boolean;
@@ -31,72 +22,31 @@ interface Status {
overview: boolean; overview: boolean;
} }
const BOOTSTRAP_SCRIPT = `
const now = Date.now();
// Clear cached data model to force a refresh
clear("Device", now);
clear("InternetGatewayDevice", now);
`.trim();
const DEFAULT_SCRIPT = `
const hourly = Date.now(3600000);
// Refresh basic parameters hourly
declare("InternetGatewayDevice.DeviceInfo.HardwareVersion", {path: hourly, value: hourly});
declare("InternetGatewayDevice.DeviceInfo.SoftwareVersion", {path: hourly, value: hourly});
declare("InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress", {path: hourly, value: hourly});
declare("InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress", {path: hourly, value: hourly});
declare("InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID", {path: hourly, value: hourly});
// Don't refresh password field periodically because CPEs always report blank passowrds for security reasons
declare("InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase", {path: hourly, value: 1});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName", {path: hourly, value: hourly});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress", {path: hourly, value: hourly});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress", {path: hourly, value: hourly});
`.trim();
const INFORM_SCRIPT = `
// Device ID as user name
const username = declare("DeviceID.ID", {value: 1}).value[0]
// Password will be fixed for a given device because Math.random() is seeded with device ID by default.
const password = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
const informInterval = 300;
// Refresh values daily
const daily = Date.now(86400000);
// Unique inform offset per device for better load distribution
const informTime = daily % 86400000;
declare("InternetGatewayDevice.ManagementServer.ConnectionRequestUsername", {value: daily}, {value: username});
declare("InternetGatewayDevice.ManagementServer.ConnectionRequestPassword", {value: daily}, {value: password});
declare("InternetGatewayDevice.ManagementServer.PeriodicInformEnable", {value: daily}, {value: true});
declare("InternetGatewayDevice.ManagementServer.PeriodicInformInterval", {value: daily}, {value: informInterval});
declare("InternetGatewayDevice.ManagementServer.PeriodicInformTime", {value: daily}, {value: informTime});
declare("Device.ManagementServer.ConnectionRequestUsername", {value: daily}, {value: username});
declare("Device.ManagementServer.ConnectionRequestPassword", {value: daily}, {value: password});
declare("Device.ManagementServer.PeriodicInformEnable", {value: daily}, {value: true});
declare("Device.ManagementServer.PeriodicInformInterval", {value: daily}, {value: informInterval});
declare("Device.ManagementServer.PeriodicInformTime", {value: daily}, {value: informTime});
`.trim();
export async function getStatus(): Promise<Status> { export async function getStatus(): Promise<Status> {
const configSnapshot = await localCache.getCurrentSnapshot(); const [configSnapshot, presetCount] = await Promise.all([
const users = localCache.getUsers(configSnapshot); getRevision(),
const presets = localCache.getPresets(configSnapshot); collections.presets.countDocuments(),
const ui = localCache.getUiConfig(configSnapshot); ]);
const users = getUsers(configSnapshot);
const ui = getUiConfig(configSnapshot);
return { const status = {
users: !Object.keys(users).length, users: !Object.keys(users).length,
presets: !presets.length, presets: !presetCount,
filters: !Object.keys(ui["filters"]).length, filters: true,
device: !Object.keys(ui["device"]).length, device: true,
index: !Object.keys(ui["index"]).length, index: true,
overview: !Object.keys(ui["overview"]).length, overview: true,
}; };
for (const k of Object.keys(ui)) {
if (k.startsWith("filters.")) status.filters = false;
if (k.startsWith("device.")) status.device = false;
if (k.startsWith("index.")) status.index = false;
if (k.startsWith("overview.")) status.overview = false;
}
return status;
} }
export async function seed(options: Record<string, boolean>): Promise<void> { export async function seed(options: Record<string, boolean>): Promise<void> {
@@ -395,7 +345,7 @@ export async function seed(options: Record<string, boolean>): Promise<void> {
if (resources["permissions"]) { if (resources["permissions"]) {
for (const p of resources["permissions"]) { for (const p of resources["permissions"]) {
p["_id"] = `${p["role"]}:${p["resource"]}:${p["access"]}`; p["_id"] = `${p["role"]}:${p["resource"]}:${p["access"]}`;
proms.push(db.putPermission(p["_id"], p)); proms.push(putPermission(p["_id"], p));
} }
} }
@@ -406,21 +356,21 @@ export async function seed(options: Record<string, boolean>): Promise<void> {
u["roles"] = (u["roles"] || []).join(","); u["roles"] = (u["roles"] || []).join(",");
u["_id"] = u["username"]; u["_id"] = u["username"];
delete u["username"]; delete u["username"];
proms.push(db.putUser(u["_id"], u)); proms.push(putUser(u["_id"], u));
} }
} }
if (resources["provisions"]) { if (resources["provisions"]) {
for (const p of resources["provisions"]) for (const p of resources["provisions"])
proms.push(db.putProvision(p["_id"], p)); proms.push(putProvision(p["_id"], p));
} }
if (resources["presets"]) if (resources["presets"])
for (const p of resources["presets"]) proms.push(db.putPreset(p["_id"], p)); for (const p of resources["presets"]) proms.push(putPreset(p["_id"], p));
if (resources["config"]) if (resources["config"])
for (const c of resources["config"]) proms.push(db.putConfig(c["_id"], c)); for (const c of resources["config"]) proms.push(putConfig(c["_id"], c));
await proms; await proms;
return del("presets_hash"); await Promise.all([del("ui-local-cache-hash"), del("cwmp-local-cache-hash")]);
} }
+1 -20
View File
@@ -1,28 +1,9 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
interface Instance { interface Instance {
[name: string]: string; [name: string]: string;
} }
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();
+49 -526
View File
@@ -1,550 +1,73 @@
/** import { setTimeoutPromise } from "./util.ts";
* Copyright 2013-2019 GenieACS Inc. import { get, set } from "./cache.ts";
* import { acquireLock, releaseLock } from "./lock.ts";
* 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 vm from "vm";
import * as crypto from "crypto";
import * as config from "./config";
import * as db from "./db";
import * as cache from "./cache";
import { convertOldPrecondition } from "./mongodb-functions";
import * as logger from "./logger";
import * as scheduling from "./scheduling";
import * as expression from "./common/expression";
import { parse } from "./common/expression-parser";
import {
Preset,
Expression,
Provisions,
VirtualParameters,
Files,
Users,
Permissions,
Config,
UiConfig,
} from "./types";
interface Snapshot {
presets: Preset[];
provisions: Provisions;
virtualParameters: VirtualParameters;
files: Files;
permissions: Permissions;
users: Users;
config: Config;
ui: UiConfig;
}
const REFRESH = 5000; const REFRESH = 5000;
const EVICT_TIMEOUT = 120000; const EVICT_TIMEOUT = 120000;
const snapshots = new Map<string, Snapshot>(); export class LocalCache<T> {
let currentSnapshot: string = null; private nextRefresh = 1;
let nextRefresh = 1; private currentRevision: string = null;
private snapshots: Map<string, T> = new Map();
function computeHash(snapshot): string { constructor(
// MD5 hash for presets, provisions, virtual parameters for detecting changes private cacheKey: string,
const h = crypto.createHash("md5"); private callback: () => Promise<[string, T]>,
for (const p of snapshot.presets) { ) {}
h.update(JSON.stringify(p.name));
h.update(JSON.stringify(p.channel)); async getRevision(): Promise<string> {
h.update(JSON.stringify(p.schedule)); if (Date.now() > this.nextRefresh) await this.refresh();
h.update(JSON.stringify(p.events)); return this.currentRevision;
h.update(JSON.stringify(p.precondition));
h.update(JSON.stringify(p.provisions));
} }
let keys; hasRevision(revision: string): boolean {
return this.snapshots.has(revision);
keys = Object.keys(snapshot.provisions).sort();
h.update(JSON.stringify(keys));
for (const k of keys) h.update(snapshot.provisions[k].md5);
keys = Object.keys(snapshot.virtualParameters).sort();
h.update(JSON.stringify(keys));
for (const k of keys) h.update(snapshot.virtualParameters[k].md5);
keys = Object.keys(snapshot.config).sort();
h.update(JSON.stringify(keys));
for (const k of keys) h.update(JSON.stringify(snapshot.config[k]));
keys = Object.keys(snapshot.files).sort();
h.update(JSON.stringify(keys));
for (const k of keys) h.update(JSON.stringify(snapshot.files[k]));
keys = Object.keys(snapshot.users).sort();
h.update(JSON.stringify(keys));
for (const k of keys) h.update(JSON.stringify(snapshot.users[k]));
const roles = Object.keys(snapshot.permissions).sort();
h.update(JSON.stringify(roles));
for (const r of roles) {
const levels = Object.keys(snapshot.permissions[r]).sort();
h.update(JSON.stringify(levels));
for (const l of levels) {
keys = Object.keys(snapshot.permissions[r][l]).sort();
h.update(JSON.stringify(keys));
for (const k of keys)
h.update(JSON.stringify(snapshot.permissions[r][l][k]));
}
} }
return h.digest("hex"); get(revision: string): T {
} const snapshot = this.snapshots.get(revision);
if (!snapshot) throw new Error("Cache snapshot does not exist");
function flattenObject( return snapshot;
src: Record<string, unknown>,
prefix = "",
dst = {}
): Record<string, unknown> {
for (const k of Object.keys(src)) {
const v = src[k];
if (typeof v === "object" && !Array.isArray(v))
flattenObject(v as Record<string, unknown>, `${prefix}${k}.`, dst);
else dst[`${prefix}${k}`] = v;
} }
return dst;
}
async function fetchPresets(): Promise<Preset[]> { async refresh(): Promise<void> {
const res = await db.getPresets(); if (!this.nextRefresh) {
let objects = await db.getObjects(); await setTimeoutPromise(20);
await this.refresh();
objects = objects.map((obj) => { return;
// Flatten object
obj = flattenObject(obj);
// If no keys are defined, consider all parameters as keys to keep the
// same behavior from v1.0
if (!obj["_keys"]?.length)
obj["_keys"] = Object.keys(obj).filter((k) => !k.startsWith("_"));
return obj;
});
res.sort((a, b) => {
if (a["weight"] === b["weight"])
return a["_id"] > b["_id"] ? 1 : a["_id"] < b["_id"] ? -1 : 0;
else return a["weight"] - b["weight"];
});
const presets = [] as Preset[];
for (const preset of res) {
let schedule: { md5: string; duration: number; schedule: any } = null;
if (preset["schedule"]) {
const parts = preset["schedule"].trim().split(/\s+/);
schedule = {
md5: crypto.createHash("md5").update(preset["schedule"]).digest("hex"),
duration: null,
schedule: null,
};
try {
schedule.duration = +parts.shift() * 1000;
schedule.schedule = scheduling.parseCron(parts.join(" "));
} catch (err) {
logger.warn({
message: "Invalid preset schedule",
preset: preset["_id"],
schedule: preset["schedule"],
});
schedule.schedule = false;
}
} }
const events = preset["events"] || {}; const now = Date.now();
let precondition = true as Expression; if (now < this.nextRefresh) return;
if (preset["precondition"]) { this.nextRefresh = 0;
try {
precondition = parse(preset["precondition"]);
} catch (error) {
precondition = convertOldPrecondition(
JSON.parse(preset["precondition"])
);
}
// Simplify expression const dbHash = await get(this.cacheKey);
precondition = expression.evaluate(precondition);
if (this.currentRevision && dbHash === this.currentRevision) {
this.nextRefresh = now + (REFRESH - (now % REFRESH));
return;
} }
const _provisions = preset["provisions"] || []; const lockToken = await acquireLock(this.cacheKey, 5000);
// Generate provisions from the old configuration format const [hash, snapshot] = await this.callback();
for (const c of preset["configurations"]) {
switch (c.type) {
case "age":
_provisions.push(["refresh", c.name, +c.age]);
break;
case "value": if (this.currentRevision) {
_provisions.push(["value", c.name, c.value]); const r = this.currentRevision;
break; const s = this.snapshots.get(r);
setTimeout(() => {
case "add_tag": if (this.snapshots.get(r) === s) this.snapshots.delete(r);
_provisions.push(["tag", c.tag, true]); }, EVICT_TIMEOUT).unref();
break;
case "delete_tag":
_provisions.push(["tag", c.tag, false]);
break;
case "provision":
_provisions.push([c.name].concat(c.args || []));
break;
case "add_object":
for (const obj of objects) {
if (obj["_id"] === c.object) {
const alias = obj["_keys"]
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push(["instances", p, 1]);
for (const k in obj) {
if (!k.startsWith("_") && !(obj["_keys"].indexOf(k) !== -1))
_provisions.push(["value", `${p}.${k}`, obj[k]]);
}
}
}
break;
case "delete_object":
for (const obj of objects) {
if (obj["_id"] === c.object) {
const alias = obj["_keys"]
.map((k) => `${k}:${JSON.stringify(obj[k])}`)
.join(",");
const p = `${c.name}.[${alias}]`;
_provisions.push(["instances", p, 0]);
}
}
break;
default:
throw new Error(`Unknown configuration type ${c.type}`);
}
} }
presets.push({ this.currentRevision = hash;
name: preset["_id"], this.snapshots.set(hash, snapshot);
channel: (preset["channel"] as string) || "default",
schedule: schedule,
events: events,
precondition: precondition,
provisions: _provisions,
});
}
return presets; if (lockToken) {
} if (hash !== dbHash) await set(this.cacheKey, hash, 300);
await releaseLock(this.cacheKey, lockToken);
async function fetchProvisions(): Promise<Provisions> {
const res = await db.getProvisions();
const provisions = {};
for (const r of res) {
provisions[r["_id"]] = {};
provisions[r["_id"]].md5 = crypto
.createHash("md5")
.update(r["script"])
.digest("hex");
provisions[r["_id"]].script = new vm.Script(
`"use strict";(function(){\n${r["script"]}\n})();`,
{ filename: r["_id"], lineOffset: -1, timeout: 50 }
);
}
return provisions;
}
async function fetchVirtualParameters(): Promise<VirtualParameters> {
const res = await db.getVirtualParameters();
const virtualParameters = {};
for (const r of res) {
virtualParameters[r["_id"]] = {};
virtualParameters[r["_id"]].md5 = crypto
.createHash("md5")
.update(r["script"])
.digest("hex");
virtualParameters[r["_id"]].script = new vm.Script(
`"use strict";(function(){\n${r["script"]}\n})();`,
{ filename: r["_id"], lineOffset: -1, timeout: 50 }
);
}
return virtualParameters;
}
async function fetchPermissions(): Promise<Permissions> {
const perms = await db.getPermissions();
const permissions: Permissions = {};
for (const p of perms) {
if (!permissions[p.role]) permissions[p.role] = {};
if (!permissions[p.role][p.access]) permissions[p.role][p.access] = {};
permissions[p.role][p.access][p.resource] = {
access: p.access,
filter: parse(p.filter || "true"),
};
if (p.validate)
permissions[p.role][p.access][p.resource].validate = parse(p.validate);
}
return permissions;
}
async function fetchFiles(): Promise<Files> {
const res = await db.getFiles();
const files = {};
for (const r of res) {
const id = r["filename"] || r["_id"].toString();
files[id] = {};
files[id].length = r["length"];
}
return files;
}
async function fetchUsers(): Promise<Users> {
const _users = await db.getUsers();
const users = {};
for (const user of _users) {
users[user._id] = {
password: user.password,
salt: user.salt,
roles: user.roles.split(",").map((s) => s.trim()),
};
}
return users;
}
async function fetchConfig(): Promise<[Config, UiConfig]> {
const conf = await db.getConfig();
conf.sort((a, b) => (a.id > b.id ? 1 : a.id < b.id ? -1 : 0));
const ui = {
filters: {},
device: {},
index: {},
overview: {},
pageSize: null,
};
const _config = {};
for (const c of conf) {
// Evaluate expressions to simplify them
const val = expression.evaluate(c.value);
_config[c.id] = val;
if (c.id.startsWith("ui.")) {
const keys = c.id.split(".");
if (!(keys[1] in ui)) continue;
// remove the first key(ui)
keys.shift();
let ref = ui;
while (keys.length > 1) {
const k = keys.shift();
if (ref[k] == null || typeof ref[k] !== "object") ref[k] = {};
ref = ref[k];
}
ref[keys[0]] = val;
} }
this.nextRefresh = now + (REFRESH - (now % REFRESH));
} }
return [_config, ui];
}
async function refresh(): Promise<void> {
if (!nextRefresh) {
await new Promise((resolve) => setTimeout(resolve, 20));
return refresh();
}
const now = Date.now();
if (now < nextRefresh) return;
nextRefresh = 0;
const dbHash = await cache.get("presets_hash");
if (currentSnapshot && dbHash === currentSnapshot) {
nextRefresh = now + (REFRESH - (now % REFRESH));
return;
}
const lockToken = await cache.acquireLock("presets_hash_lock", 5000);
const res = await Promise.all([
fetchPresets(),
fetchProvisions(),
fetchVirtualParameters(),
fetchFiles(),
fetchPermissions(),
fetchUsers(),
fetchConfig(),
]);
const snapshot = {
presets: res[0],
provisions: res[1],
virtualParameters: res[2],
files: res[3],
permissions: res[4],
users: res[5],
config: res[6][0],
ui: res[6][1],
};
if (currentSnapshot) {
const h = currentSnapshot;
const s = snapshots.get(h);
setTimeout(() => {
if (snapshots.get(h) === s) snapshots.delete(h);
}, EVICT_TIMEOUT).unref();
}
currentSnapshot = computeHash(snapshot);
snapshots.set(currentSnapshot, snapshot);
if (lockToken) {
if (currentSnapshot !== dbHash)
await cache.set("presets_hash", currentSnapshot, 300);
await cache.releaseLock("presets_hash_lock", lockToken);
}
nextRefresh = now + (REFRESH - (now % REFRESH));
}
export async function getCurrentSnapshot(): Promise<string> {
if (Date.now() > nextRefresh) await refresh();
return currentSnapshot;
}
export function hasSnapshot(hash: string): boolean {
return snapshots.has(hash);
}
export function getPresets(snapshotKey: string): Preset[] {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.presets;
}
export function getProvisions(snapshotKey: string): Provisions {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.provisions;
}
export function getVirtualParameters(snapshotKey: string): VirtualParameters {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.virtualParameters;
}
export function getFiles(snapshotKey: string): Files {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.files;
}
export function getConfig(
snapshotKey: string,
key: string,
context: Record<string, unknown>,
now: number,
cb?: (e: Expression) => Expression
): string | number | boolean | null {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
const oldOpts = {
"cwmp.downloadTimeout": "DOWNLOAD_TIMEOUT",
"cwmp.debug": "DEBUG",
"cwmp.retryDelay": "RETRY_DELAY",
"cwmp.sessionTimeout": "SESSION_TIMEOUT",
"cwmp.connectionRequestTimeout": "CONNECTION_REQUEST_TIMEOUT",
"cwmp.gpnNextLevel": "GPN_NEXT_LEVEL",
"cwmp.gpvBatchSize": "GPV_BATCH_SIZE",
"cwmp.cookiesPath": "COOKIES_PATH",
"cwmp.datetimeMilliseconds": "DATETIME_MILLISECONDS",
"cwmp.booleanLiteral": "BOOLEAN_LITERAL",
"cwmp.connectionRequestAllowBasicAuth":
"CONNECTION_REQUEST_ALLOW_BASIC_AUTH",
"cwmp.maxCommitIterations": "MAX_COMMIT_ITERATIONS",
"cwmp.deviceOnlineThreshold": "DEVICE_ONLINE_THRESHOLD",
"cwmp.udpConnectionRequestPort": "UDP_CONNECTION_REQUEST_PORT",
};
if (!(key in snapshot.config)) {
if (key in oldOpts) {
let id;
if (context?.["id"]) {
id = context["id"];
} else if (cb) {
id = cb(["PARAM", "DeviceID.ID"]);
if (Array.isArray(id)) id = null;
}
return config.get(oldOpts[key], id);
}
return null;
}
const v = expression.evaluate(snapshot.config[key], context, now, cb);
return Array.isArray(v) ? null : v;
}
export function getConfigExpression(
snapshotKey: string,
key: string
): Expression {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.config[key];
}
export function getUsers(snapshotKey: string): Users {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.users;
}
export function getPermissions(snapshotKey: string): Permissions {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.permissions;
}
export function getUiConfig(snapshotKey: string): UiConfig {
const snapshot = snapshots.get(snapshotKey);
if (!snapshot) throw new Error("Cache snapshot does not exist");
return snapshot.ui;
} }
+50
View File
@@ -0,0 +1,50 @@
import { collections } from "./db/db.ts";
const CLOCK_SKEW_TOLERANCE = 30000;
export async function acquireLock(
lockName: string,
ttl: number,
timeout = 0,
token = Math.random().toString(36).slice(2),
): Promise<string> {
try {
const now = Date.now();
const r = await collections.locks.findOneAndUpdate(
{ _id: lockName, value: token },
{
$set: {
expire: new Date(now + ttl + CLOCK_SKEW_TOLERANCE),
},
$currentDate: { timestamp: true },
},
{ upsert: true, returnDocument: "after" },
);
if (Math.abs(r.value.timestamp.getTime() - now) > CLOCK_SKEW_TOLERANCE)
throw new Error("Database clock skew too great");
} catch (err) {
if (err.code !== 11000) throw err;
if (!(timeout > 0)) return null;
const w = 50 + Math.random() * 50;
await new Promise((resolve) => setTimeout(resolve, w));
return acquireLock(lockName, ttl, timeout - w, token);
}
return token;
}
export async function releaseLock(
lockName: string,
token: string,
): Promise<void> {
const res = await collections.locks.deleteOne({
_id: lockName,
value: token,
});
if (res.deletedCount !== 1) throw new Error("Lock expired");
}
export async function getToken(lockName: string): Promise<string> {
const res = await collections.locks.findOne({ _id: lockName });
return res?.value;
}
+10 -30
View File
@@ -1,27 +1,7 @@
/** import * as fs from "node:fs";
* Copyright 2013-2019 GenieACS Inc. import * as os from "node:os";
* import * as config from "./config.ts";
* This file is part of GenieACS. import { getRequestOrigin } from "./forwarded.ts";
*
* 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 fs from "fs";
import * as os from "os";
import * as config from "./config";
import { getRequestOrigin } from "./forwarded";
import { import {
SessionContext, SessionContext,
AcsRequest, AcsRequest,
@@ -29,7 +9,7 @@ import {
CpeFault, CpeFault,
InformRequest, InformRequest,
Fault, Fault,
} from "./types"; } from "./types.ts";
const REOPEN_EVERY = 60000; const REOPEN_EVERY = 60000;
@@ -156,13 +136,13 @@ export function close(): void {
} }
export function flatten( export function flatten(
details: Record<string, unknown> details: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
if (details.sessionContext) { if (details.sessionContext) {
const sessionContext = details.sessionContext as SessionContext; const sessionContext = details.sessionContext as SessionContext;
details.deviceId = sessionContext.deviceId; details.deviceId = sessionContext.deviceId;
details.remoteAddress = getRequestOrigin( details.remoteAddress = getRequestOrigin(
sessionContext.httpRequest sessionContext.httpRequest,
).remoteAddress; ).remoteAddress;
delete details.sessionContext; delete details.sessionContext;
} }
@@ -220,7 +200,7 @@ export function flatten(
// For genieacs-ui // For genieacs-ui
if (details.context) { if (details.context) {
details.remoteAddress = getRequestOrigin( details.remoteAddress = getRequestOrigin(
details.context["req"] details.context["req"],
).remoteAddress; ).remoteAddress;
if (details.context["state"].user) if (details.context["state"].user)
details.user = details.context["state"].user.username; details.user = details.context["state"].user.username;
@@ -235,7 +215,7 @@ export function flatten(
function formatJson( function formatJson(
details: Record<string, unknown>, details: Record<string, unknown>,
systemd: boolean systemd: boolean,
): string { ): string {
if (systemd) { if (systemd) {
let severity = ""; let severity = "";
@@ -251,7 +231,7 @@ function formatJson(
function formatSimple( function formatSimple(
details: Record<string, unknown>, details: Record<string, unknown>,
systemd: boolean systemd: boolean,
): string { ): string {
const skip = { const skip = {
user: true, user: true,
-685
View File
@@ -1,685 +0,0 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { ObjectId } from "mongodb";
import { map, parse, stringify, parseList } from "./common/expression-parser";
import { likePatternToRegExp } from "./common/expression";
import { Expression, Fault, Task } from "./types";
import { decodeTag, encodeTag } from "./common";
const isArray = Array.isArray;
export function processDeviceFilter(filter: Expression): Expression {
return map(filter, (exp) => {
if (!isArray(exp)) return exp;
if (exp[0] === "PARAM") {
const p = exp[1];
if (p === "DeviceID.ID") return ["PARAM", "_id"];
else if (p === "DeviceID") return ["PARAM", "_deviceId"];
else if (p.startsWith("DeviceID."))
return ["PARAM", "_deviceId._" + p.slice(9)];
else if (p === "Events.Inform") return ["PARAM", "_lastInform"];
else if (p === "Events.Registered") return ["PARAM", "_registered"];
else if (p === "Events.0_BOOTSTRAP") return ["PARAM", "_lastBootstrap"];
else if (p === "Events.1_BOOT") return ["PARAM", "_lastBoot"];
} else if (
isArray(exp[1]) &&
exp[1][0] === "PARAM" &&
exp[1][1].startsWith("Tags.")
) {
const t = decodeTag(exp[1][1].slice(5));
if (exp[0] === "IS NULL") return ["_tags", t, false];
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];
} else if (
[
"=",
"<>",
">",
">=",
"<",
"<=",
"LIKE",
"NOT LIKE",
"IS NULL",
"IS NOT NULL",
].includes(exp[0])
) {
let e = map(exp, (ee) => {
if (
isArray(ee) &&
ee[0] === "PARAM" &&
typeof ee[1] === "string" &&
!ee[1].startsWith("_")
)
return ["PARAM", `${ee[1]}._value`];
return ee;
});
if (typeof e[2] === "number") {
const alt = (e as any[]).slice();
alt[2] = new Date(e[2]);
if (exp[0] === "<>" || exp[0] === "NOT LIKE") e = ["AND", e, alt];
else e = ["OR", e, alt];
}
return e;
}
return exp;
});
}
export function processTasksFilter(filter: Expression): Expression {
return map(filter, (exp) => {
if (!isArray(exp)) return exp;
if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) {
const e = exp.slice();
if (e[1][0] === "PARAM" && e[1][1] === "_id") e[2] = new ObjectId(e[2]);
else if (e[1][0] === "PARAM" && e[1][1] === "timestamp")
e[2] = new Date(e[2]);
else if (e[1][0] === "PARAM" && e[1][1] === "expiry")
e[2] = new Date(e[2]);
return e;
}
return exp;
});
}
export function processFaultsFilter(filter: Expression): Expression {
return map(filter, (exp) => {
if (!isArray(exp)) return exp;
if (["=", "<>", ">", ">=", "<", "<="].includes(exp[0])) {
const e = exp.slice();
if (e[1][0] === "PARAM" && e[1][1] === "timestamp") e[2] = new Date(e[2]);
else if (e[1][0] === "PARAM" && e[1][1] === "expiry")
e[2] = new Date(e[2]);
return e;
}
return exp;
});
}
export function filterToMongoQuery(exp: Expression): Record<string, unknown> {
function recursive(filter, negate): Record<string, unknown> {
const op = filter[0];
if (op === "AND") {
filter = filter.filter((f) => f !== true);
if (filter.length === 2) return recursive(filter[1], negate);
}
if (op === "OR") {
filter = filter.filter((f) => f !== false);
if (filter.length === 2) return recursive(filter[1], negate);
}
if ((!negate && op === "AND") || (negate && op === "OR"))
return { $and: filter.slice(1).map((f) => recursive(f, negate)) };
if ((!negate && op === "OR") || (negate && op === "AND"))
return { $or: filter.slice(1).map((f) => recursive(f, negate)) };
if (op === "NOT") return recursive(filter[1], !negate);
if (isArray(filter[2]))
throw new Error(`Invalid RHS operand of ${op} clause`);
if (op === "LIKE" || op === "NOT LIKE") {
if (op === "NOT LIKE") negate = !negate;
let param;
let flags;
if (!Array.isArray(filter[1]))
throw new Error(`Invalid LHS operand of ${op} clause`);
if (filter[1][0] === "FUNC" && filter[1][1] === "UPPER") {
if (filter[2] !== filter[2].toUpperCase())
throw new Error(`Invalid RHS operand of ${op} clause`);
param = filter[1][2][1];
flags = "i";
} else if (filter[1][0] === "FUNC" && filter[1][1] === "LOWER") {
if (filter[2] !== filter[2].toLowerCase())
throw new Error(`Invalid RHS operand of ${op} clause`);
param = filter[1][2][1];
flags = "i";
} else if (filter[1][0] === "PARAM") {
param = filter[1][1];
flags = "";
} else {
throw new Error(`Invalid LHS operand of ${op} clause`);
}
const r = likePatternToRegExp(filter[2], filter[3], flags);
if (negate) return { [param]: { $nin: [r, null] } };
else return { [param]: r };
}
if (op === "_tags") {
let t = filter[2];
if (negate) t = !t;
if (t) return { _tags: filter[1] };
else return { _tags: { $ne: filter[1] } };
}
if (!isArray(filter[1]) || filter[1][0] !== "PARAM")
throw new Error(`Invalid LHS operand of ${op} clause`);
if (op === "IS NULL") {
const p = filter[1][1];
return { [p]: null };
}
if (op === "IS NOT NULL") {
const p = filter[1][1];
return { [p]: { $ne: null } };
}
if (isArray(filter[2]) || filter[2] == null)
throw new Error(`Invalid RHS operand of ${op} clause`);
if (op === "=") {
const p = filter[1][1];
const v = filter[2];
if (!negate) return { [p]: v };
else return { [p]: { $nin: [v, null] } };
}
if (op === "<>") {
const p = filter[1][1];
const v = filter[2];
if (negate) return { [p]: v };
else return { [p]: { $nin: [v, null] } };
}
if (op === ">") {
const p = filter[1][1];
const v = filter[2];
if (negate) return { [p]: { $lte: v } };
else return { [p]: { $gt: v } };
}
if (op === ">=") {
const p = filter[1][1];
const v = filter[2];
if (negate) return { [p]: { $lt: v } };
else return { [p]: { $gte: v } };
}
if (op === "<") {
const p = filter[1][1];
const v = filter[2];
if (negate) return { [p]: { $gte: v } };
else return { [p]: { $lt: v } };
}
if (op === "<=") {
const p = filter[1][1];
const v = filter[2];
if (negate) return { [p]: { $gt: v } };
else return { [p]: { $lte: v } };
}
throw new Error(`Unrecognized operator ${op}`);
}
if (!isArray(exp)) {
if (exp === true) return {};
throw new Error("Primitives are not valid queries");
}
return recursive(exp, false);
}
export function processDeviceProjection(
projection: Record<string, 1>
): Record<string, 1> {
if (!projection) return projection;
const p = {};
for (const [k, v] of Object.entries(projection)) {
if (k === "DeviceID.ID") {
p["_id"] = 1;
} else if (k.startsWith("DeviceID")) {
p["_deviceId._SerialNumber"] = v;
p["_deviceId._OUI"] = v;
p["_deviceId._ProductClass"] = v;
p["_deviceId._Manufacturer"] = v;
} else if (k.startsWith("Tags")) {
p["_tags"] = v;
} else if (k.startsWith("Events")) {
p["_lastInform"] = v;
p["_registered"] = v;
p["_lastBoot"] = v;
p["_lastBootstrap"] = v;
} else {
p[k] = v;
}
}
return p;
}
export function processDeviceSort(
sort: Record<string, number>
): Record<string, number> {
if (!sort) return sort;
const s = {};
for (const [k, v] of Object.entries(sort)) {
if (k === "DeviceID.ID") s["_id"] = v;
else if (k.startsWith("DeviceID.")) s[`_deviceId._${k.slice(9)}`] = v;
else if (k === "Events.Inform") s["_lastInform"] = v;
else if (k === "Events.Registered") s["_registered"] = v;
else if (k === "Events.1_BOOT") s["_lastBoot"] = v;
else if (k === "Events.0_BOOTSTRAP") s["_lastBootstrap"] = v;
else s[`${k}._value`] = v;
}
return s;
}
function parseDate(d: Date): number | string {
const n = +d;
return isNaN(n) ? "" + d : n;
}
interface FlatAttributes {
object?: boolean;
objectTimestamp?: number;
writable?: boolean;
writableTimestamp?: number;
value?: [string | number | boolean, string];
valueTimestamp?: number;
notification?: number;
notificationTimestamp?: number;
accessList?: string[];
accessListTimestamp?: number;
}
interface FlatDevice {
[param: string]: FlatAttributes;
}
export function flattenDevice(device: Record<string, unknown>): FlatDevice {
function recursive(
input,
root: string,
output: FlatDevice,
timestamp: number
): void {
for (const [name, tree] of Object.entries(input)) {
if (!root) {
if (name === "_lastInform") {
output["Events.Inform"] = {
value: [parseDate(tree as Date), "xsd:dateTime"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_registered") {
output["Events.Registered"] = {
value: [parseDate(tree as Date), "xsd:dateTime"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_lastBoot") {
output["Events.1_BOOT"] = {
value: [parseDate(tree as Date), "xsd:dateTime"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_lastBootstrap") {
output["Events.0_BOOTSTRAP"] = {
value: [parseDate(tree as Date), "xsd:dateTime"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_id") {
output["DeviceID.ID"] = {
value: [tree as string, "xsd:string"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_deviceId") {
output["DeviceID.Manufacturer"] = {
value: [tree["_Manufacturer"], "xsd:string"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
output["DeviceID.OUI"] = {
value: [tree["_OUI"], "xsd:string"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
output["DeviceID.ProductClass"] = {
value: [tree["_ProductClass"], "xsd:string"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
output["DeviceID.SerialNumber"] = {
value: [tree["_SerialNumber"], "xsd:string"],
valueTimestamp: timestamp,
writable: false,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
} else if (name === "_tags") {
output["Tags"] = {
writable: false,
writableTimestamp: timestamp,
object: true,
objectTimestamp: timestamp,
};
for (const t of tree as string[]) {
output[`Tags.${encodeTag(t)}`] = {
value: [true, "xsd:boolean"],
valueTimestamp: timestamp,
writable: true,
writableTimestamp: timestamp,
object: false,
objectTimestamp: timestamp,
};
}
}
}
if (name.startsWith("_")) continue;
let childrenTimestamp = timestamp;
if (!root) childrenTimestamp = +(input["_timestamp"] || 1);
else if (+input["_timestamp"] > timestamp)
childrenTimestamp = +input["_timestamp"];
const attrs: FlatAttributes = {};
if (tree["_value"] != null) {
attrs.value = [
tree["_value"] instanceof Date ? +tree["_value"] : tree["_value"],
tree["_type"],
];
attrs.valueTimestamp = +(tree["_timestamp"] || childrenTimestamp);
attrs.object = false;
attrs.objectTimestamp = childrenTimestamp;
} else if (tree["_object"] != null) {
attrs.object = tree["_object"];
attrs.objectTimestamp = childrenTimestamp;
}
if (tree["_writable"] != null) {
attrs.writable = tree["_writable"];
attrs.writableTimestamp = childrenTimestamp;
}
if (tree["_notification"] != null) {
attrs.notification = tree["_notification"];
attrs.notificationTimestamp = +tree["_attributesTimestamp"] || 1;
}
if (tree["_accessList"] != null) {
attrs.accessList = tree["_accessList"];
attrs.accessListTimestamp = +tree["_attributesTimestamp"] || 1;
}
const r = root ? `${root}.${name}` : name;
output[r] = attrs;
if (attrs.object || tree["object"] == null)
recursive(tree, r, output, childrenTimestamp);
}
}
const newDevice: FlatDevice = {};
const timestamp = new Date((device["_lastInform"] as Date) || 1).getTime();
recursive(device, "", newDevice, timestamp);
return newDevice;
}
export function flattenFault(fault: unknown): Fault {
const f = Object.assign({}, fault) as Fault;
if (f.timestamp) f.timestamp = +f.timestamp;
if (f["expiry"]) f["expiry"] = +f["expiry"];
return f as Fault;
}
export function flattenTask(task: unknown): Task {
const t = Object.assign({}, task) as Task;
t._id = "" + t._id;
if (t["timestamp"]) t["timestamp"] = +t["timestamp"];
if (t.expiry) t.expiry = +t.expiry;
return t;
}
export function convertOldPrecondition(
query: Record<string, unknown>
): Expression {
function recursive(_query): Expression {
const expressions: Expression[] = [];
for (const [k, v] of Object.entries(_query)) {
if (k[0] === "$") {
if (k === "$and") {
const and: Expression = ["AND"];
for (const vv of Object.values(v)) and.push(recursive(vv));
expressions.push(and);
} else if (k === "$or") {
const or: Expression = ["OR"];
for (const vv of Object.values(v)) or.push(recursive(vv));
expressions.push(or);
} else {
throw new Error(`Operator ${k} not supported`);
}
} else if (k === "_tags") {
if (typeof v === "object") {
if (isArray(v)) throw new Error(`Invalid type`);
const conjs: Expression[] = [];
for (const [op, val] of Object.entries(v)) {
if (op === "$ne") {
if (typeof v["$ne"] !== "string")
throw new Error("Only string values are allowed for _tags");
conjs.push(["IS NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
} else if (op === "$eq") {
if (typeof v["$eq"] !== "string")
throw new Error("Only string values are allowed for _tags");
conjs.push(["IS NOT NULL", ["PARAM", `Tags.${encodeTag(val)}`]]);
} else {
throw new Error(`Invalid tag query`);
}
}
if (conjs.length === 1) expressions.push(conjs[0]);
else if (conjs.length > 1) expressions.push(["AND", ...conjs]);
} else {
expressions.push([
"IS NOT NULL",
["PARAM", `Tags.${encodeTag(v as string)}`],
]);
}
} else if (k.startsWith("Tags.")) {
let exists: boolean;
if (typeof v === "boolean") exists = v;
else if (v.hasOwnProperty("$eq")) exists = !!v["$eq"];
else if (v.hasOwnProperty("$ne")) exists = !v["$ne"];
else if (v.hasOwnProperty("$exists")) exists = !!v["$exists"];
else throw new Error(`Invalid tag query`);
expressions.push([exists ? "IS NOT NULL" : "IS NULL", ["PARAM", k]]);
} else if (typeof v === "object") {
if (isArray(v)) throw new Error(`Invalid type`);
const exps: Expression[] = [];
for (const [kk, vv] of Object.entries(v)) {
if (kk === "$eq") {
exps.push(["=", ["PARAM", k], vv]);
} else if (kk === "$ne") {
exps.push([
"OR",
["<>", ["PARAM", k], vv],
["IS NULL", ["PARAM", k]],
]);
} else if (kk === "$lt") {
exps.push(["<", ["PARAM", k], vv]);
} else if (kk === "$lte") {
exps.push(["<=", ["PARAM", k], vv]);
} else if (kk === "$gt") {
exps.push([">", ["PARAM", k], vv]);
} else if (kk === "$gte") {
exps.push([">=", ["PARAM", k], vv]);
} else {
throw new Error(`Operator ${kk} not supported`);
}
if (!["string", "number", "boolean"].includes(typeof vv))
throw new Error(`Invalid value for ${kk} operator`);
}
if (exps.length === 1) {
expressions.push(exps[0]);
} else if (exps.length > 1) {
const and: Expression = ["AND"];
expressions.push(and.concat(exps));
}
} else {
expressions.push(["=", ["PARAM", k], v]);
}
}
if (expressions.length === 1) return expressions[0];
if (expressions.length === 0) return true;
return ["AND", ...expressions];
}
// empty filter
if (!Object.keys(query).length) return true;
return recursive(query);
}
export function flattenPreset(
preset: Record<string, unknown>
): Record<string, unknown> {
const p = Object.assign({}, preset);
if (p.precondition) {
try {
// Try parse to check expression validity
parse(p.precondition as string);
} catch (error) {
p.precondition = convertOldPrecondition(
JSON.parse(p.precondition as string)
);
p.precondition = (p.precondition as string).length
? stringify(p.precondition as Expression)
: "";
}
}
if (p.events) {
const e = [];
for (const [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`);
p.events = e.join(", ");
}
const provision = p.configurations[0];
if (
(p.configurations as any[]).length === 1 &&
provision.type === "provision" &&
provision.name &&
provision.name.length
) {
p.provision = provision.name;
p.provisionArgs = provision.args
? provision.args.map((a) => stringify(a)).join(", ")
: "";
}
delete p.configurations;
return p;
}
export function flattenFile(
file: Record<string, unknown>
): Record<string, unknown> {
const f = {};
f["_id"] = file["_id"];
if (file.metadata) {
f["metadata.fileType"] = file["metadata"]["fileType"] || "";
f["metadata.oui"] = file["metadata"]["oui"] || "";
f["metadata.productClass"] = file["metadata"]["productClass"] || "";
f["metadata.version"] = file["metadata"]["version"] || "";
}
return f;
}
export function preProcessPreset(
data: Record<string, unknown>
): Record<string, unknown> {
const preset = Object.assign({}, data);
if (!preset.precondition) preset.precondition = "";
// Try parse to check expression validity
parse(preset.precondition as string);
preset.weight = parseInt(preset.weight as string) || 0;
const events = {};
if (preset.events) {
for (let e of (preset.events as string).split(",")) {
let v = true;
e = e.trim();
if (e.startsWith("-")) {
v = false;
e = e.slice(1).trim();
}
if (e) events[e] = v;
}
}
preset.events = events;
if (!preset.provision) throw new Error("Invalid preset provision");
const configuration = {
type: "provision",
name: preset.provision,
args: null,
};
if (preset.provisionArgs)
configuration.args = parseList(preset.provisionArgs as string);
delete preset.provision;
delete preset.provisionArgs;
preset.configurations = [configuration];
return preset;
}
+630 -811
View File
File diff suppressed because it is too large. Load diff
+4 -23
View File
@@ -1,25 +1,6 @@
/** import { platform } from "node:os";
* Copyright 2013-2019 GenieACS Inc. import { exec } from "node:child_process";
* import { domainToASCII } from "node:url";
* 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 { platform } from "os";
import { exec } from "child_process";
import { domainToASCII } from "url";
export interface PingResult { export interface PingResult {
packetsTransmitted: number; packetsTransmitted: number;
@@ -88,7 +69,7 @@ export function parsePing(osPlatform: string, stdout: string): PingResult {
export function ping( export function ping(
host: string, host: string,
callback: (err: Error, res?: PingResult, stdout?: string) => void callback: (err: Error, res?: PingResult, stdout?: string) => void,
): 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
+3 -22
View File
@@ -1,22 +1,3 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
function isObject(obj: any): boolean { function isObject(obj: any): boolean {
return Object.prototype.toString.call(obj) === "[object Object]"; return Object.prototype.toString.call(obj) === "[object Object]";
} }
@@ -118,7 +99,7 @@ function permute(param, val): any[] {
} }
export function expand( export function expand(
query: Record<string, unknown> query: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
const newQuery = {}; const newQuery = {};
for (const [k, v] of Object.entries(query)) { for (const [k, v] of Object.entries(query)) {
@@ -132,7 +113,7 @@ export function expand(
if (v && (v["$ne"] != null || v["$not"] != null)) { if (v && (v["$ne"] != null || v["$not"] != null)) {
if (Object.keys(v).length > 1) if (Object.keys(v).length > 1)
throw new Error("Cannot mix $ne or $not with other operators"); throw new Error("Cannot mix $ne or $not with other operators");
for (const c of conditions) newQuery["$an"].push(c); for (const c of conditions) newQuery["$and"].push(c);
} else { } else {
newQuery["$and"].push({ $or: conditions }); newQuery["$and"].push({ $or: conditions });
} }
@@ -147,7 +128,7 @@ export function expand(
export function sanitizeQueryTypes( export function sanitizeQueryTypes(
query: Record<string, unknown>, query: Record<string, unknown>,
types: Record<string, (v: unknown) => unknown> types: Record<string, (v: unknown) => unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
for (const [k, v] of Object.entries(query)) { for (const [k, v] of Object.entries(query)) {
if (k[0] === "$") { if (k[0] === "$") {
+24 -43
View File
@@ -1,30 +1,11 @@
/** import * as vm from "node:vm";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import * as vm from "vm";
import seedrandom from "seedrandom"; import seedrandom from "seedrandom";
import * as device from "./device"; import * as device from "./device.ts";
import * as extensions from "./extensions"; import * as extensions from "./extensions.ts";
import * as logger from "./logger"; import * as logger from "./logger.ts";
import * as scheduling from "./scheduling"; import * as scheduling from "./scheduling.ts";
import Path from "./common/path"; import Path from "./common/path.ts";
import { Fault, SessionContext, ScriptResult } from "./types"; import { Fault, SessionContext, ScriptResult } from "./types.ts";
// Used for throwing to exit user script and commit // Used for throwing to exit user script and commit
const COMMIT = Symbol(); const COMMIT = Symbol();
@@ -34,7 +15,7 @@ const EXT = Symbol();
const UNDEFINED = undefined; const UNDEFINED = undefined;
const context = vm.createContext(); const context = vm.createContext(undefined, { microtaskMode: "afterEvaluate" });
let state; let state;
@@ -50,7 +31,7 @@ function runExtension(sessionContext, key, extCall): Promise<Fault> {
} }
let prom = re.get(key); let prom = re.get(key);
if (!prom) { if (prom == null) {
re.set( re.set(
key, key,
(prom = new Promise((resolve, reject) => { (prom = new Promise((resolve, reject) => {
@@ -62,7 +43,7 @@ function runExtension(sessionContext, key, extCall): Promise<Fault> {
resolve(fault); resolve(fault);
}) })
.catch(reject); .catch(reject);
})) })),
); );
} }
@@ -78,7 +59,7 @@ class SandboxDate {
number?, number?,
number?, number?,
number?, number?,
number? number?,
] ]
) { ) {
if (argumentList.length) return new Date(...argumentList); if (argumentList.length) return new Date(...argumentList);
@@ -143,7 +124,7 @@ class ParameterWrapper {
unpacked = device.unpack( unpacked = device.unpack(
state.sessionContext.deviceData, state.sessionContext.deviceData,
path, path,
state.revision state.revision,
); );
} }
@@ -151,7 +132,7 @@ class ParameterWrapper {
const attr = state.sessionContext.deviceData.attributes.get( const attr = state.sessionContext.deviceData.attributes.get(
unpacked[0], unpacked[0],
state.revision state.revision,
)[attrName]; )[attrName];
if (!attr) return UNDEFINED; if (!attr) return UNDEFINED;
@@ -170,7 +151,7 @@ class ParameterWrapper {
unpacked = device.unpack( unpacked = device.unpack(
state.sessionContext.deviceData, state.sessionContext.deviceData,
path, path,
state.revision state.revision,
); );
} }
@@ -189,7 +170,7 @@ class ParameterWrapper {
unpacked = device.unpack( unpacked = device.unpack(
state.sessionContext.deviceData, state.sessionContext.deviceData,
path, path,
state.revision state.revision,
); );
} }
@@ -207,7 +188,7 @@ class ParameterWrapper {
unpacked = device.unpack( unpacked = device.unpack(
state.sessionContext.deviceData, state.sessionContext.deviceData,
path, path,
state.revision state.revision,
); );
} }
@@ -220,7 +201,7 @@ class ParameterWrapper {
function declare( function declare(
path: string, path: string,
timestamps: { [attr: string]: number }, timestamps: { [attr: string]: number },
values: { [attr: string]: any } values: { [attr: string]: any },
): ParameterWrapper { ): ParameterWrapper {
state.uncommitted = true; state.uncommitted = true;
if (!timestamps) timestamps = {}; if (!timestamps) timestamps = {};
@@ -286,7 +267,7 @@ function commit(): void {
throw COMMIT; throw COMMIT;
} else if (state.revision > state.maxRevision + 1) { } else if (state.revision > state.maxRevision + 1) {
throw new Error( throw new Error(
"Declare function should not be called from within a try/catch block" "Declare function should not be called from within a try/catch block",
); );
} }
} }
@@ -351,12 +332,12 @@ function errorToFault(err: Error): Fault {
fault.detail["stack"] = err.stack; fault.detail["stack"] = err.stack;
// Trim the stack trace at the self-executing anonymous wrapper function // Trim the stack trace at the self-executing anonymous wrapper function
const stackTrimIndex = fault.detail["stack"].match( const stackTrimIndex = fault.detail["stack"].match(
/\s+at\s[^\s]+\s+at\s[^\s]+\s\(vm\.js.+\)/ /\s+at\s[^\s]+\s+at\s[^\s]+\s\(vm\.js.+\)/,
); );
if (stackTrimIndex) { if (stackTrimIndex) {
fault.detail["stack"] = fault.detail["stack"].slice( fault.detail["stack"] = fault.detail["stack"].slice(
0, 0,
stackTrimIndex.index stackTrimIndex.index,
); );
} }
} }
@@ -370,7 +351,7 @@ export async function run(
sessionContext: SessionContext, sessionContext: SessionContext,
startRevision: number, startRevision: number,
maxRevision: number, maxRevision: number,
extCounter = 0 extCounter = 0,
): Promise<ScriptResult> { ): Promise<ScriptResult> {
state = { state = {
sessionContext: sessionContext, sessionContext: sessionContext,
@@ -391,7 +372,7 @@ export async function run(
let ret, status; let ret, status;
try { try {
ret = script.runInContext(context, { displayErrors: false }); ret = script.runInContext(context, { displayErrors: false, timeout: 50 });
status = 0; status = 0;
} catch (err) { } catch (err) {
if (err === COMMIT) { if (err === COMMIT) {
@@ -415,7 +396,7 @@ export async function run(
await Promise.all( await Promise.all(
Object.entries(_state.extensions).map(async ([k, v]) => { Object.entries(_state.extensions).map(async ([k, v]) => {
fault = (await runExtension(_state.sessionContext, k, v)) || fault; fault = (await runExtension(_state.sessionContext, k, v)) || fault;
}) }),
); );
if (fault) { if (fault) {
@@ -435,7 +416,7 @@ export async function run(
sessionContext, sessionContext,
startRevision, startRevision,
maxRevision, maxRevision,
extCounter - _state.extCounter extCounter - _state.extCounter,
); );
} }
+3 -22
View File
@@ -1,23 +1,4 @@
/** import * as crypto from "node:crypto";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import * as crypto from "crypto";
import * as later from "@breejs/later"; import * as later from "@breejs/later";
function md532(str): number { function md532(str): number {
@@ -37,7 +18,7 @@ export function variance(deviceId: string, vrnc: number): number {
export function interval( export function interval(
timestamp: number, timestamp: number,
intrvl: number, intrvl: number,
offset = 0 offset = 0,
): number { ): number {
return Math.trunc((timestamp + offset) / intrvl) * intrvl - offset; return Math.trunc((timestamp + offset) / intrvl) * intrvl - offset;
} }
@@ -52,7 +33,7 @@ export function parseCron(cronExp: string): any {
export function cron( export function cron(
timestamp: number, timestamp: number,
schedule: unknown, schedule: unknown,
offset = 0 offset = 0,
): number[] { ): number[] {
// TODO later.js doesn't throw erorr if expression is invalid! // TODO later.js doesn't throw erorr if expression is invalid!
const ret = [0, 0]; const ret = [0, 0];
+56 -35
View File
@@ -1,31 +1,13 @@
/** import { readFileSync } from "node:fs";
* Copyright 2013-2019 GenieACS Inc. import * as http from "node:http";
* import * as https from "node:https";
* This file is part of GenieACS. import { Socket } from "node:net";
* import * as path from "node:path";
* GenieACS is free software: you can redistribute it and/or modify import { ROOT_DIR } from "./config.ts";
* 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 fs from "fs";
import * as http from "http";
import * as https from "https";
import { Socket } from "net";
import * as path from "path";
import { ROOT_DIR } from "./config";
let server: http.Server | https.Server; let server: http.Server | https.Server;
let listener: (...args) => void; let listener: http.RequestListener;
let stopping = false;
function closeServer(timeout, callback): void { function closeServer(timeout, callback): void {
if (!server) return void callback(); if (!server) return void callback();
@@ -58,6 +40,7 @@ interface ServerOptions {
ssl?: { key: string; cert: string }; ssl?: { key: string; cert: string };
timeout?: number; timeout?: number;
keepAliveTimeout?: number; keepAliveTimeout?: number;
requestTimeout?: number;
onConnection?: (socket: Socket) => void; onConnection?: (socket: Socket) => void;
onClientError?: (err: Error, socket: Socket) => void; onClientError?: (err: Error, socket: Socket) => void;
} }
@@ -73,20 +56,54 @@ interface SocketEndpoint {
// Save this info as they're not accessible after a socket has been closed // Save this info as they're not accessible after a socket has been closed
const socketEndpoints: WeakMap<Socket, SocketEndpoint> = new WeakMap(); const socketEndpoints: WeakMap<Socket, SocketEndpoint> = new WeakMap();
type Promisify<T extends (...args: any) => any> = (
...args: Parameters<T>
) => Promise<ReturnType<T>>;
function getValidPrivKeys(value: string): Buffer[] {
return value.split(":").map((str) => {
str = str.trim();
const buf = str.startsWith("-----BEGIN ")
? Buffer.from(str)
: readFileSync(path.resolve(ROOT_DIR, str));
return buf;
});
}
function getValidCerts(value: string): Buffer[] {
return value.split(":").map((str) => {
str = str.trim();
const buf = str.startsWith("-----BEGIN ")
? Buffer.from(str)
: readFileSync(path.resolve(ROOT_DIR, str));
return buf;
});
}
export function start( export function start(
options: ServerOptions, options: ServerOptions,
_listener: http.RequestListener _listener: Promisify<http.RequestListener>,
): void { ): void {
listener = _listener; listener = (req, res) => {
if (stopping) res.setHeader("Connection", "close");
_listener(req, res).catch((err) => {
try {
res.socket.unref();
if (res.headersSent) {
res.writeHead(500, { Connection: "close" });
res.end(`${err.name}: ${err.message}`);
}
} catch {
// Ignore
}
throw err;
});
};
if (options.ssl) { if (options.ssl) {
const opts = { const opts = {
key: options.ssl.key key: getValidPrivKeys(options.ssl.key),
.split(":") cert: getValidCerts(options.ssl.cert),
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
cert: options.ssl.cert
.split(":")
.map((f) => fs.readFileSync(path.resolve(ROOT_DIR, f.trim()))),
}; };
server = https.createServer(opts, listener); server = https.createServer(opts, listener);
@@ -121,10 +138,14 @@ export function start(
server.timeout = options.timeout || 0; server.timeout = options.timeout || 0;
if (options.keepAliveTimeout != null) if (options.keepAliveTimeout != null)
server.keepAliveTimeout = options.keepAliveTimeout; server.keepAliveTimeout = options.keepAliveTimeout;
if (options.requestTimeout != null)
server.requestTimeout = options.requestTimeout;
server.listen({ port: options.port, host: options.host }); server.listen({ port: options.port, host: options.host });
} }
export function stop(): Promise<void> { export function stop(terminateConnections = true): Promise<void> {
stopping = terminateConnections;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
reject(new Error("Could not close server in a timely manner")); reject(new Error("Could not close server in a timely manner"));
+299 -351
View File
File diff suppressed because it is too large. Load diff
+36 -55
View File
@@ -1,30 +1,11 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { import {
parseXml, parseXml,
Element, Element,
parseAttrs, parseAttrs,
encodeEntities, encodeEntities,
decodeEntities, decodeEntities,
} from "./xml-parser"; } from "./xml-parser.ts";
import memoize from "./common/memoize"; import memoize from "./common/memoize.ts";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
import { import {
InformRequest, InformRequest,
@@ -34,22 +15,22 @@ import {
SoapMessage, SoapMessage,
TransferCompleteRequest, TransferCompleteRequest,
AcsRequest, AcsRequest,
GetParameterNamesResponse, type GetParameterNamesResponse,
GetParameterValuesResponse, type GetParameterValuesResponse,
GetParameterAttributesResponse, type GetParameterAttributesResponse,
SetParameterValuesResponse, type SetParameterValuesResponse,
SetParameterAttributesResponse, type SetParameterAttributesResponse,
AddObjectResponse, type AddObjectResponse,
DeleteObject, type DeleteObject,
DeleteObjectResponse, type DeleteObjectResponse,
RebootResponse, type RebootResponse,
FactoryResetResponse, type FactoryResetResponse,
DownloadResponse, type DownloadResponse,
GetRPCMethodsRequest, GetRPCMethodsRequest,
RequestDownloadRequest, RequestDownloadRequest,
AcsResponse, AcsResponse,
} from "./types"; } from "./types.ts";
import Path from "./common/path"; import Path from "./common/path.ts";
const SERVER_NAME = `GenieACS/${VERSION}`; const SERVER_NAME = `GenieACS/${VERSION}`;
@@ -138,7 +119,7 @@ function parameterInfoList(xml: Element): [Path, boolean, boolean][] {
if (param && !param.endsWith(".")) if (param && !param.endsWith("."))
return [Path.parse(param), false, parsed]; return [Path.parse(param), false, parsed];
else return [Path.parse(param.slice(0, -1)), true, parsed]; else return [Path.parse(param.slice(0, -1)), true, parsed];
} catch (err) { } catch {
warnings.push({ warnings.push({
message: "Missing or invalid XML node", message: "Missing or invalid XML node",
element: "Name", element: "Name",
@@ -158,7 +139,7 @@ const getValueType = memoize((str: string) => {
}); });
function parameterValueList( function parameterValueList(
xml: Element xml: Element,
): [Path, string | number | boolean, string][] { ): [Path, string | number | boolean, string][] {
return xml.children return xml.children
.map<[Path, string | number | boolean, string]>((e) => { .map<[Path, string | number | boolean, string]>((e) => {
@@ -220,7 +201,7 @@ function parameterValueList(
} }
try { try {
return [Path.parse(param), parsed, valueType]; return [Path.parse(param), parsed, valueType];
} catch (err) { } catch {
warnings.push({ warnings.push({
message: "Missing or invalid XML node", message: "Missing or invalid XML node",
element: "Name", element: "Name",
@@ -269,7 +250,7 @@ function parameterAttributeList(xml: Element): [Path, number, string[]][] {
try { try {
return [Path.parse(param), notification, accessList]; return [Path.parse(param), notification, accessList];
} catch (err) { } catch {
warnings.push({ warnings.push({
message: "Missing or invalid XML node", message: "Missing or invalid XML node",
element: "Name", element: "Name",
@@ -291,7 +272,7 @@ function GetParameterNamesResponse(xml): GetParameterNamesResponse {
return { return {
name: "GetParameterNamesResponse", name: "GetParameterNamesResponse",
parameterList: parameterInfoList( parameterList: parameterInfoList(
xml.children.find((n) => n.localName === "ParameterList") xml.children.find((n) => n.localName === "ParameterList"),
), ),
}; };
} }
@@ -308,7 +289,7 @@ function GetParameterValuesResponse(xml: Element): GetParameterValuesResponse {
return { return {
name: "GetParameterValuesResponse", name: "GetParameterValuesResponse",
parameterList: parameterValueList( parameterList: parameterValueList(
xml.children.find((n) => n.localName === "ParameterList") xml.children.find((n) => n.localName === "ParameterList"),
), ),
}; };
} }
@@ -322,12 +303,12 @@ function GetParameterAttributes(methodRequest): string {
} }
function GetParameterAttributesResponse( function GetParameterAttributesResponse(
xml: Element xml: Element,
): GetParameterAttributesResponse { ): GetParameterAttributesResponse {
return { return {
name: "GetParameterAttributesResponse", name: "GetParameterAttributesResponse",
parameterList: parameterAttributeList( parameterList: parameterAttributeList(
xml.children.find((n) => n.localName === "ParameterList") xml.children.find((n) => n.localName === "ParameterList"),
), ),
}; };
} }
@@ -509,19 +490,19 @@ function Download(methodRequest): string {
}</CommandKey><FileType>${methodRequest.fileType}</FileType><URL>${ }</CommandKey><FileType>${methodRequest.fileType}</FileType><URL>${
methodRequest.url methodRequest.url
}</URL><Username>${encodeEntities( }</URL><Username>${encodeEntities(
methodRequest.username || "" methodRequest.username || "",
)}</Username><Password>${encodeEntities( )}</Username><Password>${encodeEntities(
methodRequest.password || "" methodRequest.password || "",
)}</Password><FileSize>${ )}</Password><FileSize>${
methodRequest.fileSize || "0" methodRequest.fileSize || "0"
}</FileSize><TargetFileName>${encodeEntities( }</FileSize><TargetFileName>${encodeEntities(
methodRequest.targetFileName || "" methodRequest.targetFileName || "",
)}</TargetFileName><DelaySeconds>${ )}</TargetFileName><DelaySeconds>${
methodRequest.delaySeconds || "0" methodRequest.delaySeconds || "0"
}</DelaySeconds><SuccessURL>${encodeEntities( }</DelaySeconds><SuccessURL>${encodeEntities(
methodRequest.successUrl || "" methodRequest.successUrl || "",
)}</SuccessURL><FailureURL>${encodeEntities( )}</SuccessURL><FailureURL>${encodeEntities(
methodRequest.failureUrl || "" methodRequest.failureUrl || "",
)}</FailureURL></cwmp:Download>`; )}</FailureURL></cwmp:Download>`;
} }
@@ -732,13 +713,13 @@ function RequestDownloadResponse(): string {
function AcsFault(f: CpeFault): string { function AcsFault(f: CpeFault): string {
return `<soap-env:Body:Fault><faultcode>${encodeEntities( return `<soap-env:Body:Fault><faultcode>${encodeEntities(
f.faultCode f.faultCode,
)}</faultcode><faultstring>${encodeEntities( )}</faultcode><faultstring>${encodeEntities(
f.faultString f.faultString,
)}</faultstring><detail><cwmp:Fault><FaultCode>${encodeEntities( )}</faultstring><detail><cwmp:Fault><FaultCode>${encodeEntities(
f.detail.faultCode f.detail.faultCode,
)}</FaultCode><FaultString>${encodeEntities( )}</FaultCode><FaultString>${encodeEntities(
f.detail.faultString f.detail.faultString,
)}</FaultString></cwmp:Fault></detail></soap-env:Body:Fault>`; )}</FaultString></cwmp:Fault></detail></soap-env:Body:Fault>`;
} }
@@ -839,7 +820,7 @@ function fault(xml: Element): CpeFault {
export function request( export function request(
body: string, body: string,
warn: Record<string, unknown>[] warn: Record<string, unknown>[],
): SoapMessage { ): SoapMessage {
warnings = warn; warnings = warn;
@@ -897,7 +878,7 @@ export function request(
const attrs = memoizedParseAttrs(e.attrs); const attrs = memoizedParseAttrs(e.attrs);
const attr = namespace const attr = namespace
? attrs.find( ? attrs.find(
(s) => s.namespace === "xmlns" && s.localName === namespace (s) => s.namespace === "xmlns" && s.localName === namespace,
) )
: attrs.find((s) => s.name === "xmlns"); : attrs.find((s) => s.name === "xmlns");
@@ -1030,7 +1011,7 @@ export function response(rpc: {
throw new Error( throw new Error(
`Unknown method response type ${ `Unknown method response type ${
(rpc.acsResponse as AcsResponse).name (rpc.acsResponse as AcsResponse).name
}` }`,
); );
} }
} else if (rpc.acsRequest) { } else if (rpc.acsRequest) {
@@ -1067,7 +1048,7 @@ export function response(rpc: {
break; break;
default: default:
throw new Error( throw new Error(
`Unknown method request ${(rpc.acsRequest as AcsRequest).name}` `Unknown method request ${(rpc.acsRequest as AcsRequest).name}`,
); );
} }
} else if (rpc.acsFault) { } else if (rpc.acsFault) {
+14 -44
View File
@@ -1,30 +1,10 @@
/** import { IncomingMessage, ServerResponse } from "node:http";
* Copyright 2013-2019 GenieACS Inc. import { Script } from "node:vm";
* import Path from "./common/path.ts";
* This file is part of GenieACS. import PathSet from "./common/path-set.ts";
* import VersionedMap from "./versioned-map.ts";
* GenieACS is free software: you can redistribute it and/or modify import InstanceSet from "./instance-set.ts";
* it under the terms of the GNU Affero General Public License as import Expression, { Value } from "./common/expression.ts";
* 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 Path from "./common/path";
import PathSet from "./common/path-set";
import VersionedMap from "./versioned-map";
import InstanceSet from "./instance-set";
import { IncomingMessage, ServerResponse } from "http";
import { Script } from "vm";
export type Expression = string | number | boolean | null | any[];
export interface Fault { export interface Fault {
code: string; code: string;
@@ -97,7 +77,7 @@ export type VirtualParameterDeclaration = [
value?: [string | number | boolean, string?]; value?: [string | number | boolean, string?];
notification?: number; notification?: number;
accessList?: string[]; accessList?: string[];
}? }?,
]; ];
export interface SyncState { export interface SyncState {
@@ -139,7 +119,7 @@ export interface SessionContext {
AttributeTimestamps, AttributeTimestamps,
AttributeValues, AttributeValues,
AttributeTimestamps, AttributeTimestamps,
AttributeValues AttributeValues,
][][]; ][][];
revisions: number[]; revisions: number[];
rpcCount: number; rpcCount: number;
@@ -160,7 +140,6 @@ export interface SessionContext {
authState: number; authState: number;
tasks?: Task[]; tasks?: Task[];
operations?: { [commandKey: string]: Operation }; operations?: { [commandKey: string]: Operation };
cacheUntil?: number;
syncState?: SyncState; syncState?: SyncState;
lastActivity?: number; lastActivity?: number;
extendLock?: number; extendLock?: number;
@@ -180,7 +159,7 @@ export interface Task {
fileName?: string; fileName?: string;
targetFileName?: string; targetFileName?: string;
expiry?: number; expiry?: number;
provisions?: [string, ...Expression[]][]; provisions?: [string, ...Value[]][];
} }
export interface Operation { export interface Operation {
@@ -456,7 +435,7 @@ export type Clear = [
notification?: number; notification?: number;
accessList?: number; accessList?: number;
}?, }?,
number? number?,
]; ];
export interface Preset { export interface Preset {
@@ -490,7 +469,7 @@ export interface Permissions {
[resource: string]: { [resource: string]: {
access: number; access: number;
filter: Expression; filter: Expression;
validate?: Expression; validate: Expression;
}; };
}; };
}; };
@@ -499,7 +478,7 @@ export interface Permissions {
export type PermissionSet = { export type PermissionSet = {
[resource: string]: { [resource: string]: {
access: number; access: number;
validate?: Expression; validate: Expression;
filter: Expression; filter: Expression;
}; };
}[]; }[];
@@ -508,16 +487,7 @@ export interface Config {
[name: string]: Expression; [name: string]: Expression;
} }
export interface UiConfig { export type UiConfig = Record<string, string>;
filters: Record<string, unknown>;
device: Record<string, unknown>;
index: Record<string, unknown>;
overview: {
charts?: Record<string, unknown>;
groups?: Record<string, unknown>;
};
pageSize?: Expression;
}
export interface SoapMessage { export interface SoapMessage {
id: string; id: string;
+69 -58
View File
@@ -1,46 +1,22 @@
/** import { constants } from "node:zlib";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
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";
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"; import * as config from "./config.ts";
import api from "./ui/api"; import api from "./ui/api.ts";
import Authorizer from "./common/authorizer"; import Authorizer from "./common/authorizer.ts";
import * as logger from "./logger"; import * as logger from "./logger.ts";
import * as localCache from "./local-cache"; import * as localCache from "./ui/local-cache.ts";
import { PermissionSet } from "./types"; import { PermissionSet } from "./types.ts";
import { authLocal } from "./ui/api-functions"; import { authLocal } from "./api-functions.ts";
import * as init from "./init"; import * as init from "./init.ts";
import { version as VERSION } from "../package.json"; import { version as VERSION } from "../package.json";
import memoize from "./common/memoize"; import memoize from "./common/memoize.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();
@@ -53,22 +29,26 @@ const getAuthorizer = memoize(
const roles: string[] = JSON.parse(rolesStr); const roles: string[] = JSON.parse(rolesStr);
const allPermissions = localCache.getPermissions(snapshot); const allPermissions = localCache.getPermissions(snapshot);
const permissionSets: PermissionSet[] = roles.map((r) => const permissionSets: PermissionSet[] = roles.map((r) =>
Object.values(allPermissions[r] || {}) Object.values(allPermissions[r] || {}),
); );
return new Authorizer(permissionSets); return new Authorizer(permissionSets);
} },
); );
koa.on("error", async (err, ctx) => { koa.on("error", (err, ctx) => {
setTimeout(() => { setTimeout(() => {
// Ignored errors resulting from aborted requests // Ignored errors resulting from aborted requests
if (ctx?.req.aborted) return; if (ctx?.req.aborted) return;
// Ignore client errors (e.g. malicious path)
if (err.status === 400) return;
throw err; throw err;
}); });
}); });
koa.use(async (ctx, next) => { koa.use(async (ctx, next) => {
const configSnapshot = await localCache.getCurrentSnapshot(); const configSnapshot = await localCache.getRevision();
ctx.state.configSnapshot = configSnapshot; ctx.state.configSnapshot = configSnapshot;
ctx.set("X-Config-Snapshot", configSnapshot); ctx.set("X-Config-Snapshot", configSnapshot);
ctx.set("GenieACS-Version", VERSION); ctx.set("GenieACS-Version", VERSION);
@@ -89,7 +69,7 @@ koa.use(
return true; return true;
}, },
}) }),
); );
koa.use(async (ctx, next) => { koa.use(async (ctx, next) => {
@@ -109,7 +89,7 @@ koa.use(async (ctx, next) => {
ctx.state.authorizer = getAuthorizer( ctx.state.authorizer = getAuthorizer(
ctx.state.configSnapshot, ctx.state.configSnapshot,
JSON.stringify(roles) JSON.stringify(roles),
); );
return next(); return next();
@@ -125,6 +105,9 @@ router.post("/login", async (ctx) => {
const username = ctx.request.body.username; const username = ctx.request.body.username;
const password = ctx.request.body.password; const password = ctx.request.body.password;
const remember = ctx.request.body.remember;
const TWO_WEEKS_SECS = 1209600;
const ONE_DAY_SECS = 86400;
const log = { const log = {
message: "Log in", message: "Log in",
@@ -135,8 +118,14 @@ router.post("/login", async (ctx) => {
function success(authMethod): void { function success(authMethod): void {
log.method = authMethod; log.method = authMethod;
const token = jwt.sign({ username, authMethod }, JWT_SECRET); const expiresIn = remember ? TWO_WEEKS_SECS : ONE_DAY_SECS;
ctx.cookies.set(JWT_COOKIE, token, { sameSite: "lax" }); const token = jwt.sign({ username, authMethod }, JWT_SECRET, {
expiresIn,
});
ctx.cookies.set(JWT_COOKIE, token, {
sameSite: "lax",
maxAge: remember ? expiresIn * 1000 : null,
});
ctx.body = JSON.stringify(token); ctx.body = JSON.stringify(token);
logger.accessInfo(log); logger.accessInfo(log);
} }
@@ -221,8 +210,21 @@ router.post("/init", async (ctx) => {
}); });
router.get("/", async (ctx) => { router.get("/", async (ctx) => {
const permissionSets: PermissionSet[] = // koa-router seems to tolerate double slashes in the URL but that can
ctx.state.authorizer.getPermissionSets(); // be problematic when using relatives asset paths in HTML
if (ctx.path.endsWith("//")) return;
const ps: PermissionSet[] = ctx.state.authorizer.getPermissionSets();
const permissionSets = ps.map((p) =>
p.map((s) =>
Object.fromEntries(
Object.entries(s).map(([resource, { access, validate, filter }]) => [
resource,
{ access, validate: validate.toString(), filter: filter.toString() },
]),
),
),
);
let wizard = ""; let wizard = "";
if (!Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length) if (!Object.keys(localCache.getUsers(ctx.state.configSnapshot)).length)
@@ -232,23 +234,21 @@ router.get("/", async (ctx) => {
<html> <html>
<head> <head>
<title>GenieACS</title> <title>GenieACS</title>
<link rel="shortcut icon" type="image/png" href="favicon.png" /> <link rel="shortcut icon" type="image/png" href="${FAVICON_PNG}" />
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="${APP_CSS}">
</head> </head>
<body> <body class="h-full bg-stone-100">
<noscript>GenieACS UI requires JavaScript to work. Please enable JavaScript in your browser.</noscript> <noscript>GenieACS UI requires JavaScript to work. Please enable JavaScript in your browser.</noscript>
<script> <script>
window.clientConfig = ${JSON.stringify({ window.clientConfig = ${JSON.stringify(localCache.getUiConfig(ctx.state.configSnapshot))};
ui: localCache.getUiConfig(ctx.state.configSnapshot),
})};
window.configSnapshot = ${JSON.stringify(ctx.state.configSnapshot)}; window.configSnapshot = ${JSON.stringify(ctx.state.configSnapshot)};
window.genieacsVersion = ${JSON.stringify(VERSION)}; window.genieacsVersion = ${JSON.stringify(VERSION)};
window.username = ${JSON.stringify( window.username = ${JSON.stringify(
ctx.state.user ? ctx.state.user.username : "" ctx.state.user ? ctx.state.user.username : "",
)}; )};
window.permissionSets = ${JSON.stringify(permissionSets)}; window.permissionSets = ${JSON.stringify(permissionSets)};
</script> </script>
<script type="module" src="app.js"></script>${wizard} <script type="module" src="${APP_JS}"></script>${wizard}
</body> </body>
</html> </html>
`; `;
@@ -268,10 +268,21 @@ koa.use(
[constants.BROTLI_PARAM_QUALITY]: 5, [constants.BROTLI_PARAM_QUALITY]: 5,
}, },
}, },
}) }),
); );
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();
-211
View File
@@ -1,211 +0,0 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
import { ObjectId } from "mongodb";
import * as db from "./db";
import { del } from "../cache";
import { getUsers } from "../local-cache";
import { hashPassword } from "../auth";
import {
insertTasks,
awaitSessionEnd,
awaitSessionStart,
connectionRequest,
deleteDevice,
} from "../api-functions";
import { Task } from "../types";
async function deleteFault(id): Promise<void> {
const deviceId = id.split(":", 1)[0];
const channel = id.slice(deviceId.length + 1);
await Promise.all([
db.deleteFault(id),
channel.startsWith("task_")
? db.deleteTask(new ObjectId(channel.slice(5)))
: null,
]);
await del(`${deviceId}_tasks_faults_operations`);
}
export async function deleteResource(
resource: string,
id: string
): Promise<void> {
switch (resource) {
case "devices":
await deleteDevice(id);
break;
case "files":
await db.deleteFile(id);
break;
case "faults":
await deleteFault(id);
break;
case "provisions":
await db.deleteProvision(id);
break;
case "presets":
await db.deletePreset(id);
break;
case "virtualParameters":
await db.deleteVirtualParameter(id);
break;
case "config":
await db.deleteConfig(id);
break;
case "permissions":
await db.deletePermission(id);
break;
case "users":
await db.deleteUser(id);
break;
}
await del("presets_hash");
}
export async function postTasks(
deviceId: string,
tasks: Task[],
timeout: number,
device: Record<string, { value?: [boolean | number | string, string] }>
): Promise<{ connectionRequest: string; tasks: any[] }> {
for (const task of tasks) {
delete task._id;
task["device"] = deviceId;
}
tasks = await insertTasks(tasks);
const statuses = tasks.map((t) => {
return { _id: t._id, status: "pending" };
});
const lastInform = Date.now();
const notInSession = await awaitSessionEnd(deviceId, 30000);
if (!notInSession) {
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectId(t._id))));
return {
connectionRequest: "Session took too long to complete",
tasks: statuses,
};
}
await del(`${deviceId}_tasks_faults_operations`);
const status = await connectionRequest(deviceId, device);
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 = [];
for (const s of statuses) {
promises.push(db.query("tasks", ["=", ["PARAM", "_id"], s._id]));
promises.push(
db.query("faults", ["=", ["PARAM", "_id"], `${deviceId}:task_${s._id}`])
);
}
const res = await Promise.all(promises);
for (const [i, r] of statuses.entries()) {
if (res[i * 2].length === 0) {
r.status = "done";
} else if (res[i * 2 + 1].length === 1) {
r.status = "fault";
r["fault"] = res[i * 2 + 1][0];
}
}
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectId(t._id))));
return { connectionRequest: "OK", tasks: statuses };
}
export async function putResource(
resource: string,
id: string,
data: Record<string, unknown>
): Promise<void> {
if (resource === "presets") {
await db.putPreset(id, data);
} else if (resource === "provisions") {
await db.putProvision(id, data);
} else if (resource === "virtualParameters") {
await db.putVirtualParameter(id, data);
} else if (resource === "config") {
await db.putConfig(id, data);
} else if (resource === "permissions") {
await db.putPermission(id, data);
} else if (resource === "users") {
delete data["password"];
delete data["salt"];
await db.putUser(id, data);
}
await del("presets_hash");
}
export function authLocal(
snapshot: string,
username: string,
password: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const users = getUsers(snapshot);
const user = users[username];
if (!user?.password) return void resolve(null);
hashPassword(password, user.salt)
.then((hash) => {
if (hash === user.password) resolve(true);
else resolve(false);
})
.catch(reject);
});
}
+370 -258
View File
@@ -1,37 +1,22 @@
/** import { Readable } from "node:stream";
* Copyright 2013-2019 GenieACS Inc. import Router from "@koa/router";
* import { ObjectId } from "mongodb";
* This file is part of GenieACS. import * as db from "./db.ts";
* import * as apiFunctions from "../api-functions.ts";
* GenieACS is free software: you can redistribute it and/or modify import Expression, { extractPaths } from "../common/expression.ts";
* it under the terms of the GNU Affero General Public License as import Path from "../common/path.ts";
* published by the Free Software Foundation, either version 3 of the import * as logger from "../logger.ts";
* License, or (at your option) any later version. import { getConfig } from "../ui/local-cache.ts";
* import { Task } from "../types.ts";
* GenieACS is distributed in the hope that it will be useful, import { generateSalt, hashPassword } from "../auth.ts";
* but WITHOUT ANY WARRANTY; without even the implied warranty of import { del } from "../cache.ts";
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the import Authorizer from "../common/authorizer.ts";
* GNU Affero General Public License for more details. import { ping } from "../ping.ts";
* import { decodeTag } from "../util.ts";
* You should have received a copy of the GNU Affero General Public License import { stringify as yamlStringify } from "../common/yaml.ts";
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>. import { ResourceLockedError } from "../common/errors.ts";
*/ import { acquireLock, releaseLock } from "../lock.ts";
import { collections } from "../db/db.ts";
import { PassThrough } from "stream";
import Router from "koa-router";
import * as db from "./db";
import * as apiFunctions from "./api-functions";
import { evaluate, and, extractParams } from "../common/expression";
import { parse } from "../common/expression-parser";
import * as logger from "../logger";
import { getConfig } from "../local-cache";
import { QueryOptions, Expression } from "../types";
import { generateSalt, hashPassword } from "../auth";
import { del } from "../cache";
import Authorizer from "../common/authorizer";
import { ping } from "../ping";
import { decodeTag } from "../common";
import { stringify as yamlStringify } from "../common/yaml";
const router = new Router(); const router = new Router();
export default router; export default router;
@@ -82,60 +67,77 @@ router.get(`/devices/:id.csv`, async (ctx) => {
id: ctx.params.id, id: ctx.params.id,
}; };
const filter = and(authorizer.getFilter("devices", 2), [ const filter = Expression.and(
"=", authorizer.getFilter("devices", 2),
["PARAM", RESOURCE_IDS.devices], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS.devices)),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess("devices", 2)) { if (!authorizer.hasAccess("devices", 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const res = await db.query("devices", filter); const { value: device } = await db.query("devices", filter).next();
if (!res[0]) return void (ctx.status = 404); if (!device) return void (ctx.status = 404);
ctx.type = "text/csv"; ctx.type = "text/csv";
ctx.attachment( ctx.attachment(
`device-${ctx.params.id}-${new Date() `device-${ctx.params.id}-${new Date()
.toISOString() .toISOString()
.replace(/[:.]/g, "")}.csv` .replace(/[:.]/g, "")}.csv`,
); );
ctx.body = new PassThrough(); const lines: string[] = [
ctx.body.write( "Parameter,Object,Writable,Value,Value type,Timestamp,Value timestamp,Notification,Access list,Attributes 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(res[0]).sort()) { const keys = Object.keys(device).sort();
const p = res[0][k]; let prevParam = "";
let value = ""; let attrs: Record<string, string | number | boolean | null> = {};
let type = "";
if (p.value) { function flushRow(): void {
value = p.value[0]; if (!prevParam) return;
type = p.value[1]; let value: string | number | boolean | null = attrs["value"] ?? "";
if (type === "xsd:dateTime" && typeof value === "number") if (attrs["type"] === "xsd:dateTime" && typeof value === "number")
value = new Date(value).toJSON(); value = new Date(value).toJSON();
}
const row = [ const row = [
k, prevParam,
p.object, attrs["object"] ?? "",
new Date(p.objectTimestamp).toJSON(), attrs["writable"] ?? "",
p.writable,
new Date(p.writableTimestamp).toJSON(),
`"${String(value).replace(/"/g, '""')}"`, `"${String(value).replace(/"/g, '""')}"`,
type, attrs["type"] ?? "",
new Date(p.valueTimestamp).toJSON(), attrs["timestamp"] != null ? new Date(+attrs["timestamp"]).toJSON() : "",
p.notification, attrs["valueTimestamp"] != null
new Date(p.notificationTimestamp).toJSON(), ? new Date(+attrs["valueTimestamp"]).toJSON()
p.accessList ? p.accessList.join(", ") : "", : "",
new Date(p.accessListTimestamp).toJSON(), attrs["notification"] ?? "",
attrs["accessList"] ?? "",
attrs["attributesTimestamp"] != null
? new Date(+attrs["attributesTimestamp"]).toJSON()
: "",
]; ];
ctx.body.write(row.map((r) => (r != null ? r : "")).join(",") + "\n"); lines.push(row.map((r) => (r != null ? r : "")).join(","));
} }
ctx.body.end();
for (const k of keys) {
const colonIdx = k.lastIndexOf(":");
const param = colonIdx === -1 ? k : k.slice(0, colonIdx);
const attr = colonIdx === -1 ? "value" : k.slice(colonIdx + 1);
if (param !== prevParam) {
flushRow();
prevParam = param;
attrs = {};
}
attrs[attr] = device[k];
}
flushRow();
ctx.body = lines.join("\n");
logger.accessInfo(log); logger.accessInfo(log);
}); });
@@ -144,7 +146,10 @@ for (const [resource, flags] of Object.entries(resources)) {
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)
filter = and(filter, parse(singleParam(ctx.request.query.filter))); filter = Expression.and(
filter,
Expression.parse(singleParam(ctx.request.query.filter)),
);
const log = { const log = {
message: `Count ${resource}`, message: `Count ${resource}`,
@@ -160,11 +165,18 @@ for (const [resource, flags] of Object.entries(resources)) {
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
if (resource === "tasks" || resource === "faults") { if (resource === "tasks" || resource === "faults") {
filter = and(filter, [ const p = new Expression.Parameter(Path.parse("expiry"));
"OR", filter = Expression.and(
[">=", ["PARAM", "expiry"], Date.now() + 60000], filter,
["IS NULL", ["PARAM", "expiry"]], Expression.or(
]); new Expression.Binary(
">=",
p,
new Expression.Literal(Date.now() + 60000),
),
new Expression.Unary("IS NULL", p),
),
);
} }
const count = await db.count(resource, filter); const count = await db.count(resource, filter);
@@ -178,10 +190,13 @@ for (const [resource, flags] of Object.entries(resources)) {
router.get(`/${resource}`, async (ctx) => { router.get(`/${resource}`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const options: QueryOptions = {}; const options: Parameters<typeof db.query>[2] = {};
let filter: Expression = authorizer.getFilter(resource, 2); let filter: Expression = authorizer.getFilter(resource, 2);
if (ctx.request.query.filter) if (ctx.request.query.filter)
filter = and(filter, parse(singleParam(ctx.request.query.filter))); filter = Expression.and(
filter,
Expression.parse(singleParam(ctx.request.query.filter)),
);
if (ctx.request.query.limit) options.limit = +ctx.request.query.limit; if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;
if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;
if (ctx.request.query.sort) if (ctx.request.query.sort)
@@ -209,37 +224,43 @@ for (const [resource, flags] of Object.entries(resources)) {
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
if (resource === "tasks" || resource === "faults") { if (resource === "tasks" || resource === "faults") {
filter = and(filter, [ const p = new Expression.Parameter(Path.parse("expiry"));
"OR", filter = Expression.and(
[">=", ["PARAM", "expiry"], Date.now() + 60000], filter,
["IS NULL", ["PARAM", "expiry"]], Expression.or(
]); new Expression.Binary(
">=",
p,
new Expression.Literal(Date.now() + 60000),
),
new Expression.Unary("IS NULL", p),
),
);
} }
ctx.body = new PassThrough(); logger.accessInfo(log);
ctx.type = "application/json"; ctx.type = "application/json";
ctx.body = Readable.from(
let c = 0; (async function* () {
ctx.body.write("[\n"); let c = 0;
db.query(resource, filter, options, (obj) => { yield "[\n";
ctx.body.write((c++ ? "," : "") + JSON.stringify(obj) + "\n"); for await (const obj of db.query(resource, filter, options))
}) yield (c++ ? "," : "") + JSON.stringify(obj) + "\n";
.then(() => { yield "]";
ctx.body.end("]"); })(),
logger.accessInfo(log); );
})
.catch((err) => {
ctx.body.emit("error", err);
});
}); });
// CSV download // CSV download
router.get(`/${resource}.csv`, async (ctx) => { router.get(`/${resource}.csv`, async (ctx) => {
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const options: QueryOptions = { projection: {} }; const options: Parameters<typeof db.query>[2] = { projection: {} };
let filter: Expression = authorizer.getFilter(resource, 2); let filter: Expression = authorizer.getFilter(resource, 2);
if (ctx.request.query.filter) if (ctx.request.query.filter)
filter = and(filter, parse(singleParam(ctx.request.query.filter))); filter = Expression.and(
filter,
Expression.parse(singleParam(ctx.request.query.filter)),
);
if (ctx.request.query.limit) options.limit = +ctx.request.query.limit; if (ctx.request.query.limit) options.limit = +ctx.request.query.limit;
if (ctx.request.query.skip) options.skip = +ctx.request.query.skip; if (ctx.request.query.skip) options.skip = +ctx.request.query.skip;
if (ctx.request.query.sort) if (ctx.request.query.sort)
@@ -259,86 +280,91 @@ for (const [resource, flags] of Object.entries(resources)) {
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const columns: Record<string, Expression> = JSON.parse( const columnsStr: Record<string, string> = JSON.parse(
singleParam(ctx.request.query.columns) singleParam(ctx.request.query.columns),
); );
const now = Date.now();
for (const [k, v] of Object.entries(columns)) { const now = Date.now();
const e = evaluate(parse(v as string), null, now); const columns: Record<string, Expression> = Object.fromEntries(
columns[k] = e; Object.entries(columnsStr).map(([k, v]) => {
for (const p of extractParams(e)) let exp = Expression.parse(v);
if (typeof p === "string") options.projection[p] = 1; exp = exp.evaluate((e) => {
} if (e instanceof Expression.FunctionCall && e.name === "NOW")
return new Expression.Literal(now);
return e;
});
for (const p of extractPaths(exp)) options.projection[p.toString()] = 1;
return [k, exp];
}),
);
// Exclude temporary tasks and faults // Exclude temporary tasks and faults
if (resource === "tasks" || resource === "faults") { if (resource === "tasks" || resource === "faults") {
filter = and(filter, [ const p = new Expression.Parameter(Path.parse("expiry"));
"OR", filter = Expression.and(
[">=", ["PARAM", "expiry"], Date.now() + 60000], filter,
["IS NULL", ["PARAM", "expiry"]], Expression.or(
]); new Expression.Binary(
">=",
p,
new Expression.Literal(Date.now() + 60000),
),
new Expression.Unary("IS NULL", p),
),
);
} }
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) => {
return exp.evaluate((e) => {
if (e instanceof Expression.Literal) return e;
else if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(now);
if (e.name === "DATE_STRING") {
if (e.args[0] instanceof Expression.Literal)
return new Expression.Literal(
new Date(e.args[0].value as number).toJSON(),
);
}
} else if (e instanceof Expression.Parameter) {
let v = obj[e.path.toString()];
if (resource === "devices") {
if (e.path.toString() === "Tags") {
const tags = [];
for (const p in obj)
if (p.startsWith("Tags.") && p.lastIndexOf(":") === -1)
tags.push(decodeTag(p.slice(5)));
v = tags.join(", ");
}
if (e === exp) {
const type = obj[e.path.toString() + ":type"];
if (type === "xsd:dateTime" && typeof v === "number")
v = new Date(v).toJSON();
}
} else if (resource === "faults") {
if (e.path.toString() === "detail") v = yamlStringify(v);
}
if (typeof v === "string") v = `"${v.replace(/"/g, '""')}"`;
if (v != null) return new Expression.Literal(v);
}
return new Expression.Literal(null);
}).value;
});
yield arr.join(",") + "\n";
}
})(),
); );
db.query(resource, filter, options, (obj) => {
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");
})
.then(() => {
ctx.body.end();
logger.accessInfo(log);
})
.catch((err) => {
ctx.body.emit("error", err);
});
}); });
router.head(`/${resource}/:id`, async (ctx) => { router.head(`/${resource}/:id`, async (ctx) => {
@@ -349,19 +375,23 @@ for (const [resource, flags] of Object.entries(resources)) {
filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"`, filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"`,
}; };
const filter = and(authorizer.getFilter(resource, 2), [ const filter = Expression.and(
"=", authorizer.getFilter(resource, 2),
["PARAM", RESOURCE_IDS[resource]], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const count = await db.count(resource, filter);
if (!res.length) return void (ctx.status = 404); if (!count) return void (ctx.status = 404);
logger.accessInfo(log); logger.accessInfo(log);
ctx.body = ""; ctx.body = "";
@@ -375,22 +405,25 @@ for (const [resource, flags] of Object.entries(resources)) {
filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"`, filter: `${RESOURCE_IDS[resource]} = "${ctx.params.id}"`,
}; };
const filter = and(authorizer.getFilter(resource, 2), [ const filter = Expression.and(
"=", authorizer.getFilter(resource, 2),
["PARAM", RESOURCE_IDS[resource]], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const { value: res } = await db.query(resource, filter).next();
if (!res.length) return void (ctx.status = 404); if (!res) return void (ctx.status = 404);
logger.accessInfo(log); logger.accessInfo(log);
ctx.body = res[0]; ctx.body = res;
}); });
if (flags & RESOURCE_DELETE) { if (flags & RESOURCE_DELETE) {
@@ -402,25 +435,39 @@ for (const [resource, flags] of Object.entries(resources)) {
id: ctx.params.id, id: ctx.params.id,
}; };
const filter = and(authorizer.getFilter(resource, 3), [ const filter = Expression.and(
"=", authorizer.getFilter(resource, 3),
["PARAM", RESOURCE_IDS[resource]], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess(resource, 3)) { if (!authorizer.hasAccess(resource, 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const res = await db.query(resource, filter); const { value: res } = await db.query(resource, filter).next();
if (!res.length) return void (ctx.status = 404); if (!res) return void (ctx.status = 404);
const validate = authorizer.getValidator(resource, res[0]); const validate = authorizer.getValidator(resource, res);
if (!validate("delete")) { if (!validate("delete")) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
await apiFunctions.deleteResource(resource, ctx.params.id); try {
await apiFunctions.deleteResource(resource, ctx.params.id);
} catch (err) {
if (err instanceof ResourceLockedError) {
log.message += " failed";
logger.accessWarn(log);
ctx.status = 503;
ctx.body = err.message;
return;
}
throw err;
}
logger.accessInfo(log); logger.accessInfo(log);
@@ -479,11 +526,14 @@ router.get("/blob/files/:id", async (ctx) => {
id: id, id: id,
}; };
const filter = and(authorizer.getFilter(resource, 2), [ const filter = Expression.and(
"=", authorizer.getFilter(resource, 2),
["PARAM", RESOURCE_IDS[resource]], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS[resource])),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess(resource, 2)) { if (!authorizer.hasAccess(resource, 2)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
@@ -531,7 +581,7 @@ router.put("/files/:id", async (ctx) => {
try { try {
await db.deleteFile(id); await db.deleteFile(id);
} catch (err) { } catch {
// File doesn't exist, ignore // File doesn't exist, ignore
} }
@@ -543,79 +593,135 @@ router.put("/files/:id", async (ctx) => {
}); });
router.post("/devices/:id/tasks", async (ctx) => { router.post("/devices/:id/tasks", async (ctx) => {
const deviceId = ctx.params.id;
const authorizer: Authorizer = ctx.state.authorizer; const authorizer: Authorizer = ctx.state.authorizer;
const log = { const log = {
message: "Commit tasks", message: "Commit tasks",
context: ctx, context: ctx,
deviceId: ctx.params.id, deviceId: deviceId,
tasks: null, tasks: null,
}; };
const filter = and(authorizer.getFilter("devices", 3), [
"=",
["PARAM", "DeviceID.ID"],
ctx.params.id,
]);
if (!authorizer.hasAccess("devices", 3)) { if (!authorizer.hasAccess("devices", 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const devices = await db.query("devices", filter);
if (!devices.length) return void (ctx.status = 404);
const device = devices[0];
const validate = authorizer.getValidator("devices", device); const socketTimeout: number = ctx.socket.timeout;
for (const t of ctx.request.body) {
if (!validate("task", t)) { // Extend socket timeout while waiting for session
logUnauthorizedWarning(log); if (socketTimeout) ctx.socket.setTimeout(300000);
return void (ctx.status = 403);
} const token = await acquireLock(`cwmp_session_${deviceId}`, 5000, 30000);
if (!token) {
log.message += " failed";
logger.accessWarn(log);
ctx.body = "Device is in session";
ctx.status = 503;
// Restore socket timeout
if (socketTimeout) ctx.socket.setTimeout(socketTimeout);
return;
} }
let device;
let statuses: { _id: string; status: string }[];
try {
const filter = Expression.and(
authorizer.getFilter("devices", 3),
new Expression.Binary(
"=",
new Expression.Parameter(Path.parse(RESOURCE_IDS["devices"])),
new Expression.Literal(ctx.params.id),
),
);
device = (await db.query("devices", filter).next()).value;
if (!device) return void (ctx.status = 404);
const validate = authorizer.getValidator("devices", device);
for (const t of ctx.request.body) {
if (!validate("task", t)) {
logUnauthorizedWarning(log);
return void (ctx.status = 403);
}
}
let tasks = ctx.request.body as Task[];
for (const task of tasks) {
delete task._id;
task["device"] = deviceId;
}
tasks = await apiFunctions.insertTasks(tasks);
statuses = tasks.map((t) => ({ _id: t._id, status: "pending" }));
} finally {
await releaseLock(`cwmp_session_${deviceId}`, token);
}
const now = Date.now();
const onlineThreshold = getConfig( const onlineThreshold = getConfig(
ctx.state.configSnapshot, ctx.state.configSnapshot,
"cwmp.deviceOnlineThreshold", "cwmp.deviceOnlineThreshold",
{}, 4000,
Date.now(),
(exp) => { (exp) => {
if (!Array.isArray(exp)) return exp; if (exp instanceof Expression.Literal) return exp;
if (exp[0] === "PARAM") { else if (exp instanceof Expression.Parameter) {
const p = device[exp[1]]; const p = device[exp.path.toString()];
if (p?.value) return p.value[0]; if (p != null) return new Expression.Literal(p);
} else if (exp[0] === "FUNC") { } else if (exp instanceof Expression.FunctionCall) {
if (exp[1] === "REMOTE_ADDRESS") { if (exp.name === "NOW") return new Expression.Literal(now);
if (exp.name === "REMOTE_ADDRESS") {
for (const root of ["InternetGatewayDevice", "Device"]) { for (const root of ["InternetGatewayDevice", "Device"]) {
const p = device[`${root}.ManagementServer.ConnectionRequestURL`]; const p = device[`${root}.ManagementServer.ConnectionRequestURL`];
if (p?.value) return new URL(p.value[0]).hostname; if (p != null) return new Expression.Literal(new URL(p).hostname);
} }
return null;
} }
} }
return exp; return new Expression.Literal(null);
} },
) 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(
ctx.params.id,
ctx.request.body,
onlineThreshold,
device
); );
const lastInform = device["Events.Inform"] as number;
let status = await apiFunctions.connectionRequest(deviceId, device);
if (!status) {
const sessionStarted = await apiFunctions.awaitSessionStart(
deviceId,
lastInform,
onlineThreshold,
);
if (!sessionStarted) {
status = "No contact from CPE";
} else {
const sessionEnded = await apiFunctions.awaitSessionEnd(deviceId, 120000);
if (!sessionEnded) status = "Session took too long to complete";
}
}
if (!status) {
const promises = statuses.map((t) =>
collections.faults.count({ _id: `${deviceId}:task_${t._id}` }),
);
const res = await Promise.all(promises);
for (const [i, r] of statuses.entries())
r.status = res[i] ? "fault" : "done";
}
await Promise.all(statuses.map((t) => db.deleteTask(new ObjectId(t._id))));
// Restore socket timeout // Restore socket timeout
if (socketTimeout) ctx.socket.setTimeout(socketTimeout); if (socketTimeout) ctx.socket.setTimeout(socketTimeout);
log.tasks = res.tasks.map((t) => t._id).join(","); log.tasks = statuses.map((t) => t._id).join(",");
logger.accessInfo(log); logger.accessInfo(log);
ctx.set("Connection-Request", res.connectionRequest); ctx.set("Connection-Request", status || "OK");
ctx.body = res.tasks; ctx.body = statuses;
}); });
router.post("/devices/:id/tags", async (ctx) => { router.post("/devices/:id/tags", async (ctx) => {
@@ -627,19 +733,22 @@ router.post("/devices/:id/tags", async (ctx) => {
tags: ctx.request.body, tags: ctx.request.body,
}; };
const filter = and(authorizer.getFilter("devices", 3), [ const filter = Expression.and(
"=", authorizer.getFilter("devices", 3),
["PARAM", "DeviceID.ID"], new Expression.Binary(
ctx.params.id, "=",
]); new Expression.Parameter(Path.parse(RESOURCE_IDS["devices"])),
new Expression.Literal(ctx.params.id),
),
);
if (!authorizer.hasAccess("devices", 3)) { if (!authorizer.hasAccess("devices", 3)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const res = await db.query("devices", filter); const { value: res } = await db.query("devices", filter).next();
if (!res.length) return void (ctx.status = 404); if (!res) return void (ctx.status = 404);
const validate = authorizer.getValidator("devices", res[0]); const validate = authorizer.getValidator("devices", res);
if (!validate("tags", ctx.request.body)) { if (!validate("tags", ctx.request.body)) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
@@ -689,7 +798,7 @@ router.put("/users/:id/password", async (ctx) => {
!(await apiFunctions.authLocal( !(await apiFunctions.authLocal(
ctx.state.configSnapshot, ctx.state.configSnapshot,
username, username,
ctx.request.body.authPassword ctx.request.body.authPassword,
)) ))
) { ) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
@@ -702,17 +811,20 @@ router.put("/users/:id/password", async (ctx) => {
return void (ctx.status = 403); return void (ctx.status = 403);
} }
const filter = and(authorizer.getFilter("users", 3), [
"=",
["PARAM", RESOURCE_IDS.users],
username,
]);
const res = await db.query("users", filter);
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) {
const validate = authorizer.getValidator("users", res[0]); const filter = Expression.and(
authorizer.getFilter("users", 3),
new Expression.Binary(
"=",
new Expression.Parameter(Path.parse(RESOURCE_IDS["users"])),
new Expression.Literal(username),
),
);
const { value: res } = await db.query("users", filter).next();
if (!res) return void (ctx.status = 404);
const validate = authorizer.getValidator("users", res);
if (!validate("password", { password: newPassword })) { if (!validate("password", { password: newPassword })) {
logUnauthorizedWarning(log); logUnauthorizedWarning(log);
return void (ctx.status = 403); return void (ctx.status = 403);
@@ -723,7 +835,7 @@ router.put("/users/:id/password", async (ctx) => {
const password = await hashPassword(newPassword, salt); const password = await hashPassword(newPassword, salt);
await db.putUser(username, { password, salt }); await db.putUser(username, { password, salt });
await del("presets_hash"); await del("ui-local-cache-hash");
logger.accessInfo(log); logger.accessInfo(log);
ctx.body = ""; ctx.body = "";
+417 -262
View File
@@ -1,179 +1,352 @@
/** import { Script } from "node:vm";
* Copyright 2013-2019 GenieACS Inc. import { Readable } from "node:stream";
* import { Collection, ObjectId, WithoutId } from "mongodb";
* This file is part of GenieACS. import { encodeTag } from "../util.ts";
* import { Fault, Task } from "../types.ts";
* GenieACS is free software: you can redistribute it and/or modify import { collections, filesBucket } from "../db/db.ts";
* it under the terms of the GNU Affero General Public License as import { convertOldPrecondition, optimizeProjection } from "../db/util.ts";
* published by the Free Software Foundation, either version 3 of the import * as MongoTypes from "../db/types.ts";
* License, or (at your option) any later version. import Expression, { parseList, Value } from "../common/expression.ts";
* import { toMongoQuery } from "../db/synth.ts";
* 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 { Db, GridFSBucket, ObjectId } from "mongodb"; function processDeviceProjection(
import { Script } from "vm"; projection: Record<string, 1>,
import { onConnect, optimizeProjection } from "../db"; ): Record<string, 1> {
import * as mongodbFunctions from "../mongodb-functions"; if (!projection) return projection;
import * as expression from "../common/expression"; const p = {};
import { QueryOptions, Expression } from "../types"; for (const [k, v] of Object.entries(projection)) {
import { Readable, Writable } from "stream"; if (k === "DeviceID.ID") {
import { minimize } from "../common/boolean-expression"; p["_id"] = 1;
} else if (k.startsWith("DeviceID")) {
p["_deviceId._SerialNumber"] = v;
p["_deviceId._OUI"] = v;
p["_deviceId._ProductClass"] = v;
p["_deviceId._Manufacturer"] = v;
} else if (k.startsWith("Tags")) {
p["_tags"] = v;
} else if (k.startsWith("Events")) {
p["_lastInform"] = v;
p["_registered"] = v;
p["_lastBoot"] = v;
p["_lastBootstrap"] = v;
} else {
p[k] = v;
}
}
const RESOURCE_COLLECTION = { return p;
files: "fs.files", }
};
let db: Db; function processDeviceSort(
sort: Record<string, number>,
): Record<string, number> {
if (!sort) return sort;
const s = {};
for (const [k, v] of Object.entries(sort)) {
if (k === "DeviceID.ID") s["_id"] = v;
else if (k.startsWith("DeviceID.")) s[`_deviceId._${k.slice(9)}`] = v;
else if (k === "Events.Inform") s["_lastInform"] = v;
else if (k === "Events.Registered") s["_registered"] = v;
else if (k === "Events.1_BOOT") s["_lastBoot"] = v;
else if (k === "Events.0_BOOTSTRAP") s["_lastBootstrap"] = v;
else s[`${k}._value`] = v;
}
onConnect(async (_db) => { return s;
db = _db; }
});
export function query( function parseDate(d: Date): number | string {
resource: string, const n = +d;
filter: Expression, return isNaN(n) ? "" + d : n;
options?: QueryOptions }
): Promise<any[]>;
export function query( export interface FlatDevice {
resource: string, [param: string]: Value;
filter: Expression, }
options: QueryOptions,
callback: (doc: any) => void export function flattenDevice(device: Record<string, unknown>): FlatDevice {
): Promise<void>; function recursive(
export function query( input,
root: string,
output: FlatDevice,
timestamp: number,
): void {
for (const [name, tree] of Object.entries(input)) {
if (!root) {
if (name === "_lastInform") {
output["Events.Inform"] = parseDate(tree as Date);
output["Events.Inform:type"] = "xsd:dateTime";
} else if (name === "_registered") {
output["Events.Registered"] = parseDate(tree as Date);
output["Events.Registered:type"] = "xsd:dateTime";
} else if (name === "_lastBoot") {
output["Events.1_BOOT"] = parseDate(tree as Date);
output["Events.1_BOOT:type"] = "xsd:dateTime";
} else if (name === "_lastBootstrap") {
output["Events.0_BOOTSTRAP"] = parseDate(tree as Date);
output["Events.0_BOOTSTRAP:type"] = "xsd:dateTime";
} else if (name === "_id") {
output["DeviceID.ID"] = tree as string;
output["DeviceID.ID:type"] = "xsd:dateTime";
} else if (name === "_deviceId") {
output["DeviceID.Manufacturer"] = tree["_Manufacturer"];
output["DeviceID.Manufacturer:type"] = "xsd:string";
output["DeviceID.OUI"] = tree["_OUI"];
output["DeviceID.OUI:type"] = "xsd:string";
output["DeviceID.ProductClass"] = tree["_ProductClass"];
output["DeviceID.ProductClass:type"] = "xsd:string";
output["DeviceID.SerialNumber"] = tree["_SerialNumber"];
output["DeviceID.SerialNumber:type"] = "xsd:string";
} else if (name === "_tags") {
output["Tags:object"] = true;
output["Tags:writable"] = true;
for (const t of tree as string[]) {
const et = encodeTag(t);
output[`Tags.${et}`] = true;
output[`Tags.${et}:type`] = "xsd:boolean";
output[`Tags.${et}:writable`] = true;
}
}
}
if (name.startsWith("_")) continue;
let childrenTimestamp = timestamp;
if (!root) childrenTimestamp = +(input["_timestamp"] || 1);
else if (+input["_timestamp"] > timestamp)
childrenTimestamp = +input["_timestamp"];
const r = root ? `${root}.${name}` : name;
if (tree["_value"] != null) {
output[r] =
tree["_value"] instanceof Date ? +tree["_value"] : tree["_value"];
output[`${r}:type`] = tree["_type"];
output[`${r}:timestamp`] = childrenTimestamp;
output[`${r}:valueTimestamp`] = +(
tree["_timestamp"] || childrenTimestamp
);
} else if (tree["_object"] != null) {
output[`${r}:object`] = tree["_object"];
output[`${r}:timestamp`] = childrenTimestamp;
}
if (tree["_writable"] != null) {
output[`${r}:writable`] = tree["_writable"];
output[`${r}:timestamp`] = childrenTimestamp;
}
if (tree["_notification"] != null) {
output[`${r}:notification`] = tree["_notification"];
output[`${r}:attributesTimestamp`] = +tree["_attributesTimestamp"] || 1;
}
if (tree["_accessList"] != null) {
output[`${r}:accessList`] = tree["_accessList"].join(",");
output[`${r}:attributesTimestamp`] = +tree["_attributesTimestamp"] || 1;
}
recursive(tree, r, output, childrenTimestamp);
}
}
const newDevice: FlatDevice = {};
const timestamp = new Date((device["_lastInform"] as Date) || 1).getTime();
recursive(device, "", newDevice, timestamp);
return newDevice;
}
function flattenFault(fault: unknown): Fault {
const f = Object.assign({}, fault) as Fault;
if (f.timestamp) f.timestamp = +f.timestamp;
if (f["expiry"]) f["expiry"] = +f["expiry"];
return f as Fault;
}
function flattenTask(task: unknown): Task {
const t = Object.assign({}, task) as Task;
t._id = "" + t._id;
if (t["timestamp"]) t["timestamp"] = +t["timestamp"];
if (t.expiry) t.expiry = +t.expiry;
return t;
}
function flattenPreset(
preset: Record<string, unknown>,
): Record<string, unknown> {
const p = Object.assign({}, preset);
if (p.precondition) {
try {
// Try parse to check expression validity
Expression.parse(p.precondition as string);
} catch {
const e = convertOldPrecondition(JSON.parse(p.precondition as string));
if (e instanceof Expression.Literal && e.value)
p.precondition = e.toString();
else p.precondition = "";
}
}
if (p.events) {
const e = [];
for (const [k, v] of Object.entries(p.events)) e.push(v ? k : `-${k}`);
p.events = e.join(", ");
}
const provision = p.configurations[0];
if (
(p.configurations as any[]).length === 1 &&
provision.type === "provision" &&
provision.name &&
provision.name.length
) {
p.provision = provision.name;
p.provisionArgs = provision.args
? provision.args.map((a) => a.toString()).join(", ")
: "";
}
delete p.configurations;
return p;
}
function flattenFile(file: Record<string, unknown>): Record<string, unknown> {
const f = {};
f["_id"] = file["_id"];
if (file.metadata) {
f["metadata.fileType"] = file["metadata"]["fileType"] || "";
f["metadata.oui"] = file["metadata"]["oui"] || "";
f["metadata.productClass"] = file["metadata"]["productClass"] || "";
f["metadata.version"] = file["metadata"]["version"] || "";
}
return f;
}
function preProcessPreset(data: Record<string, unknown>): MongoTypes.Preset {
const preset = Object.assign({}, data);
if (!preset.precondition) preset.precondition = "";
else Expression.parse(preset.precondition as string); // Try parse to check validity
preset.weight = parseInt(preset.weight as string) || 0;
const events = {};
if (preset.events) {
for (let e of (preset.events as string).split(",")) {
let v = true;
e = e.trim();
if (e.startsWith("-")) {
v = false;
e = e.slice(1).trim();
}
if (e) events[e] = v;
}
}
preset.events = events;
if (!preset.provision) throw new Error("Invalid preset provision");
const configuration = {
type: "provision",
name: preset.provision,
args: null,
};
if (preset.provisionArgs)
configuration.args = parseList(preset.provisionArgs as string);
delete preset.provision;
delete preset.provisionArgs;
preset.configurations = [configuration];
return preset as unknown as MongoTypes.Preset;
}
interface QueryOptions {
projection?: any;
skip?: number;
limit?: number;
sort?: {
[param: string]: number;
};
}
export async function* query(
resource: string, resource: string,
filter: Expression, filter: Expression,
options?: QueryOptions, options?: QueryOptions,
callback?: (doc: any) => void ): AsyncGenerator<any, void, undefined> {
): Promise<void | any[]> {
options = options || {}; options = options || {};
let q; const now = Date.now();
filter = expression.evaluate(filter, null, Date.now()); filter = filter.evaluate((e) => {
filter = minimize(filter, true); if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(now);
if (Array.isArray(filter)) {
if (resource === "devices") {
filter = mongodbFunctions.processDeviceFilter(filter);
} else if (resource === "tasks") {
filter = mongodbFunctions.processTasksFilter(filter);
} else if (resource === "faults") {
filter = mongodbFunctions.processFaultsFilter(filter);
} else if (resource === "users") {
// Protect against brute force, and dictionary attacks
const params = expression.extractParams(filter);
if (params.includes("password") || params.includes("salt"))
return Promise.reject(new Error("Invalid users filter"));
} }
return e;
});
const q = toMongoQuery(filter, resource);
if (!q) return;
q = mongodbFunctions.filterToMongoQuery(filter); const collection = collections[resource] as Collection<any>;
} else if (!filter) { const cursor = collection.find(q);
return Promise.resolve([]); if (options.projection) {
let projection = options.projection;
if (resource === "devices")
projection = processDeviceProjection(options.projection);
if (resource === "presets") projection.configurations = 1;
projection = optimizeProjection(projection);
cursor.project(projection);
} }
return new Promise((resolve, reject) => { if (resource === "users") cursor.project({ password: 0, salt: 0 });
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
const cursor = collection.find(q);
if (options.projection) {
let projection = options.projection;
if (resource === "devices") {
projection = mongodbFunctions.processDeviceProjection(
options.projection
);
}
if (resource === "presets") projection.configurations = 1; if (options.skip) cursor.skip(options.skip);
projection = optimizeProjection(projection); if (options.limit) cursor.limit(options.limit);
cursor.project(projection);
}
if (resource === "users") cursor.project({ password: 0, salt: 0 }); if (options.sort) {
let s = Object.entries(options.sort)
if (options.skip) cursor.skip(options.skip); .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
if (options.limit) cursor.limit(options.limit); .reduce(
(obj, [k, v]) =>
if (options.sort) { Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }),
let s = Object.entries(options.sort) {},
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
.reduce(
(obj, [k, v]) =>
Object.assign(obj, { [k]: Math.min(Math.max(v, -1), 1) }),
{}
);
if (resource === "devices") s = mongodbFunctions.processDeviceSort(s);
cursor.sort(s);
}
if (!callback) {
cursor.toArray((err, docs) => {
if (err) return reject(err);
if (resource === "devices")
return resolve(docs.map((d) => mongodbFunctions.flattenDevice(d)));
else if (resource === "faults")
return resolve(docs.map((d) => mongodbFunctions.flattenFault(d)));
else if (resource === "tasks")
return resolve(docs.map((d) => mongodbFunctions.flattenTask(d)));
else if (resource === "presets")
return resolve(docs.map((d) => mongodbFunctions.flattenPreset(d)));
else if (resource === "files")
return resolve(docs.map((d) => mongodbFunctions.flattenFile(d)));
return resolve(docs);
});
} else {
cursor.forEach(
(doc) => {
if (resource === "devices")
callback(mongodbFunctions.flattenDevice(doc));
else if (resource === "faults")
callback(mongodbFunctions.flattenFault(doc));
else if (resource === "tasks")
callback(mongodbFunctions.flattenTask(doc));
else if (resource === "presets")
callback(mongodbFunctions.flattenPreset(doc));
else if (resource === "files")
callback(mongodbFunctions.flattenFile(doc));
else callback(doc);
},
(err) => {
if (err) reject(err);
else resolve();
}
); );
}
}); if (resource === "devices") s = processDeviceSort(s);
cursor.sort(s);
}
for await (let doc of cursor) {
if (resource === "devices") doc = flattenDevice(doc);
else if (resource === "faults") doc = flattenFault(doc);
else if (resource === "tasks") doc = flattenTask(doc);
else if (resource === "presets") doc = flattenPreset(doc);
else if (resource === "files") doc = flattenFile(doc);
yield doc;
}
} }
export function count(resource: string, filter: Expression): Promise<number> { export function count(resource: string, filter: Expression): Promise<number> {
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource); const collection = collections[resource] as Collection<any>;
let q: Parameters<typeof collection.countDocuments>[0]; const now = Date.now();
filter = expression.evaluate(filter, null, Date.now()); filter = filter.evaluate((e) => {
filter = minimize(filter, true); if (e instanceof Expression.FunctionCall) {
if (e.name === "NOW") return new Expression.Literal(now);
if (Array.isArray(filter)) { }
if (resource === "devices") return e;
filter = mongodbFunctions.processDeviceFilter(filter); });
else if (resource === "tasks") const q = toMongoQuery(filter, resource);
filter = mongodbFunctions.processTasksFilter(filter); if (!q) return Promise.resolve(0);
else if (resource === "faults")
filter = mongodbFunctions.processFaultsFilter(filter);
q = mongodbFunctions.filterToMongoQuery(filter);
} else if (!filter) {
return Promise.resolve(0);
}
return collection.countDocuments(q); return collection.countDocuments(q);
} }
export async function updateDeviceTags( export async function updateDeviceTags(
deviceId: string, deviceId: string,
tags: Record<string, boolean> tags: Record<string, boolean>,
): Promise<void> { ): Promise<void> {
const add = []; const add = [];
const pull = []; const pull = [];
@@ -183,54 +356,29 @@ export async function updateDeviceTags(
if (onOff) add.push(tag); if (onOff) add.push(tag);
else pull.push(tag); else pull.push(tag);
} }
const collection = db.collection("devices");
const object = {}; const object = {};
if (add?.length) object["$addToSet"] = { _tags: { $each: add } }; if (add?.length) object["$addToSet"] = { _tags: { $each: add } };
if (pull?.length) object["$pullAll"] = { _tags: pull }; if (pull?.length) object["$pullAll"] = { _tags: pull };
await collection.updateOne({ _id: deviceId }, object); await collections.devices.updateOne({ _id: deviceId }, object);
} }
function putResource(resource, id, object): Promise<void> { export async function putPreset(
return new Promise((resolve, reject) => {
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
collection.replaceOne({ _id: id }, object, { upsert: true }, (err) => {
if (err) return void reject(err);
resolve();
});
});
}
function deleteResource(
resource: string,
id: string | ObjectId
): Promise<void> {
return new Promise((resolve, reject) => {
const collection = db.collection(RESOURCE_COLLECTION[resource] || resource);
collection.deleteOne({ _id: id }, (err) => {
if (err) return void reject(err);
resolve();
});
});
}
export function putPreset(
id: string, id: string,
object: Record<string, unknown> object: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
object = mongodbFunctions.preProcessPreset(object); const p = preProcessPreset(object);
return putResource("presets", id, object); await collections.presets.replaceOne({ _id: id }, p, { upsert: true });
} }
export function deletePreset(id: string): Promise<void> { export async function deletePreset(id: string): Promise<void> {
return deleteResource("presets", id); await collections.presets.deleteOne({ _id: id });
} }
export function putProvision( export async function putProvision(
id: string, id: string,
object: Record<string, unknown> object: { script: string },
): Promise<void> { ): Promise<void> {
if (!object.script) object.script = ""; if (!object.script) object.script = "";
try { try {
@@ -241,21 +389,23 @@ export function putProvision(
} catch (err) { } catch (err) {
if (err.stack?.startsWith(`${id}:`)) { if (err.stack?.startsWith(`${id}:`)) {
return Promise.reject( return Promise.reject(
new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`) new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`),
); );
} }
return Promise.reject(err); return Promise.reject(err);
} }
return putResource("provisions", id, object); await collections.provisions.replaceOne({ _id: id }, object, {
upsert: true,
});
} }
export function deleteProvision(id: string): Promise<void> { export async function deleteProvision(id: string): Promise<void> {
return deleteResource("provisions", id); await collections.provisions.deleteOne({ _id: id });
} }
export function putVirtualParameter( export async function putVirtualParameter(
id: string, id: string,
object: Record<string, unknown> object: { script: string },
): Promise<void> { ): Promise<void> {
if (!object.script) object.script = ""; if (!object.script) object.script = "";
try { try {
@@ -266,103 +416,108 @@ export function putVirtualParameter(
} catch (err) { } catch (err) {
if (err.stack?.startsWith(`${id}:`)) { if (err.stack?.startsWith(`${id}:`)) {
return Promise.reject( return Promise.reject(
new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`) new Error(`${err.name} at ${err.stack.split("\n", 1)[0]}`),
); );
} }
return Promise.reject(err); return Promise.reject(err);
} }
return putResource("virtualParameters", id, object); await collections.virtualParameters.replaceOne({ _id: id }, object, {
} upsert: true,
export function deleteVirtualParameter(id: string): Promise<void> {
return deleteResource("virtualParameters", id);
}
export function putConfig(
id: string,
object: Record<string, unknown>
): Promise<void> {
return putResource("config", id, object);
}
export function deleteConfig(id: string): Promise<void> {
return deleteResource("config", id);
}
export function putPermission(
id: string,
object: Record<string, unknown>
): Promise<void> {
return putResource("permissions", id, object);
}
export function deletePermission(id: string): Promise<void> {
return deleteResource("permissions", id);
}
export function putUser(
id: string,
object: Record<string, unknown>
): Promise<void> {
return new Promise((resolve, reject) => {
const collection = db.collection("users");
// update instead of replace to keep the password if not set by user
collection.updateOne(
{ _id: id },
{ $set: object },
{ upsert: true },
(err) => {
if (err) return void reject(err);
resolve();
}
);
}); });
} }
export function deleteUser(id: string): Promise<void> { export async function deleteVirtualParameter(id: string): Promise<void> {
return deleteResource("users", id); await collections.virtualParameters.deleteOne({ _id: id });
}
export async function putConfig(
id: string,
object: WithoutId<MongoTypes.Config>,
): Promise<void> {
await collections.config.replaceOne({ _id: id }, object, { upsert: true });
}
export async function deleteConfig(id: string): Promise<void> {
await collections.config.deleteOne({ _id: id });
}
export async function putPermission(
id: string,
object: WithoutId<MongoTypes.Permission>,
): Promise<void> {
await collections.permissions.replaceOne({ _id: id }, object, {
upsert: true,
});
}
export async function deletePermission(id: string): Promise<void> {
await collections.permissions.deleteOne({ _id: id });
}
export async function putUser(
id: string,
object: Partial<WithoutId<MongoTypes.User>>,
): Promise<void> {
// update instead of replace to keep the password if not set by user
await collections.users.updateOne(
{ _id: id },
{ $set: object },
{ upsert: true },
);
}
export async function deleteUser(id: string): Promise<void> {
await collections.users.deleteOne({ _id: id });
} }
export function downloadFile(filename: string): Readable { export function downloadFile(filename: string): Readable {
const bucket = new GridFSBucket(db); return filesBucket.openDownloadStreamByName(filename);
return bucket.openDownloadStreamByName(filename);
} }
export function putFile( export function putFile(
filename: string, filename: string,
metadata: Record<string, string>, metadata: Record<string, string>,
contentStream: Readable contentStream: Readable,
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const bucket = new GridFSBucket(db); const uploadStream = filesBucket.openUploadStreamWithId(
const uploadStream = bucket.openUploadStreamWithId(
filename as unknown as ObjectId, filename as unknown as ObjectId,
filename, filename,
{ {
metadata: metadata, metadata: metadata,
} },
); );
uploadStream.on("error", reject);
contentStream.on("error", reject);
uploadStream.on("finish", resolve);
contentStream.pipe(uploadStream as Writable);
});
}
export function deleteFile(filename: string): Promise<void> { let readableEnded = false;
return new Promise((resolve, reject) => { contentStream.on("end", () => {
const bucket = new GridFSBucket(db); readableEnded = true;
bucket.delete(filename as any, (err) => {
if (err) return void reject(err);
resolve();
}); });
contentStream.on("close", () => {
// In Node versions prior to 15, the stream will not emit an error if the
// connection is closed before the stream is finished.
// For Node 12.9+ we can just use stream.readableEnded
if (!readableEnded)
uploadStream.destroy(new Error("Stream closed prematurely"));
});
contentStream.on("error", (err) => {
uploadStream.destroy(err);
});
uploadStream.on("error", reject);
uploadStream.on("finish", resolve);
contentStream.pipe(uploadStream);
}); });
} }
export function deleteFault(id: string): Promise<void> { export async function deleteFile(filename: string): Promise<void> {
return deleteResource("faults", id); await filesBucket.delete(filename as any);
} }
export function deleteTask(id: ObjectId): Promise<void> { export async function deleteFault(id: string): Promise<void> {
return deleteResource("tasks", id); await collections.faults.deleteOne({ _id: id });
}
export async function deleteTask(id: ObjectId): Promise<void> {
await collections.tasks.deleteOne({ _id: id });
} }
+160
View File
@@ -0,0 +1,160 @@
import * as crypto from "node:crypto";
import { collections } from "../db/db.ts";
import Expression, { Value } from "../common/expression.ts";
import { Users, Permissions, Config, UiConfig } from "../types.ts";
import { LocalCache } from "../local-cache.ts";
interface Snapshot {
permissions: Permissions;
users: Users;
config: Config;
ui: UiConfig;
}
async function fetchPermissions(): Promise<[string, Permissions]> {
const perms = await collections.permissions.find().toArray();
perms.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto
.createHash("md5")
.update(JSON.stringify(perms))
.digest("hex");
const permissions: Permissions = {};
for (const p of perms) {
if (!permissions[p.role]) permissions[p.role] = {};
if (!permissions[p.role][p.access]) permissions[p.role][p.access] = {};
let validate: Expression;
if (p.validate) validate = Expression.parse(p.validate);
else validate = new Expression.Literal(true);
permissions[p.role][p.access][p.resource] = {
access: p.access,
filter: Expression.parse(p.filter || "true"),
validate,
};
}
return [h, permissions];
}
async function fetchUsers(): Promise<[string, Users]> {
const _users = await collections.users.find().toArray();
_users.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto
.createHash("md5")
.update(JSON.stringify(_users))
.digest("hex");
const users = {};
for (const user of _users) {
users[user._id] = {
password: user.password,
salt: user.salt,
roles: user.roles.split(",").map((s) => s.trim()),
};
}
return [h, users];
}
async function fetchConfig(): Promise<[string, Config, UiConfig]> {
const conf = await collections.config.find().toArray();
conf.sort((a, b) => (a._id > b._id ? 1 : -1));
const h = crypto.createHash("md5").update(JSON.stringify(conf)).digest("hex");
const ui: UiConfig = {};
const _config = {};
for (const c of conf) {
if (c._id.startsWith("ui.")) {
ui[c._id.slice(3)] = c.value;
continue;
}
// Evaluate expressions to simplify them
const val = Expression.parse(c.value).evaluate((e) => e);
_config[c._id] = val;
}
return [h, _config, ui];
}
const localCache = new LocalCache<Snapshot>("ui-local-cache-hash", refresh);
async function refresh(): Promise<[string, Snapshot]> {
const res = await Promise.all([
fetchPermissions(),
fetchUsers(),
fetchConfig(),
]);
const h = crypto.createHash("md5");
for (const r of res) h.update(r[0]);
const snapshot = {
permissions: res[0][1],
users: res[1][1],
config: res[2][1],
ui: res[2][2],
};
return [h.digest("hex"), snapshot];
}
export async function getRevision(): Promise<string> {
return await localCache.getRevision();
}
export function getConfig(
revision: string,
key: string,
dflt: string,
fn: (e: Expression) => Expression.Literal,
): string;
export function getConfig(
revision: string,
key: string,
dflt: number,
fn: (e: Expression) => Expression.Literal,
): number;
export function getConfig(
revision: string,
key: string,
dflt: boolean,
fn: (e: Expression) => Expression.Literal,
): boolean;
export function getConfig(
revision: string,
key: string,
dflt: Value,
fn: (e: Expression) => Expression.Literal,
): Value {
const snapshot = localCache.get(revision);
if (!snapshot) throw new Error("Cache snapshot does not exist");
const e = snapshot.config[key];
if (!e) return dflt;
const v = e.evaluate(fn).value;
if (typeof v !== typeof dflt) return dflt;
return v;
}
export function getConfigExpression(revision: string, key: string): Expression {
const snapshot = localCache.get(revision);
return snapshot.config[key];
}
export function getUsers(revision: string): Users {
const snapshot = localCache.get(revision);
return snapshot.users;
}
export function getPermissions(revision: string): Permissions {
const snapshot = localCache.get(revision);
return snapshot.permissions;
}
export function getUiConfig(revision: string): UiConfig {
const snapshot = localCache.get(revision);
return snapshot.ui;
}
+27 -20
View File
@@ -1,24 +1,7 @@
/** import { EventEmitter } from "node:events";
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
export function generateDeviceId( export function generateDeviceId(
deviceIdStruct: Record<string, string> deviceIdStruct: Record<string, string>,
): string { ): string {
// Escapes everything except alphanumerics and underscore // Escapes everything except alphanumerics and underscore
function esc(str): string { function esc(str): string {
@@ -52,7 +35,7 @@ export function encodeTag(tag: string): string {
return encodeURIComponent(tag) return encodeURIComponent(tag)
.replace( .replace(
/[!~*'().]/g, /[!~*'().]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase() (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
) )
.replace(/0x(?=[0-9A-Z]{2})/g, "0%78") .replace(/0x(?=[0-9A-Z]{2})/g, "0%78")
.replace(/%/g, "0x"); .replace(/%/g, "0x");
@@ -61,3 +44,27 @@ export function encodeTag(tag: string): string {
export function decodeTag(tag: string): string { export function decodeTag(tag: string): string {
return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, "%")); return decodeURIComponent(tag.replace(/0x(?=[0-9A-Z]{2})/g, "%"));
} }
export function once(
emitter: EventEmitter,
event: string,
timeout: number,
): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Event ${event} timed out after ${timeout} ms`));
}, timeout);
emitter.once(event, (...args: unknown[]) => {
clearTimeout(timer);
resolve(args);
});
});
}
export function setTimeoutPromise(delay: number, ref = true): Promise<void> {
return new Promise((resolve) => {
const timerId = setTimeout(resolve, delay);
if (!ref) timerId.unref();
});
}
+18 -25
View File
@@ -1,22 +1,3 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
const NONEXISTENT = Symbol(); const NONEXISTENT = Symbol();
const UNDEFINED = undefined; const UNDEFINED = undefined;
@@ -26,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];
@@ -131,6 +112,8 @@ export default class VersionedMap<K, V> {
prev = v; prev = v;
} }
if (prev === NONEXISTENT && !res.delete) return null;
return res; return res;
} }
@@ -138,9 +121,11 @@ export default class VersionedMap<K, V> {
const del = revisionsObj.delete || 0; const del = revisionsObj.delete || 0;
const mutations = Object.keys(revisionsObj).reduce( const mutations = Object.keys(revisionsObj).reduce(
(acc, cur) => (cur === "delete" ? acc : acc | (1 << +cur)), (acc, cur) => (cur === "delete" ? acc : acc | (1 << +cur)),
del del,
); );
if (!mutations) return;
const revisions = []; const revisions = [];
let prev: V | symbol = NONEXISTENT; let prev: V | symbol = NONEXISTENT;
@@ -168,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;
@@ -198,4 +183,12 @@ export default class VersionedMap<K, V> {
this.map.delete(k); this.map.delete(k);
} }
} }
public *[Symbol.iterator](): IterableIterator<[K, V]> {
for (const [key, revisions] of this.map) {
const last = revisions[revisions.length - 1];
if (last === NONEXISTENT) continue;
yield [key, last as V];
}
}
} }
+1 -20
View File
@@ -1,22 +1,3 @@
/**
* Copyright 2013-2019 GenieACS Inc.
*
* This file is part of GenieACS.
*
* GenieACS is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* GenieACS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with GenieACS. If not, see <http://www.gnu.org/licenses/>.
*/
const CHAR_SINGLE_QUOTE = 39; const CHAR_SINGLE_QUOTE = 39;
const CHAR_DOUBLE_QUOTE = 34; const CHAR_DOUBLE_QUOTE = 34;
const CHAR_LESS_THAN = 60; const CHAR_LESS_THAN = 60;
@@ -60,7 +41,7 @@ export function parseXmlDeclaration(buffer: Buffer): Attribute[] {
str = str.slice(0, str.indexOf("?>")); str = str.slice(0, str.indexOf("?>"));
try { try {
return parseAttrs(str.slice(5)); return parseAttrs(str.slice(5));
} catch (err) { } catch {
// Ignore // Ignore
} }
} }
+460
View File
@@ -0,0 +1,460 @@
import * as net from "node:net";
import * as tls from "node:tls";
import { EventEmitter } from "node:events";
import { createHash, createHmac, randomBytes } from "node:crypto";
import { parseXml, Element, parseAttrs } from "./xml-parser.ts";
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 async connect(opts: XmppClientOptions): Promise<XmppClient> {
function connectSocket(host: string, port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
socket.on("error", reject);
socket.connect(port, host, () => {
socket.removeListener("error", reject);
resolve(socket);
});
});
}
let socket = await connectSocket(opts.host, opts.port || 5222);
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),
);
}
} catch (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 {
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 }));
});
});
}
}
+7202 -10504
View File
File diff suppressed because it is too large. Load diff
+50 -55
View File
@@ -1,6 +1,6 @@
{ {
"name": "genieacs", "name": "genieacs",
"version": "1.2.9", "version": "1.3.0-dev",
"description": "A TR-069 Auto Configuration Server (ACS)", "description": "A TR-069 Auto Configuration Server (ACS)",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -12,74 +12,69 @@
"CWMP", "CWMP",
"ACS" "ACS"
], ],
"author": "Zaid Abdulla <zaid@genieacs.com>", "author": {
"name": "GenieACS Inc.",
"url": "https://genieacs.com"
},
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"directories": { "bin": {
"bin": "bin" "genieacs-cwmp": "bin/genieacs-cwmp",
"genieacs-fs": "bin/genieacs-fs",
"genieacs-nbi": "bin/genieacs-nbi",
"genieacs-ui": "bin/genieacs-ui"
}, },
"dependencies": { "dependencies": {
"@breejs/later": "^4.1.0", "@breejs/later": "^4.2.0",
"espresso-iisojs": "^1.0.4", "@koa/bodyparser": "^5.1.2",
"@koa/router": "^13.1.1",
"bson": "^4.7.2",
"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": "^8.5.1", "jsonwebtoken": "^9.0.3",
"koa": "^2.13.4", "koa": "^2.16.4",
"koa-bodyparser": "^4.3.0", "koa-compress": "^5.2.0",
"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.9.0",
"parsimmon": "^1.18.1",
"seedrandom": "^3.0.5" "seedrandom": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^22.0.2", "@codemirror/commands": "^6.10.2",
"@rollup/plugin-json": "^4.1.0", "@codemirror/lang-javascript": "^6.2.5",
"@rollup/plugin-node-resolve": "^13.3.0", "@codemirror/lang-yaml": "^6.1.2",
"@rollup/plugin-typescript": "^8.3.4", "@codemirror/language": "^6.12.2",
"@types/codemirror": "^5.60.5", "@codemirror/state": "^6.5.4",
"@types/jsonwebtoken": "^8.5.8", "@codemirror/view": "^6.39.16",
"@types/koa": "^2.13.5", "@eslint/js": "^10.0.1",
"@types/koa-compress": "^4.0.3", "@tailwindcss/cli": "^4.2.1",
"@types/koa-router": "^7.4.4", "@tailwindcss/forms": "^0.5.11",
"@types/koa-static": "^4.0.2", "@types/jsonwebtoken": "^9.0.10",
"@types/mithril": "^2.0.11", "@types/koa": "^2.15.0",
"@types/node": "^18.7.9", "@types/koa-compress": "^4.0.7",
"@types/parsimmon": "^1.10.6", "@types/mithril": "^2.2.7",
"@types/seedrandom": "^3.0.2", "@types/node": "^25.3.5",
"@typescript-eslint/eslint-plugin": "^5.33.1", "@types/seedrandom": "^3.0.8",
"@typescript-eslint/parser": "^5.33.1", "esbuild": "^0.27.4",
"ava": "^3.15.0", "eslint": "^10.0.3",
"codemirror": "^5.65.8", "eslint-config-prettier": "^10.1.8",
"cssnano": "^5.1.13", "globals": "^17.4.0",
"eslint": "^8.22.0", "mithril": "^2.3.8",
"eslint-config-prettier": "^8.5.0", "prettier": "^3.8.1",
"esm": "^3.2.25", "sql.js": "^1.14.1",
"mithril": "^2.2.2", "svgo": "^3.3.3",
"postcss": "^8.4.16", "tailwindcss": "^4.2.1",
"postcss-color-mod-function": "^3.0.3", "typescript": "^5.9.3",
"postcss-import": "^14.1.0", "typescript-eslint": "^8.56.1",
"postcss-preset-env": "^7.8.0",
"prettier": "^2.7.1",
"rollup": "^2.78.1",
"rollup-plugin-terser": "^7.0.2",
"sql.js": "^1.7.0",
"svgo": "^2.8.0",
"ts-node": "^10.9.1",
"typescript": "^4.7.4",
"yaml": "^1.10.2" "yaml": "^1.10.2"
}, },
"engines": { "engines": {
"node": ">=12.13.0" "node": ">=12.13.0"
}, },
"scripts": { "scripts": {
"test": "ava --verbose --color", "test": "esbuild --log-level=warning --bundle --platform=node --target=node18 --packages=external --sourcemap=inline --outdir=test test/*.ts && node --test --enable-source-maps test/*.js && rm test/*.js",
"lint-eslint": "eslint --fix \"bin/*.ts\" \"lib/**/*.ts\" \"ui/**/*.ts\" \"test/**/*.ts\" \"build/**/*.ts\"", "lint": "esbuild build/lint.ts --bundle --platform=node --target=node16 --packages=external | node",
"lint-prettier": "prettier --prose-wrap always --write .", "build": "esbuild build/build.ts --bundle --platform=node --target=node12 --packages=external | node"
"lint-tsc": "tsc --noEmit",
"lint": "npm run lint-eslint && npm run lint-prettier && npm run lint-tsc",
"build": "node -r esm -r ts-node/register/transpile-only build/build"
} }
} }
+5
View File
@@ -0,0 +1,5 @@
const now = Date.now();
// Clear cached data model to force a refresh
clear("Device", now);
clear("InternetGatewayDevice", now);
+40
View File
@@ -0,0 +1,40 @@
const hourly = Date.now(3600000);
// Refresh basic parameters hourly
declare("InternetGatewayDevice.DeviceInfo.HardwareVersion", {
path: hourly,
value: hourly,
});
declare("InternetGatewayDevice.DeviceInfo.SoftwareVersion", {
path: hourly,
value: hourly,
});
declare(
"InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.MACAddress",
{ path: hourly, value: hourly },
);
declare(
"InternetGatewayDevice.WANDevice.*.WANConnectionDevice.*.WANIPConnection.*.ExternalIPAddress",
{ path: hourly, value: hourly },
);
declare("InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.SSID", {
path: hourly,
value: hourly,
});
// Don't refresh password field periodically because CPEs always report blank passowrds for security reasons
declare("InternetGatewayDevice.LANDevice.*.WLANConfiguration.*.KeyPassphrase", {
path: hourly,
value: 1,
});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.HostName", {
path: hourly,
value: hourly,
});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.IPAddress", {
path: hourly,
value: hourly,
});
declare("InternetGatewayDevice.LANDevice.*.Hosts.Host.*.MACAddress", {
path: hourly,
value: hourly,
});
+67
View File
@@ -0,0 +1,67 @@
// Device ID as user name
const username = declare("DeviceID.ID", { value: 1 }).value[0];
// Password will be fixed for a given device because Math.random() is seeded with device ID by default.
const password = Math.trunc(Math.random() * Number.MAX_SAFE_INTEGER).toString(
36,
);
const informInterval = 300;
// Refresh values daily
const daily = Date.now(86400000);
// Unique inform offset per device for better load distribution
const informTime = daily % 86400000;
declare(
"InternetGatewayDevice.ManagementServer.ConnectionRequestUsername",
{ value: daily },
{ value: username },
);
declare(
"InternetGatewayDevice.ManagementServer.ConnectionRequestPassword",
{ value: daily },
{ value: password },
);
declare(
"InternetGatewayDevice.ManagementServer.PeriodicInformEnable",
{ value: daily },
{ value: true },
);
declare(
"InternetGatewayDevice.ManagementServer.PeriodicInformInterval",
{ value: daily },
{ value: informInterval },
);
declare(
"InternetGatewayDevice.ManagementServer.PeriodicInformTime",
{ value: daily },
{ value: informTime },
);
declare(
"Device.ManagementServer.ConnectionRequestUsername",
{ value: daily },
{ value: username },
);
declare(
"Device.ManagementServer.ConnectionRequestPassword",
{ value: daily },
{ value: password },
);
declare(
"Device.ManagementServer.PeriodicInformEnable",
{ value: daily },
{ value: true },
);
declare(
"Device.ManagementServer.PeriodicInformInterval",
{ value: daily },
{ value: informInterval },
);
declare(
"Device.ManagementServer.PeriodicInformTime",
{ value: daily },
{ value: informTime },
);
+71
View File
@@ -0,0 +1,71 @@
interface Timestamps {
path?: number;
object?: number;
writable?: number;
value?: number;
notification?: number;
accessList?: number;
}
interface Values {
path?: number | [number, number];
object?: boolean;
writable?: boolean;
value?: string | number | boolean | [string | number | boolean, string?];
notification?: number;
accessList?: string[];
}
interface ParameterWrapper extends Iterable<ParameterWrapper> {
readonly path: string | undefined;
readonly size: number | undefined;
readonly object: 0 | 1 | undefined;
readonly writable: 0 | 1 | undefined;
readonly value: [string | number | boolean, string] | undefined;
readonly notification: number | undefined;
readonly accessList: string[] | undefined;
}
declare function declare(
path: string,
timestamps?: Timestamps | null,
values?: Values | null,
): ParameterWrapper;
declare function clear(
path: string,
timestamp: number,
attributes?: Timestamps,
): void;
declare function commit(): void;
declare function ext(...args: unknown[]): unknown;
declare function log(msg: string, meta?: Record<string, unknown>): void;
declare const args: unknown[];
interface DateConstructor {
new (): Date;
new (
year: number,
monthIndex?: number,
day?: number,
hours?: number,
minutes?: number,
seconds?: number,
milliseconds?: number,
): Date;
now(intervalOrCron?: number | string, variance?: number): number;
parse(dateString: string): number;
UTC(
year: number,
monthIndex?: number,
day?: number,
hours?: number,
minutes?: number,
seconds?: number,
milliseconds?: number,
): number;
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES2022",
"types": []
},
"include": ["./*.js", "./*.d.ts"]
}
+9 -10
View File
@@ -1,8 +1,9 @@
import ava from "ava"; import test from "node:test";
import { randomBytes } from "crypto"; import assert from "node:assert";
import * as auth from "../lib/auth"; import { randomBytes } from "node:crypto";
import * as auth from "../lib/auth.ts";
ava("digest", (t) => { void test("digest", () => {
const username = "test"; const username = "test";
const password = "test"; const password = "test";
const uri = "/"; const uri = "/";
@@ -17,8 +18,6 @@ ava("digest", (t) => {
`Digest realm="${realm}",nonce="${nonce}",qop="auth-int"`, `Digest realm="${realm}",nonce="${nonce}",qop="auth-int"`,
]; ];
t.plan(challenges.length);
for (const challenge of challenges) { for (const challenge of challenges) {
const wwwAuthHeader = auth.parseWwwAuthenticateHeader(challenge); const wwwAuthHeader = auth.parseWwwAuthenticateHeader(challenge);
const solution = auth.solveDigest( const solution = auth.solveDigest(
@@ -27,10 +26,10 @@ ava("digest", (t) => {
uri, uri,
method, method,
body, body,
wwwAuthHeader wwwAuthHeader,
); );
const authHeader = auth.parseAuthorizationHeader(solution); const authHeader = auth.parseAuthorizationHeader(solution);
t.is( assert.strictEqual(
authHeader["response"], authHeader["response"],
auth.digest( auth.digest(
username, username,
@@ -42,8 +41,8 @@ ava("digest", (t) => {
authHeader["qop"], authHeader["qop"],
body, body,
authHeader["cnonce"], authHeader["cnonce"],
authHeader["nc"] authHeader["nc"],
) ),
); );
} }
}); });
-118
View File
@@ -1,118 +0,0 @@
import ava, { TestInterface } from "ava";
import { minimize, unionDiff } from "../lib/common/boolean-expression";
import { parse, stringify } from "../lib/common/expression-parser";
import initSqlJs from "sql.js/dist/sql-asm.js";
const test = ava as TestInterface<{
query: (filter: string) => Promise<Set<number>>;
}>;
const STRING_VALUES = [null, "", "a", "ab", "ab10", "ab-10"];
const DECIMAL_VALUES = [null, 0, -10, 10];
test.before(async (t) => {
const sql = await initSqlJs();
const db = new sql.Database();
t.context.query = async (filter: string): Promise<Set<number>> => {
const res = db.exec(`SELECT id FROM test WHERE ${filter}`);
if (!res.length) return new Set();
return new Set(res[0].values.flat());
};
db.run(
"CREATE TABLE test (id INTEGER PRIMARY KEY, string STRING, decimal DECIMAL(4,2))"
);
const stmt = db.prepare("INSERT INTO test (string, decimal) VALUES (?, ?)");
const count = STRING_VALUES.length * DECIMAL_VALUES.length;
for (let i = 0; i < count; ++i) {
const str = i % STRING_VALUES.length;
const dec = Math.trunc(i / STRING_VALUES.length) % DECIMAL_VALUES.length;
stmt.run([STRING_VALUES[str], DECIMAL_VALUES[dec]]);
}
stmt.free();
});
function setsEqual(set1: Set<number>, set2: Set<number>): boolean {
if (set1.size !== set2.size) return false;
for (const s of set1) if (!set2.has(s)) return false;
return true;
}
function getPermutations(...arrs: any[][]): any[][] {
const count = arrs.reduce((total, arr) => total * arr.length, 1);
const res = [];
for (let i = 0; i < count; ++i) {
let j = i;
const row = [];
for (const arr of arrs) {
const v = arr[j % arr.length];
j = Math.trunc(j / arr.length);
row.push(v);
}
res.push(row);
}
return res;
}
test("minimize", async (t) => {
const cases: string[] = [];
cases.push("null");
cases.push("false");
cases.push("true");
cases.push("string");
cases.push("(string + decimal) IS NULL");
cases.push("(string + decimal) = NULL");
cases.push("COALESCE(string, decimal) = 0");
for (const [s1, s2, s3, op1, op2] of getPermutations(
STRING_VALUES.filter((s) => s),
STRING_VALUES.filter((s) => s),
STRING_VALUES.filter((s) => s),
[">", "=", "<"],
["<>", ">="]
)) {
cases.push(
`string ${op1} "${s1}" OR string ='${s2}' OR NOT string ${op2} '${s3}'`
);
}
for (const [s1, s2] of getPermutations(
STRING_VALUES.filter((s) => s),
STRING_VALUES.filter((s) => s)
))
cases.push(`string > "${s1}" AND string < '${s2}'`);
for (const c of cases) {
const res1 = await t.context.query(c);
const min = stringify(minimize(parse(c), true));
const res2 = await t.context.query(min);
t.true(setsEqual(res1, res2));
}
});
test("unionDiff", async (t) => {
const cases = [
"true",
"decimal > 0",
"decimal > 10",
"UPPER(string || decimal) LIKE 'AB10'",
"COALESCE(string, decimal) = 0",
];
for (const [c1, c2] of getPermutations(cases, cases)) {
const res1 = await t.context.query(c1);
const res2 = await t.context.query(c2);
const [union, diff] = unionDiff(parse(c1), parse(c2));
const res3 = await t.context.query(stringify(union));
const res4 = await t.context.query(stringify(diff));
const unionSet = new Set([...res1, ...res2]);
const diffSet = new Set(Array.from(res2).filter((r) => !res1.has(r)));
t.true(setsEqual(res3, unionSet));
t.true(setsEqual(res4, diffSet));
}
});
+165
View File
@@ -0,0 +1,165 @@
import test from "node:test";
import assert from "node:assert";
import { EJSON } from "bson";
import { Filter } from "mongodb";
import Expression from "../lib/common/expression.ts";
import { convertOldPrecondition } from "../lib/db/util.ts";
import { toMongoQuery } from "../lib/db/synth.ts";
void test("convertOldPrecondition", () => {
const tests = [
[{}, "TRUE"],
[{ test: "test" }, 'test = "test"'],
[{ test: { $eq: "test" } }, 'test = "test"'],
[{ test: { $ne: "test" } }, 'test <> "test" OR test IS NULL'],
[{ test: { $gte: "test" } }, 'test >= "test"'],
[{ "Tags.test": true }, "Tags.test IS NOT NULL"],
[{ "Tags.test": false }, "Tags.test IS NULL"],
[{ "Tags.test": { $exists: true } }, "Tags.test IS NOT NULL"],
[{ "Tags.test": { $exists: false } }, "Tags.test IS NULL"],
[{ "Tags.test": { $ne: true } }, "Tags.test IS NULL"],
[{ "Tags.test": { $ne: false } }, "Tags.test IS NOT NULL"],
[{ "Tags.test": { $eq: true } }, "Tags.test IS NOT NULL"],
[{ "Tags.test": { $eq: false } }, "Tags.test IS NULL"],
[{ _tags: "test" }, "Tags.test IS NOT NULL"],
[{ _tags: { $ne: "test" } }, "Tags.test IS NULL"],
[{ _tags: { $eq: "test" } }, "Tags.test IS NOT NULL"],
[
{ $and: [{ test: "test" }, { test: { $ne: "test" } }] },
'test = "test" AND (test <> "test" OR test IS NULL)',
],
[{ test: "test", test2: "test2" }, 'test = "test" AND test2 = "test2"'],
[
{ $or: [{ test: "test" }, { test: { $ne: "test" } }] },
'test = "test" OR test <> "test" OR test IS NULL',
],
[
{ test: { $gte: "test1", $ne: "test2" } },
'test >= "test1" AND (test <> "test2" OR test IS NULL)',
],
[
{ test: "test", test2: { $ne: "test2" } },
'test = "test" AND (test2 <> "test2" OR test2 IS NULL)',
],
];
const shouldFailTests = [
[{ test: { $gee: "test" } }, "Operator $gee not supported"],
[{ test: [] }, "Invalid type"],
[{ "Tags.test": { $gt: true } }, "Invalid tag query"],
[{ _tags: [] }, "Invalid type"],
[{ _tags: { $gt: "test" } }, "Invalid tag query"],
[{ $nor: [] }, "Operator $nor not supported"],
] as [Filter<unknown>, string][];
for (const t of tests) {
assert.strictEqual(
convertOldPrecondition(t[0] as Record<string, unknown>).toString(),
t[1],
);
}
for (const t of shouldFailTests) {
const func = (): void => {
convertOldPrecondition(t[0]);
};
assert.throws(func, new Error(t[1]));
}
});
void test("toMongoQuery", async () => {
const queries: [string, Filter<unknown> | false][] = [
["true", {}],
["Tags.tag1 = true", { _tags: { $eq: "tag1" } }],
["Tags.tag1 <> false", { _tags: { $eq: "tag1" } }],
["Tags.tag1 IS NULL", { _tags: { $ne: "tag1" } }],
["Tags.tag1 = 123", false],
["Param1 = 'value1'", { "Param1._value": { $eq: "value1" } }],
[
"Param1 <> 'value1'",
{
"Param1._value": { $ne: "value1" },
$and: [{ "Param1._value": { $ne: null } }],
},
],
[
"Param1 <> 1657844103524",
{
"Param1._value": { $ne: 1657844103524 },
$and: [
{
"Param1._value": { $ne: { $date: "2022-07-15T00:15:03.524Z" } },
},
{ "Param1._value": { $ne: null } },
],
},
],
[
"Param1 = 1657844103524",
{
$or: [
{ "Param1._value": { $eq: { $date: "2022-07-15T00:15:03.524Z" } } },
{ "Param1._value": { $eq: 1657844103524 } },
],
},
],
["Param1 > 'value'", { "Param1._value": { $gt: "value" } }],
["Param1 IS NOT NULL", { "Param1._value": { $ne: null } }],
[
"Param1 LIKE 'value'",
{
"Param1._value": {
$regularExpression: { options: "s", pattern: "^value$" },
},
},
],
[
"LOWER(Param1) LIKE 'value'",
{
"Param1._value": {
$regularExpression: { options: "is", pattern: "^value$" },
},
},
],
[
"Param1 <> 'value2' OR NOT (Param2 = 'value1' OR Param1 < 'value2')",
{
$or: [
{
"Param2._value": { $ne: null },
$and: [{ "Param2._value": { $ne: "value1" } }],
"Param1._value": { $eq: "value2" },
},
{
"Param1._value": { $ne: "value2" },
$and: [{ "Param1._value": { $ne: null } }],
},
],
},
],
[
"Param1 <> 'value2' OR Param1 IS NULL",
{ "Param1._value": { $ne: "value2" } },
],
];
for (const [expStr, expect] of queries) {
const exp = Expression.parse(expStr);
let query = toMongoQuery(exp, "devices");
if (query) query = EJSON.serialize(query);
assert.deepStrictEqual(query, expect);
}
const failQueries: [any, string][] = [
["Param1 = Param2", "Right-hand operand must be a literal value"],
["Param1 LIKE Param2", "Right-hand operand of 'LIKE' must be a string"],
["NOW() = 1", "Left-hand operand must be a parameter"],
];
for (const [expStr, err] of failQueries) {
const exp = Expression.parse(expStr);
assert.throws(() => toMongoQuery(exp, "devices"), {
message: err,
});
}
});
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more