Initial commit
This commit is contained in:
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# ---------- Build Stage ----------
|
||||
FROM node:25-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
# ---------- Production Stage ----------
|
||||
FROM node:25-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
proxy:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
HTTP_PROXY: http://localhost:3080
|
||||
NODE_ENV: production
|
||||
1649
package-lock.json
generated
Normal file
1649
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "proxy",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"nodemon": "^3.1.11",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
14
src/index.ts
Normal file
14
src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import express from "express";
|
||||
import router from "./router";
|
||||
import { env } from "./utils/env";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", true);
|
||||
|
||||
// ⚠ Do NOT use express.json() globally for streaming proxy
|
||||
app.use(router);
|
||||
|
||||
app.listen(parseInt(env.PORT), () => {
|
||||
console.log(`Server running on port ${env.PORT}`);
|
||||
});
|
||||
35
src/proxy/axiosConfig.ts
Normal file
35
src/proxy/axiosConfig.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AxiosRequestConfig } from "axios";
|
||||
import { env } from "../utils/env";
|
||||
import { URL } from "url";
|
||||
|
||||
function buildProxyConfig(proxyUrl: string) {
|
||||
const parsed = new URL(proxyUrl);
|
||||
|
||||
return {
|
||||
protocol: parsed.protocol.replace(":", ""),
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? parseInt(parsed.port) : 80
|
||||
};
|
||||
}
|
||||
|
||||
export function createAxiosConfig(
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
data?: any
|
||||
): AxiosRequestConfig {
|
||||
const config: AxiosRequestConfig = {
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
responseType: "stream",
|
||||
validateStatus: () => true
|
||||
};
|
||||
|
||||
if (env.HTTP_PROXY) {
|
||||
config.proxy = buildProxyConfig(env.HTTP_PROXY);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
19
src/proxy/clientIp.ts
Normal file
19
src/proxy/clientIp.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Request } from "express";
|
||||
|
||||
function normalize(ip: string): string {
|
||||
if (ip.startsWith("::ffff:")) return ip.replace("::ffff:", "");
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function getClientIp(req: Request): string | null {
|
||||
if (req.ip) return normalize(req.ip);
|
||||
|
||||
const xff = req.headers["x-forwarded-for"];
|
||||
if (typeof xff === "string") {
|
||||
// @ts-ignore
|
||||
return normalize(xff.split(",")[0].trim());
|
||||
}
|
||||
|
||||
const remote = req.socket?.remoteAddress;
|
||||
return remote ? normalize(remote) : null;
|
||||
}
|
||||
59
src/proxy/handler.ts
Normal file
59
src/proxy/handler.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Request, Response } from "express";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { getClientIp } from "./clientIp";
|
||||
import { isIpAllowed } from "./ipWhitelist";
|
||||
import { filterRequestHeaders } from "./headerFilter";
|
||||
import { createAxiosConfig } from "./axiosConfig";
|
||||
|
||||
export async function proxyHandler(req: Request, res: Response) {
|
||||
const clientIp = getClientIp(req);
|
||||
|
||||
if (!clientIp || !isIpAllowed(clientIp)) {
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const target = req.query.q;
|
||||
if (!target || typeof target !== "string") {
|
||||
return res.status(400).json({ error: "Query param 'q' required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(target);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = filterRequestHeaders(req.headers);
|
||||
|
||||
const axiosConfig = createAxiosConfig(
|
||||
req.method,
|
||||
target,
|
||||
headers,
|
||||
["GET", "HEAD"].includes(req.method) ? undefined : req
|
||||
);
|
||||
|
||||
const response = await axios(axiosConfig);
|
||||
|
||||
res.status(response.status);
|
||||
|
||||
// Forward response headers except hop-by-hop
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (
|
||||
value &&
|
||||
!["transfer-encoding", "connection"].includes(key.toLowerCase())
|
||||
) {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
response.data.pipe(res);
|
||||
} catch (error: any) {
|
||||
// console.error(error.message);
|
||||
console.error((error as AxiosError).toJSON());
|
||||
res.status(500).json({ error: "Proxy request failed" });
|
||||
}
|
||||
}
|
||||
35
src/proxy/headerFilter.ts
Normal file
35
src/proxy/headerFilter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IncomingHttpHeaders } from "http";
|
||||
|
||||
const hopByHop = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade"
|
||||
]);
|
||||
|
||||
export function filterRequestHeaders(
|
||||
headers: IncomingHttpHeaders
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (!key) continue;
|
||||
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if (hopByHop.has(lower)) continue;
|
||||
if (lower.startsWith("x-")) continue;
|
||||
|
||||
if (typeof value === "string") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
1
src/proxy/index.ts
Normal file
1
src/proxy/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { proxyHandler } from "./handler";
|
||||
19
src/proxy/ipWhitelist.ts
Normal file
19
src/proxy/ipWhitelist.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import ipaddr from "ipaddr.js";
|
||||
import { whitelistIps } from "../utils/env";
|
||||
|
||||
export function isIpAllowed(clientIp: string): boolean {
|
||||
try {
|
||||
const parsedIp = ipaddr.parse(clientIp);
|
||||
|
||||
return whitelistIps.some(entry => {
|
||||
if (entry.includes("/")) {
|
||||
const [range, prefix] = entry.split("/") as [ string, string ];
|
||||
const subnet = ipaddr.parse(range);
|
||||
return parsedIp.match(subnet, parseInt(prefix));
|
||||
}
|
||||
return parsedIp.toString() === entry;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
8
src/router.ts
Normal file
8
src/router.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from "express";
|
||||
import { proxyHandler } from "./proxy";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.all("/proxy", proxyHandler);
|
||||
|
||||
export default router;
|
||||
21
src/utils/env.ts
Normal file
21
src/utils/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
|
||||
const EnvSchema = z.object({
|
||||
PORT: z.string().default("3000"),
|
||||
HTTP_PROXY: z.string().optional(),
|
||||
WHITELIST_IPS: z.string().default("0.0.0.0/0")
|
||||
});
|
||||
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
// console.error(parsed.error.format());
|
||||
console.log(z.treeifyError(parsed.error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export const whitelistIps = env.WHITELIST_IPS.split(",").map(v => v.trim());
|
||||
44
tsconfig.json
Normal file
44
tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// File Layout
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"types": [],
|
||||
// For nodejs:
|
||||
// "lib": ["esnext"],
|
||||
// "types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"verbatimModuleSyntax": false,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user