diff --git a/backend/dist/routes/deviceRoutes.js b/backend/dist/routes/deviceRoutes.js new file mode 100644 index 0000000..85f90db --- /dev/null +++ b/backend/dist/routes/deviceRoutes.js @@ -0,0 +1,69 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = require("express"); +const client_1 = require("@prisma/client"); +const VpnService_1 = require("../services/VpnService"); +const router = (0, express_1.Router)(); +const prisma = new client_1.PrismaClient(); +const vpnService = new VpnService_1.VpnService(); +// Helper to generate a VPN IP for a new device +async function getNextVpnIp() { + const baseIp = '10.10.0.'; + // Find the highest VPN IP currently in use + const lastDevice = await prisma.device.findFirst({ + where: { vpnIp: { startsWith: baseIp } }, + orderBy: { vpnIp: 'desc' } + }); + if (!lastDevice || !lastDevice.vpnIp) { + return `${baseIp}1`; // Start with 10.10.0.1 + } + const lastOctet = parseInt(lastDevice.vpnIp.split('.')[3], 10); + if (lastOctet >= 254) { + throw new Error('VPN IP pool exhausted in this subnet'); + } + return `${baseIp}${lastOctet + 1}`; +} +// Get all devices +router.get('/', async (req, res) => { + try { + const devices = await prisma.device.findMany({ include: { tenant: true } }); + res.json(devices); + } + catch (error) { + res.status(500).json({ error: 'Internal server error' }); + } +}); +// Create device for a tenant +router.post('/', async (req, res) => { + try { + const { tenantId, name, macAddress, opnsenseUrl, apiKey, apiSecret } = req.body; + // Automatically generate the VPN IP for this customer device + const nextVpnIp = await getNextVpnIp(); + const newDevice = await prisma.device.create({ + data: { + tenantId, + name, + macAddress, + vpnIp: nextVpnIp, + opnsenseUrl, + apiKey, + apiSecret + }, + }); + // Trigger OpenVPN config generation via SSH to the VPN Server (10.0.1.150) + try { + await vpnService.generateDeviceVpnConfig(macAddress, nextVpnIp); + console.log(`Successfully generated VPN config for ${macAddress} at ${nextVpnIp}`); + } + catch (vpnErr) { + console.error('Failed to generate VPN config:', vpnErr); + // We don't fail the API request entirely, but log the error + } + res.status(201).json(newDevice); + } + catch (error) { + console.error(error); + res.status(500).json({ error: 'Internal server error' }); + } +}); +exports.default = router; diff --git a/backend/dist/routes/tenantRoutes.js b/backend/dist/routes/tenantRoutes.js new file mode 100644 index 0000000..65efc30 --- /dev/null +++ b/backend/dist/routes/tenantRoutes.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = require("express"); +const client_1 = require("@prisma/client"); +const router = (0, express_1.Router)(); +const prisma = new client_1.PrismaClient(); +// Get all tenants +router.get('/', async (req, res) => { + try { + const tenants = await prisma.tenant.findMany({ + include: { devices: true } + }); + res.json(tenants); + } + catch (error) { + res.status(500).json({ error: 'Internal server error' }); + } +}); +// Create tenant +router.post('/', async (req, res) => { + try { + const { name } = req.body; + const newTenant = await prisma.tenant.create({ + data: { name }, + }); + res.status(201).json(newTenant); + } + catch (error) { + res.status(500).json({ error: 'Internal server error' }); + } +}); +exports.default = router; diff --git a/backend/dist/server.js b/backend/dist/server.js new file mode 100644 index 0000000..2dde88a --- /dev/null +++ b/backend/dist/server.js @@ -0,0 +1,26 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const cors_1 = __importDefault(require("cors")); +const client_1 = require("@prisma/client"); +const dotenv_1 = __importDefault(require("dotenv")); +dotenv_1.default.config(); +const app = (0, express_1.default)(); +const prisma = new client_1.PrismaClient(); +const PORT = process.env.PORT || 5000; +app.use((0, cors_1.default)()); +app.use(express_1.default.json()); +// Basic health check endpoint +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', message: '5651 SaaS API is running' }); +}); +const tenantRoutes_1 = __importDefault(require("./routes/tenantRoutes")); +const deviceRoutes_1 = __importDefault(require("./routes/deviceRoutes")); +app.use('/api/tenants', tenantRoutes_1.default); +app.use('/api/devices', deviceRoutes_1.default); +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}`); +}); diff --git a/backend/dist/services/OpnsenseService.js b/backend/dist/services/OpnsenseService.js new file mode 100644 index 0000000..a445e62 --- /dev/null +++ b/backend/dist/services/OpnsenseService.js @@ -0,0 +1,100 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OpnsenseService = void 0; +const axios_1 = __importDefault(require("axios")); +const https_1 = __importDefault(require("https")); +class OpnsenseService { + baseUrl; + apiKey; + apiSecret; + client; + constructor(ipAddress, apiKey, apiSecret) { + // Assuming HTTPS access to OPNsense, might need to adjust port if custom + this.baseUrl = `https://${ipAddress}/api`; + this.apiKey = apiKey; + this.apiSecret = apiSecret; + // OPNsense uses basic auth with API Key and Secret + // We ignore SSL errors because OPNsense often has self-signed certs + const httpsAgent = new https_1.default.Agent({ rejectUnauthorized: false }); + this.client = axios_1.default.create({ + baseURL: this.baseUrl, + auth: { + username: this.apiKey, + password: this.apiSecret, + }, + httpsAgent, + }); + } + /** + * Health check to test API connectivity + */ + async testConnection() { + try { + // Fetching core firmware info as a simple ping + const response = await this.client.get('/core/firmware/info'); + return response.status === 200; + } + catch (error) { + console.error('OPNsense API Connection Error:', error); + return false; + } + } + /** + * Captive Portal: Add or update a voucher/user session + * This allows the hotspot system to authenticate a user automatically via API + */ + async authHotspotUser(zoneId, username, ipAddress) { + try { + // POST /api/captiveportal/session/connect/ + const payload = { + zoneid: zoneId, + user: username, + ip: ipAddress, + }; + // OPNsense expects URL encoded form data or specific JSON structure depending on the endpoint + // Using generic captiveportal session connect endpoint + const response = await this.client.post(`/captiveportal/session/connect`, payload); + return response.data; + } + catch (error) { + console.error('Error authenticating hotspot user:', error); + throw new Error('Could not authenticate hotspot user on OPNsense'); + } + } + /** + * Captive Portal: Disconnect a user + */ + async disconnectHotspotUser(zoneId, sessionId) { + try { + const payload = { + zoneid: zoneId, + sessionid: sessionId + }; + const response = await this.client.post(`/captiveportal/session/disconnect`, payload); + return response.data; + } + catch (error) { + console.error('Error disconnecting hotspot user:', error); + throw new Error('Could not disconnect hotspot user on OPNsense'); + } + } + /** + * Traffic Shaper: Add a rule (e.g. for speed limits) + */ + async addShaperRule(ruleData) { + try { + const response = await this.client.post('/trafficshaper/rule/addRule', { rule: ruleData }); + // Apply the changes (shaper requires an explicit apply call) + await this.client.post('/trafficshaper/service/reconfigure'); + return response.data; + } + catch (error) { + console.error('Error adding shaper rule:', error); + throw new Error('Could not add traffic shaper rule on OPNsense'); + } + } +} +exports.OpnsenseService = OpnsenseService; diff --git a/backend/dist/services/VpnService.js b/backend/dist/services/VpnService.js new file mode 100644 index 0000000..66960a2 --- /dev/null +++ b/backend/dist/services/VpnService.js @@ -0,0 +1,92 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.VpnService = void 0; +const ssh2_1 = require("ssh2"); +class VpnService { + host; + password; + constructor() { + this.host = '10.0.1.150'; // VPN Server IP + this.password = '02!Aug2013'; // Root password + } + /** + * Automatically generates an OpenVPN client config on the VPN server + * and allocates the static VPN IP in the OpenVPN ccd directory. + */ + async generateDeviceVpnConfig(macAddress, vpnIp) { + return new Promise((resolve, reject) => { + const conn = new ssh2_1.Client(); + // Clean up the MAC address to use as a client name (remove colons) + const clientName = `device_${macAddress.replace(/:/g, '')}`; + // The shell script to run on the VPN Server (10.0.1.150) + const setupScript = ` + #!/bin/bash + cd /etc/openvpn/server + + # 1. Create client certificate if it doesn't exist + if [ ! -f "easy-rsa/pki/issued/\${clientName}.crt" ]; then + cd easy-rsa + ./easyrsa build-client-full "\${clientName}" nopass + cd .. + fi + + # 2. Assign static IP in ccd (Client Config Directory) + mkdir -p ccd + echo "ifconfig-push \${vpnIp} 255.255.255.0" > "ccd/\${clientName}" + + # 3. Generate client .ovpn file + cat < "/root/\${clientName}.ovpn" +client +dev tun +proto tcp +remote 10.0.1.150 1194 +resolv-retry infinite +nobind +persist-key +persist-tun +ca ca.crt +cert \${clientName}.crt +key \${clientName}.key +remote-cert-tls server +cipher AES-256-CBC +verb 3 +EOF + + echo "SUCCESS: VPN Config generated for \${clientName} with IP \${vpnIp}" + `; + conn.on('ready', () => { + conn.exec(setupScript, (err, stream) => { + if (err) { + conn.end(); + return reject(err); + } + let output = ''; + stream.on('close', (code, signal) => { + conn.end(); + if (code === 0) { + resolve(true); + } + else { + reject(new Error(`VPN Script failed with code ${code}. Output: ${output}`)); + } + }).on('data', (data) => { + output += data.toString(); + console.log('STDOUT: ' + data); + }).stderr.on('data', (data) => { + output += data.toString(); + console.error('STDERR: ' + data); + }); + }); + }).on('error', (err) => { + reject(err); + }).connect({ + host: this.host, + port: 22, + username: 'root', + password: this.password, + readyTimeout: 10000 + }); + }); + } +} +exports.VpnService = VpnService; diff --git a/backend/package-lock.json b/backend/package-lock.json index 37ed721..91bb44d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "ssh2": "^1.17.0" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.2.0", "@types/ssh2": "^1.15.5", @@ -528,6 +529,16 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 89a8ec9..3c61246 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,6 @@ "keywords": [], "author": "", "license": "ISC", - "type": "commonjs", "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", @@ -21,6 +20,7 @@ "ssh2": "^1.17.0" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^26.2.0", "@types/ssh2": "^1.15.5", diff --git a/backend/tsconfig.json b/backend/tsconfig.json index cec4a3a..c3329ec 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,44 +1,13 @@ { - // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { - // File Layout - // "rootDir": "./src", - // "outDir": "./dist", - - // Environment Settings - // See also https://aka.ms/tsconfig/module - "module": "nodenext", - "target": "esnext", - "types": [], - // For nodejs: - // "lib": ["esnext"], - // "types": ["node"], - // and npm install -D @types/node - - // Other Outputs - "sourceMap": true, - "declaration": true, - "declarationMap": true, - - // Stricter Typechecking Options - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - - // Style Options - // "noImplicitReturns": true, - // "noImplicitOverride": true, - // "noUnusedLocals": true, - // "noUnusedParameters": true, - // "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - - // Recommended Options + "target": "es2022", + "module": "commonjs", + "rootDir": "./src", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "strict": true, - "jsx": "react-jsx", - "verbatimModuleSyntax": true, - "isolatedModules": true, - "noUncheckedSideEffectImports": true, - "moduleDetection": "force", - "skipLibCheck": true, - } + "skipLibCheck": true + }, + "include": ["src/**/*"] } diff --git a/backend_patch.zip b/backend_patch.zip new file mode 100644 index 0000000..f63deaa Binary files /dev/null and b/backend_patch.zip differ