UNPKG

@alltiptop/geoip-3xui-rules

Version:

Middleware server to set routing rules by countries for XRAY

305 lines (304 loc) 12.1 kB
import Fastify from 'fastify'; import { readFileSync, readdirSync, existsSync } from 'fs'; import { join, parse } from 'path'; import { lookup as ipLookup } from 'ip-location-api'; import countries from 'world-countries'; import { COUNTRY_TLDS } from './constants.js'; import { removeDuplicateRules } from './utils/removeDuplicateRules.js'; import { get3xui } from './utils/get3xui.js'; import { getClientIp } from './utils/getClientIp.js'; import { buildDomainRule } from './utils/buildDomainRule.js'; /** * Cache for last country lookup to each user */ const USERS_COUNTRY_CACHE = new Map(); export async function createServer({ upstreamUrl, secretUrl, rulesDir = 'rules', overridesDir = 'overrides', directSameCountry = true, logger = true, publicURL, xuiOptions, transform, }) { const app = Fastify({ logger }); const RULE_PRESETS = {}; const OVERRIDE_PRESETS = {}; const REVERSE_PRESETS = []; const TAGS_PRESETS = {}; const { getUserTags } = xuiOptions ? await get3xui(xuiOptions) : { getUserTags: () => '', }; // Includes support const includesDir = join(rulesDir, 'includes'); const expandIncludes = (text, seen = new Set()) => { return text.replace(/"@include\s+([A-Za-z0-9._-]+)"/g, (_m, name) => { const fileName = name.endsWith('.json') ? name : `${name}.json`; const fullPath = join(includesDir, fileName); try { if (seen.has(fullPath)) return '{}'; if (!existsSync(fullPath)) return '{}'; seen.add(fullPath); let content = readFileSync(fullPath, 'utf8'); content = expandIncludes(content, seen); seen.delete(fullPath); return content.trim(); } catch (err) { app.log.error(`Include failed for ${fullPath}: ${err}`); return '{}'; } }); }; const flattenRuleArray = (arr) => { const out = []; for (const item of arr) { if (Array.isArray(item)) { // Recursively flatten only when an array appears where a rule item is expected out.push(...flattenRuleArray(item)); } else { out.push(item); } } return out; }; const parseWithIncludes = (filePath, expectArray = false) => { const raw = readFileSync(filePath, 'utf8'); const expanded = expandIncludes(raw); const parsed = JSON.parse(expanded); if (expectArray && Array.isArray(parsed)) return flattenRuleArray(parsed); return parsed; }; if (existsSync(rulesDir)) { for (const file of readdirSync(rulesDir).filter((f) => f.endsWith('.json'))) { const baseName = parse(file).name; const full = join(rulesDir, file); try { if (baseName.startsWith('!')) { const tokens = baseName .slice(1) .split(',') .map((s) => s.trim().toUpperCase()) .filter(Boolean); const excludeEU = tokens.includes('EU'); const countries = tokens.filter((t) => t !== 'EU'); const rules = parseWithIncludes(full, true); REVERSE_PRESETS.push({ exclude: new Set(countries), excludeEU, rules, name: baseName }); app.log.info(`Loaded reverse rules ${baseName}`); } else { const code = baseName.toUpperCase(); RULE_PRESETS[code] = parseWithIncludes(full, true); app.log.info(`Loaded rules for ${code}`); } } catch (err) { app.log.error(`Failed to load ${file}: ${err}`); } } } if (existsSync(overridesDir)) { for (const file of readdirSync(overridesDir).filter((f) => f.endsWith('.json'))) { const code = parse(file).name.toUpperCase(); try { OVERRIDE_PRESETS[code] = parseWithIncludes(join(overridesDir, file), false); app.log.info(`Loaded overrides for ${code}`); } catch (err) { app.log.error(`Failed to load ${file}: ${err}`); } } } const tagsDir = join(rulesDir, 'tags'); if (existsSync(tagsDir)) { for (const dirent of readdirSync(tagsDir, { withFileTypes: true })) { if (!dirent.isDirectory()) continue; const tagName = dirent.name; const tagPath = join(tagsDir, tagName); const preset = { base: [], default: [], country: {} }; const baseFile = join(tagPath, 'base.json'); if (existsSync(baseFile)) { try { preset.base = parseWithIncludes(baseFile, true); } catch (err) { app.log.error(`Failed to load ${baseFile}: ${err}`); } } const defaultFile = join(tagPath, 'default.json'); if (existsSync(defaultFile)) { try { preset.default = parseWithIncludes(defaultFile, true); } catch (err) { app.log.error(`Failed to load ${defaultFile}: ${err}`); } } for (const file of readdirSync(tagPath).filter((f) => f.endsWith('.json') && !['base.json', 'default.json'].includes(f))) { const code = parse(file).name.toUpperCase(); try { preset.country[code] = parseWithIncludes(join(tagPath, file), true); } catch (err) { app.log.error(`Failed to load ${file}: ${err}`); } } TAGS_PRESETS[tagName] = preset; app.log.info(`Loaded tag preset ${tagName}`); } } else { app.log.info('No tags directory found – skipping tag presets'); } app.get(`/${secretUrl}/json/:subscriptionId`, async (req, reply) => { const query = req.query; const { subscriptionId } = req.params; const { tags, country: countryOverride, isEU: isEUOverride } = query; const tagsList = (Array.isArray(tags) ? tags : (tags?.split(',') || [])).filter(Boolean) || []; const userTags = getUserTags(subscriptionId); const activeTags = [...tagsList, ...userTags]; const ip = getClientIp(req.headers, req.ip); let iso = ''; let isEU = false; try { const countryInfo = await ipLookup(ip); iso = countryInfo?.country?.toUpperCase() || ''; isEU = countryInfo?.eu || false; } catch (err) { req.log.warn(`GeoIP failed for ${ip}: ${err}`); } // Override from query params if provided if (countryOverride && typeof countryOverride === 'string') { const co = countryOverride.toUpperCase(); if (co === 'EU') { // Special token: mark as EU region, keep ISO unchanged isEU = true; } else { iso = co; } } if (typeof isEUOverride !== 'undefined') { const val = typeof isEUOverride === 'boolean' ? isEUOverride : /^(1|true|yes|on)$/i.test(String(isEUOverride)); isEU = Boolean(val); } if (iso) USERS_COUNTRY_CACHE.set(subscriptionId, iso); if (!iso && USERS_COUNTRY_CACHE.has(subscriptionId)) iso = USERS_COUNTRY_CACHE.get(subscriptionId) || ''; let original; try { const res = await fetch(`${upstreamUrl}/${subscriptionId}`); if (!res.ok) return reply.code(res.status).send({ error: 'upstream_error' }); original = await res.json(); /** * Forward original headers (except content-length), * like "profile-update-interval" or "subscription-userinfo" * to keep original behavior */ const hopByHop = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); for (const [k, v] of res.headers.entries()) { if (!hopByHop.has(k.toLowerCase())) reply.header(k, v); } } catch (err) { req.log.error(`Fetch failed: ${err}`); return reply.code(502).send({ error: 'bad_gateway' }); } const baseRules = RULE_PRESETS['BASE'] ?? []; const euRules = isEU ? RULE_PRESETS['EU'] ?? [] : []; const countryRules = RULE_PRESETS[iso] ?? RULE_PRESETS['DEFAULT'] ?? []; const reverseRules = REVERSE_PRESETS .filter((p) => (!p.excludeEU || !isEU) && !p.exclude.has(iso)) .flatMap((p) => p.rules); const tagsRules = activeTags .flatMap((tag) => { const preset = TAGS_PRESETS[tag]; if (!preset) return []; const countryRules = preset.country[iso] ?? preset.default; return [...preset.base, ...countryRules]; }); const sameCountryRules = []; if (iso && directSameCountry) { const tldRule = buildDomainRule(COUNTRY_TLDS.get(iso) || []); if (tldRule) sameCountryRules.push(tldRule); sameCountryRules.push({ type: 'field', ip: [`geoip:${iso.toLowerCase()}`], outboundTag: 'direct', remarks: 'directSameCountry', }); } /** * Direct rule for current service to avoid wrong routing on update */ const directRules = publicURL ? [ { type: 'field', domain: [`domain:${publicURL}`], outboundTag: 'direct', }, ] : []; const rules = [ ...directRules, ...baseRules, ...tagsRules, ...sameCountryRules, ...reverseRules, ...euRules, ...countryRules, ]; const merged = { ...original, ...(OVERRIDE_PRESETS[iso] ?? OVERRIDE_PRESETS['DEFAULT'] ?? {}), remarks: `${original.remarks}${iso ? ` (${countries.find((c) => c.cca2 === iso)?.name.common})` : ''}`, routing: { domainStrategy: 'IPIfNonMatch', rules, }, }; if (transform) { try { const { transformed, headers } = await transform({ json: merged, iso, subId: subscriptionId, isEU, query, requestHeaders: req.headers, }); for (const headerName in headers) { reply.header(headerName, headers[headerName]); } const finalRules = removeDuplicateRules(transformed); return reply.send(JSON.stringify(finalRules, null, 2)); } catch (err) { app.log.error(`Transform failed: ${err}`); const finalRules = removeDuplicateRules(merged); return reply.send(JSON.stringify(finalRules, null, 2)); } } return reply.send(JSON.stringify(merged, null, 2)); }); app.setNotFoundHandler((_, reply) => reply.code(204).send()); return app; }