Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ PORT=4000
CLIENT_ORIGIN=http://localhost:5173
SIMULATION_MODE=false
MONITORING_INTERVAL_SECONDS=30
MONITORED_TARGETS=192.168.1.1,8.8.8.8,google.com
MONITORED_TARGETS=192.168.1.1
MONITORING_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
MONITORING_ALLOWED_HOSTNAMES=
ALLOW_LOOPBACK_TARGETS=false
ALLOW_PUBLIC_TARGETS=false
HOTSPOT_SUBNET=192.168.137.0/24
TSHARK_PATH=C:\Program Files\Wireshark\tshark.exe
JWT_SECRET=replace-with-a-long-random-secret
JWT_SECRET=generate-a-random-secret-at-least-32-characters-long
ADMIN_EMAIL=admin@network.local
ADMIN_PASSWORD=change-this-development-password
ADMIN_PASSWORD=replace-with-a-unique-password-at-least-12-characters
VIEWER_EMAIL=
VIEWER_PASSWORD=
DB_HOST=localhost
Expand All @@ -17,3 +21,4 @@ DB_PASSWORD=change-me
DB_NAME=smart_network_monitoring
DB_ENABLED=false
DB_CONNECTION_LIMIT=10
MAX_MANUAL_CHECKS=4
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: CI

on:
push:
pull_request:

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run test
- run: npm run build
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A full-stack academic network-monitoring project that makes network performance
- Express + TypeScript REST API with live Windows ping checks, local interface traffic measurement, optional simulation, a 30-second monitoring cycle, KPI aggregation, health score, alert evaluation, and on-demand checks.
- Statistics: mean, median, variance, standard deviation, coefficient of variation, P25/P50/P75/P90/P95/P99, moving average, trend, Pearson correlation, and Z-score/IQR anomalies.
- MySQL schema including indexed `network_metrics`, users, devices, alerts, alert rules, and monitoring logs; a seven-day seed script is included.
- Authentication uses bcrypt-backed bootstrap credentials and JWT bearer tokens. Development defaults to `admin@network.local` / `admin123`; set `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and a strong `JWT_SECRET` before use outside local development. Optional viewer credentials can be configured with `VIEWER_EMAIL` and `VIEWER_PASSWORD`.
- Authentication uses bcrypt-backed bootstrap credentials and JWT bearer tokens. `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and a randomly generated `JWT_SECRET` of at least 32 characters are required at startup. Optional viewer credentials can be configured with `VIEWER_EMAIL` and `VIEWER_PASSWORD`.

## Architecture

Expand All @@ -17,16 +17,16 @@ React client → REST API → Express monitoring service → live Windows ICMP p
└──────────────────────────→ MySQL historical data
```

The browser accesses only the API. Login is required for all API routes except `/api/auth/login`; administrator credentials are required for device creation, alert changes, simulation runs, and settings changes. The current session token is held in browser session storage and expires after eight hours. Monitoring state is still in memory; MySQL persistence is planned for the next phase.
The browser accesses only the API. Login is required for all API routes except `/api/auth/login`; administrator credentials are required for device creation, alert changes, simulation runs, and settings changes. The current session token is held in browser session storage and expires after one hour. Monitoring state is held in memory when persistence is disabled and is restored from MySQL when `DB_ENABLED=true`.

## Run locally

1. Copy `.env.example` to `.env`, set a strong `JWT_SECRET` and non-default `ADMIN_PASSWORD`, and change `MONITORED_TARGETS` to IP addresses or hostnames you are authorized to monitor. For example: `192.168.1.1,192.168.1.10,google.com`.
1. Copy `.env.example` to `.env`, generate a strong secret (for example, `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`), set a unique `ADMIN_PASSWORD`, and configure `MONITORING_ALLOWED_CIDRS` or `MONITORING_ALLOWED_HOSTNAMES` for systems you are authorized to monitor. Public targets require `ALLOW_PUBLIC_TARGETS=true`; loopback requires `ALLOW_LOOPBACK_TARGETS=true`.
2. Run `npm install` in the project root.
3. Run `npm run dev`.
4. Open `http://localhost:5173`.

