forked from jason5ng32/MyIP
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackend-server.js
More file actions
321 lines (289 loc) · 13.9 KB
/
Copy pathbackend-server.js
File metadata and controls
321 lines (289 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import dotenv from 'dotenv';
import express from 'express';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { slowDown } from 'express-slow-down'
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import logger from './common/logger.js';
import { requireReferer, requireValidIP, requireValidPrefix, requireValidASN, requireValidProviderId, requireValidReportId } from './common/guards.js';
// Backend APIs
import mapHandler from './api/google-map.js';
// IP Info
import ipinfoHandler from './api/ipinfo-io.js';
import ipapicomHandler from './api/ipapi-com.js';
import ipCheckingHandler from './api/ipcheck-ing.js';
import ipapiisHandler from './api/ipapi-is.js';
import ip2locationHandler from './api/ip2location-io.js';
import ipsbHandler from './api/ip-sb.js';
import maxmindHandler from './api/maxmind.js';
// Others
import cfHander from './api/cf-radar.js';
import asnHistoryHandler from './api/asn-history.js';
import asnConnectivityHandler from './api/asn-connectivity.js';
import dnsResolver from './api/dns-resolver.js';
import serviceStatusHandler, {
detailHandler as serviceStatusDetailHandler,
} from './api/service-status.js';
import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js';
import getWhois from './api/get-whois.js';
import sentryTunnelHandler from './api/sentry-tunnel.js';
import createReportHandler, { getReport as getReportHandler } from './api/share-report.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
import updateUserAchievement from './api/update-user-achievement.js';
import { reloadMaxMindDatabases, startMaxMindFileWatcher } from './common/maxmind-service.js';
import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js';
import { startCaidaAutoUpdate, bootstrapCaidaIfMissing } from './common/caida-updater.js';
import { bootstrapServiceStatus, startServiceStatusPolling } from './common/service-status-store.js';
dotenv.config({ quiet: true });
const app = express();
const backEndPort = parseInt(process.env.BACKEND_PORT || 11966, 10);
// Local rate-limit ledger file — opt-in: empty means no file is written.
// The logger.warn in the limiter handler always fires regardless (and flows
// to Sentry Logs when a backend DSN is configured), so the file only adds a
// permanent on-disk record for deployments that want one (e.g. Sentry-less
// self-hosts, or feeding a firewall script).
const blackListIPLogFilePath = process.env.SECURITY_BLACKLIST_LOG_FILE_PATH || '';
const rateLimitSet = parseInt(process.env.SECURITY_RATE_LIMIT || 0, 10);
const speedLimitSet = parseInt(process.env.SECURITY_DELAY_AFTER || 0, 10);
app.set('trust proxy', 1);
// HTTP request logging on /api/* — off by default to keep pm2 logs lean.
// Set LOG_HTTP=true in .env to enable. Mounted before the rate limiter
// so 429s are also logged when enabled.
if (process.env.LOG_HTTP === 'true') {
app.use('/api', pinoHttp({
logger,
customLogLevel: (req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
customSuccessMessage: (req, res) => `${req.method} ${req.url} → ${res.statusCode}`,
customErrorMessage: (req, res, err) => `${req.method} ${req.url} → ${res.statusCode}: ${err.message}`,
serializers: {
req: (req) => ({ method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
}));
logger.info('📝 HTTP request logging enabled (LOG_HTTP=true)');
}
function getClientIp(req) {
const cfIp = req.headers['cf-connecting-ip'];
const forwardedIps = req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0] : null;
const cfIpV6 = req.headers['cf-connecting-ipv6'];
return cfIp || forwardedIps || cfIpV6 || req.ip;
}
// Host-local time with an explicit UTC offset ("2026-07-14 10:23:45 +0800"),
// matching the pretty-log timestamp style — ledger entries stay unambiguous
// whatever timezone the deployment runs in.
const formatDate = (timestamp) => {
const d = new Date(timestamp);
const pad = (n) => String(n).padStart(2, '0');
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? '+' : '-';
const abs = Math.abs(offsetMin);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} `
+ `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`;
};
// Append-or-update one line in the rate-limit log, keeping the original
// timestamp on repeat offenders so we can see when an IP *first* showed up.
function logLimitedIP(ip) {
const logPath = path.join(__dirname, blackListIPLogFilePath);
const logDir = path.dirname(logPath);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
logger.info({ logDir }, 'Created log directory');
}
fs.readFile(logPath, 'utf8', (err, data) => {
if (err && err.code !== 'ENOENT') {
logger.error({ err }, 'Error reading the log file');
return;
}
const now = Date.now();
let newCount = 1;
let logExists = false;
let updatedData = '';
if (data) {
const lines = data.split('\n');
updatedData = lines.map(line => {
const [currentIp, count, timestamp] = line.split(',');
if (currentIp === ip) {
newCount = parseInt(count, 10) + 1;
logExists = true;
return `${ip},${newCount},${timestamp}`;
}
return line;
}).join('\n');
}
if (!logExists) {
const newLine = `${ip},${newCount},${formatDate(now)}`;
updatedData += (updatedData ? '\n' : '') + newLine;
}
fs.writeFile(logPath, updatedData, 'utf8', err => {
if (err) {
logger.error({ err }, 'Failed to write to log file');
}
});
});
}
const rateLimiter = rateLimit({
windowMs: 20 * 60 * 1000,
max: rateLimitSet,
message: 'Too Many Requests',
// The Sentry tunnel is exempted — it has its own limiter at the route.
// Telemetry sharing the app quota is how reporting silently dies: one
// 429 and the browser SDK drops every event for the next minute.
skip: (req) => req.path === '/monitoring',
handler: (req, res, next) => {
const ip = getClientIp(req);
// Log on the exact transition into rate-limited state — not every
// blocked request — to avoid log flooding when an abusive client
// keeps hammering after being limited. The warn line is the primary
// record (mirrored to Sentry Logs when configured); the on-disk
// ledger is the opt-in extra.
if (req.rateLimit.current === req.rateLimit.limit + 1) {
logger.warn({ ip }, 'IP rate-limited');
if (blackListIPLogFilePath) {
logLimitedIP(ip);
}
}
res.status(429).json({ message: 'Too Many Requests' });
}
});
const speedLimiter = slowDown({
windowMs: 60 * 60 * 1000,
delayAfter: speedLimitSet,
delayMs: (hits) => hits * 400,
skip: (req) => req.path === '/monitoring',
})
if (rateLimitSet !== 0) {
app.use('/api', rateLimiter);
logger.info(`🛡️ Rate limiter enabled — ${rateLimitSet} requests per 60 minutes`);
}
if (speedLimitSet !== 0) {
app.use('/api', speedLimiter);
logger.info(`🐢 Speed limiter enabled — slow down after ${speedLimitSet} requests`);
}
// 500kb instead of the 100kb default: shared diagnostic reports (POST
// /api/report) legitimately reach ~100KB; the report handler enforces its
// own tighter cap (REPORT_MAX_BYTES) after schema validation.
// Must stay above REPORT_MAX_BYTES (common/report-schema.js) or report
// uploads die here with a raw 413 before the handler's own size check.
app.use(express.json({ limit: '500kb' }));
// Default every /api/* response to no-store. Routes that want edge caching
// declare it explicitly via the `cacheable(maxAge)` middleware below.
app.use('/api', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
// Cache-Control middleware factory. Hooks res.json so the header is only
// attached on 2xx — CF must not cache 4xx/5xx error pages. The intended cache
// value is also stashed on res.locals.cacheControl so binary-stream handlers
// (which bypass res.json) can apply it themselves on their own 2xx path.
const cacheable = (maxAgeSeconds) => (req, res, next) => {
res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`;
const originalJson = res.json.bind(res);
res.json = function (body) {
if (res.statusCode < 400) {
res.setHeader('Cache-Control', res.locals.cacheControl);
}
return originalJson(body);
};
next();
};
// Global referer gate for all /api/* routes. Handlers no longer repeat this
// check individually — see common/guards.js.
app.use('/api', requireReferer);
const FIVE_MIN_CACHE = 5 * 60;
const ONE_DAY_CACHE = 24 * 60 * 60;
const THIRTY_DAYS_CACHE = 30 * 24 * 60 * 60;
const ONE_YEAR_CACHE = 365 * 24 * 60 * 60;
// Cacheable routes — TTLs picked against each upstream's natural refresh cadence.
// Short Cache
app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);
// Cache for 1 day
app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_DAY_CACHE), ipinfoHandler);
app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_DAY_CACHE), ipapicomHandler);
app.get('/api/ipsb', requireValidIP(), cacheable(ONE_DAY_CACHE), ipsbHandler);
app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_DAY_CACHE), ipapiisHandler);
app.get('/api/ip2location', requireValidIP(), cacheable(ONE_DAY_CACHE), ip2locationHandler);
app.get('/api/maxmind', requireValidIP(), cacheable(ONE_DAY_CACHE), maxmindHandler);
app.get('/api/whois', cacheable(ONE_DAY_CACHE), getWhois);
app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler);
// Cache for 30 days — registry / historical data that changes on a monthly
// (or slower) cadence: IEEE OUI assignments, ASN metadata, ASN interconnection,
// and append-only BGP routing history.
app.get('/api/cfradar', cacheable(THIRTY_DAYS_CACHE), cfHander);
app.get('/api/asn-history', requireValidPrefix(), cacheable(THIRTY_DAYS_CACHE), asnHistoryHandler);
app.get('/api/asn-connectivity', requireValidASN(), cacheable(THIRTY_DAYS_CACHE), asnConnectivityHandler);
app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker);
// Long Cache
app.get('/api/map', cacheable(ONE_YEAR_CACHE), mapHandler);
// Non-cacheable routes — auth-context, debug tools, or per-request lookups.
app.get('/api/ipchecking', requireValidIP(), ipCheckingHandler);
app.get('/api/dnsresolver', dnsResolver);
app.get('/api/dnsleaktest/session/:token', dnsLeakGetResult);
app.get('/api/invisibility', invisibilitytestHandler);
app.get('/api/getuserinfo', getUserinfo);
app.put('/api/updateuserachievement', updateUserAchievement);
app.get('/api/configs', validateConfigs);
// Shared reports stay no-store: an edge cache could serve a report past its
// KV expiry, and private diagnostic data doesn't belong in a public cache.
app.get('/api/report/:id', requireValidReportId(), getReportHandler);
app.post('/api/report', createReportHandler);
// Sentry tunnel — first-party relay for the frontend SDK's envelopes
// Mounted only when this deployment actually built the frontend with a DSN.
//
// `type` must be a catch-all FUNCTION: Replay envelopes are binary
// (deflate-compressed recording) and fetch sends those with NO Content-Type
// header at all — a '*/*' string matcher skips such requests and req.body
// would never be populated.
if (process.env.VITE_SENTRY_DSN_FRONTEND) {
// Dedicated, more generous per-IP limiter
const monitoringLimiter = rateLimit({
windowMs: 20 * 60 * 1000,
max: 600,
message: 'Too Many Requests',
});
app.post('/api/monitoring', monitoringLimiter, express.raw({ type: () => true, limit: '10mb' }), sentryTunnelHandler);
}
// Set static file server
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, './dist')));
// Sentry error capture — the SDK itself is initialized (or not) by
// sentry-instrument.js via `node --import`; this attaches the Express error
// handler only when a DSN made that init happen. Must come after every route.
if (process.env.SENTRY_DSN_BACKEND) {
const Sentry = await import('@sentry/node');
Sentry.setupExpressErrorHandler(app);
logger.info('🛰️ Sentry backend monitoring enabled');
}
// Bootstrap every offline dataset (MaxMind, CAIDA) before accepting traffic
// so we never serve mid-download. Each step is non-fatal: a failure leaves
// the dependent API in a degraded state (MaxMind → 503; CAIDA → empty graph
// or RIPEstat fallback) but doesn't block the listener.
async function bootBackend() {
await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases });
await reloadMaxMindDatabases('startup').catch(() => {
logger.error('❌ MaxMind API will return 503 until databases are loaded successfully');
});
await bootstrapCaidaIfMissing();
await bootstrapServiceStatus();
startMaxMindFileWatcher();
startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases });
startCaidaAutoUpdate();
startServiceStatusPolling();
app.listen(backEndPort, () => {
logger.info(`🚀 Backend server ready on http://localhost:${backEndPort}`);
});
}
bootBackend();