commit 327efdbe9ad6a09718657453cd3cf80aaf2ff2ad Author: STEINNI Date: Fri Jun 12 17:05:35 2026 +0000 first GodDaemons group commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9da1861 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.well-known +wssGatewayConfig.json +nodeserver.cert +nodeserver.key +package-lock.json +brol/ +node_modules/ +*.log diff --git a/GPS/actions/arena/arenaHandlers.js b/GPS/actions/arena/arenaHandlers.js new file mode 100644 index 0000000..5ba7eac --- /dev/null +++ b/GPS/actions/arena/arenaHandlers.js @@ -0,0 +1,46 @@ + +export const construct = (redisCnx) => { + const tickMs = redisCnx.gpsSrv?.getGpsSettings().collisionTickMs ?? 100 + setInterval(() => { + redisCnx.gpsSrv?.tickArena() + }, tickMs) +} + +export const methods = { + + handleAgentEvent(msg) { + const agentId = msg.sender + if(!agentId || typeof(agentId) !== 'string') { + console.warn(`[${this.redisId}] Agent event without sender`) + return + } + + if(msg.eventType === 'change') { + const newVector = msg.payload?.newVector + if(!newVector || typeof(newVector.x) !== 'number' || typeof(newVector.y) !== 'number' || typeof(newVector.z) !== 'number') { + console.warn(`[${this.redisId}] Invalid newVector from ${agentId}`) + return + } + const newPosition = msg.payload?.newPosition ?? null + this.gpsSrv.onVectorChange(agentId, newVector, newPosition) + return + } + + if(msg.eventType === 'remove') { + this.gpsSrv.onAgentRemove(agentId) + return + } + }, + + dispatchArenaMessage(msg, chan) { + const gps = this.config.gps + if(!gps || !this.gpsSrv) return false + + if(this.matchesChan(chan, gps.agentVectorChangeChannel)) { + this.handleAgentEvent(msg) + return(true) + } + return(false) + }, + +} diff --git a/GPS/actions/arena/index.js b/GPS/actions/arena/index.js new file mode 100644 index 0000000..0f98cb3 --- /dev/null +++ b/GPS/actions/arena/index.js @@ -0,0 +1,9 @@ +import { methods as arenaMethods, construct as arenaConstruct } from './arenaHandlers.js' + +export const afterLoginMethods = [ + arenaConstruct, +] + +export const meshActions = { + ...arenaMethods, +} diff --git a/GPS/actions/arena/worldline.js b/GPS/actions/arena/worldline.js new file mode 100644 index 0000000..b787778 --- /dev/null +++ b/GPS/actions/arena/worldline.js @@ -0,0 +1,150 @@ +const PROXIMITY_EPSILON = 1e-6 + +export function needsPrismRefresh(agent, now, prismTimeHeight, prismRefreshLeadSeconds = 0) { + return(now >= agent.since + prismTimeHeight - prismRefreshLeadSeconds) +} + +export function advanceAgentSegment(agent, now) { + agent.position = positionAt(agent, now) + agent.since = now + agent.generation = (agent.generation ?? 0) + 1 +} + +export function positionAt(agent, t) { + const dt = t - agent.since + return({ + x: agent.position.x + agent.vector.x * dt, + y: agent.position.y + agent.vector.y * dt, + z: agent.position.z + agent.vector.z * dt, + }) +} + +function distanceBetween(agentA, agentB, t) { + const a = positionAt(agentA, t) + const b = positionAt(agentB, t) + const dx = a.x - b.x + const dy = a.y - b.y + const dz = a.z - b.z + return(Math.sqrt(dx * dx + dy * dy + dz * dz)) +} + +export function buildPrism(agent, tStart, tEnd, nearMissDistance) { + const p0 = positionAt(agent, tStart) + const p1 = positionAt(agent, tEnd) + const pad = nearMissDistance + return({ + xMin: Math.min(p0.x, p1.x) - pad, + xMax: Math.max(p0.x, p1.x) + pad, + yMin: Math.min(p0.y, p1.y) - pad, + yMax: Math.max(p0.y, p1.y) + pad, + zMin: Math.min(p0.z, p1.z) - pad, + zMax: Math.max(p0.z, p1.z) + pad, + tMin: tStart, + tMax: tEnd, + }) +} + +export function prismsIntersect(a, b) { + return( + a.xMin <= b.xMax && a.xMax >= b.xMin && + a.yMin <= b.yMax && a.yMax >= b.yMin && + a.zMin <= b.zMax && a.zMax >= b.zMin && + a.tMin <= b.tMax && a.tMax >= b.tMin + ) +} + +export function minimalDistance(agentA, agentB, tStart, tEnd) { + const relPos = { + x: positionAt(agentA, tStart).x - positionAt(agentB, tStart).x, + y: positionAt(agentA, tStart).y - positionAt(agentB, tStart).y, + z: positionAt(agentA, tStart).z - positionAt(agentB, tStart).z, + } + const relVel = { + x: agentA.vector.x - agentB.vector.x, + y: agentA.vector.y - agentB.vector.y, + z: agentA.vector.z - agentB.vector.z, + } + + const relVelSq = relVel.x * relVel.x + relVel.y * relVel.y + relVel.z * relVel.z + let tStar = tStart + if(relVelSq > 0) { + const dot = relPos.x * relVel.x + relPos.y * relVel.y + relPos.z * relVel.z + tStar = tStart - dot / relVelSq + } + tStar = Math.max(tStart, Math.min(tEnd, tStar)) + + return({ + distance: distanceBetween(agentA, agentB, tStar), + time: tStar, + }) +} + +export function firstProximityEntry(agentA, agentB, tStart, tEnd, nearMissDistance) { + const d0 = distanceBetween(agentA, agentB, tStart) + if(d0 <= nearMissDistance + PROXIMITY_EPSILON) { + return({ time: tStart, distance: d0 }) + } + + const relPos = { + x: positionAt(agentA, tStart).x - positionAt(agentB, tStart).x, + y: positionAt(agentA, tStart).y - positionAt(agentB, tStart).y, + z: positionAt(agentA, tStart).z - positionAt(agentB, tStart).z, + } + const relVel = { + x: agentA.vector.x - agentB.vector.x, + y: agentA.vector.y - agentB.vector.y, + z: agentA.vector.z - agentB.vector.z, + } + const relVelSq = relVel.x * relVel.x + relVel.y * relVel.y + relVel.z * relVel.z + if(relVelSq === 0) return(null) + + const a = relVelSq + const b = 2 * (relPos.x * relVel.x + relPos.y * relVel.y + relPos.z * relVel.z) + const c = relPos.x * relPos.x + relPos.y * relPos.y + relPos.z * relPos.z - nearMissDistance * nearMissDistance + const D = b * b - 4 * a * c + if(D < 0) return(null) + + const sqrtD = Math.sqrt(D) + const maxDt = tEnd - tStart + const roots = [(-b - sqrtD) / (2 * a), (-b + sqrtD) / (2 * a)] + .filter(dt => dt >= 0 && dt <= maxDt) + .sort((x, y) => x - y) + + for(const dt of roots) { + const t = tStart + dt + const d = distanceBetween(agentA, agentB, t) + if(d > nearMissDistance + PROXIMITY_EPSILON) continue + if(dt < 1e-12) return({ time: tStart, distance: d0 }) + const dBefore = distanceBetween(agentA, agentB, t - 1e-6) + if(dBefore > nearMissDistance + PROXIMITY_EPSILON) return({ time: t, distance: d }) + } + return(null) +} + +export function compareWorldlinePair(agentA, agentB, prismTimeHeight, nearMissDistance, now) { + const tStart = Math.max(agentA.since, agentB.since, now) + const tEnd = Math.min( + agentA.since + prismTimeHeight, + agentB.since + prismTimeHeight + ) + if(tEnd <= tStart) return(null) + + const prismA = buildPrism(agentA, tStart, tEnd, nearMissDistance) + const prismB = buildPrism(agentB, tStart, tEnd, nearMissDistance) + if(!prismsIntersect(prismA, prismB)) return(null) + + const min = minimalDistance(agentA, agentB, tStart, tEnd) + if(min.distance > nearMissDistance + PROXIMITY_EPSILON) return(null) + + const entry = firstProximityEntry(agentA, agentB, tStart, tEnd, nearMissDistance) + if(!entry) return(null) + + return({ + agentA: agentA.id, + agentB: agentB.id, + time: entry.time, + distance: entry.distance, + minTime: min.time, + minDistance: min.distance, + }) +} diff --git a/GPS/actions/system/index.js b/GPS/actions/system/index.js new file mode 100644 index 0000000..481aa58 --- /dev/null +++ b/GPS/actions/system/index.js @@ -0,0 +1,10 @@ +import { methods as utilities, construct as utilitiesConstruct } from './utilities.js' +import { methods as positions } from './positions.js' + +export const afterLoginMethods = [ + utilitiesConstruct, +] +export const meshActions = { + ...utilities, + ...positions, +} diff --git a/GPS/actions/system/positions.js b/GPS/actions/system/positions.js new file mode 100644 index 0000000..a05ec85 --- /dev/null +++ b/GPS/actions/system/positions.js @@ -0,0 +1,146 @@ +import { publishActionReply, parseAt } from '../../actionsHelper.js' + +export const methods = { + + /* Event-Rx: + { + "action": "GETAGENTPOSITION", + "reqid": "6az5e4r6a", + "payload": { + "agentId": "agent42", + "at": "2026-06-07T12:00:00.000Z" + } + } + Event-Tx: + { + "action": "GETAGENTPOSITION", + "success": true, + "reqid": "6az5e4r6a", + "payload": { + "agent": { + "id": "agent42", + "position": { "x": 1, "y": 2, "z": 3 }, + "vector": { "x": 0, "y": 0, "z": 0 }, + "since": 1717750800, + "generation": 2, + "at": "2026-06-07T12:00:00.000Z" + } + } + } + */ + async action_GETAGENTPOSITION(action, payload, reqid, sender, roles) { + const replyOpts = { + action, + reqid, + sender, + replyChannel: this.config.gps.gpsActionsReply, + } + if(!this.accessRights.canDo(roles, action)) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Unauthorized action !', + } }) + return + } + + const agentId = payload?.agentId + if(!agentId || typeof(agentId) !== 'string') { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Missing or invalid agentId', + } }) + return + } + + const at = parseAt(payload, () => this.gpsSrv.now()) + if(at === null) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Invalid at timestamp', + } }) + return + } + + const agent = this.gpsSrv.getAgentPosition(agentId, at) + if(!agent) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: `Unknown agent: ${agentId}`, + } }) + return + } + + publishActionReply(this, { ...replyOpts, reply: { + success: true, + payload: { agent }, + } }) + }, + + /* Event-Rx: + { + "action": "GETAGENTSINPRISM", + "reqid": "6az5e4r6a", + "payload": { + "prism": { + "xMin": -10, "xMax": 10, + "yMin": -10, "yMax": 10, + "zMin": 0, "zMax": 5 + }, + "at": "2026-06-07T12:00:00.000Z" + } + } + Event-Tx: + { + "action": "GETAGENTSINPRISM", + "success": true, + "reqid": "6az5e4r6a", + "payload": { + "agents": [ ... ], + "at": "2026-06-07T12:00:00.000Z" + } + } + */ + async action_GETAGENTSINPRISM(action, payload, reqid, sender, roles) { + const replyOpts = { + action, + reqid, + sender, + replyChannel: this.config.gps.gpsActionsReply, + } + if(!this.accessRights.canDo(roles, action)) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Unauthorized action !', + } }) + return + } + + const prism = payload?.prism + if(!this.gpsSrv.isValidPrism(prism)) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Missing or invalid prism bounds', + } }) + return + } + + const at = parseAt(payload, () => this.gpsSrv.now()) + if(at === null) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Invalid at timestamp', + } }) + return + } + + const agents = this.gpsSrv.getAgentsInPrism(prism, at) + publishActionReply(this, { ...replyOpts, reply: { + success: true, + payload: { + agents, + at: new Date(at * 1000).toISOString(), + }, + } }) + }, + +} diff --git a/GPS/actions/system/utilities.js b/GPS/actions/system/utilities.js new file mode 100644 index 0000000..07bf1b8 --- /dev/null +++ b/GPS/actions/system/utilities.js @@ -0,0 +1,109 @@ +import { publishActionReply } from '../../actionsHelper.js' + +export const construct = (redisCnx) => { + // console.log('Hello after login from utilities...') + // redisCnx.v42=0 + // setInterval(redisCnx.move4243.bind(redisCnx), 200) +} + +export const methods = { + + /* Event-Rx: + { + "action": "TIME" + "reqid": "6az5e4r6a" + } + Event-Tx: + { + "action": "TIME", + "success": true, + "payload" : { + gpsTime: "2022-09-01T14:42:22.603Z", + redisTime: "2022-09-01T14:42:22.603Z" + }, + "reqid": "6az5e4r6a" + } + */ + async action_TIME(action, payload, reqid, sender, roles){ + publishActionReply(this, { + action, + reqid, + sender, + replyChannel: this.config.gps.gpsActionsReply, + reply: { + success: true, + payload: { + gpsTime: new Date().toISOString(), + redisTime: await this.redisClient.time(), + }, + }, + }) + }, + + + /* Event-Rx: + { + "action": "RELOADCONFIG" + "reqid": "6az5e4r6a" + } + Event-Tx: + { + "action": "RELOADCONFIG", + "success": true, + "reqid": "6az5e4r6a" + } + */ + async action_RELOADCONFIG(action, payload, reqid, sender, roles){ + const replyOpts = { + action, + reqid, + sender, + replyChannel: this.config.gps.gpsActionsReply, + } + if(!this.accessRights.canDo(roles, action)) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Unauthorized action !', + } }) + return + } + this.reloadAccessRights() + publishActionReply(this, { ...replyOpts, reply: { + success: true, + } }) + }, + + /* Event-Rx: + { + "action": "GETCONFIG" + "reqid": "6az5e4r6a" + } + Event-Tx: + { + "action": "GETCONFIG", + "success": true, + "reqid": "6az5e4r6a", + payload: { ...the access rights, and roles... } + } + */ + async action_GETCONFIG(action, payload, reqid, sender, roles){ + const replyOpts = { + action, + reqid, + sender, + replyChannel: this.config.gps.gpsActionsReply, + } + if(!this.accessRights.canDo(roles, action)) { + publishActionReply(this, { ...replyOpts, reply: { + success: false, + err: 'Unauthorized action !', + } }) + return + } + publishActionReply(this, { ...replyOpts, reply: { + success: true, + payload: this.getAccessRights(), + } }) + }, + +} diff --git a/GPS/actionsHelper.js b/GPS/actionsHelper.js new file mode 100644 index 0000000..f67eb18 --- /dev/null +++ b/GPS/actionsHelper.js @@ -0,0 +1,23 @@ + +export function publishActionReply(redisCnx, options) { + const { + action, + reqid, + sender, + reply, + replyChannel, + senderId = 'gps', + } = options + reply.action = action + reply.sender = senderId + if(reqid) reply.reqid = reqid + const chan = replyChannel.replace(/\[UID\]/g, sender) + redisCnx.redisPublish(chan, reply) +} + +export function parseAt(payload, fallbackFn) { + if(!payload?.at) return(fallbackFn()) + const t = Date.parse(payload.at) + if(Number.isNaN(t)) return(null) + return(t / 1000) +} diff --git a/GPS/agentStore.js b/GPS/agentStore.js new file mode 100644 index 0000000..7da7980 --- /dev/null +++ b/GPS/agentStore.js @@ -0,0 +1,58 @@ + +export class AgentStore { + + constructor(systemCnx, storage, debug = false) { + this.cnx = systemCnx + this.storage = storage + this.debug = debug + } + + agentHashKey(agentId) { + return(this.storage.agentHashKey.replace(/\[UID\]/g, agentId)) + } + + async exportSegment(agent, eventType) { + try { + const record = { + eventType, + id: agent.id, + position: { ...agent.position }, + vector: { ...agent.vector }, + since: agent.since, + generation: agent.generation ?? 0, + at: new Date().toISOString(), + } + await this.cnx.redisHset(this.agentHashKey(agent.id), 'segment', record) + await this.cnx.redisSadd(this.storage.agentsIndexKey, agent.id) + await this.cnx.redisXadd( + this.storage.positionsStream, + record, + this.storage.streamMaxLen ?? '' + ) + if(this.debug) console.log(`[GPS] Exported segment ${agent.id} (${eventType})`) + } catch(err) { + console.error(`[GPS] Failed to export segment for ${agent.id}:`, err) + } + } + + async exportRemove(agentId) { + try { + const record = { + eventType: 'remove', + id: agentId, + at: new Date().toISOString(), + } + await this.cnx.redisDel(this.agentHashKey(agentId)) + await this.cnx.redisSrem(this.storage.agentsIndexKey, agentId) + await this.cnx.redisXadd( + this.storage.positionsStream, + record, + this.storage.streamMaxLen ?? '' + ) + if(this.debug) console.log(`[GPS] Exported remove ${agentId}`) + } catch(err) { + console.error(`[GPS] Failed to export remove for ${agentId}:`, err) + } + } + +} diff --git a/GPS/collisionRegistry.js b/GPS/collisionRegistry.js new file mode 100644 index 0000000..248a1e9 --- /dev/null +++ b/GPS/collisionRegistry.js @@ -0,0 +1,39 @@ + +export class CollisionRegistry { + + constructor() { + this.entries = [] + } + + purge(agentId) { + this.entries = this.entries.filter( + e => e.agentA !== agentId && e.agentB !== agentId + ) + } + + add(entry) { + const duplicate = this.entries.find( + e => + (e.agentA === entry.agentA && e.agentB === entry.agentB) || + (e.agentA === entry.agentB && e.agentB === entry.agentA) + ) + if(duplicate) { + if(entry.time < duplicate.time) Object.assign(duplicate, entry) + return + } + this.entries.push(entry) + } + + dueBefore(t) { + return(this.entries.filter(e => e.time <= t)) + } + + remove(entry) { + this.entries = this.entries.filter( + e => + !((e.agentA === entry.agentA && e.agentB === entry.agentB) || + (e.agentA === entry.agentB && e.agentB === entry.agentA)) + ) + } + +} diff --git a/GPS/gpsServer.js b/GPS/gpsServer.js new file mode 100644 index 0000000..c6e3b5f --- /dev/null +++ b/GPS/gpsServer.js @@ -0,0 +1,274 @@ +import { AccesRights } from '../accesRights.js' +import { AgentStore } from './agentStore.js' +import { CollisionRegistry } from './collisionRegistry.js' +import { + compareWorldlinePair, + positionAt, + needsPrismRefresh, + advanceAgentSegment, +} from './actions/arena/worldline.js' + +export class gpsServer { + + constructor(configHelper, allRediscnx, debug) { + this.configHelper = configHelper + this.gpsConfig = configHelper.config + this.allRediscnx = allRediscnx + this.debug = debug + this.accessRights = new AccesRights(this.gpsConfig, debug) + this.agents = new Map() + this.registry = new CollisionRegistry() + this.arenaCnx = null + this.systemCnx = null + this.agentStore = null + } + + getGpsSettings() { + const gps = this.gpsConfig.gps ?? {} + return({ + nearMissDistance: gps.nearMissDistance ?? 1, + prismTimeHeight: gps.prismTimeHeight ?? 60, + collisionTickMs: gps.collisionTickMs ?? 100, + prismRefreshLeadSeconds: gps.prismRefreshLeadSeconds ?? 1, + }) + } + + now() { + return(Date.now() / 1000) + } + + wireSystemConnexion(cnx) { + cnx.gpsSrv = this + cnx.accessRights = this.accessRights + cnx.reloadAccessRights = () => this.reloadAccessRights() + cnx.getAccessRights = () => this.getAccessRights() + if(!this.systemCnx || cnx.redisConfig.role === 'primary') { + this.systemCnx = cnx + this.initAgentStore() + } + } + + initAgentStore() { + const storage = this.gpsConfig.gps?.storage + if(storage && this.systemCnx) { + this.agentStore = new AgentStore(this.systemCnx, storage, this.debug) + } + } + + wireArenaConnexion(cnx) { + cnx.gpsSrv = this + this.arenaCnx = cnx + } + + getAgent(agentId) { + return(this.agents.get(agentId) ?? null) + } + + getAllAgents() { + return([...this.agents.values()]) + } + + buildAgentSnapshot(agent, at) { + return({ + id: agent.id, + position: positionAt(agent, at), + vector: { ...agent.vector }, + since: agent.since, + generation: agent.generation ?? 0, + at: new Date(at * 1000).toISOString(), + }) + } + + getAgentPosition(agentId, at = null) { + const agent = this.agents.get(agentId) + if(!agent) return(null) + const t = at ?? this.now() + return(this.buildAgentSnapshot(agent, t)) + } + + isPositionInPrism(position, prism) { + return( + position.x >= prism.xMin && position.x <= prism.xMax && + position.y >= prism.yMin && position.y <= prism.yMax && + position.z >= prism.zMin && position.z <= prism.zMax + ) + } + + isValidPrism(prism) { + if(!prism || typeof(prism) !== 'object') return(false) + const axes = ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax'] + for(const key of axes) { + if(typeof(prism[key]) !== 'number' || Number.isNaN(prism[key])) return(false) + } + return( + prism.xMin <= prism.xMax && + prism.yMin <= prism.yMax && + prism.zMin <= prism.zMax + ) + } + + getAgentsInPrism(prism, at = null) { + const t = at ?? this.now() + const agents = [] + for(const agent of this.agents.values()) { + const position = positionAt(agent, t) + if(this.isPositionInPrism(position, prism)) { + agents.push(this.buildAgentSnapshot(agent, t)) + } + } + return(agents) + } + + upsertAgent(agentId, newVector, newPosition = null) { + const now = this.now() + let agent = this.agents.get(agentId) + if(!agent) { + agent = { + id: agentId, + position: newPosition ?? { x: 0, y: 0, z: 0 }, + vector: { ...newVector }, + since: now, + generation: 1, + } + this.agents.set(agentId, agent) + this.agentStore?.exportSegment(agent, 'change') + return(agent) + } + + agent.position = positionAt(agent, now) + agent.vector = { ...newVector } + agent.since = now + agent.generation = (agent.generation ?? 0) + 1 + if(newPosition) agent.position = { ...newPosition } + this.agentStore?.exportSegment(agent, 'change') + return(agent) + } + + ensurePrismValid(agentId, refreshed = new Set()) { + return(this.refreshAgentPrism(agentId, refreshed)) + } + + refreshAgentPrism(agentId, refreshed = new Set()) { + if(refreshed.has(agentId)) return(false) + const agent = this.agents.get(agentId) + if(!agent) return(false) + + const { prismTimeHeight, prismRefreshLeadSeconds } = this.getGpsSettings() + const now = this.now() + if(!needsPrismRefresh(agent, now, prismTimeHeight, prismRefreshLeadSeconds)) return(false) + + refreshed.add(agentId) + this.registry.purge(agentId) + advanceAgentSegment(agent, now) + + const hits = this.scanAgentPairs(agentId, refreshed) + for(const hit of hits) this.registry.add(hit) + this.agentStore?.exportSegment(agent, 'refresh') + if(this.debug) console.log(`[GPS] Prism refresh: ${agentId}`) + return(true) + } + + scanAgentPairs(changedAgentId, refreshed = new Set()) { + this.ensurePrismValid(changedAgentId, refreshed) + const changed = this.agents.get(changedAgentId) + if(!changed) return([]) + + const { nearMissDistance, prismTimeHeight } = this.getGpsSettings() + const now = this.now() + const hits = [] + for(const [otherId, other] of this.agents) { + if(otherId === changedAgentId) continue + this.ensurePrismValid(otherId, refreshed) + const hit = compareWorldlinePair( + changed, + other, + prismTimeHeight, + nearMissDistance, + now + ) + if(hit) hits.push(hit) + } + return(hits) + } + + registerHits(hits) { + for(const hit of hits) this.registry.add(hit) + } + + onVectorChange(agentId, newVector, newPosition = null) { + this.registry.purge(agentId) + this.upsertAgent(agentId, newVector, newPosition) + const hits = this.scanAgentPairs(agentId) + this.registerHits(hits) + if(this.debug && hits.length) console.log(`[GPS] ${hits.length} proximity pair(s) for ${agentId}`) + return(hits) + } + + onAgentRemove(agentId) { + this.agents.delete(agentId) + this.registry.purge(agentId) + this.agentStore?.exportRemove(agentId) + if(this.debug) console.log(`[GPS] Agent removed: ${agentId}`) + } + + tickPrismRefresh() { + for(const agentId of this.agents.keys()) { + this.refreshAgentPrism(agentId) + } + } + + buildProximityBatches(due) { + const byAgent = new Map() + for(const entry of due) { + for(const agentId of [entry.agentA, entry.agentB]) { + const otherAgent = entry.agentA === agentId ? entry.agentB : entry.agentA + if(!byAgent.has(agentId)) byAgent.set(agentId, []) + byAgent.get(agentId).push({ + otherAgent, + distance: entry.distance, + at: new Date(entry.time * 1000).toISOString(), + minDistance: entry.minDistance, + minAt: new Date(entry.minTime * 1000).toISOString(), + }) + } + } + return(byAgent) + } + + async publishProximityBatch(targetAgentId, pairs) { + if(!this.arenaCnx || !pairs.length) return + const chan = this.arenaCnx.config.gps.collisionsChannel.replace(/\[UID\]/g, targetAgentId) + await this.arenaCnx.redisPublish(chan, { + eventType: 'proximity', + payload: { pairs }, + sender: 'gps', + }) + } + + async tickCollisions() { + const due = this.registry.dueBefore(this.now()) + if(!due.length) return + + const batches = this.buildProximityBatches(due) + for(const entry of due) this.registry.remove(entry) + for(const [agentId, pairs] of batches) { + await this.publishProximityBatch(agentId, pairs) + } + } + + tickArena() { + this.tickPrismRefresh() + this.tickCollisions() + } + + async reloadAccessRights() { + await this.configHelper.refreshAccessRights() + this.gpsConfig.accessRights = this.configHelper.config.accessRights + this.accessRights.refreshAccessRights(this.gpsConfig) + } + + getAccessRights() { + return(this.gpsConfig.accessRights) + } + +} diff --git a/GPS/p42Gps.js b/GPS/p42Gps.js new file mode 100644 index 0000000..32fb32b --- /dev/null +++ b/GPS/p42Gps.js @@ -0,0 +1,130 @@ + +import yargs from 'yargs/yargs' +import { hideBin } from 'yargs/helpers' +import 'node:process' +import {RedisConnexion} from './redisConnexion.js' +import {configHelper} from '../configHelper.js' +import {gpsServer} from './gpsServer.js' + + +///////////////////////////// Little improvement on console.xxx ///////////////////////////////////// +const originalLog = console.log +const originalWarn = console.warn +const originalError = console.error +function logWithTimestamp(originalFn, level, ...args) { + const timestamp = new Date().toISOString() + originalFn(`[${timestamp}] [${level}]`, ...args) +} + +console.log = (...args) => logWithTimestamp(originalLog, 'LOG', ...args) +console.warn = (...args) => logWithTimestamp(originalWarn, 'WARN', ...args) +console.error = (...args) => logWithTimestamp(originalError, 'ERROR', ...args) +////////////////////////////////////////////////////////////////////////////////////////////////////// + +const argv = yargs(hideBin(process.argv)).command('GPS', 'Global positions and collision system for P42', {}) + .options({ + 'debug': { + description: 'shows debug info', + alias: 'd', + defaut: false, + type: 'boolean' + }, + 'config': { + description: 'Points to config file (default: ../config.json)', + alias: 'c', + default: '../config.json', + type: 'string' + }, + }).help().version('1.1').argv + +const debug = Boolean(process.env.DEBUG) || argv.debug + +let cfgh = new configHelper({ + localfile: argv.config, +}) + + +function meshRedisConns(mesh, meshName, debug, rootConfig) { + const { redis, ...meshConfig } = mesh + return redis.map(cfg => + new RedisConnexion({ + debug, + config: { ...cfg, ...meshConfig, gps: rootConfig.gps }, + redisId: cfg.redisId, + meshName, + }) + ) +} + +async function startAllRedis(rootConfig, cfgh) { + if (debug) console.log('Starting all Redis instances...') + + //1. instantiate all & login all + const redisConns = [ + ...meshRedisConns(rootConfig.systemMesh, 'system', debug, rootConfig), + ...meshRedisConns(rootConfig.arenaMesh, 'arena', debug, rootConfig), + ] + + const srv = new gpsServer(cfgh, redisConns, debug) + for(const cnx of redisConns) { + if(cnx.meshName === 'system') srv.wireSystemConnexion(cnx) + else if(cnx.meshName === 'arena') srv.wireArenaConnexion(cnx) + } + + const loginResults = await Promise.allSettled( + redisConns.map(async cnx => { + await cnx.redisLogin() + return cnx.redisId + }) + ) + + //2. make sure all connected before going any further + const failedLogin = loginResults.filter(r => r.status !== 'fulfilled') + if (failedLogin.length > 0) { + console.error('Redis login failures:') + failedLogin.forEach((r, i) => { + const id = redisConns[i].redisId + console.error(`chansStart failed for redis:[${id}] → ${r.reason}`) + }) + throw new Error( + `Redis login failed for ${failedLogin.length}/${redisConns.length} instances` + ) + } + + if (debug) console.log('All Redis logins OK') + + // --- Phase 2: start channels for all (since all succeeded) + const chanResults = await Promise.allSettled( + redisConns.map(async cnx => { + await cnx.redisChansStart() + return cnx.redisId + }) + ) + + const failedChans = chanResults.filter(r => r.status !== 'fulfilled') + if (failedChans.length > 0) { + console.error('Redis chansStart failures:') + failedChans.forEach((r, i) => { + const id = redisConns[i].redisId + console.error(`chansStart failed for redis:[${id}] → ${r.reason}`) + }) + throw new Error( + `Redis chansStart failed for ${failedChans.length}/${redisConns.length} instances` + ) + } + + if (debug) console.log('All Redis chansStart OK') + + return { redisConns, srv } +} + +cfgh.fetchConfig().then(async rootConfig => { + if(!rootConfig) { + console.error('Cannot get a valid configuration ! Aaarrghhh...') + process.exit() + } + + console.log(`Debug mode : ${debug ? 'ON' : 'OFF'}`) + + await startAllRedis(rootConfig, cfgh) +}) diff --git a/GPS/package.json b/GPS/package.json new file mode 100644 index 0000000..128a293 --- /dev/null +++ b/GPS/package.json @@ -0,0 +1,23 @@ +{ + "name": "gps", + "version": "3.4.6", + "description": "Websocket-Redis Message Bus Gateway", + "main": "gps.js", + "type": "module", + "scripts": { + "test": "jest --verbose" + }, + "repository": { + "type": "git", + "url": "wp42GPS" + }, + "author": "Nike", + "license": "ISC", + "dependencies": { + "ajv": "^8.12.0", + "pm2": "^6.0.10", + "redis": "^4.3.0", + "urldecode": "^1.0.1", + "yargs": "^17.7.2" + } +} diff --git a/GPS/redisConnexion.js b/GPS/redisConnexion.js new file mode 100644 index 0000000..9db90ba --- /dev/null +++ b/GPS/redisConnexion.js @@ -0,0 +1,295 @@ +import redis from 'redis' +import * as systemMesh from './actions/system/index.js' +import * as arenaMesh from './actions/arena/index.js' + +const meshModules = { + system: systemMesh, + arena: arenaMesh, +} + +export class RedisConnexion { + + constructor(options) { + this.config = options.config + this.debug = options.debug + this.redisId = options.redisId + this.redisConfig = this.config + this.meshName = options.meshName + + const mesh = meshModules[this.meshName] + if(mesh?.meshActions) Object.assign(this, mesh.meshActions) + this.afterLoginMethods = mesh?.afterLoginMethods ?? [] + if(!mesh) console.warn(`[${this.redisId}] Unknown meshName: ${this.meshName}`) + + this.redisClient = redis.createClient({ + socket: { + tls: this.redisConfig.tls, + host: this.redisConfig.host, + port: this.redisConfig.port + } + }); + + this.redisSubscriber = null; + this.redisClient.on('error', (err) => { + console.error('Redis error: ', err); + }); + + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis started...`) + } + + fullChan(chanName){ + if(!chanName.startsWith(this.redisConfig.basePrefix)) chanName = this.redisConfig.basePrefix + chanName + return(chanName) + } + + matchesChan(chan, pattern) { + const fullChan = this.fullChan(chan) + const fullPattern = this.fullChan(pattern) + const re = new RegExp('^' + fullPattern.replace(/\*/g, '(.+)') + '$') + return(re.test(fullChan)) + } + + async redisLogin(){ + if(this.debug) console.log(`Connecting to Redis (${this.redisConfig.host}:${this.redisConfig.port}, tls:${this.redisConfig.tls?'yes':'no'})...`); + await this.redisClient.connect(); + if(this.debug) console.log(`Connected to Redis ${this.redisConfig.redisId}`); + if(this.redisConfig.user) { + await this.redisClient.sendCommand(['AUTH', this.redisConfig.user, this.redisConfig.pass]); + if(this.debug) console.log(`Logged into Redis ${this.redisConfig.redisId}`); + } else { + if(this.debug) console.log(`Connected (anon) to Redis ${this.redisConfig.redisId}`); + } + if(this.debug) { + var redisTime = await this.redisClient.time(); + console.log(`[${this.redisConfig.redisId}] Redis ${this.redisConfig.redisId} time:`, redisTime); + } + + for(const method of this.afterLoginMethods){ + if(typeof method != 'function') continue + method(this) + } + } + + async redisChansStart(){ + this.redisSubscriber = this.redisClient.duplicate(); + await this.redisSubscriber.connect(); + if(this.redisConfig.user) { + await this.redisSubscriber.sendCommand(['AUTH', this.redisConfig.user, this.redisConfig.pass]); + } + const allChans = this.redisConfig.basePrefix + this.redisConfig.chansNamespace+'*' + this.redisSubscriber.pSubscribe(allChans, this.redisReceive.bind(this)); + if(this.debug) console.log(`[${this.redisConfig.redisId}] PSubscription OK `, allChans); + } + + async redisSubscribe(chanName, callBack){ + if(!chanName.startsWith(this.redisConfig.basePrefix)) chanName = this.redisConfig.basePrefix + chanName + if(!chanName.startsWith(this.redisConfig.basePrefix+this.redisConfig.chansNamespace)) { + console.warn(`[${this.redisConfig.redisId}] redisSubscribe : forbidden channel range on this redis !`) + return + } + await this.redisSubscriber.subscribe(chanName, callBack); + } + + async redisPublish(chanName, msg){ + if(typeof (msg) != 'string') msg = JSON.stringify(msg); + if(!chanName.startsWith(this.redisConfig.basePrefix)) chanName = this.redisConfig.basePrefix + chanName + if(!chanName.startsWith(this.redisConfig.basePrefix+this.redisConfig.chansNamespace)) { + console.warn(`[${this.redisConfig.redisId}] redisPublish : forbidden channel range on this redis ! (${chanName} / ${this.redisConfig.basePrefix+this.redisConfig.chansNamespace}) `) + return + } + + await this.redisClient.publish(chanName, msg); + } + + async redisSet(k, v, exp = 0, customPrefix=null){ + if(typeof(v) != 'string') v = JSON.stringify(v); + if(customPrefix!==null) k = customPrefix + k + else if(!k.startsWith(this.redisConfig.basePrefix)) k = this.redisConfig.basePrefix + k + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis SET `, k); + try { await this.redisClient.set(k, v) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis set: `, k, v) } + if(exp > 0) { + try { await this.redisClient.expire(k, exp) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis expire: `, k, exp) } + } + } + + async redisGet(k, customPrefix=null){ + if(customPrefix!==null) k = customPrefix + k + else if(!k.startsWith(this.redisConfig.basePrefix)) k = this.redisConfig.basePrefix + k + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis GET`, k) + let v=null + try { v = await this.redisClient.get(k) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis get: `, k) } + return(v); + } + + resolveKey(k, customPrefix=null){ + if(customPrefix!==null) return(customPrefix + k) + if(!k.startsWith(this.redisConfig.basePrefix)) k = this.redisConfig.basePrefix + k + return(k) + } + + async redisDel(k, customPrefix=null){ + k = this.resolveKey(k, customPrefix) + if(this.debug) console.log(`[${this.redisConfig.redisId}] Deleting`, k) + await this.redisClient.del(k) + } + + async redisHset(k, field, v, customPrefix=null){ + if(typeof(v) != 'string') v = JSON.stringify(v) + k = this.resolveKey(k, customPrefix) + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis HSET`, k, field) + try { await this.redisClient.hSet(k, field, v) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing HSET: `, k, field, err) } + } + + async redisHdel(k, field, customPrefix=null){ + k = this.resolveKey(k, customPrefix) + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis HDEL`, k, field) + try { await this.redisClient.hDel(k, field) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing HDEL: `, k, field, err) } + } + + async redisSadd(k, member, customPrefix=null){ + k = this.resolveKey(k, customPrefix) + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis SADD`, k, member) + try { await this.redisClient.sAdd(k, member) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing SADD: `, k, member, err) } + } + + async redisSrem(k, member, customPrefix=null){ + k = this.resolveKey(k, customPrefix) + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis SREM`, k, member) + try { await this.redisClient.sRem(k, member) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing SREM: `, k, member, err) } + } + + + async redisGetTtl(k, customPrefix=null){ + if(customPrefix!==null) k = customPrefix + k + else if(!k.startsWith(this.redisConfig.basePrefix)) k = this.redisConfig.basePrefix + k + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis Get TTL `, k) + let v=null + try { v = await this.redisClient.ttl(k) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis ttl: `, k) } + return(v); + } + + async redisSetTtl(k, ttl, customPrefix=null){ + if(customPrefix!==null) k = customPrefix + k + else if(!k.startsWith(this.redisConfig.basePrefix)) k = this.redisConfig.basePrefix + k + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis Set TTL`, k); + try { await this.redisClient.expire(k, ttl) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis expire: `, k, ttl) } + } + + async redisXadd(streamName, kvObj, max = ''){ + if(!streamName.startsWith(this.redisConfig.basePrefix)) streamName = this.redisConfig.basePrefix + streamName + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis XADD `, streamName, kvObj); + let arr = ['XADD', streamName] + if(max != '') arr = [...arr, ...['MAXLEN', '~', (1*max).toString()]] + arr.push('*') + let payload = '""' + try{ payload = JSON.stringify(kvObj) } + catch(e) { console.warn(`[${this.redisConfig.redisId}] cannot historize bad json: `,kvObj) } + arr.push('streamData') + arr.push(payload) + let sid = null + try { sid = await this.redisClient.sendCommand(arr); } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis command: `, arr, err) } + return(sid); + } + + async redisXrange(streamName, start = '-', end = '+', withPayload = true){ + if(!streamName.startsWith(this.redisConfig.basePrefix)) streamName = this.redisConfig.basePrefix + streamName + if(this.debug) console.log(`[${this.redisConfig.redisId}]Redis XRANGE `, streamName); + if(typeof(start)!='string') start = start.toString() + if(typeof(end)!='string') end = end.toString() + let arr = ['XRANGE', streamName, start, end]; + let res = [] + try { res = await this.redisClient.sendCommand(arr) } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis command: `, arr, err.msg) } + + if(withPayload){ + let o = {}; + for (let row of res) { // We'll take only the first content of the stream (the value of the key 'streamdata') + let payload = '' + try{ payload = JSON.parse(row[1][1]) } + catch(e) { console.warn(`[${this.redisConfig.redisId}] cannot unhistorize bad json: `,row[1][1]) } + o[row[0]] = payload + } + return(o); + } else { + return(res.map(row => row[0])) + } + } + + isHistorizedChan(chan){ + if(!chan.startsWith(this.redisConfig.basePrefix)) chan = this.redisConfig.basePrefix + chan + var matches = this.redisConfig.historizeChannels.filter((e) => { + if(!e.startsWith(this.redisConfig.basePrefix)) e = this.redisConfig.basePrefix + e + if(e.indexOf('*') > -1) { + let r = new RegExp('^'+e.replace(/\*/g,'(.+)')+'$','g') + return(chan.match(r) != null); + } else return(chan == e); + }); + return(matches.length > 0); + } + + async getProcessInfo(){ + if(this.debug) console.log(`[${this.redisConfig.redisId}] Redis INFO`); + try { + const rawInfo = await this.redisClient.INFO() + let arr = rawInfo.replace(/\r/g,'').split('\n') + arr = arr.filter(item => (item.trim()!='') && (!item.trim().startsWith('#'))) + let infoObject = arr.reduce((acc, val) => { kv = val.split(':',2); if(kv.length>0){ acc[kv[0]] = kv[1] } return(acc) }, {}) + } + catch(err) { console.error(`[${this.redisConfig.redisId}] Redis crash doing Redis INFO: `) } + return(infoObject) + } + + async redisReceive(msg, chan){ + if(this.debug) console.log(`[${this.redisConfig.redisId}] From Redis chan:`, chan, msg) + try { + msg = JSON.parse(msg) + } catch { + console.warn(`[${this.redisConfig.redisId}] Ignoring non-json on channel ${chan} : ${msg}`) + return + } + + if(this.meshName === 'arena' && this.config.gps && typeof(this.dispatchArenaMessage) === 'function') { + if(this.dispatchArenaMessage(msg, chan)) return + } + + if(this.meshName === 'system' && this.config.gps?.gpsActionsChannel){ + const actionsChan = this.fullChan(this.config.gps.gpsActionsChannel) + if(chan != actionsChan) return + + const action = msg.action + if(!action || typeof(action) !== 'string') { + console.warn(`[${this.redisConfig.redisId}] Ignoring message without action on ${chan}`) + return + } + + const handler = this['action_'+action] + if(typeof(handler) != 'function') { + if(this.debug) console.warn(`[${this.redisConfig.redisId}] Unknown action ${action} on ${chan}`) + return + } + + const payload = ('payload' in msg) ? msg.payload : null + const reqid = ('reqid' in msg) ? msg.reqid.substr(0, 50) : null + const sender = msg.sender || null + const roles = Array.isArray(msg.roles) ? msg.roles : ['*'] + + if(this.debug) console.log(`[${this.redisConfig.redisId}] Dispatching action ${action} from ${sender}`) + handler.call(this, action, payload, reqid, sender, roles) + } + + } + +} + + +// redis-cli -h redis.backend.eismea.eu --tls --user default diff --git a/GPS/startGps.sh b/GPS/startGps.sh new file mode 100755 index 0000000..323d831 --- /dev/null +++ b/GPS/startGps.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +cd /opt/p42GodDaemons/GPS/ + +pid=`ps -ef | grep p42Gps |grep -v grep | awk '{print $2}'` +if [ -z "$pid" ] +then + node p42Gps.js --debug > gps.log 2>&1 & +else + echo '' + echo 'Already running PID='"$pid"' (use stopGps.sh to stop it)' + echo '' +fi diff --git a/GPS/stopGps.sh b/GPS/stopGps.sh new file mode 100755 index 0000000..d8648ac --- /dev/null +++ b/GPS/stopGps.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +pid=`ps -ef | grep p42Gps.js |grep -v grep | awk '{print $2}'` +if [ -n "$pid" ] +then + echo "killing pid: $pid" + kill -9 $pid +fi + + + diff --git a/accesRights.js b/accesRights.js new file mode 100644 index 0000000..ea0df40 --- /dev/null +++ b/accesRights.js @@ -0,0 +1,131 @@ +export class AccesRights { + + constructor(config, debug){ + this.debug = debug + this.config = config + this.rights = config.accessRights + } + + refreshAccessRights(config){ + this.rights = config.accessRights + } + + mustSubscribe(uid, roles) { + if(roles.indexOf('*')<0) roles.push('*') + let chans = [] + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.mustSubscribe) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + chans = this.merge(chans, rightBlock.mustSubscribe.map(item=>item.replace(/\[UID\]/g,uid))) + } + } + } + return(chans) + } + + isMandatory(uid, roles, chan){ + return(this.mustSubscribe(uid, roles).filter(this.chanMatch.bind(this, chan)).length>0) + } + + canSubscribe(uid, roles, myChan) { + if(roles.indexOf('*')<0) roles.push('*') + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.canSubscribe) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + let canSubList = rightBlock.canSubscribe.map(item=>item.replace(/\[UID\]/g, uid)) + if(canSubList.find(this.chanMatch.bind(this, myChan))) return(true) + } + } + } + //if(this.debug) console.log(`Roles : ${roles} cannot subscribe on ${myChan}`) + return(false) + } + + canPublish(uid, roles, myChan) { + if(roles.indexOf('*')<0) roles.push('*') + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.canPublish) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + let canPubList = rightBlock.canPublish.map(item=>item.replace(/\[UID\]/g, uid)) + if(canPubList.find(this.chanMatch.bind(this, myChan))) return(true) + } + } + } + //if(this.debug) console.log(`Roles : ${roles} cannot publish on ${myChan}`) + return(false) + } + + canSet(uid, roles, myKey){ + if(roles.indexOf('*')<0) roles.push('*') + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.canSet) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + let canSetList = rightBlock.canSet.map(item=>item.replace(/\[UID\]/g, uid)) + if(canSetList.find(this.chanMatch.bind(this, myKey))) return(true) + } + } + } + //if(this.debug) console.log(`Roles : ${roles} cannot set ${myKey}`) + return(false) + } + + canGet(uid, roles, myKey){ + if(roles.indexOf('*')<0) roles.push('*') + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.canGet) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + let canGetList = rightBlock.canGet.map(item=>item.replace(/\[UID\]/g, uid)) + if(canGetList.find(this.chanMatch.bind(this, myKey))) return(true) + } + } + } + //if(this.debug) console.log(`Roles : ${roles} cannot get ${myKey}`) + return(false) + } + + canDo(roles, action, uid=null){ + if(roles.indexOf('*')<0) roles.push('*') + for(let myRole of roles){ + for(let rightBlock of this.rights) { + if(!rightBlock.canDo) continue + if((rightBlock.roles=='*') || (rightBlock.roles.indexOf(myRole)>-1)) { + if(rightBlock.canDo.indexOf(action)>-1) { + if((rightBlock.uuids) && Array.isArray((rightBlock.uuids))) { + // !!! Separate condition so if rightBlock.uuids but not uid in it, we don't give access ! + if(rightBlock.uuids.includes(uid)) return(true) // null should never be in config uuids => old style calls are safe + } else { // no uuid block => role is sufficient to give access + return(true) + } + } + } + } + } + + //Anti-shoot-your-foot + if((action=='reloadAccessRights') && (roles.includes('EIC_Dev'))) { + console.error('Prevented you from shooting your foot ! \nPlease keep least EIC_Dev in reload access rights !') + return(true) + } + + //if(this.debug) console.log(`Roles : ${roles} cannot do ${action}`) + return(false) + } + + chanMatch(myChan, configChan) { + if((!myChan) || (typeof(myChan)!='string')) return(false) + let re = new RegExp('^'+configChan.replace(/\*/g,'(.+)')+'$','g') + return(myChan.match(re)!=null) + } + + merge(x, y) { + let tmp = x + for(let yitem of y) if(tmp.indexOf(yitem)<0) tmp.push(yitem) + return(tmp) + } + +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..d241541 --- /dev/null +++ b/config.json @@ -0,0 +1,66 @@ +{ + "accessRights": [ + { + "canDo": [ + "RELOADCONFIG", + "GETCONFIG" + ], + "roles": [ + "admin" + ] + }, + { + "canDo": [ + "GETAGENTPOSITION", + "GETAGENTSINPRISM" + ], + "roles": "*" + } + ], + "gps": { + "gpsActionsChannel": "system:requests:gps", + "gpsActionsReply": "system:replies:[UID]", + "storage": { + "agentHashKey": "system:gps:agent:[UID]", + "agentsIndexKey": "system:gps:agents", + "positionsStream": "system:gps:positions", + "streamMaxLen": 100000 + }, + "agentVectorChangeChannel": "arena:agents:*", + "collisionsChannel": "arena:agents:[UID]", + "nearMissDistance": 1, + "prismTimeHeight": 60, + "collisionTickMs": 100, + "prismRefreshLeadSeconds": 1 + }, + "systemMesh": { + "redis": [ + { + "redisId": "SYS_1", + "role": "primary", + "host": "127.0.0.1", + "tls": false, + "port": 6380, + "user": "", + "pass": "", + "chansNamespace": "system:", + "basePrefix": "messageBus:" + } + ] + }, + "arenaMesh": { + "redis": [ + { + "redisId": "ARN_1", + "role": "primary", + "host": "127.0.0.1", + "tls": false, + "port": 6379, + "user": "", + "pass": "", + "chansNamespace": "arena:", + "basePrefix": "messageBus:" + } + ] + } +} diff --git a/configHelper.js b/configHelper.js new file mode 100644 index 0000000..cdfdf55 --- /dev/null +++ b/configHelper.js @@ -0,0 +1,54 @@ +import Ajv from 'ajv' +import confSchema from './configSchema.json' with { type: 'json' } +import { pathToFileURL } from 'url' + +export class configHelper { + + constructor(options){ + this.config = {} + this.localfile = options.localfile + this.fetchConfig = this.fetchConfigFile + this.refreshAccessRights = this.refreshAccessRightsFile + const ajv = new Ajv({ + allowUnionTypes: true + }) + this.configValidator = ajv.compile(confSchema) + } + + isValidConfig(conf){ + if(this.configValidator(conf)) return(true) + console.error('Invalid configuration: ', this.configValidator.errors) + return(false) + } + + async fetchConfigFile(){ + let curConfig = this.config + const url = pathToFileURL(this.localfile).href + this.config = await import(url, { + with: { type: 'json' } + }).then(m => m.default) + if(this.isValidConfig(this.config)) return(this.config) + console.error(this.configValidator.errors) + //revert if invalid conf + this.config = curConfig + } + + async refreshAccessRightsDynamo(){ + let ar = await this.dynamoGet('accessRights') + this.config.accessRights = ar + } + + async refreshAccessRightsFile(){ + let tmp + try { tmp = import(`${this.localfile}?update=${Date.now()}`, { assert: { type: 'json' } }) } + catch(err) { + console.error('Error Reloading config !! (bad json?) => Keeping current accessRights !') + return + } + if(!tmp.accessRights) { + console.error('Error Reloading config !! (no accessRights !) => Keeping current accessRights !') + return + } + this.config.accessRights = tmp.accessRights + } +} diff --git a/configSchema.json b/configSchema.json new file mode 100644 index 0000000..8fed15c --- /dev/null +++ b/configSchema.json @@ -0,0 +1,119 @@ +{ + "$id": "https://nicsys.eu/god-daemons-config.schema.json", + "title": "P42 God Daemons Configuration", + "description": "Shared configuration for P42 God Daemons", + "type": "object", + "definitions": { + "redisConnection": { + "type": "object", + "properties": { + "redisId": { "type": "string" }, + "role": { "type": "string", "enum": ["primary", "shard"] }, + "host": { "type": "string" }, + "tls": { "type": "boolean" }, + "port": { "type": "integer" }, + "user": { "type": "string" }, + "pass": { "type": "string" }, + "chansNamespace": { "type": "string" }, + "basePrefix": { "type": "string" } + }, + "required": [ + "redisId", + "chansNamespace", + "basePrefix" + ] + }, + "redisArray": { + "type": "array", + "items": { "$ref": "#/definitions/redisConnection" }, + "minItems": 1 + } + }, + "properties": { + "accessRights": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roles": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + }, + "canDo": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "roles", + "canDo" + ] + } + }, + "gps": { + "type": "object", + "properties": { + "gpsActionsChannel": { "type": "string" }, + "gpsActionsReply": { "type": "string" }, + "storage": { + "type": "object", + "properties": { + "agentHashKey": { "type": "string" }, + "agentsIndexKey": { "type": "string" }, + "positionsStream": { "type": "string" }, + "streamMaxLen": { "type": "integer", "minimum": 0 } + }, + "required": [ + "agentHashKey", + "agentsIndexKey", + "positionsStream" + ] + }, + "agentVectorChangeChannel": { "type": "string" }, + "collisionsChannel": { "type": "string" }, + "nearMissDistance": { "type": "number", "minimum": 0 }, + "prismTimeHeight": { "type": "number", "minimum": 0 }, + "collisionTickMs": { "type": "integer", "minimum": 1 }, + "prismRefreshLeadSeconds": { "type": "number", "minimum": 0 } + }, + "required": [ + "gpsActionsChannel", + "gpsActionsReply", + "storage", + "agentVectorChangeChannel", + "collisionsChannel" + ] + }, + "systemMesh": { + "type": "object", + "properties": { + "redis": { "$ref": "#/definitions/redisArray" } + }, + "required": [ + "redis" + ] + }, + "arenaMesh": { + "type": "object", + "properties": { + "redis": { "$ref": "#/definitions/redisArray" } + }, + "required": [ + "redis" + ] + } + }, + "required": [ + "accessRights", + "systemMesh", + "arenaMesh" + ], + "additionalProperties": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0c10d6 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "p42GodDaemons", + "type": "module", + "dependencies": { + "ajv": "^8.12.0" + } +}