The backend is available at `http://localhost:4000`. Live Mode is the default (`SIMULATION_MODE=false`); it monitors `127.0.0.1` if no targets are configured. Set `SIMULATION_MODE=true` for the built-in demo scenario. To create the database, run `mysql -u root -p < database/schema.sql` then `mysql -u root -p < database/seed.sql`.
The backend is available at `http://localhost:4000`. Live Mode is the default (`SIMULATION_MODE=false`). Set `SIMULATION_MODE=true` for the built-in demo scenario. For a new MySQL database, create the database and run `database/schema.sql`; application startup then applies ordered files in `database/migrations/`. The migration runner records each applied version and is safe to rerun. Seed data can be loaded with `mysql -u root -p < database/seed.sql`.

## Key API endpoints

Expand All @@ -42,6 +42,10 @@ The backend is available at `http://localhost:4000`. Live Mode is the default (`

All endpoints except `POST /api/auth/login` require `Authorization: Bearer <token>`. The login limiter allows five attempts per IP/email key within fifteen minutes. The current bootstrap users are environment-based; database-backed users will be introduced with persistence.

Process health is available at `GET /health`; `GET /ready` reports whether initialization has completed and identifies the active persistence mode. Responses include an `x-request-id` header for operational tracing.

When MySQL persistence is enabled, application settings are stored in `app_settings` and take precedence over the local configuration file. Settings changes update the scheduler interval, alert latency threshold, and monitoring mode without leaving the previous scheduler or hotspot capture running.

## Health and anomaly formulas

`health = availability×0.30 + latency×0.20 + packet-loss×0.20 + bandwidth×0.20 + errors×0.10`.
Expand All @@ -62,8 +66,8 @@ When Windows Mobile Hotspot is enabled, the **Hotspot client traffic** panel ide

## Security notes

- Never use the development password or JWT fallback in production. Production startup fails when `JWT_SECRET` or `ADMIN_PASSWORD` is missing.
- Only monitor systems you own or are authorized to test. Target allowlisting and stronger SSRF protections will be added with the live monitoring repository work.
- Never commit secrets. Startup fails when authentication configuration is missing or weak.
- Only monitor systems you own or are authorized to test. Targets are resolved before probing and must match the configured CIDR/hostname policy; loopback, multicast, link-local, broadcast, and cloud metadata addresses are blocked by default.
- Logs contain request method, path, status, and a generic error message; passwords, tokens, and request bodies are not logged.

## Future scope
Expand Down
2 changes: 1 addition & 1 deletion backend/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import test from 'node:test'
import type { NextFunction, Request, Response } from 'express'
import { authenticate, issueToken, loginRateLimit, requireRole } from './auth.js'

process.env.JWT_SECRET = 'test-secret-with-enough-entropy'
process.env.JWT_SECRET = 'test-secret-with-enough-entropy-for-auth-tests'

const response = () => {
let statusCode = 200
Expand Down
27 changes: 21 additions & 6 deletions backend/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ type JwtClaims = {
role: UserRole
}

const tokenIssuer = process.env.JWT_ISSUER || 'network-analyzer'
const tokenAudience = process.env.JWT_AUDIENCE || 'network-analyzer-client'
const weakPasswords = new Set(['admin123', 'change-this-development-password', 'password', 'password123'])

const claimsSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
Expand All @@ -27,13 +31,24 @@ const claimsSchema = z.object({

const getSecret = () => {
const secret = process.env.JWT_SECRET?.trim()
if (!secret && process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be configured in production')
}
return secret || 'development-only-change-me'
if (!secret) throw new Error('JWT_SECRET must be configured')
if (secret.length < 32 || weakPasswords.has(secret)) throw new Error('JWT_SECRET must be at least 32 characters and not a known development value')
return secret
}

export const issueToken = (user: AuthUser) => jwt.sign(user, getSecret(), { expiresIn: '8h' })
export const validateAuthConfig = () => {
const adminEmail = process.env.ADMIN_EMAIL?.trim()
const adminPassword = process.env.ADMIN_PASSWORD
if (!adminEmail || !z.string().email().safeParse(adminEmail).success) throw new Error('ADMIN_EMAIL must be configured with a valid email address')
if (!adminPassword || adminPassword.length < 12 || weakPasswords.has(adminPassword)) throw new Error('ADMIN_PASSWORD must be at least 12 characters and not a known development value')
getSecret()
}

export const issueToken = (user: AuthUser) => jwt.sign(user, getSecret(), {
expiresIn: '1h',
issuer: tokenIssuer,
audience: tokenAudience
})

export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const header = req.header('authorization')
Expand All @@ -44,7 +59,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) =>
}

try {
const claims = claimsSchema.parse(jwt.verify(token, getSecret()))
const claims = claimsSchema.parse(jwt.verify(token, getSecret(), { issuer: tokenIssuer, audience: tokenAudience }))
req.user = claims
next()
} catch {
Expand Down
89 changes: 80 additions & 9 deletions backend/src/persistence.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import mysql, { type Pool, type RowDataPacket } from 'mysql2/promise'
import fs from 'node:fs/promises'
import path from 'node:path'
import type { AuthUser } from './auth.js'
import type { Alert, Device, Metric } from './types.js'
import type { AppSettings } from './services/settings.js'

export type StoredUser = AuthUser & { passwordHash: string }

Expand Down Expand Up @@ -58,9 +61,12 @@ export interface MonitorRepository {
saveDevice(device: Device): Promise<void>
saveMetric(deviceId: number, metric: Metric): Promise<void>
saveAlert(alert: Alert): Promise<void>
saveState(devices: Device[], alerts: Alert[]): Promise<void>
saveLog(deviceId: number, result: 'success' | 'failure', responseTime: number | null, errorMessage?: string): Promise<void>
findUserByEmail(email: string): Promise<StoredUser | null>
upsertUser(user: StoredUser): Promise<void>
loadSettings(): Promise<Partial<AppSettings> | null>
saveSettings(settings: AppSettings): Promise<void>
}

class NoopRepository implements MonitorRepository {
Expand All @@ -73,9 +79,12 @@ class NoopRepository implements MonitorRepository {
async saveDevice(_device: Device) {}
async saveMetric(_deviceId: number, _metric: Metric) {}
async saveAlert(_alert: Alert) {}
async saveState(_devices: Device[], _alerts: Alert[]) {}
async saveLog(_deviceId: number, _result: 'success' | 'failure', _responseTime: number | null, _errorMessage?: string) {}
async findUserByEmail(_email: string) { return null }
async upsertUser(_user: StoredUser) {}
async loadSettings() { return null }
async saveSettings(_settings: AppSettings) {}
}

class MySqlRepository implements MonitorRepository {
Expand All @@ -93,7 +102,8 @@ class MySqlRepository implements MonitorRepository {
connectionLimit: Number(process.env.DB_CONNECTION_LIMIT || 10),
queueLimit: 0,
enableKeepAlive: true,
connectTimeout: 5000
connectTimeout: 5000,
multipleStatements: true
})
}

Expand All @@ -104,14 +114,26 @@ class MySqlRepository implements MonitorRepository {
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
await this.pool.query(`
CREATE TABLE IF NOT EXISTS app_settings (
setting_key VARCHAR(100) PRIMARY KEY,
setting_value JSON NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`)
await this.pool.query("INSERT IGNORE INTO schema_migrations(version) VALUES ('002-app-settings')")
const migrationsDirectory = path.resolve(process.cwd(), 'database', 'migrations')
const files = (await fs.readdir(migrationsDirectory)).filter(file => /^\d+_[a-z0-9-]+\.sql$/.test(file)).sort()
for (const file of files) {
const version = file.slice(0, file.indexOf('_'))
const [rows] = await this.pool.query<RowDataPacket[]>('SELECT version FROM schema_migrations WHERE version = ?', [version])
if (rows.length) continue
const sql = await fs.readFile(path.join(migrationsDirectory, file), 'utf8')
const connection = await this.pool.getConnection()
try {
await connection.beginTransaction()
await connection.query(sql)
await connection.execute('INSERT INTO schema_migrations(version) VALUES (?)', [version])
await connection.commit()
} catch (error) {
await connection.rollback()
throw new Error(`Migration ${file} failed: ${error instanceof Error ? error.message : String(error)}`)
} finally {
connection.release()
}
}
await this.pool.query('SELECT 1')
}

Expand Down Expand Up @@ -184,6 +206,40 @@ class MySqlRepository implements MonitorRepository {
)
}

async saveState(devices: Device[], alerts: Alert[]) {
const connection = await this.pool.getConnection()
try {
await connection.beginTransaction()
for (const device of devices) {
await connection.execute(
`INSERT INTO devices(id, name, ip_address, hostname, type, location, monitoring_interval, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name=VALUES(name), ip_address=VALUES(ip_address), hostname=VALUES(hostname), type=VALUES(type), location=VALUES(location), monitoring_interval=VALUES(monitoring_interval), status=VALUES(status)`,
[device.id, device.name, device.address || null, device.address || null, device.type, device.location, device.monitoringInterval, device.status]
)
await connection.execute(
`INSERT INTO network_metrics(device_id, timestamp, latency_ms, packet_loss_percent, download_mbps, upload_mbps, availability_percent, error_count, response_time_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[device.id, new Date(device.latest.timestamp), device.latest.latencyMs, device.latest.packetLossPercent, device.latest.downloadMbps, device.latest.uploadMbps, device.latest.availabilityPercent, device.latest.errorCount, device.latest.responseTimeMs]
)
}
for (const alert of alerts) {
await connection.execute(
`INSERT INTO alerts(id, device_id, metric, value, threshold, severity, message, status, created_at, resolved_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE value=VALUES(value), threshold=VALUES(threshold), severity=VALUES(severity), message=VALUES(message), status=VALUES(status), resolved_at=IF(VALUES(status) = 'resolved', COALESCE(resolved_at, CURRENT_TIMESTAMP), NULL)`,
[alert.id, alert.deviceId, alert.metric, alert.value, alert.threshold, alert.severity, alert.message, alert.status, new Date(alert.createdAt), null]
)
}
await connection.commit()
} catch (error) {
await connection.rollback()
throw error
} finally {
connection.release()
}
}

