first commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
patchprobe.db
|
||||||
|
patchprobe.db-shm
|
||||||
|
patchprobe.db-wal
|
||||||
|
.env
|
||||||
|
public/
|
||||||
|
logs/
|
||||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# PatchProbe Server
|
||||||
|
|
||||||
|
Node.js / Express server that receives scan payloads from enrolled PatchProbe devices and exposes a management API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## First-run setup
|
||||||
|
|
||||||
|
### 1. Configure environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.env .env
|
||||||
|
# Edit .env — at minimum set PATCHPROBE_ADMIN_KEY_HASH (see below)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generate an admin key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node server.js --gen-admin-key
|
||||||
|
# Prints a random key and its bcrypt hash.
|
||||||
|
# Paste the hash into PATCHPROBE_ADMIN_KEY_HASH in .env
|
||||||
|
# Store the key itself securely — it is only shown once.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # production
|
||||||
|
npm run dev # development (auto-reload, pretty logs)
|
||||||
|
```
|
||||||
|
|
||||||
|
On first start the SQLite database is created automatically at `DB_PATH` (default `./patchprobe.db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enrolling a device end-to-end
|
||||||
|
|
||||||
|
### Step 1 — Create an enrollment token (admin)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/admin/tokens \
|
||||||
|
-H "Authorization: Bearer <admin-key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"label": "lab-devices", "expiresInDays": 7, "maxUses": 10}'
|
||||||
|
# Response: { "token": "<64-char hex>", ... }
|
||||||
|
# The token value is only returned here — copy it now.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Enroll the device (on the endpoint)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PatchProbe.exe enroll --server-url http://your-server:3000 --enrollment-key <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The client POSTs to `POST /api/enrollments`, receives a `deviceId`, and stores credentials encrypted on disk.
|
||||||
|
|
||||||
|
### Step 3 — Run a scan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PatchProbe.exe scan
|
||||||
|
# Collects evidence and POSTs signed payload to POST /api/scans
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin API reference
|
||||||
|
|
||||||
|
All `/api/admin/*` routes require `Authorization: Bearer <admin-key>`.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/api/admin/tokens` | Create enrollment token (`label`, `expiresInDays?`, `maxUses?`) |
|
||||||
|
| `GET` | `/api/admin/tokens` | List tokens (value masked as `ab12****`) |
|
||||||
|
| `DELETE` | `/api/admin/tokens/:token` | Revoke a token |
|
||||||
|
| `GET` | `/api/admin/devices` | List all enrolled devices |
|
||||||
|
| `DELETE` | `/api/admin/devices/:id` | Revoke a device (blocks future scan uploads) |
|
||||||
|
| `GET` | `/api/admin/devices/:id/scans` | Scan summaries for a device |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Device API (used by PatchProbe.exe — do not change)
|
||||||
|
|
||||||
|
| Method | Path | Auth |
|
||||||
|
|--------|------|------|
|
||||||
|
| `POST` | `/api/enrollments` | Enrollment token in body |
|
||||||
|
| `POST` | `/api/scans` | ECDSA device signature headers |
|
||||||
|
| `GET` | `/api/scans` | None |
|
||||||
|
| `GET` | `/api/scans/:id` | None |
|
||||||
|
| `DELETE` | `/api/scans/:id` | None |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server first (auth must be enabled, admin key configured)
|
||||||
|
node test-auth.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- **Persistence**: SQLite via Node.js built-in `node:sqlite` (no native compilation). WAL mode, foreign keys enforced.
|
||||||
|
- **Device auth**: ECDSA-P256-SHA256 over `deviceId\ntimestamp\nbase64(sha256(rawBody))`. Raw body bytes are captured before JSON parsing so re-serialization cannot alter the signed content.
|
||||||
|
- **Admin auth**: bcrypt-hashed bearer key. bcrypt comparison is async (non-blocking).
|
||||||
|
- **Enrollment tokens**: constant-time comparison iterates all active tokens without early exit to prevent timing oracles.
|
||||||
|
- **Rate limiting**: 10 req/min on `/api/enrollments`, 60 req/min on `/api/scans`.
|
||||||
32
config.example.env
Normal file
32
config.example.env
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# PatchProbe Server — environment configuration
|
||||||
|
# Copy to .env and fill in values before starting the server.
|
||||||
|
|
||||||
|
# ── HTTP ─────────────────────────────────────────────────────────────────────
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# ── Runtime ──────────────────────────────────────────────────────────────────
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# ── Database ─────────────────────────────────────────────────────────────────
|
||||||
|
DB_PATH=./patchprobe.db
|
||||||
|
|
||||||
|
# ── Device authentication ─────────────────────────────────────────────────────
|
||||||
|
PATCHPROBE_AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# ── Admin API key (bearer token for curl/API access) ─────────────────────────
|
||||||
|
# Generate with: node server.js --gen-admin-key
|
||||||
|
PATCHPROBE_ADMIN_KEY_HASH=
|
||||||
|
|
||||||
|
# ── Dashboard session ─────────────────────────────────────────────────────────
|
||||||
|
# Random 64-char hex string. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
# Must be set in production or sessions will not survive server restarts.
|
||||||
|
PATCHPROBE_JWT_SECRET=
|
||||||
|
|
||||||
|
# ── WebAuthn passkeys ─────────────────────────────────────────────────────────
|
||||||
|
# rpId: hostname only (no port, no protocol) — must match the domain serving the dashboard.
|
||||||
|
# Use 'localhost' in development.
|
||||||
|
PATCHPROBE_RP_ID=localhost
|
||||||
|
|
||||||
|
# origin: full origin the browser uses to reach this server (protocol + host + port).
|
||||||
|
# Must match exactly what appears in the browser URL bar.
|
||||||
|
PATCHPROBE_ORIGIN=http://localhost:3000
|
||||||
6
config.json.example
Normal file
6
config.json.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"authEnabled": true,
|
||||||
|
"enrollmentKeys": [
|
||||||
|
"change-this-to-a-strong-random-key"
|
||||||
|
]
|
||||||
|
}
|
||||||
1660
package-lock.json
generated
Normal file
1660
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "patchprobe-server",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "PatchProbe scan ingestion and management server",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js",
|
||||||
|
"gen-admin-key": "node server.js --gen-admin-key"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@simplewebauthn/server": "^13.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"express-rate-limit": "^7.3.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pino": "^9.3.2",
|
||||||
|
"pino-pretty": "^11.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6027
scans/2026-05-13T00-14-14-616Z_SSIWKSBT04.json
Normal file
6027
scans/2026-05-13T00-14-14-616Z_SSIWKSBT04.json
Normal file
File diff suppressed because it is too large
Load Diff
17
scans/2026-05-22T12-00-00-000Z_TEST-HOST.json
Normal file
17
scans/2026-05-22T12-00-00-000Z_TEST-HOST.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"collector": {
|
||||||
|
"machineName": "TEST-HOST",
|
||||||
|
"collectedAt": "2026-05-22T12:00:00Z",
|
||||||
|
"ranAsAdministrator": true
|
||||||
|
},
|
||||||
|
"windowsUpdate": {
|
||||||
|
"applicableUpdates": [
|
||||||
|
{
|
||||||
|
"title": "KB12345"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pendingReboot": {
|
||||||
|
"anyPending": false
|
||||||
|
}
|
||||||
|
}
|
||||||
3949
scans/patchprobe_20260513_010721_SSIWKSBT03.json
Normal file
3949
scans/patchprobe_20260513_010721_SSIWKSBT03.json
Normal file
File diff suppressed because it is too large
Load Diff
3365
scans/patchprobe_20260513_012830_SSIWKSZO02.json
Normal file
3365
scans/patchprobe_20260513_012830_SSIWKSZO02.json
Normal file
File diff suppressed because it is too large
Load Diff
5236
scans/patchprobe_20260513_014129_SSIWKSAP01.json
Normal file
5236
scans/patchprobe_20260513_014129_SSIWKSAP01.json
Normal file
File diff suppressed because it is too large
Load Diff
176
server.js
Normal file
176
server.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// --gen-admin-key utility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (process.argv.includes('--gen-admin-key')) {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const key = crypto.randomBytes(32).toString('hex');
|
||||||
|
const hash = bcrypt.hashSync(key, 12);
|
||||||
|
console.log('\nGenerated admin key (store securely — shown only once):');
|
||||||
|
console.log(' Key: ' + key);
|
||||||
|
console.log(' Hash: ' + hash);
|
||||||
|
console.log('\nAdd to your .env file:');
|
||||||
|
console.log(' PATCHPROBE_ADMIN_KEY_HASH=' + hash + '\n');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server startup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
|
||||||
|
const config = require('./src/config');
|
||||||
|
const logger = require('./src/logger');
|
||||||
|
const { initDb, closeDb } = require('./src/db');
|
||||||
|
const authRouter = require('./src/routes/auth');
|
||||||
|
const enrollmentRouter = require('./src/routes/enrollments');
|
||||||
|
const scansRouter = require('./src/routes/scans');
|
||||||
|
const adminRouter = require('./src/routes/admin');
|
||||||
|
|
||||||
|
initDb();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Security headers
|
||||||
|
// Relax CSP slightly so the built React SPA can load scripts/styles from self
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"], // Vite injects a small inline style in dev
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
fontSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
frameSrc: ["'none'"],
|
||||||
|
upgradeInsecureRequests: config.nodeEnv === 'production' ? [] : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rate limiting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.use('/api/enrollments', rateLimit({
|
||||||
|
windowMs: 60_000, max: 10,
|
||||||
|
standardHeaders: true, legacyHeaders: false,
|
||||||
|
message: { error: 'Too many enrollment requests — try again in a minute' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use('/api/scans', rateLimit({
|
||||||
|
windowMs: 60_000, max: 60,
|
||||||
|
standardHeaders: true, legacyHeaders: false,
|
||||||
|
message: { error: 'Too many scan requests — try again in a minute' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use('/api/auth', rateLimit({
|
||||||
|
windowMs: 60_000, max: 20,
|
||||||
|
standardHeaders: true, legacyHeaders: false,
|
||||||
|
message: { error: 'Too many auth requests — try again in a minute' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Body parsing + cookie parsing
|
||||||
|
// raw body capture must come before cookie-parser to avoid interference
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.use(express.json({
|
||||||
|
limit: '50mb',
|
||||||
|
verify: (req, _res, buf) => { req.rawBody = buf; },
|
||||||
|
}));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Static files (built SPA)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/enrollments', enrollmentRouter);
|
||||||
|
app.use('/api/scans', scansRouter);
|
||||||
|
app.use('/api/admin', adminRouter);
|
||||||
|
|
||||||
|
// API 404
|
||||||
|
app.use('/api/', (req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SPA catch-all — serve index.html for all non-API GET requests
|
||||||
|
// React Router handles client-side routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
const indexPath = path.join(__dirname, 'public', 'index.html');
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
} else {
|
||||||
|
res.status(503).send(
|
||||||
|
'Dashboard not built — run: cd PatchProbe.Dashboard && npm install && npm run build',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err.type === 'entity.parse.failed') {
|
||||||
|
return res.status(400).json({ error: 'Invalid JSON in request body' });
|
||||||
|
}
|
||||||
|
logger.error({ err, method: req.method, path: req.path }, 'Unhandled error');
|
||||||
|
res.status(err.status ?? 500).json({ error: err.message ?? 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Startup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const server = app.listen(config.port, '0.0.0.0', () => {
|
||||||
|
logger.info({ port: config.port, auth: config.authEnabled, env: config.nodeEnv }, 'PatchProbe Server started');
|
||||||
|
if (!config.adminKeyHash) {
|
||||||
|
logger.warn('PATCHPROBE_ADMIN_KEY_HASH not set — run: node server.js --gen-admin-key');
|
||||||
|
}
|
||||||
|
if (!process.env.PATCHPROBE_JWT_SECRET) {
|
||||||
|
logger.warn('PATCHPROBE_JWT_SECRET not set — sessions will not survive server restarts');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graceful shutdown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function shutdown(signal) {
|
||||||
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
server.close(() => {
|
||||||
|
closeDb();
|
||||||
|
logger.info('Server stopped cleanly');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10_000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
20
src/config.js
Normal file
20
src/config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
port: parseInt(process.env.PORT ?? '3000', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
|
dbPath: process.env.DB_PATH ?? './patchprobe.db',
|
||||||
|
authEnabled: (process.env.PATCHPROBE_AUTH_ENABLED ?? 'true') === 'true',
|
||||||
|
adminKeyHash: process.env.PATCHPROBE_ADMIN_KEY_HASH ?? null,
|
||||||
|
// WebAuthn — rpId must be a plain hostname (no port, no protocol)
|
||||||
|
rpId: process.env.PATCHPROBE_RP_ID ?? 'localhost',
|
||||||
|
// WebAuthn — full origin that the browser will use to reach this server
|
||||||
|
origin: process.env.PATCHPROBE_ORIGIN ?? 'http://localhost:3000',
|
||||||
|
// JWT — auto-generated if unset; set in production so sessions survive restarts
|
||||||
|
jwtSecret: process.env.PATCHPROBE_JWT_SECRET ?? crypto.randomBytes(32).toString('hex'),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
126
src/db.js
Normal file
126
src/db.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const config = require('./config');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
function getVersion() {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get();
|
||||||
|
return row?.v ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate() {
|
||||||
|
const current = getVersion();
|
||||||
|
|
||||||
|
if (current < 1) {
|
||||||
|
logger.info('Applying DB migration v1');
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN');
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
machine_name TEXT NOT NULL,
|
||||||
|
device_fingerprint TEXT UNIQUE,
|
||||||
|
public_key_spki TEXT NOT NULL,
|
||||||
|
enrolled_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS scans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
device_id TEXT REFERENCES devices(device_id),
|
||||||
|
machine_name TEXT,
|
||||||
|
collected_at TEXT,
|
||||||
|
ran_as_administrator INTEGER,
|
||||||
|
applicable_update_count INTEGER,
|
||||||
|
pending_reboot INTEGER,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS enrollment_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
used_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_uses INTEGER,
|
||||||
|
revoked_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.prepare('DELETE FROM schema_version').run();
|
||||||
|
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(1);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < 2) {
|
||||||
|
logger.info('Applying DB migration v2');
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_credentials (
|
||||||
|
credential_id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES admin_users(user_id),
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
device_type TEXT,
|
||||||
|
backed_up INTEGER NOT NULL DEFAULT 0,
|
||||||
|
transports TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
db.prepare('DELETE FROM schema_version').run();
|
||||||
|
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(2);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (err) {
|
||||||
|
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDb() {
|
||||||
|
db = new DatabaseSync(config.dbPath);
|
||||||
|
db.exec('PRAGMA journal_mode = WAL');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON');
|
||||||
|
migrate();
|
||||||
|
logger.info({ dbPath: config.dbPath }, 'Database ready');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
if (!db) throw new Error('Database not initialized — call initDb() first');
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDb() {
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initDb, getDb, closeDb };
|
||||||
18
src/logger.js
Normal file
18
src/logger.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const pino = require('pino');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const logger = pino(
|
||||||
|
config.nodeEnv === 'production'
|
||||||
|
? { level: 'info' }
|
||||||
|
: {
|
||||||
|
level: 'debug',
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: { colorize: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
70
src/middleware/adminAuth.js
Normal file
70
src/middleware/adminAuth.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const config = require('../config');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'pp_session';
|
||||||
|
|
||||||
|
// Bearer key check (bcrypt) — for curl / API access
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
const authHeader = req.headers['authorization'] ?? '';
|
||||||
|
const key = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return res.status(401).json({ error: 'Admin authentication required (Authorization: Bearer <key>)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.adminKeyHash) {
|
||||||
|
logger.error('Admin endpoint called but PATCHPROBE_ADMIN_KEY_HASH is not configured');
|
||||||
|
return res.status(503).json({ error: 'Admin authentication not configured — run: node server.js --gen-admin-key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt.compare(key, config.adminKeyHash, (err, match) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error({ err }, 'bcrypt compare error during admin auth');
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
logger.warn({ reason: 'invalid_key' }, 'Admin auth failure');
|
||||||
|
return res.status(401).json({ error: 'Invalid admin key' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT session check — for browser / dashboard access
|
||||||
|
function requireSession(req, res, next) {
|
||||||
|
const token = req.cookies?.[SESSION_COOKIE];
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
req.adminSession = jwt.verify(token, config.jwtSecret);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
return res.status(401).json({ error: 'Session expired — please log in again' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session cookie OR bearer key — used by all /api/admin/* routes
|
||||||
|
function requireAdminAccess(req, res, next) {
|
||||||
|
const token = req.cookies?.[SESSION_COOKIE];
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
req.adminSession = jwt.verify(token, config.jwtSecret);
|
||||||
|
return next();
|
||||||
|
} catch {
|
||||||
|
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const authHeader = req.headers['authorization'] ?? '';
|
||||||
|
if (authHeader.startsWith('Bearer ')) {
|
||||||
|
return requireAdmin(req, res, next);
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAdmin, requireSession, requireAdminAccess };
|
||||||
74
src/middleware/auth.js
Normal file
74
src/middleware/auth.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (!config.authEnabled) return next();
|
||||||
|
|
||||||
|
const deviceId = req.headers['x-device-id'];
|
||||||
|
const timestamp = req.headers['x-timestamp'];
|
||||||
|
const signature = req.headers['x-signature'];
|
||||||
|
|
||||||
|
if (!deviceId || !timestamp || !signature) {
|
||||||
|
logger.warn({ reason: 'missing_headers' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Missing authentication headers (X-Device-Id, X-Timestamp, X-Signature)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqTime = parseInt(timestamp, 10);
|
||||||
|
if (isNaN(reqTime) || Math.abs(Date.now() - reqTime) > 5 * 60 * 1000) {
|
||||||
|
logger.warn({ deviceId, reason: 'timestamp_expired' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Request timestamp is expired or invalid' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const device = db.prepare('SELECT * FROM devices WHERE device_id = ?').get(deviceId);
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
logger.warn({ deviceId, reason: 'unknown_device' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Unknown device' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.revoked_at) {
|
||||||
|
logger.warn({ deviceId, reason: 'device_revoked' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Device has been revoked' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the signed message: deviceId LF timestamp LF base64(sha256(rawBody))
|
||||||
|
const bodyHash = crypto.createHash('sha256').update(req.rawBody ?? '').digest('base64');
|
||||||
|
const message = `${deviceId}\n${timestamp}\n${bodyHash}`;
|
||||||
|
|
||||||
|
let valid = false;
|
||||||
|
try {
|
||||||
|
const publicKey = crypto.createPublicKey({
|
||||||
|
key: Buffer.from(device.public_key_spki, 'base64'),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
});
|
||||||
|
const verifier = crypto.createVerify('SHA256');
|
||||||
|
verifier.update(message);
|
||||||
|
// .NET ECDsa.SignData() produces IEEE P1363 (raw r‖s), not DER
|
||||||
|
valid = verifier.verify(
|
||||||
|
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
|
||||||
|
Buffer.from(signature, 'base64'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
logger.warn({ deviceId, reason: 'sig_error' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Signature verification error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
logger.warn({ deviceId, reason: 'invalid_sig' }, 'Device auth failure');
|
||||||
|
return res.status(401).json({ error: 'Invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE devices SET last_seen_at = ? WHERE device_id = ?')
|
||||||
|
.run(new Date().toISOString(), deviceId);
|
||||||
|
|
||||||
|
req.device = device;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireAuth };
|
||||||
155
src/routes/admin.js
Normal file
155
src/routes/admin.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const { requireAdminAccess } = require('../middleware/adminAuth');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAdminAccess);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enrollment tokens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// POST /api/admin/tokens — create an enrollment token
|
||||||
|
router.post('/tokens', (req, res) => {
|
||||||
|
const { label, expiresInDays, maxUses } = req.body ?? {};
|
||||||
|
|
||||||
|
if (typeof label !== 'string' || label.trim().length === 0 || label.length > 255) {
|
||||||
|
return res.status(400).json({ error: 'label is required (string, max 255 chars)' });
|
||||||
|
}
|
||||||
|
if (expiresInDays !== undefined && (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 3650)) {
|
||||||
|
return res.status(400).json({ error: 'expiresInDays must be a positive integer (max 3650)' });
|
||||||
|
}
|
||||||
|
if (maxUses !== undefined && (!Number.isInteger(maxUses) || maxUses < 1)) {
|
||||||
|
return res.status(400).json({ error: 'maxUses must be a positive integer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const expiresAt = expiresInDays
|
||||||
|
? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO enrollment_tokens (token, label, created_at, expires_at, max_uses) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(token, label.trim(), now, expiresAt, maxUses ?? null);
|
||||||
|
|
||||||
|
logger.info({ label: label.trim(), expiresInDays, maxUses }, 'Enrollment token created');
|
||||||
|
|
||||||
|
// Token value only returned once — on creation
|
||||||
|
res.status(201).json({
|
||||||
|
token,
|
||||||
|
label: label.trim(),
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt,
|
||||||
|
maxUses: maxUses ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/tokens — list tokens (masked)
|
||||||
|
router.get('/tokens', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const tokens = db.prepare(
|
||||||
|
'SELECT rowid, token, label, created_at, expires_at, used_count, max_uses, revoked_at FROM enrollment_tokens ORDER BY created_at DESC'
|
||||||
|
).all();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
res.json(tokens.map(t => ({
|
||||||
|
id: t.rowid,
|
||||||
|
tokenMasked: `${t.token.slice(0, 4)}****`,
|
||||||
|
label: t.label,
|
||||||
|
createdAt: t.created_at,
|
||||||
|
expiresAt: t.expires_at,
|
||||||
|
usedCount: t.used_count,
|
||||||
|
maxUses: t.max_uses,
|
||||||
|
revokedAt: t.revoked_at,
|
||||||
|
active: !t.revoked_at
|
||||||
|
&& (!t.expires_at || new Date(t.expires_at) > now)
|
||||||
|
&& (t.max_uses === null || t.used_count < t.max_uses),
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/admin/tokens/:id — revoke a token by rowid
|
||||||
|
router.delete('/tokens/:id', (req, res) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isInteger(id) || id < 1) {
|
||||||
|
return res.status(400).json({ error: 'Invalid token id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT token FROM enrollment_tokens WHERE rowid = ?').get(id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Token not found' });
|
||||||
|
if (row.revoked_at) return res.status(409).json({ error: 'Token already revoked' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE enrollment_tokens SET revoked_at = ? WHERE rowid = ?')
|
||||||
|
.run(new Date().toISOString(), id);
|
||||||
|
|
||||||
|
logger.info({ tokenId: id, tokenMasked: row.token.slice(0, 4) + '****' }, 'Enrollment token revoked');
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Devices
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/admin/devices — list all devices
|
||||||
|
router.get('/devices', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const devices = db.prepare(
|
||||||
|
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
|
||||||
|
).all();
|
||||||
|
|
||||||
|
res.json(devices.map(d => ({
|
||||||
|
id: d.device_id,
|
||||||
|
machineName: d.machine_name,
|
||||||
|
deviceFingerprint: d.device_fingerprint,
|
||||||
|
enrolledAt: d.enrolled_at,
|
||||||
|
lastSeenAt: d.last_seen_at,
|
||||||
|
revoked: !!d.revoked_at,
|
||||||
|
revokedAt: d.revoked_at,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/admin/devices/:id — revoke a device
|
||||||
|
router.delete('/devices/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
|
||||||
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE devices SET revoked_at = ? WHERE device_id = ?')
|
||||||
|
.run(new Date().toISOString(), req.params.id);
|
||||||
|
|
||||||
|
logger.info({ deviceId: req.params.id }, 'Device revoked');
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/devices/:id/scans — scan summaries for one device
|
||||||
|
router.get('/devices/:id/scans', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
|
||||||
|
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||||
|
|
||||||
|
const scans = db.prepare(`
|
||||||
|
SELECT id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, received_at
|
||||||
|
FROM scans
|
||||||
|
WHERE device_id = ?
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
`).all(req.params.id);
|
||||||
|
|
||||||
|
res.json(scans.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
machineName: s.machine_name,
|
||||||
|
collectedAt: s.collected_at,
|
||||||
|
receivedAt: s.received_at,
|
||||||
|
ranAsAdministrator: s.ran_as_administrator === 1,
|
||||||
|
applicableUpdateCount: s.applicable_update_count,
|
||||||
|
pendingReboot: s.pending_reboot === 1,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
350
src/routes/auth.js
Normal file
350
src/routes/auth.js
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const {
|
||||||
|
generateRegistrationOptions,
|
||||||
|
verifyRegistrationResponse,
|
||||||
|
generateAuthenticationOptions,
|
||||||
|
verifyAuthenticationResponse,
|
||||||
|
} = require('@simplewebauthn/server');
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const { requireAdminAccess, requireSession } = require('../middleware/adminAuth');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-memory challenge store (TTL 2 minutes, single-server only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function storeEntry(data) {
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
pending.set(token, { ...data, expiresAt: Date.now() + 120_000 });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeEntry(token) {
|
||||||
|
if (!token) return null;
|
||||||
|
const entry = pending.get(token);
|
||||||
|
pending.delete(token);
|
||||||
|
if (!entry || entry.expiresAt < Date.now()) return null;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [k, v] of pending) if (v.expiresAt < now) pending.delete(k);
|
||||||
|
}, 60_000).unref();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cookie helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'pp_session';
|
||||||
|
const CEREMONY_COOKIE = 'pp_ceremony';
|
||||||
|
|
||||||
|
const BASE_OPTS = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: config.nodeEnv === 'production',
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
function setSession(res, user) {
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: user.user_id, username: user.username, displayName: user.display_name },
|
||||||
|
config.jwtSecret,
|
||||||
|
{ expiresIn: '8h' },
|
||||||
|
);
|
||||||
|
res.cookie(SESSION_COOKIE, token, { ...BASE_OPTS, maxAge: 8 * 60 * 60 * 1000 });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bootstrap guard: allow if no admin users exist yet, else require admin access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function requireAdminOrBootstrap(req, res, next) {
|
||||||
|
const db = getDb();
|
||||||
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
|
||||||
|
if (n === 0) return next();
|
||||||
|
return requireAdminAccess(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status — lets the login page know if first-time setup is needed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
|
||||||
|
res.json({ hasUsers: n > 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/me', (req, res) => {
|
||||||
|
const token = req.cookies?.[SESSION_COOKIE];
|
||||||
|
if (!token) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
try {
|
||||||
|
const p = jwt.verify(token, config.jwtSecret);
|
||||||
|
res.json({ userId: p.sub, username: p.username, displayName: p.displayName });
|
||||||
|
} catch {
|
||||||
|
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
res.status(401).json({ error: 'Session expired' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req, res) => {
|
||||||
|
res.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.post('/register/begin', requireAdminOrBootstrap, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { username, displayName } = req.body ?? {};
|
||||||
|
if (typeof username !== 'string' || !username.trim() || username.length > 64) {
|
||||||
|
return res.status(400).json({ error: 'username required (max 64 chars)' });
|
||||||
|
}
|
||||||
|
if (typeof displayName !== 'string' || !displayName.trim() || displayName.length > 128) {
|
||||||
|
return res.status(400).json({ error: 'displayName required (max 128 chars)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const cleanName = username.trim().toLowerCase();
|
||||||
|
const cleanDisplay = displayName.trim();
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Exclude credentials already registered for this username
|
||||||
|
const existingCreds = db.prepare(`
|
||||||
|
SELECT ac.credential_id, ac.transports
|
||||||
|
FROM admin_credentials ac
|
||||||
|
JOIN admin_users au ON au.user_id = ac.user_id
|
||||||
|
WHERE au.username = ?
|
||||||
|
`).all(cleanName);
|
||||||
|
|
||||||
|
const options = await generateRegistrationOptions({
|
||||||
|
rpName: 'PatchProbe',
|
||||||
|
rpID: config.rpId,
|
||||||
|
userID: Buffer.from(userId),
|
||||||
|
userName: cleanName,
|
||||||
|
userDisplayName: cleanDisplay,
|
||||||
|
attestation: 'none',
|
||||||
|
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||||
|
excludeCredentials: existingCreds.map(c => ({
|
||||||
|
id: c.credential_id,
|
||||||
|
transports: JSON.parse(c.transports ?? '[]'),
|
||||||
|
})),
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ceremonyToken = storeEntry({
|
||||||
|
challenge: options.challenge,
|
||||||
|
pendingUser: { userId, username: cleanName, displayName: cleanDisplay },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
|
||||||
|
logger.info({ username: cleanName }, 'Passkey registration ceremony started');
|
||||||
|
res.json(options);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/register/finish', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
|
||||||
|
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
|
||||||
|
|
||||||
|
if (!entry?.challenge || !entry?.pendingUser) {
|
||||||
|
return res.status(400).json({ error: 'Registration ceremony expired or not started' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { challenge, pendingUser } = entry;
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyRegistrationResponse({
|
||||||
|
response: req.body,
|
||||||
|
expectedChallenge: challenge,
|
||||||
|
expectedOrigin: config.origin,
|
||||||
|
expectedRPID: config.rpId,
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err: err.message, username: pendingUser.username }, 'Passkey registration verification failed');
|
||||||
|
return res.status(400).json({ error: 'Passkey verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
|
return res.status(400).json({ error: 'Passkey verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { credential } = verification.registrationInfo;
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Upsert user (handles both new registration and additional passkeys)
|
||||||
|
const existingUser = db.prepare('SELECT user_id FROM admin_users WHERE username = ?').get(pendingUser.username);
|
||||||
|
const userId = existingUser?.user_id ?? pendingUser.userId;
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO admin_users (user_id, username, display_name, created_at) VALUES (?, ?, ?, ?)',
|
||||||
|
).run(userId, pendingUser.username, pendingUser.displayName, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO admin_credentials
|
||||||
|
(credential_id, user_id, public_key, counter, device_type, backed_up, transports, created_at, last_used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
credential.id,
|
||||||
|
userId,
|
||||||
|
Buffer.from(credential.publicKey).toString('base64'),
|
||||||
|
credential.counter,
|
||||||
|
credential.deviceType ?? null,
|
||||||
|
credential.backedUp ? 1 : 0,
|
||||||
|
JSON.stringify(credential.transports ?? []),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(userId);
|
||||||
|
setSession(res, user);
|
||||||
|
logger.info({ userId, username: pendingUser.username }, 'Passkey registered');
|
||||||
|
res.json({ ok: true, username: user.username, displayName: user.display_name });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Authentication
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.post('/login/begin', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const credCount = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials').get().n;
|
||||||
|
if (credCount === 0) {
|
||||||
|
return res.status(404).json({ error: 'No passkeys registered — complete first-time setup' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// No allowCredentials → browser picks from all passkeys registered for this RP
|
||||||
|
const options = await generateAuthenticationOptions({
|
||||||
|
rpID: config.rpId,
|
||||||
|
userVerification: 'required',
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ceremonyToken = storeEntry({ challenge: options.challenge });
|
||||||
|
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
|
||||||
|
res.json(options);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login/finish', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
|
||||||
|
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
|
||||||
|
|
||||||
|
if (!entry?.challenge) {
|
||||||
|
return res.status(400).json({ error: 'Authentication ceremony expired or not started' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialId = req.body?.id;
|
||||||
|
const db = getDb();
|
||||||
|
const storedCred = db.prepare('SELECT * FROM admin_credentials WHERE credential_id = ?').get(credentialId);
|
||||||
|
|
||||||
|
if (!storedCred) {
|
||||||
|
return res.status(401).json({ error: 'Passkey not recognized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let verification;
|
||||||
|
try {
|
||||||
|
verification = await verifyAuthenticationResponse({
|
||||||
|
response: req.body,
|
||||||
|
expectedChallenge: entry.challenge,
|
||||||
|
expectedOrigin: config.origin,
|
||||||
|
expectedRPID: config.rpId,
|
||||||
|
credential: {
|
||||||
|
id: storedCred.credential_id,
|
||||||
|
publicKey: Buffer.from(storedCred.public_key, 'base64'),
|
||||||
|
counter: storedCred.counter,
|
||||||
|
transports: JSON.parse(storedCred.transports ?? '[]'),
|
||||||
|
},
|
||||||
|
requireUserVerification: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err: err.message }, 'Passkey authentication verification failed');
|
||||||
|
return res.status(401).json({ error: 'Passkey verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verification.verified) {
|
||||||
|
return res.status(401).json({ error: 'Passkey verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { newCounter } = verification.authenticationInfo;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare('UPDATE admin_credentials SET counter = ?, last_used_at = ? WHERE credential_id = ?')
|
||||||
|
.run(newCounter, now, storedCred.credential_id);
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(storedCred.user_id);
|
||||||
|
setSession(res, user);
|
||||||
|
logger.info({ userId: user.user_id, username: user.username }, 'Passkey login successful');
|
||||||
|
res.json({ ok: true, username: user.username, displayName: user.display_name });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Passkey management (session required — bearer key has no user context)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/passkeys', requireSession, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const passkeys = db.prepare(
|
||||||
|
'SELECT credential_id, device_type, backed_up, transports, created_at, last_used_at FROM admin_credentials WHERE user_id = ?',
|
||||||
|
).all(req.adminSession.sub);
|
||||||
|
|
||||||
|
res.json(passkeys.map(p => ({
|
||||||
|
id: p.credential_id,
|
||||||
|
idMasked: `${p.credential_id.slice(0, 8)}…`,
|
||||||
|
deviceType: p.device_type,
|
||||||
|
backedUp: p.backed_up === 1,
|
||||||
|
transports: JSON.parse(p.transports ?? '[]'),
|
||||||
|
createdAt: p.created_at,
|
||||||
|
lastUsedAt: p.last_used_at,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/passkeys/:id', requireSession, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const userId = req.adminSession.sub;
|
||||||
|
const count = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials WHERE user_id = ?').get(userId).n;
|
||||||
|
if (count <= 1) {
|
||||||
|
return res.status(400).json({ error: 'Cannot remove your only passkey' });
|
||||||
|
}
|
||||||
|
const result = db.prepare('DELETE FROM admin_credentials WHERE credential_id = ? AND user_id = ?')
|
||||||
|
.run(req.params.id, userId);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Passkey not found' });
|
||||||
|
logger.info({ userId, credentialId: req.params.id.slice(0, 8) }, 'Passkey removed');
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
158
src/routes/enrollments.js
Normal file
158
src/routes/enrollments.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const config = require('../config');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const { requireAdmin } = require('../middleware/adminAuth');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input validation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function validateEnrollBody(body) {
|
||||||
|
const { machineName, publicKeySpki, deviceFingerprint } = body ?? {};
|
||||||
|
if (typeof machineName !== 'string' || machineName.trim().length === 0 || machineName.length > 255) {
|
||||||
|
return 'machineName is required (string, max 255 chars)';
|
||||||
|
}
|
||||||
|
if (typeof publicKeySpki !== 'string' || publicKeySpki.length === 0 || publicKeySpki.length > 4096) {
|
||||||
|
return 'publicKeySpki is required (base64 DER SPKI string)';
|
||||||
|
}
|
||||||
|
if (deviceFingerprint !== undefined &&
|
||||||
|
(typeof deviceFingerprint !== 'string' || deviceFingerprint.length > 255)) {
|
||||||
|
return 'deviceFingerprint must be a string (max 255 chars)';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/enrollments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
const { enrollmentKey, machineName, deviceFingerprint, publicKeySpki } = req.body ?? {};
|
||||||
|
|
||||||
|
const validationError = validateEnrollBody(req.body);
|
||||||
|
if (validationError) {
|
||||||
|
return res.status(400).json({ error: validationError });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
let matchedToken = null;
|
||||||
|
|
||||||
|
if (config.authEnabled) {
|
||||||
|
if (!enrollmentKey || typeof enrollmentKey !== 'string') {
|
||||||
|
logger.warn({ machineName, reason: 'missing_token' }, 'Enrollment rejected');
|
||||||
|
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = db.prepare(
|
||||||
|
'SELECT token, expires_at, max_uses, used_count FROM enrollment_tokens WHERE revoked_at IS NULL'
|
||||||
|
).all();
|
||||||
|
|
||||||
|
const providedBuf = Buffer.from(enrollmentKey);
|
||||||
|
|
||||||
|
// Compare against every token without short-circuiting to avoid timing oracle
|
||||||
|
for (const t of tokens) {
|
||||||
|
const storedBuf = Buffer.from(t.token);
|
||||||
|
const maxLen = Math.max(providedBuf.length, storedBuf.length);
|
||||||
|
const a = Buffer.alloc(maxLen);
|
||||||
|
const b = Buffer.alloc(maxLen);
|
||||||
|
providedBuf.copy(a);
|
||||||
|
storedBuf.copy(b);
|
||||||
|
if (crypto.timingSafeEqual(a, b)) {
|
||||||
|
matchedToken = t;
|
||||||
|
// Continue loop — do not break early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedToken) {
|
||||||
|
logger.warn({ machineName, reason: 'invalid_token' }, 'Enrollment rejected');
|
||||||
|
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedToken.expires_at && new Date(matchedToken.expires_at) < new Date()) {
|
||||||
|
logger.warn({ machineName, reason: 'token_expired' }, 'Enrollment rejected');
|
||||||
|
return res.status(403).json({ error: 'Enrollment token has expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedToken.max_uses !== null && matchedToken.used_count >= matchedToken.max_uses) {
|
||||||
|
logger.warn({ machineName, reason: 'token_max_uses' }, 'Enrollment rejected');
|
||||||
|
return res.status(403).json({ error: 'Enrollment token usage limit reached' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the public key before storing anything
|
||||||
|
try {
|
||||||
|
const key = crypto.createPublicKey({
|
||||||
|
key: Buffer.from(publicKeySpki, 'base64'),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki',
|
||||||
|
});
|
||||||
|
if (key.asymmetricKeyType !== 'ec') {
|
||||||
|
throw new Error('Not an EC key');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: 'Invalid public key — must be ECDSA P-256 SubjectPublicKeyInfo (DER, base64)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedName = machineName.trim().slice(0, 255);
|
||||||
|
const sanitizedFp = deviceFingerprint?.trim().slice(0, 255) ?? null;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Re-enrollment: same fingerprint → rotate public key, preserve deviceId
|
||||||
|
if (sanitizedFp) {
|
||||||
|
const existing = db.prepare('SELECT device_id FROM devices WHERE device_fingerprint = ?').get(sanitizedFp);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE devices SET public_key_spki = ?, machine_name = ?, last_seen_at = ? WHERE device_id = ?'
|
||||||
|
).run(publicKeySpki, sanitizedName, now, existing.device_id);
|
||||||
|
|
||||||
|
if (matchedToken) {
|
||||||
|
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
|
||||||
|
.run(matchedToken.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ deviceId: existing.device_id, machineName: sanitizedName }, 'Device re-enrolled');
|
||||||
|
return res.status(200).json({ deviceId: existing.device_id, message: 'Re-enrollment successful' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New device
|
||||||
|
const deviceId = crypto.randomUUID();
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO devices (device_id, machine_name, device_fingerprint, public_key_spki, enrolled_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(deviceId, sanitizedName, sanitizedFp, publicKeySpki, now);
|
||||||
|
|
||||||
|
if (matchedToken) {
|
||||||
|
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
|
||||||
|
.run(matchedToken.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ deviceId, machineName: sanitizedName }, 'Device enrolled');
|
||||||
|
res.status(201).json({ deviceId, message: 'Enrollment successful' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/enrollments — admin-protected device list (backward-compat route)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/', requireAdmin, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const devices = db.prepare(
|
||||||
|
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
|
||||||
|
).all();
|
||||||
|
res.json(devices.map(d => ({
|
||||||
|
deviceId: d.device_id,
|
||||||
|
machineName: d.machine_name,
|
||||||
|
deviceFingerprint: d.device_fingerprint,
|
||||||
|
enrolledAt: d.enrolled_at,
|
||||||
|
lastSeenAt: d.last_seen_at,
|
||||||
|
revoked: !!d.revoked_at,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
102
src/routes/scans.js
Normal file
102
src/routes/scans.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/scans — ingest a scan payload from an enrolled device
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.post('/', requireAuth, (req, res) => {
|
||||||
|
const payload = req.body;
|
||||||
|
|
||||||
|
if (typeof payload !== 'object' || payload === null) {
|
||||||
|
return res.status(400).json({ error: 'Request body must be a JSON object' });
|
||||||
|
}
|
||||||
|
if (typeof payload.collector?.machineName !== 'string' || payload.collector.machineName.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid payload: missing collector.machineName' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const machineName = payload.collector.machineName.trim().slice(0, 255);
|
||||||
|
const deviceId = req.device?.device_id ?? null;
|
||||||
|
|
||||||
|
let collectedAt = null;
|
||||||
|
try {
|
||||||
|
collectedAt = payload.collector.collectedAt
|
||||||
|
? new Date(payload.collector.collectedAt).toISOString()
|
||||||
|
: null;
|
||||||
|
} catch { /* leave null */ }
|
||||||
|
|
||||||
|
const ranAsAdmin = payload.collector.ranAsAdministrator ? 1 : 0;
|
||||||
|
const applicableUpdateCount = Array.isArray(payload.windowsUpdate?.applicableUpdates)
|
||||||
|
? payload.windowsUpdate.applicableUpdates.length
|
||||||
|
: 0;
|
||||||
|
const pendingReboot = payload.pendingReboot?.anyPending ? 1 : 0;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO scans
|
||||||
|
(id, device_id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, raw_json, received_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, deviceId, machineName, collectedAt, ranAsAdmin, applicableUpdateCount, pendingReboot, JSON.stringify(payload), now);
|
||||||
|
|
||||||
|
logger.info({ id, machineName, deviceId }, 'Scan ingested');
|
||||||
|
res.status(201).json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/scans — list scan summaries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT id, device_id, machine_name, collected_at, ran_as_administrator,
|
||||||
|
applicable_update_count, pending_reboot, received_at
|
||||||
|
FROM scans
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(rows.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
deviceId: s.device_id,
|
||||||
|
machineName: s.machine_name,
|
||||||
|
collectedAt: s.collected_at,
|
||||||
|
receivedAt: s.received_at,
|
||||||
|
ranAsAdministrator: s.ran_as_administrator === 1,
|
||||||
|
applicableUpdateCount: s.applicable_update_count,
|
||||||
|
pendingReboot: s.pending_reboot === 1,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/scans/:id — return full scan payload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const scan = db.prepare('SELECT raw_json FROM scans WHERE id = ?').get(req.params.id);
|
||||||
|
if (!scan) return res.status(404).json({ error: 'Not found' });
|
||||||
|
// Send raw stored JSON without re-parsing to preserve exact byte representation
|
||||||
|
res.set('Content-Type', 'application/json').send(scan.raw_json);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /api/scans/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare('DELETE FROM scans WHERE id = ?').run(req.params.id);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user