Compare commits

..

No commits in common. "master" and "v1.0.16" have entirely different histories.

74 changed files with 2149 additions and 29286 deletions

View File

@ -1,66 +0,0 @@
name: Default (not tags)
on:
push:
tags-ignore:
- '**'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
- name: Run npm prepare
run: npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build

View File

@ -1,124 +0,0 @@
name: Default (tags)
on:
push:
tags:
- '*'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Release
run: |
npmci node install stable
npmci npm publish
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @ship.zone/npmci
npmci npm prepare
- name: Code quality
run: |
npmci command npm install -g typescript
npmci npm install
- name: Trigger
run: npmci trigger
- name: Build docs and upload artifacts
run: |
npmci node install stable
npmci npm install
pnpm install -g @git.zone/tsdoc
npmci command tsdoc
continue-on-error: true

8
.gitignore vendored
View File

@ -3,6 +3,7 @@
# artifacts
coverage/
public/
pages/
# installs
node_modules/
@ -14,7 +15,8 @@ node_modules/
# builds
dist/
dist_*/
dist_web/
dist_serve/
dist_ts_web/
#------# custom
.claude/*
# custom

119
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,119 @@
# gitzone ci_default
image: registry.gitlab.com/hosttoday/ht-docker-node:npmci
cache:
paths:
- .npmci_cache/
key: "$CI_BUILD_STAGE"
stages:
- security
- test
- release
- metadata
# ====================
# security stage
# ====================
mirror:
stage: security
script:
- npmci git mirror
tags:
- docker
- notpriv
snyk:
stage: security
script:
- npmci npm prepare
- npmci command npm install -g snyk
- npmci command npm install --ignore-scripts
- npmci command snyk test
tags:
- docker
- notpriv
# ====================
# test stage
# ====================
testLTS:
stage: test
script:
- npmci npm prepare
- npmci node install lts
- npmci npm install
- npmci npm test
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
- priv
testBuild:
stage: test
script:
- npmci npm prepare
- npmci node install lts
- npmci npm install
- npmci command npm run build
coverage: /\d+.?\d+?\%\s*coverage/
tags:
- docker
- notpriv
release:
stage: release
script:
- npmci node install lts
- npmci npm publish
only:
- tags
tags:
- docker
- notpriv
# ====================
# metadata stage
# ====================
codequality:
stage: metadata
allow_failure: true
script:
- npmci command npm install -g tslint typescript
- npmci npm install
- npmci command "tslint -c tslint.json ./ts/**/*.ts"
tags:
- docker
- priv
trigger:
stage: metadata
script:
- npmci trigger
only:
- tags
tags:
- docker
- notpriv
pages:
image: hosttoday/ht-docker-dbase:npmci
services:
- docker:18-dind
stage: metadata
script:
- npmci command npm install -g @gitzone/tsdoc
- npmci npm prepare
- npmci npm install
- npmci command tsdoc
tags:
- docker
- notpriv
only:
- tags
artifacts:
expire_in: 1 week
paths:
- public
allow_failure: true

24
.vscode/launch.json vendored
View File

@ -2,10 +2,28 @@
"version": "0.2.0",
"configurations": [
{
"command": "npm test",
"name": "Run npm test",
"name": "current file",
"type": "node",
"request": "launch",
"type": "node-terminal"
"args": [
"${relativeFile}"
],
"runtimeArgs": ["-r", "@gitzone/tsrun"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
},
{
"name": "test.ts",
"type": "node",
"request": "launch",
"args": [
"test/test.ts"
],
"runtimeArgs": ["-r", "@gitzone/tsrun"],
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart"
}
]
}

26
.vscode/settings.json vendored
View File

@ -1,26 +0,0 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"schema": {
"type": "object",
"properties": {
"npmci": {
"type": "object",
"description": "settings for npmci"
},
"gitzone": {
"type": "object",
"description": "settings for gitzone",
"properties": {
"projectType": {
"type": "string",
"enum": ["website", "element", "service", "npm", "wcc"]
}
}
}
}
}
}
]
}

View File

@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIUPU4tviz3ZvsMDjCz1NZRT16b0Y4wDQYJKoZIhvcNAQEL
BQAwFTETMBEGA1UEAwwKcHVzaC5yb2NrczAeFw0yNTAyMDMyMzA5MzRaFw0yNjAy
MDMyMzA5MzRaMBUxEzARBgNVBAMMCnB1c2gucm9ja3MwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQCZMkBYD/pYLBv9MiyHTLRT24kQyPeJBtZqryibi1jk
BT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ4wpvLHu7xzREqwT9N9WcDcxaahUi
P8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UWZHMEs6eY/Y7i8m4+0NwP5h8RNBCF
KSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL7X2kQb+gS6Rvq5sEGLLKMC5QtTwI
rdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCtpze08jMzoHLG9Nv97cJQjb/BhiES
hLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6IadGQLXVXAgMBAAGjUzBRMB0GA1Ud
DgQWBBQjpowWjrql/Eo2EVjl29xcjuCgkTAfBgNVHSMEGDAWgBQjpowWjrql/Eo2
EVjl29xcjuCgkTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAY
44vqbaf6ewFrZC0f3Kk4A10lC6qjWkcDFfw+JE8nzt+4+xPqp1eWgZKF2rONyAv2
nG41Xygt19ByancXLU44KB24LX8F1GV5Oo7CGBA+xtoSPc0JulXw9fGclZDC6XiR
P/+vhGgCHicbfP2O+N00pOifrTtf2tmOT4iPXRRo4TxmPzuCd+ZJTlBhPlKCmICq
yGdAiEo6HsSiP+M5qVlNx8s57MhQYk5TpgmI6FU4mO7zfDfSatFonlg+aDbrnaqJ
v/+km02M+oB460GmKwsSTnThHZgLNCLiKqD8bdziiCQjx5u0GjLI6468o+Aehb8l
l/x9vWTTk/QKq41X5hFk
-----END CERTIFICATE-----

View File

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCZMkBYD/pYLBv9
MiyHTLRT24kQyPeJBtZqryibi1jkBT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ
4wpvLHu7xzREqwT9N9WcDcxaahUiP8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UW
ZHMEs6eY/Y7i8m4+0NwP5h8RNBCFKSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL
7X2kQb+gS6Rvq5sEGLLKMC5QtTwIrdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCt
pze08jMzoHLG9Nv97cJQjb/BhiEShLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6I
adGQLXVXAgMBAAECggEARGCBBq1PBHbfoUH5TQSIAlvdEEBa9+602lZG7jIioVfT
W7Uem5Ctuan+kcDcY9hbNsqqZ+9KgsvoJmlIGXoF2jjeE/4vUmRO9AHWoc5yk2Be
4NjcxN3QMLdEfiLBnLlFCOd4CdX1ZxZ6TG3WRpV3a1pVIeeqHGB1sKT6Xd/atcwG
RvpiXzu0SutGxVb6WE9r6hovZ4fVERCyCRczUGrUH5ICbxf6E7L4u8xjEYR4uEKK
/8ZkDqrWdRASDAdPPMNqnHUEAho/WnxpNeb6B4lvvv2QWxIS9H1OikF/NzWPgVNS
oPpvtJgjyo5xdgLm3zE4lcSPNVSrh1TBXuAn9TG4WQKBgQDScPFkUNBqjC5iPMof
bqDHlhlptrHmiv9LC0lgjEDPgIEQfjLfdCugwDk32QyAcb5B60upDYeqCFDkfV/C
T536qxevYPjPAjahLPHqMxkWpjvtY6NOTgbbcpVtblU2Fj8R8qbyPNADG31LicU9
GVPtQ4YcVaMWCYbg5107+9dFWQKBgQC6XK+foKK+81RFdrqaNNgebTWTsANnBcZe
xl0bj6oL5yY0IzroxHvgcNS7UMriWCu+K2xfkUBdMmxU773VN5JQ5k15ezjgtrvc
8oAaEsxYP4su12JSTC/zsBANUgrNbFj8++qqKYWt2aQc2O/kbZ4MNfekIVFc8AjM
2X9PxvxKLwKBgHXL7QO3TQLnVyt8VbQEjBFMzwriznB7i+4o8jkOKVU93IEr8zQr
5iQElcLSR3I6uUJTALYvsaoXH5jXKVwujwL69LYiNQRDe+r6qqvrUHbiNJdsd8Rk
XuhGGqj34tD04Pcd+h+MtO+YWqmHBBZwcA9XBeIkebbjPFH2kLT8AwN5AoGAYQy9
hMJxnkE3hIkk+gNE/OtgeE20J+Vw/ZANkrnJEzPHyGUEW41e+W2oyvdzAFZsSTdx
037f5ujIU58Z27x53NliRT4vS4693H0Iyws5EUfeIoGVuUflvODWKymraHjhCrXh
6cV/0R5DAabTnsCbCr7b/MRBC8YQvyUQ0KnOXo8CgYBQYGpvJnSWyvsCjtb6apTP
drjcBhVd0aSBpLGtDdtUCV4oLl9HPy+cLzcGaqckBqCwEq5DKruhMEf7on56bUMd
m/3ItFk1TnhysAeJHb3zLqmJ9CKBitpqLlsOE7MEXVNmbTYeXU10Uo9yOfyt1i7T
su+nT5VtyPkmF/l4wZl5+g==
-----END PRIVATE KEY-----

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,17 @@
{
"gitzone": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"githost": "gitlab.com",
"gitscope": "pushrocks",
"gitrepo": "smartproxy",
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, and dynamic routing with authentication options.",
"npmPackagename": "@push.rocks/smartproxy",
"shortDescription": "a proxy for handling high workloads of proxying",
"npmPackagename": "@pushrocks/smartproxy",
"license": "MIT",
"projectDomain": "push.rocks",
"keywords": [
"proxy",
"network",
"traffic management",
"SSL",
"TLS",
"WebSocket",
"port proxying",
"dynamic routing",
"authentication",
"real-time applications",
"high workload",
"HTTPS",
"reverse proxy",
"server",
"network security"
]
"projectDomain": "push.rocks"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
}
}

1779
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,90 +1,41 @@
{
"name": "@push.rocks/smartproxy",
"version": "12.0.0",
"name": "@pushrocks/smartproxy",
"version": "1.0.16",
"private": false,
"description": "A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"description": "a proxy for handling high workloads of proxying",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"author": "Lossless GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"build": "(tsbuild tsfolders --allowimplicitany)",
"format": "(gitzone format)",
"buildDocs": "tsdoc"
"build": "(tsbuild)",
"format": "(gitzone format)"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.3.2",
"@git.zone/tsrun": "^1.2.44",
"@git.zone/tstest": "^1.0.77",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^22.15.3",
"typescript": "^5.8.3"
"@gitzone/tsbuild": "^2.0.22",
"@gitzone/tstest": "^1.0.15",
"@pushrocks/tapbundle": "^3.0.7",
"@types/node": "^12.7.2",
"tslint": "^5.19.0",
"tslint-config-prettier": "^1.15.0"
},
"dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartacme": "^7.3.2",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartnetwork": "^4.0.1",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0",
"@push.rocks/smartstring": "^4.0.15",
"@push.rocks/taskbuffer": "^3.1.7",
"@tsclass/tsclass": "^9.2.0",
"@types/minimatch": "^5.1.2",
"@types/ws": "^8.18.1",
"minimatch": "^10.0.1",
"pretty-ms": "^9.2.0",
"ws": "^8.18.2"
"@pushrocks/smartpromise": "^3.0.2",
"@pushrocks/smartrequest": "^1.1.20",
"@tsclass/tsclass": "^2.0.3",
"@types/ws": "^6.0.2",
"ws": "^7.1.2"
},
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"ts/*",
"ts_web/*",
"dist/*",
"dist_web/*",
"dist_ts_web/*",
"assets/*",
"cli.js",
"npmextra.json",
"readme.md"
],
"browserslist": [
"last 1 chrome versions"
],
"keywords": [
"proxy",
"network",
"traffic management",
"SSL",
"TLS",
"WebSocket",
"port proxying",
"dynamic routing",
"authentication",
"real-time applications",
"high workload",
"HTTPS",
"reverse proxy",
"server",
"network security"
],
"homepage": "https://code.foss.global/push.rocks/smartproxy#readme",
"repository": {
"type": "git",
"url": "https://code.foss.global/push.rocks/smartproxy.git"
},
"bugs": {
"url": "https://code.foss.global/push.rocks/smartproxy/issues"
},
"pnpm": {
"overrides": {},
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
]
}

11363
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,64 +0,0 @@
# SmartProxy Project Hints
## Project Overview
- Package: `@push.rocks/smartproxy` high-performance proxy supporting HTTP(S), TCP, WebSocket, and ACME integration.
- Written in TypeScript, compiled output in `dist_ts/`, uses ESM with NodeNext resolution.
## Repository Structure
- `ts/` TypeScript source files:
- `index.ts` exports main modules.
- `plugins.ts` centralizes native and third-party imports.
- Subdirectories: `networkproxy/`, `nftablesproxy/`, `port80handler/`, `redirect/`, `smartproxy/`.
- Key classes: `ProxyRouter` (`classes.router.ts`), `SmartProxy` (`classes.smartproxy.ts`), plus handlers/managers.
- `dist_ts/` transpiled `.js` and `.d.ts` files mirroring `ts/` structure.
- `test/` test suites in TypeScript:
- `test.router.ts` routing logic (hostname matching, wildcards, path parameters, config management).
- `test.smartproxy.ts` proxy behavior tests (TCP forwarding, SNI handling, concurrency, chaining, timeouts).
- `test/helpers/` utilities (e.g., certificates).
- `assets/certs/` placeholder certificates for ACME and TLS.
## Development Setup
- Requires `pnpm` (v10+).
- Install dependencies: `pnpm install`.
- Build: `pnpm build` (runs `tsbuild --web --allowimplicitany`).
- Test: `pnpm test` (runs `tstest test/`).
- Format: `pnpm format` (runs `gitzone format`).
## Testing Framework
- Uses `@push.rocks/tapbundle` (`tap`, `expect`, `expactAsync`).
- Test files: must start with `test.` and use `.ts` extension.
- Run specific tests via `tsx`, e.g., `tsx test/test.router.ts`.
## Coding Conventions
- Import modules via `plugins.ts`:
```ts
import * as plugins from './plugins.ts';
const server = new plugins.http.Server();
```
- Reference plugins with full path: `plugins.acme`, `plugins.smartdelay`, `plugins.minimatch`, etc.
- Path patterns support globs (`*`) and parameters (`:param`) in `ProxyRouter`.
- Wildcard hostname matching leverages `minimatch` patterns.
## Key Components
- **ProxyRouter**
- Methods: `routeReq`, `routeReqWithDetails`.
- Hostname matching: case-insensitive, strips port, supports exact, wildcard, TLD, complex patterns.
- Path routing: exact, wildcard, parameter extraction (`pathParams`), returns `pathMatch` and `pathRemainder`.
- Config API: `setNewProxyConfigs`, `addProxyConfig`, `removeProxyConfig`, `getHostnames`, `getProxyConfigs`.
- **SmartProxy**
- Manages one or more `net.Server` instances to forward TCP streams.
- Options: `preserveSourceIP`, `defaultAllowedIPs`, `globalPortRanges`, `sniEnabled`.
- DomainConfigManager: round-robin selection for multiple target IPs.
- Graceful shutdown in `stop()`, ensures no lingering servers or sockets.
## Notable Points
- **TSConfig**: `module: NodeNext`, `verbatimModuleSyntax`, allows `.js` extension imports in TS.
- Mermaid diagrams and architecture flows in `readme.md` illustrate component interactions and protocol flows.
- CLI entrypoint (`cli.js`) supports command-line usage (ACME, proxy controls).
- ACME and certificate handling via `Port80Handler` and `helpers.certificates.ts`.
## TODOs / Considerations
- Ensure import extensions in source match build outputs (`.ts` vs `.js`).
- Update `plugins.ts` when adding new dependencies.
- Maintain test coverage for new routing or proxy features.
- Keep `ts/` and `dist_ts/` in sync after refactors.

640
readme.md
View File

@ -1,626 +1,26 @@
# @push.rocks/smartproxy
# @pushrocks/smartproxy
a proxy for handling high workloads of proxying
A high-performance proxy toolkit for Node.js, offering:
- HTTP/HTTPS reverse proxy with TLS termination and WebSocket support
- Automatic ACME certificate management (HTTP-01)
- Low-level port forwarding via nftables
- HTTP-to-HTTPS and custom URL redirects
- Advanced TCP/SNI-based proxying with IP filtering and rules
- Unified forwarding configuration system for all proxy types
## Availabililty and Links
* [npmjs.org (npm package)](https://www.npmjs.com/package/@pushrocks/smartproxy)
* [gitlab.com (source)](https://gitlab.com/pushrocks/smartproxy)
* [github.com (source mirror)](https://github.com/pushrocks/smartproxy)
* [docs (typedoc)](https://pushrocks.gitlab.io/smartproxy/)
## Exports
The following classes and interfaces are provided:
## Status for master
[![build status](https://gitlab.com/pushrocks/smartproxy/badges/master/build.svg)](https://gitlab.com/pushrocks/smartproxy/commits/master)
[![coverage report](https://gitlab.com/pushrocks/smartproxy/badges/master/coverage.svg)](https://gitlab.com/pushrocks/smartproxy/commits/master)
[![npm downloads per month](https://img.shields.io/npm/dm/@pushrocks/smartproxy.svg)](https://www.npmjs.com/package/@pushrocks/smartproxy)
[![Known Vulnerabilities](https://snyk.io/test/npm/@pushrocks/smartproxy/badge.svg)](https://snyk.io/test/npm/@pushrocks/smartproxy)
[![TypeScript](https://img.shields.io/badge/TypeScript->=%203.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
[![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
[![JavaScript Style Guide](https://img.shields.io/badge/code%20style-prettier-ff69b4.svg)](https://prettier.io/)
- **NetworkProxy** (ts/networkproxy/classes.np.networkproxy.ts)
HTTP/HTTPS reverse proxy with TLS termination, WebSocket support,
connection pooling, and optional ACME integration.
- **Port80Handler** (ts/port80handler/classes.port80handler.ts)
ACME HTTP-01 challenge handler and certificate manager.
- **NfTablesProxy** (ts/nfttablesproxy/classes.nftablesproxy.ts)
Low-level port forwarding using nftables NAT rules.
- **Redirect**, **SslRedirect** (ts/redirect/classes.redirect.ts)
HTTP/HTTPS redirect server and shortcut for HTTP→HTTPS.
- **SmartProxy** (ts/smartproxy/classes.smartproxy.ts)
TCP/SNI-based proxy with dynamic routing, IP filtering, and unified certificates.
- **SniHandler** (ts/smartproxy/classes.pp.snihandler.ts)
Static utilities to extract SNI hostnames from TLS handshakes.
- **Forwarding Handlers** (ts/smartproxy/forwarding/*.ts)
Unified forwarding handlers for different connection types (HTTP, HTTPS passthrough, TLS termination).
- **Interfaces**
- IPortProxySettings, IDomainConfig (ts/smartproxy/classes.pp.interfaces.ts)
- INetworkProxyOptions (ts/networkproxy/classes.np.types.ts)
- IAcmeOptions, IDomainOptions (ts/common/types.ts)
- INfTableProxySettings (ts/nfttablesproxy/classes.nftablesproxy.ts)
- IForwardConfig, ForwardingType (ts/smartproxy/types/forwarding.types.ts)
## Usage
## Installation
Install via npm:
```bash
npm install @push.rocks/smartproxy
```
For further information read the linked docs at the top of this readme.
## Quick Start
> MIT licensed | **©** [Lossless GmbH](https://lossless.gmbh)
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy)
### 1. HTTP(S) Reverse Proxy (NetworkProxy)
```typescript
import { NetworkProxy } from '@push.rocks/smartproxy';
const proxy = new NetworkProxy({ port: 443 });
await proxy.start();
await proxy.updateProxyConfigs([
{
hostName: 'example.com',
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
publicKey: fs.readFileSync('cert.pem', 'utf8'),
privateKey: fs.readFileSync('key.pem', 'utf8'),
}
]);
// Add default headers to all responses
await proxy.addDefaultHeaders({
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
});
// ...
await proxy.stop();
```
### 2. HTTP→HTTPS Redirect (Redirect / SslRedirect)
```typescript
import { Redirect, SslRedirect } from '@push.rocks/smartproxy';
import * as fs from 'fs';
// Custom redirect rules
const redirect = new Redirect({
httpPort: 80,
httpsPort: 443,
sslOptions: {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem'),
},
rules: [
{
fromProtocol: 'http',
fromHost: '*',
toProtocol: 'https',
toHost: '$1',
statusCode: 301
}
]
});
await redirect.start();
// Quick HTTP→HTTPS helper on port 80
const quick = new SslRedirect(80);
await quick.start();
```
### 3. Automatic Certificates (ACME Port80Handler)
```typescript
import { Port80Handler } from '@push.rocks/smartproxy';
// Configure ACME on port 80 with contact email
const acme = new Port80Handler({
port: 80,
contactEmail: 'admin@example.com',
useProduction: true,
renewThresholdDays: 30
});
acme.on('certificate-issued', evt => {
console.log(`Certificate ready for ${evt.domain}, expires ${evt.expiryDate}`);
});
await acme.start();
acme.addDomain({
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true
});
```
### 4. Low-Level Port Forwarding (NfTablesProxy)
```typescript
import { NfTablesProxy } from '@push.rocks/smartproxy';
// Forward port 80→8080 with source IP preservation
const nft = new NfTablesProxy({
fromPort: 80,
toPort: 8080,
toHost: 'localhost',
preserveSourceIP: true,
deleteOnExit: true
});
await nft.start();
// ...
await nft.stop();
```
### 5. TCP/SNI Proxy (SmartProxy)
```typescript
import { SmartProxy } from '@push.rocks/smartproxy';
import { createDomainConfig, httpOnly, tlsTerminateToHttp, httpsPassthrough } from '@push.rocks/smartproxy';
const smart = new SmartProxy({
fromPort: 443,
toPort: 8443,
domainConfigs: [
// HTTPS passthrough example
createDomainConfig(['example.com', '*.example.com'],
httpsPassthrough({
target: {
host: '127.0.0.1',
port: 443
},
security: {
allowedIps: ['*']
}
})
),
// HTTPS termination example
createDomainConfig('secure.example.com',
tlsTerminateToHttp({
target: {
host: 'localhost',
port: 3000
},
acme: {
enabled: true,
production: true
}
})
)
],
sniEnabled: true
});
smart.on('certificate', evt => console.log(evt));
await smart.start();
// Update domains later
await smart.updateDomainConfigs([/* new configs */]);
```
### 6. SNI Utilities (SniHandler)
```js
import { SniHandler } from '@push.rocks/smartproxy';
// Extract SNI from a TLS ClientHello buffer
const sni = SniHandler.extractSNI(buffer);
// Reassemble fragmented ClientHello
const complete = SniHandler.handleFragmentedClientHello(buf, connId);
```
## API Reference
For full configuration options and type definitions, see the TypeScript interfaces in the `ts/` directory:
- `INetworkProxyOptions` (ts/networkproxy/classes.np.types.ts)
- `IAcmeOptions`, `IDomainOptions`, `IForwardConfig` (ts/common/types.ts)
- `INfTableProxySettings` (ts/nfttablesproxy/classes.nftablesproxy.ts)
- `IPortProxySettings`, `IDomainConfig` (ts/smartproxy/classes.pp.interfaces.ts)
## Architecture & Flow Diagrams
```mermaid
flowchart TB
Client([Client])
subgraph "SmartProxy Components"
direction TB
HTTP80["HTTP Port 80<br>Redirect / SslRedirect"]
HTTPS443["HTTPS Port 443<br>NetworkProxy"]
SmartProxy["SmartProxy<br>(TCP/SNI Proxy)"]
NfTables[NfTablesProxy]
Router[ProxyRouter]
ACME["Port80Handler<br>(ACME HTTP-01)"]
Certs[(SSL Certificates)]
end
subgraph "Backend Services"
Service1[Service 1]
Service2[Service 2]
Service3[Service 3]
end
Client -->|HTTP Request| HTTP80
HTTP80 -->|Redirect| Client
Client -->|HTTPS Request| HTTPS443
Client -->|TLS/TCP| SmartProxy
HTTPS443 -->|Route Request| Router
Router -->|Proxy Request| Service1
Router -->|Proxy Request| Service2
SmartProxy -->|Direct TCP| Service2
SmartProxy -->|Direct TCP| Service3
NfTables -.->|Low-level forwarding| SmartProxy
HTTP80 -.->|Challenge Response| ACME
ACME -.->|Generate/Manage| Certs
Certs -.->|Provide TLS Certs| HTTPS443
classDef component fill:#f9f,stroke:#333,stroke-width:2px;
classDef backend fill:#bbf,stroke:#333,stroke-width:1px;
classDef client fill:#dfd,stroke:#333,stroke-width:2px;
class Client client;
class HTTP80,HTTPS443,SmartProxy,NfTables,Router,ACME component;
class Service1,Service2,Service3 backend;
```
### HTTPS Reverse Proxy Flow
This diagram shows how HTTPS requests are handled and proxied to backend services:
```mermaid
sequenceDiagram
participant Client
participant NetworkProxy
participant ProxyRouter
participant Backend
Client->>NetworkProxy: HTTPS Request
Note over NetworkProxy: TLS Termination
NetworkProxy->>ProxyRouter: Route Request
ProxyRouter->>ProxyRouter: Match hostname to config
alt Authentication Required
NetworkProxy->>Client: Request Authentication
Client->>NetworkProxy: Send Credentials
NetworkProxy->>NetworkProxy: Validate Credentials
end
NetworkProxy->>Backend: Forward Request
Backend->>NetworkProxy: Response
Note over NetworkProxy: Add Default Headers
NetworkProxy->>Client: Forward Response
alt WebSocket Request
Client->>NetworkProxy: Upgrade to WebSocket
NetworkProxy->>Backend: Upgrade to WebSocket
loop WebSocket Active
Client->>NetworkProxy: WebSocket Message
NetworkProxy->>Backend: Forward Message
Backend->>NetworkProxy: WebSocket Message
NetworkProxy->>Client: Forward Message
NetworkProxy-->>NetworkProxy: Heartbeat Check
end
end
```
### SNI-based Connection Handling
This diagram illustrates how TCP connections with SNI (Server Name Indication) are processed and forwarded:
```mermaid
sequenceDiagram
participant Client
participant SmartProxy
participant Backend
Client->>SmartProxy: TLS Connection
alt SNI Enabled
SmartProxy->>Client: Accept Connection
Client->>SmartProxy: TLS ClientHello with SNI
SmartProxy->>SmartProxy: Extract SNI Hostname
SmartProxy->>SmartProxy: Match Domain Config
SmartProxy->>SmartProxy: Validate Client IP
alt IP Allowed
SmartProxy->>Backend: Forward Connection
Note over SmartProxy,Backend: Bidirectional Data Flow
else IP Rejected
SmartProxy->>Client: Close Connection
end
else Port-based Routing
SmartProxy->>SmartProxy: Match Port Range
SmartProxy->>SmartProxy: Find Domain Config
SmartProxy->>SmartProxy: Validate Client IP
alt IP Allowed
SmartProxy->>Backend: Forward Connection
Note over SmartProxy,Backend: Bidirectional Data Flow
else IP Rejected
SmartProxy->>Client: Close Connection
end
end
loop Connection Active
SmartProxy-->>SmartProxy: Monitor Activity
SmartProxy-->>SmartProxy: Check Max Lifetime
alt Inactivity or Max Lifetime Exceeded
SmartProxy->>Client: Close Connection
SmartProxy->>Backend: Close Connection
end
end
```
### Let's Encrypt Certificate Acquisition
This diagram shows how certificates are automatically acquired through the ACME protocol:
```mermaid
sequenceDiagram
participant Client
participant Port80Handler
participant ACME as Let's Encrypt ACME
participant NetworkProxy
Client->>Port80Handler: HTTP Request for domain
alt Certificate Exists
Port80Handler->>Client: Redirect to HTTPS
else No Certificate
Port80Handler->>Port80Handler: Mark domain as obtaining cert
Port80Handler->>ACME: Create account & new order
ACME->>Port80Handler: Challenge information
Port80Handler->>Port80Handler: Store challenge token & key authorization
ACME->>Port80Handler: HTTP-01 Challenge Request
Port80Handler->>ACME: Challenge Response
ACME->>ACME: Validate domain ownership
ACME->>Port80Handler: Challenge validated
Port80Handler->>Port80Handler: Generate CSR
Port80Handler->>ACME: Submit CSR
ACME->>Port80Handler: Issue Certificate
Port80Handler->>Port80Handler: Store certificate & private key
Port80Handler->>Port80Handler: Mark certificate as obtained
Note over Port80Handler,NetworkProxy: Certificate available for use
Client->>Port80Handler: Another HTTP Request
Port80Handler->>Client: Redirect to HTTPS
Client->>NetworkProxy: HTTPS Request
Note over NetworkProxy: Uses new certificate
end
```
## Features
- HTTP/HTTPS Reverse Proxy (NetworkProxy)
• TLS termination, virtual-host routing, HTTP/2 & WebSocket support, pooling & metrics
- Automatic ACME Certificates (Port80Handler)
• HTTP-01 challenge handling, certificate issuance/renewal, pluggable storage
- Low-Level Port Forwarding (NfTablesProxy)
• nftables NAT rules for ports/ranges, IPv4/IPv6, IP filtering, QoS & ipset support
- Custom Redirects (Redirect / SslRedirect)
• URL redirects with wildcard host/path, template variables & status codes
- TCP/SNI Proxy (SmartProxy)
• SNI-based routing, IP allow/block lists, port ranges, timeouts & graceful shutdown
- SNI Utilities (SniHandler)
• Robust ClientHello parsing, fragmentation & session resumption support
## Certificate Hooks & Events
Listen for certificate events via EventEmitter:
- **Port80Handler**:
- `certificate-issued`, `certificate-renewed`, `certificate-failed`
- `manager-started`, `manager-stopped`, `request-forwarded`
- **SmartProxy**:
- `certificate` (domain, publicKey, privateKey, expiryDate, source, isRenewal)
Provide a `certProvisionFunction(domain)` in SmartProxy settings to supply static certs or return `'http01'`.
## Unified Forwarding System
The SmartProxy Unified Forwarding System provides a clean, use-case driven approach to configuring different types of traffic forwarding. It replaces disparate configuration mechanisms with a unified interface.
### Forwarding Types
The system supports four primary forwarding types:
1. **HTTP-only (`http-only`)**: Forwards HTTP traffic to a backend server.
2. **HTTPS Passthrough (`https-passthrough`)**: Passes through raw TLS traffic without termination (SNI forwarding).
3. **HTTPS Termination to HTTP (`https-terminate-to-http`)**: Terminates TLS and forwards the decrypted traffic to an HTTP backend.
4. **HTTPS Termination to HTTPS (`https-terminate-to-https`)**: Terminates TLS and creates a new TLS connection to an HTTPS backend.
### Basic Configuration
Each domain is configured with a forwarding type and target:
```typescript
{
domains: ['example.com'],
forwarding: {
type: 'http-only',
target: {
host: 'localhost',
port: 3000
}
}
}
```
### Helper Functions
Helper functions are provided for common configurations:
```typescript
import { createDomainConfig, httpOnly, tlsTerminateToHttp,
tlsTerminateToHttps, httpsPassthrough } from '@push.rocks/smartproxy';
// HTTP-only
await domainManager.addDomainConfig(
createDomainConfig('example.com', httpOnly({
target: { host: 'localhost', port: 3000 }
}))
);
// HTTPS termination to HTTP
await domainManager.addDomainConfig(
createDomainConfig('secure.example.com', tlsTerminateToHttp({
target: { host: 'localhost', port: 3000 },
acme: { production: true }
}))
);
// HTTPS termination to HTTPS
await domainManager.addDomainConfig(
createDomainConfig('api.example.com', tlsTerminateToHttps({
target: { host: 'internal-api', port: 8443 },
http: { redirectToHttps: true }
}))
);
// HTTPS passthrough (SNI)
await domainManager.addDomainConfig(
createDomainConfig('passthrough.example.com', httpsPassthrough({
target: { host: '10.0.0.5', port: 443 }
}))
);
```
### Advanced Configuration
For more complex scenarios, additional options can be specified:
```typescript
{
domains: ['api.example.com'],
forwarding: {
type: 'https-terminate-to-https',
target: {
host: ['10.0.0.10', '10.0.0.11'], // Round-robin load balancing
port: 8443
},
http: {
enabled: true,
redirectToHttps: true
},
https: {
// Custom certificate instead of ACME-provisioned
customCert: {
key: '-----BEGIN PRIVATE KEY-----\n...',
cert: '-----BEGIN CERTIFICATE-----\n...'
}
},
security: {
allowedIps: ['10.0.0.*', '192.168.1.*'],
blockedIps: ['1.2.3.4'],
maxConnections: 100
},
advanced: {
timeout: 30000,
headers: {
'X-Forwarded-For': '{clientIp}',
'X-Original-Host': '{sni}'
}
}
}
}
```
### Extended Configuration Options
#### IForwardConfig
- `type`: 'http-only' | 'https-passthrough' | 'https-terminate-to-http' | 'https-terminate-to-https'
- `target`: { host: string | string[], port: number }
- `http?`: { enabled?: boolean, redirectToHttps?: boolean, headers?: Record<string, string> }
- `https?`: { customCert?: { key: string, cert: string }, forwardSni?: boolean }
- `acme?`: { enabled?: boolean, maintenance?: boolean, production?: boolean, forwardChallenges?: { host: string, port: number, useTls?: boolean } }
- `security?`: { allowedIps?: string[], blockedIps?: string[], maxConnections?: number }
- `advanced?`: { portRanges?: Array<{ from: number, to: number }>, networkProxyPort?: number, keepAlive?: boolean, timeout?: number, headers?: Record<string, string> }
## Configuration Options
### NetworkProxy (INetworkProxyOptions)
- `port` (number, required)
- `backendProtocol` ('http1'|'http2', default 'http1')
- `maxConnections` (number, default 10000)
- `keepAliveTimeout` (ms, default 120000)
- `headersTimeout` (ms, default 60000)
- `cors` (object)
- `connectionPoolSize` (number, default 50)
- `logLevel` ('error'|'warn'|'info'|'debug')
- `acme` (IAcmeOptions)
- `useExternalPort80Handler` (boolean)
- `portProxyIntegration` (boolean)
### Port80Handler (IAcmeOptions)
- `enabled` (boolean, default true)
- `port` (number, default 80)
- `contactEmail` (string)
- `useProduction` (boolean, default false)
- `renewThresholdDays` (number, default 30)
- `autoRenew` (boolean, default true)
- `certificateStore` (string)
- `skipConfiguredCerts` (boolean)
- `domainForwards` (IDomainForwardConfig[])
### NfTablesProxy (INfTableProxySettings)
- `fromPort` / `toPort` (number|range|array)
- `toHost` (string, default 'localhost')
- `preserveSourceIP`, `deleteOnExit`, `protocol`, `enableLogging`, `ipv6Support` (booleans)
- `allowedSourceIPs`, `bannedSourceIPs` (string[])
- `useIPSets` (boolean, default true)
- `qos`, `netProxyIntegration` (objects)
### Redirect / SslRedirect
- Constructor options: `httpPort`, `httpsPort`, `sslOptions`, `rules` (RedirectRule[])
### SmartProxy (IPortProxySettings)
- `fromPort`, `toPort` (number)
- `domainConfigs` (IDomainConfig[]) - Using unified forwarding configuration
- `sniEnabled`, `preserveSourceIP` (booleans)
- `defaultAllowedIPs`, `defaultBlockedIPs` (string[]) - Default IP allowlists/blocklists
- Timeouts: `initialDataTimeout`, `socketTimeout`, `inactivityTimeout`, etc.
- Socket opts: `noDelay`, `keepAlive`, `enableKeepAliveProbes`
- `acme` (IAcmeOptions), `certProvisionFunction` (callback)
- `useNetworkProxy` (number[]), `networkProxyPort` (number)
- `globalPortRanges` (Array<{ from: number; to: number }>)
## Troubleshooting
### NetworkProxy
- Verify ports, certificates and `rejectUnauthorized` for TLS errors
- Configure CORS or use `addDefaultHeaders` for preflight issues
- Increase `maxConnections` or `connectionPoolSize` under load
### Port80Handler
- Run as root or grant CAP_NET_BIND_SERVICE for port 80
- Inspect `certificate-failed` events and switch staging/production
### NfTablesProxy
- Ensure `nft` is installed and run with sufficient privileges
- Use `forceCleanSlate:true` to clear conflicting rules
### Redirect / SslRedirect
- Check `fromHost`/`fromPath` patterns and Host headers
- Validate `sslOptions` key/cert correctness
### SmartProxy & SniHandler
- Increase `initialDataTimeout`/`maxPendingDataSize` for large ClientHello
- Enable `enableTlsDebugLogging` to trace handshake
- Ensure `allowSessionTicket` and fragmentation support for resumption
- Double-check forwarding configuration to ensure correct `type` for your use case
- Use helper functions like `httpOnly()`, `httpsPassthrough()`, etc. to create correct configurations
- For IP filtering issues, check the `security.allowedIps` and `security.blockedIps` settings
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
[![repo-footer](https://lossless.gitlab.io/publicrelations/repofooter.svg)](https://maintainedby.lossless.com)

View File

@ -1,471 +0,0 @@
# SmartProxy Unified Forwarding Configuration Plan
## Project Goal
Create a clean, use-case driven forwarding configuration interface for SmartProxy that elegantly handles all forwarding scenarios: SNI-based forwarding, termination-based forwarding (NetworkProxy), HTTP forwarding, and ACME challenge forwarding.
## Current State
Currently, SmartProxy has several different forwarding mechanisms configured separately:
1. **HTTPS/SNI forwarding** via `IDomainConfig` properties
2. **NetworkProxy forwarding** via `useNetworkProxy` in domain configs
3. **HTTP forwarding** via Port80Handler's `forward` configuration
4. **ACME challenge forwarding** via `acmeForward` configuration
This separation creates configuration complexity and reduced cohesion between related settings.
## Proposed Solution: Clean Use-Case Driven Forwarding Interface
### Phase 1: Design Streamlined Forwarding Interface
- [ ] Create a use-case driven `IForwardConfig` interface that simplifies configuration:
```typescript
export interface IForwardConfig {
// Define the primary forwarding type - use-case driven approach
type: 'http-only' | 'https-passthrough' | 'https-terminate-to-http' | 'https-terminate-to-https';
// Target configuration
target: {
host: string | string[]; // Support single host or round-robin
port: number;
};
// HTTP-specific options
http?: {
enabled?: boolean; // Defaults to true for http-only, optional for others
redirectToHttps?: boolean; // Redirect HTTP to HTTPS
headers?: Record<string, string>; // Custom headers for HTTP responses
};
// HTTPS-specific options
https?: {
customCert?: { // Use custom cert instead of auto-provisioned
key: string;
cert: string;
};
forwardSni?: boolean; // Forward SNI info in passthrough mode
};
// ACME certificate handling
acme?: {
enabled?: boolean; // Enable ACME certificate provisioning
maintenance?: boolean; // Auto-renew certificates
production?: boolean; // Use production ACME servers
forwardChallenges?: { // Forward ACME challenges
host: string;
port: number;
useTls?: boolean;
};
};
// Security options
security?: {
allowedIps?: string[]; // IPs allowed to connect
blockedIps?: string[]; // IPs blocked from connecting
maxConnections?: number; // Max simultaneous connections
};
// Advanced options
advanced?: {
portRanges?: Array<{ from: number; to: number }>; // Allowed port ranges
networkProxyPort?: number; // Custom NetworkProxy port if using terminate mode
keepAlive?: boolean; // Enable TCP keepalive
timeout?: number; // Connection timeout in ms
headers?: Record<string, string>; // Custom headers with support for variables like {sni}
};
}
```
### Phase 2: Create New Domain Configuration Interface
- [ ] Replace existing `IDomainConfig` interface with a new one using the forwarding pattern:
```typescript
export interface IDomainConfig {
// Core properties
domains: string[]; // Domain patterns to match
// Unified forwarding configuration
forwarding: IForwardConfig;
}
```
### Phase 3: Implement Forwarding Handler System
- [ ] Create an implementation strategy focused on the new forwarding types:
```typescript
/**
* Base class for all forwarding handlers
*/
abstract class ForwardingHandler {
constructor(protected config: IForwardConfig) {}
abstract handleConnection(socket: Socket): void;
abstract handleHttpRequest(req: IncomingMessage, res: ServerResponse): void;
}
/**
* Factory for creating the appropriate handler based on forwarding type
*/
class ForwardingHandlerFactory {
public static createHandler(config: IForwardConfig): ForwardingHandler {
switch (config.type) {
case 'http-only':
return new HttpForwardingHandler(config);
case 'https-passthrough':
return new HttpsPassthroughHandler(config);
case 'https-terminate-to-http':
return new HttpsTerminateToHttpHandler(config);
case 'https-terminate-to-https':
return new HttpsTerminateToHttpsHandler(config);
default:
throw new Error(`Unknown forwarding type: ${config.type}`);
}
}
}
```
## Usage Examples for Common Scenarios
### 1. Basic HTTP Server
```typescript
{
domains: ['example.com'],
forwarding: {
type: 'http-only',
target: {
host: 'localhost',
port: 3000
}
}
}
```
### 2. HTTPS Termination with HTTP Backend
```typescript
{
domains: ['secure.example.com'],
forwarding: {
type: 'https-terminate-to-http',
target: {
host: 'localhost',
port: 3000
},
acme: {
production: true // Use production Let's Encrypt
}
}
}
```
### 3. HTTPS Termination with HTTPS Backend
```typescript
{
domains: ['secure-backend.example.com'],
forwarding: {
type: 'https-terminate-to-https',
target: {
host: 'internal-api',
port: 8443
},
http: {
redirectToHttps: true // Redirect HTTP requests to HTTPS
}
}
}
```
### 4. SNI Passthrough
```typescript
{
domains: ['passthrough.example.com'],
forwarding: {
type: 'https-passthrough',
target: {
host: '10.0.0.5',
port: 443
}
}
}
```
### 5. Mixed HTTP/HTTPS with Custom ACME Forwarding
```typescript
{
domains: ['mixed.example.com'],
forwarding: {
type: 'https-terminate-to-http',
target: {
host: 'localhost',
port: 3000
},
http: {
redirectToHttps: false // Allow both HTTP and HTTPS access
},
acme: {
enabled: true,
maintenance: true,
forwardChallenges: {
host: '192.168.1.100',
port: 8080
}
}
}
}
```
### 6. Load-Balanced Backend
```typescript
{
domains: ['api.example.com'],
forwarding: {
type: 'https-terminate-to-https',
target: {
host: ['10.0.0.10', '10.0.0.11', '10.0.0.12'], // Round-robin
port: 8443
},
security: {
allowedIps: ['10.0.0.*', '192.168.1.*'] // Restrict access
}
}
}
```
### 7. Advanced Proxy Chain with Custom Headers
```typescript
{
domains: ['secure-chain.example.com'],
forwarding: {
type: 'https-terminate-to-https',
target: {
host: 'backend-gateway.internal',
port: 443
},
advanced: {
// Pass original client info to backend
headers: {
'X-Original-SNI': '{sni}',
'X-Client-IP': '{clientIp}'
}
}
}
}
```
## Implementation Plan
### Task 1: Core Types and Interfaces (Week 1)
- [ ] Create the new `IForwardConfig` interface in `classes.pp.interfaces.ts`
- [ ] Design the new `IDomainConfig` interface using the forwarding property
- [ ] Define the internal data types for expanded configuration
### Task 2: Forwarding Handlers (Week 1-2)
- [ ] Create abstract `ForwardingHandler` base class
- [ ] Implement concrete handlers for each forwarding type:
- [ ] `HttpForwardingHandler` - For HTTP-only configurations
- [ ] `HttpsPassthroughHandler` - For SNI passthrough
- [ ] `HttpsTerminateToHttpHandler` - For TLS termination to HTTP backends
- [ ] `HttpsTerminateToHttpsHandler` - For TLS termination to HTTPS backends
- [ ] Implement `ForwardingHandlerFactory` to create the appropriate handler
### Task 3: SmartProxy Integration (Week 2-3)
- [ ] Update `SmartProxy` class to use the new forwarding system
- [ ] Modify `ConnectionHandler` to delegate to forwarding handlers
- [ ] Refactor domain configuration processing to use forwarding types
- [ ] Update `Port80Handler` integration to work with the new system
### Task 4: Certificate Management (Week 3)
- [ ] Create a certificate management system that works with forwarding types
- [ ] Implement automatic ACME provisioning based on forwarding type
- [ ] Add custom certificate support
### Task 5: Testing & Helper Functions (Week 4)
- [ ] Create helper functions for common forwarding patterns
- [ ] Implement comprehensive test suite for each forwarding handler
- [ ] Add validation for forwarding configurations
### Task 6: Documentation (Week 4)
- [ ] Create detailed documentation for the new forwarding system
- [ ] Document the forwarding types and their use cases
- [ ] Update README with the new configuration examples
## Detailed Type Documentation
### Core Forwarding Types
```typescript
/**
* The primary forwarding types supported by SmartProxy
*/
export type ForwardingType =
| 'http-only' // HTTP forwarding only (no HTTPS)
| 'https-passthrough' // Pass-through TLS traffic (SNI forwarding)
| 'https-terminate-to-http' // Terminate TLS and forward to HTTP backend
| 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend
```
### Type-Specific Behavior
Each forwarding type has specific default behavior:
#### HTTP-Only
- Handles only HTTP traffic
- No TLS/HTTPS support
- No certificate management
#### HTTPS Passthrough
- Forwards raw TLS traffic to backend (no termination)
- Passes SNI information through
- No HTTP support (TLS only)
- No certificate management
#### HTTPS Terminate to HTTP
- Terminates TLS at SmartProxy
- Connects to backend using HTTP (non-TLS)
- Manages certificates automatically (ACME)
- Supports HTTP requests with option to redirect to HTTPS
#### HTTPS Terminate to HTTPS
- Terminates client TLS at SmartProxy
- Creates new TLS connection to backend
- Manages certificates automatically (ACME)
- Supports HTTP requests with option to redirect to HTTPS
## Handler Implementation Strategy
```typescript
/**
* Handler for HTTP-only forwarding
*/
class HttpForwardingHandler extends ForwardingHandler {
public handleConnection(socket: Socket): void {
// Process HTTP connection
// For HTTP-only, we'll mostly defer to handleHttpRequest
}
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
// Forward HTTP request to target
const target = this.getTargetFromConfig();
this.proxyRequest(req, res, target);
}
}
/**
* Handler for HTTPS passthrough (SNI forwarding)
*/
class HttpsPassthroughHandler extends ForwardingHandler {
public handleConnection(socket: Socket): void {
// Extract SNI from TLS ClientHello if needed
// Forward raw TLS traffic to target without termination
const target = this.getTargetFromConfig();
this.forwardTlsConnection(socket, target);
}
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
// HTTP not supported in SNI passthrough mode
res.statusCode = 404;
res.end('HTTP not supported for this domain');
}
}
/**
* Handler for HTTPS termination with HTTP backend
*/
class HttpsTerminateToHttpHandler extends ForwardingHandler {
private tlsContext: SecureContext;
public async initialize(): Promise<void> {
// Set up TLS termination context
this.tlsContext = await this.createTlsContext();
}
public handleConnection(socket: Socket): void {
// Terminate TLS
const tlsSocket = this.createTlsSocket(socket, this.tlsContext);
// Forward to HTTP backend after TLS termination
tlsSocket.on('data', (data) => {
this.forwardToHttpBackend(data);
});
}
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
if (this.config.http?.redirectToHttps) {
// Redirect to HTTPS if configured
this.redirectToHttps(req, res);
} else {
// Handle HTTP request
const target = this.getTargetFromConfig();
this.proxyRequest(req, res, target);
}
}
}
/**
* Handler for HTTPS termination with HTTPS backend
*/
class HttpsTerminateToHttpsHandler extends ForwardingHandler {
private tlsContext: SecureContext;
public async initialize(): Promise<void> {
// Set up TLS termination context
this.tlsContext = await this.createTlsContext();
}
public handleConnection(socket: Socket): void {
// Terminate client TLS
const tlsSocket = this.createTlsSocket(socket, this.tlsContext);
// Create new TLS connection to backend
tlsSocket.on('data', (data) => {
this.forwardToHttpsBackend(data);
});
}
public handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
if (this.config.http?.redirectToHttps) {
// Redirect to HTTPS if configured
this.redirectToHttps(req, res);
} else {
// Handle HTTP request via HTTPS to backend
const target = this.getTargetFromConfig();
this.proxyRequestOverHttps(req, res, target);
}
}
}
```
## Benefits of This Approach
1. **Clean, Type-Driven Design**
- Forwarding types clearly express intent
- No backward compatibility compromises
- Code structure follows the domain model
2. **Explicit Configuration**
- Configuration directly maps to behavior
- Reduced chance of unexpected behavior
3. **Modular Implementation**
- Each forwarding type handled by dedicated class
- Clear separation of concerns
- Easier to test and extend
4. **Simplified Mental Model**
- Users think in terms of use cases, not low-level settings
- Configuration matches mental model
5. **Future-Proof**
- Easy to add new forwarding types
- Clean extension points for new features

View File

@ -1,37 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import * as tls from 'tls';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export interface TestCertificates {
privateKey: string;
publicKey: string;
}
export function loadTestCertificates(): TestCertificates {
const certPath = path.join(__dirname, '..', '..', 'assets', 'certs', 'cert.pem');
const keyPath = path.join(__dirname, '..', '..', 'assets', 'certs', 'key.pem');
// Read certificates
const publicKey = fs.readFileSync(certPath, 'utf8');
const privateKey = fs.readFileSync(keyPath, 'utf8');
// Validate certificates
try {
// Try to create a secure context with the certificates
tls.createSecureContext({
cert: publicKey,
key: privateKey
});
} catch (error) {
throw new Error(`Invalid certificates: ${error.message}`);
}
return {
privateKey,
publicKey
};
}

View File

@ -1,170 +0,0 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import { CertProvisioner } from '../ts/smartproxy/classes.pp.certprovisioner.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from '../ts/smartproxy/classes.pp.interfaces.js';
import type { ICertificateData } from '../ts/common/types.js';
// Fake Port80Handler stub
class FakePort80Handler extends plugins.EventEmitter {
public domainsAdded: string[] = [];
public renewCalled: string[] = [];
addDomain(opts: { domainName: string; sslRedirect: boolean; acmeMaintenance: boolean }) {
this.domainsAdded.push(opts.domainName);
}
async renewCertificate(domain: string): Promise<void> {
this.renewCalled.push(domain);
}
}
// Fake NetworkProxyBridge stub
class FakeNetworkProxyBridge {
public appliedCerts: ICertificateData[] = [];
applyExternalCertificate(cert: ICertificateData) {
this.appliedCerts.push(cert);
}
}
tap.test('CertProvisioner handles static provisioning', async () => {
const domain = 'static.com';
const domainConfigs: IDomainConfig[] = [{
domains: [domain],
forwarding: {
type: 'https-terminate-to-https',
target: { host: 'localhost', port: 443 }
}
}];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
// certProvider returns static certificate
const certProvider = async (d: string): Promise<ISmartProxyCertProvisionObject> => {
expect(d).toEqual(domain);
return {
domainName: domain,
publicKey: 'CERT',
privateKey: 'KEY',
validUntil: Date.now() + 3600 * 1000,
created: Date.now(),
csr: 'CSR',
id: 'ID',
};
};
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1, // low renew threshold
1, // short interval
false // disable auto renew for unit test
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.start();
// Static flow: no addDomain, certificate applied via bridge
expect(fakePort80.domainsAdded.length).toEqual(0);
expect(fakeBridge.appliedCerts.length).toEqual(1);
expect(events.length).toEqual(1);
const evt = events[0];
expect(evt.domain).toEqual(domain);
expect(evt.certificate).toEqual('CERT');
expect(evt.privateKey).toEqual('KEY');
expect(evt.isRenewal).toEqual(false);
expect(evt.source).toEqual('static');
});
tap.test('CertProvisioner handles http01 provisioning', async () => {
const domain = 'http01.com';
const domainConfigs: IDomainConfig[] = [{
domains: [domain],
forwarding: {
type: 'https-terminate-to-http',
target: { host: 'localhost', port: 80 }
}
}];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
// certProvider returns http01 directive
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => 'http01';
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.start();
// HTTP-01 flow: addDomain called, no static cert applied
expect(fakePort80.domainsAdded).toEqual([domain]);
expect(fakeBridge.appliedCerts.length).toEqual(0);
expect(events.length).toEqual(0);
});
tap.test('CertProvisioner on-demand http01 renewal', async () => {
const domain = 'renew.com';
const domainConfigs: IDomainConfig[] = [{
domains: [domain],
forwarding: {
type: 'https-terminate-to-http',
target: { host: 'localhost', port: 80 }
}
}];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => 'http01';
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
// requestCertificate should call renewCertificate
await prov.requestCertificate(domain);
expect(fakePort80.renewCalled).toEqual([domain]);
});
tap.test('CertProvisioner on-demand static provisioning', async () => {
const domain = 'ondemand.com';
const domainConfigs: IDomainConfig[] = [{
domains: [domain],
forwarding: {
type: 'https-terminate-to-https',
target: { host: 'localhost', port: 443 }
}
}];
const fakePort80 = new FakePort80Handler();
const fakeBridge = new FakeNetworkProxyBridge();
const certProvider = async (): Promise<ISmartProxyCertProvisionObject> => ({
domainName: domain,
publicKey: 'PKEY',
privateKey: 'PRIV',
validUntil: Date.now() + 1000,
created: Date.now(),
csr: 'CSR',
id: 'ID',
});
const prov = new CertProvisioner(
domainConfigs,
fakePort80 as any,
fakeBridge as any,
certProvider,
1,
1,
false
);
const events: any[] = [];
prov.on('certificate', (data) => events.push(data));
await prov.requestCertificate(domain);
expect(fakeBridge.appliedCerts.length).toEqual(1);
expect(events.length).toEqual(1);
expect(events[0].domain).toEqual(domain);
expect(events[0].source).toEqual('static');
});
export default tap.start();

View File

@ -1,112 +0,0 @@
import * as plugins from '../ts/plugins.js';
import { tap, expect } from '@push.rocks/tapbundle';
import { SmartProxy } from '../ts/smartproxy/classes.smartproxy.js';
import type { IDomainConfig } from '../ts/smartproxy/classes.pp.interfaces.js';
import type { ForwardingType } from '../ts/smartproxy/types/forwarding.types.js';
import {
httpOnly,
httpsPassthrough,
tlsTerminateToHttp,
tlsTerminateToHttps
} from '../ts/smartproxy/types/forwarding.types.js';
// Test to demonstrate various forwarding configurations
tap.test('Forwarding configuration examples', async (tools) => {
// Example 1: HTTP-only configuration
const httpOnlyConfig: IDomainConfig = {
domains: ['http.example.com'],
forwarding: httpOnly({
target: {
host: 'localhost',
port: 3000
},
security: {
allowedIps: ['*'] // Allow all
}
})
};
console.log(httpOnlyConfig.forwarding, 'HTTP-only configuration created successfully');
expect(httpOnlyConfig.forwarding.type).toEqual('http-only');
// Example 2: HTTPS Passthrough (SNI)
const httpsPassthroughConfig: IDomainConfig = {
domains: ['pass.example.com'],
forwarding: httpsPassthrough({
target: {
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs
port: 443
},
security: {
allowedIps: ['*'] // Allow all
}
})
};
expect(httpsPassthroughConfig.forwarding).toBeTruthy();
expect(httpsPassthroughConfig.forwarding.type).toEqual('https-passthrough');
expect(Array.isArray(httpsPassthroughConfig.forwarding.target.host)).toBeTrue();
// Example 3: HTTPS Termination to HTTP Backend
const terminateToHttpConfig: IDomainConfig = {
domains: ['secure.example.com'],
forwarding: tlsTerminateToHttp({
target: {
host: 'localhost',
port: 8080
},
http: {
redirectToHttps: true, // Redirect HTTP requests to HTTPS
headers: {
'X-Forwarded-Proto': 'https'
}
},
acme: {
enabled: true,
maintenance: true,
production: false // Use staging ACME server for testing
},
security: {
allowedIps: ['*'] // Allow all
}
})
};
expect(terminateToHttpConfig.forwarding).toBeTruthy();
expect(terminateToHttpConfig.forwarding.type).toEqual('https-terminate-to-http');
expect(terminateToHttpConfig.forwarding.http?.redirectToHttps).toBeTrue();
// Example 4: HTTPS Termination to HTTPS Backend
const terminateToHttpsConfig: IDomainConfig = {
domains: ['proxy.example.com'],
forwarding: tlsTerminateToHttps({
target: {
host: 'internal-api.local',
port: 8443
},
https: {
forwardSni: true // Forward original SNI info
},
security: {
allowedIps: ['10.0.0.0/24', '192.168.1.0/24'],
maxConnections: 1000
},
advanced: {
timeout: 3600000, // 1 hour in ms
headers: {
'X-Original-Host': '{sni}'
}
}
})
};
expect(terminateToHttpsConfig.forwarding).toBeTruthy();
expect(terminateToHttpsConfig.forwarding.type).toEqual('https-terminate-to-https');
expect(terminateToHttpsConfig.forwarding.https?.forwardSni).toBeTrue();
expect(terminateToHttpsConfig.forwarding.security?.allowedIps?.length).toEqual(2);
// Skip the SmartProxy integration test for now and just verify our configuration objects work
console.log('All forwarding configurations were created successfully');
// This is just to verify that our test passes
expect(true).toBeTrue();
});
export default tap.start();

View File

@ -1,199 +0,0 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import type { IForwardConfig, ForwardingType } from '../ts/smartproxy/types/forwarding.types.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/smartproxy/forwarding/forwarding.factory.js';
import { createDomainConfig } from '../ts/smartproxy/forwarding/domain-config.js';
import { DomainManager } from '../ts/smartproxy/forwarding/domain-manager.js';
import { httpOnly, tlsTerminateToHttp, tlsTerminateToHttps, httpsPassthrough } from '../ts/smartproxy/types/forwarding.types.js';
const helpers = {
httpOnly,
tlsTerminateToHttp,
tlsTerminateToHttps,
httpsPassthrough
};
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
// HTTP-only defaults
const httpConfig: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 }
};
const expandedHttpConfig = ForwardingHandlerFactory.applyDefaults(httpConfig);
expect(expandedHttpConfig.http?.enabled).toEqual(true);
// HTTPS-passthrough defaults
const passthroughConfig: IForwardConfig = {
type: 'https-passthrough',
target: { host: 'localhost', port: 443 }
};
const expandedPassthroughConfig = ForwardingHandlerFactory.applyDefaults(passthroughConfig);
expect(expandedPassthroughConfig.https?.forwardSni).toEqual(true);
expect(expandedPassthroughConfig.http?.enabled).toEqual(false);
// HTTPS-terminate-to-http defaults
const terminateToHttpConfig: IForwardConfig = {
type: 'https-terminate-to-http',
target: { host: 'localhost', port: 3000 }
};
const expandedTerminateToHttpConfig = ForwardingHandlerFactory.applyDefaults(terminateToHttpConfig);
expect(expandedTerminateToHttpConfig.http?.enabled).toEqual(true);
expect(expandedTerminateToHttpConfig.http?.redirectToHttps).toEqual(true);
expect(expandedTerminateToHttpConfig.acme?.enabled).toEqual(true);
expect(expandedTerminateToHttpConfig.acme?.maintenance).toEqual(true);
// HTTPS-terminate-to-https defaults
const terminateToHttpsConfig: IForwardConfig = {
type: 'https-terminate-to-https',
target: { host: 'localhost', port: 8443 }
};
const expandedTerminateToHttpsConfig = ForwardingHandlerFactory.applyDefaults(terminateToHttpsConfig);
expect(expandedTerminateToHttpsConfig.http?.enabled).toEqual(true);
expect(expandedTerminateToHttpsConfig.http?.redirectToHttps).toEqual(true);
expect(expandedTerminateToHttpsConfig.acme?.enabled).toEqual(true);
expect(expandedTerminateToHttpsConfig.acme?.maintenance).toEqual(true);
});
tap.test('ForwardingHandlerFactory - validate configuration', async () => {
// Valid configuration
const validConfig: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 }
};
expect(() => ForwardingHandlerFactory.validateConfig(validConfig)).not.toThrow();
// Invalid configuration - missing target
const invalidConfig1: any = {
type: 'http-only'
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig1)).toThrow();
// Invalid configuration - invalid port
const invalidConfig2: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 0 }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig2)).toThrow();
// Invalid configuration - HTTP disabled for HTTP-only
const invalidConfig3: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 },
http: { enabled: false }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig3)).toThrow();
// Invalid configuration - HTTP enabled for HTTPS passthrough
const invalidConfig4: IForwardConfig = {
type: 'https-passthrough',
target: { host: 'localhost', port: 443 },
http: { enabled: true }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig4)).toThrow();
});
tap.test('DomainManager - manage domain configurations', async () => {
const domainManager = new DomainManager();
// Add a domain configuration
await domainManager.addDomainConfig(
createDomainConfig('example.com', helpers.httpOnly({
target: { host: 'localhost', port: 3000 }
}))
);
// Check that the configuration was added
const configs = domainManager.getDomainConfigs();
expect(configs.length).toEqual(1);
expect(configs[0].domains[0]).toEqual('example.com');
expect(configs[0].forwarding.type).toEqual('http-only');
// Find a handler for a domain
const handler = domainManager.findHandlerForDomain('example.com');
expect(handler).toBeDefined();
// Remove a domain configuration
const removed = domainManager.removeDomainConfig('example.com');
expect(removed).toBeTrue();
// Check that the configuration was removed
const configsAfterRemoval = domainManager.getDomainConfigs();
expect(configsAfterRemoval.length).toEqual(0);
// Check that no handler exists anymore
const handlerAfterRemoval = domainManager.findHandlerForDomain('example.com');
expect(handlerAfterRemoval).toBeUndefined();
});
tap.test('DomainManager - support wildcard domains', async () => {
const domainManager = new DomainManager();
// Add a wildcard domain configuration
await domainManager.addDomainConfig(
createDomainConfig('*.example.com', helpers.httpOnly({
target: { host: 'localhost', port: 3000 }
}))
);
// Find a handler for a subdomain
const handler = domainManager.findHandlerForDomain('test.example.com');
expect(handler).toBeDefined();
// Find a handler for a different domain (should not match)
const noHandler = domainManager.findHandlerForDomain('example.org');
expect(noHandler).toBeUndefined();
});
tap.test('Helper Functions - create http-only forwarding config', async () => {
const config = helpers.httpOnly({
target: { host: 'localhost', port: 3000 }
});
expect(config.type).toEqual('http-only');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(3000);
expect(config.http?.enabled).toBeTrue();
});
tap.test('Helper Functions - create https-terminate-to-http config', async () => {
const config = helpers.tlsTerminateToHttp({
target: { host: 'localhost', port: 3000 }
});
expect(config.type).toEqual('https-terminate-to-http');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(3000);
expect(config.http?.redirectToHttps).toBeTrue();
expect(config.acme?.enabled).toBeTrue();
expect(config.acme?.maintenance).toBeTrue();
});
tap.test('Helper Functions - create https-terminate-to-https config', async () => {
const config = helpers.tlsTerminateToHttps({
target: { host: 'localhost', port: 8443 }
});
expect(config.type).toEqual('https-terminate-to-https');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(8443);
expect(config.http?.redirectToHttps).toBeTrue();
expect(config.acme?.enabled).toBeTrue();
expect(config.acme?.maintenance).toBeTrue();
});
tap.test('Helper Functions - create https-passthrough config', async () => {
const config = helpers.httpsPassthrough({
target: { host: 'localhost', port: 443 }
});
expect(config.type).toEqual('https-passthrough');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(443);
expect(config.https?.forwardSni).toBeTrue();
});
export default tap.start();

View File

@ -1,172 +0,0 @@
import { tap, expect } from '@push.rocks/tapbundle';
import * as plugins from '../ts/plugins.js';
import type { IForwardConfig } from '../ts/smartproxy/types/forwarding.types.js';
// First, import the components directly to avoid issues with compiled modules
import { ForwardingHandlerFactory } from '../ts/smartproxy/forwarding/forwarding.factory.js';
import { createDomainConfig } from '../ts/smartproxy/forwarding/domain-config.js';
import { DomainManager } from '../ts/smartproxy/forwarding/domain-manager.js';
import { httpOnly, tlsTerminateToHttp, tlsTerminateToHttps, httpsPassthrough } from '../ts/smartproxy/types/forwarding.types.js';
const helpers = {
httpOnly,
tlsTerminateToHttp,
tlsTerminateToHttps,
httpsPassthrough
};
tap.test('ForwardingHandlerFactory - apply defaults based on type', async () => {
// HTTP-only defaults
const httpConfig: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 }
};
const expandedHttpConfig = ForwardingHandlerFactory.applyDefaults(httpConfig);
expect(expandedHttpConfig.http?.enabled).toEqual(true);
// HTTPS-passthrough defaults
const passthroughConfig: IForwardConfig = {
type: 'https-passthrough',
target: { host: 'localhost', port: 443 }
};
const expandedPassthroughConfig = ForwardingHandlerFactory.applyDefaults(passthroughConfig);
expect(expandedPassthroughConfig.https?.forwardSni).toEqual(true);
expect(expandedPassthroughConfig.http?.enabled).toEqual(false);
// HTTPS-terminate-to-http defaults
const terminateToHttpConfig: IForwardConfig = {
type: 'https-terminate-to-http',
target: { host: 'localhost', port: 3000 }
};
const expandedTerminateToHttpConfig = ForwardingHandlerFactory.applyDefaults(terminateToHttpConfig);
expect(expandedTerminateToHttpConfig.http?.enabled).toEqual(true);
expect(expandedTerminateToHttpConfig.http?.redirectToHttps).toEqual(true);
expect(expandedTerminateToHttpConfig.acme?.enabled).toEqual(true);
expect(expandedTerminateToHttpConfig.acme?.maintenance).toEqual(true);
// HTTPS-terminate-to-https defaults
const terminateToHttpsConfig: IForwardConfig = {
type: 'https-terminate-to-https',
target: { host: 'localhost', port: 8443 }
};
const expandedTerminateToHttpsConfig = ForwardingHandlerFactory.applyDefaults(terminateToHttpsConfig);
expect(expandedTerminateToHttpsConfig.http?.enabled).toEqual(true);
expect(expandedTerminateToHttpsConfig.http?.redirectToHttps).toEqual(true);
expect(expandedTerminateToHttpsConfig.acme?.enabled).toEqual(true);
expect(expandedTerminateToHttpsConfig.acme?.maintenance).toEqual(true);
});
tap.test('ForwardingHandlerFactory - validate configuration', async () => {
// Valid configuration
const validConfig: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 }
};
expect(() => ForwardingHandlerFactory.validateConfig(validConfig)).not.toThrow();
// Invalid configuration - missing target
const invalidConfig1: any = {
type: 'http-only'
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig1)).toThrow();
// Invalid configuration - invalid port
const invalidConfig2: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 0 }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig2)).toThrow();
// Invalid configuration - HTTP disabled for HTTP-only
const invalidConfig3: IForwardConfig = {
type: 'http-only',
target: { host: 'localhost', port: 3000 },
http: { enabled: false }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig3)).toThrow();
// Invalid configuration - HTTP enabled for HTTPS passthrough
const invalidConfig4: IForwardConfig = {
type: 'https-passthrough',
target: { host: 'localhost', port: 443 },
http: { enabled: true }
};
expect(() => ForwardingHandlerFactory.validateConfig(invalidConfig4)).toThrow();
});
tap.test('DomainManager - manage domain configurations', async () => {
const domainManager = new DomainManager();
// Add a domain configuration
await domainManager.addDomainConfig(
createDomainConfig('example.com', helpers.httpOnly({
target: { host: 'localhost', port: 3000 }
}))
);
// Check that the configuration was added
const configs = domainManager.getDomainConfigs();
expect(configs.length).toEqual(1);
expect(configs[0].domains[0]).toEqual('example.com');
expect(configs[0].forwarding.type).toEqual('http-only');
// Remove a domain configuration
const removed = domainManager.removeDomainConfig('example.com');
expect(removed).toBeTrue();
// Check that the configuration was removed
const configsAfterRemoval = domainManager.getDomainConfigs();
expect(configsAfterRemoval.length).toEqual(0);
});
tap.test('Helper Functions - create http-only forwarding config', async () => {
const config = helpers.httpOnly({
target: { host: 'localhost', port: 3000 }
});
expect(config.type).toEqual('http-only');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(3000);
expect(config.http?.enabled).toBeTrue();
});
tap.test('Helper Functions - create https-terminate-to-http config', async () => {
const config = helpers.tlsTerminateToHttp({
target: { host: 'localhost', port: 3000 }
});
expect(config.type).toEqual('https-terminate-to-http');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(3000);
expect(config.http?.redirectToHttps).toBeTrue();
expect(config.acme?.enabled).toBeTrue();
expect(config.acme?.maintenance).toBeTrue();
});
tap.test('Helper Functions - create https-terminate-to-https config', async () => {
const config = helpers.tlsTerminateToHttps({
target: { host: 'localhost', port: 8443 }
});
expect(config.type).toEqual('https-terminate-to-https');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(8443);
expect(config.http?.redirectToHttps).toBeTrue();
expect(config.acme?.enabled).toBeTrue();
expect(config.acme?.maintenance).toBeTrue();
});
tap.test('Helper Functions - create https-passthrough config', async () => {
const config = helpers.httpsPassthrough({
target: { host: 'localhost', port: 443 }
});
expect(config.type).toEqual('https-passthrough');
expect(config.target.host).toEqual('localhost');
expect(config.target.port).toEqual(443);
expect(config.https?.forwardSni).toBeTrue();
});
export default tap.start();

View File

@ -1,578 +0,0 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as smartproxy from '../ts/index.js';
import { loadTestCertificates } from './helpers/certificates.js';
import * as https from 'https';
import * as http from 'http';
import { WebSocket, WebSocketServer } from 'ws';
let testProxy: smartproxy.NetworkProxy;
let testServer: http.Server;
let wsServer: WebSocketServer;
let testCertificates: { privateKey: string; publicKey: string };
// Helper function to make HTTPS requests
async function makeHttpsRequest(
options: https.RequestOptions,
): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; body: string }> {
console.log('[TEST] Making HTTPS request:', {
hostname: options.hostname,
port: options.port,
path: options.path,
method: options.method,
headers: options.headers,
});
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
console.log('[TEST] Received HTTPS response:', {
statusCode: res.statusCode,
headers: res.headers,
});
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
console.log('[TEST] Response completed:', { data });
resolve({
statusCode: res.statusCode!,
headers: res.headers,
body: data,
});
});
});
req.on('error', (error) => {
console.error('[TEST] Request error:', error);
reject(error);
});
req.end();
});
}
// Setup test environment
tap.test('setup test environment', async () => {
// Load and validate certificates
console.log('[TEST] Loading and validating certificates');
testCertificates = loadTestCertificates();
console.log('[TEST] Certificates loaded and validated');
// Create a test HTTP server
testServer = http.createServer((req, res) => {
console.log('[TEST SERVER] Received HTTP request:', {
url: req.url,
method: req.method,
headers: req.headers,
});
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from test server!');
});
// Handle WebSocket upgrade requests
testServer.on('upgrade', (request, socket, head) => {
console.log('[TEST SERVER] Received WebSocket upgrade request:', {
url: request.url,
method: request.method,
headers: {
host: request.headers.host,
upgrade: request.headers.upgrade,
connection: request.headers.connection,
'sec-websocket-key': request.headers['sec-websocket-key'],
'sec-websocket-version': request.headers['sec-websocket-version'],
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
},
});
if (request.headers.upgrade?.toLowerCase() !== 'websocket') {
console.log('[TEST SERVER] Not a WebSocket upgrade request');
socket.destroy();
return;
}
console.log('[TEST SERVER] Handling WebSocket upgrade');
wsServer.handleUpgrade(request, socket, head, (ws) => {
console.log('[TEST SERVER] WebSocket connection upgraded');
wsServer.emit('connection', ws, request);
});
});
// Create a WebSocket server (for the test HTTP server)
console.log('[TEST SERVER] Creating WebSocket server');
wsServer = new WebSocketServer({
noServer: true,
perMessageDeflate: false,
clientTracking: true,
handleProtocols: () => 'echo-protocol',
});
wsServer.on('connection', (ws, request) => {
console.log('[TEST SERVER] WebSocket connection established:', {
url: request.url,
headers: {
host: request.headers.host,
upgrade: request.headers.upgrade,
connection: request.headers.connection,
'sec-websocket-key': request.headers['sec-websocket-key'],
'sec-websocket-version': request.headers['sec-websocket-version'],
'sec-websocket-protocol': request.headers['sec-websocket-protocol'],
},
});
// Set up connection timeout
const connectionTimeout = setTimeout(() => {
console.error('[TEST SERVER] WebSocket connection timed out');
ws.terminate();
}, 5000);
// Clear timeout when connection is properly closed
const clearConnectionTimeout = () => {
clearTimeout(connectionTimeout);
};
ws.on('message', (message) => {
const msg = message.toString();
console.log('[TEST SERVER] Received message:', msg);
try {
const response = `Echo: ${msg}`;
console.log('[TEST SERVER] Sending response:', response);
ws.send(response);
// Clear timeout on successful message exchange
clearConnectionTimeout();
} catch (error) {
console.error('[TEST SERVER] Error sending message:', error);
}
});
ws.on('error', (error) => {
console.error('[TEST SERVER] WebSocket error:', error);
clearConnectionTimeout();
});
ws.on('close', (code, reason) => {
console.log('[TEST SERVER] WebSocket connection closed:', {
code,
reason: reason.toString(),
wasClean: code === 1000 || code === 1001,
});
clearConnectionTimeout();
});
ws.on('ping', (data) => {
try {
console.log('[TEST SERVER] Received ping, sending pong');
ws.pong(data);
} catch (error) {
console.error('[TEST SERVER] Error sending pong:', error);
}
});
ws.on('pong', (data) => {
console.log('[TEST SERVER] Received pong');
});
});
wsServer.on('error', (error) => {
console.error('Test server: WebSocket server error:', error);
});
wsServer.on('headers', (headers) => {
console.log('Test server: WebSocket headers:', headers);
});
wsServer.on('close', () => {
console.log('Test server: WebSocket server closed');
});
await new Promise<void>((resolve) => testServer.listen(3000, resolve));
console.log('Test server listening on port 3000');
});
tap.test('should create proxy instance', async () => {
// Test with the original minimal options (only port)
testProxy = new smartproxy.NetworkProxy({
port: 3001,
});
expect(testProxy).toEqual(testProxy); // Instance equality check
});
tap.test('should create proxy instance with extended options', async () => {
// Test with extended options to verify backward compatibility
testProxy = new smartproxy.NetworkProxy({
port: 3001,
maxConnections: 5000,
keepAliveTimeout: 120000,
headersTimeout: 60000,
logLevel: 'info',
cors: {
allowOrigin: '*',
allowMethods: 'GET, POST, OPTIONS',
allowHeaders: 'Content-Type',
maxAge: 3600
}
});
expect(testProxy).toEqual(testProxy); // Instance equality check
expect(testProxy.options.port).toEqual(3001);
});
tap.test('should start the proxy server', async () => {
// Ensure any previous server is closed
if (testProxy && testProxy.httpsServer) {
await new Promise<void>((resolve) =>
testProxy.httpsServer.close(() => resolve())
);
}
console.log('[TEST] Starting the proxy server');
await testProxy.start();
console.log('[TEST] Proxy server started');
// Configure proxy with test certificates
// Awaiting the update ensures that the SNI context is added before any requests come in.
await testProxy.updateProxyConfigs([
{
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
hostName: 'push.rocks',
publicKey: testCertificates.publicKey,
privateKey: testCertificates.privateKey,
},
]);
console.log('[TEST] Proxy configuration updated');
});
tap.test('should route HTTPS requests based on host header', async () => {
// IMPORTANT: Connect to localhost (where the proxy is listening) but use the Host header "push.rocks"
const response = await makeHttpsRequest({
hostname: 'localhost', // changed from 'push.rocks' to 'localhost'
port: 3001,
path: '/',
method: 'GET',
headers: {
host: 'push.rocks', // virtual host for routing
},
rejectUnauthorized: false,
});
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual('Hello from test server!');
});
tap.test('should handle unknown host headers', async () => {
// Connect to localhost but use an unknown host header.
const response = await makeHttpsRequest({
hostname: 'localhost', // connecting to localhost
port: 3001,
path: '/',
method: 'GET',
headers: {
host: 'unknown.host', // this should not match any proxy config
},
rejectUnauthorized: false,
});
// Expect a 404 response with the appropriate error message.
expect(response.statusCode).toEqual(404);
});
tap.test('should support WebSocket connections', async () => {
console.log('\n[TEST] ====== WebSocket Test Started ======');
console.log('[TEST] Test server port:', 3000);
console.log('[TEST] Proxy server port:', 3001);
console.log('\n[TEST] Starting WebSocket test');
// Reconfigure proxy with test certificates if necessary
await testProxy.updateProxyConfigs([
{
destinationIps: ['127.0.0.1'],
destinationPorts: [3000],
hostName: 'push.rocks',
publicKey: testCertificates.publicKey,
privateKey: testCertificates.privateKey,
},
]);
return new Promise<void>((resolve, reject) => {
console.log('[TEST] Creating WebSocket client');
// IMPORTANT: Connect to localhost but specify the SNI servername and Host header as "push.rocks"
const wsUrl = 'wss://localhost:3001'; // changed from 'wss://push.rocks:3001'
console.log('[TEST] Creating WebSocket connection to:', wsUrl);
const ws = new WebSocket(wsUrl, {
rejectUnauthorized: false, // Accept self-signed certificates
handshakeTimeout: 5000,
perMessageDeflate: false,
headers: {
Host: 'push.rocks', // required for SNI and routing on the proxy
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
},
protocol: 'echo-protocol',
agent: new https.Agent({
rejectUnauthorized: false, // Also needed for the underlying HTTPS connection
}),
});
console.log('[TEST] WebSocket client created');
let resolved = false;
const cleanup = () => {
if (!resolved) {
resolved = true;
try {
console.log('[TEST] Cleaning up WebSocket connection');
ws.close();
resolve();
} catch (error) {
console.error('[TEST] Error during cleanup:', error);
reject(error);
}
}
};
const timeout = setTimeout(() => {
console.error('[TEST] WebSocket test timed out');
cleanup();
reject(new Error('WebSocket test timed out after 5 seconds'));
}, 5000);
// Connection establishment events
ws.on('upgrade', (response) => {
console.log('[TEST] WebSocket upgrade response received:', {
headers: response.headers,
statusCode: response.statusCode,
});
});
ws.on('open', () => {
console.log('[TEST] WebSocket connection opened');
try {
console.log('[TEST] Sending test message');
ws.send('Hello WebSocket');
} catch (error) {
console.error('[TEST] Error sending message:', error);
cleanup();
reject(error);
}
});
ws.on('message', (message) => {
console.log('[TEST] Received message:', message.toString());
if (
message.toString() === 'Hello WebSocket' ||
message.toString() === 'Echo: Hello WebSocket'
) {
console.log('[TEST] Message received correctly');
clearTimeout(timeout);
cleanup();
}
});
ws.on('error', (error) => {
console.error('[TEST] WebSocket error:', error);
cleanup();
reject(error);
});
ws.on('close', (code, reason) => {
console.log('[TEST] WebSocket connection closed:', {
code,
reason: reason.toString(),
});
cleanup();
});
});
});
tap.test('should handle custom headers', async () => {
await testProxy.addDefaultHeaders({
'X-Proxy-Header': 'test-value',
});
const response = await makeHttpsRequest({
hostname: 'localhost', // changed to 'localhost'
port: 3001,
path: '/',
method: 'GET',
headers: {
host: 'push.rocks', // still routing to push.rocks
},
rejectUnauthorized: false,
});
expect(response.headers['x-proxy-header']).toEqual('test-value');
});
tap.test('should handle CORS preflight requests', async () => {
try {
console.log('[TEST] Testing CORS preflight handling...');
// First ensure the existing proxy is working correctly
console.log('[TEST] Making initial GET request to verify server');
const initialResponse = await makeHttpsRequest({
hostname: 'localhost',
port: 3001,
path: '/',
method: 'GET',
headers: { host: 'push.rocks' },
rejectUnauthorized: false,
});
console.log('[TEST] Initial response status:', initialResponse.statusCode);
expect(initialResponse.statusCode).toEqual(200);
// Add CORS headers to the existing proxy
console.log('[TEST] Adding CORS headers');
await testProxy.addDefaultHeaders({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
});
// Allow server to process the header changes
console.log('[TEST] Waiting for headers to be processed');
await new Promise(resolve => setTimeout(resolve, 500)); // Increased timeout
// Send OPTIONS request to simulate CORS preflight
console.log('[TEST] Sending OPTIONS request for CORS preflight');
const response = await makeHttpsRequest({
hostname: 'localhost',
port: 3001,
path: '/',
method: 'OPTIONS',
headers: {
host: 'push.rocks',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type',
'Origin': 'https://example.com'
},
rejectUnauthorized: false,
});
console.log('[TEST] CORS preflight response status:', response.statusCode);
console.log('[TEST] CORS preflight response headers:', response.headers);
// For now, accept either 204 or 200 as success
expect([200, 204]).toContain(response.statusCode);
console.log('[TEST] CORS test completed successfully');
} catch (error) {
console.error('[TEST] Error in CORS test:', error);
throw error; // Rethrow to fail the test
}
});
tap.test('should track connections and metrics', async () => {
try {
console.log('[TEST] Testing metrics tracking...');
// Get initial metrics counts
const initialRequestsServed = testProxy.requestsServed || 0;
console.log('[TEST] Initial requests served:', initialRequestsServed);
// Make a few requests to ensure we have metrics to check
console.log('[TEST] Making test requests to increment metrics');
for (let i = 0; i < 3; i++) {
console.log(`[TEST] Making request ${i+1}/3`);
await makeHttpsRequest({
hostname: 'localhost',
port: 3001,
path: '/metrics-test-' + i,
method: 'GET',
headers: { host: 'push.rocks' },
rejectUnauthorized: false,
});
}
// Wait a bit to let metrics update
console.log('[TEST] Waiting for metrics to update');
await new Promise(resolve => setTimeout(resolve, 500)); // Increased timeout
// Verify metrics tracking is working
console.log('[TEST] Current requests served:', testProxy.requestsServed);
console.log('[TEST] Connected clients:', testProxy.connectedClients);
expect(testProxy.connectedClients).toBeDefined();
expect(typeof testProxy.requestsServed).toEqual('number');
// Use ">=" instead of ">" to be more forgiving with edge cases
expect(testProxy.requestsServed).toBeGreaterThanOrEqual(initialRequestsServed + 2);
console.log('[TEST] Metrics test completed successfully');
} catch (error) {
console.error('[TEST] Error in metrics test:', error);
throw error; // Rethrow to fail the test
}
});
tap.test('cleanup', async () => {
try {
console.log('[TEST] Starting cleanup');
// Clean up all servers
console.log('[TEST] Terminating WebSocket clients');
try {
wsServer.clients.forEach((client) => {
try {
client.terminate();
} catch (err) {
console.error('[TEST] Error terminating client:', err);
}
});
} catch (err) {
console.error('[TEST] Error accessing WebSocket clients:', err);
}
console.log('[TEST] Closing WebSocket server');
try {
await new Promise<void>((resolve) => {
wsServer.close(() => {
console.log('[TEST] WebSocket server closed');
resolve();
});
// Add timeout to prevent hanging
setTimeout(() => {
console.log('[TEST] WebSocket server close timed out, continuing');
resolve();
}, 1000);
});
} catch (err) {
console.error('[TEST] Error closing WebSocket server:', err);
}
console.log('[TEST] Closing test server');
try {
await new Promise<void>((resolve) => {
testServer.close(() => {
console.log('[TEST] Test server closed');
resolve();
});
// Add timeout to prevent hanging
setTimeout(() => {
console.log('[TEST] Test server close timed out, continuing');
resolve();
}, 1000);
});
} catch (err) {
console.error('[TEST] Error closing test server:', err);
}
console.log('[TEST] Stopping proxy');
try {
await testProxy.stop();
} catch (err) {
console.error('[TEST] Error stopping proxy:', err);
}
console.log('[TEST] Cleanup complete');
} catch (error) {
console.error('[TEST] Error during cleanup:', error);
// Don't throw here - we want cleanup to always complete
}
});
process.on('exit', () => {
console.log('[TEST] Shutting down test server');
testServer.close(() => console.log('[TEST] Test server shut down'));
wsServer.close(() => console.log('[TEST] WebSocket server shut down'));
testProxy.stop().then(() => console.log('[TEST] Proxy server stopped'));
});
export default tap.start();

View File

@ -1,392 +0,0 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as tsclass from '@tsclass/tsclass';
import * as http from 'http';
import { ProxyRouter, type IRouterResult } from '../ts/classes.router.js';
// Test proxies and configurations
let router: ProxyRouter;
// Sample hostname for testing
const TEST_DOMAIN = 'example.com';
const TEST_SUBDOMAIN = 'api.example.com';
const TEST_WILDCARD = '*.example.com';
// Helper: Creates a mock HTTP request for testing
function createMockRequest(host: string, url: string = '/'): http.IncomingMessage {
const req = {
headers: { host },
url,
socket: {
remoteAddress: '127.0.0.1'
}
} as any;
return req;
}
// Helper: Creates a test proxy configuration
function createProxyConfig(
hostname: string,
destinationIp: string = '10.0.0.1',
destinationPort: number = 8080
): tsclass.network.IReverseProxyConfig {
return {
hostName: hostname,
publicKey: 'mock-cert',
privateKey: 'mock-key',
destinationIps: [destinationIp],
destinationPorts: [destinationPort],
} as tsclass.network.IReverseProxyConfig;
}
// SETUP: Create a ProxyRouter instance
tap.test('setup proxy router test environment', async () => {
router = new ProxyRouter();
// Initialize with empty config
router.setNewProxyConfigs([]);
});
// Test basic routing by hostname
tap.test('should route requests by hostname', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const req = createMockRequest(TEST_DOMAIN);
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test handling of hostname with port number
tap.test('should handle hostname with port number', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const req = createMockRequest(`${TEST_DOMAIN}:443`);
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test case-insensitive hostname matching
tap.test('should perform case-insensitive hostname matching', async () => {
const config = createProxyConfig(TEST_DOMAIN.toLowerCase());
router.setNewProxyConfigs([config]);
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test handling of unmatched hostnames
tap.test('should return undefined for unmatched hostnames', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
const req = createMockRequest('unknown.domain.com');
const result = router.routeReq(req);
expect(result).toBeUndefined();
});
// Test adding path patterns
tap.test('should match requests using path patterns', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
// Add a path pattern to the config
router.setPathPattern(config, '/api/users');
// Test that path matches
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
const result1 = router.routeReqWithDetails(req1);
expect(result1).toBeTruthy();
expect(result1.config).toEqual(config);
expect(result1.pathMatch).toEqual('/api/users');
// Test that non-matching path doesn't match
const req2 = createMockRequest(TEST_DOMAIN, '/web/users');
const result2 = router.routeReqWithDetails(req2);
expect(result2).toBeUndefined();
});
// Test handling wildcard patterns
tap.test('should support wildcard path patterns', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/api/*');
// Test with path that matches the wildcard pattern
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.pathMatch).toEqual('/api');
// Print the actual value to diagnose issues
console.log('Path remainder value:', result.pathRemainder);
expect(result.pathRemainder).toBeTruthy();
expect(result.pathRemainder).toEqual('/users/123');
});
// Test extracting path parameters
tap.test('should extract path parameters from URL', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/users/:id/profile');
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.id).toEqual('123');
});
// Test multiple configs for same hostname with different paths
tap.test('should support multiple configs for same hostname with different paths', async () => {
const apiConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
const webConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
// Add both configs
router.setNewProxyConfigs([apiConfig, webConfig]);
// Set different path patterns
router.setPathPattern(apiConfig, '/api');
router.setPathPattern(webConfig, '/web');
// Test API path routes to API config
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
const apiResult = router.routeReq(apiReq);
expect(apiResult).toEqual(apiConfig);
// Test web path routes to web config
const webReq = createMockRequest(TEST_DOMAIN, '/web/dashboard');
const webResult = router.routeReq(webReq);
expect(webResult).toEqual(webConfig);
// Test unknown path returns undefined
const unknownReq = createMockRequest(TEST_DOMAIN, '/unknown');
const unknownResult = router.routeReq(unknownReq);
expect(unknownResult).toBeUndefined();
});
// Test wildcard subdomains
tap.test('should match wildcard subdomains', async () => {
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
router.setNewProxyConfigs([wildcardConfig]);
// Test that subdomain.example.com matches *.example.com
const req = createMockRequest('subdomain.example.com');
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(wildcardConfig);
});
// Test TLD wildcards (example.*)
tap.test('should match TLD wildcards', async () => {
const tldWildcardConfig = createProxyConfig('example.*');
router.setNewProxyConfigs([tldWildcardConfig]);
// Test that example.com matches example.*
const req1 = createMockRequest('example.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(tldWildcardConfig);
// Test that example.org matches example.*
const req2 = createMockRequest('example.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(tldWildcardConfig);
// Test that subdomain.example.com doesn't match example.*
const req3 = createMockRequest('subdomain.example.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined();
});
// Test complex pattern matching (*.lossless*)
tap.test('should match complex wildcard patterns', async () => {
const complexWildcardConfig = createProxyConfig('*.lossless*');
router.setNewProxyConfigs([complexWildcardConfig]);
// Test that sub.lossless.com matches *.lossless*
const req1 = createMockRequest('sub.lossless.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(complexWildcardConfig);
// Test that api.lossless.org matches *.lossless*
const req2 = createMockRequest('api.lossless.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(complexWildcardConfig);
// Test that losslessapi.com matches *.lossless*
const req3 = createMockRequest('losslessapi.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain
});
// Test default configuration fallback
tap.test('should fall back to default configuration', async () => {
const defaultConfig = createProxyConfig('*');
const specificConfig = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([defaultConfig, specificConfig]);
// Test specific domain routes to specific config
const specificReq = createMockRequest(TEST_DOMAIN);
const specificResult = router.routeReq(specificReq);
expect(specificResult).toEqual(specificConfig);
// Test unknown domain falls back to default config
const unknownReq = createMockRequest('unknown.com');
const unknownResult = router.routeReq(unknownReq);
expect(unknownResult).toEqual(defaultConfig);
});
// Test priority between exact and wildcard matches
tap.test('should prioritize exact hostname over wildcard', async () => {
const wildcardConfig = createProxyConfig(TEST_WILDCARD);
const exactConfig = createProxyConfig(TEST_SUBDOMAIN);
router.setNewProxyConfigs([wildcardConfig, exactConfig]);
// Test that exact match takes priority
const req = createMockRequest(TEST_SUBDOMAIN);
const result = router.routeReq(req);
expect(result).toEqual(exactConfig);
});
// Test adding and removing configurations
tap.test('should manage configurations correctly', async () => {
router.setNewProxyConfigs([]);
// Add a config
const config = createProxyConfig(TEST_DOMAIN);
router.addProxyConfig(config);
// Verify routing works
const req = createMockRequest(TEST_DOMAIN);
let result = router.routeReq(req);
expect(result).toEqual(config);
// Remove the config and verify it no longer routes
const removed = router.removeProxyConfig(TEST_DOMAIN);
expect(removed).toBeTrue();
result = router.routeReq(req);
expect(result).toBeUndefined();
});
// Test path pattern specificity
tap.test('should prioritize more specific path patterns', async () => {
const genericConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.1', 8001);
const specificConfig = createProxyConfig(TEST_DOMAIN, '10.0.0.2', 8002);
router.setNewProxyConfigs([genericConfig, specificConfig]);
router.setPathPattern(genericConfig, '/api/*');
router.setPathPattern(specificConfig, '/api/users');
// The more specific '/api/users' should match before the '/api/*' wildcard
const req = createMockRequest(TEST_DOMAIN, '/api/users');
const result = router.routeReq(req);
expect(result).toEqual(specificConfig);
});
// Test getHostnames method
tap.test('should retrieve all configured hostnames', async () => {
router.setNewProxyConfigs([
createProxyConfig(TEST_DOMAIN),
createProxyConfig(TEST_SUBDOMAIN)
]);
const hostnames = router.getHostnames();
expect(hostnames.length).toEqual(2);
expect(hostnames).toContain(TEST_DOMAIN.toLowerCase());
expect(hostnames).toContain(TEST_SUBDOMAIN.toLowerCase());
});
// Test handling missing host header
tap.test('should handle missing host header', async () => {
const defaultConfig = createProxyConfig('*');
router.setNewProxyConfigs([defaultConfig]);
const req = createMockRequest('');
req.headers.host = undefined;
const result = router.routeReq(req);
expect(result).toEqual(defaultConfig);
});
// Test complex path parameters
tap.test('should handle complex path parameters', async () => {
const config = createProxyConfig(TEST_DOMAIN);
router.setNewProxyConfigs([config]);
router.setPathPattern(config, '/api/:version/users/:userId/posts/:postId');
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.config).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.version).toEqual('v1');
expect(result.pathParams.userId).toEqual('123');
expect(result.pathParams.postId).toEqual('456');
});
// Performance test
tap.test('should handle many configurations efficiently', async () => {
const configs = [];
// Create many configs with different hostnames
for (let i = 0; i < 100; i++) {
configs.push(createProxyConfig(`host-${i}.example.com`));
}
router.setNewProxyConfigs(configs);
// Test middle of the list to avoid best/worst case
const req = createMockRequest('host-50.example.com');
const result = router.routeReq(req);
expect(result).toEqual(configs[50]);
});
// Test cleanup
tap.test('cleanup proxy router test environment', async () => {
// Clear all configurations
router.setNewProxyConfigs([]);
// Verify empty state
expect(router.getHostnames().length).toEqual(0);
expect(router.getProxyConfigs().length).toEqual(0);
});
export default tap.start();

View File

@ -1,353 +0,0 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as net from 'net';
import { SmartProxy } from '../ts/smartproxy/classes.smartproxy.js';
let testServer: net.Server;
let smartProxy: SmartProxy;
const TEST_SERVER_PORT = 4000;
const PROXY_PORT = 4001;
const TEST_DATA = 'Hello through port proxy!';
// Track all created servers and proxies for proper cleanup
const allServers: net.Server[] = [];
const allProxies: SmartProxy[] = [];
// Helper: Creates a test TCP server that listens on a given port and host.
function createTestServer(port: number, host: string = 'localhost'): Promise<net.Server> {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
socket.on('data', (data) => {
// Echo the received data back with a prefix.
socket.write(`Echo: ${data.toString()}`);
});
socket.on('error', (error) => {
console.error(`[Test Server] Socket error on ${host}:${port}:`, error);
});
});
server.listen(port, host, () => {
console.log(`[Test Server] Listening on ${host}:${port}`);
allServers.push(server); // Track this server
resolve(server);
});
});
}
// Helper: Creates a test client connection.
function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => {
const client = new net.Socket();
let response = '';
const timeout = setTimeout(() => {
client.destroy();
reject(new Error(`Client connection timeout to port ${port}`));
}, 5000);
client.connect(port, 'localhost', () => {
console.log('[Test Client] Connected to server');
client.write(data);
});
client.on('data', (chunk) => {
response += chunk.toString();
client.end();
});
client.on('end', () => {
clearTimeout(timeout);
resolve(response);
});
client.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
}
// SETUP: Create a test server and a PortProxy instance.
tap.test('setup port proxy test environment', async () => {
testServer = await createTestServer(TEST_SERVER_PORT);
smartProxy = new SmartProxy({
fromPort: PROXY_PORT,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
allProxies.push(smartProxy); // Track this proxy
});
// Test that the proxy starts and its servers are listening.
tap.test('should start port proxy', async () => {
await smartProxy.start();
expect((smartProxy as any).netServers.every((server: net.Server) => server.listening)).toBeTrue();
});
// Test basic TCP forwarding.
tap.test('should forward TCP connections and data to localhost', async () => {
const response = await createTestClient(PROXY_PORT, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
});
// Test proxy with a custom target host.
tap.test('should forward TCP connections to custom host', async () => {
const customHostProxy = new SmartProxy({
fromPort: PROXY_PORT + 1,
toPort: TEST_SERVER_PORT,
targetIP: '127.0.0.1',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
globalPortRanges: []
});
allProxies.push(customHostProxy); // Track this proxy
await customHostProxy.start();
const response = await createTestClient(PROXY_PORT + 1, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
await customHostProxy.stop();
// Remove from tracking after stopping
const index = allProxies.indexOf(customHostProxy);
if (index !== -1) allProxies.splice(index, 1);
});
// Test custom IP forwarding
// Modified to work in Docker/CI environments without needing 127.0.0.2
tap.test('should forward connections to custom IP', async () => {
// Set up ports that are FAR apart to avoid any possible confusion
const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on
const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port
// Create a test server listening on a unique port on 127.0.0.1 (works in all environments)
const testServer2 = await createTestServer(targetServerPort, '127.0.0.1');
// We're simulating routing to a different IP by using a different port
// This tests the core functionality without requiring multiple IPs
const domainProxy = new SmartProxy({
fromPort: forcedProxyPort, // 4003 - Listen on this port
toPort: targetServerPort, // 4200 - Forward to this port
targetIP: '127.0.0.1', // Always use localhost (works in Docker)
domainConfigs: [], // No domain configs to confuse things
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'], // Allow localhost
// We'll test the functionality WITHOUT port ranges this time
globalPortRanges: []
});
allProxies.push(domainProxy); // Track this proxy
await domainProxy.start();
// Send a single test connection
const response = await createTestClient(forcedProxyPort, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`);
await domainProxy.stop();
// Remove from tracking after stopping
const proxyIndex = allProxies.indexOf(domainProxy);
if (proxyIndex !== -1) allProxies.splice(proxyIndex, 1);
// Close the test server
await new Promise<void>((resolve) => testServer2.close(() => resolve()));
// Remove from tracking
const serverIndex = allServers.indexOf(testServer2);
if (serverIndex !== -1) allServers.splice(serverIndex, 1);
});
// Test handling of multiple concurrent connections.
tap.test('should handle multiple concurrent connections', async () => {
const concurrentRequests = 5;
const requests = Array(concurrentRequests).fill(null).map((_, i) =>
createTestClient(PROXY_PORT, `${TEST_DATA} ${i + 1}`)
);
const responses = await Promise.all(requests);
responses.forEach((response, i) => {
expect(response).toEqual(`Echo: ${TEST_DATA} ${i + 1}`);
});
});
// Test connection timeout handling.
tap.test('should handle connection timeouts', async () => {
const client = new net.Socket();
await new Promise<void>((resolve) => {
// Add a timeout to ensure we don't hang here
const timeout = setTimeout(() => {
client.destroy();
resolve();
}, 3000);
client.connect(PROXY_PORT, 'localhost', () => {
// Do not send any data to trigger a timeout.
client.on('close', () => {
clearTimeout(timeout);
resolve();
});
});
client.on('error', () => {
clearTimeout(timeout);
client.destroy();
resolve();
});
});
});
// Test stopping the port proxy.
tap.test('should stop port proxy', async () => {
await smartProxy.stop();
expect((smartProxy as any).netServers.every((server: net.Server) => !server.listening)).toBeTrue();
// Remove from tracking
const index = allProxies.indexOf(smartProxy);
if (index !== -1) allProxies.splice(index, 1);
});
// Test chained proxies with and without source IP preservation.
tap.test('should support optional source IP preservation in chained proxies', async () => {
// Chained proxies without IP preservation.
const firstProxyDefault = new SmartProxy({
fromPort: PROXY_PORT + 4,
toPort: PROXY_PORT + 5,
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
const secondProxyDefault = new SmartProxy({
fromPort: PROXY_PORT + 5,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1', '::ffff:127.0.0.1'],
globalPortRanges: []
});
allProxies.push(firstProxyDefault, secondProxyDefault); // Track these proxies
await secondProxyDefault.start();
await firstProxyDefault.start();
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyDefault.stop();
await secondProxyDefault.stop();
// Remove from tracking
const index1 = allProxies.indexOf(firstProxyDefault);
if (index1 !== -1) allProxies.splice(index1, 1);
const index2 = allProxies.indexOf(secondProxyDefault);
if (index2 !== -1) allProxies.splice(index2, 1);
// Chained proxies with IP preservation.
const firstProxyPreserved = new SmartProxy({
fromPort: PROXY_PORT + 6,
toPort: PROXY_PORT + 7,
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true,
globalPortRanges: []
});
const secondProxyPreserved = new SmartProxy({
fromPort: PROXY_PORT + 7,
toPort: TEST_SERVER_PORT,
targetIP: 'localhost',
domainConfigs: [],
sniEnabled: false,
defaultAllowedIPs: ['127.0.0.1'],
preserveSourceIP: true,
globalPortRanges: []
});
allProxies.push(firstProxyPreserved, secondProxyPreserved); // Track these proxies
await secondProxyPreserved.start();
await firstProxyPreserved.start();
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyPreserved.stop();
await secondProxyPreserved.stop();
// Remove from tracking
const index3 = allProxies.indexOf(firstProxyPreserved);
if (index3 !== -1) allProxies.splice(index3, 1);
const index4 = allProxies.indexOf(secondProxyPreserved);
if (index4 !== -1) allProxies.splice(index4, 1);
});
// Test round-robin behavior for multiple target hosts in a domain config.
tap.test('should use round robin for multiple target hosts in domain config', async () => {
// Create a domain config with multiple hosts in the target
const domainConfig = {
domains: ['rr.test'],
forwarding: {
type: 'http-only',
target: {
host: ['hostA', 'hostB'], // Array of hosts for round-robin
port: 80
},
http: { enabled: true }
}
};
const proxyInstance = new SmartProxy({
fromPort: 0,
toPort: 0,
targetIP: 'localhost',
domainConfigs: [domainConfig],
sniEnabled: false,
defaultAllowedIPs: [],
globalPortRanges: []
});
// Don't track this proxy as it doesn't actually start or listen
// Get the first target host from the forwarding config
const firstTarget = proxyInstance.domainConfigManager.getTargetHost(domainConfig);
// Get the second target host - should be different due to round-robin
const secondTarget = proxyInstance.domainConfigManager.getTargetHost(domainConfig);
expect(firstTarget).toEqual('hostA');
expect(secondTarget).toEqual('hostB');
});
// CLEANUP: Tear down all servers and proxies
tap.test('cleanup port proxy test environment', async () => {
// Stop all remaining proxies
for (const proxy of [...allProxies]) {
try {
await proxy.stop();
const index = allProxies.indexOf(proxy);
if (index !== -1) allProxies.splice(index, 1);
} catch (err) {
console.error(`Error stopping proxy: ${err}`);
}
}
// Close all remaining servers
for (const server of [...allServers]) {
try {
await new Promise<void>((resolve) => {
if (server.listening) {
server.close(() => resolve());
} else {
resolve();
}
});
const index = allServers.indexOf(server);
if (index !== -1) allServers.splice(index, 1);
} catch (err) {
console.error(`Error closing server: ${err}`);
}
}
// Verify all resources are cleaned up
expect(allProxies.length).toEqual(0);
expect(allServers.length).toEqual(0);
});
export default tap.start();

18
test/test.ts Normal file
View File

@ -0,0 +1,18 @@
import { expect, tap } from '@pushrocks/tapbundle';
import * as smartproxy from '../ts/index';
let testProxy: smartproxy.SmartProxy;
tap.test('first test', async () => {
testProxy = new smartproxy.SmartProxy();
});
tap.test('should start the testproxy', async () => {
await testProxy.start();
});
tap.test('should close the testproxy', async () => {
await testProxy.stop();
});
tap.start();

View File

@ -1,8 +0,0 @@
/**
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@push.rocks/smartproxy',
version: '12.0.0',
description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.'
}

View File

@ -1,430 +0,0 @@
import * as plugins from './plugins.js';
/**
* Optional path pattern configuration that can be added to proxy configs
*/
export interface IPathPatternConfig {
pathPattern?: string;
}
/**
* Interface for router result with additional metadata
*/
export interface IRouterResult {
config: plugins.tsclass.network.IReverseProxyConfig;
pathMatch?: string;
pathParams?: Record<string, string>;
pathRemainder?: string;
}
/**
* Router for HTTP reverse proxy requests
*
* Supports the following domain matching patterns:
* - Exact matches: "example.com"
* - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com)
* - TLD wildcards: "example.*" (matches example.com, example.org, etc.)
* - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain)
* - Default fallback: "*" (matches any unmatched domain)
*
* Also supports path pattern matching for each domain:
* - Exact path: "/api/users"
* - Wildcard paths: "/api/*"
* - Path parameters: "/users/:id/profile"
*/
export class ProxyRouter {
// Store original configs for reference
private reverseProxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
// Default config to use when no match is found (optional)
private defaultConfig?: plugins.tsclass.network.IReverseProxyConfig;
// Store path patterns separately since they're not in the original interface
private pathPatterns: Map<plugins.tsclass.network.IReverseProxyConfig, string> = new Map();
// Logger interface
private logger: {
error: (message: string, data?: any) => void;
warn: (message: string, data?: any) => void;
info: (message: string, data?: any) => void;
debug: (message: string, data?: any) => void;
};
constructor(
configs?: plugins.tsclass.network.IReverseProxyConfig[],
logger?: {
error: (message: string, data?: any) => void;
warn: (message: string, data?: any) => void;
info: (message: string, data?: any) => void;
debug: (message: string, data?: any) => void;
}
) {
this.logger = logger || console;
if (configs) {
this.setNewProxyConfigs(configs);
}
}
/**
* Sets a new set of reverse configs to be routed to
* @param reverseCandidatesArg Array of reverse proxy configurations
*/
public setNewProxyConfigs(reverseCandidatesArg: plugins.tsclass.network.IReverseProxyConfig[]): void {
this.reverseProxyConfigs = [...reverseCandidatesArg];
// Find default config if any (config with "*" as hostname)
this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*');
this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`);
}
/**
* Routes a request based on hostname and path
* @param req The incoming HTTP request
* @returns The matching proxy config or undefined if no match found
*/
public routeReq(req: plugins.http.IncomingMessage): plugins.tsclass.network.IReverseProxyConfig {
const result = this.routeReqWithDetails(req);
return result ? result.config : undefined;
}
/**
* Routes a request with detailed matching information
* @param req The incoming HTTP request
* @returns Detailed routing result including matched config and path information
*/
public routeReqWithDetails(req: plugins.http.IncomingMessage): IRouterResult | undefined {
// Extract and validate host header
const originalHost = req.headers.host;
if (!originalHost) {
this.logger.error('No host header found in request');
return this.defaultConfig ? { config: this.defaultConfig } : undefined;
}
// Parse URL for path matching
const parsedUrl = plugins.url.parse(req.url || '/');
const urlPath = parsedUrl.pathname || '/';
// Extract hostname without port
const hostWithoutPort = originalHost.split(':')[0].toLowerCase();
// First try exact hostname match
const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath);
if (exactConfig) {
return exactConfig;
}
// Try various wildcard patterns
if (hostWithoutPort.includes('.')) {
const domainParts = hostWithoutPort.split('.');
// Try wildcard subdomain (*.example.com)
if (domainParts.length > 2) {
const wildcardDomain = `*.${domainParts.slice(1).join('.')}`;
const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath);
if (wildcardConfig) {
return wildcardConfig;
}
}
// Try TLD wildcard (example.*)
const baseDomain = domainParts.slice(0, -1).join('.');
const tldWildcardDomain = `${baseDomain}.*`;
const tldWildcardConfig = this.findConfigForHost(tldWildcardDomain, urlPath);
if (tldWildcardConfig) {
return tldWildcardConfig;
}
// Try complex wildcard patterns
const wildcardPatterns = this.findWildcardMatches(hostWithoutPort);
for (const pattern of wildcardPatterns) {
const wildcardConfig = this.findConfigForHost(pattern, urlPath);
if (wildcardConfig) {
return wildcardConfig;
}
}
}
// Fall back to default config if available
if (this.defaultConfig) {
this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`);
return { config: this.defaultConfig };
}
this.logger.error(`No config found for host: ${hostWithoutPort}`);
return undefined;
}
/**
* Find potential wildcard patterns that could match a given hostname
* Handles complex patterns like "*.lossless*" or other partial matches
* @param hostname The hostname to find wildcard matches for
* @returns Array of potential wildcard patterns that could match
*/
private findWildcardMatches(hostname: string): string[] {
const patterns: string[] = [];
const hostnameParts = hostname.split('.');
// Find all configured hostnames that contain wildcards
const wildcardConfigs = this.reverseProxyConfigs.filter(
config => config.hostName.includes('*')
);
// Extract unique wildcard patterns
const wildcardPatterns = [...new Set(
wildcardConfigs.map(config => config.hostName.toLowerCase())
)];
// For each wildcard pattern, check if it could match the hostname
// using simplified regex pattern matching
for (const pattern of wildcardPatterns) {
// Skip the default wildcard '*'
if (pattern === '*') continue;
// Skip already checked patterns (*.domain.com and domain.*)
if (pattern.startsWith('*.') && pattern.indexOf('*', 2) === -1) continue;
if (pattern.endsWith('.*') && pattern.indexOf('*') === pattern.length - 1) continue;
// Convert wildcard pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*'); // Convert * to .* for regex
// Create regex object with case insensitive flag
const regex = new RegExp(`^${regexPattern}$`, 'i');
// If hostname matches this complex pattern, add it to the list
if (regex.test(hostname)) {
patterns.push(pattern);
}
}
return patterns;
}
/**
* Find a config for a specific host and path
*/
private findConfigForHost(hostname: string, path: string): IRouterResult | undefined {
// Find all configs for this hostname
const configs = this.reverseProxyConfigs.filter(
config => config.hostName.toLowerCase() === hostname.toLowerCase()
);
if (configs.length === 0) {
return undefined;
}
// First try configs with path patterns
const configsWithPaths = configs.filter(config => this.pathPatterns.has(config));
// Sort by path pattern specificity - more specific first
configsWithPaths.sort((a, b) => {
const aPattern = this.pathPatterns.get(a) || '';
const bPattern = this.pathPatterns.get(b) || '';
// Exact patterns come before wildcard patterns
const aHasWildcard = aPattern.includes('*');
const bHasWildcard = bPattern.includes('*');
if (aHasWildcard && !bHasWildcard) return 1;
if (!aHasWildcard && bHasWildcard) return -1;
// Longer patterns are considered more specific
return bPattern.length - aPattern.length;
});
// Check each config with path pattern
for (const config of configsWithPaths) {
const pathPattern = this.pathPatterns.get(config);
if (pathPattern) {
const pathMatch = this.matchPath(path, pathPattern);
if (pathMatch) {
return {
config,
pathMatch: pathMatch.matched,
pathParams: pathMatch.params,
pathRemainder: pathMatch.remainder
};
}
}
}
// If no path pattern matched, use the first config without a path pattern
const configWithoutPath = configs.find(config => !this.pathPatterns.has(config));
if (configWithoutPath) {
return { config: configWithoutPath };
}
return undefined;
}
/**
* Matches a URL path against a pattern
* Supports:
* - Exact matches: /users/profile
* - Wildcards: /api/* (matches any path starting with /api/)
* - Path parameters: /users/:id (captures id as a parameter)
*
* @param path The URL path to match
* @param pattern The pattern to match against
* @returns Match result with params and remainder, or null if no match
*/
private matchPath(path: string, pattern: string): {
matched: string;
params: Record<string, string>;
remainder: string;
} | null {
// Handle exact match
if (path === pattern) {
return {
matched: pattern,
params: {},
remainder: ''
};
}
// Handle wildcard match
if (pattern.endsWith('/*')) {
const prefix = pattern.slice(0, -2);
if (path === prefix || path.startsWith(`${prefix}/`)) {
return {
matched: prefix,
params: {},
remainder: path.slice(prefix.length)
};
}
return null;
}
// Handle path parameters
const patternParts = pattern.split('/').filter(p => p);
const pathParts = path.split('/').filter(p => p);
// Too few path parts to match
if (pathParts.length < patternParts.length) {
return null;
}
const params: Record<string, string> = {};
// Compare each part
for (let i = 0; i < patternParts.length; i++) {
const patternPart = patternParts[i];
const pathPart = pathParts[i];
// Handle parameter
if (patternPart.startsWith(':')) {
const paramName = patternPart.slice(1);
params[paramName] = pathPart;
continue;
}
// Handle wildcard at the end
if (patternPart === '*' && i === patternParts.length - 1) {
break;
}
// Handle exact match for this part
if (patternPart !== pathPart) {
return null;
}
}
// Calculate the remainder - the unmatched path parts
const remainderParts = pathParts.slice(patternParts.length);
const remainder = remainderParts.length ? '/' + remainderParts.join('/') : '';
// Calculate the matched path
const matchedParts = patternParts.map((part, i) => {
return part.startsWith(':') ? pathParts[i] : part;
});
const matched = '/' + matchedParts.join('/');
return {
matched,
params,
remainder
};
}
/**
* Gets all currently active proxy configurations
* @returns Array of all active configurations
*/
public getProxyConfigs(): plugins.tsclass.network.IReverseProxyConfig[] {
return [...this.reverseProxyConfigs];
}
/**
* Gets all hostnames that this router is configured to handle
* @returns Array of hostnames
*/
public getHostnames(): string[] {
const hostnames = new Set<string>();
for (const config of this.reverseProxyConfigs) {
if (config.hostName !== '*') {
hostnames.add(config.hostName.toLowerCase());
}
}
return Array.from(hostnames);
}
/**
* Adds a single new proxy configuration
* @param config The configuration to add
* @param pathPattern Optional path pattern for route matching
*/
public addProxyConfig(
config: plugins.tsclass.network.IReverseProxyConfig,
pathPattern?: string
): void {
this.reverseProxyConfigs.push(config);
// Store path pattern if provided
if (pathPattern) {
this.pathPatterns.set(config, pathPattern);
}
}
/**
* Sets a path pattern for an existing config
* @param config The existing configuration
* @param pathPattern The path pattern to set
* @returns Boolean indicating if the config was found and updated
*/
public setPathPattern(
config: plugins.tsclass.network.IReverseProxyConfig,
pathPattern: string
): boolean {
const exists = this.reverseProxyConfigs.includes(config);
if (exists) {
this.pathPatterns.set(config, pathPattern);
return true;
}
return false;
}
/**
* Removes a proxy configuration by hostname
* @param hostname The hostname to remove
* @returns Boolean indicating whether any configs were removed
*/
public removeProxyConfig(hostname: string): boolean {
const initialCount = this.reverseProxyConfigs.length;
// Find configs to remove
const configsToRemove = this.reverseProxyConfigs.filter(
config => config.hostName === hostname
);
// Remove them from the patterns map
for (const config of configsToRemove) {
this.pathPatterns.delete(config);
}
// Filter them out of the configs array
this.reverseProxyConfigs = this.reverseProxyConfigs.filter(
config => config.hostName !== hostname
);
return this.reverseProxyConfigs.length !== initialCount;
}
}

View File

@ -1,23 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import type { IAcmeOptions } from './types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
/**
* Factory to create a Port80Handler with common setup.
* Ensures the certificate store directory exists and instantiates the handler.
* @param options Port80Handler configuration options
* @returns A new Port80Handler instance
*/
export function buildPort80Handler(
options: IAcmeOptions
): Port80Handler {
if (options.certificateStore) {
const certStorePath = path.resolve(options.certificateStore);
if (!fs.existsSync(certStorePath)) {
fs.mkdirSync(certStorePath, { recursive: true });
console.log(`Created certificate store directory: ${certStorePath}`);
}
}
return new Port80Handler(options);
}

View File

@ -1,34 +0,0 @@
import type { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from './types.js';
import type { ICertificateData, ICertificateFailure, ICertificateExpiring } from './types.js';
/**
* Subscribers callback definitions for Port80Handler events
*/
export interface Port80HandlerSubscribers {
onCertificateIssued?: (data: ICertificateData) => void;
onCertificateRenewed?: (data: ICertificateData) => void;
onCertificateFailed?: (data: ICertificateFailure) => void;
onCertificateExpiring?: (data: ICertificateExpiring) => void;
}
/**
* Subscribes to Port80Handler events based on provided callbacks
*/
export function subscribeToPort80Handler(
handler: Port80Handler,
subscribers: Port80HandlerSubscribers
): void {
if (subscribers.onCertificateIssued) {
handler.on(Port80HandlerEvents.CERTIFICATE_ISSUED, subscribers.onCertificateIssued);
}
if (subscribers.onCertificateRenewed) {
handler.on(Port80HandlerEvents.CERTIFICATE_RENEWED, subscribers.onCertificateRenewed);
}
if (subscribers.onCertificateFailed) {
handler.on(Port80HandlerEvents.CERTIFICATE_FAILED, subscribers.onCertificateFailed);
}
if (subscribers.onCertificateExpiring) {
handler.on(Port80HandlerEvents.CERTIFICATE_EXPIRING, subscribers.onCertificateExpiring);
}
}

View File

@ -1,87 +0,0 @@
import * as plugins from '../plugins.js';
import type {
IForwardConfig as ILegacyForwardConfig,
IDomainOptions
} from './types.js';
import type {
IForwardConfig
} from '../smartproxy/types/forwarding.types.js';
/**
* Converts a forwarding configuration target to the legacy format
* for Port80Handler
*/
export function convertToLegacyForwardConfig(
forwardConfig: IForwardConfig
): ILegacyForwardConfig {
// Determine host from the target configuration
const host = Array.isArray(forwardConfig.target.host)
? forwardConfig.target.host[0] // Use the first host in the array
: forwardConfig.target.host;
return {
ip: host,
port: forwardConfig.target.port
};
}
/**
* Creates Port80Handler domain options from a domain name and forwarding config
*/
export function createPort80HandlerOptions(
domain: string,
forwardConfig: IForwardConfig
): IDomainOptions {
// Determine if we should redirect HTTP to HTTPS
let sslRedirect = false;
if (forwardConfig.http?.redirectToHttps) {
sslRedirect = true;
}
// Determine if ACME maintenance should be enabled
// Enable by default for termination types, unless explicitly disabled
const requiresTls =
forwardConfig.type === 'https-terminate-to-http' ||
forwardConfig.type === 'https-terminate-to-https';
const acmeMaintenance =
requiresTls &&
forwardConfig.acme?.enabled !== false;
// Set up forwarding configuration
const options: IDomainOptions = {
domainName: domain,
sslRedirect,
acmeMaintenance
};
// Add ACME challenge forwarding if configured
if (forwardConfig.acme?.forwardChallenges) {
options.acmeForward = {
ip: Array.isArray(forwardConfig.acme.forwardChallenges.host)
? forwardConfig.acme.forwardChallenges.host[0]
: forwardConfig.acme.forwardChallenges.host,
port: forwardConfig.acme.forwardChallenges.port
};
}
// Add HTTP forwarding if this is an HTTP-only config or if HTTP is enabled
const supportsHttp =
forwardConfig.type === 'http-only' ||
(forwardConfig.http?.enabled !== false &&
(forwardConfig.type === 'https-terminate-to-http' ||
forwardConfig.type === 'https-terminate-to-https'));
if (supportsHttp) {
options.forward = {
ip: Array.isArray(forwardConfig.target.host)
? forwardConfig.target.host[0]
: forwardConfig.target.host,
port: forwardConfig.target.port
};
}
return options;
}

View File

@ -1,91 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Shared types for certificate management and domain options
*/
/**
* Domain forwarding configuration
*/
export interface IForwardConfig {
ip: string;
port: number;
}
/**
* Domain configuration options
*/
export interface IDomainOptions {
domainName: string;
sslRedirect: boolean; // if true redirects the request to port 443
acmeMaintenance: boolean; // tries to always have a valid cert for this domain
forward?: IForwardConfig; // forwards all http requests to that target
acmeForward?: IForwardConfig; // forwards letsencrypt requests to this config
}
/**
* Certificate data that can be emitted via events or set from outside
*/
export interface ICertificateData {
domain: string;
certificate: string;
privateKey: string;
expiryDate: Date;
}
/**
* Events emitted by the Port80Handler
*/
export enum Port80HandlerEvents {
CERTIFICATE_ISSUED = 'certificate-issued',
CERTIFICATE_RENEWED = 'certificate-renewed',
CERTIFICATE_FAILED = 'certificate-failed',
CERTIFICATE_EXPIRING = 'certificate-expiring',
MANAGER_STARTED = 'manager-started',
MANAGER_STOPPED = 'manager-stopped',
REQUEST_FORWARDED = 'request-forwarded',
}
/**
* Certificate failure payload type
*/
export interface ICertificateFailure {
domain: string;
error: string;
isRenewal: boolean;
}
/**
* Certificate expiry payload type
*/
export interface ICertificateExpiring {
domain: string;
expiryDate: Date;
daysRemaining: number;
}
/**
* Forwarding configuration for specific domains in ACME setup
*/
export interface IDomainForwardConfig {
domain: string;
forwardConfig?: IForwardConfig;
acmeForwardConfig?: IForwardConfig;
sslRedirect?: boolean;
}
/**
* Unified ACME configuration options used across proxies and handlers
*/
export interface IAcmeOptions {
accountEmail?: string; // Email for Let's Encrypt account
enabled?: boolean; // Whether ACME is enabled
port?: number; // Port to listen on for ACME challenges (default: 80)
useProduction?: boolean; // Use production environment (default: staging)
httpsRedirectPort?: number; // Port to redirect HTTP requests to HTTPS (default: 443)
renewThresholdDays?: number; // Days before expiry to renew certificates
renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains with existing certificates
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
}

View File

@ -1,30 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface ICertificates {
privateKey: string;
publicKey: string;
}
export function loadDefaultCertificates(): ICertificates {
try {
const certPath = path.join(__dirname, '..', 'assets', 'certs');
const privateKey = fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8');
const publicKey = fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8');
if (!privateKey || !publicKey) {
throw new Error('Failed to load default certificates');
}
return {
privateKey,
publicKey
};
} catch (error) {
console.error('Error loading default certificates:', error);
throw error;
}
}

View File

@ -1,12 +1 @@
export * from './nfttablesproxy/classes.nftablesproxy.js';
export * from './networkproxy/index.js';
export * from './port80handler/classes.port80handler.js';
export * from './redirect/classes.redirect.js';
export * from './smartproxy/classes.smartproxy.js';
export * from './smartproxy/classes.pp.snihandler.js';
export * from './smartproxy/classes.pp.interfaces.js';
export * from './common/types.js';
// Export forwarding system
export * as forwarding from './smartproxy/forwarding/index.js';
export * from './smartproxy.classes.smartproxy';

7
ts/interfaces/index.ts Normal file
View File

@ -0,0 +1,7 @@
export interface IHostConfig {
hostName: string;
destinationIp: string;
destinationPort: number;
privateKey: string;
publicKey: string;
}

View File

@ -1,413 +0,0 @@
import * as plugins from '../plugins.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { type INetworkProxyOptions, type ICertificateEntry, type ILogger, createLogger } from './classes.np.types.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { buildPort80Handler } from '../common/acmeFactory.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { IDomainOptions } from '../common/types.js';
/**
* Manages SSL certificates for NetworkProxy including ACME integration
*/
export class CertificateManager {
private defaultCertificates: { key: string; cert: string };
private certificateCache: Map<string, ICertificateEntry> = new Map();
private port80Handler: Port80Handler | null = null;
private externalPort80Handler: boolean = false;
private certificateStoreDir: string;
private logger: ILogger;
private httpsServer: plugins.https.Server | null = null;
constructor(private options: INetworkProxyOptions) {
this.certificateStoreDir = path.resolve(options.acme?.certificateStore || './certs');
this.logger = createLogger(options.logLevel || 'info');
// Ensure certificate store directory exists
try {
if (!fs.existsSync(this.certificateStoreDir)) {
fs.mkdirSync(this.certificateStoreDir, { recursive: true });
this.logger.info(`Created certificate store directory: ${this.certificateStoreDir}`);
}
} catch (error) {
this.logger.warn(`Failed to create certificate store directory: ${error}`);
}
this.loadDefaultCertificates();
}
/**
* Loads default certificates from the filesystem
*/
public loadDefaultCertificates(): void {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const certPath = path.join(__dirname, '..', '..', 'assets', 'certs');
try {
this.defaultCertificates = {
key: fs.readFileSync(path.join(certPath, 'key.pem'), 'utf8'),
cert: fs.readFileSync(path.join(certPath, 'cert.pem'), 'utf8')
};
this.logger.info('Default certificates loaded successfully');
} catch (error) {
this.logger.error('Error loading default certificates', error);
// Generate self-signed fallback certificates
try {
// This is a placeholder for actual certificate generation code
// In a real implementation, you would use a library like selfsigned to generate certs
this.defaultCertificates = {
key: "FALLBACK_KEY_CONTENT",
cert: "FALLBACK_CERT_CONTENT"
};
this.logger.warn('Using fallback self-signed certificates');
} catch (fallbackError) {
this.logger.error('Failed to generate fallback certificates', fallbackError);
throw new Error('Could not load or generate SSL certificates');
}
}
}
/**
* Set the HTTPS server reference for context updates
*/
public setHttpsServer(server: plugins.https.Server): void {
this.httpsServer = server;
}
/**
* Get default certificates
*/
public getDefaultCertificates(): { key: string; cert: string } {
return { ...this.defaultCertificates };
}
/**
* Sets an external Port80Handler for certificate management
*/
public setExternalPort80Handler(handler: Port80Handler): void {
if (this.port80Handler && !this.externalPort80Handler) {
this.logger.warn('Replacing existing internal Port80Handler with external handler');
// Clean up existing handler if needed
if (this.port80Handler !== handler) {
// Unregister event handlers to avoid memory leaks
this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_ISSUED);
this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_RENEWED);
this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_FAILED);
this.port80Handler.removeAllListeners(Port80HandlerEvents.CERTIFICATE_EXPIRING);
}
}
// Set the external handler
this.port80Handler = handler;
this.externalPort80Handler = true;
// Subscribe to Port80Handler events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: this.handleCertificateIssued.bind(this),
onCertificateRenewed: this.handleCertificateIssued.bind(this),
onCertificateFailed: this.handleCertificateFailed.bind(this),
onCertificateExpiring: (data) => {
this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
}
});
this.logger.info('External Port80Handler connected to CertificateManager');
// Register domains with Port80Handler if we have any certificates cached
if (this.certificateCache.size > 0) {
const domains = Array.from(this.certificateCache.keys())
.filter(domain => !domain.includes('*')); // Skip wildcard domains
this.registerDomainsWithPort80Handler(domains);
}
}
/**
* Handle newly issued or renewed certificates from Port80Handler
*/
private handleCertificateIssued(data: { domain: string; certificate: string; privateKey: string; expiryDate: Date }): void {
const { domain, certificate, privateKey, expiryDate } = data;
this.logger.info(`Certificate ${this.certificateCache.has(domain) ? 'renewed' : 'issued'} for ${domain}, valid until ${expiryDate.toISOString()}`);
// Update certificate in HTTPS server
this.updateCertificateCache(domain, certificate, privateKey, expiryDate);
// Save the certificate to the filesystem if not using external handler
if (!this.externalPort80Handler && this.options.acme?.certificateStore) {
this.saveCertificateToStore(domain, certificate, privateKey);
}
}
/**
* Handle certificate issuance failures
*/
private handleCertificateFailed(data: { domain: string; error: string }): void {
this.logger.error(`Certificate issuance failed for ${data.domain}: ${data.error}`);
}
/**
* Saves certificate and private key to the filesystem
*/
private saveCertificateToStore(domain: string, certificate: string, privateKey: string): void {
try {
const certPath = path.join(this.certificateStoreDir, `${domain}.cert.pem`);
const keyPath = path.join(this.certificateStoreDir, `${domain}.key.pem`);
fs.writeFileSync(certPath, certificate);
fs.writeFileSync(keyPath, privateKey);
// Ensure private key has restricted permissions
try {
fs.chmodSync(keyPath, 0o600);
} catch (error) {
this.logger.warn(`Failed to set permissions on private key for ${domain}: ${error}`);
}
this.logger.info(`Saved certificate for ${domain} to ${certPath}`);
} catch (error) {
this.logger.error(`Failed to save certificate for ${domain}: ${error}`);
}
}
/**
* Handles SNI (Server Name Indication) for TLS connections
* Used by the HTTPS server to select the correct certificate for each domain
*/
public handleSNI(domain: string, cb: (err: Error | null, ctx: plugins.tls.SecureContext) => void): void {
this.logger.debug(`SNI request for domain: ${domain}`);
// Check if we have a certificate for this domain
const certs = this.certificateCache.get(domain);
if (certs) {
try {
// Create TLS context with the cached certificate
const context = plugins.tls.createSecureContext({
key: certs.key,
cert: certs.cert
});
this.logger.debug(`Using cached certificate for ${domain}`);
cb(null, context);
return;
} catch (err) {
this.logger.error(`Error creating secure context for ${domain}:`, err);
}
}
// No existing certificate: trigger dynamic provisioning via Port80Handler
if (this.port80Handler) {
try {
this.logger.info(`Triggering on-demand certificate retrieval for ${domain}`);
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: false,
acmeMaintenance: true
});
} catch (err) {
this.logger.error(`Error registering domain for on-demand certificate: ${domain}`, err);
}
}
// Check if we should trigger certificate issuance
if (this.options.acme?.enabled && this.port80Handler && !domain.includes('*')) {
// Check if this domain is already registered
const certData = this.port80Handler.getCertificate(domain);
if (!certData) {
this.logger.info(`No certificate found for ${domain}, registering for issuance`);
// Register with new domain options format
const domainOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
}
}
// Fall back to default certificate
try {
const context = plugins.tls.createSecureContext({
key: this.defaultCertificates.key,
cert: this.defaultCertificates.cert
});
this.logger.debug(`Using default certificate for ${domain}`);
cb(null, context);
} catch (err) {
this.logger.error(`Error creating default secure context:`, err);
cb(new Error('Cannot create secure context'), null);
}
}
/**
* Updates certificate in cache
*/
public updateCertificateCache(domain: string, certificate: string, privateKey: string, expiryDate?: Date): void {
// Update certificate context in HTTPS server if it's running
if (this.httpsServer) {
try {
this.httpsServer.addContext(domain, {
key: privateKey,
cert: certificate
});
this.logger.debug(`Updated SSL context for domain: ${domain}`);
} catch (error) {
this.logger.error(`Error updating SSL context for domain ${domain}:`, error);
}
}
// Update certificate in cache
this.certificateCache.set(domain, {
key: privateKey,
cert: certificate,
expires: expiryDate
});
}
/**
* Gets a certificate for a domain
*/
public getCertificate(domain: string): ICertificateEntry | undefined {
return this.certificateCache.get(domain);
}
/**
* Requests a new certificate for a domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
if (!this.options.acme?.enabled && !this.externalPort80Handler) {
this.logger.warn('ACME certificate management is not enabled');
return false;
}
if (!this.port80Handler) {
this.logger.error('Port80Handler is not initialized');
return false;
}
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
if (domain.includes('*')) {
this.logger.error(`Cannot request certificate for wildcard domain: ${domain}`);
return false;
}
try {
// Use the new domain options format
const domainOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.logger.info(`Certificate request submitted for domain: ${domain}`);
return true;
} catch (error) {
this.logger.error(`Error requesting certificate for domain ${domain}:`, error);
return false;
}
}
/**
* Registers domains with Port80Handler for ACME certificate management
*/
public registerDomainsWithPort80Handler(domains: string[]): void {
if (!this.port80Handler) {
this.logger.warn('Port80Handler is not initialized');
return;
}
for (const domain of domains) {
// Skip wildcard domains - can't get certs for these with HTTP-01 validation
if (domain.includes('*')) {
this.logger.info(`Skipping wildcard domain for ACME: ${domain}`);
continue;
}
// Skip domains already with certificates if configured to do so
if (this.options.acme?.skipConfiguredCerts) {
const cachedCert = this.certificateCache.get(domain);
if (cachedCert) {
this.logger.info(`Skipping domain with existing certificate: ${domain}`);
continue;
}
}
// Register the domain for certificate issuance with new domain options format
const domainOptions: IDomainOptions = {
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
};
this.port80Handler.addDomain(domainOptions);
this.logger.info(`Registered domain for ACME certificate issuance: ${domain}`);
}
}
/**
* Initialize internal Port80Handler
*/
public async initializePort80Handler(): Promise<Port80Handler | null> {
// Skip if using external handler
if (this.externalPort80Handler) {
this.logger.info('Using external Port80Handler, skipping initialization');
return this.port80Handler;
}
if (!this.options.acme?.enabled) {
return null;
}
// Build and configure Port80Handler
this.port80Handler = buildPort80Handler({
port: this.options.acme.port,
accountEmail: this.options.acme.accountEmail,
useProduction: this.options.acme.useProduction,
httpsRedirectPort: this.options.port, // Redirect to our HTTPS port
enabled: this.options.acme.enabled,
certificateStore: this.options.acme.certificateStore,
skipConfiguredCerts: this.options.acme.skipConfiguredCerts
});
// Subscribe to Port80Handler events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: this.handleCertificateIssued.bind(this),
onCertificateRenewed: this.handleCertificateIssued.bind(this),
onCertificateFailed: this.handleCertificateFailed.bind(this),
onCertificateExpiring: (data) => {
this.logger.info(`Certificate for ${data.domain} expires in ${data.daysRemaining} days`);
}
});
// Start the handler
try {
await this.port80Handler.start();
this.logger.info(`Port80Handler started on port ${this.options.acme.port}`);
return this.port80Handler;
} catch (error) {
this.logger.error(`Failed to start Port80Handler: ${error}`);
this.port80Handler = null;
return null;
}
}
/**
* Stop the Port80Handler if it was internally created
*/
public async stopPort80Handler(): Promise<void> {
if (this.port80Handler && !this.externalPort80Handler) {
try {
await this.port80Handler.stop();
this.logger.info('Port80Handler stopped');
} catch (error) {
this.logger.error('Error stopping Port80Handler', error);
}
}
}
}

View File

@ -1,241 +0,0 @@
import * as plugins from '../plugins.js';
import { type INetworkProxyOptions, type IConnectionEntry, type ILogger, createLogger } from './classes.np.types.js';
/**
* Manages a pool of backend connections for efficient reuse
*/
export class ConnectionPool {
private connectionPool: Map<string, Array<IConnectionEntry>> = new Map();
private roundRobinPositions: Map<string, number> = new Map();
private logger: ILogger;
constructor(private options: INetworkProxyOptions) {
this.logger = createLogger(options.logLevel || 'info');
}
/**
* Get a connection from the pool or create a new one
*/
public getConnection(host: string, port: number): Promise<plugins.net.Socket> {
return new Promise((resolve, reject) => {
const poolKey = `${host}:${port}`;
const connectionList = this.connectionPool.get(poolKey) || [];
// Look for an idle connection
const idleConnectionIndex = connectionList.findIndex(c => c.isIdle);
if (idleConnectionIndex >= 0) {
// Get existing connection from pool
const connection = connectionList[idleConnectionIndex];
connection.isIdle = false;
connection.lastUsed = Date.now();
this.logger.debug(`Reusing connection from pool for ${poolKey}`);
// Update the pool
this.connectionPool.set(poolKey, connectionList);
resolve(connection.socket);
return;
}
// No idle connection available, create a new one if pool isn't full
const poolSize = this.options.connectionPoolSize || 50;
if (connectionList.length < poolSize) {
this.logger.debug(`Creating new connection to ${host}:${port}`);
try {
const socket = plugins.net.connect({
host,
port,
keepAlive: true,
keepAliveInitialDelay: 30000 // 30 seconds
});
socket.once('connect', () => {
// Add to connection pool
const connection = {
socket,
lastUsed: Date.now(),
isIdle: false
};
connectionList.push(connection);
this.connectionPool.set(poolKey, connectionList);
// Setup cleanup when the connection is closed
socket.once('close', () => {
const idx = connectionList.findIndex(c => c.socket === socket);
if (idx >= 0) {
connectionList.splice(idx, 1);
this.connectionPool.set(poolKey, connectionList);
this.logger.debug(`Removed closed connection from pool for ${poolKey}`);
}
});
resolve(socket);
});
socket.once('error', (err) => {
this.logger.error(`Error creating connection to ${host}:${port}`, err);
reject(err);
});
} catch (err) {
this.logger.error(`Failed to create connection to ${host}:${port}`, err);
reject(err);
}
} else {
// Pool is full, wait for an idle connection or reject
this.logger.warn(`Connection pool for ${poolKey} is full (${connectionList.length})`);
reject(new Error(`Connection pool for ${poolKey} is full`));
}
});
}
/**
* Return a connection to the pool for reuse
*/
public returnConnection(socket: plugins.net.Socket, host: string, port: number): void {
const poolKey = `${host}:${port}`;
const connectionList = this.connectionPool.get(poolKey) || [];
// Find this connection in the pool
const connectionIndex = connectionList.findIndex(c => c.socket === socket);
if (connectionIndex >= 0) {
// Mark as idle and update last used time
connectionList[connectionIndex].isIdle = true;
connectionList[connectionIndex].lastUsed = Date.now();
this.logger.debug(`Returned connection to pool for ${poolKey}`);
} else {
this.logger.warn(`Attempted to return unknown connection to pool for ${poolKey}`);
}
}
/**
* Cleanup the connection pool by removing idle connections
* or reducing pool size if it exceeds the configured maximum
*/
public cleanupConnectionPool(): void {
const now = Date.now();
const idleTimeout = this.options.keepAliveTimeout || 120000; // 2 minutes default
for (const [host, connections] of this.connectionPool.entries()) {
// Sort by last used time (oldest first)
connections.sort((a, b) => a.lastUsed - b.lastUsed);
// Remove idle connections older than the idle timeout
let removed = 0;
while (connections.length > 0) {
const connection = connections[0];
// Remove if idle and exceeds timeout, or if pool is too large
if ((connection.isIdle && now - connection.lastUsed > idleTimeout) ||
connections.length > (this.options.connectionPoolSize || 50)) {
try {
if (!connection.socket.destroyed) {
connection.socket.end();
connection.socket.destroy();
}
} catch (err) {
this.logger.error(`Error destroying pooled connection to ${host}`, err);
}
connections.shift(); // Remove from pool
removed++;
} else {
break; // Stop removing if we've reached active or recent connections
}
}
if (removed > 0) {
this.logger.debug(`Removed ${removed} idle connections from pool for ${host}, ${connections.length} remaining`);
}
// Update the pool with the remaining connections
if (connections.length === 0) {
this.connectionPool.delete(host);
} else {
this.connectionPool.set(host, connections);
}
}
}
/**
* Close all connections in the pool
*/
public closeAllConnections(): void {
for (const [host, connections] of this.connectionPool.entries()) {
this.logger.debug(`Closing ${connections.length} connections to ${host}`);
for (const connection of connections) {
try {
if (!connection.socket.destroyed) {
connection.socket.end();
connection.socket.destroy();
}
} catch (error) {
this.logger.error(`Error closing connection to ${host}:`, error);
}
}
}
this.connectionPool.clear();
this.roundRobinPositions.clear();
}
/**
* Get load balancing target using round-robin
*/
public getNextTarget(targets: string[], port: number): { host: string, port: number } {
const targetKey = targets.join(',');
// Initialize position if not exists
if (!this.roundRobinPositions.has(targetKey)) {
this.roundRobinPositions.set(targetKey, 0);
}
// Get current position and increment for next time
const currentPosition = this.roundRobinPositions.get(targetKey)!;
const nextPosition = (currentPosition + 1) % targets.length;
this.roundRobinPositions.set(targetKey, nextPosition);
// Return the selected target
return {
host: targets[currentPosition],
port
};
}
/**
* Gets the connection pool status
*/
public getPoolStatus(): Record<string, { total: number, idle: number }> {
return Object.fromEntries(
Array.from(this.connectionPool.entries()).map(([host, connections]) => [
host,
{
total: connections.length,
idle: connections.filter(c => c.isIdle).length
}
])
);
}
/**
* Setup a periodic cleanup task
*/
public setupPeriodicCleanup(interval: number = 60000): NodeJS.Timeout {
const timer = setInterval(() => {
this.cleanupConnectionPool();
}, interval);
// Don't prevent process exit
if (timer.unref) {
timer.unref();
}
return timer;
}
}

View File

@ -1,477 +0,0 @@
import * as plugins from '../plugins.js';
import { type INetworkProxyOptions, type ILogger, createLogger, type IReverseProxyConfig } from './classes.np.types.js';
import { CertificateManager } from './classes.np.certificatemanager.js';
import { ConnectionPool } from './classes.np.connectionpool.js';
import { RequestHandler, type IMetricsTracker } from './classes.np.requesthandler.js';
import { WebSocketHandler } from './classes.np.websockethandler.js';
import { ProxyRouter } from '../classes.router.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
/**
* NetworkProxy provides a reverse proxy with TLS termination, WebSocket support,
* automatic certificate management, and high-performance connection pooling.
*/
export class NetworkProxy implements IMetricsTracker {
// Provide a minimal JSON representation to avoid circular references during deep equality checks
public toJSON(): any {
return {};
}
// Configuration
public options: INetworkProxyOptions;
public proxyConfigs: IReverseProxyConfig[] = [];
// Server instances (HTTP/2 with HTTP/1 fallback)
public httpsServer: any;
// Core components
private certificateManager: CertificateManager;
private connectionPool: ConnectionPool;
private requestHandler: RequestHandler;
private webSocketHandler: WebSocketHandler;
private router = new ProxyRouter();
// State tracking
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
public activeContexts: Set<string> = new Set();
public connectedClients: number = 0;
public startTime: number = 0;
public requestsServed: number = 0;
public failedRequests: number = 0;
// Tracking for PortProxy integration
private portProxyConnections: number = 0;
private tlsTerminatedConnections: number = 0;
// Timers
private metricsInterval: NodeJS.Timeout;
private connectionPoolCleanupInterval: NodeJS.Timeout;
// Logger
private logger: ILogger;
/**
* Creates a new NetworkProxy instance
*/
constructor(optionsArg: INetworkProxyOptions) {
// Set default options
this.options = {
port: optionsArg.port,
maxConnections: optionsArg.maxConnections || 10000,
keepAliveTimeout: optionsArg.keepAliveTimeout || 120000, // 2 minutes
headersTimeout: optionsArg.headersTimeout || 60000, // 1 minute
logLevel: optionsArg.logLevel || 'info',
cors: optionsArg.cors || {
allowOrigin: '*',
allowMethods: 'GET, POST, PUT, DELETE, OPTIONS',
allowHeaders: 'Content-Type, Authorization',
maxAge: 86400
},
// Defaults for PortProxy integration
connectionPoolSize: optionsArg.connectionPoolSize || 50,
portProxyIntegration: optionsArg.portProxyIntegration || false,
useExternalPort80Handler: optionsArg.useExternalPort80Handler || false,
// Backend protocol (http1 or http2)
backendProtocol: optionsArg.backendProtocol || 'http1',
// Default ACME options
acme: {
enabled: optionsArg.acme?.enabled || false,
port: optionsArg.acme?.port || 80,
accountEmail: optionsArg.acme?.accountEmail || 'admin@example.com',
useProduction: optionsArg.acme?.useProduction || false, // Default to staging for safety
renewThresholdDays: optionsArg.acme?.renewThresholdDays || 30,
autoRenew: optionsArg.acme?.autoRenew !== false, // Default to true
certificateStore: optionsArg.acme?.certificateStore || './certs',
skipConfiguredCerts: optionsArg.acme?.skipConfiguredCerts || false
}
};
// Initialize logger
this.logger = createLogger(this.options.logLevel);
// Initialize components
this.certificateManager = new CertificateManager(this.options);
this.connectionPool = new ConnectionPool(this.options);
this.requestHandler = new RequestHandler(this.options, this.connectionPool, this.router);
this.webSocketHandler = new WebSocketHandler(this.options, this.connectionPool, this.router);
// Connect request handler to this metrics tracker
this.requestHandler.setMetricsTracker(this);
}
/**
* Implements IMetricsTracker interface to increment request counters
*/
public incrementRequestsServed(): void {
this.requestsServed++;
}
/**
* Implements IMetricsTracker interface to increment failed request counters
*/
public incrementFailedRequests(): void {
this.failedRequests++;
}
/**
* Returns the port number this NetworkProxy is listening on
* Useful for PortProxy to determine where to forward connections
*/
public getListeningPort(): number {
return this.options.port;
}
/**
* Updates the server capacity settings
* @param maxConnections Maximum number of simultaneous connections
* @param keepAliveTimeout Keep-alive timeout in milliseconds
* @param connectionPoolSize Size of the connection pool per backend
*/
public updateCapacity(maxConnections?: number, keepAliveTimeout?: number, connectionPoolSize?: number): void {
if (maxConnections !== undefined) {
this.options.maxConnections = maxConnections;
this.logger.info(`Updated max connections to ${maxConnections}`);
}
if (keepAliveTimeout !== undefined) {
this.options.keepAliveTimeout = keepAliveTimeout;
if (this.httpsServer) {
this.httpsServer.keepAliveTimeout = keepAliveTimeout;
this.logger.info(`Updated keep-alive timeout to ${keepAliveTimeout}ms`);
}
}
if (connectionPoolSize !== undefined) {
this.options.connectionPoolSize = connectionPoolSize;
this.logger.info(`Updated connection pool size to ${connectionPoolSize}`);
// Clean up excess connections in the pool
this.connectionPool.cleanupConnectionPool();
}
}
/**
* Returns current server metrics
* Useful for PortProxy to determine which NetworkProxy to use for load balancing
*/
public getMetrics(): any {
return {
activeConnections: this.connectedClients,
totalRequests: this.requestsServed,
failedRequests: this.failedRequests,
portProxyConnections: this.portProxyConnections,
tlsTerminatedConnections: this.tlsTerminatedConnections,
connectionPoolSize: this.connectionPool.getPoolStatus(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
memoryUsage: process.memoryUsage(),
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections
};
}
/**
* Sets an external Port80Handler for certificate management
* This allows the NetworkProxy to use a centrally managed Port80Handler
* instead of creating its own
*
* @param handler The Port80Handler instance to use
*/
public setExternalPort80Handler(handler: Port80Handler): void {
// Connect it to the certificate manager
this.certificateManager.setExternalPort80Handler(handler);
}
/**
* Starts the proxy server
*/
public async start(): Promise<void> {
this.startTime = Date.now();
// Initialize Port80Handler if enabled and not using external handler
if (this.options.acme?.enabled && !this.options.useExternalPort80Handler) {
await this.certificateManager.initializePort80Handler();
}
// Create HTTP/2 server with HTTP/1 fallback
this.httpsServer = plugins.http2.createSecureServer(
{
key: this.certificateManager.getDefaultCertificates().key,
cert: this.certificateManager.getDefaultCertificates().cert,
allowHTTP1: true,
ALPNProtocols: ['h2', 'http/1.1']
}
);
// Track raw TCP connections for metrics and limits
this.setupConnectionTracking();
// Handle incoming HTTP/2 streams
this.httpsServer.on('stream', (stream: any, headers: any) => {
this.requestHandler.handleHttp2(stream, headers);
});
// Handle HTTP/1.x fallback requests
this.httpsServer.on('request', (req: any, res: any) => {
this.requestHandler.handleRequest(req, res);
});
// Share server with certificate manager for dynamic contexts
this.certificateManager.setHttpsServer(this.httpsServer);
// Setup WebSocket support on HTTP/1 fallback
this.webSocketHandler.initialize(this.httpsServer);
// Start metrics logging
this.setupMetricsCollection();
// Start periodic connection pool cleanup
this.connectionPoolCleanupInterval = this.connectionPool.setupPeriodicCleanup();
// Start the server
return new Promise((resolve) => {
this.httpsServer.listen(this.options.port, () => {
this.logger.info(`NetworkProxy started on port ${this.options.port}`);
resolve();
});
});
}
/**
* Sets up tracking of TCP connections
*/
private setupConnectionTracking(): void {
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
// Check if max connections reached
if (this.socketMap.getArray().length >= this.options.maxConnections) {
this.logger.warn(`Max connections (${this.options.maxConnections}) reached, rejecting new connection`);
connection.destroy();
return;
}
// Add connection to tracking
this.socketMap.add(connection);
this.connectedClients = this.socketMap.getArray().length;
// Check for connection from PortProxy by inspecting the source port
const localPort = connection.localPort || 0;
const remotePort = connection.remotePort || 0;
// If this connection is from a PortProxy (usually indicated by it coming from localhost)
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections++;
this.logger.debug(`New connection from PortProxy (local: ${localPort}, remote: ${remotePort})`);
} else {
this.logger.debug(`New direct connection (local: ${localPort}, remote: ${remotePort})`);
}
// Setup connection cleanup handlers
const cleanupConnection = () => {
if (this.socketMap.checkForObject(connection)) {
this.socketMap.remove(connection);
this.connectedClients = this.socketMap.getArray().length;
// If this was a PortProxy connection, decrement the counter
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
this.portProxyConnections--;
}
this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`);
}
};
connection.on('close', cleanupConnection);
connection.on('error', (err) => {
this.logger.debug('Connection error', err);
cleanupConnection();
});
connection.on('end', cleanupConnection);
});
// Track TLS handshake completions
this.httpsServer.on('secureConnection', (tlsSocket) => {
this.tlsTerminatedConnections++;
this.logger.debug('TLS handshake completed, connection secured');
});
}
/**
* Sets up metrics collection
*/
private setupMetricsCollection(): void {
this.metricsInterval = setInterval(() => {
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
const metrics = {
uptime,
activeConnections: this.connectedClients,
totalRequests: this.requestsServed,
failedRequests: this.failedRequests,
portProxyConnections: this.portProxyConnections,
tlsTerminatedConnections: this.tlsTerminatedConnections,
activeWebSockets: this.webSocketHandler.getConnectionInfo().activeConnections,
memoryUsage: process.memoryUsage(),
activeContexts: Array.from(this.activeContexts),
connectionPool: this.connectionPool.getPoolStatus()
};
this.logger.debug('Proxy metrics', metrics);
}, 60000); // Log metrics every minute
// Don't keep process alive just for metrics
if (this.metricsInterval.unref) {
this.metricsInterval.unref();
}
}
/**
* Updates proxy configurations
*/
public async updateProxyConfigs(
proxyConfigsArg: plugins.tsclass.network.IReverseProxyConfig[]
): Promise<void> {
this.logger.info(`Updating proxy configurations (${proxyConfigsArg.length} configs)`);
// Update internal configs
this.proxyConfigs = proxyConfigsArg;
this.router.setNewProxyConfigs(proxyConfigsArg);
// Collect all hostnames for cleanup later
const currentHostNames = new Set<string>();
// Add/update SSL contexts for each host
for (const config of proxyConfigsArg) {
currentHostNames.add(config.hostName);
try {
// Update certificate in cache
this.certificateManager.updateCertificateCache(
config.hostName,
config.publicKey,
config.privateKey
);
this.activeContexts.add(config.hostName);
} catch (error) {
this.logger.error(`Failed to add SSL context for ${config.hostName}`, error);
}
}
// Clean up removed contexts
for (const hostname of this.activeContexts) {
if (!currentHostNames.has(hostname)) {
this.logger.info(`Hostname ${hostname} removed from configuration`);
this.activeContexts.delete(hostname);
}
}
// Register domains with Port80Handler if available
const domainsForACME = Array.from(currentHostNames)
.filter(domain => !domain.includes('*')); // Skip wildcard domains
this.certificateManager.registerDomainsWithPort80Handler(domainsForACME);
}
/**
* Converts PortProxy domain configurations to NetworkProxy configs
* @param domainConfigs PortProxy domain configs
* @param sslKeyPair Default SSL key pair to use if not specified
* @returns Array of NetworkProxy configs
*/
public convertPortProxyConfigs(
domainConfigs: Array<{
domains: string[];
targetIPs?: string[];
allowedIPs?: string[];
}>,
sslKeyPair?: { key: string; cert: string }
): plugins.tsclass.network.IReverseProxyConfig[] {
const proxyConfigs: plugins.tsclass.network.IReverseProxyConfig[] = [];
// Use default certificates if not provided
const defaultCerts = this.certificateManager.getDefaultCertificates();
const sslKey = sslKeyPair?.key || defaultCerts.key;
const sslCert = sslKeyPair?.cert || defaultCerts.cert;
for (const domainConfig of domainConfigs) {
// Each domain in the domains array gets its own config
for (const domain of domainConfig.domains) {
// Skip non-hostname patterns (like IP addresses)
if (domain.match(/^\d+\.\d+\.\d+\.\d+$/) || domain === '*' || domain === 'localhost') {
continue;
}
proxyConfigs.push({
hostName: domain,
destinationIps: domainConfig.targetIPs || ['localhost'],
destinationPorts: [this.options.port], // Use the NetworkProxy port
privateKey: sslKey,
publicKey: sslCert
});
}
}
this.logger.info(`Converted ${domainConfigs.length} PortProxy configs to ${proxyConfigs.length} NetworkProxy configs`);
return proxyConfigs;
}
/**
* Adds default headers to be included in all responses
*/
public async addDefaultHeaders(headersArg: { [key: string]: string }): Promise<void> {
this.logger.info('Adding default headers', headersArg);
this.requestHandler.setDefaultHeaders(headersArg);
}
/**
* Stops the proxy server
*/
public async stop(): Promise<void> {
this.logger.info('Stopping NetworkProxy server');
// Clear intervals
if (this.metricsInterval) {
clearInterval(this.metricsInterval);
}
if (this.connectionPoolCleanupInterval) {
clearInterval(this.connectionPoolCleanupInterval);
}
// Stop WebSocket handler
this.webSocketHandler.shutdown();
// Close all tracked sockets
for (const socket of this.socketMap.getArray()) {
try {
socket.destroy();
} catch (error) {
this.logger.error('Error destroying socket', error);
}
}
// Close all connection pool connections
this.connectionPool.closeAllConnections();
// Stop Port80Handler if internally managed
await this.certificateManager.stopPort80Handler();
// Close the HTTPS server
return new Promise((resolve) => {
this.httpsServer.close(() => {
this.logger.info('NetworkProxy server stopped successfully');
resolve();
});
});
}
/**
* Requests a new certificate for a domain
* This can be used to manually trigger certificate issuance
* @param domain The domain to request a certificate for
* @returns A promise that resolves when the request is submitted (not when the certificate is issued)
*/
public async requestCertificate(domain: string): Promise<boolean> {
return this.certificateManager.requestCertificate(domain);
}
/**
* Gets all proxy configurations currently in use
*/
public getProxyConfigs(): plugins.tsclass.network.IReverseProxyConfig[] {
return [...this.proxyConfigs];
}
}

View File

@ -1,458 +0,0 @@
import * as plugins from '../plugins.js';
import { type INetworkProxyOptions, type ILogger, createLogger, type IReverseProxyConfig } from './classes.np.types.js';
import { ConnectionPool } from './classes.np.connectionpool.js';
import { ProxyRouter } from '../classes.router.js';
/**
* Interface for tracking metrics
*/
export interface IMetricsTracker {
incrementRequestsServed(): void;
incrementFailedRequests(): void;
}
/**
* Handles HTTP request processing and proxying
*/
export class RequestHandler {
private defaultHeaders: { [key: string]: string } = {};
private logger: ILogger;
private metricsTracker: IMetricsTracker | null = null;
// HTTP/2 client sessions for backend proxying
private h2Sessions: Map<string, plugins.http2.ClientHttp2Session> = new Map();
constructor(
private options: INetworkProxyOptions,
private connectionPool: ConnectionPool,
private router: ProxyRouter
) {
this.logger = createLogger(options.logLevel || 'info');
}
/**
* Set the metrics tracker instance
*/
public setMetricsTracker(tracker: IMetricsTracker): void {
this.metricsTracker = tracker;
}
/**
* Set default headers to be included in all responses
*/
public setDefaultHeaders(headers: { [key: string]: string }): void {
this.defaultHeaders = {
...this.defaultHeaders,
...headers
};
this.logger.info('Updated default response headers');
}
/**
* Get all default headers
*/
public getDefaultHeaders(): { [key: string]: string } {
return { ...this.defaultHeaders };
}
/**
* Apply CORS headers to response if configured
*/
private applyCorsHeaders(
res: plugins.http.ServerResponse,
req: plugins.http.IncomingMessage
): void {
if (!this.options.cors) {
return;
}
// Apply CORS headers
if (this.options.cors.allowOrigin) {
res.setHeader('Access-Control-Allow-Origin', this.options.cors.allowOrigin);
}
if (this.options.cors.allowMethods) {
res.setHeader('Access-Control-Allow-Methods', this.options.cors.allowMethods);
}
if (this.options.cors.allowHeaders) {
res.setHeader('Access-Control-Allow-Headers', this.options.cors.allowHeaders);
}
if (this.options.cors.maxAge) {
res.setHeader('Access-Control-Max-Age', this.options.cors.maxAge.toString());
}
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
res.statusCode = 204; // No content
res.end();
return;
}
}
/**
* Apply default headers to response
*/
private applyDefaultHeaders(res: plugins.http.ServerResponse): void {
// Apply default headers
for (const [key, value] of Object.entries(this.defaultHeaders)) {
if (!res.hasHeader(key)) {
res.setHeader(key, value);
}
}
// Add server identifier if not already set
if (!res.hasHeader('Server')) {
res.setHeader('Server', 'NetworkProxy');
}
}
/**
* Handle an HTTP request
*/
public async handleRequest(
req: plugins.http.IncomingMessage,
res: plugins.http.ServerResponse
): Promise<void> {
// Record start time for logging
const startTime = Date.now();
// Apply CORS headers if configured
this.applyCorsHeaders(res, req);
// If this is an OPTIONS request, the response has already been ended in applyCorsHeaders
// so we should return early to avoid trying to set more headers
if (req.method === 'OPTIONS') {
// Increment metrics for OPTIONS requests too
if (this.metricsTracker) {
this.metricsTracker.incrementRequestsServed();
}
return;
}
// Apply default headers
this.applyDefaultHeaders(res);
// Determine routing configuration
let proxyConfig: IReverseProxyConfig | undefined;
try {
proxyConfig = this.router.routeReq(req);
} catch (err) {
this.logger.error('Error routing request', err);
res.statusCode = 500;
res.end('Internal Server Error');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
if (!proxyConfig) {
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
res.statusCode = 404;
res.end('Not Found: No proxy configuration for this host');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
// Determine protocol to backend (per-domain override or global)
const backendProto = proxyConfig.backendProtocol || this.options.backendProtocol;
if (backendProto === 'http2') {
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
const key = `${destination.host}:${destination.port}`;
let session = this.h2Sessions.get(key);
if (!session || session.closed || (session as any).destroyed) {
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
this.h2Sessions.set(key, session);
session.on('error', () => this.h2Sessions.delete(key));
session.on('close', () => this.h2Sessions.delete(key));
}
// Build headers for HTTP/2 request
const hdrs: Record<string, any> = {
':method': req.method,
':path': req.url,
':authority': `${destination.host}:${destination.port}`
};
for (const [hk, hv] of Object.entries(req.headers)) {
if (typeof hv === 'string') hdrs[hk] = hv;
}
const h2Stream = session.request(hdrs);
req.pipe(h2Stream);
h2Stream.on('response', (hdrs2: any) => {
const status = (hdrs2[':status'] as number) || 502;
res.statusCode = status;
// Copy headers from HTTP/2 response to HTTP/1 response
for (const [hk, hv] of Object.entries(hdrs2)) {
if (!hk.startsWith(':') && hv != null) {
res.setHeader(hk, hv as string | string[]);
}
}
h2Stream.pipe(res);
});
h2Stream.on('error', (err) => {
res.statusCode = 502;
res.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
return;
}
try {
// Find target based on hostname
const proxyConfig = this.router.routeReq(req);
if (!proxyConfig) {
// No matching proxy configuration
this.logger.warn(`No proxy configuration for host: ${req.headers.host}`);
res.statusCode = 404;
res.end('Not Found: No proxy configuration for this host');
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
return;
}
// Get destination IP using round-robin if multiple IPs configured
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
// Create options for the proxy request
const options: plugins.http.RequestOptions = {
hostname: destination.host,
port: destination.port,
path: req.url,
method: req.method,
headers: { ...req.headers }
};
// Remove host header to avoid issues with virtual hosts on target server
// The host header should match the target server's expected hostname
if (options.headers && options.headers.host) {
if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) {
options.headers.host = `${destination.host}:${destination.port}`;
}
}
this.logger.debug(
`Proxying request to ${destination.host}:${destination.port}${req.url}`,
{ method: req.method }
);
// Create proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code
res.statusCode = proxyRes.statusCode || 500;
// Copy headers from proxy response to client response
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value !== undefined) {
res.setHeader(key, value);
}
}
// Pipe proxy response to client response
proxyRes.pipe(res);
// Increment served requests counter when the response finishes
res.on('finish', () => {
if (this.metricsTracker) {
this.metricsTracker.incrementRequestsServed();
}
// Log the completed request
const duration = Date.now() - startTime;
this.logger.debug(
`Request completed in ${duration}ms: ${req.method} ${req.url} ${res.statusCode}`,
{ duration, statusCode: res.statusCode }
);
});
});
// Handle proxy request errors
proxyReq.on('error', (error) => {
const duration = Date.now() - startTime;
this.logger.error(
`Proxy error for ${req.method} ${req.url}: ${error.message}`,
{ duration, error: error.message }
);
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
// Check if headers have already been sent
if (!res.headersSent) {
res.statusCode = 502;
res.end(`Bad Gateway: ${error.message}`);
} else {
// If headers already sent, just close the connection
res.end();
}
});
// Pipe request body to proxy request and handle client-side errors
req.pipe(proxyReq);
// Handle client disconnection
req.on('error', (error) => {
this.logger.debug(`Client connection error: ${error.message}`);
proxyReq.destroy();
// Increment failed requests counter on client errors
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
});
// Handle response errors
res.on('error', (error) => {
this.logger.debug(`Response error: ${error.message}`);
proxyReq.destroy();
// Increment failed requests counter on response errors
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
});
} catch (error) {
// Handle any unexpected errors
this.logger.error(
`Unexpected error handling request: ${error.message}`,
{ error: error.stack }
);
// Increment failed requests counter
if (this.metricsTracker) {
this.metricsTracker.incrementFailedRequests();
}
if (!res.headersSent) {
res.statusCode = 500;
res.end('Internal Server Error');
} else {
res.end();
}
}
}
/**
* Handle HTTP/2 stream requests by proxying to HTTP/1 backends
*/
public async handleHttp2(stream: any, headers: any): Promise<void> {
const startTime = Date.now();
const method = headers[':method'] || 'GET';
const path = headers[':path'] || '/';
// If configured to proxy to backends over HTTP/2, use HTTP/2 client sessions
if (this.options.backendProtocol === 'http2') {
const authority = headers[':authority'] as string || '';
const host = authority.split(':')[0];
const fakeReq: any = { headers: { host }, method: headers[':method'], url: headers[':path'], socket: (stream.session as any).socket };
const proxyConfig = this.router.routeReq(fakeReq);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
const destination = this.connectionPool.getNextTarget(proxyConfig.destinationIps, proxyConfig.destinationPorts[0]);
const key = `${destination.host}:${destination.port}`;
let session = this.h2Sessions.get(key);
if (!session || session.closed || (session as any).destroyed) {
session = plugins.http2.connect(`http://${destination.host}:${destination.port}`);
this.h2Sessions.set(key, session);
session.on('error', () => this.h2Sessions.delete(key));
session.on('close', () => this.h2Sessions.delete(key));
}
// Build headers for backend HTTP/2 request
const h2Headers: Record<string, any> = {
':method': headers[':method'],
':path': headers[':path'],
':authority': `${destination.host}:${destination.port}`
};
for (const [k, v] of Object.entries(headers)) {
if (!k.startsWith(':') && typeof v === 'string') {
h2Headers[k] = v;
}
}
const h2Stream2 = session.request(h2Headers);
stream.pipe(h2Stream2);
h2Stream2.on('response', (hdrs: any) => {
// Map status and headers to client
const resp: Record<string, any> = { ':status': hdrs[':status'] as number };
for (const [hk, hv] of Object.entries(hdrs)) {
if (!hk.startsWith(':') && hv) resp[hk] = hv;
}
stream.respond(resp);
h2Stream2.pipe(stream);
});
h2Stream2.on('error', (err) => {
stream.respond({ ':status': 502 });
stream.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
return;
}
try {
// Determine host for routing
const authority = headers[':authority'] as string || '';
const host = authority.split(':')[0];
// Fake request object for routing
const fakeReq: any = { headers: { host }, method, url: path, socket: (stream.session as any).socket };
const proxyConfig = this.router.routeReq(fakeReq as any);
if (!proxyConfig) {
stream.respond({ ':status': 404 });
stream.end('Not Found');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
return;
}
// Select backend target
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
// Build headers for HTTP/1 proxy
const outboundHeaders: Record<string,string> = {};
for (const [key, value] of Object.entries(headers)) {
if (typeof key === 'string' && typeof value === 'string' && !key.startsWith(':')) {
outboundHeaders[key] = value;
}
}
if (outboundHeaders.host && (proxyConfig as any).rewriteHostHeader) {
outboundHeaders.host = `${destination.host}:${destination.port}`;
}
// Create HTTP/1 proxy request
const proxyReq = plugins.http.request(
{ hostname: destination.host, port: destination.port, path, method, headers: outboundHeaders },
(proxyRes) => {
// Map status and headers back to HTTP/2
const responseHeaders: Record<string, number|string|string[]> = {};
for (const [k, v] of Object.entries(proxyRes.headers)) {
if (v !== undefined) responseHeaders[k] = v;
}
stream.respond({ ':status': proxyRes.statusCode || 500, ...responseHeaders });
proxyRes.pipe(stream);
stream.on('close', () => proxyReq.destroy());
stream.on('error', () => proxyReq.destroy());
if (this.metricsTracker) stream.on('end', () => this.metricsTracker.incrementRequestsServed());
}
);
proxyReq.on('error', (err) => {
stream.respond({ ':status': 502 });
stream.end(`Bad Gateway: ${err.message}`);
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
});
// Pipe client stream to backend
stream.pipe(proxyReq);
} catch (err: any) {
stream.respond({ ':status': 500 });
stream.end('Internal Server Error');
if (this.metricsTracker) this.metricsTracker.incrementFailedRequests();
}
}
}

View File

@ -1,126 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Configuration options for NetworkProxy
*/
import type { IAcmeOptions } from '../common/types.js';
/**
* Configuration options for NetworkProxy
*/
export interface INetworkProxyOptions {
port: number;
maxConnections?: number;
keepAliveTimeout?: number;
headersTimeout?: number;
logLevel?: 'error' | 'warn' | 'info' | 'debug';
cors?: {
allowOrigin?: string;
allowMethods?: string;
allowHeaders?: string;
maxAge?: number;
};
// Settings for PortProxy integration
connectionPoolSize?: number; // Maximum connections to maintain in the pool to each backend
portProxyIntegration?: boolean; // Flag to indicate this proxy is used by PortProxy
useExternalPort80Handler?: boolean; // Flag to indicate using external Port80Handler
// Protocol to use when proxying to backends: HTTP/1.x or HTTP/2
backendProtocol?: 'http1' | 'http2';
// ACME certificate management options
acme?: IAcmeOptions;
}
/**
* Interface for a certificate entry in the cache
*/
export interface ICertificateEntry {
key: string;
cert: string;
expires?: Date;
}
/**
* Interface for reverse proxy configuration
*/
export interface IReverseProxyConfig {
destinationIps: string[];
destinationPorts: number[];
hostName: string;
privateKey: string;
publicKey: string;
authentication?: {
type: 'Basic';
user: string;
pass: string;
};
rewriteHostHeader?: boolean;
/**
* Protocol to use when proxying to this backend: 'http1' or 'http2'.
* Overrides the global backendProtocol option if set.
*/
backendProtocol?: 'http1' | 'http2';
}
/**
* Interface for connection tracking in the pool
*/
export interface IConnectionEntry {
socket: plugins.net.Socket;
lastUsed: number;
isIdle: boolean;
}
/**
* WebSocket with heartbeat interface
*/
export interface IWebSocketWithHeartbeat extends plugins.wsDefault {
lastPong: number;
isAlive: boolean;
}
/**
* Logger interface for consistent logging across components
*/
export interface ILogger {
debug(message: string, data?: any): void;
info(message: string, data?: any): void;
warn(message: string, data?: any): void;
error(message: string, data?: any): void;
}
/**
* Creates a logger based on the specified log level
*/
export function createLogger(logLevel: string = 'info'): ILogger {
const logLevels = {
error: 0,
warn: 1,
info: 2,
debug: 3
};
return {
debug: (message: string, data?: any) => {
if (logLevels[logLevel] >= logLevels.debug) {
console.log(`[DEBUG] ${message}`, data || '');
}
},
info: (message: string, data?: any) => {
if (logLevels[logLevel] >= logLevels.info) {
console.log(`[INFO] ${message}`, data || '');
}
},
warn: (message: string, data?: any) => {
if (logLevels[logLevel] >= logLevels.warn) {
console.warn(`[WARN] ${message}`, data || '');
}
},
error: (message: string, data?: any) => {
if (logLevels[logLevel] >= logLevels.error) {
console.error(`[ERROR] ${message}`, data || '');
}
}
};
}

View File

@ -1,226 +0,0 @@
import * as plugins from '../plugins.js';
import { type INetworkProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger, type IReverseProxyConfig } from './classes.np.types.js';
import { ConnectionPool } from './classes.np.connectionpool.js';
import { ProxyRouter } from '../classes.router.js';
/**
* Handles WebSocket connections and proxying
*/
export class WebSocketHandler {
private heartbeatInterval: NodeJS.Timeout | null = null;
private wsServer: plugins.ws.WebSocketServer | null = null;
private logger: ILogger;
constructor(
private options: INetworkProxyOptions,
private connectionPool: ConnectionPool,
private router: ProxyRouter
) {
this.logger = createLogger(options.logLevel || 'info');
}
/**
* Initialize WebSocket server on an existing HTTPS server
*/
public initialize(server: plugins.https.Server): void {
// Create WebSocket server
this.wsServer = new plugins.ws.WebSocketServer({
server: server,
clientTracking: true
});
// Handle WebSocket connections
this.wsServer.on('connection', (wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage) => {
this.handleWebSocketConnection(wsIncoming, req);
});
// Start the heartbeat interval
this.startHeartbeat();
this.logger.info('WebSocket handler initialized');
}
/**
* Start the heartbeat interval to check for inactive WebSocket connections
*/
private startHeartbeat(): void {
// Clean up existing interval if any
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
// Set up the heartbeat interval (check every 30 seconds)
this.heartbeatInterval = setInterval(() => {
if (!this.wsServer || this.wsServer.clients.size === 0) {
return; // Skip if no active connections
}
this.logger.debug(`WebSocket heartbeat check for ${this.wsServer.clients.size} clients`);
this.wsServer.clients.forEach((ws: plugins.wsDefault) => {
const wsWithHeartbeat = ws as IWebSocketWithHeartbeat;
if (wsWithHeartbeat.isAlive === false) {
this.logger.debug('Terminating inactive WebSocket connection');
return wsWithHeartbeat.terminate();
}
wsWithHeartbeat.isAlive = false;
wsWithHeartbeat.ping();
});
}, 30000);
// Make sure the interval doesn't keep the process alive
if (this.heartbeatInterval.unref) {
this.heartbeatInterval.unref();
}
}
/**
* Handle a new WebSocket connection
*/
private handleWebSocketConnection(wsIncoming: IWebSocketWithHeartbeat, req: plugins.http.IncomingMessage): void {
try {
// Initialize heartbeat tracking
wsIncoming.isAlive = true;
wsIncoming.lastPong = Date.now();
// Handle pong messages to track liveness
wsIncoming.on('pong', () => {
wsIncoming.isAlive = true;
wsIncoming.lastPong = Date.now();
});
// Find target configuration based on request
const proxyConfig = this.router.routeReq(req);
if (!proxyConfig) {
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);
wsIncoming.close(1008, 'No proxy configuration for this host');
return;
}
// Get destination target using round-robin if multiple targets
const destination = this.connectionPool.getNextTarget(
proxyConfig.destinationIps,
proxyConfig.destinationPorts[0]
);
// Build target URL
const protocol = (req.socket as any).encrypted ? 'wss' : 'ws';
const targetUrl = `${protocol}://${destination.host}:${destination.port}${req.url}`;
this.logger.debug(`WebSocket connection from ${req.socket.remoteAddress} to ${targetUrl}`);
// Create headers for outgoing WebSocket connection
const headers: { [key: string]: string } = {};
// Copy relevant headers from incoming request
for (const [key, value] of Object.entries(req.headers)) {
if (value && typeof value === 'string' &&
key.toLowerCase() !== 'connection' &&
key.toLowerCase() !== 'upgrade' &&
key.toLowerCase() !== 'sec-websocket-key' &&
key.toLowerCase() !== 'sec-websocket-version') {
headers[key] = value;
}
}
// Override host header if needed
if ((proxyConfig as IReverseProxyConfig).rewriteHostHeader) {
headers['host'] = `${destination.host}:${destination.port}`;
}
// Create outgoing WebSocket connection
const wsOutgoing = new plugins.wsDefault(targetUrl, {
headers: headers,
followRedirects: true
});
// Handle connection errors
wsOutgoing.on('error', (err) => {
this.logger.error(`WebSocket target connection error: ${err.message}`);
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.close(1011, 'Internal server error');
}
});
// Handle outgoing connection open
wsOutgoing.on('open', () => {
// Forward incoming messages to outgoing connection
wsIncoming.on('message', (data, isBinary) => {
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
wsOutgoing.send(data, { binary: isBinary });
}
});
// Forward outgoing messages to incoming connection
wsOutgoing.on('message', (data, isBinary) => {
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.send(data, { binary: isBinary });
}
});
// Handle closing of connections
wsIncoming.on('close', (code, reason) => {
this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`);
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
wsOutgoing.close(code, reason);
}
});
wsOutgoing.on('close', (code, reason) => {
this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`);
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.close(code, reason);
}
});
this.logger.debug(`WebSocket connection established: ${req.headers.host} -> ${destination.host}:${destination.port}`);
});
} catch (error) {
this.logger.error(`Error handling WebSocket connection: ${error.message}`);
if (wsIncoming.readyState === wsIncoming.OPEN) {
wsIncoming.close(1011, 'Internal server error');
}
}
}
/**
* Get information about active WebSocket connections
*/
public getConnectionInfo(): { activeConnections: number } {
return {
activeConnections: this.wsServer ? this.wsServer.clients.size : 0
};
}
/**
* Shutdown the WebSocket handler
*/
public shutdown(): void {
// Stop heartbeat interval
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
// Close all WebSocket connections
if (this.wsServer) {
this.logger.info(`Closing ${this.wsServer.clients.size} WebSocket connections`);
for (const client of this.wsServer.clients) {
try {
client.terminate();
} catch (error) {
this.logger.error('Error terminating WebSocket client', error);
}
}
// Close the server
this.wsServer.close();
this.wsServer = null;
}
}
}

View File

@ -1,7 +0,0 @@
// Re-export all components for easier imports
export * from './classes.np.types.js';
export * from './classes.np.certificatemanager.js';
export * from './classes.np.connectionpool.js';
export * from './classes.np.requesthandler.js';
export * from './classes.np.websockethandler.js';
export * from './classes.np.networkproxy.js';

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +0,0 @@
// node native scope
import { EventEmitter } from 'events';
import * as http from 'http';
import * as https from 'https';
import * as net from 'net';
import * as tls from 'tls';
import * as url from 'url';
import * as http2 from 'http2';
export { EventEmitter, http, https, net, tls, url, http2 };
// tsclass scope
import * as tsclass from '@tsclass/tsclass';
export { tsclass };
// pushrocks scope
import * as lik from '@push.rocks/lik';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartstring from '@push.rocks/smartstring';
import * as smartacme from '@push.rocks/smartacme';
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js';
import * as taskbuffer from '@push.rocks/taskbuffer';
export {
lik,
smartdelay,
smartrequest,
smartpromise,
smartstring,
smartacme,
smartacmePlugins,
smartacmeHandlers,
taskbuffer,
};
// third party scope
import prettyMs from 'pretty-ms';
import * as ws from 'ws';
import wsDefault from 'ws';
import { minimatch } from 'minimatch';
export { prettyMs, ws, wsDefault, minimatch };

View File

@ -1,679 +0,0 @@
import * as plugins from '../plugins.js';
import { IncomingMessage, ServerResponse } from 'http';
import { Port80HandlerEvents } from '../common/types.js';
import type {
IForwardConfig,
IDomainOptions,
ICertificateData,
ICertificateFailure,
ICertificateExpiring,
IAcmeOptions
} from '../common/types.js';
// (fs and path I/O moved to CertProvisioner)
/**
* Custom error classes for better error handling
*/
export class Port80HandlerError extends Error {
constructor(message: string) {
super(message);
this.name = 'Port80HandlerError';
}
}
export class CertificateError extends Port80HandlerError {
constructor(
message: string,
public readonly domain: string,
public readonly isRenewal: boolean = false
) {
super(`${message} for domain ${domain}${isRenewal ? ' (renewal)' : ''}`);
this.name = 'CertificateError';
}
}
export class ServerError extends Port80HandlerError {
constructor(message: string, public readonly code?: string) {
super(message);
this.name = 'ServerError';
}
}
/**
* Represents a domain configuration with certificate status information
*/
interface IDomainCertificate {
options: IDomainOptions;
certObtained: boolean;
obtainingInProgress: boolean;
certificate?: string;
privateKey?: string;
expiryDate?: Date;
lastRenewalAttempt?: Date;
}
/**
* Configuration options for the Port80Handler
*/
// Port80Handler options moved to common types
/**
* Port80Handler with ACME certificate management and request forwarding capabilities
* Now with glob pattern support for domain matching
*/
export class Port80Handler extends plugins.EventEmitter {
private domainCertificates: Map<string, IDomainCertificate>;
// SmartAcme instance for certificate management
private smartAcme: plugins.smartacme.SmartAcme | null = null;
private smartAcmeHttp01Handler!: plugins.smartacme.handlers.Http01MemoryHandler;
private server: plugins.http.Server | null = null;
// Renewal scheduling is handled externally by SmartProxy
// (Removed internal renewal timer)
private isShuttingDown: boolean = false;
private options: Required<IAcmeOptions>;
/**
* Creates a new Port80Handler
* @param options Configuration options
*/
constructor(options: IAcmeOptions = {}) {
super();
this.domainCertificates = new Map<string, IDomainCertificate>();
// Default options
this.options = {
port: options.port ?? 80,
accountEmail: options.accountEmail ?? 'admin@example.com',
useProduction: options.useProduction ?? false, // Safer default: staging
httpsRedirectPort: options.httpsRedirectPort ?? 443,
enabled: options.enabled ?? true, // Enable by default
certificateStore: options.certificateStore ?? './certs',
skipConfiguredCerts: options.skipConfiguredCerts ?? false,
renewThresholdDays: options.renewThresholdDays ?? 30,
renewCheckIntervalHours: options.renewCheckIntervalHours ?? 24,
autoRenew: options.autoRenew ?? true,
domainForwards: options.domainForwards ?? []
};
}
/**
* Starts the HTTP server for ACME challenges
*/
public async start(): Promise<void> {
if (this.server) {
throw new ServerError('Server is already running');
}
if (this.isShuttingDown) {
throw new ServerError('Server is shutting down');
}
// Skip if disabled
if (this.options.enabled === false) {
console.log('Port80Handler is disabled, skipping start');
return;
}
// Initialize SmartAcme with in-memory HTTP-01 challenge handler
if (this.options.enabled) {
this.smartAcmeHttp01Handler = new plugins.smartacme.handlers.Http01MemoryHandler();
this.smartAcme = new plugins.smartacme.SmartAcme({
accountEmail: this.options.accountEmail,
certManager: new plugins.smartacme.certmanagers.MemoryCertManager(),
environment: this.options.useProduction ? 'production' : 'integration',
challengeHandlers: [ this.smartAcmeHttp01Handler ],
challengePriority: ['http-01'],
});
await this.smartAcme.start();
}
return new Promise((resolve, reject) => {
try {
this.server = plugins.http.createServer((req, res) => this.handleRequest(req, res));
this.server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES') {
reject(new ServerError(`Permission denied to bind to port ${this.options.port}. Try running with elevated privileges or use a port > 1024.`, error.code));
} else if (error.code === 'EADDRINUSE') {
reject(new ServerError(`Port ${this.options.port} is already in use.`, error.code));
} else {
reject(new ServerError(error.message, error.code));
}
});
this.server.listen(this.options.port, () => {
console.log(`Port80Handler is listening on port ${this.options.port}`);
this.emit(Port80HandlerEvents.MANAGER_STARTED, this.options.port);
// Start certificate process for domains with acmeMaintenance enabled
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip glob patterns for certificate issuance
if (this.isGlobPattern(domain)) {
console.log(`Skipping initial certificate for glob pattern: ${domain}`);
continue;
}
if (domainInfo.options.acmeMaintenance && !domainInfo.certObtained && !domainInfo.obtainingInProgress) {
this.obtainCertificate(domain).catch(err => {
console.error(`Error obtaining initial certificate for ${domain}:`, err);
});
}
}
resolve();
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error starting server';
reject(new ServerError(message));
}
});
}
/**
* Stops the HTTP server and renewal timer
*/
public async stop(): Promise<void> {
if (!this.server) {
return;
}
this.isShuttingDown = true;
return new Promise<void>((resolve) => {
if (this.server) {
this.server.close(() => {
this.server = null;
this.isShuttingDown = false;
this.emit(Port80HandlerEvents.MANAGER_STOPPED);
resolve();
});
} else {
this.isShuttingDown = false;
resolve();
}
});
}
/**
* Adds a domain with configuration options
* @param options Domain configuration options
*/
public addDomain(options: IDomainOptions): void {
if (!options.domainName || typeof options.domainName !== 'string') {
throw new Port80HandlerError('Invalid domain name');
}
const domainName = options.domainName;
if (!this.domainCertificates.has(domainName)) {
this.domainCertificates.set(domainName, {
options,
certObtained: false,
obtainingInProgress: false
});
console.log(`Domain added: ${domainName} with configuration:`, {
sslRedirect: options.sslRedirect,
acmeMaintenance: options.acmeMaintenance,
hasForward: !!options.forward,
hasAcmeForward: !!options.acmeForward
});
// If acmeMaintenance is enabled and not a glob pattern, start certificate process immediately
if (options.acmeMaintenance && this.server && !this.isGlobPattern(domainName)) {
this.obtainCertificate(domainName).catch(err => {
console.error(`Error obtaining initial certificate for ${domainName}:`, err);
});
}
} else {
// Update existing domain with new options
const existing = this.domainCertificates.get(domainName)!;
existing.options = options;
console.log(`Domain ${domainName} configuration updated`);
}
}
/**
* Removes a domain from management
* @param domain The domain to remove
*/
public removeDomain(domain: string): void {
if (this.domainCertificates.delete(domain)) {
console.log(`Domain removed: ${domain}`);
}
}
/**
* Gets the certificate for a domain if it exists
* @param domain The domain to get the certificate for
*/
public getCertificate(domain: string): ICertificateData | null {
// Can't get certificates for glob patterns
if (this.isGlobPattern(domain)) {
return null;
}
const domainInfo = this.domainCertificates.get(domain);
if (!domainInfo || !domainInfo.certObtained || !domainInfo.certificate || !domainInfo.privateKey) {
return null;
}
return {
domain,
certificate: domainInfo.certificate,
privateKey: domainInfo.privateKey,
expiryDate: domainInfo.expiryDate || this.getDefaultExpiryDate()
};
}
/**
* Check if a domain is a glob pattern
* @param domain Domain to check
* @returns True if the domain is a glob pattern
*/
private isGlobPattern(domain: string): boolean {
return domain.includes('*');
}
/**
* Get domain info for a specific domain, using glob pattern matching if needed
* @param requestDomain The actual domain from the request
* @returns The domain info or null if not found
*/
private getDomainInfoForRequest(requestDomain: string): { domainInfo: IDomainCertificate, pattern: string } | null {
// Try direct match first
if (this.domainCertificates.has(requestDomain)) {
return {
domainInfo: this.domainCertificates.get(requestDomain)!,
pattern: requestDomain
};
}
// Then try glob patterns
for (const [pattern, domainInfo] of this.domainCertificates.entries()) {
if (this.isGlobPattern(pattern) && this.domainMatchesPattern(requestDomain, pattern)) {
return { domainInfo, pattern };
}
}
return null;
}
/**
* Check if a domain matches a glob pattern
* @param domain The domain to check
* @param pattern The pattern to match against
* @returns True if the domain matches the pattern
*/
private domainMatchesPattern(domain: string, pattern: string): boolean {
// Handle different glob pattern styles
if (pattern.startsWith('*.')) {
// *.example.com matches any subdomain
const suffix = pattern.substring(2);
return domain.endsWith(suffix) && domain.includes('.') && domain !== suffix;
} else if (pattern.endsWith('.*')) {
// example.* matches any TLD
const prefix = pattern.substring(0, pattern.length - 2);
const domainParts = domain.split('.');
return domain.startsWith(prefix + '.') && domainParts.length >= 2;
} else if (pattern === '*') {
// Wildcard matches everything
return true;
} else {
// Exact match (shouldn't reach here as we check exact matches first)
return domain === pattern;
}
}
/**
* Handles incoming HTTP requests
* @param req The HTTP request
* @param res The HTTP response
*/
private handleRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
const hostHeader = req.headers.host;
if (!hostHeader) {
res.statusCode = 400;
res.end('Bad Request: Host header is missing');
return;
}
// Extract domain (ignoring any port in the Host header)
const domain = hostHeader.split(':')[0];
// Dynamic provisioning: if domain not yet managed, register for ACME and return 503
if (!this.domainCertificates.has(domain)) {
try {
this.addDomain({ domainName: domain, sslRedirect: false, acmeMaintenance: true });
} catch (err) {
console.error(`Error registering domain for on-demand provisioning: ${err}`);
}
res.statusCode = 503;
res.end('Certificate issuance in progress');
return;
}
// Get domain config, using glob pattern matching if needed
const domainMatch = this.getDomainInfoForRequest(domain);
if (!domainMatch) {
res.statusCode = 404;
res.end('Domain not configured');
return;
}
const { domainInfo, pattern } = domainMatch;
const options = domainInfo.options;
// Handle ACME HTTP-01 challenge requests or forwarding
if (req.url && req.url.startsWith('/.well-known/acme-challenge/')) {
// Forward ACME requests if configured
if (options.acmeForward) {
this.forwardRequest(req, res, options.acmeForward, 'ACME challenge');
return;
}
// If not managing ACME for this domain, return 404
if (!options.acmeMaintenance) {
res.statusCode = 404;
res.end('Not found');
return;
}
// Delegate to Http01MemoryHandler
if (this.smartAcmeHttp01Handler) {
this.smartAcmeHttp01Handler.handleRequest(req, res);
} else {
res.statusCode = 500;
res.end('ACME HTTP-01 handler not initialized');
}
return;
}
// Check if we should forward non-ACME requests
if (options.forward) {
this.forwardRequest(req, res, options.forward, 'HTTP');
return;
}
// If certificate exists and sslRedirect is enabled, redirect to HTTPS
// (Skip for glob patterns as they won't have certificates)
if (!this.isGlobPattern(pattern) && domainInfo.certObtained && options.sslRedirect) {
const httpsPort = this.options.httpsRedirectPort;
const portSuffix = httpsPort === 443 ? '' : `:${httpsPort}`;
const redirectUrl = `https://${domain}${portSuffix}${req.url || '/'}`;
res.statusCode = 301;
res.setHeader('Location', redirectUrl);
res.end(`Redirecting to ${redirectUrl}`);
return;
}
// Handle case where certificate maintenance is enabled but not yet obtained
// (Skip for glob patterns as they can't have certificates)
if (!this.isGlobPattern(pattern) && options.acmeMaintenance && !domainInfo.certObtained) {
// Trigger certificate issuance if not already running
if (!domainInfo.obtainingInProgress) {
this.obtainCertificate(domain).catch(err => {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain,
error: errorMessage,
isRenewal: false
});
console.error(`Error obtaining certificate for ${domain}:`, err);
});
}
res.statusCode = 503;
res.end('Certificate issuance in progress, please try again later.');
return;
}
// Default response for unhandled request
res.statusCode = 404;
res.end('No handlers configured for this request');
}
/**
* Forwards an HTTP request to the specified target
* @param req The original request
* @param res The response object
* @param target The forwarding target (IP and port)
* @param requestType Type of request for logging
*/
private forwardRequest(
req: plugins.http.IncomingMessage,
res: plugins.http.ServerResponse,
target: IForwardConfig,
requestType: string
): void {
const options = {
hostname: target.ip,
port: target.port,
path: req.url,
method: req.method,
headers: { ...req.headers }
};
const domain = req.headers.host?.split(':')[0] || 'unknown';
console.log(`Forwarding ${requestType} request for ${domain} to ${target.ip}:${target.port}`);
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code
res.statusCode = proxyRes.statusCode || 500;
// Copy headers
for (const [key, value] of Object.entries(proxyRes.headers)) {
if (value) res.setHeader(key, value);
}
// Pipe response data
proxyRes.pipe(res);
this.emit(Port80HandlerEvents.REQUEST_FORWARDED, {
domain,
requestType,
target: `${target.ip}:${target.port}`,
statusCode: proxyRes.statusCode
});
});
proxyReq.on('error', (error) => {
console.error(`Error forwarding request to ${target.ip}:${target.port}:`, error);
if (!res.headersSent) {
res.statusCode = 502;
res.end(`Proxy error: ${error.message}`);
} else {
res.end();
}
});
// Pipe original request to proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
/**
* Obtains a certificate for a domain using ACME HTTP-01 challenge
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
*/
/**
* Obtains a certificate for a domain using SmartAcme HTTP-01 challenges
* @param domain The domain to obtain a certificate for
* @param isRenewal Whether this is a renewal attempt
*/
private async obtainCertificate(domain: string, isRenewal: boolean = false): Promise<void> {
if (this.isGlobPattern(domain)) {
throw new CertificateError('Cannot obtain certificates for glob pattern domains', domain, isRenewal);
}
const domainInfo = this.domainCertificates.get(domain)!;
if (!domainInfo.options.acmeMaintenance) {
console.log(`Skipping certificate issuance for ${domain} - acmeMaintenance is disabled`);
return;
}
if (domainInfo.obtainingInProgress) {
console.log(`Certificate issuance already in progress for ${domain}`);
return;
}
if (!this.smartAcme) {
throw new Port80HandlerError('SmartAcme is not initialized');
}
domainInfo.obtainingInProgress = true;
domainInfo.lastRenewalAttempt = new Date();
try {
// Request certificate via SmartAcme
const certObj = await this.smartAcme.getCertificateForDomain(domain);
const certificate = certObj.publicKey;
const privateKey = certObj.privateKey;
const expiryDate = new Date(certObj.validUntil);
domainInfo.certificate = certificate;
domainInfo.privateKey = privateKey;
domainInfo.certObtained = true;
domainInfo.expiryDate = expiryDate;
console.log(`Certificate ${isRenewal ? 'renewed' : 'obtained'} for ${domain}`);
// Persistence moved to CertProvisioner
const eventType = isRenewal
? Port80HandlerEvents.CERTIFICATE_RENEWED
: Port80HandlerEvents.CERTIFICATE_ISSUED;
this.emitCertificateEvent(eventType, {
domain,
certificate,
privateKey,
expiryDate: expiryDate || this.getDefaultExpiryDate()
});
} catch (error: any) {
const errorMsg = error?.message || 'Unknown error';
console.error(`Error during certificate issuance for ${domain}:`, error);
this.emit(Port80HandlerEvents.CERTIFICATE_FAILED, {
domain,
error: errorMsg,
isRenewal
} as ICertificateFailure);
throw new CertificateError(errorMsg, domain, isRenewal);
} finally {
domainInfo.obtainingInProgress = false;
}
}
/**
* Extract expiry date from certificate using a more robust approach
* @param certificate Certificate PEM string
* @param domain Domain for logging
* @returns Extracted expiry date or default
*/
private extractExpiryDateFromCertificate(certificate: string, domain: string): Date {
try {
// This is still using regex, but in a real implementation you would use
// a library like node-forge or x509 to properly parse the certificate
const matches = certificate.match(/Not After\s*:\s*(.*?)(?:\n|$)/i);
if (matches && matches[1]) {
const expiryDate = new Date(matches[1]);
// Validate that we got a valid date
if (!isNaN(expiryDate.getTime())) {
console.log(`Certificate for ${domain} will expire on ${expiryDate.toISOString()}`);
return expiryDate;
}
}
console.warn(`Could not extract valid expiry date from certificate for ${domain}, using default`);
return this.getDefaultExpiryDate();
} catch (error) {
console.warn(`Failed to extract expiry date from certificate for ${domain}, using default`);
return this.getDefaultExpiryDate();
}
}
/**
* Get a default expiry date (90 days from now)
* @returns Default expiry date
*/
private getDefaultExpiryDate(): Date {
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days default
}
/**
* Emits a certificate event with the certificate data
* @param eventType The event type to emit
* @param data The certificate data
*/
private emitCertificateEvent(eventType: Port80HandlerEvents, data: ICertificateData): void {
this.emit(eventType, data);
}
/**
* Gets all domains and their certificate status
* @returns Map of domains to certificate status
*/
public getDomainCertificateStatus(): Map<string, {
certObtained: boolean;
expiryDate?: Date;
daysRemaining?: number;
obtainingInProgress: boolean;
lastRenewalAttempt?: Date;
}> {
const result = new Map<string, {
certObtained: boolean;
expiryDate?: Date;
daysRemaining?: number;
obtainingInProgress: boolean;
lastRenewalAttempt?: Date;
}>();
const now = new Date();
for (const [domain, domainInfo] of this.domainCertificates.entries()) {
// Skip glob patterns
if (this.isGlobPattern(domain)) continue;
const status: {
certObtained: boolean;
expiryDate?: Date;
daysRemaining?: number;
obtainingInProgress: boolean;
lastRenewalAttempt?: Date;
} = {
certObtained: domainInfo.certObtained,
expiryDate: domainInfo.expiryDate,
obtainingInProgress: domainInfo.obtainingInProgress,
lastRenewalAttempt: domainInfo.lastRenewalAttempt
};
// Calculate days remaining if expiry date is available
if (domainInfo.expiryDate) {
const daysRemaining = Math.ceil(
(domainInfo.expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
status.daysRemaining = daysRemaining;
}
result.set(domain, status);
}
return result;
}
/**
* Request a certificate renewal for a specific domain.
* @param domain The domain to renew.
*/
public async renewCertificate(domain: string): Promise<void> {
if (!this.domainCertificates.has(domain)) {
throw new Port80HandlerError(`Domain not managed: ${domain}`);
}
// Trigger renewal via ACME
await this.obtainCertificate(domain, true);
}
}

View File

@ -1,295 +0,0 @@
import * as plugins from '../plugins.js';
export interface RedirectRule {
/**
* Optional protocol to match (http or https). If not specified, matches both.
*/
fromProtocol?: 'http' | 'https';
/**
* Optional hostname pattern to match. Can use * as wildcard.
* If not specified, matches all hosts.
*/
fromHost?: string;
/**
* Optional path prefix to match. If not specified, matches all paths.
*/
fromPath?: string;
/**
* Target protocol for the redirect (http or https)
*/
toProtocol: 'http' | 'https';
/**
* Target hostname for the redirect. Can use $1, $2, etc. to reference
* captured groups from wildcard matches in fromHost.
*/
toHost: string;
/**
* Optional target path prefix. If not specified, keeps original path.
* Can use $path to reference the original path.
*/
toPath?: string;
/**
* HTTP status code for the redirect (301 for permanent, 302 for temporary)
*/
statusCode?: 301 | 302 | 307 | 308;
}
export class Redirect {
private httpServer?: plugins.http.Server;
private httpsServer?: plugins.https.Server;
private rules: RedirectRule[] = [];
private httpPort: number = 80;
private httpsPort: number = 443;
private sslOptions?: {
key: Buffer;
cert: Buffer;
};
/**
* Create a new Redirect instance
* @param options Configuration options
*/
constructor(options: {
httpPort?: number;
httpsPort?: number;
sslOptions?: {
key: Buffer;
cert: Buffer;
};
rules?: RedirectRule[];
} = {}) {
if (options.httpPort) this.httpPort = options.httpPort;
if (options.httpsPort) this.httpsPort = options.httpsPort;
if (options.sslOptions) this.sslOptions = options.sslOptions;
if (options.rules) this.rules = options.rules;
}
/**
* Add a redirect rule
*/
public addRule(rule: RedirectRule): void {
this.rules.push(rule);
}
/**
* Remove all redirect rules
*/
public clearRules(): void {
this.rules = [];
}
/**
* Set SSL options for HTTPS redirects
*/
public setSslOptions(options: { key: Buffer; cert: Buffer }): void {
this.sslOptions = options;
}
/**
* Process a request according to the configured rules
*/
private handleRequest(
request: plugins.http.IncomingMessage,
response: plugins.http.ServerResponse,
protocol: 'http' | 'https'
): void {
const requestUrl = new URL(
request.url || '/',
`${protocol}://${request.headers.host || 'localhost'}`
);
const host = requestUrl.hostname;
const path = requestUrl.pathname + requestUrl.search;
// Find matching rule
const matchedRule = this.findMatchingRule(protocol, host, path);
if (matchedRule) {
const targetUrl = this.buildTargetUrl(matchedRule, host, path);
console.log(`Redirecting ${protocol}://${host}${path} to ${targetUrl}`);
response.writeHead(matchedRule.statusCode || 302, {
Location: targetUrl,
});
response.end();
} else {
// No matching rule, send 404
response.writeHead(404, { 'Content-Type': 'text/plain' });
response.end('Not Found');
}
}
/**
* Find a matching redirect rule for the given request
*/
private findMatchingRule(
protocol: 'http' | 'https',
host: string,
path: string
): RedirectRule | undefined {
return this.rules.find((rule) => {
// Check protocol match
if (rule.fromProtocol && rule.fromProtocol !== protocol) {
return false;
}
// Check host match
if (rule.fromHost) {
const pattern = rule.fromHost.replace(/\*/g, '(.*)');
const regex = new RegExp(`^${pattern}$`);
if (!regex.test(host)) {
return false;
}
}
// Check path match
if (rule.fromPath && !path.startsWith(rule.fromPath)) {
return false;
}
return true;
});
}
/**
* Build the target URL for a redirect
*/
private buildTargetUrl(rule: RedirectRule, originalHost: string, originalPath: string): string {
let targetHost = rule.toHost;
// Replace wildcards in host
if (rule.fromHost && rule.fromHost.includes('*')) {
const pattern = rule.fromHost.replace(/\*/g, '(.*)');
const regex = new RegExp(`^${pattern}$`);
const matches = originalHost.match(regex);
if (matches) {
for (let i = 1; i < matches.length; i++) {
targetHost = targetHost.replace(`$${i}`, matches[i]);
}
}
}
// Build target path
let targetPath = originalPath;
if (rule.toPath) {
if (rule.toPath.includes('$path')) {
// Replace $path with original path, optionally removing the fromPath prefix
const pathSuffix = rule.fromPath ?
originalPath.substring(rule.fromPath.length) :
originalPath;
targetPath = rule.toPath.replace('$path', pathSuffix);
} else {
targetPath = rule.toPath;
}
}
return `${rule.toProtocol}://${targetHost}${targetPath}`;
}
/**
* Start the redirect server(s)
*/
public async start(): Promise<void> {
const tasks = [];
// Create and start HTTP server if we have a port
if (this.httpPort) {
this.httpServer = plugins.http.createServer((req, res) =>
this.handleRequest(req, res, 'http')
);
const httpStartPromise = new Promise<void>((resolve) => {
this.httpServer?.listen(this.httpPort, () => {
console.log(`HTTP redirect server started on port ${this.httpPort}`);
resolve();
});
});
tasks.push(httpStartPromise);
}
// Create and start HTTPS server if we have SSL options and a port
if (this.httpsPort && this.sslOptions) {
this.httpsServer = plugins.https.createServer(this.sslOptions, (req, res) =>
this.handleRequest(req, res, 'https')
);
const httpsStartPromise = new Promise<void>((resolve) => {
this.httpsServer?.listen(this.httpsPort, () => {
console.log(`HTTPS redirect server started on port ${this.httpsPort}`);
resolve();
});
});
tasks.push(httpsStartPromise);
}
// Wait for all servers to start
await Promise.all(tasks);
}
/**
* Stop the redirect server(s)
*/
public async stop(): Promise<void> {
const tasks = [];
if (this.httpServer) {
const httpStopPromise = new Promise<void>((resolve) => {
this.httpServer?.close(() => {
console.log('HTTP redirect server stopped');
resolve();
});
});
tasks.push(httpStopPromise);
}
if (this.httpsServer) {
const httpsStopPromise = new Promise<void>((resolve) => {
this.httpsServer?.close(() => {
console.log('HTTPS redirect server stopped');
resolve();
});
});
tasks.push(httpsStopPromise);
}
await Promise.all(tasks);
}
}
// For backward compatibility
export class SslRedirect {
private redirect: Redirect;
port: number;
constructor(portArg: number) {
this.port = portArg;
this.redirect = new Redirect({
httpPort: portArg,
rules: [{
fromProtocol: 'http',
toProtocol: 'https',
toHost: '$1',
statusCode: 302
}]
});
}
public async start() {
await this.redirect.start();
}
public async stop() {
await this.redirect.stop();
}
}

View File

@ -0,0 +1,80 @@
import * as plugins from './smartproxy.plugins';
import * as interfaces from './interfaces';
import { SmartproxyRouter } from './smartproxy.classes.router';
export class ProxyWorker {
public httpsServer: plugins.https.Server | plugins.http.Server;
public router = new SmartproxyRouter();
/**
* starts the proxyInstance
*/
public async start() {
this.httpsServer = plugins.http.createServer(async (req, res) => {
req.headers.host = this.router.routeReq(req);
const response = await plugins.smartrequest.request(
`https://${req.headers.host}${req.url}`,
{
method: req.method,
headers: req.headers
},
true
);
res.statusCode = response.statusCode;
for (const header of Object.keys(response.headers)) {
res.setHeader(header, response.headers[header]);
}
response.on('data', data => {
res.write(data);
});
response.on('end', () => {
res.end();
});
});
for (const hostCandidate of this.hostCandidates) {
/* this.httpsServer.addContext(hostCandidate.hostName, {
cert: hostCandidate.publicKey,
key: hostCandidate.privateKey
}); */
}
// Enable websockets
const wss = new plugins.ws.Server({ server: this.httpsServer });
wss.on('connection', function connection(ws) {
const wscConnected = plugins.smartpromise.defer();
const wsc = new plugins.ws(`${ws.url}`);
wsc.on('open', () => {
wscConnected.resolve();
});
ws.on('message', async (message) => {
await wscConnected.promise;
wsc.emit('message', message);
});
wsc.on('message', (message) => {
ws.emit('message', message);
});
// handle closing
ws.on('close', (message) => {
wsc.close();
});
wsc.on('close', (message) => {
ws.close();
});
});
this.httpsServer.listen(3000);
}
public async update() {
await this.start();
}
public async stop() {
const done = plugins.smartpromise.defer();
this.httpsServer.close(() => {
done.resolve();
});
}
}

View File

@ -0,0 +1,8 @@
import * as plugins from './smartproxy.plugins';
export class SmartproxyRouter {
public routeReq(req: plugins.http.IncomingMessage) {
return 'lossless.gmbh';
}
}

View File

@ -0,0 +1,22 @@
import * as plugins from './smartproxy.plugins';
import * as interfaces from './interfaces';
export class SmartProxy {
public hostCandidates: interfaces.IHostConfig[] = [];
public addHostCandidate(hostCandidate: interfaces.IHostConfig) {
// TODO search for old hostCandidates with that target
this.hostCandidates.push(hostCandidate);
}
async start () {
}
async stop () {
}
}

22
ts/smartproxy.plugins.ts Normal file
View File

@ -0,0 +1,22 @@
// node native scope
import crypto from 'crypto';
import http from 'http';
import https from 'https';
export { crypto, http, https };
// pushrocks scope
import * as smartpromise from '@pushrocks/smartpromise';
import * as smartrequest from '@pushrocks/smartrequest';
export {
smartrequest,
smartpromise
};
// third party scope
import ws from 'ws';
export {
ws
};

View File

@ -1,200 +0,0 @@
import * as plugins from '../plugins.js';
import type { IDomainConfig, ISmartProxyCertProvisionObject } from './classes.pp.interfaces.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { ICertificateData } from '../common/types.js';
import type { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
/**
* CertProvisioner manages certificate provisioning and renewal workflows,
* unifying static certificates and HTTP-01 challenges via Port80Handler.
*/
export class CertProvisioner extends plugins.EventEmitter {
private domainConfigs: IDomainConfig[];
private port80Handler: Port80Handler;
private networkProxyBridge: NetworkProxyBridge;
private certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
private forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }>;
private renewThresholdDays: number;
private renewCheckIntervalHours: number;
private autoRenew: boolean;
private renewManager?: plugins.taskbuffer.TaskManager;
// Track provisioning type per domain: 'http01' or 'static'
private provisionMap: Map<string, 'http01' | 'static'>;
/**
* @param domainConfigs Array of domain configuration objects
* @param port80Handler HTTP-01 challenge handler instance
* @param networkProxyBridge Bridge for applying external certificates
* @param certProvider Optional callback returning a static cert or 'http01'
* @param renewThresholdDays Days before expiry to trigger renewals
* @param renewCheckIntervalHours Interval in hours to check for renewals
* @param autoRenew Whether to automatically schedule renewals
*/
constructor(
domainConfigs: IDomainConfig[],
port80Handler: Port80Handler,
networkProxyBridge: NetworkProxyBridge,
certProvider?: (domain: string) => Promise<ISmartProxyCertProvisionObject>,
renewThresholdDays: number = 30,
renewCheckIntervalHours: number = 24,
autoRenew: boolean = true,
forwardConfigs: Array<{ domain: string; forwardConfig?: { ip: string; port: number }; acmeForwardConfig?: { ip: string; port: number }; sslRedirect: boolean }> = []
) {
super();
this.domainConfigs = domainConfigs;
this.port80Handler = port80Handler;
this.networkProxyBridge = networkProxyBridge;
this.certProvisionFunction = certProvider;
this.renewThresholdDays = renewThresholdDays;
this.renewCheckIntervalHours = renewCheckIntervalHours;
this.autoRenew = autoRenew;
this.provisionMap = new Map();
this.forwardConfigs = forwardConfigs;
}
/**
* Start initial provisioning and schedule renewals.
*/
public async start(): Promise<void> {
// Subscribe to Port80Handler certificate events
subscribeToPort80Handler(this.port80Handler, {
onCertificateIssued: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: false });
},
onCertificateRenewed: (data: ICertificateData) => {
this.emit('certificate', { ...data, source: 'http01', isRenewal: true });
}
});
// Apply external forwarding for ACME challenges (e.g. Synology)
for (const f of this.forwardConfigs) {
this.port80Handler.addDomain({
domainName: f.domain,
sslRedirect: f.sslRedirect,
acmeMaintenance: false,
forward: f.forwardConfig,
acmeForward: f.acmeForwardConfig
});
}
// Initial provisioning for all domains
const domains = this.domainConfigs.flatMap(cfg => cfg.domains);
for (const domain of domains) {
const isWildcard = domain.includes('*');
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
if (this.certProvisionFunction) {
try {
provision = await this.certProvisionFunction(domain);
} catch (err) {
console.error(`certProvider error for ${domain}:`, err);
}
} else if (isWildcard) {
// No certProvider: cannot handle wildcard without DNS-01 support
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
continue;
}
if (provision === 'http01') {
if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
continue;
}
this.provisionMap.set(domain, 'http01');
this.port80Handler.addDomain({ domainName: domain, sslRedirect: true, acmeMaintenance: true });
} else {
// Static certificate (e.g., DNS-01 provisioned or user-provided) supports wildcard domains
this.provisionMap.set(domain, 'static');
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
}
}
// Schedule renewals if enabled
if (this.autoRenew) {
this.renewManager = new plugins.taskbuffer.TaskManager();
const renewTask = new plugins.taskbuffer.Task({
name: 'CertificateRenewals',
taskFunction: async () => {
for (const [domain, type] of this.provisionMap.entries()) {
// Skip wildcard domains
if (domain.includes('*')) continue;
try {
if (type === 'http01') {
await this.port80Handler.renewCertificate(domain);
} else if (type === 'static' && this.certProvisionFunction) {
const provision2 = await this.certProvisionFunction(domain);
if (provision2 !== 'http01') {
const certObj = provision2 as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: true });
}
}
} catch (err) {
console.error(`Renewal error for ${domain}:`, err);
}
}
}
});
const hours = this.renewCheckIntervalHours;
const cronExpr = `0 0 */${hours} * * *`;
this.renewManager.addAndScheduleTask(renewTask, cronExpr);
this.renewManager.start();
}
}
/**
* Stop all scheduled renewal tasks.
*/
public async stop(): Promise<void> {
// Stop scheduled renewals
if (this.renewManager) {
this.renewManager.stop();
}
}
/**
* Request a certificate on-demand for the given domain.
* @param domain Domain name to provision
*/
public async requestCertificate(domain: string): Promise<void> {
const isWildcard = domain.includes('*');
// Determine provisioning method
let provision: ISmartProxyCertProvisionObject | 'http01' = 'http01';
if (this.certProvisionFunction) {
provision = await this.certProvisionFunction(domain);
} else if (isWildcard) {
// Cannot perform HTTP-01 on wildcard without certProvider
throw new Error(`Cannot request certificate for wildcard domain without certProvisionFunction: ${domain}`);
}
if (provision === 'http01') {
if (isWildcard) {
throw new Error(`Cannot request HTTP-01 certificate for wildcard domain: ${domain}`);
}
await this.port80Handler.renewCertificate(domain);
} else {
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
this.emit('certificate', { ...certData, source: 'static', isRenewal: false });
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,446 +0,0 @@
import * as plugins from '../plugins.js';
import type { IConnectionRecord, ISmartProxyOptions } from './classes.pp.interfaces.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
/**
* Manages connection lifecycle, tracking, and cleanup
*/
export class ConnectionManager {
private connectionRecords: Map<string, IConnectionRecord> = new Map();
private terminationStats: {
incoming: Record<string, number>;
outgoing: Record<string, number>;
} = { incoming: {}, outgoing: {} };
constructor(
private settings: ISmartProxyOptions,
private securityManager: SecurityManager,
private timeoutManager: TimeoutManager
) {}
/**
* Generate a unique connection ID
*/
public generateConnectionId(): string {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
/**
* Create and track a new connection
*/
public createConnection(socket: plugins.net.Socket): IConnectionRecord {
const connectionId = this.generateConnectionId();
const remoteIP = socket.remoteAddress || '';
const localPort = socket.localPort || 0;
const record: IConnectionRecord = {
id: connectionId,
incoming: socket,
outgoing: null,
incomingStartTime: Date.now(),
lastActivity: Date.now(),
connectionClosed: false,
pendingData: [],
pendingDataSize: 0,
bytesReceived: 0,
bytesSent: 0,
remoteIP,
localPort,
isTLS: false,
tlsHandshakeComplete: false,
hasReceivedInitialData: false,
hasKeepAlive: false,
incomingTerminationReason: null,
outgoingTerminationReason: null,
usingNetworkProxy: false,
isBrowserConnection: false,
domainSwitches: 0
};
this.trackConnection(connectionId, record);
return record;
}
/**
* Track an existing connection
*/
public trackConnection(connectionId: string, record: IConnectionRecord): void {
this.connectionRecords.set(connectionId, record);
this.securityManager.trackConnectionByIP(record.remoteIP, connectionId);
}
/**
* Get a connection by ID
*/
public getConnection(connectionId: string): IConnectionRecord | undefined {
return this.connectionRecords.get(connectionId);
}
/**
* Get all active connections
*/
public getConnections(): Map<string, IConnectionRecord> {
return this.connectionRecords;
}
/**
* Get count of active connections
*/
public getConnectionCount(): number {
return this.connectionRecords.size;
}
/**
* Initiates cleanup once for a connection
*/
public initiateCleanupOnce(record: IConnectionRecord, reason: string = 'normal'): void {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection cleanup initiated for ${record.remoteIP} (${reason})`);
}
if (
record.incomingTerminationReason === null ||
record.incomingTerminationReason === undefined
) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
}
this.cleanupConnection(record, reason);
}
/**
* Clean up a connection record
*/
public cleanupConnection(record: IConnectionRecord, reason: string = 'normal'): void {
if (!record.connectionClosed) {
record.connectionClosed = true;
// Track connection termination
this.securityManager.removeConnectionByIP(record.remoteIP, record.id);
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// Detailed logging data
const duration = Date.now() - record.incomingStartTime;
const bytesReceived = record.bytesReceived;
const bytesSent = record.bytesSent;
// Remove all data handlers to make sure we clean up properly
if (record.incoming) {
try {
// Remove our safe data handler
record.incoming.removeAllListeners('data');
// Reset the handler references
record.renegotiationHandler = undefined;
} catch (err) {
console.log(`[${record.id}] Error removing data handlers: ${err}`);
}
}
// Handle incoming socket
this.cleanupSocket(record, 'incoming', record.incoming);
// Handle outgoing socket
if (record.outgoing) {
this.cleanupSocket(record, 'outgoing', record.outgoing);
}
// Clear pendingData to avoid memory leaks
record.pendingData = [];
record.pendingDataSize = 0;
// Remove the record from the tracking map
this.connectionRecords.delete(record.id);
// Log connection details
if (this.settings.enableDetailedLogging) {
console.log(
`[${record.id}] Connection from ${record.remoteIP} on port ${record.localPort} terminated (${reason}).` +
` Duration: ${plugins.prettyMs(duration)}, Bytes IN: ${bytesReceived}, OUT: ${bytesSent}, ` +
`TLS: ${record.isTLS ? 'Yes' : 'No'}, Keep-Alive: ${record.hasKeepAlive ? 'Yes' : 'No'}` +
`${record.usingNetworkProxy ? ', Using NetworkProxy' : ''}` +
`${record.domainSwitches ? `, Domain switches: ${record.domainSwitches}` : ''}`
);
} else {
console.log(
`[${record.id}] Connection from ${record.remoteIP} terminated (${reason}). Active connections: ${this.connectionRecords.size}`
);
}
}
}
/**
* Helper method to clean up a socket
*/
private cleanupSocket(record: IConnectionRecord, side: 'incoming' | 'outgoing', socket: plugins.net.Socket): void {
try {
if (!socket.destroyed) {
// Try graceful shutdown first, then force destroy after a short timeout
socket.end();
const socketTimeout = setTimeout(() => {
try {
if (!socket.destroyed) {
socket.destroy();
}
} catch (err) {
console.log(`[${record.id}] Error destroying ${side} socket: ${err}`);
}
}, 1000);
// Ensure the timeout doesn't block Node from exiting
if (socketTimeout.unref) {
socketTimeout.unref();
}
}
} catch (err) {
console.log(`[${record.id}] Error closing ${side} socket: ${err}`);
try {
if (!socket.destroyed) {
socket.destroy();
}
} catch (destroyErr) {
console.log(`[${record.id}] Error destroying ${side} socket: ${destroyErr}`);
}
}
}
/**
* Creates a generic error handler for incoming or outgoing sockets
*/
public handleError(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return (err: Error) => {
const code = (err as any).code;
let reason = 'error';
const now = Date.now();
const connectionDuration = now - record.incomingStartTime;
const lastActivityAge = now - record.lastActivity;
if (code === 'ECONNRESET') {
reason = 'econnreset';
console.log(
`[${record.id}] ECONNRESET on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
} else if (code === 'ETIMEDOUT') {
reason = 'etimedout';
console.log(
`[${record.id}] ETIMEDOUT on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
} else {
console.log(
`[${record.id}] Error on ${side} side from ${record.remoteIP}: ${err.message}. ` +
`Duration: ${plugins.prettyMs(connectionDuration)}, Last activity: ${plugins.prettyMs(lastActivityAge)} ago`
);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = reason;
this.incrementTerminationStat('incoming', reason);
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = reason;
this.incrementTerminationStat('outgoing', reason);
}
this.initiateCleanupOnce(record, reason);
};
}
/**
* Creates a generic close handler for incoming or outgoing sockets
*/
public handleClose(side: 'incoming' | 'outgoing', record: IConnectionRecord) {
return () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${record.id}] Connection closed on ${side} side from ${record.remoteIP}`);
}
if (side === 'incoming' && record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'normal';
this.incrementTerminationStat('incoming', 'normal');
} else if (side === 'outgoing' && record.outgoingTerminationReason === null) {
record.outgoingTerminationReason = 'normal';
this.incrementTerminationStat('outgoing', 'normal');
// Record the time when outgoing socket closed.
record.outgoingClosedTime = Date.now();
}
this.initiateCleanupOnce(record, 'closed_' + side);
};
}
/**
* Increment termination statistics
*/
public incrementTerminationStat(side: 'incoming' | 'outgoing', reason: string): void {
this.terminationStats[side][reason] = (this.terminationStats[side][reason] || 0) + 1;
}
/**
* Get termination statistics
*/
public getTerminationStats(): { incoming: Record<string, number>; outgoing: Record<string, number> } {
return this.terminationStats;
}
/**
* Check for stalled/inactive connections
*/
public performInactivityCheck(): void {
const now = Date.now();
const connectionIds = [...this.connectionRecords.keys()];
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (!record) continue;
// Skip inactivity check if disabled or for immortal keep-alive connections
if (
this.settings.disableInactivityCheck ||
(record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal')
) {
continue;
}
const inactivityTime = now - record.lastActivity;
// Use extended timeout for extended-treatment keep-alive connections
let effectiveTimeout = this.settings.inactivityTimeout!;
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
if (inactivityTime > effectiveTimeout && !record.connectionClosed) {
// For keep-alive connections, issue a warning first
if (record.hasKeepAlive && !record.inactivityWarningIssued) {
console.log(
`[${id}] Warning: Keep-alive connection from ${record.remoteIP} inactive for ${
plugins.prettyMs(inactivityTime)
}. Will close in 10 minutes if no activity.`
);
// Set warning flag and add grace period
record.inactivityWarningIssued = true;
record.lastActivity = now - (effectiveTimeout - 600000);
// Try to stimulate activity with a probe packet
if (record.outgoing && !record.outgoing.destroyed) {
try {
record.outgoing.write(Buffer.alloc(0));
if (this.settings.enableDetailedLogging) {
console.log(`[${id}] Sent probe packet to test keep-alive connection`);
}
} catch (err) {
console.log(`[${id}] Error sending probe packet: ${err}`);
}
}
} else {
// For non-keep-alive or after warning, close the connection
console.log(
`[${id}] Inactivity check: No activity on connection from ${record.remoteIP} ` +
`for ${plugins.prettyMs(inactivityTime)}.` +
(record.hasKeepAlive ? ' Despite keep-alive being enabled.' : '')
);
this.cleanupConnection(record, 'inactivity');
}
} else if (inactivityTime <= effectiveTimeout && record.inactivityWarningIssued) {
// If activity detected after warning, clear the warning
if (this.settings.enableDetailedLogging) {
console.log(
`[${id}] Connection activity detected after inactivity warning, resetting warning`
);
}
record.inactivityWarningIssued = false;
}
// Parity check: if outgoing socket closed and incoming remains active
if (
record.outgoingClosedTime &&
!record.incoming.destroyed &&
!record.connectionClosed &&
now - record.outgoingClosedTime > 120000
) {
console.log(
`[${id}] Parity check: Incoming socket for ${record.remoteIP} still active ${
plugins.prettyMs(now - record.outgoingClosedTime)
} after outgoing closed.`
);
this.cleanupConnection(record, 'parity_check');
}
}
}
/**
* Clear all connections (for shutdown)
*/
public clearConnections(): void {
// Create a copy of the keys to avoid modification during iteration
const connectionIds = [...this.connectionRecords.keys()];
// First pass: End all connections gracefully
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Clear any timers
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
record.cleanupTimer = undefined;
}
// End sockets gracefully
if (record.incoming && !record.incoming.destroyed) {
record.incoming.end();
}
if (record.outgoing && !record.outgoing.destroyed) {
record.outgoing.end();
}
} catch (err) {
console.log(`Error during graceful connection end for ${id}: ${err}`);
}
}
}
// Short delay to allow graceful ends to process
setTimeout(() => {
// Second pass: Force destroy everything
for (const id of connectionIds) {
const record = this.connectionRecords.get(id);
if (record) {
try {
// Remove all listeners to prevent memory leaks
if (record.incoming) {
record.incoming.removeAllListeners();
if (!record.incoming.destroyed) {
record.incoming.destroy();
}
}
if (record.outgoing) {
record.outgoing.removeAllListeners();
if (!record.outgoing.destroyed) {
record.outgoing.destroy();
}
}
} catch (err) {
console.log(`Error during forced connection destruction for ${id}: ${err}`);
}
}
}
// Clear all maps
this.connectionRecords.clear();
this.terminationStats = { incoming: {}, outgoing: {} };
}, 100);
}
}

View File

@ -1,297 +0,0 @@
import * as plugins from '../plugins.js';
import type { IDomainConfig, ISmartProxyOptions } from './classes.pp.interfaces.js';
import type { ForwardingType, IForwardConfig, IForwardingHandler } from './types/forwarding.types.js';
import { ForwardingHandlerFactory } from './forwarding/forwarding.factory.js';
/**
* Manages domain configurations and target selection
*/
export class DomainConfigManager {
// Track round-robin indices for domain configs
private domainTargetIndices: Map<IDomainConfig, number> = new Map();
// Cache forwarding handlers for each domain config
private forwardingHandlers: Map<IDomainConfig, IForwardingHandler> = new Map();
constructor(private settings: ISmartProxyOptions) {}
/**
* Updates the domain configurations
*/
public updateDomainConfigs(newDomainConfigs: IDomainConfig[]): void {
this.settings.domainConfigs = newDomainConfigs;
// Reset target indices for removed configs
const currentConfigSet = new Set(newDomainConfigs);
for (const [config] of this.domainTargetIndices) {
if (!currentConfigSet.has(config)) {
this.domainTargetIndices.delete(config);
}
}
// Clear handlers for removed configs and create handlers for new configs
const handlersToRemove: IDomainConfig[] = [];
for (const [config] of this.forwardingHandlers) {
if (!currentConfigSet.has(config)) {
handlersToRemove.push(config);
}
}
// Remove handlers that are no longer needed
for (const config of handlersToRemove) {
this.forwardingHandlers.delete(config);
}
// Create handlers for new configs
for (const config of newDomainConfigs) {
if (!this.forwardingHandlers.has(config)) {
try {
const handler = this.createForwardingHandler(config);
this.forwardingHandlers.set(config, handler);
} catch (err) {
console.log(`Error creating forwarding handler for domain ${config.domains.join(', ')}: ${err}`);
}
}
}
}
/**
* Get all domain configurations
*/
public getDomainConfigs(): IDomainConfig[] {
return this.settings.domainConfigs;
}
/**
* Find domain config matching a server name
*/
public findDomainConfig(serverName: string): IDomainConfig | undefined {
if (!serverName) return undefined;
return this.settings.domainConfigs.find((config) =>
config.domains.some((d) => plugins.minimatch(serverName, d))
);
}
/**
* Find domain config for a specific port
*/
public findDomainConfigForPort(port: number): IDomainConfig | undefined {
return this.settings.domainConfigs.find(
(domain) => {
const portRanges = domain.forwarding?.advanced?.portRanges;
return portRanges &&
portRanges.length > 0 &&
this.isPortInRanges(port, portRanges);
}
);
}
/**
* Check if a port is within any of the given ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get target IP with round-robin support
*/
public getTargetIP(domainConfig: IDomainConfig): string {
const targetHosts = Array.isArray(domainConfig.forwarding.target.host)
? domainConfig.forwarding.target.host
: [domainConfig.forwarding.target.host];
if (targetHosts.length > 0) {
const currentIndex = this.domainTargetIndices.get(domainConfig) || 0;
const ip = targetHosts[currentIndex % targetHosts.length];
this.domainTargetIndices.set(domainConfig, currentIndex + 1);
return ip;
}
return this.settings.targetIP || 'localhost';
}
/**
* Get target host with round-robin support (for tests)
* This is just an alias for getTargetIP for easier test compatibility
*/
public getTargetHost(domainConfig: IDomainConfig): string {
return this.getTargetIP(domainConfig);
}
/**
* Get target port from domain config
*/
public getTargetPort(domainConfig: IDomainConfig, defaultPort: number): number {
return domainConfig.forwarding.target.port || defaultPort;
}
/**
* Checks if a domain should use NetworkProxy
*/
public shouldUseNetworkProxy(domainConfig: IDomainConfig): boolean {
const forwardingType = this.getForwardingType(domainConfig);
return forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https';
}
/**
* Gets the NetworkProxy port for a domain
*/
public getNetworkProxyPort(domainConfig: IDomainConfig): number | undefined {
// First check if we should use NetworkProxy at all
if (!this.shouldUseNetworkProxy(domainConfig)) {
return undefined;
}
return domainConfig.forwarding.advanced?.networkProxyPort || this.settings.networkProxyPort;
}
/**
* Get effective allowed and blocked IPs for a domain
*
* This method combines domain-specific security rules from the forwarding configuration
* with global security defaults when necessary.
*/
public getEffectiveIPRules(domainConfig: IDomainConfig): {
allowedIPs: string[],
blockedIPs: string[]
} {
// Start with empty arrays
const allowedIPs: string[] = [];
const blockedIPs: string[] = [];
// Add IPs from forwarding security settings if available
if (domainConfig.forwarding?.security?.allowedIps) {
allowedIPs.push(...domainConfig.forwarding.security.allowedIps);
} else {
// If no allowed IPs are specified in forwarding config and global defaults exist, use them
if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
allowedIPs.push(...this.settings.defaultAllowedIPs);
} else {
// Default to allow all if no specific rules
allowedIPs.push('*');
}
}
// Add blocked IPs from forwarding security settings if available
if (domainConfig.forwarding?.security?.blockedIps) {
blockedIPs.push(...domainConfig.forwarding.security.blockedIps);
}
// Always add global blocked IPs, even if domain has its own rules
// This ensures that global blocks take precedence
if (this.settings.defaultBlockedIPs && this.settings.defaultBlockedIPs.length > 0) {
// Add only unique IPs that aren't already in the list
for (const ip of this.settings.defaultBlockedIPs) {
if (!blockedIPs.includes(ip)) {
blockedIPs.push(ip);
}
}
}
return {
allowedIPs,
blockedIPs
};
}
/**
* Get connection timeout for a domain
*/
public getConnectionTimeout(domainConfig?: IDomainConfig): number {
if (domainConfig?.forwarding.advanced?.timeout) {
return domainConfig.forwarding.advanced.timeout;
}
return this.settings.maxConnectionLifetime || 86400000; // 24 hours default
}
/**
* Creates a forwarding handler for a domain configuration
*/
private createForwardingHandler(domainConfig: IDomainConfig): IForwardingHandler {
// Create a new handler using the factory
const handler = ForwardingHandlerFactory.createHandler(domainConfig.forwarding);
// Initialize the handler
handler.initialize().catch(err => {
console.log(`Error initializing forwarding handler for ${domainConfig.domains.join(', ')}: ${err}`);
});
return handler;
}
/**
* Gets a forwarding handler for a domain config
* If no handler exists, creates one
*/
public getForwardingHandler(domainConfig: IDomainConfig): IForwardingHandler {
// If we already have a handler, return it
if (this.forwardingHandlers.has(domainConfig)) {
return this.forwardingHandlers.get(domainConfig)!;
}
// Otherwise create a new handler
const handler = this.createForwardingHandler(domainConfig);
this.forwardingHandlers.set(domainConfig, handler);
return handler;
}
/**
* Gets the forwarding type for a domain config
*/
public getForwardingType(domainConfig?: IDomainConfig): ForwardingType | undefined {
if (!domainConfig?.forwarding) return undefined;
return domainConfig.forwarding.type;
}
/**
* Checks if the forwarding type requires TLS termination
*/
public requiresTlsTermination(domainConfig?: IDomainConfig): boolean {
if (!domainConfig) return false;
const forwardingType = this.getForwardingType(domainConfig);
return forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https';
}
/**
* Checks if the forwarding type supports HTTP
*/
public supportsHttp(domainConfig?: IDomainConfig): boolean {
if (!domainConfig) return false;
const forwardingType = this.getForwardingType(domainConfig);
// HTTP-only always supports HTTP
if (forwardingType === 'http-only') return true;
// For termination types, check the HTTP settings
if (forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https') {
// HTTP is supported by default for termination types
return domainConfig.forwarding?.http?.enabled !== false;
}
// HTTPS-passthrough doesn't support HTTP
return false;
}
/**
* Checks if HTTP requests should be redirected to HTTPS
*/
public shouldRedirectToHttps(domainConfig?: IDomainConfig): boolean {
if (!domainConfig?.forwarding) return false;
// Only check for redirect if HTTP is enabled
if (this.supportsHttp(domainConfig)) {
return !!domainConfig.forwarding.http?.redirectToHttps;
}
return false;
}
}

View File

@ -1,132 +0,0 @@
import * as plugins from '../plugins.js';
import type { IForwardConfig } from './forwarding/index.js';
/**
* Provision object for static or HTTP-01 certificate
*/
export type ISmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
/** Domain configuration with forwarding configuration */
export interface IDomainConfig {
domains: string[]; // Glob patterns for domain(s)
forwarding: IForwardConfig; // Unified forwarding configuration
}
/** Port proxy settings including global allowed port ranges */
import type { IAcmeOptions } from '../common/types.js';
export interface ISmartProxyOptions {
fromPort: number;
toPort: number;
targetIP?: string; // Global target host to proxy to, defaults to 'localhost'
domainConfigs: IDomainConfig[];
sniEnabled?: boolean;
defaultAllowedIPs?: string[];
defaultBlockedIPs?: string[];
preserveSourceIP?: boolean;
// TLS options
pfx?: Buffer;
key?: string | Buffer | Array<Buffer | string>;
passphrase?: string;
cert?: string | Buffer | Array<string | Buffer>;
ca?: string | Buffer | Array<string | Buffer>;
ciphers?: string;
honorCipherOrder?: boolean;
rejectUnauthorized?: boolean;
secureProtocol?: string;
servername?: string;
minVersion?: string;
maxVersion?: string;
// Timeout settings
initialDataTimeout?: number; // Timeout for initial data/SNI (ms), default: 60000 (60s)
socketTimeout?: number; // Socket inactivity timeout (ms), default: 3600000 (1h)
inactivityCheckInterval?: number; // How often to check for inactive connections (ms), default: 60000 (60s)
maxConnectionLifetime?: number; // Default max connection lifetime (ms), default: 86400000 (24h)
inactivityTimeout?: number; // Inactivity timeout (ms), default: 14400000 (4h)
gracefulShutdownTimeout?: number; // (ms) maximum time to wait for connections to close during shutdown
globalPortRanges: Array<{ from: number; to: number }>; // Global allowed port ranges
forwardAllGlobalRanges?: boolean; // When true, forwards all connections on global port ranges to the global targetIP
// Socket optimization settings
noDelay?: boolean; // Disable Nagle's algorithm (default: true)
keepAlive?: boolean; // Enable TCP keepalive (default: true)
keepAliveInitialDelay?: number; // Initial delay before sending keepalive probes (ms)
maxPendingDataSize?: number; // Maximum bytes to buffer during connection setup
// Enhanced features
disableInactivityCheck?: boolean; // Disable inactivity checking entirely
enableKeepAliveProbes?: boolean; // Enable TCP keep-alive probes
enableDetailedLogging?: boolean; // Enable detailed connection logging
enableTlsDebugLogging?: boolean; // Enable TLS handshake debug logging
enableRandomizedTimeouts?: boolean; // Randomize timeouts slightly to prevent thundering herd
allowSessionTicket?: boolean; // Allow TLS session ticket for reconnection (default: true)
// Rate limiting and security
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
// Enhanced keep-alive settings
keepAliveTreatment?: 'standard' | 'extended' | 'immortal'; // How to treat keep-alive connections
keepAliveInactivityMultiplier?: number; // Multiplier for inactivity timeout for keep-alive connections
extendedKeepAliveLifetime?: number; // Extended lifetime for keep-alive connections (ms)
// NetworkProxy integration
useNetworkProxy?: number[]; // Array of ports to forward to NetworkProxy
networkProxyPort?: number; // Port where NetworkProxy is listening (default: 8443)
// ACME configuration options for SmartProxy
acme?: IAcmeOptions;
/**
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
* or a static certificate object for immediate provisioning.
*/
certProvisionFunction?: (domain: string) => Promise<ISmartProxyCertProvisionObject>;
}
/**
* Enhanced connection record
*/
export interface IConnectionRecord {
id: string; // Unique connection identifier
incoming: plugins.net.Socket;
outgoing: plugins.net.Socket | null;
incomingStartTime: number;
outgoingStartTime?: number;
outgoingClosedTime?: number;
lockedDomain?: string; // Used to lock this connection to the initial SNI
connectionClosed: boolean; // Flag to prevent multiple cleanup attempts
cleanupTimer?: NodeJS.Timeout; // Timer for max lifetime/inactivity
alertFallbackTimeout?: NodeJS.Timeout; // Timer for fallback after alert
lastActivity: number; // Last activity timestamp for inactivity detection
pendingData: Buffer[]; // Buffer to hold data during connection setup
pendingDataSize: number; // Track total size of pending data
// Enhanced tracking fields
bytesReceived: number; // Total bytes received
bytesSent: number; // Total bytes sent
remoteIP: string; // Remote IP (cached for logging after socket close)
localPort: number; // Local port (cached for logging)
isTLS: boolean; // Whether this connection is a TLS connection
tlsHandshakeComplete: boolean; // Whether the TLS handshake is complete
hasReceivedInitialData: boolean; // Whether initial data has been received
domainConfig?: IDomainConfig; // Associated domain config for this connection
// Keep-alive tracking
hasKeepAlive: boolean; // Whether keep-alive is enabled for this connection
inactivityWarningIssued?: boolean; // Whether an inactivity warning has been issued
incomingTerminationReason?: string | null; // Reason for incoming termination
outgoingTerminationReason?: string | null; // Reason for outgoing termination
// NetworkProxy tracking
usingNetworkProxy?: boolean; // Whether this connection is using a NetworkProxy
// Renegotiation handler
renegotiationHandler?: (chunk: Buffer) => void; // Handler for renegotiation detection
// Browser connection tracking
isBrowserConnection?: boolean; // Whether this connection appears to be from a browser
domainSwitches?: number; // Number of times the domain has been switched on this connection
}

View File

@ -1,371 +0,0 @@
import * as plugins from '../plugins.js';
import { NetworkProxy } from '../networkproxy/classes.np.networkproxy.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { Port80HandlerEvents } from '../common/types.js';
import { subscribeToPort80Handler } from '../common/eventUtils.js';
import type { ICertificateData } from '../common/types.js';
import type { IConnectionRecord, ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js';
/**
* Manages NetworkProxy integration for TLS termination
*/
export class NetworkProxyBridge {
private networkProxy: NetworkProxy | null = null;
private port80Handler: Port80Handler | null = null;
constructor(private settings: ISmartProxyOptions) {}
/**
* Set the Port80Handler to use for certificate management
*/
public setPort80Handler(handler: Port80Handler): void {
this.port80Handler = handler;
// Subscribe to certificate events
subscribeToPort80Handler(handler, {
onCertificateIssued: this.handleCertificateEvent.bind(this),
onCertificateRenewed: this.handleCertificateEvent.bind(this)
});
// If NetworkProxy is already initialized, connect it with Port80Handler
if (this.networkProxy) {
this.networkProxy.setExternalPort80Handler(handler);
}
console.log('Port80Handler connected to NetworkProxyBridge');
}
/**
* Initialize NetworkProxy instance
*/
public async initialize(): Promise<void> {
if (!this.networkProxy && this.settings.useNetworkProxy && this.settings.useNetworkProxy.length > 0) {
// Configure NetworkProxy options based on PortProxy settings
const networkProxyOptions: any = {
port: this.settings.networkProxyPort!,
portProxyIntegration: true,
logLevel: this.settings.enableDetailedLogging ? 'debug' : 'info',
useExternalPort80Handler: !!this.port80Handler // Use Port80Handler if available
};
this.networkProxy = new NetworkProxy(networkProxyOptions);
console.log(`Initialized NetworkProxy on port ${this.settings.networkProxyPort}`);
// Connect Port80Handler if available
if (this.port80Handler) {
this.networkProxy.setExternalPort80Handler(this.port80Handler);
}
// Convert and apply domain configurations to NetworkProxy
await this.syncDomainConfigsToNetworkProxy();
}
}
/**
* Handle certificate issuance or renewal events
*/
private handleCertificateEvent(data: ICertificateData): void {
if (!this.networkProxy) return;
console.log(`Received certificate for ${data.domain} from Port80Handler, updating NetworkProxy`);
try {
// Find existing config for this domain
const existingConfigs = this.networkProxy.getProxyConfigs()
.filter(config => config.hostName === data.domain);
if (existingConfigs.length > 0) {
// Update existing configs with new certificate
for (const config of existingConfigs) {
config.privateKey = data.privateKey;
config.publicKey = data.certificate;
}
// Apply updated configs
this.networkProxy.updateProxyConfigs(existingConfigs)
.then(() => console.log(`Updated certificate for ${data.domain} in NetworkProxy`))
.catch(err => console.log(`Error updating certificate in NetworkProxy: ${err}`));
} else {
// Create a new config for this domain
console.log(`No existing config found for ${data.domain}, creating new config in NetworkProxy`);
}
} catch (err) {
console.log(`Error handling certificate event: ${err}`);
}
}
/**
* Apply an external (static) certificate into NetworkProxy
*/
public applyExternalCertificate(data: ICertificateData): void {
if (!this.networkProxy) {
console.log(`NetworkProxy not initialized: cannot apply external certificate for ${data.domain}`);
return;
}
this.handleCertificateEvent(data);
}
/**
* Get the NetworkProxy instance
*/
public getNetworkProxy(): NetworkProxy | null {
return this.networkProxy;
}
/**
* Get the NetworkProxy port
*/
public getNetworkProxyPort(): number {
return this.networkProxy ? this.networkProxy.getListeningPort() : this.settings.networkProxyPort || 8443;
}
/**
* Start NetworkProxy
*/
public async start(): Promise<void> {
if (this.networkProxy) {
await this.networkProxy.start();
console.log(`NetworkProxy started on port ${this.settings.networkProxyPort}`);
}
}
/**
* Stop NetworkProxy
*/
public async stop(): Promise<void> {
if (this.networkProxy) {
try {
console.log('Stopping NetworkProxy...');
await this.networkProxy.stop();
console.log('NetworkProxy stopped successfully');
} catch (err) {
console.log(`Error stopping NetworkProxy: ${err}`);
}
}
}
/**
* Register domains with Port80Handler
*/
public registerDomainsWithPort80Handler(domains: string[]): void {
if (!this.port80Handler) {
console.log('Cannot register domains - Port80Handler not initialized');
return;
}
for (const domain of domains) {
// Skip wildcards
if (domain.includes('*')) {
console.log(`Skipping wildcard domain for ACME: ${domain}`);
continue;
}
// Register the domain
try {
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Registered domain with Port80Handler: ${domain}`);
} catch (err) {
console.log(`Error registering domain ${domain} with Port80Handler: ${err}`);
}
}
}
/**
* Forwards a TLS connection to a NetworkProxy for handling
*/
public forwardToNetworkProxy(
connectionId: string,
socket: plugins.net.Socket,
record: IConnectionRecord,
initialData: Buffer,
customProxyPort?: number,
onError?: (reason: string) => void
): void {
// Ensure NetworkProxy is initialized
if (!this.networkProxy) {
console.log(
`[${connectionId}] NetworkProxy not initialized. Cannot forward connection.`
);
if (onError) {
onError('network_proxy_not_initialized');
}
return;
}
// Use the custom port if provided, otherwise use the default NetworkProxy port
const proxyPort = customProxyPort || this.networkProxy.getListeningPort();
const proxyHost = 'localhost'; // Assuming NetworkProxy runs locally
if (this.settings.enableDetailedLogging) {
console.log(
`[${connectionId}] Forwarding TLS connection to NetworkProxy at ${proxyHost}:${proxyPort}`
);
}
// Create a connection to the NetworkProxy
const proxySocket = plugins.net.connect({
host: proxyHost,
port: proxyPort,
});
// Store the outgoing socket in the record
record.outgoing = proxySocket;
record.outgoingStartTime = Date.now();
record.usingNetworkProxy = true;
// Set up error handlers
proxySocket.on('error', (err) => {
console.log(`[${connectionId}] Error connecting to NetworkProxy: ${err.message}`);
if (onError) {
onError('network_proxy_connect_error');
}
});
// Handle connection to NetworkProxy
proxySocket.on('connect', () => {
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Connected to NetworkProxy at ${proxyHost}:${proxyPort}`);
}
// First send the initial data that contains the TLS ClientHello
proxySocket.write(initialData);
// Now set up bidirectional piping between client and NetworkProxy
socket.pipe(proxySocket);
proxySocket.pipe(socket);
// Update activity on data transfer (caller should handle this)
if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] TLS connection successfully forwarded to NetworkProxy`);
}
});
}
/**
* Synchronizes domain configurations to NetworkProxy
*/
public async syncDomainConfigsToNetworkProxy(): Promise<void> {
if (!this.networkProxy) {
console.log('Cannot sync configurations - NetworkProxy not initialized');
return;
}
try {
// Get SSL certificates from assets
// Import fs directly since it's not in plugins
const fs = await import('fs');
let certPair;
try {
certPair = {
key: fs.readFileSync('assets/certs/key.pem', 'utf8'),
cert: fs.readFileSync('assets/certs/cert.pem', 'utf8'),
};
} catch (certError) {
console.log(`Warning: Could not read default certificates: ${certError}`);
console.log(
'Using empty certificate placeholders - ACME will generate proper certificates if enabled'
);
// Use empty placeholders - NetworkProxy will use its internal defaults
// or ACME will generate proper ones if enabled
certPair = {
key: '',
cert: '',
};
}
// Convert domain configs to NetworkProxy configs
const proxyConfigs = this.networkProxy.convertPortProxyConfigs(
this.settings.domainConfigs,
certPair
);
// Log ACME-eligible domains
const acmeEnabled = !!this.settings.acme?.enabled;
if (acmeEnabled) {
const acmeEligibleDomains = proxyConfigs
.filter((config) => !config.hostName.includes('*')) // Exclude wildcards
.map((config) => config.hostName);
if (acmeEligibleDomains.length > 0) {
console.log(`Domains eligible for ACME certificates: ${acmeEligibleDomains.join(', ')}`);
// Register these domains with Port80Handler if available
if (this.port80Handler) {
this.registerDomainsWithPort80Handler(acmeEligibleDomains);
}
} else {
console.log('No domains eligible for ACME certificates found in configuration');
}
}
// Update NetworkProxy with the converted configs
await this.networkProxy.updateProxyConfigs(proxyConfigs);
console.log(`Successfully synchronized ${proxyConfigs.length} domain configurations to NetworkProxy`);
} catch (err) {
console.log(`Failed to sync configurations: ${err}`);
}
}
/**
* Request a certificate for a specific domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
// Delegate to Port80Handler if available
if (this.port80Handler) {
try {
// Check if the domain is already registered
const cert = this.port80Handler.getCertificate(domain);
if (cert) {
console.log(`Certificate already exists for ${domain}`);
return true;
}
// Register the domain for certificate issuance
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Domain ${domain} registered for certificate issuance`);
return true;
} catch (err) {
console.log(`Error requesting certificate: ${err}`);
return false;
}
}
// Fall back to NetworkProxy if Port80Handler is not available
if (!this.networkProxy) {
console.log('Cannot request certificate - NetworkProxy not initialized');
return false;
}
if (!this.settings.acme?.enabled) {
console.log('Cannot request certificate - ACME is not enabled');
return false;
}
try {
const result = await this.networkProxy.requestCertificate(domain);
if (result) {
console.log(`Certificate request for ${domain} submitted successfully`);
} else {
console.log(`Certificate request for ${domain} failed`);
}
return result;
} catch (err) {
console.log(`Error requesting certificate: ${err}`);
return false;
}
}
}

View File

@ -1,211 +0,0 @@
import type{ ISmartProxyOptions } from './classes.pp.interfaces.js';
/**
* Manages port ranges and port-based configuration
*/
export class PortRangeManager {
constructor(private settings: ISmartProxyOptions) {}
/**
* Get all ports that should be listened on
*/
public getListeningPorts(): Set<number> {
const listeningPorts = new Set<number>();
// Always include the main fromPort
listeningPorts.add(this.settings.fromPort);
// Add ports from global port ranges if defined
if (this.settings.globalPortRanges && this.settings.globalPortRanges.length > 0) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
listeningPorts.add(port);
}
}
}
return listeningPorts;
}
/**
* Check if a port should use NetworkProxy for forwarding
*/
public shouldUseNetworkProxy(port: number): boolean {
return !!this.settings.useNetworkProxy && this.settings.useNetworkProxy.includes(port);
}
/**
* Check if port should use global forwarding
*/
public shouldUseGlobalForwarding(port: number): boolean {
return (
!!this.settings.forwardAllGlobalRanges &&
this.isPortInGlobalRanges(port)
);
}
/**
* Check if a port is in global ranges
*/
public isPortInGlobalRanges(port: number): boolean {
return (
this.settings.globalPortRanges &&
this.isPortInRanges(port, this.settings.globalPortRanges)
);
}
/**
* Check if a port falls within the specified ranges
*/
public isPortInRanges(port: number, ranges: Array<{ from: number; to: number }>): boolean {
return ranges.some((range) => port >= range.from && port <= range.to);
}
/**
* Get forwarding port for a specific listening port
* This determines what port to connect to on the target
*/
public getForwardingPort(listeningPort: number): number {
// If using global forwarding, forward to the original port
if (this.settings.forwardAllGlobalRanges && this.isPortInGlobalRanges(listeningPort)) {
return listeningPort;
}
// Otherwise use the configured toPort
return this.settings.toPort;
}
/**
* Find domain-specific port ranges that include a given port
*/
public findDomainPortRange(port: number): {
domainIndex: number,
range: { from: number, to: number }
} | undefined {
for (let i = 0; i < this.settings.domainConfigs.length; i++) {
const domain = this.settings.domainConfigs[i];
// Get port ranges from forwarding.advanced if available
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
if (port >= range.from && port <= range.to) {
return { domainIndex: i, range };
}
}
}
}
return undefined;
}
/**
* Get a list of all configured ports
* This includes the fromPort, NetworkProxy ports, and ports from all ranges
*/
public getAllConfiguredPorts(): number[] {
const ports = new Set<number>();
// Add main listening port
ports.add(this.settings.fromPort);
// Add NetworkProxy port if configured
if (this.settings.networkProxyPort) {
ports.add(this.settings.networkProxyPort);
}
// Add NetworkProxy ports
if (this.settings.useNetworkProxy) {
for (const port of this.settings.useNetworkProxy) {
ports.add(port);
}
}
// Add global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
// Get port ranges from forwarding.advanced
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
for (let port = range.from; port <= range.to; port++) {
ports.add(port);
}
}
}
// Add domain-specific NetworkProxy port if configured in forwarding.advanced
const networkProxyPort = domain.forwarding?.advanced?.networkProxyPort;
if (networkProxyPort) {
ports.add(networkProxyPort);
}
}
return Array.from(ports);
}
/**
* Validate port configuration
* Returns array of warning messages
*/
public validateConfiguration(): string[] {
const warnings: string[] = [];
// Check for overlapping port ranges
const portMappings = new Map<number, string[]>();
// Track global port ranges
if (this.settings.globalPortRanges) {
for (const range of this.settings.globalPortRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push('Global Port Range');
}
}
}
// Track domain-specific port ranges
for (const domain of this.settings.domainConfigs) {
// Get port ranges from forwarding.advanced
const portRanges = domain.forwarding?.advanced?.portRanges;
if (portRanges && portRanges.length > 0) {
for (const range of portRanges) {
for (let port = range.from; port <= range.to; port++) {
if (!portMappings.has(port)) {
portMappings.set(port, []);
}
portMappings.get(port)!.push(`Domain: ${domain.domains.join(', ')}`);
}
}
}
}
// Check for ports with multiple mappings
for (const [port, mappings] of portMappings.entries()) {
if (mappings.length > 1) {
warnings.push(`Port ${port} has multiple mappings: ${mappings.join(', ')}`);
}
}
// Check if main ports are used elsewhere
if (portMappings.has(this.settings.fromPort) && portMappings.get(this.settings.fromPort)!.length > 0) {
warnings.push(`Main listening port ${this.settings.fromPort} is also used in port ranges`);
}
if (this.settings.networkProxyPort && portMappings.has(this.settings.networkProxyPort)) {
warnings.push(`NetworkProxy port ${this.settings.networkProxyPort} is also used in port ranges`);
}
return warnings;
}
}

View File

@ -1,171 +0,0 @@
import * as plugins from '../plugins.js';
import type { ISmartProxyOptions } from './classes.pp.interfaces.js';
/**
* Handles security aspects like IP tracking, rate limiting, and authorization
*/
export class SecurityManager {
private connectionsByIP: Map<string, Set<string>> = new Map();
private connectionRateByIP: Map<string, number[]> = new Map();
constructor(private settings: ISmartProxyOptions) {}
/**
* Get connections count by IP
*/
public getConnectionCountByIP(ip: string): number {
return this.connectionsByIP.get(ip)?.size || 0;
}
/**
* Check and update connection rate for an IP
* @returns true if within rate limit, false if exceeding limit
*/
public checkConnectionRate(ip: string): boolean {
const now = Date.now();
const minute = 60 * 1000;
if (!this.connectionRateByIP.has(ip)) {
this.connectionRateByIP.set(ip, [now]);
return true;
}
// Get timestamps and filter out entries older than 1 minute
const timestamps = this.connectionRateByIP.get(ip)!.filter((time) => now - time < minute);
timestamps.push(now);
this.connectionRateByIP.set(ip, timestamps);
// Check if rate exceeds limit
return timestamps.length <= this.settings.connectionRateLimitPerMinute!;
}
/**
* Track connection by IP
*/
public trackConnectionByIP(ip: string, connectionId: string): void {
if (!this.connectionsByIP.has(ip)) {
this.connectionsByIP.set(ip, new Set());
}
this.connectionsByIP.get(ip)!.add(connectionId);
}
/**
* Remove connection tracking for an IP
*/
public removeConnectionByIP(ip: string, connectionId: string): void {
if (this.connectionsByIP.has(ip)) {
const connections = this.connectionsByIP.get(ip)!;
connections.delete(connectionId);
if (connections.size === 0) {
this.connectionsByIP.delete(ip);
}
}
}
/**
* Check if an IP is authorized using forwarding security rules
*
* This method is used to determine if an IP is allowed to connect, based on security
* rules configured in the forwarding configuration. The allowed and blocked IPs are
* typically derived from domain.forwarding.security.allowedIps and blockedIps through
* DomainConfigManager.getEffectiveIPRules().
*
* @param ip - The IP address to check
* @param allowedIPs - Array of allowed IP patterns from forwarding.security.allowedIps
* @param blockedIPs - Array of blocked IP patterns from forwarding.security.blockedIps
* @returns true if IP is authorized, false if blocked
*/
public isIPAuthorized(ip: string, allowedIPs: string[], blockedIPs: string[] = []): boolean {
// Skip IP validation if allowedIPs is empty
if (!ip || (allowedIPs.length === 0 && blockedIPs.length === 0)) {
return true;
}
// First check if IP is blocked - blocked IPs take precedence
if (blockedIPs.length > 0 && this.isGlobIPMatch(ip, blockedIPs)) {
return false;
}
// Then check if IP is allowed
return this.isGlobIPMatch(ip, allowedIPs);
}
/**
* Check if the IP matches any of the glob patterns from security configuration
*
* This method checks IP addresses against glob patterns and handles IPv4/IPv6 normalization.
* It's used to implement IP filtering based on the forwarding.security configuration.
*
* @param ip - The IP address to check
* @param patterns - Array of glob patterns from forwarding.security.allowedIps or blockedIps
* @returns true if IP matches any pattern, false otherwise
*/
private isGlobIPMatch(ip: string, patterns: string[]): boolean {
if (!ip || !patterns || patterns.length === 0) return false;
// Handle IPv4/IPv6 normalization for proper matching
const normalizeIP = (ip: string): string[] => {
if (!ip) return [];
// Handle IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
if (ip.startsWith('::ffff:')) {
const ipv4 = ip.slice(7);
return [ip, ipv4];
}
// Handle IPv4 addresses by also checking IPv4-mapped form
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) {
return [ip, `::ffff:${ip}`];
}
return [ip];
};
// Normalize the IP being checked
const normalizedIPVariants = normalizeIP(ip);
if (normalizedIPVariants.length === 0) return false;
// Normalize the pattern IPs for consistent comparison
const expandedPatterns = patterns.flatMap(normalizeIP);
// Check for any match between normalized IP variants and patterns
return normalizedIPVariants.some((ipVariant) =>
expandedPatterns.some((pattern) => plugins.minimatch(ipVariant, pattern))
);
}
/**
* Check if IP should be allowed considering connection rate and max connections
* @returns Object with result and reason
*/
public validateIP(ip: string): { allowed: boolean; reason?: string } {
// Check connection count limit
if (
this.settings.maxConnectionsPerIP &&
this.getConnectionCountByIP(ip) >= this.settings.maxConnectionsPerIP
) {
return {
allowed: false,
reason: `Maximum connections per IP (${this.settings.maxConnectionsPerIP}) exceeded`
};
}
// Check connection rate limit
if (
this.settings.connectionRateLimitPerMinute &&
!this.checkConnectionRate(ip)
) {
return {
allowed: false,
reason: `Connection rate limit (${this.settings.connectionRateLimitPerMinute}/min) exceeded`
};
}
return { allowed: true };
}
/**
* Clears all IP tracking data (for shutdown)
*/
public clearIPTracking(): void {
this.connectionsByIP.clear();
this.connectionRateByIP.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,190 +0,0 @@
import type { IConnectionRecord, ISmartProxyOptions } from './classes.pp.interfaces.js';
/**
* Manages timeouts and inactivity tracking for connections
*/
export class TimeoutManager {
constructor(private settings: ISmartProxyOptions) {}
/**
* Ensure timeout values don't exceed Node.js max safe integer
*/
public ensureSafeTimeout(timeout: number): number {
const MAX_SAFE_TIMEOUT = 2147483647; // Maximum safe value (2^31 - 1)
return Math.min(Math.floor(timeout), MAX_SAFE_TIMEOUT);
}
/**
* Generate a slightly randomized timeout to prevent thundering herd
*/
public randomizeTimeout(baseTimeout: number, variationPercent: number = 5): number {
const safeBaseTimeout = this.ensureSafeTimeout(baseTimeout);
const variation = safeBaseTimeout * (variationPercent / 100);
return this.ensureSafeTimeout(
safeBaseTimeout + Math.floor(Math.random() * variation * 2) - variation
);
}
/**
* Update connection activity timestamp
*/
public updateActivity(record: IConnectionRecord): void {
record.lastActivity = Date.now();
// Clear any inactivity warning
if (record.inactivityWarningIssued) {
record.inactivityWarningIssued = false;
}
}
/**
* Calculate effective inactivity timeout based on connection type
*/
public getEffectiveInactivityTimeout(record: IConnectionRecord): number {
let effectiveTimeout = this.settings.inactivityTimeout || 14400000; // 4 hours default
// For immortal keep-alive connections, use an extremely long timeout
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, apply multiplier
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
const multiplier = this.settings.keepAliveInactivityMultiplier || 6;
effectiveTimeout = effectiveTimeout * multiplier;
}
return this.ensureSafeTimeout(effectiveTimeout);
}
/**
* Calculate effective max lifetime based on connection type
*/
public getEffectiveMaxLifetime(record: IConnectionRecord): number {
// Use domain-specific timeout from forwarding.advanced if available
const baseTimeout = record.domainConfig?.forwarding?.advanced?.timeout ||
this.settings.maxConnectionLifetime ||
86400000; // 24 hours default
// For immortal keep-alive connections, use an extremely long lifetime
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return Number.MAX_SAFE_INTEGER;
}
// For extended keep-alive connections, use the extended lifetime setting
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'extended') {
return this.ensureSafeTimeout(
this.settings.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000 // 7 days default
);
}
// Apply randomization if enabled
if (this.settings.enableRandomizedTimeouts) {
return this.randomizeTimeout(baseTimeout);
}
return this.ensureSafeTimeout(baseTimeout);
}
/**
* Setup connection timeout
* @returns The cleanup timer
*/
public setupConnectionTimeout(
record: IConnectionRecord,
onTimeout: (record: IConnectionRecord, reason: string) => void
): NodeJS.Timeout {
// Clear any existing timer
if (record.cleanupTimer) {
clearTimeout(record.cleanupTimer);
}
// Calculate effective timeout
const effectiveLifetime = this.getEffectiveMaxLifetime(record);
// Set up the timeout
const timer = setTimeout(() => {
// Call the provided callback
onTimeout(record, 'connection_timeout');
}, effectiveLifetime);
// Make sure timeout doesn't keep the process alive
if (timer.unref) {
timer.unref();
}
return timer;
}
/**
* Check for inactivity on a connection
* @returns Object with check results
*/
public checkInactivity(record: IConnectionRecord): {
isInactive: boolean;
shouldWarn: boolean;
inactivityTime: number;
effectiveTimeout: number;
} {
// Skip for connections with inactivity check disabled
if (this.settings.disableInactivityCheck) {
return {
isInactive: false,
shouldWarn: false,
inactivityTime: 0,
effectiveTimeout: 0
};
}
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
return {
isInactive: false,
shouldWarn: false,
inactivityTime: 0,
effectiveTimeout: 0
};
}
const now = Date.now();
const inactivityTime = now - record.lastActivity;
const effectiveTimeout = this.getEffectiveInactivityTimeout(record);
// Check if inactive
const isInactive = inactivityTime > effectiveTimeout;
// For keep-alive connections, we should warn first
const shouldWarn = record.hasKeepAlive &&
isInactive &&
!record.inactivityWarningIssued;
return {
isInactive,
shouldWarn,
inactivityTime,
effectiveTimeout
};
}
/**
* Apply socket timeout settings
*/
public applySocketTimeouts(record: IConnectionRecord): void {
// Skip for immortal keep-alive connections
if (record.hasKeepAlive && this.settings.keepAliveTreatment === 'immortal') {
// Disable timeouts completely for immortal connections
record.incoming.setTimeout(0);
if (record.outgoing) {
record.outgoing.setTimeout(0);
}
return;
}
// Apply normal timeouts
const timeout = this.ensureSafeTimeout(this.settings.socketTimeout || 3600000); // 1 hour default
record.incoming.setTimeout(timeout);
if (record.outgoing) {
record.outgoing.setTimeout(timeout);
}
}
}

View File

@ -1,258 +0,0 @@
import * as plugins from '../plugins.js';
/**
* TlsAlert class for managing TLS alert messages
*/
export class TlsAlert {
// TLS Alert Levels
static readonly LEVEL_WARNING = 0x01;
static readonly LEVEL_FATAL = 0x02;
// TLS Alert Description Codes - RFC 8446 (TLS 1.3) / RFC 5246 (TLS 1.2)
static readonly CLOSE_NOTIFY = 0x00;
static readonly UNEXPECTED_MESSAGE = 0x0A;
static readonly BAD_RECORD_MAC = 0x14;
static readonly DECRYPTION_FAILED = 0x15; // TLS 1.0 only
static readonly RECORD_OVERFLOW = 0x16;
static readonly DECOMPRESSION_FAILURE = 0x1E; // TLS 1.2 and below
static readonly HANDSHAKE_FAILURE = 0x28;
static readonly NO_CERTIFICATE = 0x29; // SSLv3 only
static readonly BAD_CERTIFICATE = 0x2A;
static readonly UNSUPPORTED_CERTIFICATE = 0x2B;
static readonly CERTIFICATE_REVOKED = 0x2C;
static readonly CERTIFICATE_EXPIRED = 0x2F;
static readonly CERTIFICATE_UNKNOWN = 0x30;
static readonly ILLEGAL_PARAMETER = 0x2F;
static readonly UNKNOWN_CA = 0x30;
static readonly ACCESS_DENIED = 0x31;
static readonly DECODE_ERROR = 0x32;
static readonly DECRYPT_ERROR = 0x33;
static readonly EXPORT_RESTRICTION = 0x3C; // TLS 1.0 only
static readonly PROTOCOL_VERSION = 0x46;
static readonly INSUFFICIENT_SECURITY = 0x47;
static readonly INTERNAL_ERROR = 0x50;
static readonly INAPPROPRIATE_FALLBACK = 0x56;
static readonly USER_CANCELED = 0x5A;
static readonly NO_RENEGOTIATION = 0x64; // TLS 1.2 and below
static readonly MISSING_EXTENSION = 0x6D; // TLS 1.3
static readonly UNSUPPORTED_EXTENSION = 0x6E; // TLS 1.3
static readonly CERTIFICATE_REQUIRED = 0x6F; // TLS 1.3
static readonly UNRECOGNIZED_NAME = 0x70;
static readonly BAD_CERTIFICATE_STATUS_RESPONSE = 0x71;
static readonly BAD_CERTIFICATE_HASH_VALUE = 0x72; // TLS 1.2 and below
static readonly UNKNOWN_PSK_IDENTITY = 0x73;
static readonly CERTIFICATE_REQUIRED_1_3 = 0x74; // TLS 1.3
static readonly NO_APPLICATION_PROTOCOL = 0x78;
/**
* Create a TLS alert buffer with the specified level and description code
*
* @param level Alert level (warning or fatal)
* @param description Alert description code
* @param tlsVersion TLS version bytes (default is TLS 1.2: 0x0303)
* @returns Buffer containing the TLS alert message
*/
static create(
level: number,
description: number,
tlsVersion: [number, number] = [0x03, 0x03]
): Buffer {
return Buffer.from([
0x15, // Alert record type
tlsVersion[0],
tlsVersion[1], // TLS version (default to TLS 1.2: 0x0303)
0x00,
0x02, // Length
level, // Alert level
description, // Alert description
]);
}
/**
* Create a warning-level TLS alert
*
* @param description Alert description code
* @returns Buffer containing the warning-level TLS alert message
*/
static createWarning(description: number): Buffer {
return this.create(this.LEVEL_WARNING, description);
}
/**
* Create a fatal-level TLS alert
*
* @param description Alert description code
* @returns Buffer containing the fatal-level TLS alert message
*/
static createFatal(description: number): Buffer {
return this.create(this.LEVEL_FATAL, description);
}
/**
* Send a TLS alert to a socket and optionally close the connection
*
* @param socket The socket to send the alert to
* @param level Alert level (warning or fatal)
* @param description Alert description code
* @param closeAfterSend Whether to close the connection after sending the alert
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent
*/
static async send(
socket: plugins.net.Socket,
level: number,
description: number,
closeAfterSend: boolean = false,
closeDelay: number = 200
): Promise<void> {
const alert = this.create(level, description);
return new Promise<void>((resolve, reject) => {
try {
// Ensure the alert is written as a single packet
socket.cork();
const writeSuccessful = socket.write(alert, (err) => {
if (err) {
reject(err);
return;
}
if (closeAfterSend) {
setTimeout(() => {
socket.end();
resolve();
}, closeDelay);
} else {
resolve();
}
});
socket.uncork();
// If write wasn't successful immediately, wait for drain
if (!writeSuccessful && !closeAfterSend) {
socket.once('drain', () => {
resolve();
});
}
} catch (err) {
reject(err);
}
});
}
/**
* Pre-defined TLS alert messages
*/
static readonly alerts = {
// Warning level alerts
closeNotify: TlsAlert.createWarning(TlsAlert.CLOSE_NOTIFY),
unsupportedExtension: TlsAlert.createWarning(TlsAlert.UNSUPPORTED_EXTENSION),
certificateRequired: TlsAlert.createWarning(TlsAlert.CERTIFICATE_REQUIRED),
unrecognizedName: TlsAlert.createWarning(TlsAlert.UNRECOGNIZED_NAME),
noRenegotiation: TlsAlert.createWarning(TlsAlert.NO_RENEGOTIATION),
userCanceled: TlsAlert.createWarning(TlsAlert.USER_CANCELED),
// Warning level alerts for session resumption
certificateExpiredWarning: TlsAlert.createWarning(TlsAlert.CERTIFICATE_EXPIRED),
handshakeFailureWarning: TlsAlert.createWarning(TlsAlert.HANDSHAKE_FAILURE),
insufficientSecurityWarning: TlsAlert.createWarning(TlsAlert.INSUFFICIENT_SECURITY),
// Fatal level alerts
unexpectedMessage: TlsAlert.createFatal(TlsAlert.UNEXPECTED_MESSAGE),
badRecordMac: TlsAlert.createFatal(TlsAlert.BAD_RECORD_MAC),
recordOverflow: TlsAlert.createFatal(TlsAlert.RECORD_OVERFLOW),
handshakeFailure: TlsAlert.createFatal(TlsAlert.HANDSHAKE_FAILURE),
badCertificate: TlsAlert.createFatal(TlsAlert.BAD_CERTIFICATE),
certificateExpired: TlsAlert.createFatal(TlsAlert.CERTIFICATE_EXPIRED),
certificateUnknown: TlsAlert.createFatal(TlsAlert.CERTIFICATE_UNKNOWN),
illegalParameter: TlsAlert.createFatal(TlsAlert.ILLEGAL_PARAMETER),
unknownCA: TlsAlert.createFatal(TlsAlert.UNKNOWN_CA),
accessDenied: TlsAlert.createFatal(TlsAlert.ACCESS_DENIED),
decodeError: TlsAlert.createFatal(TlsAlert.DECODE_ERROR),
decryptError: TlsAlert.createFatal(TlsAlert.DECRYPT_ERROR),
protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION),
insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY),
internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR),
unrecognizedNameFatal: TlsAlert.createFatal(TlsAlert.UNRECOGNIZED_NAME),
};
/**
* Utility method to send a warning-level unrecognized_name alert
* Specifically designed for SNI issues to encourage the client to retry with SNI
*
* @param socket The socket to send the alert to
* @returns Promise that resolves when the alert has been sent
*/
static async sendSniRequired(socket: plugins.net.Socket): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.UNRECOGNIZED_NAME);
}
/**
* Utility method to send a close_notify alert and close the connection
*
* @param socket The socket to send the alert to
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent and the connection closed
*/
static async sendCloseNotify(socket: plugins.net.Socket, closeDelay: number = 200): Promise<void> {
return this.send(socket, this.LEVEL_WARNING, this.CLOSE_NOTIFY, true, closeDelay);
}
/**
* Utility method to send a certificate_expired alert to force new TLS session
*
* @param socket The socket to send the alert to
* @param fatal Whether to send as a fatal alert (default: false)
* @param closeAfterSend Whether to close the connection after sending the alert (default: true)
* @param closeDelay Milliseconds to wait before closing the connection (default: 200ms)
* @returns Promise that resolves when the alert has been sent
*/
static async sendCertificateExpired(
socket: plugins.net.Socket,
fatal: boolean = false,
closeAfterSend: boolean = true,
closeDelay: number = 200
): Promise<void> {
const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING;
return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay);
}
/**
* Send a sequence of alerts to force SNI from clients
* This combines multiple alerts to ensure maximum browser compatibility
*
* @param socket The socket to send the alerts to
* @returns Promise that resolves when all alerts have been sent
*/
static async sendForceSniSequence(socket: plugins.net.Socket): Promise<void> {
try {
// Send unrecognized_name (warning)
socket.cork();
socket.write(this.alerts.unrecognizedName);
socket.uncork();
// Give the socket time to send the alert
return new Promise((resolve) => {
setTimeout(resolve, 50);
});
} catch (err) {
return Promise.reject(err);
}
}
/**
* Send a fatal level alert that immediately terminates the connection
*
* @param socket The socket to send the alert to
* @param description Alert description code
* @param closeDelay Milliseconds to wait before closing the connection (default: 100ms)
* @returns Promise that resolves when the alert has been sent and the connection closed
*/
static async sendFatalAndClose(
socket: plugins.net.Socket,
description: number,
closeDelay: number = 100
): Promise<void> {
return this.send(socket, this.LEVEL_FATAL, description, true, closeDelay);
}
}

View File

@ -1,206 +0,0 @@
import * as plugins from '../plugins.js';
import type { ISmartProxyOptions } from './classes.pp.interfaces.js';
import { SniHandler } from './classes.pp.snihandler.js';
/**
* Interface for connection information used for SNI extraction
*/
interface IConnectionInfo {
sourceIp: string;
sourcePort: number;
destIp: string;
destPort: number;
}
/**
* Manages TLS-related operations including SNI extraction and validation
*/
export class TlsManager {
constructor(private settings: ISmartProxyOptions) {}
/**
* Check if a data chunk appears to be a TLS handshake
*/
public isTlsHandshake(chunk: Buffer): boolean {
return SniHandler.isTlsHandshake(chunk);
}
/**
* Check if a data chunk appears to be a TLS ClientHello
*/
public isClientHello(chunk: Buffer): boolean {
return SniHandler.isClientHello(chunk);
}
/**
* Extract Server Name Indication (SNI) from TLS handshake
*/
public extractSNI(
chunk: Buffer,
connInfo: IConnectionInfo,
previousDomain?: string
): string | undefined {
// Use the SniHandler to process the TLS packet
return SniHandler.processTlsPacket(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false,
previousDomain
);
}
/**
* Handle session resumption attempts
*/
public handleSessionResumption(
chunk: Buffer,
connectionId: string,
hasSNI: boolean
): { shouldBlock: boolean; reason?: string } {
// Skip if session tickets are allowed
if (this.settings.allowSessionTicket !== false) {
return { shouldBlock: false };
}
// Check for session resumption attempt
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
);
// If this is a resumption attempt without SNI, block it
if (resumptionInfo.isResumption && !hasSNI && !resumptionInfo.hasSNI) {
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Session resumption detected without SNI and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake.`
);
}
return {
shouldBlock: true,
reason: 'session_ticket_blocked'
};
}
return { shouldBlock: false };
}
/**
* Check for SNI mismatch during renegotiation
*/
public checkRenegotiationSNI(
chunk: Buffer,
connInfo: IConnectionInfo,
expectedDomain: string,
connectionId: string
): { hasMismatch: boolean; extractedSNI?: string } {
// Only process if this looks like a TLS ClientHello
if (!this.isClientHello(chunk)) {
return { hasMismatch: false };
}
try {
// Extract SNI with renegotiation support
const newSNI = SniHandler.extractSNIWithResumptionSupport(
chunk,
connInfo,
this.settings.enableTlsDebugLogging || false
);
// Skip if no SNI was found
if (!newSNI) return { hasMismatch: false };
// Check for SNI mismatch
if (newSNI !== expectedDomain) {
if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation with different SNI: ${expectedDomain} -> ${newSNI}. ` +
`Terminating connection - SNI domain switching is not allowed.`
);
}
return { hasMismatch: true, extractedSNI: newSNI };
} else if (this.settings.enableTlsDebugLogging) {
console.log(
`[${connectionId}] Renegotiation detected with same SNI: ${newSNI}. Allowing.`
);
}
} catch (err) {
console.log(
`[${connectionId}] Error processing ClientHello: ${err}. Allowing connection to continue.`
);
}
return { hasMismatch: false };
}
/**
* Create a renegotiation handler function for a connection
*/
public createRenegotiationHandler(
connectionId: string,
lockedDomain: string,
connInfo: IConnectionInfo,
onMismatch: (connectionId: string, reason: string) => void
): (chunk: Buffer) => void {
return (chunk: Buffer) => {
const result = this.checkRenegotiationSNI(chunk, connInfo, lockedDomain, connectionId);
if (result.hasMismatch) {
onMismatch(connectionId, 'sni_mismatch');
}
};
}
/**
* Analyze TLS connection for browser fingerprinting
* This helps identify browser vs non-browser connections
*/
public analyzeClientHello(chunk: Buffer): {
isBrowserConnection: boolean;
isRenewal: boolean;
hasSNI: boolean;
} {
// Default result
const result = {
isBrowserConnection: false,
isRenewal: false,
hasSNI: false
};
try {
// Check if it's a ClientHello
if (!this.isClientHello(chunk)) {
return result;
}
// Check for session resumption
const resumptionInfo = SniHandler.hasSessionResumption(
chunk,
this.settings.enableTlsDebugLogging || false
);
// Extract SNI
const sni = SniHandler.extractSNI(
chunk,
this.settings.enableTlsDebugLogging || false
);
// Update result
result.isRenewal = resumptionInfo.isResumption;
result.hasSNI = !!sni;
// Browsers typically:
// 1. Send SNI extension
// 2. Have a variety of extensions (ALPN, etc.)
// 3. Use standard cipher suites
// ...more complex heuristics could be implemented here
// Simple heuristic: presence of SNI suggests browser
result.isBrowserConnection = !!sni;
return result;
} catch (err) {
console.log(`Error analyzing ClientHello: ${err}`);
return result;
}
}
}

View File

@ -1,651 +0,0 @@
import * as plugins from '../plugins.js';
import { ConnectionManager } from './classes.pp.connectionmanager.js';
import { SecurityManager } from './classes.pp.securitymanager.js';
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
import { TlsManager } from './classes.pp.tlsmanager.js';
import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js';
import { TimeoutManager } from './classes.pp.timeoutmanager.js';
import { PortRangeManager } from './classes.pp.portrangemanager.js';
import { ConnectionHandler } from './classes.pp.connectionhandler.js';
import { Port80Handler } from '../port80handler/classes.port80handler.js';
import { CertProvisioner } from './classes.pp.certprovisioner.js';
import type { ICertificateData } from '../common/types.js';
import { buildPort80Handler } from '../common/acmeFactory.js';
import type { ForwardingType } from './types/forwarding.types.js';
import { createPort80HandlerOptions } from '../common/port80-adapter.js';
import type { ISmartProxyOptions, IDomainConfig } from './classes.pp.interfaces.js';
export type { ISmartProxyOptions as IPortProxySettings, IDomainConfig };
/**
* SmartProxy - Main class that coordinates all components
*/
export class SmartProxy extends plugins.EventEmitter {
private netServers: plugins.net.Server[] = [];
private connectionLogger: NodeJS.Timeout | null = null;
private isShuttingDown: boolean = false;
// Component managers
private connectionManager: ConnectionManager;
private securityManager: SecurityManager;
public domainConfigManager: DomainConfigManager;
private tlsManager: TlsManager;
private networkProxyBridge: NetworkProxyBridge;
private timeoutManager: TimeoutManager;
private portRangeManager: PortRangeManager;
private connectionHandler: ConnectionHandler;
// Port80Handler for ACME certificate management
private port80Handler: Port80Handler | null = null;
// CertProvisioner for unified certificate workflows
private certProvisioner?: CertProvisioner;
constructor(settingsArg: ISmartProxyOptions) {
super();
// Set reasonable defaults for all settings
this.settings = {
...settingsArg,
targetIP: settingsArg.targetIP || 'localhost',
initialDataTimeout: settingsArg.initialDataTimeout || 120000,
socketTimeout: settingsArg.socketTimeout || 3600000,
inactivityCheckInterval: settingsArg.inactivityCheckInterval || 60000,
maxConnectionLifetime: settingsArg.maxConnectionLifetime || 86400000,
inactivityTimeout: settingsArg.inactivityTimeout || 14400000,
gracefulShutdownTimeout: settingsArg.gracefulShutdownTimeout || 30000,
noDelay: settingsArg.noDelay !== undefined ? settingsArg.noDelay : true,
keepAlive: settingsArg.keepAlive !== undefined ? settingsArg.keepAlive : true,
keepAliveInitialDelay: settingsArg.keepAliveInitialDelay || 10000,
maxPendingDataSize: settingsArg.maxPendingDataSize || 10 * 1024 * 1024,
disableInactivityCheck: settingsArg.disableInactivityCheck || false,
enableKeepAliveProbes:
settingsArg.enableKeepAliveProbes !== undefined ? settingsArg.enableKeepAliveProbes : true,
enableDetailedLogging: settingsArg.enableDetailedLogging || false,
enableTlsDebugLogging: settingsArg.enableTlsDebugLogging || false,
enableRandomizedTimeouts: settingsArg.enableRandomizedTimeouts || false,
allowSessionTicket:
settingsArg.allowSessionTicket !== undefined ? settingsArg.allowSessionTicket : true,
maxConnectionsPerIP: settingsArg.maxConnectionsPerIP || 100,
connectionRateLimitPerMinute: settingsArg.connectionRateLimitPerMinute || 300,
keepAliveTreatment: settingsArg.keepAliveTreatment || 'extended',
keepAliveInactivityMultiplier: settingsArg.keepAliveInactivityMultiplier || 6,
extendedKeepAliveLifetime: settingsArg.extendedKeepAliveLifetime || 7 * 24 * 60 * 60 * 1000,
networkProxyPort: settingsArg.networkProxyPort || 8443,
acme: settingsArg.acme || {},
globalPortRanges: settingsArg.globalPortRanges || [],
};
// Set default ACME options if not provided
if (!this.settings.acme || Object.keys(this.settings.acme).length === 0) {
this.settings.acme = {
enabled: false,
port: 80,
accountEmail: 'admin@example.com',
useProduction: false,
renewThresholdDays: 30,
autoRenew: true,
certificateStore: './certs',
skipConfiguredCerts: false,
httpsRedirectPort: this.settings.fromPort,
renewCheckIntervalHours: 24,
domainForwards: []
};
}
// Initialize component managers
this.timeoutManager = new TimeoutManager(this.settings);
this.securityManager = new SecurityManager(this.settings);
this.connectionManager = new ConnectionManager(
this.settings,
this.securityManager,
this.timeoutManager
);
this.domainConfigManager = new DomainConfigManager(this.settings);
this.tlsManager = new TlsManager(this.settings);
this.networkProxyBridge = new NetworkProxyBridge(this.settings);
this.portRangeManager = new PortRangeManager(this.settings);
// Initialize connection handler
this.connectionHandler = new ConnectionHandler(
this.settings,
this.connectionManager,
this.securityManager,
this.domainConfigManager,
this.tlsManager,
this.networkProxyBridge,
this.timeoutManager,
this.portRangeManager
);
}
/**
* The settings for the port proxy
*/
public settings: ISmartProxyOptions;
/**
* Initialize the Port80Handler for ACME certificate management
*/
private async initializePort80Handler(): Promise<void> {
const config = this.settings.acme!;
if (!config.enabled) {
console.log('ACME is disabled in configuration');
return;
}
try {
// Build and start the Port80Handler
this.port80Handler = buildPort80Handler({
...config,
httpsRedirectPort: config.httpsRedirectPort || this.settings.fromPort
});
// Share Port80Handler with NetworkProxyBridge before start
this.networkProxyBridge.setPort80Handler(this.port80Handler);
await this.port80Handler.start();
console.log(`Port80Handler started on port ${config.port}`);
} catch (err) {
console.log(`Error initializing Port80Handler: ${err}`);
}
}
/**
* Start the proxy server
*/
public async start() {
// Don't start if already shutting down
if (this.isShuttingDown) {
console.log("Cannot start PortProxy while it's shutting down");
return;
}
// Process domain configs
// Note: ensureForwardingConfig is no longer needed since forwarding is now required
// Initialize domain config manager with the processed configs
this.domainConfigManager.updateDomainConfigs(this.settings.domainConfigs);
// Initialize Port80Handler if enabled
await this.initializePort80Handler();
// Initialize CertProvisioner for unified certificate workflows
if (this.port80Handler) {
const acme = this.settings.acme!;
// Convert domain forwards to use the new forwarding system if possible
const domainForwards = acme.domainForwards?.map(f => {
// If the domain has a forwarding config in domainConfigs, use that
const domainConfig = this.settings.domainConfigs.find(
dc => dc.domains.some(d => d === f.domain)
);
if (domainConfig?.forwarding) {
return {
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || domainConfig.forwarding.http?.redirectToHttps || false
};
}
// Otherwise use the existing configuration
return {
domain: f.domain,
forwardConfig: f.forwardConfig,
acmeForwardConfig: f.acmeForwardConfig,
sslRedirect: f.sslRedirect || false
};
}) || [];
this.certProvisioner = new CertProvisioner(
this.settings.domainConfigs,
this.port80Handler,
this.networkProxyBridge,
this.settings.certProvisionFunction,
acme.renewThresholdDays!,
acme.renewCheckIntervalHours!,
acme.autoRenew!,
domainForwards
);
this.certProvisioner.on('certificate', (certData) => {
this.emit('certificate', {
domain: certData.domain,
publicKey: certData.certificate,
privateKey: certData.privateKey,
expiryDate: certData.expiryDate,
source: certData.source,
isRenewal: certData.isRenewal
});
});
await this.certProvisioner.start();
console.log('CertProvisioner started');
}
// Initialize and start NetworkProxy if needed
if (
this.settings.useNetworkProxy &&
this.settings.useNetworkProxy.length > 0
) {
await this.networkProxyBridge.initialize();
await this.networkProxyBridge.start();
}
// Validate port configuration
const configWarnings = this.portRangeManager.validateConfiguration();
if (configWarnings.length > 0) {
console.log("Port configuration warnings:");
for (const warning of configWarnings) {
console.log(` - ${warning}`);
}
}
// Get listening ports from PortRangeManager
const listeningPorts = this.portRangeManager.getListeningPorts();
// Create servers for each port
for (const port of listeningPorts) {
const server = plugins.net.createServer((socket) => {
// Check if shutting down
if (this.isShuttingDown) {
socket.end();
socket.destroy();
return;
}
// Delegate to connection handler
this.connectionHandler.handleConnection(socket);
}).on('error', (err: Error) => {
console.log(`Server Error on port ${port}: ${err.message}`);
});
server.listen(port, () => {
const isNetworkProxyPort = this.settings.useNetworkProxy?.includes(port);
console.log(
`PortProxy -> OK: Now listening on port ${port}${
this.settings.sniEnabled && !isNetworkProxyPort ? ' (SNI passthrough enabled)' : ''
}${isNetworkProxyPort ? ' (NetworkProxy forwarding enabled)' : ''}`
);
});
this.netServers.push(server);
}
// Set up periodic connection logging and inactivity checks
this.connectionLogger = setInterval(() => {
// Immediately return if shutting down
if (this.isShuttingDown) return;
// Perform inactivity check
this.connectionManager.performInactivityCheck();
// Log connection statistics
const now = Date.now();
let maxIncoming = 0;
let maxOutgoing = 0;
let tlsConnections = 0;
let nonTlsConnections = 0;
let completedTlsHandshakes = 0;
let pendingTlsHandshakes = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Get connection records for analysis
const connectionRecords = this.connectionManager.getConnections();
// Analyze active connections
for (const record of connectionRecords.values()) {
// Track connection stats
if (record.isTLS) {
tlsConnections++;
if (record.tlsHandshakeComplete) {
completedTlsHandshakes++;
} else {
pendingTlsHandshakes++;
}
} else {
nonTlsConnections++;
}
if (record.hasKeepAlive) {
keepAliveConnections++;
}
if (record.usingNetworkProxy) {
networkProxyConnections++;
}
maxIncoming = Math.max(maxIncoming, now - record.incomingStartTime);
if (record.outgoingStartTime) {
maxOutgoing = Math.max(maxOutgoing, now - record.outgoingStartTime);
}
}
// Get termination stats
const terminationStats = this.connectionManager.getTerminationStats();
// Log detailed stats
console.log(
`Active connections: ${connectionRecords.size}. ` +
`Types: TLS=${tlsConnections} (Completed=${completedTlsHandshakes}, Pending=${pendingTlsHandshakes}), ` +
`Non-TLS=${nonTlsConnections}, KeepAlive=${keepAliveConnections}, NetworkProxy=${networkProxyConnections}. ` +
`Longest running: IN=${plugins.prettyMs(maxIncoming)}, OUT=${plugins.prettyMs(maxOutgoing)}. ` +
`Termination stats: ${JSON.stringify({
IN: terminationStats.incoming,
OUT: terminationStats.outgoing,
})}`
);
}, this.settings.inactivityCheckInterval || 60000);
// Make sure the interval doesn't keep the process alive
if (this.connectionLogger.unref) {
this.connectionLogger.unref();
}
}
/**
* Stop the proxy server
*/
public async stop() {
console.log('PortProxy shutting down...');
this.isShuttingDown = true;
// Stop CertProvisioner if active
if (this.certProvisioner) {
await this.certProvisioner.stop();
console.log('CertProvisioner stopped');
}
// Stop the Port80Handler if running
if (this.port80Handler) {
try {
await this.port80Handler.stop();
console.log('Port80Handler stopped');
this.port80Handler = null;
} catch (err) {
console.log(`Error stopping Port80Handler: ${err}`);
}
}
// Stop accepting new connections
const closeServerPromises: Promise<void>[] = this.netServers.map(
(server) =>
new Promise<void>((resolve) => {
if (!server.listening) {
resolve();
return;
}
server.close((err) => {
if (err) {
console.log(`Error closing server: ${err.message}`);
}
resolve();
});
})
);
// Stop the connection logger
if (this.connectionLogger) {
clearInterval(this.connectionLogger);
this.connectionLogger = null;
}
// Wait for servers to close
await Promise.all(closeServerPromises);
console.log('All servers closed. Cleaning up active connections...');
// Clean up all active connections
this.connectionManager.clearConnections();
// Stop NetworkProxy
await this.networkProxyBridge.stop();
// Clear all servers
this.netServers = [];
console.log('PortProxy shutdown complete.');
}
/**
* Updates the domain configurations for the proxy
*/
public async updateDomainConfigs(newDomainConfigs: IDomainConfig[]): Promise<void> {
console.log(`Updating domain configurations (${newDomainConfigs.length} configs)`);
// Update domain configs in DomainConfigManager
this.domainConfigManager.updateDomainConfigs(newDomainConfigs);
// If NetworkProxy is initialized, resync the configurations
if (this.networkProxyBridge.getNetworkProxy()) {
await this.networkProxyBridge.syncDomainConfigsToNetworkProxy();
}
// If Port80Handler is running, provision certificates based on forwarding type
if (this.port80Handler && this.settings.acme?.enabled) {
for (const domainConfig of newDomainConfigs) {
// Skip certificate provisioning for http-only or passthrough configs that don't need certs
const forwardingType = domainConfig.forwarding.type;
const needsCertificate =
forwardingType === 'https-terminate-to-http' ||
forwardingType === 'https-terminate-to-https';
// Skip certificate provisioning if ACME is explicitly disabled for this domain
const acmeDisabled = domainConfig.forwarding.acme?.enabled === false;
if (!needsCertificate || acmeDisabled) {
if (this.settings.enableDetailedLogging) {
console.log(`Skipping certificate provisioning for ${domainConfig.domains.join(', ')} (${forwardingType})`);
}
continue;
}
for (const domain of domainConfig.domains) {
const isWildcard = domain.includes('*');
let provision: string | plugins.tsclass.network.ICert = 'http01';
// Check for ACME forwarding configuration in the domain
const forwardAcmeChallenges = domainConfig.forwarding.acme?.forwardChallenges;
if (this.settings.certProvisionFunction) {
try {
provision = await this.settings.certProvisionFunction(domain);
} catch (err) {
console.log(`certProvider error for ${domain}: ${err}`);
}
} else if (isWildcard) {
console.warn(`Skipping wildcard domain without certProvisionFunction: ${domain}`);
continue;
}
if (provision === 'http01') {
if (isWildcard) {
console.warn(`Skipping HTTP-01 for wildcard domain: ${domain}`);
continue;
}
// Create Port80Handler options from the forwarding configuration
const port80Config = createPort80HandlerOptions(domain, domainConfig.forwarding);
this.port80Handler.addDomain(port80Config);
console.log(`Registered domain ${domain} with Port80Handler for HTTP-01`);
} else {
// Static certificate (e.g., DNS-01 provisioned) supports wildcards
const certObj = provision as plugins.tsclass.network.ICert;
const certData: ICertificateData = {
domain: certObj.domainName,
certificate: certObj.publicKey,
privateKey: certObj.privateKey,
expiryDate: new Date(certObj.validUntil)
};
this.networkProxyBridge.applyExternalCertificate(certData);
console.log(`Applied static certificate for ${domain} from certProvider`);
}
}
}
console.log('Provisioned certificates for new domains');
}
}
/**
* Request a certificate for a specific domain
*/
public async requestCertificate(domain: string): Promise<boolean> {
// Validate domain format
if (!this.isValidDomain(domain)) {
console.log(`Invalid domain format: ${domain}`);
return false;
}
// Use Port80Handler if available
if (this.port80Handler) {
try {
// Check if we already have a certificate
const cert = this.port80Handler.getCertificate(domain);
if (cert) {
console.log(`Certificate already exists for ${domain}, valid until ${cert.expiryDate.toISOString()}`);
return true;
}
// Register domain for certificate issuance
this.port80Handler.addDomain({
domainName: domain,
sslRedirect: true,
acmeMaintenance: true
});
console.log(`Domain ${domain} registered for certificate issuance`);
return true;
} catch (err) {
console.log(`Error registering domain with Port80Handler: ${err}`);
return false;
}
}
// Fall back to NetworkProxyBridge
return this.networkProxyBridge.requestCertificate(domain);
}
/**
* Validates if a domain name is valid for certificate issuance
*/
private isValidDomain(domain: string): boolean {
// Very basic domain validation
if (!domain || domain.length === 0) {
return false;
}
// Check for wildcard domains (they can't get ACME certs)
if (domain.includes('*')) {
console.log(`Wildcard domains like "${domain}" are not supported for ACME certificates`);
return false;
}
// Check if domain has at least one dot and no invalid characters
const validDomainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!validDomainRegex.test(domain)) {
console.log(`Domain "${domain}" has invalid format`);
return false;
}
return true;
}
/**
* Get statistics about current connections
*/
public getStatistics(): any {
const connectionRecords = this.connectionManager.getConnections();
const terminationStats = this.connectionManager.getTerminationStats();
let tlsConnections = 0;
let nonTlsConnections = 0;
let keepAliveConnections = 0;
let networkProxyConnections = 0;
// Analyze active connections
for (const record of connectionRecords.values()) {
if (record.isTLS) tlsConnections++;
else nonTlsConnections++;
if (record.hasKeepAlive) keepAliveConnections++;
if (record.usingNetworkProxy) networkProxyConnections++;
}
return {
activeConnections: connectionRecords.size,
tlsConnections,
nonTlsConnections,
keepAliveConnections,
networkProxyConnections,
terminationStats,
acmeEnabled: !!this.port80Handler,
port80HandlerPort: this.port80Handler ? this.settings.acme?.port : null
};
}
/**
* Get a list of eligible domains for ACME certificates
*/
public getEligibleDomainsForCertificates(): string[] {
// Collect all non-wildcard domains from domain configs
const domains: string[] = [];
for (const config of this.settings.domainConfigs) {
// Skip domains that can't be used with ACME
const eligibleDomains = config.domains.filter(domain =>
!domain.includes('*') && this.isValidDomain(domain)
);
domains.push(...eligibleDomains);
}
return domains;
}
/**
* Get status of certificates managed by Port80Handler
*/
public getCertificateStatus(): any {
if (!this.port80Handler) {
return {
enabled: false,
message: 'Port80Handler is not enabled'
};
}
// Get eligible domains
const eligibleDomains = this.getEligibleDomainsForCertificates();
const certificateStatus: Record<string, any> = {};
// Check each domain
for (const domain of eligibleDomains) {
const cert = this.port80Handler.getCertificate(domain);
if (cert) {
const now = new Date();
const expiryDate = cert.expiryDate;
const daysRemaining = Math.floor((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
certificateStatus[domain] = {
status: 'valid',
expiryDate: expiryDate.toISOString(),
daysRemaining,
renewalNeeded: daysRemaining <= (this.settings.acme?.renewThresholdDays ?? 0)
};
} else {
certificateStatus[domain] = {
status: 'missing',
message: 'No certificate found'
};
}
}
const acme = this.settings.acme!;
return {
enabled: true,
port: acme.port!,
useProduction: acme.useProduction!,
autoRenew: acme.autoRenew!,
certificates: certificateStatus
};
}
}

View File

@ -1,28 +0,0 @@
import type { IForwardConfig } from '../types/forwarding.types.js';
/**
* Domain configuration with unified forwarding configuration
*/
export interface IDomainConfig {
// Core properties - domain patterns
domains: string[];
// Unified forwarding configuration
forwarding: IForwardConfig;
}
/**
* Helper function to create a domain configuration
*/
export function createDomainConfig(
domains: string | string[],
forwarding: IForwardConfig
): IDomainConfig {
// Normalize domains to an array
const domainArray = Array.isArray(domains) ? domains : [domains];
return {
domains: domainArray,
forwarding
};
}

View File

@ -1,283 +0,0 @@
import * as plugins from '../../plugins.js';
import type { IDomainConfig } from './domain-config.js';
import type { IForwardingHandler } from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
import { ForwardingHandlerFactory } from './forwarding.factory.js';
/**
* Events emitted by the DomainManager
*/
export enum DomainManagerEvents {
DOMAIN_ADDED = 'domain-added',
DOMAIN_REMOVED = 'domain-removed',
DOMAIN_MATCHED = 'domain-matched',
DOMAIN_MATCH_FAILED = 'domain-match-failed',
CERTIFICATE_NEEDED = 'certificate-needed',
CERTIFICATE_LOADED = 'certificate-loaded',
ERROR = 'error'
}
/**
* Manages domains and their forwarding handlers
*/
export class DomainManager extends plugins.EventEmitter {
private domainConfigs: IDomainConfig[] = [];
private domainHandlers: Map<string, IForwardingHandler> = new Map();
/**
* Create a new DomainManager
* @param initialDomains Optional initial domain configurations
*/
constructor(initialDomains?: IDomainConfig[]) {
super();
if (initialDomains) {
this.setDomainConfigs(initialDomains);
}
}
/**
* Set or replace all domain configurations
* @param configs Array of domain configurations
*/
public async setDomainConfigs(configs: IDomainConfig[]): Promise<void> {
// Clear existing handlers
this.domainHandlers.clear();
// Store new configurations
this.domainConfigs = [...configs];
// Initialize handlers for each domain
for (const config of this.domainConfigs) {
await this.createHandlersForDomain(config);
}
}
/**
* Add a new domain configuration
* @param config The domain configuration to add
*/
public async addDomainConfig(config: IDomainConfig): Promise<void> {
// Check if any of these domains already exist
for (const domain of config.domains) {
if (this.domainHandlers.has(domain)) {
// Remove existing handler for this domain
this.domainHandlers.delete(domain);
}
}
// Add the new configuration
this.domainConfigs.push(config);
// Create handlers for the new domain
await this.createHandlersForDomain(config);
this.emit(DomainManagerEvents.DOMAIN_ADDED, {
domains: config.domains,
forwardingType: config.forwarding.type
});
}
/**
* Remove a domain configuration
* @param domain The domain to remove
* @returns True if the domain was found and removed
*/
public removeDomainConfig(domain: string): boolean {
// Find the config that includes this domain
const index = this.domainConfigs.findIndex(config =>
config.domains.includes(domain)
);
if (index === -1) {
return false;
}
// Get the config
const config = this.domainConfigs[index];
// Remove all handlers for this config
for (const domainName of config.domains) {
this.domainHandlers.delete(domainName);
}
// Remove the config
this.domainConfigs.splice(index, 1);
this.emit(DomainManagerEvents.DOMAIN_REMOVED, {
domains: config.domains
});
return true;
}
/**
* Find the handler for a domain
* @param domain The domain to find a handler for
* @returns The handler or undefined if no match
*/
public findHandlerForDomain(domain: string): IForwardingHandler | undefined {
// Try exact match
if (this.domainHandlers.has(domain)) {
return this.domainHandlers.get(domain);
}
// Try wildcard matches
const wildcardHandler = this.findWildcardHandler(domain);
if (wildcardHandler) {
return wildcardHandler;
}
// No match found
return undefined;
}
/**
* Handle a connection for a domain
* @param domain The domain
* @param socket The client socket
* @returns True if the connection was handled
*/
public handleConnection(domain: string, socket: plugins.net.Socket): boolean {
const handler = this.findHandlerForDomain(domain);
if (!handler) {
this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, {
domain,
remoteAddress: socket.remoteAddress
});
return false;
}
this.emit(DomainManagerEvents.DOMAIN_MATCHED, {
domain,
handlerType: handler.constructor.name,
remoteAddress: socket.remoteAddress
});
// Handle the connection
handler.handleConnection(socket);
return true;
}
/**
* Handle an HTTP request for a domain
* @param domain The domain
* @param req The HTTP request
* @param res The HTTP response
* @returns True if the request was handled
*/
public handleHttpRequest(domain: string, req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): boolean {
const handler = this.findHandlerForDomain(domain);
if (!handler) {
this.emit(DomainManagerEvents.DOMAIN_MATCH_FAILED, {
domain,
remoteAddress: req.socket.remoteAddress
});
return false;
}
this.emit(DomainManagerEvents.DOMAIN_MATCHED, {
domain,
handlerType: handler.constructor.name,
remoteAddress: req.socket.remoteAddress
});
// Handle the request
handler.handleHttpRequest(req, res);
return true;
}
/**
* Create handlers for a domain configuration
* @param config The domain configuration
*/
private async createHandlersForDomain(config: IDomainConfig): Promise<void> {
try {
// Create a handler for this forwarding configuration
const handler = ForwardingHandlerFactory.createHandler(config.forwarding);
// Initialize the handler
await handler.initialize();
// Set up event forwarding
this.setupHandlerEvents(handler, config);
// Store the handler for each domain in the config
for (const domain of config.domains) {
this.domainHandlers.set(domain, handler);
}
} catch (error) {
this.emit(DomainManagerEvents.ERROR, {
domains: config.domains,
error: error instanceof Error ? error.message : String(error)
});
}
}
/**
* Set up event forwarding from a handler
* @param handler The handler
* @param config The domain configuration for this handler
*/
private setupHandlerEvents(handler: IForwardingHandler, config: IDomainConfig): void {
// Forward relevant events
handler.on(ForwardingHandlerEvents.CERTIFICATE_NEEDED, (data) => {
this.emit(DomainManagerEvents.CERTIFICATE_NEEDED, {
...data,
domains: config.domains
});
});
handler.on(ForwardingHandlerEvents.CERTIFICATE_LOADED, (data) => {
this.emit(DomainManagerEvents.CERTIFICATE_LOADED, {
...data,
domains: config.domains
});
});
handler.on(ForwardingHandlerEvents.ERROR, (data) => {
this.emit(DomainManagerEvents.ERROR, {
...data,
domains: config.domains
});
});
}
/**
* Find a handler for a domain using wildcard matching
* @param domain The domain to find a handler for
* @returns The handler or undefined if no match
*/
private findWildcardHandler(domain: string): IForwardingHandler | undefined {
// Exact match already checked in findHandlerForDomain
// Try subdomain wildcard (*.example.com)
if (domain.includes('.')) {
const parts = domain.split('.');
if (parts.length > 2) {
const wildcardDomain = `*.${parts.slice(1).join('.')}`;
if (this.domainHandlers.has(wildcardDomain)) {
return this.domainHandlers.get(wildcardDomain);
}
}
}
// Try full wildcard
if (this.domainHandlers.has('*')) {
return this.domainHandlers.get('*');
}
// No match found
return undefined;
}
/**
* Get all domain configurations
* @returns Array of domain configurations
*/
public getDomainConfigs(): IDomainConfig[] {
return [...this.domainConfigs];
}
}

View File

@ -1,155 +0,0 @@
import type { IForwardConfig, IForwardingHandler } from '../types/forwarding.types.js';
import { HttpForwardingHandler } from './http.handler.js';
import { HttpsPassthroughHandler } from './https-passthrough.handler.js';
import { HttpsTerminateToHttpHandler } from './https-terminate-to-http.handler.js';
import { HttpsTerminateToHttpsHandler } from './https-terminate-to-https.handler.js';
/**
* Factory for creating forwarding handlers based on the configuration type
*/
export class ForwardingHandlerFactory {
/**
* Create a forwarding handler based on the configuration
* @param config The forwarding configuration
* @returns The appropriate forwarding handler
*/
public static createHandler(config: IForwardConfig): IForwardingHandler {
// Create the appropriate handler based on the forwarding type
switch (config.type) {
case 'http-only':
return new HttpForwardingHandler(config);
case 'https-passthrough':
return new HttpsPassthroughHandler(config);
case 'https-terminate-to-http':
return new HttpsTerminateToHttpHandler(config);
case 'https-terminate-to-https':
return new HttpsTerminateToHttpsHandler(config);
default:
// Type system should prevent this, but just in case:
throw new Error(`Unknown forwarding type: ${(config as any).type}`);
}
}
/**
* Apply default values to a forwarding configuration based on its type
* @param config The original forwarding configuration
* @returns A configuration with defaults applied
*/
public static applyDefaults(config: IForwardConfig): IForwardConfig {
// Create a deep copy of the configuration
const result: IForwardConfig = JSON.parse(JSON.stringify(config));
// Apply defaults based on forwarding type
switch (config.type) {
case 'http-only':
// Set defaults for HTTP-only mode
result.http = {
enabled: true,
...config.http
};
break;
case 'https-passthrough':
// Set defaults for HTTPS passthrough
result.https = {
forwardSni: true,
...config.https
};
// SNI forwarding doesn't do HTTP
result.http = {
enabled: false,
...config.http
};
break;
case 'https-terminate-to-http':
// Set defaults for HTTPS termination to HTTP
result.https = {
...config.https
};
// Support HTTP access by default in this mode
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
// Enable ACME by default
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
break;
case 'https-terminate-to-https':
// Similar to terminate-to-http but with different target handling
result.https = {
...config.https
};
result.http = {
enabled: true,
redirectToHttps: true,
...config.http
};
result.acme = {
enabled: true,
maintenance: true,
...config.acme
};
break;
}
return result;
}
/**
* Validate a forwarding configuration
* @param config The configuration to validate
* @throws Error if the configuration is invalid
*/
public static validateConfig(config: IForwardConfig): void {
// Validate common properties
if (!config.target) {
throw new Error('Forwarding configuration must include a target');
}
if (!config.target.host || (Array.isArray(config.target.host) && config.target.host.length === 0)) {
throw new Error('Target must include a host or array of hosts');
}
if (!config.target.port || config.target.port <= 0 || config.target.port > 65535) {
throw new Error('Target must include a valid port (1-65535)');
}
// Type-specific validation
switch (config.type) {
case 'http-only':
// HTTP-only needs http.enabled to be true
if (config.http?.enabled === false) {
throw new Error('HTTP-only forwarding must have HTTP enabled');
}
break;
case 'https-passthrough':
// HTTPS passthrough doesn't support HTTP
if (config.http?.enabled === true) {
throw new Error('HTTPS passthrough does not support HTTP');
}
// HTTPS passthrough doesn't work with ACME
if (config.acme?.enabled === true) {
throw new Error('HTTPS passthrough does not support ACME');
}
break;
case 'https-terminate-to-http':
case 'https-terminate-to-https':
// These modes support all options, nothing specific to validate
break;
}
}
}

View File

@ -1,127 +0,0 @@
import * as plugins from '../../plugins.js';
import type {
IForwardConfig,
IForwardingHandler
} from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
/**
* Base class for all forwarding handlers
*/
export abstract class ForwardingHandler extends plugins.EventEmitter implements IForwardingHandler {
/**
* Create a new ForwardingHandler
* @param config The forwarding configuration
*/
constructor(protected config: IForwardConfig) {
super();
}
/**
* Initialize the handler
* Base implementation does nothing, subclasses should override as needed
*/
public async initialize(): Promise<void> {
// Base implementation - no initialization needed
}
/**
* Handle a new socket connection
* @param socket The incoming socket connection
*/
public abstract handleConnection(socket: plugins.net.Socket): void;
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public abstract handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
/**
* Get a target from the configuration, supporting round-robin selection
* @returns A resolved target object with host and port
*/
protected getTargetFromConfig(): { host: string, port: number } {
const { target } = this.config;
// Handle round-robin host selection
if (Array.isArray(target.host)) {
if (target.host.length === 0) {
throw new Error('No target hosts specified');
}
// Simple round-robin selection
const randomIndex = Math.floor(Math.random() * target.host.length);
return {
host: target.host[randomIndex],
port: target.port
};
}
// Single host
return {
host: target.host,
port: target.port
};
}
/**
* Redirect an HTTP request to HTTPS
* @param req The HTTP request
* @param res The HTTP response
*/
protected redirectToHttps(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
const host = req.headers.host || '';
const path = req.url || '/';
const redirectUrl = `https://${host}${path}`;
res.writeHead(301, {
'Location': redirectUrl,
'Cache-Control': 'no-cache'
});
res.end(`Redirecting to ${redirectUrl}`);
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 301,
headers: { 'Location': redirectUrl },
size: 0
});
}
/**
* Apply custom headers from configuration
* @param headers The original headers
* @param variables Variables to replace in the headers
* @returns The headers with custom values applied
*/
protected applyCustomHeaders(
headers: Record<string, string | string[] | undefined>,
variables: Record<string, string>
): Record<string, string | string[] | undefined> {
const customHeaders = this.config.advanced?.headers || {};
const result = { ...headers };
// Apply custom headers with variable substitution
for (const [key, value] of Object.entries(customHeaders)) {
let processedValue = value;
// Replace variables in the header value
for (const [varName, varValue] of Object.entries(variables)) {
processedValue = processedValue.replace(`{${varName}}`, varValue);
}
result[key] = processedValue;
}
return result;
}
/**
* Get the timeout for this connection from configuration
* @returns Timeout in milliseconds
*/
protected getTimeout(): number {
return this.config.advanced?.timeout || 60000; // Default: 60 seconds
}
}

View File

@ -1,140 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './forwarding.handler.js';
import type { IForwardConfig } from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
/**
* Handler for HTTP-only forwarding
*/
export class HttpForwardingHandler extends ForwardingHandler {
/**
* Create a new HTTP forwarding handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTP-only configuration
if (config.type !== 'http-only') {
throw new Error(`Invalid configuration type for HttpForwardingHandler: ${config.type}`);
}
}
/**
* Handle a raw socket connection
* HTTP handler doesn't do much with raw sockets as it mainly processes
* parsed HTTP requests
*/
public handleConnection(socket: plugins.net.Socket): void {
// For HTTP, we mainly handle parsed requests, but we can still set up
// some basic connection tracking
const remoteAddress = socket.remoteAddress || 'unknown';
socket.on('close', (hadError) => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
hadError
});
});
socket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: error.message
});
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress
});
}
/**
* Handle an HTTP request
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create a custom headers object with variables for substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track bytes for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@ -1,182 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './forwarding.handler.js';
import type { IForwardConfig } from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
/**
* Handler for HTTPS passthrough (SNI forwarding without termination)
*/
export class HttpsPassthroughHandler extends ForwardingHandler {
/**
* Create a new HTTPS passthrough handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS passthrough configuration
if (config.type !== 'https-passthrough') {
throw new Error(`Invalid configuration type for HttpsPassthroughHandler: ${config.type}`);
}
}
/**
* Handle a TLS/SSL socket connection by forwarding it without termination
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Get the target from configuration
const target = this.getTargetFromConfig();
// Log the connection
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
target: `${target.host}:${target.port}`
});
// Create a connection to the target server
const serverSocket = plugins.net.connect(target.port, target.host);
// Handle errors on the server socket
serverSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
// Close the client socket if it's still open
if (!clientSocket.destroyed) {
clientSocket.destroy();
}
});
// Handle errors on the client socket
clientSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Client connection error: ${error.message}`
});
// Close the server socket if it's still open
if (!serverSocket.destroyed) {
serverSocket.destroy();
}
});
// Track data transfer for logging
let bytesSent = 0;
let bytesReceived = 0;
// Forward data from client to server
clientSocket.on('data', (data) => {
bytesSent += data.length;
// Check if server socket is writable
if (serverSocket.writable) {
const flushed = serverSocket.write(data);
// Handle backpressure
if (!flushed) {
clientSocket.pause();
serverSocket.once('drain', () => {
clientSocket.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
bytes: data.length,
total: bytesSent
});
});
// Forward data from server to client
serverSocket.on('data', (data) => {
bytesReceived += data.length;
// Check if client socket is writable
if (clientSocket.writable) {
const flushed = clientSocket.write(data);
// Handle backpressure
if (!flushed) {
serverSocket.pause();
clientSocket.once('drain', () => {
serverSocket.resume();
});
}
}
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'inbound',
bytes: data.length,
total: bytesReceived
});
});
// Handle connection close
const handleClose = () => {
if (!clientSocket.destroyed) {
clientSocket.destroy();
}
if (!serverSocket.destroyed) {
serverSocket.destroy();
}
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress,
bytesSent,
bytesReceived
});
};
// Set up close handlers
clientSocket.on('close', handleClose);
serverSocket.on('close', handleClose);
// Set timeouts
const timeout = this.getTimeout();
clientSocket.setTimeout(timeout);
serverSocket.setTimeout(timeout);
// Handle timeouts
clientSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Client connection timeout'
});
handleClose();
});
serverSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Server connection timeout'
});
handleClose();
});
}
/**
* Handle an HTTP request - HTTPS passthrough doesn't support HTTP
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// HTTPS passthrough doesn't support HTTP requests
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('HTTP not supported for this domain');
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: 404,
headers: { 'Content-Type': 'text/plain' },
size: 'HTTP not supported for this domain'.length
});
}
}

View File

@ -1,264 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './forwarding.handler.js';
import type { IForwardConfig } from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
/**
* Handler for HTTPS termination with HTTP backend
*/
export class HttpsTerminateToHttpHandler extends ForwardingHandler {
private tlsServer: plugins.tls.Server | null = null;
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTP backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTP configuration
if (config.type !== 'https-terminate-to-http') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and forwarding to HTTP backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true,
server: this.tlsServer || undefined
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Handle TLS errors
tlsSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `TLS error: ${error.message}`
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
// The TLS socket will now emit HTTP traffic that can be processed
// In a real implementation, we would create an HTTP parser and handle
// the requests here, but for simplicity, we'll just log the data
let dataBuffer = Buffer.alloc(0);
tlsSocket.on('data', (data) => {
// Append to buffer
dataBuffer = Buffer.concat([dataBuffer, data]);
// Very basic HTTP parsing - in a real implementation, use http-parser
if (dataBuffer.includes(Buffer.from('\r\n\r\n'))) {
const target = this.getTargetFromConfig();
// Simple example: forward the data to an HTTP server
const socket = plugins.net.connect(target.port, target.host, () => {
socket.write(dataBuffer);
dataBuffer = Buffer.alloc(0);
// Set up bidirectional data flow
tlsSocket.pipe(socket);
socket.pipe(tlsSocket);
});
socket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Target connection error: ${error.message}`
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
}
});
// Handle close
tlsSocket.on('close', () => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress
});
});
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
}
/**
* Handle an HTTP request by forwarding to the HTTP backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers
};
// Create the proxy request
const proxyReq = plugins.http.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@ -1,292 +0,0 @@
import * as plugins from '../../plugins.js';
import { ForwardingHandler } from './forwarding.handler.js';
import type { IForwardConfig } from '../types/forwarding.types.js';
import { ForwardingHandlerEvents } from '../types/forwarding.types.js';
/**
* Handler for HTTPS termination with HTTPS backend
*/
export class HttpsTerminateToHttpsHandler extends ForwardingHandler {
private secureContext: plugins.tls.SecureContext | null = null;
/**
* Create a new HTTPS termination with HTTPS backend handler
* @param config The forwarding configuration
*/
constructor(config: IForwardConfig) {
super(config);
// Validate that this is an HTTPS terminate to HTTPS configuration
if (config.type !== 'https-terminate-to-https') {
throw new Error(`Invalid configuration type for HttpsTerminateToHttpsHandler: ${config.type}`);
}
}
/**
* Initialize the handler, setting up TLS context
*/
public async initialize(): Promise<void> {
// We need to load or create TLS certificates for termination
if (this.config.https?.customCert) {
// Use custom certificate from configuration
this.secureContext = plugins.tls.createSecureContext({
key: this.config.https.customCert.key,
cert: this.config.https.customCert.cert
});
this.emit(ForwardingHandlerEvents.CERTIFICATE_LOADED, {
source: 'config',
domain: this.config.target.host
});
} else if (this.config.acme?.enabled) {
// Request certificate through ACME if needed
this.emit(ForwardingHandlerEvents.CERTIFICATE_NEEDED, {
domain: Array.isArray(this.config.target.host)
? this.config.target.host[0]
: this.config.target.host,
useProduction: this.config.acme.production || false
});
// In a real implementation, we would wait for the certificate to be issued
// For now, we'll use a dummy context
this.secureContext = plugins.tls.createSecureContext({
key: '-----BEGIN PRIVATE KEY-----\nDummy key\n-----END PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\nDummy cert\n-----END CERTIFICATE-----'
});
} else {
throw new Error('HTTPS termination requires either a custom certificate or ACME enabled');
}
}
/**
* Set the secure context for TLS termination
* Called when a certificate is available
* @param context The secure context
*/
public setSecureContext(context: plugins.tls.SecureContext): void {
this.secureContext = context;
}
/**
* Handle a TLS/SSL socket connection by terminating TLS and creating a new TLS connection to backend
* @param clientSocket The incoming socket from the client
*/
public handleConnection(clientSocket: plugins.net.Socket): void {
// Make sure we have a secure context
if (!this.secureContext) {
clientSocket.destroy(new Error('TLS secure context not initialized'));
return;
}
const remoteAddress = clientSocket.remoteAddress || 'unknown';
const remotePort = clientSocket.remotePort || 0;
// Create a TLS socket using our secure context
const tlsSocket = new plugins.tls.TLSSocket(clientSocket, {
secureContext: this.secureContext,
isServer: true
});
this.emit(ForwardingHandlerEvents.CONNECTED, {
remoteAddress,
remotePort,
tls: true
});
// Handle TLS errors
tlsSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `TLS error: ${error.message}`
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
// The TLS socket will now emit HTTP traffic that can be processed
// In a real implementation, we would create an HTTP parser and handle
// the requests here, but for simplicity, we'll just forward the data
// Get the target from configuration
const target = this.getTargetFromConfig();
// Set up the connection to the HTTPS backend
const connectToBackend = () => {
const backendSocket = plugins.tls.connect({
host: target.host,
port: target.port,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
}, () => {
this.emit(ForwardingHandlerEvents.DATA_FORWARDED, {
direction: 'outbound',
target: `${target.host}:${target.port}`,
tls: true
});
// Set up bidirectional data flow
tlsSocket.pipe(backendSocket);
backendSocket.pipe(tlsSocket);
});
backendSocket.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: `Backend connection error: ${error.message}`
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
// Handle close
backendSocket.on('close', () => {
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
// Set timeout
const timeout = this.getTimeout();
backendSocket.setTimeout(timeout);
backendSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'Backend connection timeout'
});
if (!backendSocket.destroyed) {
backendSocket.destroy();
}
});
};
// Wait for the TLS handshake to complete before connecting to backend
tlsSocket.on('secure', () => {
connectToBackend();
});
// Handle close
tlsSocket.on('close', () => {
this.emit(ForwardingHandlerEvents.DISCONNECTED, {
remoteAddress
});
});
// Set timeout
const timeout = this.getTimeout();
tlsSocket.setTimeout(timeout);
tlsSocket.on('timeout', () => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress,
error: 'TLS connection timeout'
});
if (!tlsSocket.destroyed) {
tlsSocket.destroy();
}
});
}
/**
* Handle an HTTP request by forwarding to the HTTPS backend
* @param req The HTTP request
* @param res The HTTP response
*/
public handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
// Check if we should redirect to HTTPS
if (this.config.http?.redirectToHttps) {
this.redirectToHttps(req, res);
return;
}
// Get the target from configuration
const target = this.getTargetFromConfig();
// Create custom headers with variable substitution
const variables = {
clientIp: req.socket.remoteAddress || 'unknown'
};
// Prepare headers, merging with any custom headers from config
const headers = this.applyCustomHeaders(req.headers, variables);
// Create the proxy request options
const options = {
hostname: target.host,
port: target.port,
path: req.url,
method: req.method,
headers,
// In a real implementation, we would configure TLS options
rejectUnauthorized: false // For testing only, never use in production
};
// Create the proxy request using HTTPS
const proxyReq = plugins.https.request(options, (proxyRes) => {
// Copy status code and headers from the proxied response
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the proxy response to the client response
proxyRes.pipe(res);
// Track response size for logging
let responseSize = 0;
proxyRes.on('data', (chunk) => {
responseSize += chunk.length;
});
proxyRes.on('end', () => {
this.emit(ForwardingHandlerEvents.HTTP_RESPONSE, {
statusCode: proxyRes.statusCode,
headers: proxyRes.headers,
size: responseSize
});
});
});
// Handle errors in the proxy request
proxyReq.on('error', (error) => {
this.emit(ForwardingHandlerEvents.ERROR, {
remoteAddress: req.socket.remoteAddress,
error: `Proxy request error: ${error.message}`
});
// Send an error response if headers haven't been sent yet
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'text/plain' });
res.end(`Error forwarding request: ${error.message}`);
} else {
// Just end the response if headers have already been sent
res.end();
}
});
// Track request details for logging
let requestSize = 0;
req.on('data', (chunk) => {
requestSize += chunk.length;
});
// Log the request
this.emit(ForwardingHandlerEvents.HTTP_REQUEST, {
method: req.method,
url: req.url,
headers: req.headers,
remoteAddress: req.socket.remoteAddress,
target: `${target.host}:${target.port}`
});
// Pipe the client request to the proxy request
if (req.readable) {
req.pipe(proxyReq);
} else {
proxyReq.end();
}
}
}

View File

@ -1,52 +0,0 @@
// Export types
export type {
ForwardingType,
IForwardConfig,
IForwardingHandler,
ITargetConfig,
IHttpOptions,
IHttpsOptions,
IAcmeForwardingOptions,
ISecurityOptions,
IAdvancedOptions
} from '../types/forwarding.types.js';
// Export values
export {
ForwardingHandlerEvents,
httpOnly,
tlsTerminateToHttp,
tlsTerminateToHttps,
httpsPassthrough
} from '../types/forwarding.types.js';
// Export domain configuration
export * from './domain-config.js';
// Export handlers
export { ForwardingHandler } from './forwarding.handler.js';
export { HttpForwardingHandler } from './http.handler.js';
export { HttpsPassthroughHandler } from './https-passthrough.handler.js';
export { HttpsTerminateToHttpHandler } from './https-terminate-to-http.handler.js';
export { HttpsTerminateToHttpsHandler } from './https-terminate-to-https.handler.js';
// Export factory
export { ForwardingHandlerFactory } from './forwarding.factory.js';
// Export manager
export { DomainManager, DomainManagerEvents } from './domain-manager.js';
// Helper functions as a convenience object
import {
httpOnly,
tlsTerminateToHttp,
tlsTerminateToHttps,
httpsPassthrough
} from '../types/forwarding.types.js';
export const helpers = {
httpOnly,
tlsTerminateToHttp,
tlsTerminateToHttps,
httpsPassthrough
};

View File

@ -1,162 +0,0 @@
import type * as plugins from '../../plugins.js';
/**
* The primary forwarding types supported by SmartProxy
*/
export type ForwardingType =
| 'http-only' // HTTP forwarding only (no HTTPS)
| 'https-passthrough' // Pass-through TLS traffic (SNI forwarding)
| 'https-terminate-to-http' // Terminate TLS and forward to HTTP backend
| 'https-terminate-to-https'; // Terminate TLS and forward to HTTPS backend
/**
* Target configuration for forwarding
*/
export interface ITargetConfig {
host: string | string[]; // Support single host or round-robin
port: number;
}
/**
* HTTP-specific options for forwarding
*/
export interface IHttpOptions {
enabled?: boolean; // Whether HTTP is enabled
redirectToHttps?: boolean; // Redirect HTTP to HTTPS
headers?: Record<string, string>; // Custom headers for HTTP responses
}
/**
* HTTPS-specific options for forwarding
*/
export interface IHttpsOptions {
customCert?: { // Use custom cert instead of auto-provisioned
key: string;
cert: string;
};
forwardSni?: boolean; // Forward SNI info in passthrough mode
}
/**
* ACME certificate handling options
*/
export interface IAcmeForwardingOptions {
enabled?: boolean; // Enable ACME certificate provisioning
maintenance?: boolean; // Auto-renew certificates
production?: boolean; // Use production ACME servers
forwardChallenges?: { // Forward ACME challenges
host: string;
port: number;
useTls?: boolean;
};
}
/**
* Security options for forwarding
*/
export interface ISecurityOptions {
allowedIps?: string[]; // IPs allowed to connect
blockedIps?: string[]; // IPs blocked from connecting
maxConnections?: number; // Max simultaneous connections
}
/**
* Advanced options for forwarding
*/
export interface IAdvancedOptions {
portRanges?: Array<{ from: number; to: number }>; // Allowed port ranges
networkProxyPort?: number; // Custom NetworkProxy port if using terminate mode
keepAlive?: boolean; // Enable TCP keepalive
timeout?: number; // Connection timeout in ms
headers?: Record<string, string>; // Custom headers with support for variables like {sni}
}
/**
* Unified forwarding configuration interface
*/
export interface IForwardConfig {
// Define the primary forwarding type - use-case driven approach
type: ForwardingType;
// Target configuration
target: ITargetConfig;
// Protocol options
http?: IHttpOptions;
https?: IHttpsOptions;
acme?: IAcmeForwardingOptions;
// Security and advanced options
security?: ISecurityOptions;
advanced?: IAdvancedOptions;
}
/**
* Event types emitted by forwarding handlers
*/
export enum ForwardingHandlerEvents {
CONNECTED = 'connected',
DISCONNECTED = 'disconnected',
ERROR = 'error',
DATA_FORWARDED = 'data-forwarded',
HTTP_REQUEST = 'http-request',
HTTP_RESPONSE = 'http-response',
CERTIFICATE_NEEDED = 'certificate-needed',
CERTIFICATE_LOADED = 'certificate-loaded'
}
/**
* Base interface for forwarding handlers
*/
export interface IForwardingHandler extends plugins.EventEmitter {
initialize(): Promise<void>;
handleConnection(socket: plugins.net.Socket): void;
handleHttpRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void;
}
/**
* Helper function types for common forwarding patterns
*/
export const httpOnly = (
partialConfig: Partial<IForwardConfig> & Pick<IForwardConfig, 'target'>
): IForwardConfig => ({
type: 'http-only',
target: partialConfig.target,
http: { enabled: true, ...(partialConfig.http || {}) },
...(partialConfig.security ? { security: partialConfig.security } : {}),
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
});
export const tlsTerminateToHttp = (
partialConfig: Partial<IForwardConfig> & Pick<IForwardConfig, 'target'>
): IForwardConfig => ({
type: 'https-terminate-to-http',
target: partialConfig.target,
https: { ...(partialConfig.https || {}) },
acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) },
http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) },
...(partialConfig.security ? { security: partialConfig.security } : {}),
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
});
export const tlsTerminateToHttps = (
partialConfig: Partial<IForwardConfig> & Pick<IForwardConfig, 'target'>
): IForwardConfig => ({
type: 'https-terminate-to-https',
target: partialConfig.target,
https: { ...(partialConfig.https || {}) },
acme: { enabled: true, maintenance: true, ...(partialConfig.acme || {}) },
http: { enabled: true, redirectToHttps: true, ...(partialConfig.http || {}) },
...(partialConfig.security ? { security: partialConfig.security } : {}),
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
});
export const httpsPassthrough = (
partialConfig: Partial<IForwardConfig> & Pick<IForwardConfig, 'target'>
): IForwardConfig => ({
type: 'https-passthrough',
target: partialConfig.target,
https: { forwardSni: true, ...(partialConfig.https || {}) },
...(partialConfig.security ? { security: partialConfig.security } : {}),
...(partialConfig.advanced ? { advanced: partialConfig.advanced } : {})
});

View File

@ -1,16 +0,0 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
},
"exclude": [
"dist_*/**/*.d.ts"
]
}

17
tslint.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": ["tslint:latest", "tslint-config-prettier"],
"rules": {
"semicolon": [true, "always"],
"no-console": false,
"ordered-imports": false,
"object-literal-sort-keys": false,
"member-ordering": {
"options":{
"order": [
"static-method"
]
}
}
},
"defaultSeverity": "warning"
}