This commit is contained in:
Gökhan ÖZARSLAN 2026-08-16 16:15:01 +03:00
parent 6b5a6d0a71
commit ced2c9d61b
10 changed files with 469 additions and 23 deletions

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.19.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
@ -1184,6 +1185,18 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@ -1222,6 +1235,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@ -1232,6 +1251,18 @@
"node": ">= 6.0.0" "node": ">= 6.0.0"
} }
}, },
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -1454,6 +1485,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/confbox": { "node_modules/confbox": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@ -1729,6 +1772,15 @@
"robust-predicates": "^3.0.2" "robust-predicates": "^3.0.2"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -1877,6 +1929,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -2055,6 +2122,26 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -2072,6 +2159,43 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -2246,6 +2370,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -2278,6 +2417,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@ -2934,6 +3086,15 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",

View File

@ -11,6 +11,7 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"axios": "^1.19.0",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",

View File

@ -21,6 +21,7 @@ model Device {
tenantId String tenantId String
name String name String
macAddress String macAddress String
vpnIp String? @unique
ipAddress String? ipAddress String?
opnsenseUrl String? opnsenseUrl String?
apiKey String? apiKey String?

View File

@ -0,0 +1,68 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// Helper to generate a VPN IP for a new device
async function getNextVpnIp(): Promise<string> {
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
},
});
// TODO: Actually trigger OpenVPN config generation via SSH or scripts to the VPN Server (10.0.1.150)
// generateVpnConfig(nextVpnIp, macAddress);
res.status(201).json(newDevice);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@ -0,0 +1,32 @@
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new 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' });
}
});
export default router;

View File

@ -17,30 +17,11 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: '5651 SaaS API is running' }); res.json({ status: 'ok', message: '5651 SaaS API is running' });
}); });
// Get all tenants import tenantRoutes from './routes/tenantRoutes';
app.get('/api/tenants', async (req, res) => { import deviceRoutes from './routes/deviceRoutes';
try {
const tenants = await prisma.tenant.findMany();
res.json(tenants);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Create a new tenant app.use('/api/tenants', tenantRoutes);
app.post('/api/tenants', async (req, res) => { app.use('/api/devices', deviceRoutes);
try {
const { name } = req.body;
const newTenant = await prisma.tenant.create({
data: { name },
});
res.status(201).json(newTenant);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`); console.log(`Server is running on port ${PORT}`);

View File

@ -0,0 +1,100 @@
import axios from 'axios';
import https from 'https';
export class OpnsenseService {
private baseUrl: string;
private apiKey: string;
private apiSecret: string;
private client: any;
constructor(ipAddress: string, apiKey: string, apiSecret: string) {
// 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.Agent({ rejectUnauthorized: false });
this.client = axios.create({
baseURL: this.baseUrl,
auth: {
username: this.apiKey,
password: this.apiSecret,
},
httpsAgent,
});
}
/**
* Health check to test API connectivity
*/
async testConnection(): Promise<boolean> {
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: string, username: string, ipAddress: string): Promise<any> {
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: string, sessionId: string): Promise<any> {
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: any): Promise<any> {
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');
}
}
}

View File

@ -0,0 +1,30 @@
export default function AdminDashboard() {
return (
<div className="min-h-screen bg-zinc-950 text-white p-8">
<header className="mb-8">
<h1 className="text-3xl font-light tracking-tight text-zinc-100">ArmoReg <span className="font-semibold">Admin</span></h1>
<p className="text-zinc-400 mt-2">SaaS Central Management Platform</p>
</header>
<main className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-sm hover:border-zinc-700 transition-colors">
<h2 className="text-xl font-medium mb-2">Tenants</h2>
<p className="text-zinc-400 text-sm mb-4">Manage customer accounts and subscriptions.</p>
<div className="text-3xl font-light">0</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-sm hover:border-zinc-700 transition-colors">
<h2 className="text-xl font-medium mb-2">Devices</h2>
<p className="text-zinc-400 text-sm mb-4">Active OPNsense gateways connected.</p>
<div className="text-3xl font-light">0</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-sm hover:border-zinc-700 transition-colors">
<h2 className="text-xl font-medium mb-2">VPN Tunnels</h2>
<p className="text-zinc-400 text-sm mb-4">Active DPI bypass tunnels.</p>
<div className="text-3xl font-light">0</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,34 @@
export default function TenantPanel() {
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900 p-8">
<header className="mb-8 border-b border-zinc-200 pb-4">
<h1 className="text-3xl font-light tracking-tight text-zinc-800">Customer <span className="font-semibold text-blue-600">Panel</span></h1>
<p className="text-zinc-500 mt-2">Manage your Hotspot and Firewall settings</p>
</header>
<main className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white border border-zinc-200 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-medium">Hotspot Settings</h2>
<span className="bg-green-100 text-green-700 text-xs font-semibold px-2 py-1 rounded">Active</span>
</div>
<p className="text-zinc-500 text-sm mb-4">Configure authentication methods (SMS, TC ID) for your guests.</p>
<button className="bg-zinc-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-zinc-800 transition-colors">
Configure
</button>
</div>
<div className="bg-white border border-zinc-200 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-medium">Speed Limits</h2>
<span className="bg-blue-100 text-blue-700 text-xs font-semibold px-2 py-1 rounded">Configured</span>
</div>
<p className="text-zinc-500 text-sm mb-4">Manage bandwidth allocation via OPNsense Traffic Shaper.</p>
<button className="bg-zinc-900 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-zinc-800 transition-colors">
Manage Limits
</button>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
export function middleware(req: NextRequest) {
const url = req.nextUrl;
// Get hostname (e.g., admin.armoreg.com, panel.armoreg.com, localhost:3000)
const hostname = req.headers.get('host') || '';
// Determine which subdomain we are on
const isAdmin = hostname.includes('admin.armoreg.com') || hostname.startsWith('admin.localhost');
const isPanel = hostname.includes('panel.armoreg.com') || hostname.startsWith('panel.localhost');
// Rewrite to the appropriate folder inside /app
if (isAdmin) {
return NextResponse.rewrite(new URL(`/admin${url.pathname}`, req.url));
}
if (isPanel) {
return NextResponse.rewrite(new URL(`/panel${url.pathname}`, req.url));
}
// Default fallback (e.g. main landing page or redirect to panel)
return NextResponse.next();
}