UNPKG

@alltiptop/geoip-3xui-rules

Version:

Middleware server to set routing rules by countries for XRAY

247 lines (246 loc) 9.53 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 punycode from 'punycode'; import { XuiApi } from '3x-ui'; export const get3xui = async ({ panelAddress, username, password, inboundIds, debug = false, }) => { try { const api = new XuiApi(`https://${username}:${password}@${panelAddress.split('://')[1]}`); const inbound = await api.getInbounds(); api.debug = debug; api.stdTTL = 30; const getUserTags = (subscriptionId) => { const allClients = inbound .filter((inbound) => inboundIds.includes(inbound.id)) .flatMap((inbound) => inbound.settings.clients); const client = allClients.find((client) => client.subId === subscriptionId); const comment = client?.comment || ''; // Regex: capture sequence after 'tags=' up to ;, newline, or another key=value segment const tagRegex = /tags=([\s\S]*?)(?=(?:\s+\S+=)|;|\n|$)/g; const out = []; let match; while ((match = tagRegex.exec(comment))) { const segment = match[1]; segment .split(',') .map((s) => s.trim()) .filter(Boolean) .forEach((t) => out.push(t)); } return Array.from(new Set(out)); }; return { getUserTags, }; } catch (error) { console.error(error); return { getUserTags: () => [], }; } }; const COUNTRY_TLDS = new Map(countries.map((c) => [ c.cca2.toUpperCase(), c.tld?.map((t) => t.replace(/^\./, '')) || [], ])); /** * Cache for last country lookup to each user */ const USERS_COUNTRY_CACHE = new Map(); function getClientIp(headers, ipFromFastify) { const forwarded = headers['x-forwarded-for']; return forwarded ? forwarded.split(',')[0].trim() : ipFromFastify; } function buildDomainRule(tlds) { if (!tlds.length) return null; const pattern = `regexp:.*\\.(?:${tlds .map((domainSuffix) => punycode.toASCII(domainSuffix)) .join('|')})$`; return { type: 'field', domain: [pattern], outboundTag: 'direct' }; } export async function createServer({ upstreamUrl, secretUrl, rulesDir = 'rules', overridesDir = 'overrides', directSameCountry = true, logger = true, publicURL, xuiOptions, }) { const app = Fastify({ logger }); const RULE_PRESETS = {}; const OVERRIDE_PRESETS = {}; const TAGS_PRESETS = {}; const { getUserTags } = xuiOptions ? await get3xui(xuiOptions) : { getUserTags: () => '', }; if (existsSync(rulesDir)) { for (const file of readdirSync(rulesDir).filter((f) => f.endsWith('.json'))) { const code = parse(file).name.toUpperCase(); try { RULE_PRESETS[code] = JSON.parse(readFileSync(join(rulesDir, file), 'utf8')); 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] = JSON.parse(readFileSync(join(overridesDir, file), 'utf8')); 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 = JSON.parse(readFileSync(baseFile, 'utf8')); } catch (err) { app.log.error(`Failed to load ${baseFile}: ${err}`); } } const defaultFile = join(tagPath, 'default.json'); if (existsSync(defaultFile)) { try { preset.default = JSON.parse(readFileSync(defaultFile, 'utf8')); } 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] = JSON.parse(readFileSync(join(tagPath, file), 'utf8')); } 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 { subscriptionId } = req.params; const { tags } = req.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 = ''; try { iso = (await ipLookup(ip))?.country?.toUpperCase() || ''; } catch (err) { req.log.warn(`GeoIP failed for ${ip}: ${err}`); } if (iso) USERS_COUNTRY_CACHE.set(subscriptionId, iso); if (!iso && USERS_COUNTRY_CACHE.has(subscriptionId)) iso = USERS_COUNTRY_CACHE.get(subscriptionId) || ''; const isEU = iso === 'EU'; 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 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', }); } /** * 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, ...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, }, }; reply.send(JSON.stringify(merged, null, 2)); }); app.setNotFoundHandler((_, reply) => reply.code(204).send()); return app; }