Compare commits

...

25 Commits

Author SHA1 Message Date
jkunz 7f7a26fb38 v13.40.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 8m31s
2026-05-30 19:57:32 +00:00
jkunz a089b681c4 feat(monitoring-opsserver-radius): use active connection snapshots for proxy metrics and RADIUS network secrets 2026-05-30 19:57:09 +00:00
jkunz 3e71301bf5 v13.39.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m54s
2026-05-30 18:09:42 +00:00
jkunz 58cc8c0753 feat(remoteingress,radius): add remote ingress performance overrides and update RADIUS integration 2026-05-30 18:09:18 +00:00
jkunz e279814803 v13.38.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m11s
2026-05-30 15:05:32 +00:00
jkunz 6bee2eb172 fix(deps): bump @serve.zone/remoteingress to ^4.22.1 2026-05-30 15:05:16 +00:00
jkunz db8ea99e88 v13.38.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m19s
2026-05-30 13:19:15 +00:00
jkunz 98ccf82af0 fix(deps): update @serve.zone/remoteingress to ^4.22.0 2026-05-30 13:18:48 +00:00
jkunz 0f99525612 v13.38.2
Docker (tags) / release (push) Failing after 16m7s
Release / build-and-release (push) Failing after 14m45s
2026-05-30 11:40:28 +00:00
jkunz 8e707d9c4d fix(deps): bump @serve.zone/remoteingress to ^4.21.1 2026-05-30 11:40:00 +00:00
jkunz 418c825b01 v13.38.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 8m58s
2026-05-30 10:35:31 +00:00
jkunz 75f29af27f fix(deps): update @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:35:02 +00:00
jkunz 4467fe629a fix(deps): bump @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:31:37 +00:00
jkunz 1912feffe5 v13.38.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m45s
2026-05-29 17:57:08 +00:00
jkunz 9077b3dad6 feat(dns): support explicit DNS bind interface configuration 2026-05-29 17:56:33 +00:00
jkunz d09ac51c5b v13.37.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m10s
2026-05-29 15:21:54 +00:00
jkunz 9d7975721d fix(packaging): exclude assets from compiled and published artifacts 2026-05-29 15:21:22 +00:00
jkunz 667d62b456 v13.37.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 4m28s
2026-05-29 14:52:42 +00:00
jkunz 90b1ca8de3 fix(release): configure pnpm registry for release workflow 2026-05-29 14:45:22 +00:00
jkunz 17d824d718 v13.37.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 20s
2026-05-29 14:05:26 +00:00
jkunz 06a8636aee feat(distribution): add binary installer 2026-05-29 13:58:05 +00:00
jkunz 4bf08c1fc3 fix(distribution): sync Deno binary import map 2026-05-29 10:43:12 +00:00
jkunz 7e721c54d0 feat(distribution): add CLI binary distribution and improve DNS challenge handling 2026-05-29 10:38:54 +00:00
jkunz e6aa5a1dd2 v13.36.3
Docker (tags) / release (push) Failing after 1s
2026-05-29 08:42:32 +00:00
jkunz bbe18e1413 fix(deps): bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts 2026-05-29 08:42:14 +00:00
30 changed files with 1175 additions and 310 deletions
+140
View File
@@ -0,0 +1,140 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build-and-release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-node:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Deno
uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable corepack
run: corepack enable
- name: Configure pnpm registry
run: pnpm config set registry https://verdaccio.lossless.digital/
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_number=${VERSION#v}" >> $GITHUB_OUTPUT
echo "Building version: $VERSION"
- name: Verify package.json version matches tag
run: |
PACKAGE_VERSION=$(node -p "JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version")
TAG_VERSION="${{ steps.version.outputs.version_number }}"
echo "package.json version: $PACKAGE_VERSION"
echo "Tag version: $TAG_VERSION"
if [ "$PACKAGE_VERSION" != "$TAG_VERSION" ]; then
echo "ERROR: Version mismatch!"
exit 1
fi
- name: Test package
run: pnpm test
- name: Build binary artifacts
run: pnpm run build:binary
- name: Generate SHA256 checksums
run: |
cd dist/binaries
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
cd ../..
- name: Pack npm artifact
run: |
mkdir -p dist/package
pnpm pack --pack-destination dist/package
ls -lh dist/package
- name: Extract changelog for this version
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ -f changelog.md ]; then
awk "/## $VERSION/,/## /" changelog.md | sed '$d' > /tmp/release_notes.md || true
fi
if [ ! -s /tmp/release_notes.md ]; then
cat > /tmp/release_notes.md << EOF
## DcRouter $VERSION
NodeNext package build plus self-extracting Linux binaries.
### Artifacts
- npm package tarball
- dcrouter-linux-x64
- dcrouter-linux-arm64
- SHA256SUMS.txt
EOF
fi
- name: Delete existing release if it exists
run: |
VERSION="${{ steps.version.outputs.version }}"
EXISTING_RELEASE_ID=$(curl -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/tags/$VERSION" \
| jq -r '.id // empty')
if [ -n "$EXISTING_RELEASE_ID" ]; then
curl -X DELETE -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$EXISTING_RELEASE_ID"
sleep 2
fi
- name: Create Gitea Release
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_ID=$(curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases" \
-d "{
\"tag_name\": \"$VERSION\",
\"name\": \"DcRouter $VERSION\",
\"body\": $(jq -Rs . /tmp/release_notes.md),
\"draft\": false,
\"prerelease\": false
}" | jq -r '.id')
for artifact in dist/package/* dist/binaries/*; do
[ -f "$artifact" ] || continue
filename=$(basename "$artifact")
curl -X POST -s \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$artifact" \
"https://code.foss.global/api/v1/repos/serve.zone/dcrouter/releases/$RELEASE_ID/assets?name=$filename"
done
- name: Release Summary
run: |
echo "Release ${{ steps.version.outputs.version }} complete"
ls -lh dist/package
ls -lh dist/binaries
+23 -1
View File
@@ -29,6 +29,28 @@
}
]
},
"@git.zone/tsdeno": {
"compileTargets": [
{
"name": "dcrouter-linux-x64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "x86_64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
},
{
"name": "dcrouter-linux-arm64",
"entryPoint": "binary/dcrouter.ts",
"outDir": "dist/binaries",
"target": "aarch64-unknown-linux-gnu",
"permissions": ["--allow-all"],
"noCheck": true,
"selfExtracting": true
}
]
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
@@ -96,4 +118,4 @@
]
},
"@ship.zone/szci": {}
}
}
+4
View File
@@ -0,0 +1,4 @@
process.env.CLI_CALL = 'true';
const cliTool = await import('../dist_ts/index.js');
await cliTool.runCli();
+107
View File
@@ -3,6 +3,113 @@
## Pending
## 2026-05-30 - 13.40.0
### Features
- use active connection snapshots for proxy metrics and RADIUS network secrets (monitoring-opsserver-radius)
- Add cached SmartProxy active connection snapshots for connection info and network statistics.
- Report ops security active connections from per-connection snapshots with protocol, state, and byte counters.
- Configure RADIUS clients through smartradius network secrets, including CIDR ranges, and forward additional RADIUS attributes.
- Bump smartproxy to ^27.12.1 and smartradius to ^1.3.0.
## 2026-05-30 - 13.39.0
### Features
- add remote ingress performance overrides and update RADIUS integration (remoteingress,radius)
- Persist and propagate optional remote ingress performance overrides through remote ingress create/update APIs, database documents, and hub allowed-edge sync.
- Add web UI controls and status display for per-edge maximum connection overrides.
- Extend remote ingress performance interfaces with stream payload, timeout, and server-first port settings.
- Update RADIUS server integration for smartradius 1.2 request/response handling and client secret resolution, including CIDR matching.
## 2026-05-30 - 13.38.4
### Fixes
- bump @serve.zone/remoteingress to ^4.22.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json.
## 2026-05-30 - 13.38.3
### Fixes
- update @serve.zone/remoteingress to ^4.22.0 (deps)
- Updated @serve.zone/remoteingress from ^4.21.1 to ^4.22.0 in package.json and deno.json.
## 2026-05-30 - 13.38.2
### Fixes
- bump @serve.zone/remoteingress to ^4.21.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json from ^4.21.0 to ^4.21.1.
## 2026-05-30 - 13.38.1
### Fixes
- bump @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
- update @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates the Deno import mapping for @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
## 2026-05-29 - 13.38.0
### Features
- support explicit DNS bind interface configuration (dns)
- Add a dnsBindInterface option to override the embedded DNS UDP bind address.
- Read DCROUTER_DNS_BIND_INTERFACE from OCI container configuration and document it in CLI help.
- Add test coverage for explicit DNS bind interface handling in OCI config.
## 2026-05-29 - 13.37.2
### Fixes
- exclude assets from compiled and published artifacts (packaging)
- Removed assets from the Deno compile include list.
- Removed assets from the npm package files list.
## 2026-05-29 - 13.37.1
### Fixes
- configure pnpm registry for release workflow (release)
- Sets the pnpm registry before dependency installation so release builds resolve packages from the configured registry.
## 2026-05-29 - 13.37.0
### Features
- add CLI binary distribution (distribution)
- Add dcrouter bin entry, Deno compile targets, binary entrypoint, and tag-driven release workflow for Linux artifacts.
- Add --version and --help handling to the CLI for safe package and binary smoke tests.
- Keep the Deno binary import map aligned with the current SmartDNS and SmartProxy runtime dependencies.
- add one-line installer and Docker distribution docs (distribution)
- Add an install.sh flow that installs Linux x64 and arm64 release binaries by default with a NodeNext source-build fallback.
- Document installer modes, binary artifact names, and the published multi-arch Docker image.
## 2026-05-29 - 13.36.3
### Fixes
- update SmartProxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
- bump smartproxy to keep idle WebSocket tunnels on dedicated lifecycle timeouts (deps)
- Bump @push.rocks/smartproxy to ^27.11.1.
- Prevent public gateway WebSocket routes from inheriting the HTTP socket timeout.
## 2026-05-29 - 13.36.2
### Fixes
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.40.0",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
},
"imports": {
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.11.1",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.1",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"uuid": "npm:uuid@^14.0.0"
}
}
Executable
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
# DcRouter Installer Script
# Installs the self-extracting Linux binary by default, or builds the NodeNext
# source package when --source is specified.
#
# Usage:
# Binary install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
#
# Source install:
# curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
#
# Options:
# -h, --help Show this help message
# --version VERSION Install a specific tag/version (e.g. vX.Y.Z)
# --install-dir DIR Installation directory (default: /opt/dcrouter)
# --binary Install release binary (default)
# --source Clone the tag and build the NodeNext package locally
set -euo pipefail
SHOW_HELP=0
SPECIFIED_VERSION=""
INSTALL_DIR="/opt/dcrouter"
INSTALL_MODE="binary"
GITEA_BASE_URL="https://code.foss.global"
GITEA_REPO="serve.zone/dcrouter"
SERVICE_NAME="dcrouter"
BIN_DIR="/usr/local/bin"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
SHOW_HELP=1
shift
;;
--version)
if [[ $# -lt 2 ]]; then
echo "Error: --version requires a value"
exit 1
fi
SPECIFIED_VERSION="$2"
shift 2
;;
--install-dir)
if [[ $# -lt 2 ]]; then
echo "Error: --install-dir requires a value"
exit 1
fi
INSTALL_DIR="$2"
shift 2
;;
--binary)
INSTALL_MODE="binary"
shift
;;
--source)
INSTALL_MODE="source"
shift
;;
*)
echo "Unknown option: $1"
echo "Use -h or --help for usage information"
exit 1
;;
esac
done
if [[ $SHOW_HELP -eq 1 ]]; then
echo "DcRouter Installer Script"
echo "Installs DcRouter as a self-extracting binary or NodeNext source build."
echo ""
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " --version VERSION Install a specific tag/version (e.g. vX.Y.Z)"
echo " --install-dir DIR Installation directory (default: /opt/dcrouter)"
echo " --binary Install release binary (default)"
echo " --source Clone the tag and build the NodeNext package locally"
echo ""
echo "Examples:"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source"
echo " curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --version vX.Y.Z"
exit 0
fi
if [[ "$EUID" -ne 0 ]]; then
echo "Please run as root (sudo bash install.sh or pipe to sudo bash)"
exit 1
fi
case "$INSTALL_DIR" in
""|"/")
echo "Error: unsafe install directory: $INSTALL_DIR"
exit 1
;;
esac
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command not found: $1"
exit 1
fi
}
ensure_pnpm() {
if command -v pnpm >/dev/null 2>&1; then
return
fi
if command -v corepack >/dev/null 2>&1; then
corepack enable
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "Error: pnpm is required for --source installs. Install Node.js with corepack/pnpm first."
exit 1
fi
}
make_executable_if_present() {
if [[ -f "$1" ]]; then
chmod 0755 "$1"
fi
}
get_latest_version() {
echo "Fetching latest release version from Gitea..." >&2
local api_url="${GITEA_BASE_URL}/api/v1/repos/${GITEA_REPO}/releases/latest"
local response
if ! response=$(curl -fsSL "$api_url" 2>/dev/null); then
echo "Error: Failed to fetch latest release information from Gitea API" >&2
echo "URL: $api_url" >&2
exit 1
fi
local version
version=$(printf '%s' "$response" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
if [[ -z "$version" ]]; then
echo "Error: Could not determine latest version from API response" >&2
exit 1
fi
echo "$version"
}
detect_binary_name() {
local os
local arch
os=$(uname -s)
arch=$(uname -m)
if [[ "$os" != "Linux" ]]; then
echo "Error: binary installer currently supports Linux only. Use --source for this platform." >&2
exit 1
fi
case "$arch" in
x86_64|amd64)
echo "dcrouter-linux-x64"
;;
aarch64|arm64)
echo "dcrouter-linux-arm64"
;;
*)
echo "Error: unsupported architecture for binary install: $arch. Use --source." >&2
exit 1
;;
esac
}
echo "================================================"
echo " DcRouter Installation Script"
echo "================================================"
echo ""
require_command curl
require_command sed
if [[ -n "$SPECIFIED_VERSION" ]]; then
VERSION="$SPECIFIED_VERSION"
echo "Installing specified version: $VERSION"
else
VERSION=$(get_latest_version)
echo "Installing latest version: $VERSION"
fi
echo "Install mode: $INSTALL_MODE"
echo ""
SOURCE_REF="$VERSION"
REPO_URL="${GITEA_BASE_URL}/${GITEA_REPO}.git"
TEMP_DIR=$(mktemp -d)
SOURCE_DIR="$TEMP_DIR/source"
BACKUP_DIR=""
SERVICE_WAS_RUNNING=0
SERVICE_STOPPED=0
SYSTEMD_AVAILABLE=0
cleanup_temp() {
rm -rf "$TEMP_DIR"
}
trap cleanup_temp EXIT
if command -v systemctl >/dev/null 2>&1; then
SYSTEMD_AVAILABLE=1
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
SERVICE_WAS_RUNNING=1
fi
fi
restore_previous_installation() {
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
echo "Restoring previous installation from $BACKUP_DIR..."
rm -rf "$INSTALL_DIR" || true
mv "$BACKUP_DIR" "$INSTALL_DIR" || true
if [[ -f "$INSTALL_DIR/dcrouter" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter" || true
elif [[ -f "$INSTALL_DIR/cli.js" ]]; then
mkdir -p "$BIN_DIR" || true
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter" || true
fi
fi
}
restart_previous_service_on_error() {
if [[ $SERVICE_STOPPED -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Installation failed after stopping DcRouter; restarting previous service..."
systemctl start "$SERVICE_NAME" || true
fi
}
handle_install_error() {
trap - ERR
restore_previous_installation
restart_previous_service_on_error
}
trap handle_install_error ERR
stop_service_if_running() {
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]] && systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
echo "Stopping DcRouter service..."
systemctl stop "$SERVICE_NAME"
SERVICE_STOPPED=1
fi
}
move_previous_installation() {
mkdir -p "$(dirname "$INSTALL_DIR")"
if [[ -d "$INSTALL_DIR" ]]; then
BACKUP_DIR="${INSTALL_DIR}.previous.$$"
echo "Moving previous installation to $BACKUP_DIR"
mv "$INSTALL_DIR" "$BACKUP_DIR"
fi
}
install_source_build() {
require_command git
require_command node
ensure_pnpm
echo "Cloning DcRouter source from $REPO_URL ($SOURCE_REF)..."
git clone --depth 1 --branch "$SOURCE_REF" "$REPO_URL" "$SOURCE_DIR"
echo "Installing dependencies..."
pnpm --dir "$SOURCE_DIR" install --frozen-lockfile
echo "Building DcRouter..."
pnpm --dir "$SOURCE_DIR" run build
echo "Validating built CLI..."
node "$SOURCE_DIR/cli.js" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing source build to $INSTALL_DIR"
mv "$SOURCE_DIR" "$INSTALL_DIR"
make_executable_if_present "$INSTALL_DIR/cli.js"
make_executable_if_present "$INSTALL_DIR/cli.ts.js"
make_executable_if_present "$INSTALL_DIR/cli.child.js"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/cli.js" "$BIN_DIR/dcrouter"
}
install_release_binary() {
local binary_name
local download_url
local temp_file
binary_name=$(detect_binary_name)
download_url="${GITEA_BASE_URL}/${GITEA_REPO}/releases/download/${VERSION}/${binary_name}"
temp_file="$TEMP_DIR/$binary_name"
echo "Downloading DcRouter binary: $download_url"
curl -fSL "$download_url" -o "$temp_file"
chmod 0755 "$temp_file"
echo "Validating downloaded binary..."
"$temp_file" --version >/dev/null
stop_service_if_running
move_previous_installation
echo "Installing binary to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
install -m 0755 "$temp_file" "$INSTALL_DIR/dcrouter"
mkdir -p "$BIN_DIR"
ln -sf "$INSTALL_DIR/dcrouter" "$BIN_DIR/dcrouter"
}
if [[ "$INSTALL_MODE" == "source" ]]; then
install_source_build
else
install_release_binary
fi
echo "Symlink created: $BIN_DIR/dcrouter"
if ! "$BIN_DIR/dcrouter" --version >/dev/null; then
echo "Error: Installed DcRouter CLI failed validation"
restore_previous_installation
restart_previous_service_on_error
exit 1
fi
if [[ -n "$BACKUP_DIR" && -d "$BACKUP_DIR" ]]; then
rm -rf "$BACKUP_DIR"
fi
if [[ $SERVICE_WAS_RUNNING -eq 1 && $SYSTEMD_AVAILABLE -eq 1 ]]; then
echo "Restarting DcRouter service..."
systemctl restart "$SERVICE_NAME"
SERVICE_STOPPED=0
echo "Service restarted successfully."
echo ""
fi
trap - ERR
echo "================================================"
echo " DcRouter Installation Complete!"
echo "================================================"
echo ""
echo "Installation details:"
echo " Install directory: $INSTALL_DIR"
echo " Symlink location: $BIN_DIR/dcrouter"
echo " Version: $VERSION"
echo " Mode: $INSTALL_MODE"
echo ""
echo "Get started:"
echo ""
echo " dcrouter --version"
echo " dcrouter --help"
echo ""
+17 -15
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.36.2",
"version": "13.40.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
"dcrouter": "./cli.js"
},
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js",
@@ -15,7 +18,8 @@
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"start": "(node ./cli.js)",
"startTs": "(node cli.ts.js)",
"build": "(tsbuild tsfolders --allowimplicitany && npm run bundle)",
"build": "(tsbuild tsfolders --allowimplicitany && pnpm run bundle)",
"build:binary": "(pnpm run build && tsdeno compile)",
"build:docker": "tsdocker build --verbose",
"release:docker": "tsdocker push --verbose",
"bundle": "(tsbundle)",
@@ -25,6 +29,7 @@
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.4.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
@@ -36,7 +41,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.3",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-catalog": "^3.83.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.3.1",
"@push.rocks/lik": "^6.4.1",
@@ -56,8 +61,8 @@
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.11.0",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartproxy": "^27.12.1",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
@@ -66,7 +71,7 @@
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.18.0",
"@serve.zone/remoteingress": "^4.22.1",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
@@ -99,25 +104,22 @@
"VLAN assignment",
"MAC authentication"
],
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"mongodb-memory-server",
"puppeteer"
]
},
"packageManager": "pnpm@10.11.0",
"files": [
"ts/**/*",
"binary/**/*",
"ts_web/**/*",
"ts_apiclient/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"dist_ts_apiclient/**/*",
"assets/**/*",
"cli.js",
"cli.ts.js",
"cli.child.js",
"cli.child.ts",
"deno.json",
"tsconfig.json",
".smartconfig.json",
"readme.md"
]
+43 -22
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.81.0
version: 3.81.0(@tiptap/pm@2.27.2)
specifier: ^3.83.0
version: 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -84,11 +84,11 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.11.0
version: 27.11.0
specifier: ^27.12.1
version: 27.12.1
'@push.rocks/smartradius':
specifier: ^1.1.2
version: 1.1.2
specifier: ^1.3.0
version: 1.3.0
'@push.rocks/smartrequest':
specifier: ^5.0.3
version: 5.0.3
@@ -114,8 +114,8 @@ importers:
specifier: ^5.8.0
version: 5.8.0
'@serve.zone/remoteingress':
specifier: ^4.18.0
version: 4.18.0
specifier: ^4.22.1
version: 4.22.1
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -138,6 +138,9 @@ importers:
'@git.zone/tsbundle':
specifier: ^2.10.4
version: 2.10.4
'@git.zone/tsdeno':
specifier: ^1.4.0
version: 1.4.0
'@git.zone/tsdocker':
specifier: ^2.4.0
version: 2.4.0
@@ -362,8 +365,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.81.0':
resolution: {integrity: sha512-N7ocwSKVdjDQWmVV2XWiyg3dotGEuxP4/jhyB6duH8zJ3k63wmGm8+FeoP+LzRc8/U0Bl8w7UZrewlkIEMstUA==}
'@design.estate/dees-catalog@3.83.0':
resolution: {integrity: sha512-Ia4fwZ5ndziJkSE000nCro83rD8Rujki7ASHBQhL6ZDflZRJRlfuc13azVnQC2sazKlo/bWSgiiLcpc3V2IYrw==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -726,6 +729,10 @@ packages:
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
hasBin: true
'@git.zone/tsdeno@1.4.0':
resolution: {integrity: sha512-84kFa/uKPTlzeLxtHoFxefk6O9khsWWQ2PCWNbCNYIUqWHUvN9COpGq0GXWtsoxLWPhTTIeHsOX4+O55uT2MPw==}
hasBin: true
'@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
hasBin: true
@@ -1422,14 +1429,14 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.11.0':
resolution: {integrity: sha512-ruyUMbrk28BTtrhcZpB5fX35FRQyyhJgVd7snPFa3Zttw0N8ahYrwKXpKfuagvOcaIpORMQoyR5WSv0C2ATFVA==}
'@push.rocks/smartproxy@27.12.1':
resolution: {integrity: sha512-B1QNyGzwFea8fE2vvXO0iDzYrTfe3HcEnhPhNi6hVnmdSPe1yhNYUu5tm1CKLeCoXu/EVkAUkEFv/+d7gKa9EA==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
'@push.rocks/smartradius@1.1.2':
resolution: {integrity: sha512-p4fHhMgXZRuyRuMQjFQLVnXBG1Fz2latJ7BGAsfInOuVUaitBr/Wni9mZULAuIIddeWwUx9QvIGlv3tgmFn/ow==}
'@push.rocks/smartradius@1.3.0':
resolution: {integrity: sha512-97BQhVT5gdDTNfb8LZiqaPddTMlx5Eqpsj7jTBQ2kj4tYpK0YWRiKkpBxxEXTjsIsq7iTxHeNTwc8kMZj+yU3g==}
'@push.rocks/smartrequest@2.1.0':
resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==}
@@ -1712,8 +1719,9 @@ packages:
'@serve.zone/interfaces@5.8.0':
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
'@serve.zone/remoteingress@4.18.0':
resolution: {integrity: sha512-/cW9wb/e57u9+715RzV5d8HCezWtR88LcpistTNSl7GACi5ai+C2tPy7ZQprnnrNhqjfgzWiAH4bKZafwONntg==}
'@serve.zone/remoteingress@4.22.1':
resolution: {integrity: sha512-SkpP9VeC30A6HyonlLLE8rZVNWAPjw5NeY3pU+CWRJ0Si+hJX3FkyI4IFbOOBE+PE4JbxdIjwMNzvpxuXqZeUQ==}
hasBin: true
'@smithy/chunked-blob-reader-native@4.2.3':
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
@@ -4376,7 +4384,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4)
'@cloudflare/workers-types': 4.20260507.1
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0
@@ -4910,7 +4918,7 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.81.0(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.83.0(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.6
'@design.estate/dees-element': 2.2.4
@@ -5243,6 +5251,19 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdeno@1.4.0':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.21
'@push.rocks/smartconfig': 6.1.1
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@git.zone/tsdocker@2.4.0':
dependencies:
'@push.rocks/lik': 6.4.1
@@ -6675,7 +6696,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.11.0':
'@push.rocks/smartproxy@27.12.1':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6699,7 +6720,7 @@ snapshots:
- typescript
- utf-8-validate
'@push.rocks/smartradius@1.1.2':
'@push.rocks/smartradius@1.3.0':
dependencies:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartpromise': 4.2.4
@@ -7047,7 +7068,7 @@ snapshots:
'@serve.zone/catalog@2.12.4(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.6
'@design.estate/dees-element': 2.2.4
'@design.estate/dees-wcctools': 3.9.0
@@ -7064,7 +7085,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/remoteingress@4.18.0':
'@serve.zone/remoteingress@4.22.1':
dependencies:
'@push.rocks/qenv': 6.1.4
'@push.rocks/smartnftables': 1.2.0
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
puppeteer: true
+29
View File
@@ -34,6 +34,20 @@ Highlights:
## Install
Install the CLI/runtime on a Linux gateway host with the released self-extracting binary:
```bash
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash
```
The installer downloads `dcrouter-linux-x64` or `dcrouter-linux-arm64` from the latest Gitea release, installs it under `/opt/dcrouter`, and links `/usr/local/bin/dcrouter`. Use `--version vX.Y.Z` to pin a release, `--install-dir /path` to change the target directory, or `--source` to clone the tag and build the NodeNext package locally.
```bash
curl -sSL https://code.foss.global/serve.zone/dcrouter/raw/branch/main/install.sh | sudo bash -s -- --source
```
Use the package as a TypeScript library:
```bash
pnpm add @serve.zone/dcrouter
```
@@ -260,6 +274,21 @@ Supported environment overrides include:
| `DCROUTER_CACHE_ENABLED` | Enables or disables DB-backed persistence. |
| `DCROUTER_MAX_CONNECTIONS`, `DCROUTER_MAX_CONNECTIONS_PER_IP`, `DCROUTER_CONNECTION_RATE_LIMIT` | SmartProxy capacity and rate-limit overrides. |
## Docker Image
Release builds publish a multi-arch OCI image at `code.foss.global/serve.zone/dcrouter:latest` for `linux/amd64` and `linux/arm64`. The image sets `DCROUTER_MODE=OCI_CONTAINER` and starts `node ./cli.js`.
```bash
docker run --rm --name dcrouter \
--network host \
-v dcrouter-data:/data \
-e DCROUTER_BASE_DIR=/data \
-e DCROUTER_TLS_EMAIL=ops@example.com \
code.foss.global/serve.zone/dcrouter:latest
```
Host networking is the simplest container mode for a gateway that owns HTTP/S, SMTP, DNS, RADIUS, remote ingress, and dynamic proxy ports. For narrower deployments, publish only the ports you enable in `IDcRouterOptions` or via the `DCROUTER_*` environment overrides.
## Published Modules
This repository intentionally publishes multiple module boundaries from one codebase.
@@ -14,6 +14,38 @@ const emptyProtocolDistribution = {
otherTotal: 0,
};
function createActiveConnectionSnapshots(entries: Array<{
count: number;
sourceIp?: string;
routeId?: string;
domain?: string;
localPort?: number;
}>) {
const snapshots: any[] = [];
let index = 0;
for (const entry of entries) {
for (let i = 0; i < entry.count; i++) {
snapshots.push({
id: `test-connection-${index++}`,
sourceIp: entry.sourceIp || '192.0.2.10',
sourcePort: 40000 + index,
localPort: entry.localPort || 443,
domain: entry.domain,
routeId: entry.routeId,
targetHost: '127.0.0.1',
targetPort: 8443,
protocol: 'https',
state: 'active',
startedAtMs: Date.now(),
ageMs: 0,
bytesIn: 0,
bytesOut: 0,
});
}
}
return snapshots;
}
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
@@ -90,6 +122,10 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 3, routeId: 'route-id-only', domain: 'alpha.example.com' },
{ count: 1, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -150,6 +186,9 @@ tap.test('MetricsManager prefers live domain request rates for current activity'
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 10, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -231,6 +270,7 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => [],
routeManager: {
getRoutes: () => [],
},
@@ -265,6 +305,10 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 2, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
@@ -300,6 +344,11 @@ tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () =>
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 3, sourceIp: '8.8.4.4' },
{ count: 5, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
+20
View File
@@ -0,0 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { getOciContainerConfig } from '../ts_oci_container/index.js';
tap.test('OCI config should accept explicit DNS bind interface', async () => {
const previousValue = process.env.DCROUTER_DNS_BIND_INTERFACE;
process.env.DCROUTER_DNS_BIND_INTERFACE = '192.168.190.3';
try {
const config = getOciContainerConfig();
expect(config.dnsBindInterface).toEqual('192.168.190.3');
} finally {
if (previousValue === undefined) {
delete process.env.DCROUTER_DNS_BIND_INTERFACE;
} else {
process.env.DCROUTER_DNS_BIND_INTERFACE = previousValue;
}
}
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.36.2',
version: '13.40.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+18 -10
View File
@@ -93,6 +93,9 @@ export interface IDcRouterOptions {
* Email domains with `internal-dns` mode must be included here
*/
dnsScopes?: string[];
/** Explicit UDP bind address for the embedded DNS server. Defaults to auto-detection. */
dnsBindInterface?: string;
/**
* IPs of proxies that forward traffic to your server (optional)
@@ -1875,16 +1878,21 @@ export class DcRouter {
logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`);
// Get VM IP address for UDP binding
const networkInterfaces = plugins.os.networkInterfaces();
let vmIpAddress = '0.0.0.0'; // Default to all interfaces
// Try to find the VM's internal IP address
for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
if (interfaces) {
for (const iface of interfaces) {
if (!iface.internal && iface.family === 'IPv4') {
vmIpAddress = iface.address;
break;
const networkInterfaces = plugins.os.networkInterfaces() as Record<
string,
Array<{ internal: boolean; family: string; address: string }> | undefined
>;
let vmIpAddress = this.options.dnsBindInterface || '0.0.0.0'; // Default to all interfaces
// Try to find the VM's internal IP address when no explicit bind address is configured.
if (!this.options.dnsBindInterface) {
interfaceLoop: for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
if (interfaces) {
for (const iface of interfaces) {
if (!iface.internal && iface.family === 'IPv4') {
vmIpAddress = iface.address;
break interfaceLoop;
}
}
}
}
@@ -1,5 +1,6 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@@ -27,6 +28,9 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public tags!: string[];
+2 -18
View File
@@ -1,4 +1,4 @@
import type * as plugins from '../plugins.js';
import * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
@@ -36,22 +36,6 @@ export interface IHttp3Config {
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
@@ -85,7 +69,7 @@ export function routeQualifiesForHttp3(
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
// Must have TLS
if (!route.action.tls) return false;
+24
View File
@@ -1,3 +1,4 @@
import { commitinfo } from './00_commitinfo_data.js';
export * from './00_commitinfo_data.js';
// Re-export smartmta (excluding commitinfo to avoid naming conflict)
@@ -18,6 +19,29 @@ export * from './remoteingress/index.js';
export type { IHttp3Config } from './http3/index.js';
export const runCli = async () => {
const args = process.argv.slice(2);
if (args.includes('--version') || args.includes('version')) {
console.log(commitinfo.version);
return;
}
if (args.includes('--help') || args.includes('-h') || args.includes('help')) {
console.log(`dcrouter ${commitinfo.version}
Usage:
dcrouter
dcrouter --version
dcrouter --help
Environment:
DCROUTER_MODE=OCI_CONTAINER Start with OCI container configuration
DCROUTER_DNS_BIND_INTERFACE Override the embedded DNS UDP bind address
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
+59 -21
View File
@@ -143,8 +143,9 @@ export class MetricsManager {
public async getServerStats() {
return this.metricsCache.get('serverStats', async () => {
const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
const proxyStats = smartProxy ? await smartProxy.getStatistics() : null;
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
return {
@@ -291,27 +292,44 @@ export class MetricsManager {
});
}
public async getActiveConnectionSnapshots(
options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {},
): Promise<plugins.smartproxy.IActiveConnectionSnapshot[]> {
const cacheKey = `activeConnectionSnapshots:${options.limit ?? 1000}:${options.routeId ?? ''}`;
return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshot[]>(cacheKey, async () => {
if (!this.dcRouter.smartProxy) {
return [];
}
return this.dcRouter.smartProxy.getActiveConnectionSnapshots(options);
}, 500);
}
// Get connection info from SmartProxy
public async getConnectionInfo() {
return this.metricsCache.get('connectionInfo', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
return this.metricsCache.get('connectionInfo', async () => {
const snapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByRoute = new Map<string, { count: number; lastActivity: Date }>();
for (const snapshot of snapshots) {
const source = snapshot.routeId || snapshot.domain || `${snapshot.protocol || 'connection'}:${snapshot.localPort}`;
const existing = connectionsByRoute.get(source) || { count: 0, lastActivity: new Date(snapshot.startedAtMs) };
existing.count++;
if (snapshot.startedAtMs > existing.lastActivity.getTime()) {
existing.lastActivity = new Date(snapshot.startedAtMs);
}
connectionsByRoute.set(source, existing);
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) {
for (const [source, info] of connectionsByRoute) {
connectionInfo.push({
type: 'https',
count,
source: routeName,
lastActivity: new Date(),
count: info.count,
source,
lastActivity: info.lastActivity,
});
}
return connectionInfo;
});
}
@@ -547,7 +565,8 @@ export class MetricsManager {
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return {
@@ -568,8 +587,22 @@ export class MetricsManager {
};
}
// Get metrics using the new API
const connectionsByIP = proxyMetrics.connections.byIP();
const activeConnectionSnapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByIP = new Map<string, number>();
const connectionsByRoute = new Map<string, number>();
const activeConnectionsByDomain = new Map<string, number>();
for (const snapshot of activeConnectionSnapshots) {
connectionsByIP.set(snapshot.sourceIp, (connectionsByIP.get(snapshot.sourceIp) || 0) + 1);
if (snapshot.routeId) {
connectionsByRoute.set(snapshot.routeId, (connectionsByRoute.get(snapshot.routeId) || 0) + 1);
}
if (snapshot.domain) {
activeConnectionsByDomain.set(snapshot.domain, (activeConnectionsByDomain.get(snapshot.domain) || 0) + 1);
}
}
const instantThroughput = proxyMetrics.throughput.instant();
// Get throughput rate
@@ -578,8 +611,11 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get top IPs by active connection count
const topIPs = Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([ip, count]) => ({ ip, count }));
// Get total data transferred
const totalDataTransferred = {
@@ -738,7 +774,6 @@ export class MetricsManager {
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Aggregate per-IP domain request counts into per-domain totals
@@ -773,6 +808,9 @@ export class MetricsManager {
for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain);
}
for (const snapshot of activeConnectionSnapshots) {
if (snapshot.domain) allKnownDomains.add(snapshot.domain);
}
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
@@ -844,7 +882,7 @@ export class MetricsManager {
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
activeConnections: activeConnectionsByDomain.get(domain) ?? Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeKeys.length,
@@ -67,6 +67,7 @@ export class RemoteIngressHandler {
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
dataArg.performance,
);
// Sync allowed edges with the hub
@@ -129,6 +130,7 @@ export class RemoteIngressHandler {
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
performance: dataArg.performance,
tags: dataArg.tags,
});
+57 -109
View File
@@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SecurityHandler {
@@ -46,18 +45,7 @@ export class SecurityHandler {
'getActiveConnections',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
remoteAddress: conn.source.ip,
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
}));
const connectionInfos = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = {
@@ -362,106 +350,66 @@ export class SecurityHandler {
private async getActiveConnections(
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
state?: string
): Promise<Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}>> {
const connections: Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}> = [];
// Get connection info and network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
): Promise<interfaces.data.IConnectionInfo[]> {
const metricsManager = this.opsServerRef.dcRouterRef.metricsManager;
if (!metricsManager) {
return [];
}
for (const [ip, count] of networkStats.connectionsByIP) {
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available
connectionInfo.forEach((info, index) => {
connections.push({
id: `conn-${index}`,
type: 'http',
source: {
ip: 'unknown',
port: 0,
},
destination: {
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
port: 443,
service: info.source,
},
startTime: info.lastActivity.getTime(),
bytesTransferred: 0,
status: 'active',
});
});
const snapshots = await metricsManager.getActiveConnectionSnapshots({ limit: 10000 });
const connections = snapshots.map((snapshot): interfaces.data.IConnectionInfo => ({
id: String(snapshot.id),
remoteAddress: snapshot.sourcePort === null
? snapshot.sourceIp
: `${snapshot.sourceIp}:${snapshot.sourcePort}`,
localAddress: snapshot.targetHost
? `${snapshot.targetHost}:${snapshot.targetPort ?? snapshot.localPort}`
: `${this.opsServerRef.dcRouterRef.options.publicIp || 'server'}:${snapshot.localPort}`,
startTime: snapshot.startedAtMs,
protocol: this.mapSnapshotProtocol(snapshot),
state: this.mapSnapshotState(snapshot.state),
bytesReceived: snapshot.bytesIn,
bytesSent: snapshot.bytesOut,
}));
return connections.filter((connection) => {
if (protocol && connection.protocol !== protocol) {
return false;
}
if (state && connection.state !== state) {
return false;
}
return true;
});
}
private mapSnapshotProtocol(
snapshot: plugins.smartproxy.IActiveConnectionSnapshot,
): interfaces.data.IConnectionInfo['protocol'] {
if (snapshot.localPort === 465) {
return 'smtps';
}
// Filter by protocol if specified
if (protocol) {
return connections.filter(conn => {
if (protocol === 'https' || protocol === 'http') {
return conn.type === 'http';
}
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
});
if ([25, 587, 2525].includes(snapshot.localPort)) {
return 'smtp';
}
return connections;
switch (snapshot.protocol) {
case 'http':
return 'http';
case 'https':
case 'tls':
case 'tls-passthrough':
case 'tls-reencrypt':
case 'tls-socket-handler':
case 'quic':
return 'https';
default:
return snapshot.localPort === 80 ? 'http' : 'https';
}
}
private mapSnapshotState(state: string): interfaces.data.IConnectionInfo['state'] {
return state === 'closing' ? 'closing' : 'connected';
}
private async getRateLimitStatus(
+9 -9
View File
@@ -1,13 +1,13 @@
// node native
import * as dns from 'dns';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import * as tls from 'tls';
import * as util from 'util';
import * as dns from 'node:dns';
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import * as http from 'node:http';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
import * as tls from 'node:tls';
import * as util from 'node:util';
export {
dns,
+54 -80
View File
@@ -91,7 +91,6 @@ export class RadiusServer {
private vlanManager: VlanManager;
private accountingManager: AccountingManager;
private config: IRadiusServerConfig;
private clientSecrets: Map<string, string> = new Map();
private running: boolean = false;
// Statistics
@@ -138,24 +137,18 @@ export class RadiusServer {
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
}
// Build client secrets map
this.buildClientSecretsMap();
const cidrSecrets = this.buildClientSecretsMap();
// Create the RADIUS server
this.radiusServer = new plugins.smartradius.RadiusServer({
authPort: this.config.authPort,
acctPort: this.config.acctPort,
bindAddress: this.config.bindAddress,
defaultSecret: this.getDefaultSecret(),
cidrSecrets,
authenticationHandler: this.handleAuthentication.bind(this),
accountingHandler: this.handleAccounting.bind(this),
});
// Configure per-client secrets
for (const [ip, secret] of this.clientSecrets) {
this.radiusServer.setClientSecret(ip, secret);
}
// Start the server
await this.radiusServer.start();
@@ -189,19 +182,22 @@ export class RadiusServer {
/**
* Handle authentication request
*/
private async handleAuthentication(request: any): Promise<any> {
private async handleAuthentication(
request: plugins.smartradius.IAuthenticationRequest,
): Promise<plugins.smartradius.IAuthenticationResponse> {
this.stats.authRequests++;
const authData: IAuthRequestData = {
username: request.attributes?.UserName || '',
password: request.attributes?.UserPassword,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
password: request.password,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
framedMtu: request.framedMtu,
};
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
@@ -215,15 +211,15 @@ export class RadiusServer {
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
// Build response with VLAN attributes
const response: any = {
const response: plugins.smartradius.IAuthenticationResponse = {
code: plugins.smartradius.ERadiusCode.AccessAccept,
replyMessage: result.replyMessage,
};
// Add VLAN attributes if assigned
if (result.vlanId !== undefined) {
response.tunnelType = 13; // VLAN
response.tunnelMediumType = 6; // IEEE 802
response.tunnelType = plugins.smartradius.ETunnelType.Vlan;
response.tunnelMediumType = plugins.smartradius.ETunnelMediumType.Ieee802;
response.tunnelPrivateGroupId = String(result.vlanId);
}
@@ -257,34 +253,37 @@ export class RadiusServer {
/**
* Handle accounting request
*/
private async handleAccounting(request: any): Promise<any> {
private async handleAccounting(
request: plugins.smartradius.IAccountingRequest,
): Promise<plugins.smartradius.IAccountingResponse> {
this.stats.accountingRequests++;
if (!this.config.accounting?.enabled) {
// Still respond even if not tracking
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
const statusType = request.attributes?.AcctStatusType;
const sessionId = request.attributes?.AcctSessionId || '';
const statusType = request.statusType;
const sessionId = request.sessionId || '';
const accountingData = {
sessionId,
username: request.attributes?.UserName || '',
macAddress: request.attributes?.CallingStationId,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
inputOctets: request.attributes?.AcctInputOctets,
outputOctets: request.attributes?.AcctOutputOctets,
inputPackets: request.attributes?.AcctInputPackets,
outputPackets: request.attributes?.AcctOutputPackets,
sessionTime: request.attributes?.AcctSessionTime,
terminateCause: request.attributes?.AcctTerminateCause,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
macAddress: request.callingStationId,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
inputOctets: request.inputOctets,
outputOctets: request.outputOctets,
inputPackets: request.inputPackets,
outputPackets: request.outputPackets,
sessionTime: request.sessionTime,
terminateCause: request.terminateCause !== undefined ? String(request.terminateCause) : undefined,
framedIpAddress: request.framedIpAddress,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
};
try {
@@ -311,7 +310,7 @@ export class RadiusServer {
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
}
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
/**
@@ -391,37 +390,18 @@ export class RadiusServer {
/**
* Build client secrets map from configuration
*/
private buildClientSecretsMap(): void {
this.clientSecrets.clear();
private buildClientSecretsMap(): Record<string, string> {
const cidrSecrets: Record<string, string> = {};
for (const client of this.config.clients) {
if (!client.enabled) {
continue;
}
// Handle CIDR ranges
if (client.ipRange.includes('/')) {
// For CIDR ranges, we'll use the network address as key
// In practice, smartradius may handle this differently
const [network] = client.ipRange.split('/');
this.clientSecrets.set(network, client.secret);
} else {
this.clientSecrets.set(client.ipRange, client.secret);
}
cidrSecrets[client.ipRange] = client.secret;
}
}
/**
* Get default secret for unknown clients
*/
private getDefaultSecret(): string {
// Use first enabled client's secret as default, or a random one
for (const client of this.config.clients) {
if (client.enabled) {
return client.secret;
}
}
return plugins.crypto.randomBytes(16).toString('hex');
return cidrSecrets;
}
/**
@@ -430,21 +410,19 @@ export class RadiusServer {
async addClient(client: IRadiusClient): Promise<void> {
// Check if client already exists
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
const previousClient = existingIndex >= 0 ? this.config.clients[existingIndex] : undefined;
if (existingIndex >= 0) {
this.config.clients[existingIndex] = client;
} else {
this.config.clients.push(client);
}
// Update client secrets if running
if (this.running && this.radiusServer && client.enabled) {
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.radiusServer.setClientSecret(network, client.secret);
this.clientSecrets.set(network, client.secret);
} else {
this.radiusServer.setClientSecret(client.ipRange, client.secret);
this.clientSecrets.set(client.ipRange, client.secret);
if (this.running && this.radiusServer) {
if (previousClient) {
this.radiusServer.removeNetworkSecret(previousClient.ipRange);
}
if (client.enabled) {
this.radiusServer.setNetworkSecret(client.ipRange, client.secret);
}
}
@@ -460,12 +438,8 @@ export class RadiusServer {
const client = this.config.clients[index];
this.config.clients.splice(index, 1);
// Remove from secrets map
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.clientSecrets.delete(network);
} else {
this.clientSecrets.delete(client.ipRange);
if (this.radiusServer) {
this.radiusServer.removeNetworkSecret(client.ipRange);
}
logger.log('info', `RADIUS client removed: ${name}`);
@@ -1,29 +1,13 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngress, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRemoteIngress, IRemoteIngressPerformanceConfig, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
blockedIps?: string[];
}
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
} else if (Array.isArray(portRange)) {
for (const entry of portRange) {
if (typeof entry === 'number') {
ports.add(entry);
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
for (let p = entry.from; p <= entry.to; p++) {
ports.add(p);
}
}
}
}
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
return [...ports].sort((a, b) => a - b);
}
@@ -59,6 +43,7 @@ export class RemoteIngressManager {
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
performance: doc.performance,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
@@ -189,6 +174,7 @@ export class RemoteIngressManager {
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
performance?: IRemoteIngressPerformanceConfig,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -201,6 +187,7 @@ export class RemoteIngressManager {
listenPorts,
enabled: true,
autoDerivePorts,
performance,
tags: tags || [],
createdAt: now,
updatedAt: now,
@@ -237,6 +224,7 @@ export class RemoteIngressManager {
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
@@ -249,6 +237,7 @@ export class RemoteIngressManager {
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.performance !== undefined) edge.performance = updates.performance;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
@@ -317,17 +306,19 @@ export class RemoteIngressManager {
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
const performance = edge.performance && Object.keys(edge.performance).length > 0 ? edge.performance : undefined;
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
...(performance ? { performance } : {}),
});
}
}
+10
View File
@@ -13,6 +13,8 @@ export interface IRemoteIngress {
enabled: boolean;
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
autoDerivePorts: boolean;
/** Optional per-edge performance overrides. */
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
createdAt: number;
updatedAt: number;
@@ -55,6 +57,10 @@ export interface IRemoteIngressPerformanceConfig {
maxStreamWindowBytes?: number;
sustainedStreamWindowBytes?: number;
quicDatagramReceiveBufferBytes?: number;
streamFramePayloadBytes?: number;
firstDataConnectTimeoutMs?: number;
clientWriteTimeoutMs?: number;
serverFirstPorts?: number[];
}
export interface IRemoteIngressPerformanceEffective {
@@ -65,6 +71,10 @@ export interface IRemoteIngressPerformanceEffective {
maxStreamWindowBytes: number;
sustainedStreamWindowBytes: number;
quicDatagramReceiveBufferBytes: number;
streamFramePayloadBytes: number;
firstDataConnectTimeoutMs: number;
clientWriteTimeoutMs: number;
serverFirstPorts: number[];
}
export interface IRemoteIngressFlowControlStatus {
+3 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
import type { IRemoteIngress, IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../data/remoteingress.js';
// ============================================================================
// Remote Ingress Edge Management
@@ -20,6 +20,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
};
response: {
@@ -63,6 +64,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
};
response: {
+4
View File
@@ -74,6 +74,10 @@ export function getOciContainerConfig(): IDcRouterOptions {
options.dnsScopes = dnsScopes;
}
if (process.env.DCROUTER_DNS_BIND_INTERFACE) {
options.dnsBindInterface = process.env.DCROUTER_DNS_BIND_INTERFACE;
}
// Email config
const emailHostname = process.env.DCROUTER_EMAIL_HOSTNAME;
const emailPorts = parseCommaSeparatedNumbers(process.env.DCROUTER_EMAIL_PORTS);
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.36.2',
version: '13.40.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+4
View File
@@ -1120,6 +1120,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
tags?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
@@ -1135,6 +1136,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -1187,6 +1189,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
tags?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
@@ -1203,6 +1206,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -242,6 +242,7 @@ export class OpsViewRemoteIngress extends DeesElement {
publicIp: this.getEdgePublicIp(edge.id),
ports: this.getPortsHtml(edge),
tunnels: this.getEdgeTunnelCount(edge.id),
maxConnections: this.getMaxConnectionsHtml(edge),
window: this.getWindowHtml(edge.id),
queues: this.getQueuesHtml(edge.id),
traffic: this.getTrafficHtml(edge.id),
@@ -261,6 +262,7 @@ export class OpsViewRemoteIngress extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
</dees-form>
`,
@@ -284,12 +286,20 @@ export class OpsViewRemoteIngress extends DeesElement {
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: undefined;
const autoDerivePorts = formData.autoDerivePorts !== false;
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
try {
performance = this.collectPerformanceOverride(formData);
} catch (err: unknown) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
}
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
{ name, listenPorts, autoDerivePorts, tags },
{ name, listenPorts, autoDerivePorts, performance, tags },
);
await modalArg.destroy();
},
@@ -338,6 +348,7 @@ export class OpsViewRemoteIngress extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'} .value=${edge.performance?.maxStreamsPerEdge?.toString() || ''}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
</dees-form>
`,
@@ -359,6 +370,14 @@ export class OpsViewRemoteIngress extends DeesElement {
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: [];
const autoDerivePorts = formData.autoDerivePorts !== false;
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
try {
performance = this.collectPerformanceOverride(formData, edge.performance);
} catch (err: unknown) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
}
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
@@ -369,6 +388,7 @@ export class OpsViewRemoteIngress extends DeesElement {
name: formData.name || edge.name,
listenPorts,
autoDerivePorts,
performance,
tags,
},
);
@@ -475,6 +495,19 @@ export class OpsViewRemoteIngress extends DeesElement {
return status?.activeTunnels || 0;
}
private getMaxConnectionsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult | string {
const status = this.getEdgeStatus(edge.id);
const override = edge.performance?.maxStreamsPerEdge;
const effective = status?.performance?.maxStreamsPerEdge;
if (!override && !effective) return '-';
return html`
<div class="metricStack">
<span>${override || effective}</span>
<span class="metricMuted">${override ? 'edge override' : 'hub default'}</span>
</div>
`;
}
private getTransportHtml(edgeId: string): TemplateResult | string {
const status = this.getEdgeStatus(edgeId);
if (!status?.connected) return '-';
@@ -535,4 +568,27 @@ export class OpsViewRemoteIngress extends DeesElement {
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
private collectPerformanceOverride(
formData: Record<string, any>,
base?: interfaces.data.IRemoteIngressPerformanceConfig,
): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...(base || {}) };
const maxStreamsText = `${formData.maxStreamsPerEdge || ''}`.trim();
if (maxStreamsText) {
const maxStreamsPerEdge = Number.parseInt(maxStreamsText, 10);
if (!Number.isInteger(maxStreamsPerEdge) || maxStreamsPerEdge < 1) {
throw new Error('Max Connections must be a positive integer');
}
next.maxStreamsPerEdge = maxStreamsPerEdge;
} else {
delete next.maxStreamsPerEdge;
}
if (Object.keys(next).length > 0) {
return next;
}
return base ? {} : undefined;
}
}
@@ -304,6 +304,16 @@ export class OpsViewConfig extends DeesElement {
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
];
if (ri.performance) {
fields.push(
{ key: 'Performance Profile', value: ri.performance.profile || null, type: 'badge' },
{ key: 'Max Connections / Edge', value: ri.performance.maxStreamsPerEdge ?? null },
{ key: 'Client Write Timeout', value: ri.performance.clientWriteTimeoutMs ? `${ri.performance.clientWriteTimeoutMs} ms` : null },
{ key: 'First Data Timeout', value: ri.performance.firstDataConnectTimeoutMs ? `${ri.performance.firstDataConnectTimeoutMs} ms` : null },
{ key: 'Server-first Ports', value: ri.performance.serverFirstPorts?.length ? ri.performance.serverFirstPorts.map(String) : null, type: 'pills' },
);
}
const actions: IConfigSectionAction[] = [
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
];