feat: add SmartProxy Docker image
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.nogit
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist_*
|
||||||
|
coverage
|
||||||
|
.cache
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Docker (non-tag pushes)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags-ignore:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ${{ env.IMAGE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @git.zone/tsdocker@latest
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Build daemon
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: tsdocker build
|
||||||
|
|
||||||
|
- name: Test image
|
||||||
|
run: tsdocker test
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Docker (tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: code.foss.global/host.today/ht-docker-node:szci
|
||||||
|
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
|
||||||
|
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: code.foss.global/host.today/ht-docker-dbase:szci
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
pnpm install -g pnpm
|
||||||
|
pnpm install -g @git.zone/tsdocker@latest
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Login to registries
|
||||||
|
run: tsdocker login
|
||||||
|
|
||||||
|
- name: List images
|
||||||
|
run: tsdocker list
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
run: tsdocker build
|
||||||
|
|
||||||
|
- name: Test images
|
||||||
|
run: tsdocker test
|
||||||
|
|
||||||
|
- name: Push to code.foss.global
|
||||||
|
run: tsdocker push code.foss.global
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist_*/
|
||||||
|
.nogit/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"@git.zone/tsdocker": {
|
||||||
|
"registries": ["code.foss.global"],
|
||||||
|
"registryRepoMap": {
|
||||||
|
"code.foss.global": "host.today/ht-docker-smartproxy"
|
||||||
|
},
|
||||||
|
"platforms": ["linux/amd64", "linux/arm64"],
|
||||||
|
"testDir": "./test"
|
||||||
|
},
|
||||||
|
"@git.zone/cli": {
|
||||||
|
"projectType": "docker-image",
|
||||||
|
"module": {
|
||||||
|
"githost": "code.foss.global",
|
||||||
|
"gitscope": "host.today",
|
||||||
|
"gitrepo": "ht-docker-smartproxy",
|
||||||
|
"shortDescription": "Docker image for the Rust-backed SmartProxy daemon",
|
||||||
|
"npmPackagename": "@host.today/ht-docker-smartproxy",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "A multi-architecture Docker image that runs @push.rocks/smartproxy as a managed daemon with an HTTP admin API.",
|
||||||
|
"keywords": [
|
||||||
|
"Docker",
|
||||||
|
"SmartProxy",
|
||||||
|
"reverse proxy",
|
||||||
|
"TLS",
|
||||||
|
"Rust",
|
||||||
|
"Node.js",
|
||||||
|
"multi-arch"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM code.foss.global/host.today/ht-docker-node:lts AS build
|
||||||
|
LABEL author="Task Venture Capital GmbH <hello@task.vc>"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ts ./ts
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
RUN pnpm build:daemon && pnpm prune --prod
|
||||||
|
|
||||||
|
FROM code.foss.global/host.today/ht-docker-node:lts
|
||||||
|
LABEL author="Task Venture Capital GmbH <hello@task.vc>"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV SMARTPROXY_CONFIG=/etc/smartproxy/config.json
|
||||||
|
ENV SMARTPROXY_ADMIN_HOST=0.0.0.0
|
||||||
|
ENV SMARTPROXY_ADMIN_PORT=3000
|
||||||
|
|
||||||
|
COPY --from=build /app/package.json ./package.json
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist_ts ./dist_ts
|
||||||
|
|
||||||
|
EXPOSE 80 443 3000
|
||||||
|
CMD ["node", "dist_ts/daemon.js"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Task Venture Capital GmbH
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "ht-docker-smartproxy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Multi-architecture Docker image for the Rust-backed SmartProxy daemon.",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsdocker build",
|
||||||
|
"build:daemon": "tsbuild",
|
||||||
|
"test": "pnpm build:daemon && node dist_ts/daemon.js --check",
|
||||||
|
"release:docker": "tsdocker push --verbose"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "ssh://code.foss.global:29419/host.today/ht-docker-smartproxy.git"
|
||||||
|
},
|
||||||
|
"author": "Task Venture Capital GmbH",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"Docker",
|
||||||
|
"SmartProxy",
|
||||||
|
"reverse proxy",
|
||||||
|
"TLS",
|
||||||
|
"Rust",
|
||||||
|
"Node.js",
|
||||||
|
"multi-arch"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@push.rocks/smartproxy": "^27.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
|
"@git.zone/tsdocker": "^2.1.0",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
|
}
|
||||||
Generated
+4768
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
# ht-docker-smartproxy
|
||||||
|
|
||||||
|
Multi-architecture Docker image for running `@push.rocks/smartproxy` as a daemon.
|
||||||
|
|
||||||
|
The image wraps SmartProxy with a small Node.js admin API so orchestrators such as Onebox can update routes without embedding the Node/Rust SmartProxy runtime into their own process.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
`tsdocker` builds `linux/amd64` and `linux/arm64` according to `.smartconfig.json`.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm release:docker
|
||||||
|
```
|
||||||
|
|
||||||
|
The image is pushed as `code.foss.global/host.today/ht-docker-smartproxy`.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-p 80:80 \
|
||||||
|
-p 443:443 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v ./config.json:/etc/smartproxy/config.json:ro \
|
||||||
|
code.foss.global/host.today/ht-docker-smartproxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `SMARTPROXY_CONFIG`: config path, default `/etc/smartproxy/config.json`.
|
||||||
|
- `SMARTPROXY_ADMIN_HOST`: admin bind host, default `0.0.0.0`.
|
||||||
|
- `SMARTPROXY_ADMIN_PORT`: admin bind port, default `3000`.
|
||||||
|
- `SMARTPROXY_ADMIN_TOKEN`: optional bearer token for admin endpoints.
|
||||||
|
|
||||||
|
## Admin API
|
||||||
|
|
||||||
|
- `GET /health`: health status.
|
||||||
|
- `GET /routes`: current raw routes.
|
||||||
|
- `PUT /routes`: replace routes with either an array or `{ "routes": [...] }`.
|
||||||
|
- `POST /reload`: reload config from `SMARTPROXY_CONFIG` and restart SmartProxy.
|
||||||
|
- `POST /security-policy`: update global security policy.
|
||||||
|
- `GET /statistics`: SmartProxy runtime statistics.
|
||||||
|
- `GET /listening-ports`: currently listening proxy ports.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
The config is regular `ISmartProxyOptions` JSON with one daemon extension: `httpToHttpsRedirect`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"httpToHttpsRedirect": {
|
||||||
|
"enabled": true,
|
||||||
|
"httpPort": 80,
|
||||||
|
"httpsPort": 443,
|
||||||
|
"statusCode": 301
|
||||||
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"name": "app-example-com",
|
||||||
|
"match": {
|
||||||
|
"ports": 443,
|
||||||
|
"domains": "app.example.com",
|
||||||
|
"protocol": "http"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "forward",
|
||||||
|
"targets": [{ "host": "app", "port": 3000 }],
|
||||||
|
"tls": {
|
||||||
|
"mode": "terminate",
|
||||||
|
"certificate": {
|
||||||
|
"key": "-----BEGIN PRIVATE KEY-----\\n...",
|
||||||
|
"cert": "-----BEGIN CERTIFICATE-----\\n..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Testing ht-docker-smartproxy image..."
|
||||||
|
|
||||||
|
node --version
|
||||||
|
node -e "const mod = await import('@push.rocks/smartproxy'); if (!mod.SmartProxy) process.exit(1); console.log('SmartProxy import ok');"
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) rust_suffix="linux_amd64" ;;
|
||||||
|
aarch64|arm64) rust_suffix="linux_arm64" ;;
|
||||||
|
*) echo "Unsupported test architecture: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
test -x "/app/node_modules/@push.rocks/smartproxy/dist_rust/rustproxy_${rust_suffix}"
|
||||||
|
node /app/dist_ts/daemon.js --check
|
||||||
|
|
||||||
|
echo "ht-docker-smartproxy tests passed!"
|
||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SmartProxy,
|
||||||
|
SocketHandlers,
|
||||||
|
type IRouteConfig,
|
||||||
|
type ISmartProxyOptions,
|
||||||
|
type ISmartProxySecurityPolicy,
|
||||||
|
} from '@push.rocks/smartproxy';
|
||||||
|
|
||||||
|
interface IHttpToHttpsRedirectConfig {
|
||||||
|
enabled?: boolean;
|
||||||
|
httpPort?: number;
|
||||||
|
httpsPort?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
targetTemplate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISmartProxyDaemonConfig extends Omit<ISmartProxyOptions, 'routes'> {
|
||||||
|
routes?: IRouteConfig[];
|
||||||
|
httpToHttpsRedirect?: IHttpToHttpsRedirectConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnvNumber = (envNameArg: string, defaultArg: number) => {
|
||||||
|
const value = process.env[envNameArg];
|
||||||
|
if (!value) {
|
||||||
|
return defaultArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
throw new Error(`${envNameArg} must be a TCP port number`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfigPath = () => process.env.SMARTPROXY_CONFIG || '/etc/smartproxy/config.json';
|
||||||
|
|
||||||
|
const readJsonBody = async (reqArg: IncomingMessage) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of reqArg) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.concat(chunks).toString('utf8').trim();
|
||||||
|
if (!body) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return JSON.parse(body) as unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendJson = (resArg: ServerResponse, statusCodeArg: number, dataArg: unknown) => {
|
||||||
|
resArg.writeHead(statusCodeArg, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
});
|
||||||
|
resArg.end(JSON.stringify(dataArg));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRouteArray = (valueArg: unknown): valueArg is IRouteConfig[] => {
|
||||||
|
return Array.isArray(valueArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoutesFromPayload = (payloadArg: unknown): IRouteConfig[] => {
|
||||||
|
if (isRouteArray(payloadArg)) {
|
||||||
|
return payloadArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
payloadArg &&
|
||||||
|
typeof payloadArg === 'object' &&
|
||||||
|
'routes' in payloadArg &&
|
||||||
|
isRouteArray((payloadArg as { routes: unknown }).routes)
|
||||||
|
) {
|
||||||
|
return (payloadArg as { routes: IRouteConfig[] }).routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Expected request body to be a route array or an object with a routes array');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSecurityPolicyFromPayload = (payloadArg: unknown): ISmartProxySecurityPolicy => {
|
||||||
|
if (!payloadArg || typeof payloadArg !== 'object' || Array.isArray(payloadArg)) {
|
||||||
|
throw new Error('Expected request body to be a SmartProxy security policy object');
|
||||||
|
}
|
||||||
|
return payloadArg as ISmartProxySecurityPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SmartProxyDaemon {
|
||||||
|
private smartProxy: SmartProxy | undefined;
|
||||||
|
private adminServer: Server | undefined;
|
||||||
|
private rawConfig: ISmartProxyDaemonConfig = { routes: [] };
|
||||||
|
private rawRoutes: IRouteConfig[] = [];
|
||||||
|
private activeRoutes: IRouteConfig[] = [];
|
||||||
|
private readonly configPath = getConfigPath();
|
||||||
|
private readonly adminHost = process.env.SMARTPROXY_ADMIN_HOST || '0.0.0.0';
|
||||||
|
private readonly adminPort = getEnvNumber('SMARTPROXY_ADMIN_PORT', 3000);
|
||||||
|
private readonly adminToken = process.env.SMARTPROXY_ADMIN_TOKEN;
|
||||||
|
|
||||||
|
public async check() {
|
||||||
|
this.rawConfig = await this.loadConfig();
|
||||||
|
this.rawRoutes = this.rawConfig.routes || [];
|
||||||
|
this.activeRoutes = this.createActiveRoutes();
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
configPath: this.configPath,
|
||||||
|
configuredRoutes: this.rawRoutes.length,
|
||||||
|
activeRoutes: this.activeRoutes.length,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {
|
||||||
|
this.rawConfig = await this.loadConfig();
|
||||||
|
this.rawRoutes = this.rawConfig.routes || [];
|
||||||
|
await this.startSmartProxy();
|
||||||
|
await this.startAdminServer();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`SmartProxy daemon started: admin=${this.adminHost}:${this.adminPort}, routes=${this.activeRoutes.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.adminServer) {
|
||||||
|
await new Promise<void>((resolveArg, rejectArg) => {
|
||||||
|
this.adminServer!.close((errorArg) => {
|
||||||
|
if (errorArg) {
|
||||||
|
rejectArg(errorArg);
|
||||||
|
} else {
|
||||||
|
resolveArg();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.adminServer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.smartProxy) {
|
||||||
|
await this.smartProxy.stop();
|
||||||
|
this.smartProxy = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadConfig(): Promise<ISmartProxyDaemonConfig> {
|
||||||
|
if (!existsSync(this.configPath)) {
|
||||||
|
return { routes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const configFile = await readFile(this.configPath, 'utf8');
|
||||||
|
const parsedConfig = JSON.parse(configFile) as ISmartProxyDaemonConfig;
|
||||||
|
return {
|
||||||
|
...parsedConfig,
|
||||||
|
routes: parsedConfig.routes || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createActiveRoutes() {
|
||||||
|
const routes = [...this.rawRoutes];
|
||||||
|
const redirectConfig = this.rawConfig.httpToHttpsRedirect;
|
||||||
|
|
||||||
|
if (redirectConfig?.enabled) {
|
||||||
|
const httpPort = redirectConfig.httpPort || 80;
|
||||||
|
const httpsPort = redirectConfig.httpsPort || 443;
|
||||||
|
const statusCode = redirectConfig.statusCode || 301;
|
||||||
|
const targetTemplate =
|
||||||
|
redirectConfig.targetTemplate || `https://{domain}${httpsPort === 443 ? '' : `:${httpsPort}`}{path}`;
|
||||||
|
|
||||||
|
routes.unshift({
|
||||||
|
name: 'http-to-https-redirect',
|
||||||
|
match: {
|
||||||
|
ports: httpPort,
|
||||||
|
protocol: 'http',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
type: 'socket-handler',
|
||||||
|
socketHandler: SocketHandlers.httpRedirect(targetTemplate, statusCode),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSmartProxyOptions(): ISmartProxyOptions {
|
||||||
|
const { httpToHttpsRedirect: _httpToHttpsRedirect, routes: _routes, ...smartProxyOptions } = this.rawConfig;
|
||||||
|
this.activeRoutes = this.createActiveRoutes();
|
||||||
|
return {
|
||||||
|
...smartProxyOptions,
|
||||||
|
routes: this.activeRoutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startSmartProxy() {
|
||||||
|
if (this.smartProxy) {
|
||||||
|
await this.smartProxy.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.smartProxy = new SmartProxy(this.createSmartProxyOptions());
|
||||||
|
await this.smartProxy.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateRoutes(routesArg: IRouteConfig[]) {
|
||||||
|
this.rawRoutes = routesArg;
|
||||||
|
this.rawConfig.routes = routesArg;
|
||||||
|
this.activeRoutes = this.createActiveRoutes();
|
||||||
|
|
||||||
|
if (!this.smartProxy) {
|
||||||
|
throw new Error('SmartProxy is not running');
|
||||||
|
}
|
||||||
|
await this.smartProxy.updateRoutes(this.activeRoutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reloadFromDisk() {
|
||||||
|
this.rawConfig = await this.loadConfig();
|
||||||
|
this.rawRoutes = this.rawConfig.routes || [];
|
||||||
|
await this.startSmartProxy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAuthorized(reqArg: IncomingMessage) {
|
||||||
|
if (!this.adminToken) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return reqArg.headers.authorization === `Bearer ${this.adminToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startAdminServer() {
|
||||||
|
this.adminServer = createServer(async (reqArg, resArg) => {
|
||||||
|
const requestUrl = new URL(reqArg.url || '/', 'http://localhost');
|
||||||
|
const method = reqArg.method || 'GET';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (method === 'GET' && (requestUrl.pathname === '/health' || requestUrl.pathname === '/ready')) {
|
||||||
|
sendJson(resArg, 200, {
|
||||||
|
ok: true,
|
||||||
|
routes: this.rawRoutes.length,
|
||||||
|
activeRoutes: this.activeRoutes.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isAuthorized(reqArg)) {
|
||||||
|
sendJson(resArg, 401, { ok: false, error: 'unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && requestUrl.pathname === '/routes') {
|
||||||
|
sendJson(resArg, 200, {
|
||||||
|
routes: this.rawRoutes,
|
||||||
|
activeRoutes: this.activeRoutes,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((method === 'PUT' || method === 'POST') && requestUrl.pathname === '/routes') {
|
||||||
|
const payload = await readJsonBody(reqArg);
|
||||||
|
await this.updateRoutes(getRoutesFromPayload(payload));
|
||||||
|
sendJson(resArg, 200, {
|
||||||
|
ok: true,
|
||||||
|
routes: this.rawRoutes.length,
|
||||||
|
activeRoutes: this.activeRoutes.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && requestUrl.pathname === '/reload') {
|
||||||
|
await this.reloadFromDisk();
|
||||||
|
sendJson(resArg, 200, {
|
||||||
|
ok: true,
|
||||||
|
routes: this.rawRoutes.length,
|
||||||
|
activeRoutes: this.activeRoutes.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'POST' && requestUrl.pathname === '/security-policy') {
|
||||||
|
if (!this.smartProxy) {
|
||||||
|
throw new Error('SmartProxy is not running');
|
||||||
|
}
|
||||||
|
const payload = await readJsonBody(reqArg);
|
||||||
|
const policy = getSecurityPolicyFromPayload(payload);
|
||||||
|
await this.smartProxy.updateSecurityPolicy(policy);
|
||||||
|
this.rawConfig.securityPolicy = policy;
|
||||||
|
sendJson(resArg, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && requestUrl.pathname === '/statistics') {
|
||||||
|
if (!this.smartProxy) {
|
||||||
|
throw new Error('SmartProxy is not running');
|
||||||
|
}
|
||||||
|
sendJson(resArg, 200, await this.smartProxy.getStatistics());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'GET' && requestUrl.pathname === '/listening-ports') {
|
||||||
|
if (!this.smartProxy) {
|
||||||
|
throw new Error('SmartProxy is not running');
|
||||||
|
}
|
||||||
|
sendJson(resArg, 200, { ports: await this.smartProxy.getListeningPorts() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(resArg, 404, { ok: false, error: 'not found' });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
sendJson(resArg, 500, { ok: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolveArg) => {
|
||||||
|
this.adminServer!.listen(this.adminPort, this.adminHost, resolveArg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemon = new SmartProxyDaemon();
|
||||||
|
|
||||||
|
if (process.argv.includes('--check')) {
|
||||||
|
await daemon.check();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAndExit = async (signalArg: NodeJS.Signals) => {
|
||||||
|
console.log(`Received ${signalArg}, stopping SmartProxy daemon...`);
|
||||||
|
await daemon.stop();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
void stopAndExit('SIGTERM');
|
||||||
|
});
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
void stopAndExit('SIGINT');
|
||||||
|
});
|
||||||
|
|
||||||
|
await daemon.start();
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": false,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"outDir": "dist_ts"
|
||||||
|
},
|
||||||
|
"include": ["ts/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user