feat: RPKI-based ASPA detection via Cloudflare feed (1455 objects), collapsible lists, sorted ASNs, Route Views
This commit is contained in:
parent
bd90113285
commit
1120d81dfc
@ -483,9 +483,9 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
|
|||||||
<a href="https://bgp.he.net" target="_blank">bgp.he.net</a> ·
|
<a href="https://bgp.he.net" target="_blank">bgp.he.net</a> ·
|
||||||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a> ·
|
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a> ·
|
||||||
<a href="https://www.ripe.net/manage-ips-and-asns/db" target="_blank">RIPE DB</a> ·
|
<a href="https://www.ripe.net/manage-ips-and-asns/db" target="_blank">RIPE DB</a> ·
|
||||||
<a href="https://rpki-validator.ripe.net" target="_blank">RPKI</a>
|
<a href="https://rpki.cloudflare.com" target="_blank">Cloudflare RPKI</a>
|
||||||
</div>
|
</div>
|
||||||
PeerCortex v0.4.0 — Open Source — MIT License<br>
|
PeerCortex v0.5.0 — Open Source — MIT License<br>
|
||||||
<a href="https://github.com/renefichtmueller/PaperCortex" target="_blank">PaperCortex</a> ·
|
<a href="https://github.com/renefichtmueller/PaperCortex" target="_blank">PaperCortex</a> ·
|
||||||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">PeerCortex GitHub</a>
|
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">PeerCortex GitHub</a>
|
||||||
</footer>
|
</footer>
|
||||||
@ -653,11 +653,16 @@ function renderAspa(d) {
|
|||||||
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
|
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
|
||||||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">ASPA Object</div>';
|
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">ASPA Object</div>';
|
||||||
if (d.aspa_object_exists) {
|
if (d.aspa_object_exists) {
|
||||||
h += '<div class="status-yes">Found in RIPE DB</div>';
|
h += '<div class="status-yes">Found in RPKI</div>';
|
||||||
} else {
|
} else {
|
||||||
h += '<div class="status-no">Not Found</div>';
|
h += '<div class="status-no">Not Found</div>';
|
||||||
}
|
}
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) {
|
||||||
|
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">RPKI-Declared Providers</div>';
|
||||||
|
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--green)">' + d.aspa_declared_count + '</div>';
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Detected Providers</div>';
|
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Detected Providers</div>';
|
||||||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + d.provider_count + '</div>';
|
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + d.provider_count + '</div>';
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
@ -669,7 +674,7 @@ function renderAspa(d) {
|
|||||||
// Detected providers (collapsible after 10)
|
// Detected providers (collapsible after 10)
|
||||||
if (d.detected_providers && d.detected_providers.length > 0) {
|
if (d.detected_providers && d.detected_providers.length > 0) {
|
||||||
var provLimit = 10;
|
var provLimit = 10;
|
||||||
var provList = d.detected_providers;
|
var provList = d.detected_providers.slice().sort(function(a, b) { return a.asn - b.asn; });
|
||||||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.75rem 0 .4rem">Detected Upstream Providers (' + provList.length + ')</div>';
|
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.75rem 0 .4rem">Detected Upstream Providers (' + provList.length + ')</div>';
|
||||||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||||||
provList.slice(0, provLimit).forEach(function(p) {
|
provList.slice(0, provLimit).forEach(function(p) {
|
||||||
@ -689,6 +694,18 @@ function renderAspa(d) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RPKI-declared providers (when ASPA object exists)
|
||||||
|
if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) {
|
||||||
|
var declaredList = d.aspa_declared_providers.slice().sort(function(a, b) { return a.asn - b.asn; });
|
||||||
|
h += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.75rem 0 .4rem">RPKI-Declared Providers (' + declaredList.length + ')</div>';
|
||||||
|
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||||||
|
declaredList.forEach(function(p) {
|
||||||
|
var label = p.asn === 0 ? 'AS0 (Tier-1 / No Provider)' : asnLink(p.asn);
|
||||||
|
h += '<span class="badge badge-green">' + label + '</span>';
|
||||||
|
});
|
||||||
|
h += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Recommended ASPA template (scrollable, max 200px)
|
// Recommended ASPA template (scrollable, max 200px)
|
||||||
if (d.recommended_aspa) {
|
if (d.recommended_aspa) {
|
||||||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.75rem 0 .4rem">Recommended ASPA Object</div>';
|
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.75rem 0 .4rem">Recommended ASPA Object</div>';
|
||||||
@ -1268,7 +1285,7 @@ function renderAspaDeep(d) {
|
|||||||
if (d.detected_providers && d.detected_providers.length > 0) {
|
if (d.detected_providers && d.detected_providers.length > 0) {
|
||||||
h += '<div style="margin-top:1.25rem"><div style="font-size:.85rem;font-weight:600;color:var(--blue);margin-bottom:.5rem">Detected Providers (by frequency)</div>';
|
h += '<div style="margin-top:1.25rem"><div style="font-size:.85rem;font-weight:600;color:var(--blue);margin-bottom:.5rem">Detected Providers (by frequency)</div>';
|
||||||
h += '<div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>Provider</th><th>Name</th><th>Seen in Paths</th><th>Frequency</th></tr></thead><tbody>';
|
h += '<div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>Provider</th><th>Name</th><th>Seen in Paths</th><th>Frequency</th></tr></thead><tbody>';
|
||||||
var sortedProviders = (d.detected_providers || []).slice().sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0); });
|
var sortedProviders = (d.detected_providers || []).slice().sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0) || a.asn - b.asn; });
|
||||||
sortedProviders.forEach(function(p) {
|
sortedProviders.forEach(function(p) {
|
||||||
h += '<tr><td>' + asnLink(p.asn) + '</td>';
|
h += '<tr><td>' + asnLink(p.asn) + '</td>';
|
||||||
var provName = (p.name && p.name !== 'AS' + p.asn) ? escHtml(p.name) : '';
|
var provName = (p.name && p.name !== 'AS' + p.asn) ? escHtml(p.name) : '';
|
||||||
|
|||||||
171
deploy/server.js
171
deploy/server.js
@ -23,7 +23,7 @@ try {
|
|||||||
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
|
||||||
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
|
||||||
|
|
||||||
const UA = "PeerCortex/0.4.0 (https://github.com/renefichtmueller/PeerCortex)";
|
const UA = "PeerCortex/0.5.0 (https://github.com/renefichtmueller/PeerCortex)";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Task 6: In-memory cache with TTL + Rate Limiting
|
// Task 6: In-memory cache with TTL + Rate Limiting
|
||||||
@ -56,6 +56,70 @@ const CACHE_TTL_ASPA = 10 * 60 * 1000; // 10 minutes
|
|||||||
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
|
const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes
|
||||||
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
|
const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RPKI ASPA Cache from Cloudflare RPKI JSON feed
|
||||||
|
// ============================================================
|
||||||
|
const rpkiAspaMap = new Map(); // customer_asid -> Set<provider_asn>
|
||||||
|
let rpkiAspaLastFetch = 0;
|
||||||
|
let rpkiAspaFetching = false;
|
||||||
|
|
||||||
|
function fetchRpkiAspaFeed() {
|
||||||
|
if (rpkiAspaFetching) return Promise.resolve();
|
||||||
|
rpkiAspaFetching = true;
|
||||||
|
console.log("[RPKI-ASPA] Fetching Cloudflare RPKI feed...");
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const options = {
|
||||||
|
headers: { "User-Agent": UA },
|
||||||
|
timeout: 30000,
|
||||||
|
};
|
||||||
|
https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const aspas = parsed.aspas || [];
|
||||||
|
rpkiAspaMap.clear();
|
||||||
|
aspas.forEach((a) => {
|
||||||
|
const customerAsid = Number(a.customer_asid);
|
||||||
|
const providers = (a.providers || []).map(Number);
|
||||||
|
rpkiAspaMap.set(customerAsid, new Set(providers));
|
||||||
|
});
|
||||||
|
rpkiAspaLastFetch = Date.now();
|
||||||
|
console.log("[RPKI-ASPA] Loaded " + rpkiAspaMap.size + " ASPA objects from Cloudflare RPKI feed");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[RPKI-ASPA] Failed to parse RPKI feed:", e.message);
|
||||||
|
}
|
||||||
|
rpkiAspaFetching = false;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
}).on("error", (e) => {
|
||||||
|
console.error("[RPKI-ASPA] Fetch failed:", e.message);
|
||||||
|
rpkiAspaFetching = false;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ASPA cache is fresh (fetch if older than 10 minutes)
|
||||||
|
async function ensureAspaCache() {
|
||||||
|
if (Date.now() - rpkiAspaLastFetch > 10 * 60 * 1000) {
|
||||||
|
await fetchRpkiAspaFeed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup ASPA object for a given ASN from the RPKI feed cache
|
||||||
|
function lookupAspaFromRpki(asn) {
|
||||||
|
const asnNum = Number(asn);
|
||||||
|
if (rpkiAspaMap.has(asnNum)) {
|
||||||
|
const providers = rpkiAspaMap.get(asnNum);
|
||||||
|
return { exists: true, providers: [...providers].sort((a, b) => a - b) };
|
||||||
|
}
|
||||||
|
return { exists: false, providers: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Rate limiting: max 60 requests per minute per IP
|
// Rate limiting: max 60 requests per minute per IP
|
||||||
const rateLimitMap = new Map();
|
const rateLimitMap = new Map();
|
||||||
const RATE_LIMIT_WINDOW = 60 * 1000;
|
const RATE_LIMIT_WINDOW = 60 * 1000;
|
||||||
@ -602,7 +666,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
service: "PeerCortex",
|
service: "PeerCortex",
|
||||||
version: "0.3.0",
|
version: "0.5.0",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime_seconds: Math.floor(process.uptime()),
|
uptime_seconds: Math.floor(process.uptime()),
|
||||||
bgproutes_configured: !!BGPROUTES_API_KEY,
|
bgproutes_configured: !!BGPROUTES_API_KEY,
|
||||||
@ -690,36 +754,29 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check RIPE DB for ASPA object
|
// Check Cloudflare RPKI feed for ASPA object
|
||||||
let aspaObjectExists = false;
|
await ensureAspaCache();
|
||||||
let aspaDeclaredProviders = [];
|
const aspaLookup = lookupAspaFromRpki(targetAsn);
|
||||||
try {
|
const aspaObjectExists = aspaLookup.exists;
|
||||||
const ripeDbInfo = await fetchJSON(
|
const aspaDeclaredProviders = aspaLookup.providers;
|
||||||
"https://rest.db.ripe.net/search.json?query-string=AS" +
|
|
||||||
rawAsn +
|
|
||||||
"&type-filter=aut-num&source=ripe"
|
|
||||||
);
|
|
||||||
const objects = ripeDbInfo?.objects?.object || [];
|
|
||||||
objects.forEach((obj) => {
|
|
||||||
const attrs = obj.attributes?.attribute || [];
|
|
||||||
attrs.forEach((attr) => {
|
|
||||||
if (attr.name === "remarks" && attr.value && attr.value.toLowerCase().includes("aspa")) {
|
|
||||||
aspaObjectExists = true;
|
|
||||||
}
|
|
||||||
if (attr.name === "import" || attr.name === "mp-import") {
|
|
||||||
const match = (attr.value || "").match(/from\s+AS(\d+)/i);
|
|
||||||
if (match) {
|
|
||||||
aspaDeclaredProviders.push(parseInt(match[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (_e) {
|
|
||||||
// RIPE DB query failed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build ASPA store and run verification
|
// Build ASPA store from RPKI feed data (real ASPA objects)
|
||||||
const aspaStore = buildAspaStore(detectedProviders, targetAsn);
|
const aspaStore = new Map();
|
||||||
|
// Add the target ASN's RPKI-declared providers
|
||||||
|
if (aspaObjectExists) {
|
||||||
|
aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
|
||||||
|
} else {
|
||||||
|
// Fallback: use detected providers for path verification
|
||||||
|
const providerSet = new Set(detectedProviders.map((p) => p.asn));
|
||||||
|
aspaStore.set(targetAsn, providerSet);
|
||||||
|
}
|
||||||
|
// Also populate store with all known ASPA objects from the RPKI feed
|
||||||
|
// for providers that have their own ASPA objects (enables full path verification)
|
||||||
|
for (const [cas, provSet] of rpkiAspaMap) {
|
||||||
|
if (!aspaStore.has(cas)) {
|
||||||
|
aspaStore.set(cas, provSet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Also add reverse relationships for providers we know about
|
// Also add reverse relationships for providers we know about
|
||||||
// (each provider has the target as customer)
|
// (each provider has the target as customer)
|
||||||
@ -913,26 +970,14 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
await resolveASNames(detectedProviders);
|
await resolveASNames(detectedProviders);
|
||||||
|
|
||||||
let aspaObjectExists = false;
|
// Check Cloudflare RPKI feed for ASPA object
|
||||||
try {
|
await ensureAspaCache();
|
||||||
const ripeDbInfo = await fetchJSON(
|
const aspaLookup = lookupAspaFromRpki(rawAsn);
|
||||||
"https://rest.db.ripe.net/search.json?query-string=AS" +
|
const aspaObjectExists = aspaLookup.exists;
|
||||||
rawAsn +
|
const aspaDeclaredProviders = aspaLookup.providers;
|
||||||
"&type-filter=aut-num&source=ripe"
|
|
||||||
);
|
|
||||||
const objects = ripeDbInfo?.objects?.object || [];
|
|
||||||
objects.forEach((obj) => {
|
|
||||||
const attrs = obj.attributes?.attribute || [];
|
|
||||||
attrs.forEach((attr) => {
|
|
||||||
if (attr.name === "remarks" && attr.value && attr.value.toLowerCase().includes("aspa")) {
|
|
||||||
aspaObjectExists = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (_e) {}
|
|
||||||
|
|
||||||
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
|
const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
|
||||||
const recommendedAspa =
|
let recommendedAspa =
|
||||||
"aut-num: AS" + rawAsn + "\n" +
|
"aut-num: AS" + rawAsn + "\n" +
|
||||||
"# Recommended ASPA object:\n" +
|
"# Recommended ASPA object:\n" +
|
||||||
"# customer: AS" + rawAsn + "\n" +
|
"# customer: AS" + rawAsn + "\n" +
|
||||||
@ -942,6 +987,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
"# Detected providers from BGP path analysis:\n" +
|
"# Detected providers from BGP path analysis:\n" +
|
||||||
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
|
detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
|
||||||
|
|
||||||
|
// If ASPA object exists, show RPKI-declared providers
|
||||||
|
if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
|
||||||
|
recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
|
||||||
|
aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
const samplePaths = asPaths.slice(0, 10).map((p) => {
|
const samplePaths = asPaths.slice(0, 10).map((p) => {
|
||||||
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
|
const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
|
||||||
const idx = p.path.indexOf(parseInt(rawAsn));
|
const idx = p.path.indexOf(parseInt(rawAsn));
|
||||||
@ -964,6 +1015,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
detected_providers: detectedProviders,
|
detected_providers: detectedProviders,
|
||||||
provider_count: detectedProviders.length,
|
provider_count: detectedProviders.length,
|
||||||
aspa_object_exists: aspaObjectExists,
|
aspa_object_exists: aspaObjectExists,
|
||||||
|
aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })),
|
||||||
|
aspa_declared_count: aspaDeclaredProviders.length,
|
||||||
recommended_aspa: recommendedAspa,
|
recommended_aspa: recommendedAspa,
|
||||||
path_analysis: {
|
path_analysis: {
|
||||||
total_paths_seen: asPaths.length,
|
total_paths_seen: asPaths.length,
|
||||||
@ -1597,10 +1650,10 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const result = {
|
const result = {
|
||||||
meta: {
|
meta: {
|
||||||
service: "PeerCortex",
|
service: "PeerCortex",
|
||||||
version: "0.3.0",
|
version: "0.5.0",
|
||||||
query: "AS" + asn,
|
query: "AS" + asn,
|
||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net"],
|
sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "Route Views"],
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
rpki_prefixes_checked: rpkiTotal,
|
rpki_prefixes_checked: rpkiTotal,
|
||||||
total_prefixes: prefixes.length,
|
total_prefixes: prefixes.length,
|
||||||
@ -2081,7 +2134,17 @@ const server = http.createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3101;
|
const PORT = process.env.PORT || 3101;
|
||||||
server.listen(PORT, "0.0.0.0", () => {
|
|
||||||
console.log("PeerCortex v0.3.0 running on http://0.0.0.0:" + PORT);
|
// Fetch RPKI ASPA feed at startup and refresh every 10 minutes
|
||||||
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
|
fetchRpkiAspaFeed().then(() => {
|
||||||
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
|
console.log("PeerCortex v0.4.0 running on http://0.0.0.0:" + PORT);
|
||||||
|
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
|
||||||
|
console.log("RPKI ASPA objects loaded: " + rpkiAspaMap.size);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refresh RPKI ASPA cache every 10 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
fetchRpkiAspaFeed();
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user