feat: initial zROC project recreation (stubs for large files pending)
- 61 files across zroc-ui/ and zroc-ova/ directories - Full content written for: config, auth, API layers, CSS, build files, OVA scripts, backend routes, charts, hooks, constants - Stubs in place for: page components, Sidebar, TopBar, docker-compose, authentik client, blueprint YAML, packer HCL, workflows, setup wizard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// TODO: Full content to be added
|
||||
// This file is part of the zROC project recreation
|
||||
@@ -0,0 +1,41 @@
|
||||
// backend/config.js — central configuration with validation
|
||||
'use strict';
|
||||
|
||||
function require_env(name) {
|
||||
const val = process.env[name];
|
||||
if (!val) throw new Error(`Required environment variable ${name} is not set`);
|
||||
return val;
|
||||
}
|
||||
|
||||
function optional_env(name, fallback = '') {
|
||||
return process.env[name] || fallback;
|
||||
}
|
||||
|
||||
const config = {
|
||||
port: parseInt(optional_env('PORT', '3001'), 10),
|
||||
node_env: optional_env('NODE_ENV', 'production'),
|
||||
is_dev: optional_env('NODE_ENV', 'production') === 'development',
|
||||
session_secret: optional_env('SESSION_SECRET', 'CHANGE_ME_IN_PRODUCTION_' + Math.random()),
|
||||
session_max_age_ms: parseInt(optional_env('SESSION_MAX_AGE_HOURS', '24'), 10) * 60 * 60 * 1000,
|
||||
prometheus_url: optional_env('PROMETHEUS_URL', 'http://prometheus:9090'),
|
||||
authentik_url: optional_env('AUTHENTIK_URL', 'http://authentik-server:9000'),
|
||||
authentik_client_id: optional_env('AUTHENTIK_CLIENT_ID', 'zroc-dashboard'),
|
||||
authentik_client_secret: optional_env('AUTHENTIK_CLIENT_SECRET', ''),
|
||||
authentik_admin_token: optional_env('AUTHENTIK_ADMIN_TOKEN', ''),
|
||||
public_url: optional_env('PUBLIC_URL', 'https://localhost:8443'),
|
||||
admin_group: optional_env('AUTHENTIK_ADMIN_GROUP', 'zroc-admins'),
|
||||
viewer_group: optional_env('AUTHENTIK_VIEWER_GROUP', 'zroc-viewers'),
|
||||
redis_url: optional_env('REDIS_URL', ''),
|
||||
};
|
||||
|
||||
if (!config.authentik_client_secret) {
|
||||
console.warn('[CONFIG] AUTHENTIK_CLIENT_SECRET not set — auth will fail until configured');
|
||||
}
|
||||
if (!config.authentik_admin_token) {
|
||||
console.warn('[CONFIG] AUTHENTIK_ADMIN_TOKEN not set — user management API will be unavailable');
|
||||
}
|
||||
if (config.session_secret.startsWith('CHANGE_ME')) {
|
||||
console.warn('[CONFIG] SESSION_SECRET not set — using random value, sessions will not survive restart');
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,18 @@
|
||||
// backend/logger.js
|
||||
'use strict';
|
||||
const { createLogger, format, transports } = require('winston');
|
||||
const config = require('./config');
|
||||
|
||||
const logger = createLogger({
|
||||
level: config.is_dev ? 'debug' : 'info',
|
||||
format: format.combine(
|
||||
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
format.errors({ stack: true }),
|
||||
config.is_dev
|
||||
? format.combine(format.colorize(), format.simple())
|
||||
: format.json()
|
||||
),
|
||||
transports: [new transports.Console()],
|
||||
});
|
||||
|
||||
module.exports = logger;
|
||||
@@ -0,0 +1,28 @@
|
||||
// backend/middleware/authenticate.js
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Middleware: require an authenticated session.
|
||||
* If the request has no valid session → 401.
|
||||
* Attaches req.user = { id, username, name, email, role } for downstream use.
|
||||
*/
|
||||
function authenticate(req, res, next) {
|
||||
if (!req.session?.user) {
|
||||
return res.status(401).json({ error: 'Unauthorized', code: 'NO_SESSION' });
|
||||
}
|
||||
req.user = req.session.user;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: require admin role.
|
||||
* Must be used AFTER authenticate().
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden', code: 'REQUIRES_ADMIN' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireAdmin };
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "zroc-ui-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "zROC UI backend — auth, Prometheus proxy, Authentik user management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"connect-redis": "^7.1.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^7.3.1",
|
||||
"express-session": "^1.18.0",
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"openid-client": "^5.7.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^10.0.0",
|
||||
"winston": "^3.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// backend/routes/admin/users.js
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { authenticate, requireAdmin } = require('../../middleware/authenticate');
|
||||
const authentik = require('../../authentik');
|
||||
const logger = require('../../logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate, requireAdmin);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { search = '', page = '1', pageSize = '50' } = req.query;
|
||||
const result = await authentik.listUsers({
|
||||
search,
|
||||
page: parseInt(page, 10),
|
||||
pageSize: parseInt(pageSize, 10),
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('[Users] List failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to list users', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const user = await authentik.getUser(req.params.id);
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
const status = err.response?.status === 404 ? 404 : 502;
|
||||
res.status(status).json({ error: 'User not found', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { username, name, email, isActive = true, groups = [], password } = req.body;
|
||||
if (!username || !name || !email) {
|
||||
return res.status(400).json({ error: 'username, name, and email are required' });
|
||||
}
|
||||
const user = await authentik.createUser({ username, name, email, isActive, groups, password });
|
||||
logger.info(`[Users] ${req.user.username} created user ${username}`);
|
||||
res.status(201).json(user);
|
||||
} catch (err) {
|
||||
const detail = err.response?.data || err.message;
|
||||
logger.error('[Users] Create failed:', detail);
|
||||
res.status(err.response?.status === 400 ? 400 : 502).json({ error: 'Failed to create user', detail });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, email, isActive, groups } = req.body;
|
||||
const user = await authentik.updateUser(req.params.id, { name, email, isActive, groups });
|
||||
logger.info(`[Users] ${req.user.username} updated user ${user.username}`);
|
||||
res.json(user);
|
||||
} catch (err) {
|
||||
logger.error('[Users] Update failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to update user', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const targetId = parseInt(req.params.id, 10);
|
||||
if (String(targetId) === String(req.user.id) || req.user.username === 'akadmin') {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account or the akadmin account' });
|
||||
}
|
||||
await authentik.deleteUser(targetId);
|
||||
logger.info(`[Users] ${req.user.username} deleted user ${targetId}`);
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
logger.error('[Users] Delete failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to delete user', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/set-password', async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
}
|
||||
await authentik.setPassword(req.params.id, password);
|
||||
logger.info(`[Users] ${req.user.username} reset password for user ${req.params.id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('[Users] Password reset failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to set password', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/setup-2fa', async (req, res) => {
|
||||
try {
|
||||
const { setupUrl, qrDataUrl } = await authentik.generateTwoFactorSetupLink(req.params.id);
|
||||
logger.info(`[Users] ${req.user.username} generated 2FA setup link for user ${req.params.id}`);
|
||||
res.json({ setupUrl, qrDataUrl });
|
||||
} catch (err) {
|
||||
logger.error('[Users] 2FA setup failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to generate 2FA setup link', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/meta/groups', async (req, res) => {
|
||||
try {
|
||||
const groups = await authentik.listGroups();
|
||||
res.json(groups);
|
||||
} catch (err) {
|
||||
logger.error('[Users] Groups list failed:', err.message);
|
||||
res.status(502).json({ error: 'Failed to list groups', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,138 @@
|
||||
// backend/routes/auth.js — OIDC login / callback / logout
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { Issuer, generators } = require('openid-client');
|
||||
const config = require('../config');
|
||||
const logger = require('../logger');
|
||||
const { authenticate } = require('../middleware/authenticate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
let oidcClient = null;
|
||||
|
||||
async function getOidcClient() {
|
||||
if (oidcClient) return oidcClient;
|
||||
|
||||
const issuerUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/`;
|
||||
logger.info(`[Auth] Discovering OIDC issuer at ${issuerUrl}`);
|
||||
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
oidcClient = new issuer.Client({
|
||||
client_id: config.authentik_client_id,
|
||||
client_secret: config.authentik_client_secret,
|
||||
redirect_uris: [`${config.public_url}/api/auth/callback`],
|
||||
response_types: ['code'],
|
||||
});
|
||||
|
||||
logger.info('[Auth] OIDC client initialised');
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
router.get('/login', async (req, res) => {
|
||||
try {
|
||||
const client = await getOidcClient();
|
||||
const state = generators.state();
|
||||
const nonce = generators.nonce();
|
||||
const verifier = generators.codeVerifier();
|
||||
const challenge = generators.codeChallenge(verifier);
|
||||
|
||||
req.session.oidc = { state, nonce, verifier };
|
||||
|
||||
const redirectTo = req.query.redirect || '/';
|
||||
req.session.postLoginRedirect = redirectTo;
|
||||
|
||||
const authUrl = client.authorizationUrl({
|
||||
scope: 'openid profile email groups',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
res.redirect(authUrl);
|
||||
} catch (err) {
|
||||
logger.error('[Auth] Login redirect failed:', err);
|
||||
res.status(502).json({ error: 'Identity provider unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/callback', async (req, res) => {
|
||||
try {
|
||||
const client = await getOidcClient();
|
||||
const { state, nonce, verifier } = req.session.oidc || {};
|
||||
|
||||
if (!state) {
|
||||
return res.redirect('/?error=session_expired');
|
||||
}
|
||||
|
||||
const params = client.callbackParams(req);
|
||||
const tokenSet = await client.callback(
|
||||
`${config.public_url}/api/auth/callback`,
|
||||
params,
|
||||
{ state, nonce, code_verifier: verifier }
|
||||
);
|
||||
const userinfo = await client.userinfo(tokenSet.access_token);
|
||||
|
||||
const groups = userinfo.groups ?? [];
|
||||
const role = groups.includes(config.admin_group)
|
||||
? 'admin'
|
||||
: groups.includes(config.viewer_group)
|
||||
? 'viewer'
|
||||
: 'viewer';
|
||||
|
||||
req.session.user = {
|
||||
id: userinfo.sub,
|
||||
username: userinfo.preferred_username,
|
||||
name: userinfo.name,
|
||||
email: userinfo.email,
|
||||
role,
|
||||
groups,
|
||||
accessToken: tokenSet.access_token,
|
||||
refreshToken: tokenSet.refresh_token,
|
||||
expiresAt: tokenSet.expires_at,
|
||||
};
|
||||
|
||||
delete req.session.oidc;
|
||||
|
||||
const redirect = req.session.postLoginRedirect || '/';
|
||||
delete req.session.postLoginRedirect;
|
||||
|
||||
logger.info(`[Auth] User ${userinfo.preferred_username} (${role}) logged in`);
|
||||
res.redirect(redirect);
|
||||
} catch (err) {
|
||||
logger.error('[Auth] Callback failed:', err);
|
||||
res.redirect('/?error=auth_failed');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', authenticate, async (req, res) => {
|
||||
const username = req.user?.username;
|
||||
const idToken = req.session.user?.accessToken;
|
||||
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
logger.info(`[Auth] User ${username} logged out`);
|
||||
|
||||
const endSessionUrl = `${config.authentik_url}/application/o/${config.authentik_client_id}/end-session/`;
|
||||
const params = new URLSearchParams({ post_logout_redirect_uri: config.public_url });
|
||||
if (idToken) params.set('id_token_hint', idToken);
|
||||
res.json({ redirectUrl: `${endSessionUrl}?${params}` });
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, (req, res) => {
|
||||
const { id, username, name, email, role, groups } = req.user;
|
||||
res.json({ id, username, name, email, role, groups });
|
||||
});
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
if (req.session?.user) {
|
||||
const { id, username, name, email, role } = req.session.user;
|
||||
res.json({ authenticated: true, user: { id, username, name, email, role } });
|
||||
} else {
|
||||
res.status(401).json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,26 @@
|
||||
// backend/routes/prometheus.js
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const config = require('../config');
|
||||
const { authenticate } = require('../middleware/authenticate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
const prometheusProxy = createProxyMiddleware({
|
||||
target: config.prometheus_url,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/api/prometheus': '' },
|
||||
on: {
|
||||
error: (err, req, res) => {
|
||||
res.status(502).json({ error: 'Prometheus unreachable', detail: err.message });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
router.use('/', prometheusProxy);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,75 @@
|
||||
// backend/server.js — zROC UI backend entry point
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
const config = require('./config');
|
||||
const logger = require('./logger');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const prometheusRoute = require('./routes/prometheus');
|
||||
const adminUserRoutes = require('./routes/admin/users');
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
|
||||
const sessionMiddleware = session({
|
||||
secret: config.session_secret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: !config.is_dev,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: config.session_max_age_ms,
|
||||
},
|
||||
});
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later' },
|
||||
});
|
||||
app.use('/api/auth', authLimiter);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/prometheus', prometheusRoute);
|
||||
app.use('/api/admin/users', adminUserRoutes);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
const distPath = path.join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, _next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`[Server] zROC UI backend listening on port ${config.port}`);
|
||||
logger.info(`[Server] Environment: ${config.node_env}`);
|
||||
logger.info(`[Server] Prometheus: ${config.prometheus_url}`);
|
||||
logger.info(`[Server] Authentik: ${config.authentik_url}`);
|
||||
});
|
||||
Reference in New Issue
Block a user