Initial commit

This commit is contained in:
Alexander
2026-02-14 20:36:34 +03:00
commit bc7441db20
15 changed files with 2107 additions and 0 deletions

144
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
export { proxyHandler } from "./handler";

19
src/proxy/ipWhitelist.ts Normal file
View 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
View 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
View 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
View 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,
}
}