async saveLog(deviceId: number, result: 'success' | 'failure', responseTime: number | null, errorMessage?: string) {
await this.pool.execute(
'INSERT INTO monitoring_logs(device_id, timestamp, check_type, result, response_time, error_message) VALUES (?, ?, ?, ?, ?, ?)',
Expand All @@ -204,6 +260,21 @@ class MySqlRepository implements MonitorRepository {
[user.name, user.email.toLowerCase(), user.passwordHash, user.role]
)
}

async loadSettings() {
const [rows] = await this.pool.query<RowDataPacket[]>('SELECT setting_value FROM app_settings WHERE setting_key = ? LIMIT 1', ['application'])
const value = rows[0]?.setting_value
if (!value) return null
return typeof value === 'string' ? JSON.parse(value) as Partial<AppSettings> : value as Partial<AppSettings>
}

async saveSettings(settings: AppSettings) {
await this.pool.execute(
`INSERT INTO app_settings(setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`,
['application', JSON.stringify(settings)]
)
}
}

const emptyMetric = (timestamp: string): Metric => ({ timestamp, latencyMs: 0, packetLossPercent: 100, downloadMbps: 0, uploadMbps: 0, availabilityPercent: 0, errorCount: 0, responseTimeMs: 0 })
Expand Down
Loading
Loading