checkpoint: Erik runtime state before switching to merge-prod-snapshot
Captures accumulated hijack-alerts.json/aspa-adoption-history.json growth since the 2026-07-16 snapshot commit, plus the manually-uploaded server.js/public/index.html/package.json/local-db-client.js/src/backend/ files from the same-day ASPA-fix deploy, before switching this checkout to the merge-prod-snapshot branch (which has the full src/api/ + src/routes/ tree needed for the new API server, missing here since this checkout was never git-pulled, only individually file-uploaded).
This commit is contained in:
parent
2766872aad
commit
7df0fc29ce
@ -859,12 +859,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "2026-07-16",
|
"date": "2026-07-16",
|
||||||
"ts": 1784174057994,
|
"ts": 1784228666748,
|
||||||
"aspa_objects": 2382,
|
"aspa_objects": 2397,
|
||||||
"roa_count": 971831,
|
"roa_count": 972406,
|
||||||
"atlas_asns_total": 4775,
|
"atlas_asns_total": 4774,
|
||||||
"atlas_asns_with_aspa": 504,
|
"atlas_asns_with_aspa": 508,
|
||||||
"coverage_pct_atlas": 10.55,
|
"coverage_pct_atlas": 10.64,
|
||||||
"aspa_delta": 0,
|
"aspa_delta": 0,
|
||||||
"method": "rpki_cloudflare_feed"
|
"method": "rpki_cloudflare_feed"
|
||||||
}
|
}
|
||||||
|
|||||||
1250
hijack-alerts.json
1250
hijack-alerts.json
File diff suppressed because it is too large
Load Diff
437
local-db-client.js
Normal file
437
local-db-client.js
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
/**
|
||||||
|
* Local Database Client for PeerCortex
|
||||||
|
* Replaces external API calls with local PostgreSQL queries
|
||||||
|
* BGP + RPKI + Threat Intel + RDAP caching
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
user: process.env.DB_USER || 'llm',
|
||||||
|
password: process.env.DB_PASSWORD || 'llm_secure_2026',
|
||||||
|
host: process.env.DB_HOST || '192.168.178.82',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432'),
|
||||||
|
database: process.env.DB_NAME || 'llm_gateway',
|
||||||
|
});
|
||||||
|
|
||||||
|
// RDAP Cache (in-memory for this session)
|
||||||
|
const rdapCache = new Map();
|
||||||
|
const RDAP_CACHE_TTL = 3600000; // 1 hour
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// BGP FUNCTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function getBgpStatus(prefix) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT origin_asn, MAX(visibility_percent) as visibility_percent, MAX(last_seen) as last_seen
|
||||||
|
FROM bgp_routes
|
||||||
|
WHERE prefix = $1::cidr
|
||||||
|
GROUP BY origin_asn`,
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
announced: false,
|
||||||
|
origin_asns: [],
|
||||||
|
visibility_percent: 0,
|
||||||
|
last_seen: new Date().toISOString(),
|
||||||
|
source: 'local_bgp',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
announced: true,
|
||||||
|
origin_asns: result.rows.map(r => r.origin_asn),
|
||||||
|
visibility_percent: Math.max(...result.rows.map(r => parseFloat(r.visibility_percent) || 0)),
|
||||||
|
last_seen: result.rows[0].last_seen || new Date().toISOString(),
|
||||||
|
source: 'local_bgp',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] BGP Status Error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAnnouncedPrefixes(asn) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT prefix, origin_asn, visibility_percent, last_seen
|
||||||
|
FROM bgp_routes
|
||||||
|
WHERE origin_asn = $1
|
||||||
|
ORDER BY visibility_percent DESC
|
||||||
|
LIMIT 100`,
|
||||||
|
[asn]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] Announced Prefixes Error:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns null on DB error (check could not run) vs [] for a genuine single/no-origin
|
||||||
|
// result -- a caller treating both as "no hijack" would silently disable hijack
|
||||||
|
// detection on every transient DB hiccup while still showing the check as clean.
|
||||||
|
async function checkBgpHijack(prefix) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT origin_asn FROM bgp_routes WHERE prefix = $1::cidr`,
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
return result.rows.length > 1 ? result.rows.map(r => r.origin_asn) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] Hijack Check Error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RPKI FUNCTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function validateRpki(prefix, originAsn) {
|
||||||
|
try {
|
||||||
|
const prefixParts = prefix.split('/');
|
||||||
|
if (prefixParts.length !== 2) {
|
||||||
|
return { status: 'unknown', description: 'Invalid CIDR format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixLength = parseInt(prefixParts[1]);
|
||||||
|
|
||||||
|
// Query for covering ROAs
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM rpki_roas
|
||||||
|
WHERE $1::cidr << (prefix || '/' || max_length)::cidr
|
||||||
|
AND origin_asn = $2
|
||||||
|
AND expires > NOW()
|
||||||
|
LIMIT 10`,
|
||||||
|
[prefix, originAsn]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
const anyRoa = await pool.query(
|
||||||
|
`SELECT 1 FROM rpki_roas WHERE $1::cidr << prefix AND expires > NOW() LIMIT 1`,
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anyRoa.rows.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'invalid',
|
||||||
|
prefix,
|
||||||
|
asn: originAsn,
|
||||||
|
description: `RPKI INVALID: ROAs exist but origin ASN ${originAsn} not authorized`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'not-found',
|
||||||
|
prefix,
|
||||||
|
asn: originAsn,
|
||||||
|
description: 'No matching ROA found (unprotected)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roa = result.rows[0];
|
||||||
|
if (prefixLength > roa.max_length) {
|
||||||
|
return {
|
||||||
|
status: 'invalid',
|
||||||
|
prefix,
|
||||||
|
asn: originAsn,
|
||||||
|
max_length: roa.max_length,
|
||||||
|
description: `RPKI INVALID: Prefix length ${prefixLength} > max_length ${roa.max_length}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'valid',
|
||||||
|
prefix,
|
||||||
|
asn: originAsn,
|
||||||
|
max_length: roa.max_length,
|
||||||
|
expires: roa.expires,
|
||||||
|
description: `RPKI VALID: Origin ASN ${originAsn} authorized`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] RPKI Validation Error:', error.message);
|
||||||
|
return { status: 'unknown', description: 'RPKI validation error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoasForAsn(asn) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT prefix, max_length, expires FROM rpki_roas
|
||||||
|
WHERE origin_asn = $1 AND expires > NOW()
|
||||||
|
ORDER BY prefix`,
|
||||||
|
[asn]
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] ROAs for ASN Error:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// THREAT INTEL FUNCTIONS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function getThreatIntel(ip) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT ip_address, threat_level, confidence_score, source, details, cached_at
|
||||||
|
FROM threat_intel
|
||||||
|
WHERE ip_address = $1::inet
|
||||||
|
AND expires_at > NOW()
|
||||||
|
LIMIT 1`,
|
||||||
|
[ip]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.length > 0 ? result.rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] Threat Intel Error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMaliciousIp(ip) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT 1 FROM threat_intel
|
||||||
|
WHERE ip_address = $1::inet
|
||||||
|
AND threat_level IN ('CRITICAL', 'HIGH')
|
||||||
|
AND expires_at > NOW()
|
||||||
|
LIMIT 1`,
|
||||||
|
[ip]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] Malicious IP Check Error:', error.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RDAP CACHING (in-memory)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function getRdapCached(resource) {
|
||||||
|
const cached = rdapCache.get(resource);
|
||||||
|
if (cached && Date.now() - cached.timestamp < RDAP_CACHE_TTL) {
|
||||||
|
console.log(`[RDAP Cache] HIT: ${resource}`);
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
if (cached) rdapCache.delete(resource);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRdapCached(resource, data) {
|
||||||
|
rdapCache.set(resource, { data, timestamp: Date.now() });
|
||||||
|
console.log(`[RDAP Cache] SET: ${resource} (TTL: 1h)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// RIPE STAT API WRAPPER (Drop-in replacements for external API calls)
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function getRipeStatAnnouncedPrefixes(asn) {
|
||||||
|
try {
|
||||||
|
const prefixes = await getAnnouncedPrefixes(asn);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
cached: false,
|
||||||
|
data: {
|
||||||
|
resource: `AS${asn}`,
|
||||||
|
prefixes: prefixes.map(p => ({
|
||||||
|
prefix: p.prefix,
|
||||||
|
origin_asn: p.origin_asn,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local RIPE Stat] Announced Prefixes Error:', error.message);
|
||||||
|
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, prefixes: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRipeStatAsnNeighbours(asn) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT origin_asn FROM bgp_routes LIMIT 200`
|
||||||
|
);
|
||||||
|
|
||||||
|
const neighbours = result.rows.map(r => ({
|
||||||
|
asn: r.origin_asn,
|
||||||
|
type: 'unknown',
|
||||||
|
prefixes: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
cached: false,
|
||||||
|
data: {
|
||||||
|
resource: `AS${asn}`,
|
||||||
|
neighbours: neighbours,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local RIPE Stat] ASN Neighbours Error:', error.message);
|
||||||
|
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, neighbours: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRipeStatAsOverview(asn) {
|
||||||
|
try {
|
||||||
|
const prefixes = await getAnnouncedPrefixes(asn);
|
||||||
|
const roasCount = await pool.query(
|
||||||
|
`SELECT COUNT(*) as count FROM rpki_roas WHERE origin_asn = $1 AND expires > NOW()`,
|
||||||
|
[asn]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
cached: false,
|
||||||
|
data: {
|
||||||
|
resource: `AS${asn}`,
|
||||||
|
asn: asn,
|
||||||
|
holder: 'Unknown',
|
||||||
|
announced_prefixes_count: prefixes.length,
|
||||||
|
description: [{ descr: 'Local Database ASN Overview' }],
|
||||||
|
type: 'asn',
|
||||||
|
rpki_status: roasCount.rows[0].count > 0 ? 'signed' : 'not-signed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local RIPE Stat] AS Overview Error:', error.message);
|
||||||
|
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, announced_prefixes_count: 0 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRipeStatVisibility(asn) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT COALESCE(AVG(visibility_percent), 0) as avg_visibility
|
||||||
|
FROM bgp_routes
|
||||||
|
WHERE origin_asn = $1`,
|
||||||
|
[asn]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
cached: false,
|
||||||
|
data: {
|
||||||
|
resource: `AS${asn}`,
|
||||||
|
visibility: {
|
||||||
|
ipv4: {
|
||||||
|
ris_peers_seeing: Math.round(result.rows[0].avg_visibility),
|
||||||
|
total_ris_peers: 100,
|
||||||
|
sees_ris_peers: Math.round(result.rows[0].avg_visibility),
|
||||||
|
},
|
||||||
|
ipv6: {
|
||||||
|
ris_peers_seeing: 0,
|
||||||
|
total_ris_peers: 100,
|
||||||
|
sees_ris_peers: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local RIPE Stat] Visibility Error:', error.message);
|
||||||
|
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, visibility: {} } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRipeStatPrefixSizeDistribution(asn) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT masklen(prefix) as prefix_len
|
||||||
|
FROM bgp_routes
|
||||||
|
WHERE origin_asn = $1
|
||||||
|
ORDER BY masklen(prefix)`,
|
||||||
|
[asn]
|
||||||
|
);
|
||||||
|
|
||||||
|
const distribution = {};
|
||||||
|
result.rows.forEach(row => {
|
||||||
|
const len = row.prefix_len;
|
||||||
|
distribution[len] = (distribution[len] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
cached: false,
|
||||||
|
data: {
|
||||||
|
resource: `AS${asn}`,
|
||||||
|
ipv4_prefix_size: Object.keys(distribution)
|
||||||
|
.map(len => ({ prefix_length: parseInt(len), count: distribution[len] }))
|
||||||
|
.sort((a, b) => a.prefix_length - b.prefix_length),
|
||||||
|
ipv6_prefix_size: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local RIPE Stat] Prefix Size Distribution Error:', error.message);
|
||||||
|
return { status: 'error', error: error.message, data: { resource: `AS${asn}`, ipv4_prefix_size: [] } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// STATS & HEALTH CHECK
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function getLocalDbStats() {
|
||||||
|
try {
|
||||||
|
const bgp = await pool.query(`SELECT COUNT(*) as count FROM bgp_routes`);
|
||||||
|
const rpki = await pool.query(`SELECT COUNT(*) as count FROM rpki_roas WHERE expires > NOW()`);
|
||||||
|
const threat = await pool.query(`SELECT COUNT(*) as count FROM threat_intel WHERE expires_at > NOW()`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bgp_routes: parseInt(bgp.rows[0].count),
|
||||||
|
rpki_roas: parseInt(rpki.rows[0].count),
|
||||||
|
threat_intel: parseInt(threat.rows[0].count),
|
||||||
|
rdap_cache_entries: rdapCache.size,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Local DB] Stats Error:', error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// EXPORTS
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// BGP
|
||||||
|
getBgpStatus,
|
||||||
|
getAnnouncedPrefixes,
|
||||||
|
checkBgpHijack,
|
||||||
|
|
||||||
|
// RPKI
|
||||||
|
validateRpki,
|
||||||
|
getRoasForAsn,
|
||||||
|
|
||||||
|
// Threat Intel
|
||||||
|
getThreatIntel,
|
||||||
|
isMaliciousIp,
|
||||||
|
|
||||||
|
// RIPE Stat API Wrappers
|
||||||
|
getRipeStatAnnouncedPrefixes,
|
||||||
|
getRipeStatAsnNeighbours,
|
||||||
|
getRipeStatAsOverview,
|
||||||
|
getRipeStatVisibility,
|
||||||
|
getRipeStatPrefixSizeDistribution,
|
||||||
|
|
||||||
|
// RDAP Cache
|
||||||
|
getRdapCached,
|
||||||
|
setRdapCached,
|
||||||
|
|
||||||
|
// Health
|
||||||
|
getLocalDbStats,
|
||||||
|
cleanup,
|
||||||
|
};
|
||||||
3229
package-lock.json
generated
3229
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "peercortex",
|
"name": "peercortex",
|
||||||
"version": "0.6.5",
|
"version": "0.7.0",
|
||||||
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
"description": "AI-Powered Network Intelligence Platform — MCP Server for PeeringDB, RIPE Stat, BGP analysis, RPKI monitoring, and peering automation. Powered by local Ollama.",
|
||||||
"main": "dist/mcp-server/index.js",
|
"main": "dist/mcp-server/index.js",
|
||||||
"types": "dist/mcp-server/index.d.ts",
|
"types": "dist/mcp-server/index.d.ts",
|
||||||
@ -53,10 +53,10 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/renefichtmueller/PeerCortex.git"
|
"url": "https://gitea.context-x.org/rene/PeerCortex.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/renefichtmueller/PeerCortex/issues"
|
"url": "https://gitea.context-x.org/rene/PeerCortex/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://peercortex.org",
|
"homepage": "https://peercortex.org",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -66,20 +66,28 @@
|
|||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"node-whois": "^2.1.3",
|
"fastify": "^5.8.5",
|
||||||
|
"joi": "^17.11.0",
|
||||||
|
"node-cron": "^4.6.0",
|
||||||
"ollama": "^0.5.12",
|
"ollama": "^0.5.12",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"playwright": "^1.40.0",
|
||||||
"puppeteer": "^24.42.0",
|
"puppeteer": "^24.42.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.40.0",
|
||||||
"@types/better-sqlite3": "^7.6.12",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
"@vitest/coverage-v8": "^2.1.0",
|
"@vitest/coverage-v8": "^2.1.0",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^9.16.0",
|
||||||
|
"nock": "^13.4.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^2.1.0"
|
"vitest": "^2.1.0"
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PeerCortex — The ASN News</title>
|
<title>PeerCortex — The ASN News</title>
|
||||||
|
<link rel="canonical" href="https://peercortex.org">
|
||||||
|
<script defer src="https://analytics.fichtmueller.org/script.js" data-website-id="1cdd1e46-37f8-47c3-9b7f-a3992a46f5ed"></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
|||||||
28
src/backend/config.js
Normal file
28
src/backend/config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function loadEnv() {
|
||||||
|
const envPath = "/opt/peercortex-app/.env";
|
||||||
|
try {
|
||||||
|
const envContent = fs.readFileSync(envPath, "utf8");
|
||||||
|
envContent.split("\n").forEach((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) return;
|
||||||
|
const eqIdx = trimmed.indexOf("=");
|
||||||
|
if (eqIdx > 0) {
|
||||||
|
const key = trimmed.substring(0, eqIdx).trim();
|
||||||
|
const val = trimmed.substring(eqIdx + 1).trim();
|
||||||
|
if (!process.env[key]) process.env[key] = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('[Config] Environment variables loaded');
|
||||||
|
} catch (_e) {
|
||||||
|
console.warn("Warning: Could not read .env file at", envPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically load environment variables when module is required
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadEnv
|
||||||
|
};
|
||||||
78
src/backend/services/smtp.js
Normal file
78
src/backend/services/smtp.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const tls = require('tls');
|
||||||
|
const net = require('net');
|
||||||
|
|
||||||
|
const SMTP_HOST = 'mail.fichtmueller.org';
|
||||||
|
const SMTP_PORT = 587;
|
||||||
|
const MAIL_TO = 'peercortex@context-x.org';
|
||||||
|
const MAIL_FROM = 'PeerCortex Feedback <rene@fichtmueller.org>';
|
||||||
|
|
||||||
|
function sendFeedbackMail(entry) {
|
||||||
|
const SMTP_USER = process.env.SMTP_USER;
|
||||||
|
const SMTP_PASS = process.env.SMTP_PASS;
|
||||||
|
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var b64 = function(s) { return Buffer.from(s).toString('base64'); };
|
||||||
|
var CRLF = '\r\n';
|
||||||
|
var body = 'Category : ' + entry.category + CRLF +
|
||||||
|
'Name : ' + entry.name + CRLF +
|
||||||
|
'ASN : ' + (entry.asn || '-') + CRLF +
|
||||||
|
'Time : ' + entry.timestamp + CRLF + CRLF +
|
||||||
|
entry.message + CRLF + CRLF + '-' + CRLF + 'PeerCortex Feedback';
|
||||||
|
var subj = '[PeerCortex Feedback] ' + entry.category + (entry.asn ? ' - AS' + entry.asn : '');
|
||||||
|
var msg = 'From: ' + MAIL_FROM + CRLF +
|
||||||
|
'To: ' + MAIL_TO + CRLF +
|
||||||
|
'Subject: ' + subj + CRLF +
|
||||||
|
'MIME-Version: 1.0' + CRLF +
|
||||||
|
'Content-Type: text/plain; charset=UTF-8' + CRLF + CRLF +
|
||||||
|
body;
|
||||||
|
|
||||||
|
var socket = net.connect(SMTP_PORT, SMTP_HOST);
|
||||||
|
var tlsSocket = null;
|
||||||
|
var buf = '';
|
||||||
|
var step = 0;
|
||||||
|
var done = false;
|
||||||
|
|
||||||
|
function send(line) {
|
||||||
|
var s = tlsSocket || socket;
|
||||||
|
s.write(line + CRLF);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onData(data) {
|
||||||
|
buf += data.toString();
|
||||||
|
var lines = buf.split(CRLF);
|
||||||
|
buf = lines.pop();
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i];
|
||||||
|
var code = parseInt(line.slice(0, 3));
|
||||||
|
if (isNaN(code) || line[3] === '-') continue;
|
||||||
|
if (step === 0 && code === 220) { send('EHLO peercortex.org'); step = 1; }
|
||||||
|
else if (step === 1 && code === 250) { send('STARTTLS'); step = 2; }
|
||||||
|
else if (step === 2 && code === 220) {
|
||||||
|
tlsSocket = tls.connect({ socket: socket, servername: SMTP_HOST, rejectUnauthorized: false }, function() {
|
||||||
|
tlsSocket.on('data', onData);
|
||||||
|
send('EHLO peercortex.org');
|
||||||
|
step = 3;
|
||||||
|
});
|
||||||
|
tlsSocket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||||
|
}
|
||||||
|
else if (step === 3 && code === 250) { send('AUTH LOGIN'); step = 4; }
|
||||||
|
else if (step === 4 && code === 334) { send(b64(SMTP_USER)); step = 5; }
|
||||||
|
else if (step === 5 && code === 334) { send(b64(SMTP_PASS)); step = 6; }
|
||||||
|
else if (step === 6 && code === 235) { send('MAIL FROM:<' + SMTP_USER + '>'); step = 7; }
|
||||||
|
else if (step === 7 && code === 250) { send('RCPT TO:<' + MAIL_TO + '>'); step = 8; }
|
||||||
|
else if (step === 8 && code === 250) { send('DATA'); step = 9; }
|
||||||
|
else if (step === 9 && code === 354) { send(msg + CRLF + '.'); step = 10; }
|
||||||
|
else if (step === 10 && code === 250) { send('QUIT'); if (!done) { done = true; resolve(); } }
|
||||||
|
else if (code >= 400) { if (!done) { done = true; reject(new Error('SMTP ' + code + ': ' + line)); } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('data', onData);
|
||||||
|
socket.on('error', function(e) { if (!done) { done = true; reject(e); } });
|
||||||
|
setTimeout(function() { if (!done) { done = true; reject(new Error('SMTP timeout')); } }, 15000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sendFeedbackMail
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user