improved Database & Models

This commit is contained in:
Kai Waggeling 2025-12-06 20:04:11 +01:00
commit 0bbe91bec3
18 changed files with 956 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
data/*
*test*.mjs
package-lock.json

46
docker/Dockerfile Normal file
View file

@ -0,0 +1,46 @@
FROM alpine:latest
# ----------------------------------------
# Install required packages
# ----------------------------------------
RUN apk update && apk add --no-cache \
wireguard-tools \
wireguard-virt \
nftables \
supervisor \
nodejs \
npm \
curl \
bash
# ----------------------------------------
# Setup nftables base config
# You will manage rules from Node.js or mounted config
# ----------------------------------------
RUN mkdir -p /etc/nftables
COPY nftables.conf /etc/nftables/nftables.conf
# ----------------------------------------
# Application
# ----------------------------------------
WORKDIR /app
COPY ../package.json ./
RUN npm install --production
COPY .. .
# ----------------------------------------
# Supervisor config
# ----------------------------------------
COPY supervisor.conf /etc/
COPY start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
# ----------------------------------------
# Volumes
# ----------------------------------------
VOLUME ["/etc/wireguard", "/etc/nftables", "/app/data"]
EXPOSE 3000
CMD ["/usr/local/bin/start.sh"]

28
docker/nftables.conf Normal file
View file

@ -0,0 +1,28 @@
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
# Accept localhost
iif lo accept
# Accept WireGuard traffic
udp dport 51820 accept
# Allow traffic from wg0 only if defined later (allowlist approach)
iif wg0 drop
}
chain forward {
type filter hook forward priority 0;
# Default deny
drop
}
chain output {
type filter hook output priority 0;
}
}

35
docker/start.sh Normal file
View file

@ -0,0 +1,35 @@
#!/bin/sh
set -e
# --------------------------------------------
# Ensure /etc/wireguard exists
# --------------------------------------------
if [ ! -d /etc/wireguard ]; then
echo "WARN: /etc/wireguard does not exist. Creating it..."
mkdir -p /etc/wireguard
fi
# Default config für WireGuard
if [ ! -f /etc/wireguard/wg0.conf ]; then
echo "INFO: Installing default WireGuard config..."
cp /defaults/wg0.conf /etc/wireguard/wg0.conf
fi
# --------------------------------------------
# Ensure /etc/nftables exists
# --------------------------------------------
if [ ! -d /etc/nftables ]; then
echo "WARN: /etc/nftables does not exist. Creating it..."
mkdir -p /etc/nftables
fi
# default nftables.conf
if [ ! -f /etc/nftables/nftables.conf ]; then
echo "INFO: Installing default nftables.conf..."
cp /defaults/nftables.conf /etc/nftables/nftables.conf
fi
# --------------------------------------------
# Start Supervisor
# --------------------------------------------
exec /usr/bin/supervisord -c /etc/supervisor.conf

14
docker/supervisor.conf Normal file
View file

@ -0,0 +1,14 @@
[program:nftables]
command=nft -f /etc/nftables/nftables.conf
priority=5
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stderr_logfile=/dev/fd/2
[program:manager]
command=node /app/master.mjs
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stderr_logfile=/dev/fd/2

BIN
docs/database.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -0,0 +1,15 @@
import {
instance,
peer,
address
} from "../models.mjs";
export async function generateWireguardServerConfig(interfaceName) {
}
export async function generateWireguardClientConfig(interfaceName) {
}

465
lib/models.mjs Normal file
View file

@ -0,0 +1,465 @@
import {
object,
array,
string,
number,
enums,
boolean,
literal,
optional,
dynamic,
nullable,
defaulted,
union,
size,
create,
assert,
define,
} from "superstruct";
import { Database } from "all.db";
import crypto from "node:crypto";
import {
ipv4,
ipv6
} from "cidr-block";
import path from "node:path";
import { EventEmitter } from "node:events";
class ObjectModel {
#modelName;
#modelStruct;
#modelStore;
#dynRef = {};
#hooks = new EventEmitter();
constructor(settings) {
this.#modelName = settings.name;
this.#modelStruct = settings.model;
this.#modelStore = new Database({
dataPath: path.join(process.cwd(), 'data/database', `${settings.name}.json`)
});
if (settings.hooks) {
Object.entries(settings.hooks).forEach(([eventName, eventFunction]) => {
this.on(eventName, eventFunction);
})
}
this.#dynRef.refOne = dynamic((objectId, context) => {
let permittedObjIds = Object.values(this.getAll()).map(item => item.id);
return enums(permittedObjIds);
});
this.#dynRef.refMany = dynamic((objectId, context) => {
let permittedObjIds = Object.values(this.getAll()).map(item => item.id);
return array(
enums(permittedObjIds)
);
});
if (settings.dynRef instanceof Object) {
this.#dynRef = Object.assign(this.#dynRef, settings.dynRef)
}
}
complete(data) {
try {
let result = create(data, this.#modelStruct);
return result;
} catch (error) {
throw new Error(`error completing ${this.#modelName}: ` + error.message);
}
}
validate(data) {
try {
assert(data, this.#modelStruct);
return data;
} catch (error) {
throw new Error(`error validating ${this.#modelName}: ` + error.message);
}
}
getAll() {
return this.#modelStore.getAll();
}
getById(objectId) {
return this.#modelStore.get(objectId);
}
create(objectData) {
// try {
const newData = this.validate(
this.complete(objectData)
);
if (this.#modelStore.exists(newData.id)) {
throw new Error(`failed create ${this.#modelName} with ID ${newData.id}. already existing in database.`);
}
this.#hooks.emit('beforeCreate', null, newData);
this.#modelStore.set(newData.id, newData);
this.#hooks.emit('afterCreate', newData);
return newData;
// } catch (error) {
// throw error;
// }
}
update(objectData) {
// try {
const newData = this.validate(
this.complete(objectData)
);
const oldData = this.getById(newData.id)
if (!oldData) {
throw new Error(`failed update ${this.#modelName} with ID ${newData.id}. not existing in database.`);
}
this.#hooks.emit('beforeUpdate', oldData, newData);
this.#modelStore.set(newData.id, newData);
this.#hooks.emit('afterUpdate', oldData, newData);
return newData;
// } catch (error) {
// throw error;
// }
}
delete(objectId) {
// try {
const oldData = this.getById(objectId);
if (!oldData) {
throw new Error(`failed delete ${this.#modelName} with ID ${newData.id}. not existing in database.`);
}
this.#hooks.emit('beforeDelete', oldData, null);
this.#modelStore.delete(oldData.id);
this.#hooks.emit('afterDelete', oldData, null);
return oldData;
// } catch (error) {
// throw error;
// }
}
has(objectId) {
return this.#modelStore.has(objectId);
}
get dynRef() {
return this.#dynRef;
}
on(eventName, eventFunction) {
this.#hooks.on(eventName, eventFunction);
}
}
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// Custom Checks
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
const ipv4Address = define('ipv4Address', (value) => {
return ipv4.isValidAddress(value);
});
const ipv4Cidr = define('ipv4Cidr', (value) => {
console.log(value);
console.log(ipv4.isValidCIDR(value));
return ipv4.isValidCIDR(value);
});
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// Base Models
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
// Authorisation Provider Model
export const authProvider = new ObjectModel({
name: 'authProvider',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: string(),
login_text: string(),
type: enums(["oauth2"]),
settings: dynamic((value, context) => {
switch (value.type) {
case "oauth2":
return object({
client_id: string(),
client_secret: string(),
authorize_url: string(),
token_url: string(),
user_info_url: string(),
redirect_url: string(),
scope: array(string())
});
}
return object({});
})
})
});
// User Model
export const user = new ObjectModel({
name: 'user',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
external_id: string(),
provider: string(),
displayName: string(),
email: string()
})
});
// User Group Model
export const userGroup = new ObjectModel({
name: 'userGroup',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: string(),
comment: defaulted(string(), ''),
members: user.dynRef.refMany
})
});
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// Wireguard Models
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
// Wireguard Interface Model
export const wireguardInterface = new ObjectModel({
name: 'wireguardInterface',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
enabled: defaulted(boolean(), true),
privateKey: string(),
publicKey: string(),
ifName: string(),
ifAddress: ipv4Cidr,
listenPort: number(),
endpoint: string(),
dnsServer: nullable(ipv4Address)
})
});
// Wireguard Peer Model
export const wireguardPeer = new ObjectModel({
name: 'wireguardPeer',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
interface: wireguardInterface.dynRef.refOne,
enabled: defaulted(boolean(), true),
privateKey: string(),
publicKey: string(),
presharedKey: string(),
ifAddress: ipv4Cidr,
keepalive: size(defaulted(number(), 30), 1, 300),
})
});
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// CMDB Models
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
// Address Object Model
export const addressObject = new ObjectModel({
name: 'addressObject',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
elements: array(union([
object({
type: literal('ipmask'),
ipmask: ipv4Address
}),
object({
type: literal('fqdn'),
fqdn: string(),
result: nullable(defaulted(string(), null))
}),
object({
type: literal('peer'),
peer: wireguardPeer.dynRef.refOne
})
])),
members: dynamic((memberList, context) => {
// allow many values from list of Peer IDs
// - exclude objects that contain members (loop prevention)
// - exclude self (loop prevention)
let permittedAdressObjects = Object.values(addressObject.getAll())
.filter(object => object.members.length == 0)
.filter(object => object.id != context.id)
.map(item => item.id);
// return array from here to reduce Database reads
return array(enums(permittedAdressObjects));
}),
})
});
// Service Object Model
export const serviceObject = new ObjectModel({
name: 'serviceObject',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
elements: array(union([
object({
type: literal('tcp'),
port: number()
}),
object({
type: literal('udp'),
port: number()
}),
object({
type: literal('icmp'),
icmpType: enums([
"echo-reply",
"destination-unreachable",
"source-quench",
"redirect",
"echo-request",
"time-exceeded",
"parameter-problem",
"timestamp-request",
"timestamp-reply",
"info-request",
"info-reply",
"address-mask-request",
"address-mask-reply",
"router-advertisement",
"router-solicitation"
])
}),
object({
type: literal('esp'),
port: number()
})
]))
})
});
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// Firewall Policy Models
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
export const accessPolicy = new ObjectModel({
name: 'accessPolicy',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
enabled: defaulted(boolean(), true),
srcAddr: addressObject.dynRef.refMany,
dstAddr: addressObject.dynRef.refMany,
services: serviceObject.dynRef.refMany,
})
});
export const natPolicy = new ObjectModel({
name: 'natPolicy',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
name: size(string(), 1, 128),
comment: defaulted(size(string(), 0, 256), ''),
enabled: defaulted(boolean(), true),
natType: enums(['snat', 'dnat', ' masquerade']),
srcAddress: string(),
dstAddress: string(),
dnatPort: optional(number()), // Ziel Port für DNAT
})
});
// - --- --- --- --- --- --- --- --- --- --- --- ---
//
// Access Control Models
//
// - --- --- --- --- --- --- --- --- --- --- --- ---
export const configToken = new ObjectModel({
name: 'configToken',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
token: defaulted(string(), () => {
return crypto.randomBytes(32).toString('hex');
}),
peer: string(),
name: string()
})
});
export const apiToken = new ObjectModel({
name: 'apiToken',
model: object({
id: defaulted(string(), () => {
return crypto.randomUUID();
}),
token: defaulted(string(), () => {
return crypto.randomBytes(32).toString('hex');
}),
name: string()
}),
hooks: {
beforeCreate: (oldData, newData) => {
console.log("before API Token created");
},
afterCreate: (oldData, newData) => {
console.log("after API Token created");
}
}
});
export default {
wireguardInterface,
wireguardPeer,
addressObject,
authProvider,
user,
userGroup,
accessPolicy,
natPolicy,
configToken,
apiToken
}

40
lib/nftables/config.mjs Normal file
View file

@ -0,0 +1,40 @@
import {
wireguardInterface,
wireguardPeer,
addressObject,
accessPolicy,
natPolicy
} from "../models.mjs";
import path from "node:path";
import file from "node:fs";
import ejs from "ejs";
const configTemplatePath = path.join(process.cwd(), 'templates', 'nftables.ejs');
export async function generateNftablesConfig(interfaceId) {
const ifData = Object.values(wireguardInterface.getAll()).find(fi => fi.id == interfaceId);
const addressObjects = addressObject.getAll();
const accessPolicies = accessPolicy.getAll();
const natPolicies = natPolicy.getAll();
const configData = {
interface: ifData,
peerList
};
const configContent = await ejs.renderFile(
configTemplatePath,
configData,
{
async: true
}
);
file.writeFileSync(
path.join(process.cwd(), 'data', 'nftables', `${ifData.ifName}.conf`),
configContent
)
}

34
lib/wireguard/config.mjs Normal file
View file

@ -0,0 +1,34 @@
import {
wireguardInterface,
wireguardPeer
} from "../models.mjs";
import path from "node:path";
import file from "node:fs";
import ejs from "ejs";
const serverTemplatePath = path.join(process.cwd(), 'templates', 'wg_server.ejs');
const clientTemplatePath = path.join(process.cwd(), 'templates', 'wg_client.ejs');
export async function generateInterfaceConfig(interfaceId) {
const ifData = Object.values(wireguardInterface.getAll()).find(fi => fi.id == interfaceId);
const peerList = Object.values(wireguardPeer.getAll()).filter(fi => fi.interface == interfaceId);
let configData = await ejs.renderFile(
serverTemplatePath,
{
interface: ifData,
peerList
},
{
async: true
}
);
file.writeFileSync(
path.join(process.cwd(), 'data', 'wireguard', `${ifData.ifName}.conf`),
configData
)
}

45
lib/wireguard/process.mjs Normal file
View file

@ -0,0 +1,45 @@
import {
execSync as execCommand
} from "node:child_process";
import {
wireguardInterface
} from "../models.mjs";
export async function startWireguardInterface(interfaceid) {
try {
const ifData = wireguardInterface.getById(interfaceid);
const ifLink = getInterfaceLink(ifData.ifName);
if (ifLink.operstate == 'UP') {
console.log(`Reloading interface ${ifData.ifName}`);
console.log(`» reloading interface ${ifData.ifName}`);
execCommand(`wg-quick up ${ifData.ifName}`, { stdio: 'inherit' });
console.log(`✓ reloaded interface ${ifData.ifName}`);
} else {
console.log(`» starting interface ${ifData.ifName}`);
execCommand(`wg-quick up ${ifData.ifName}`, { stdio: 'inherit' });
console.log(`✓ started interface ${ifData.ifName}`);
}
} catch (error) {
console.error(`✕ failed to start interface ${iface}:`, error.message);
}
}
export async function getInterfaceLink(ifName) {
try {
let result = JSON.parse(
execCommand(`ip -j link show`, { stdio: 'inherit' })
).find(linkResult => linkResult.ifname == ifName);
if (!result) { return undefined; }
return result;
} catch (error) {
console.error(`failed to get interface link for ${ifName}:`, error.message);
return undefined;
}
}

26
master.mjs Normal file
View file

@ -0,0 +1,26 @@
import path from "path";
import fs from "fs";
import "./webserver.mjs";
import {
apiToken,
wireguardInterface
} from "./lib/models.mjs";
// create database directory if not exists
const databaseDir = path.join(process.cwd(), 'data', 'database');
if (!fs.existsSync(databaseDir)) {
fs.mkdirSync(databaseDir, { recursive: true });
}
// create database directory if not exists
const wgConfigDir = path.join(process.cwd(), 'data', 'wireguard');
if (!fs.existsSync(wgConfigDir)) {
fs.mkdirSync(wgConfigDir, { recursive: true });
}
// create database directory if not exists
const nftConfigDir = path.join(process.cwd(), 'data', 'nftables');
if (!fs.existsSync(nftConfigDir)) {
fs.mkdirSync(nftConfigDir, { recursive: true });
}

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "wireguard-manager",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@fastify/autoload": "^6.3.1",
"@fastify/view": "^11.1.1",
"all.db": "^0.3.2",
"cidr-block": "^2.1.1",
"ejs": "^3.1.10",
"fastify": "^5.6.2",
"superstruct": "^2.0.2"
}
}

54
routes/api/v1/routes.mjs Normal file
View file

@ -0,0 +1,54 @@
import models from "../../../lib/models.mjs";
export default async function (fastify, opts) {
fastify.register(async function secureApiContext(secureApiScope) {
// check API token
secureApiScope.addHook('onRequest', async (request, reply) => {
if (!request.headers.authorization) {
reply.code(401).send({ error: 'Unauthorized: missing Authorization header' });
return;
}
const token = Object.values(await models.apiToken.getAll()).find(t => t.token === request.headers.authorization.replace('Bearer ', ''));
if (!token) {
reply.code(403).send({ error: 'Forbidden: invalid Token' });
return;
}
});
// check requested Model Type
secureApiScope.addHook('onRequest', async (request, reply) => {
if (!request.params.modelType) {
reply.code(400).send({ error: 'Bad Request: missing Model Type' });
}
if (!models[request.params.modelType]) {
reply.code(404).send({ error: 'Unavailable: requested Model is not available' });
}
});
secureApiScope.get("/:modelType", async function (request, reply) {
const objects = await models[request.params.modelType].getAll();
reply.send(objects);
});
secureApiScope.post("/:modelType", async function (request, reply) {
try {
const newObject = await models[request.params.modelType].create(request.body);
reply.send(newObject);
} catch (error) {
reply.code(500).send({ error: error.message });
}
});
secureApiScope.get("/:modelType/:resourceId", async function (request, reply) {
const object = await models[request.params.modelType].getById(request.params.resourceId);
if (object) {
reply.send(object);
} else {
reply.code(404).send({ error: 'Unavailable: requested Object is not available' });
}
});
secureApiScope.patch("/:modelType/:resourceId", async function (request, reply) { });
secureApiScope.delete("/:modelType/:resourceId", async function (request, reply) { });
});
}

60
templates/nftables.ejs Normal file
View file

@ -0,0 +1,60 @@
#!/usr/sbin/nft -f
# Lösche alte Tabelle
flush ruleset
table inet <%= interface.ifName %> {
<% addressGroupList.forEach((addressGroup) => { %>
set addressGroup_<%= addressGroup.name %> {
type ipv4_addr
flags interval
elements = { <%= addressGroup.addressList.join(", ") %> }
}
<% }) %>
<% addressGroupList.forEach((addressGroup) => { %>
set addressGroup_<%= addressGroup.name %> {
type ipv4_addr
flags interval
elements = { <%= addressGroup.addressList.join(", ") %> }
}
<% }) %>
chain input_<%= interface.ifName %> {
type filter hook input priority 0; policy drop;
# Traffic vom Interface akzeptieren
iif "<%= interface %>" tcp dport { 22, 53 } accept
iif "<%= interface %>" udp dport 53 accept
iif "<%= interface %>" icmp type echo-request accept
iif "<%= interface %>" ip saddr @allowed_sources_<%= instanceId %> counter accept
}
chain forward_<%= interface.ifName %> {
type filter hook forward priority 0; policy drop;
# Eingehende Pakete von erlaubten IPs weiterleiten
iif "<%= interface %>" ip saddr @allowed_sources_<%= instanceId %> ip daddr @allowed_destinations_<%= instanceId %> accept
# Rückläufige Antworten zulassen (established connections)
oif "<%= interface %>" ip saddr @allowed_destinations_<%= instanceId %> ip daddr @allowed_sources_<%= instanceId %> ct state established accept
}
chain output_<%= interface.ifName %> {
type filter hook output priority 0; policy accept;
# Host -> WG Interface
oif "<%= interface %>" ip daddr @allowed_destinations_<%= instanceId %> accept
}
chain postrouting_<%= interface.ifName %> {
type route hook output priority 100; policy accept;
ip saddr <%= localSubnet %> oif "<%= outboundInterface %>" masquerade
}
}
<% accessRuleList.forEach((accessRule) => { %>
<%= accessRule.proto %> dport <%= accessRule.dstport %> ip saddr
# Description: <%= accessRule.description %>
<% }) %>

11
templates/wg_client.ejs Normal file
View file

@ -0,0 +1,11 @@
[Interface]
PrivateKey = <%= client.PrivateKey %>
Address = <%= client.allowedIps %>
DNS = 1.1.1.1
[Peer]
PublicKey = <%= server.publicKey %>
PresharedKey = <%= client.presharedKey %>
Endpoint = <%= server.endpoint %>:<%= server.listenPort %>
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = <%= client.allowedIps %>

14
templates/wg_server.ejs Normal file
View file

@ -0,0 +1,14 @@
[Interface]
Address = <%= interface.ifAddress %>
<% if (interface.dnsServer) { %>
DNS = <%= interface.dnsServer %>
<% } %>
PrivateKey = <%= interface.privateKey %>
ListenPort = <%= interface.listenPort %>
<% peerList.forEach((peer) => { %>
[Peer]
# <%= peer.name %>
PublicKey = <%= peer.publicKey %>
<% }) %>

44
webserver.mjs Normal file
View file

@ -0,0 +1,44 @@
import fastify from "fastify";
import fastifyView from '@fastify/view';
import autoLoad from "@fastify/autoload";
import path from "node:path";
import ejs from "ejs";
const webServer = fastify({ logger: false });
// EJS-Renderer
webServer.register(fastifyView, {
root: path.join(process.cwd(), 'templates'),
engine: {
ejs
}
});
// file based route loader
webServer.register(autoLoad, {
dir: path.join(process.cwd(), "routes"),
dirNameRoutePrefix: true,
routeParams: true
});
// load plugins
// import registerSSOPlugin from './plugins/sso.mjs';
// await registerSSOPlugin(webServer)
// error handling
webServer.setErrorHandler((error, request, reply) => {
// gezielt Fehler anzeigen, aber nicht alles loggen
console.error("! Fehler in Route:", request.url);
console.error(error);
reply.status(500).send({ error: "Internal Server Error" });
});
// start server
webServer.listen({ port: 3000 }, (err) => {
if (err) {
console.error(err);
process.exit(1);
}
});