Compare commits

...

31 Commits

Author SHA1 Message Date
jkunz 1b4cc0567f v13.40.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m0s
2026-05-31 15:26:43 +00:00
jkunz 22de50b544 fix(routes): ensure source profiles fully own route security 2026-05-31 15:26:18 +00:00
jkunz 2e3bead40c v13.40.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 19m10s
2026-05-31 11:50:08 +00:00
jkunz 85065b05c8 fix(deps): update smartproxy, remoteingress, and tsdeno dependencies 2026-05-31 11:49:25 +00:00
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
jkunz e2a10bdc3c v13.36.2
Docker (tags) / release (push) Failing after 1s
2026-05-29 04:00:16 +00:00
jkunz 42a5f6df7b fix(dns): preserve parallel ACME TXT challenges and mixed-case DNS queries 2026-05-29 03:59:59 +00:00
37 changed files with 1622 additions and 353 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();
+140
View File
@@ -3,6 +3,146 @@
## Pending
## 2026-05-31 - 13.40.2
### Fixes
- ensure source profiles fully own route security (routes)
- Resolve profile-backed routes by cloning source profile security instead of merging inline route overrides
- Clear stale route security when a source profile reference is removed without explicit replacement security
- Add a migration to rematerialize persisted profile-backed route security
## 2026-05-31 - 13.40.1
### Fixes
- update smartproxy, remoteingress, and tsdeno dependencies (deps)
- Bump @push.rocks/smartproxy to ^27.12.1 in Deno imports
- Bump @serve.zone/remoteingress to ^4.22.2 in package and Deno configuration
- Bump @git.zone/tsdeno to ^1.5.0
## 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
- preserve parallel ACME DNS-01 TXT challenges and consume case-insensitive DNS matching (dns,certificates)
- Keep exact and wildcard SAN challenge TXT records at the same owner name instead of deleting sibling challenge values.
- Match local dcrouter-hosted DNS records case-insensitively so DNS 0x20 mixed-case queries keep resolving.
- Update @push.rocks/smartdns to 7.9.3 for case-insensitive handler matching in the embedded DNS server.
- preserve parallel ACME TXT challenges and mixed-case DNS queries (dns)
- Remove only matching ACME DNS-01 TXT challenge values during setup and cleanup so parallel challenges can coexist.
- Resolve locally hosted DNS records case-insensitively while preserving the query name casing in responses.
- Bump @push.rocks/smartdns to ^7.9.3.
## 2026-05-28 - 13.36.1
### Fixes
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.40.2",
"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.12.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.2",
"@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 ""
+18 -16
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.36.1",
"version": "13.40.2",
"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.5.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",
@@ -45,7 +50,7 @@
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.10.1",
"@push.rocks/smartdns": "^7.9.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjwt": "^2.2.2",
@@ -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.2",
"@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"
]
+58 -37
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
@@ -51,8 +51,8 @@ importers:
specifier: ^2.10.1
version: 2.10.1(@tiptap/pm@2.27.2)(socks@2.8.8)
'@push.rocks/smartdns':
specifier: ^7.9.2
version: 7.9.2
specifier: ^7.9.3
version: 7.9.3
'@push.rocks/smartfs':
specifier: ^1.5.1
version: 1.5.1
@@ -76,7 +76,7 @@ importers:
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.7.2
version: 4.7.2
version: 4.7.3
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -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.2
version: 4.22.2
'@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.5.0
version: 1.5.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.5.0':
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
hasBin: true
'@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
hasBin: true
@@ -1285,8 +1292,8 @@ packages:
'@push.rocks/smartdelay@3.1.0':
resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==}
'@push.rocks/smartdns@7.9.2':
resolution: {integrity: sha512-joMroNy/1YjXjxUaW38HQTvlyRHETE2+vnKg1c1304gHqcThyRawtdcnQsvmoK9sO1ZaPAqBKL1QP9m87nCFYQ==}
'@push.rocks/smartdns@7.9.3':
resolution: {integrity: sha512-TkqDmYeO0ogIICWIM06hE/SeNpyASsqr7d+HJv8u3FyD2jRP9LHn0X0o8CjSJ+IoTHSNXFBDFrddyysFdnwSsg==}
'@push.rocks/smartenv@5.0.13':
resolution: {integrity: sha512-ACXmUcHZHl2CF2jnVuRw9saRRrZvJblCRs2d+K5aLR1DfkYFX3eA21kcMlKeLisI3aGNbIj9vz/rowN5qkRkfA==}
@@ -1395,8 +1402,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.7.2':
resolution: {integrity: sha512-OwT8kwQeEO+E3RuCyCfgQEBz+FyydUVaTBivZzzVchdJCUDgoDkXSnRkbIuGoHd1BfRFkUg9DQlSzt0uDfsIbw==}
'@push.rocks/smartnetwork@4.7.3':
resolution: {integrity: sha512-ecv8aSGbcHUDkE0IJ+/0mRpgQv1fSjQAgcTe1qgBNY1Lk8lQTTaNjpG7g21EdK23seyShewejtGKOcK5o7Rh6A==}
'@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
@@ -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.2':
resolution: {integrity: sha512-fE7dQkhHZqNHztu5dTy91CuM+e9NBlYvB0iJRV/4MiSP/5z+SBiwyAmR1i6RTHoLogj/XCPcwy8N+mSbb2oI9A==}
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.5.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
@@ -5306,7 +5327,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrequest': 5.0.3
@@ -6113,9 +6134,9 @@ snapshots:
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartstring': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6323,7 +6344,7 @@ snapshots:
dependencies:
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartdns@7.9.2':
'@push.rocks/smartdns@7.9.3':
dependencies:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartenv': 6.1.0
@@ -6474,7 +6495,7 @@ snapshots:
'@push.rocks/smartmail@2.2.1':
dependencies:
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartfile': 13.1.3
'@push.rocks/smartmustache': 3.0.2
'@push.rocks/smartpath': 6.0.0
@@ -6591,9 +6612,9 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.7.2':
'@push.rocks/smartnetwork@4.7.3':
dependencies:
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartrust': 1.4.0
maxmind: 5.0.6
transitivePeerDependencies:
@@ -6654,7 +6675,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartjimp': 1.2.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
@@ -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.2':
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.
+84 -1
View File
@@ -1,7 +1,7 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import { ReferenceResolver, RouteConfigManager } from '../ts/config/index.js';
import { DcRouterDb, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DcRouterDb, DnsRecordDoc, DomainDoc, RouteDoc } from '../ts/db/index.js';
import { DnsManager } from '../ts/dns/manager.dns.js';
import { logger } from '../ts/logger.js';
import * as plugins from '../ts/plugins.js';
@@ -32,6 +32,9 @@ const createTestDb = async () => {
const testDbPromise = createTestDb();
const clearTestState = async () => {
for (const record of await DnsRecordDoc.findAll()) {
await record.delete();
}
for (const route of await RouteDoc.findAll()) {
await route.delete();
}
@@ -40,6 +43,86 @@ const clearTestState = async () => {
}
};
tap.test('DnsManager keeps parallel ACME TXT challenges for the same host', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const dnsManager = new DnsManager({});
const provider = dnsManager.buildAcmeConvenientDnsProvider().convenience as any;
const hostName = '_acme-challenge.blog.central.eu';
await provider.acmeSetDnsChallenge({ hostName, challenge: 'first-token' });
await provider.acmeSetDnsChallenge({ hostName, challenge: 'second-token' });
const recordsAfterSet = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterSet.map((record) => record.value).sort()).toEqual([
'first-token',
'second-token',
]);
await provider.acmeRemoveDnsChallenge({ hostName, challenge: 'first-token' });
const recordsAfterRemove = await DnsRecordDoc.findByDomainId(domain.id);
expect(recordsAfterRemove.map((record) => record.value)).toEqual(['second-token']);
});
tap.test('DnsManager local records answer mixed-case DNS queries', async () => {
await testDbPromise;
await clearTestState();
const now = Date.now();
const domain = new DomainDoc();
domain.id = 'central-eu';
domain.name = 'central.eu';
domain.source = 'dcrouter';
domain.authoritative = true;
domain.createdAt = now;
domain.updatedAt = now;
domain.createdBy = 'test';
await domain.save();
const registeredHandlers: Array<(question: { name: string; type: string }) => any> = [];
const dnsManager = new DnsManager({});
dnsManager.dnsServer = {
registerHandler: (_name: string, _types: string[], handler: (question: { name: string; type: string }) => any) => {
registeredHandlers.push(handler);
},
} as any;
await dnsManager.createRecord({
domainId: domain.id,
name: '_acme-challenge.central.eu',
type: 'TXT',
value: 'challenge-token',
ttl: 120,
createdBy: 'test',
});
const answer = registeredHandlers[0]?.({
name: '_aCMe-challeNge.Central.Eu',
type: 'txt',
});
expect(answer).toEqual({
name: '_aCMe-challeNge.Central.Eu',
type: 'TXT',
class: 'IN',
ttl: 120,
data: 'challenge-token',
});
});
tap.test('RouteConfigManager persists DoH system routes and hydrates runtime socket handlers', async () => {
await testDbPromise;
await clearTestState();
@@ -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: {
+124 -11
View File
@@ -12,13 +12,77 @@ function setPath(target: Record<string, any>, path: string, value: unknown): voi
cursor[parts[parts.length - 1]] = value;
}
function getPath(target: Record<string, any>, path: string): unknown {
let cursor: any = target;
for (const part of path.split('.')) {
if (cursor === null || cursor === undefined) return undefined;
cursor = cursor[part];
}
return cursor;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function createFakeDb(currentVersion: string) {
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
for (const [key, expected] of Object.entries(query)) {
const actual = getPath(document, key);
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$exists' in expected) {
const exists = actual !== undefined;
if (exists !== Boolean(expected.$exists)) return false;
continue;
}
if ('$type' in expected) {
if (expected.$type === 'string' && typeof actual !== 'string') return false;
continue;
}
if ('$in' in expected) {
if (!Array.isArray(expected.$in) || !expected.$in.includes(actual)) return false;
continue;
}
}
if (actual !== expected) return false;
}
return true;
}
function createFakeCollection(documents: Array<Record<string, any>> = []) {
return {
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
if (matchesQuery(document, query)) {
yield structuredClone(document);
}
}
},
}),
updateMany: async (query: Record<string, any>, update: any) => {
let modifiedCount = 0;
for (const document of documents) {
if (!matchesQuery(document, query)) continue;
applySet(document, update.$set || {});
modifiedCount++;
}
return { modifiedCount };
},
updateOne: async (query: Record<string, any>, update: any) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
applySet(document, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
}
function createFakeDb(
currentVersion: string,
collections: Record<string, Array<Record<string, any>>> = {},
) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
@@ -29,12 +93,10 @@ function createFakeDb(currentVersion: string) {
},
};
const emptyCollection = {
find: () => ({
async *[Symbol.asyncIterator]() {},
}),
updateMany: async () => ({ modifiedCount: 0 }),
};
const fakeCollections = new Map(
Object.entries(collections).map(([name, documents]) => [name, createFakeCollection(documents)]),
);
const emptyCollection = createFakeCollection();
const ledgerCollection = {
createIndex: async () => undefined,
@@ -52,18 +114,69 @@ function createFakeDb(currentVersion: string) {
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
name === 'SmartdataEasyStore'
? ledgerCollection
: fakeCollections.get(name) || emptyCollection,
},
};
}
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
tap.test('migration runner applies schema steps through the current target', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.40.2');
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.currentVersionAfter).toEqual('13.40.2');
expect(result.stepsApplied).toHaveLength(3);
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: {
ipAllowList: ['192.168.*', '127.0.0.1'],
maxConnections: 1000,
},
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'Public service domains',
match: { ports: 443, domains: ['code.foss.global'] },
action: { type: 'forward', targets: [{ host: '192.168.5.247', port: 443 }] },
security: {
ipAllowList: ['192.168.*', '*'],
maxConnections: 1000,
},
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Standard',
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.40.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].route.security.ipAllowList.includes('*')).toBeFalse();
expect(routes[0].route.security.ipAllowList).toContain('192.168.*');
expect(routes[0].route.security.maxConnections).toEqual(1000);
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
});
export default tap.start();
+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();
+18 -5
View File
@@ -91,7 +91,7 @@ tap.test('should resolve source profile onto a route', async () => {
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
tap.test('should replace inline route security when source profile is selected', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
@@ -102,13 +102,26 @@ tap.test('should merge inline route security with profile security', async () =>
const result = resolver.resolveRoute(route, metadata);
// IP lists are unioned
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
expect(result.route.security!.ipAllowList!.includes('127.0.0.1')).toBeFalse();
expect(result.route.security!.maxConnections).toEqual(1000);
});
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
const route = makeRoute({
security: {
ipAllowList: ['*'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
});
tap.test('should deduplicate IP lists during merge', async () => {
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.36.1',
version: '13.40.2',
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;
}
}
}
}
+7 -2
View File
@@ -281,6 +281,7 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes source profile and/or network target into the route's fields.
* When a source profile is selected, it owns the route security fully.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -293,10 +294,9 @@ export class ReferenceResolver {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
security: this.cloneSecurityFields(resolvedSecurity),
};
resolvedMetadata.sourceProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
@@ -445,10 +445,15 @@ export class ReferenceResolver {
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
if (override.vpn !== undefined) merged.vpn = override.vpn;
return merged;
}
private cloneSecurityFields(security: IRouteSecurity): IRouteSecurity {
return structuredClone(security);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -175,6 +175,8 @@ export class RouteConfigManager {
return { success: false, message: 'Route not found' };
}
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
@@ -216,6 +218,13 @@ export class RouteConfigManager {
...stored.metadata,
...patch.metadata,
});
if (
previousSourceProfileRef
&& !stored.metadata?.sourceProfileRef
&& !patch.route?.security
) {
delete stored.route.security;
}
}
// Re-resolve if metadata refs exist and resolver is available
@@ -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[];
+25 -8
View File
@@ -209,9 +209,9 @@ export class DnsManager {
private registerRecordWithDnsServer(rec: DnsRecordDoc): void {
if (!this.dnsServer) return;
this.dnsServer.registerHandler(rec.name, [rec.type], (question) => {
if (question.name === rec.name && question.type === rec.type) {
if (question.name.toLowerCase() === rec.name.toLowerCase() && question.type.toUpperCase() === rec.type) {
return {
name: rec.name,
name: question.name,
type: rec.type,
class: 'IN',
ttl: rec.ttl,
@@ -313,17 +313,23 @@ export class DnsManager {
}
/**
* Delete all DNS records matching a name and type under a domain.
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
* Delete DNS records matching a name and type under a domain.
* When value is provided, only that exact record is removed so parallel ACME
* challenges for the same host can coexist.
*/
public async deleteRecordsByNameAndType(
domainId: string,
name: string,
type: TDnsRecordType,
value?: string,
): Promise<void> {
const records = await DnsRecordDoc.findByDomainId(domainId);
for (const rec of records) {
if (rec.name.toLowerCase() === name.toLowerCase() && rec.type === type) {
if (
rec.name.toLowerCase() === name.toLowerCase()
&& rec.type === type
&& (value === undefined || rec.value === value)
) {
await this.deleteRecord(rec.id);
}
}
@@ -358,9 +364,15 @@ export class DnsManager {
'Add the domain in Domains before issuing certificates.',
);
}
// Clean leftover challenge records first to avoid duplicates.
// Clean only the same challenge value. Exact + wildcard SAN orders can
// legitimately need multiple TXT records at the same name.
try {
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to clean existing TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
@@ -381,7 +393,12 @@ export class DnsManager {
return;
}
try {
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
await self.deleteRecordsByNameAndType(
domainDoc.id,
dnsChallenge.hostName,
'TXT',
dnsChallenge.challenge,
);
} catch (err: unknown) {
logger.log('warn', `DnsManager: failed to remove TXT for ${dnsChallenge.hostName}: ${(err as Error).message}`);
}
+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: {
+131
View File
@@ -19,6 +19,131 @@ export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
type TMigrationSecurity = Record<string, any>;
function mergeMigrationSecurityFields(
base: TMigrationSecurity | undefined,
override: TMigrationSecurity | undefined,
): TMigrationSecurity {
if (!base && !override) return {};
if (!base) return structuredClone(override || {});
if (!override) return structuredClone(base || {});
const merged: TMigrationSecurity = structuredClone(base);
if (override.ipAllowList || base.ipAllowList) {
merged.ipAllowList = [
...new Set([
...(base.ipAllowList || []),
...(override.ipAllowList || []),
]),
];
}
if (override.ipBlockList || base.ipBlockList) {
merged.ipBlockList = [
...new Set([
...(base.ipBlockList || []),
...(override.ipBlockList || []),
]),
];
}
for (const key of ['maxConnections', 'rateLimit', 'authentication', 'basicAuth', 'jwtAuth', 'vpn']) {
if (override[key] !== undefined) {
merged[key] = structuredClone(override[key]);
}
}
return merged;
}
function resolveMigrationSourceProfileSecurity(
profileId: string,
profiles: Map<string, any>,
visited = new Set<string>(),
depth = 0,
): TMigrationSecurity | null {
if (depth > 5 || visited.has(profileId)) return null;
const profile = profiles.get(profileId);
if (!profile) return null;
visited.add(profileId);
let baseSecurity: TMigrationSecurity = {};
const extendsProfiles = Array.isArray(profile.extendsProfiles) ? profile.extendsProfiles : [];
for (const parentId of extendsProfiles) {
if (typeof parentId !== 'string') continue;
const parentSecurity = resolveMigrationSourceProfileSecurity(
parentId,
profiles,
new Set(visited),
depth + 1,
);
if (parentSecurity) {
baseSecurity = mergeMigrationSecurityFields(baseSecurity, parentSecurity);
}
}
return mergeMigrationSecurityFields(baseSecurity, profile.security || {});
}
async function rematerializeSourceProfileRouteSecurity(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
const routeCollection = ctx.mongo!.collection('RouteDoc');
const profiles = new Map<string, any>();
for await (const profile of profileCollection.find({})) {
if (typeof (profile as any).id === 'string') {
profiles.set((profile as any).id, profile);
}
}
let inspected = 0;
let migrated = 0;
let skippedMissingProfile = 0;
const now = Date.now();
for await (const routeDoc of routeCollection.find({})) {
const sourceProfileRef = (routeDoc as any).metadata?.sourceProfileRef;
if (typeof sourceProfileRef !== 'string' || sourceProfileRef.trim() === '') continue;
inspected++;
const resolvedSecurity = resolveMigrationSourceProfileSecurity(sourceProfileRef, profiles);
const profile = profiles.get(sourceProfileRef);
if (!resolvedSecurity || !profile) {
skippedMissingProfile++;
continue;
}
const currentSecurity = (routeDoc as any).route?.security || {};
const securityChanged = JSON.stringify(currentSecurity) !== JSON.stringify(resolvedSecurity);
const profileNameChanged = (routeDoc as any).metadata?.sourceProfileName !== profile.name;
if (!securityChanged && !profileNameChanged) continue;
const query = (routeDoc as any)._id
? { _id: (routeDoc as any)._id }
: { id: (routeDoc as any).id };
await routeCollection.updateOne(query, {
$set: {
'route.security': structuredClone(resolvedSecurity),
'metadata.sourceProfileName': profile.name,
'metadata.lastResolvedAt': now,
updatedAt: now,
},
});
migrated++;
}
ctx.log.log(
'info',
`rematerialize-source-profile-route-security: migrated ${migrated}/${inspected} route(s), skipped ${skippedMissingProfile} missing profile ref(s)`,
);
}
async function migrateTargetProfileTargetHosts(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
@@ -167,6 +292,12 @@ export async function createMigrationRunner(
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
.up(async (ctx) => {
await backfillSystemRouteKeys(ctx);
})
.step('rematerialize-source-profile-route-security')
.from('13.18.0').to('13.40.2')
.description('Replace stale route security with resolved source profile security')
.up(async (ctx) => {
await rematerializeSourceProfileRouteSecurity(ctx);
});
return migration;
+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.1',
version: '13.40.2',
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' } },
];