feat: add SmartProxy Docker image

This commit is contained in:
2026-04-28 20:26:17 +00:00
commit 83e483f29c
13 changed files with 5430 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.nogit
node_modules
dist
dist_*
coverage
.cache
*.log
+35
View File
@@ -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
+41
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
dist_*/
.nogit/
*.log
+31
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
+38
View File
@@ -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"
}
+4768
View File
File diff suppressed because it is too large Load Diff
+86
View File
@@ -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..."
}
}
}
}
]
}
```
+18
View File
@@ -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
View File
@@ -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();
+14
View File
@@ -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"]
}