Compare commits

...

18 Commits

Author SHA1 Message Date
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
jkunz c61d832b43 v13.36.1
Docker (tags) / release (push) Failing after 1s
2026-05-28 14:39:36 +00:00
jkunz 872a822ed7 fix(remoteingress): bump @serve.zone/remoteingress to ^4.18.0 2026-05-28 14:38:57 +00:00
jkunz 34bfd1528b v13.36.0
Docker (tags) / release (push) Failing after 1s
2026-05-28 08:48:03 +00:00
jkunz be38808795 feat(network): add top connected ASN activity to network monitoring 2026-05-28 08:47:12 +00:00
jkunz b9ae4ac344 v13.35.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 05:12:13 +00:00
jkunz 37adcc9ddc feat(vpn): use authenticated VPN route grants 2026-05-24 05:11:48 +00:00
30 changed files with 1218 additions and 392 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();
+88
View File
@@ -3,6 +3,94 @@
## Pending
## 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
- consume RemoteIngress 4.18.0 tunnel performance improvements (remoteingress)
- Update @serve.zone/remoteingress to 4.18.0 so DcRouter uses zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix.
- bump @serve.zone/remoteingress to ^4.18.0 (remoteingress)
- Updates @serve.zone/remoteingress from ^4.17.1 to ^4.18.0.
- Consumes zero-copy TCP/TLS tunnel frame handling and the partial-write priority fix from RemoteIngress.
## 2026-05-28 - 13.36.0
### Features
- add top connected ASN activity to Network Activity (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose ASN activity through network stats and combined metrics APIs.
- Add a Network Activity table with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
- add top connected ASN activity to network monitoring (network)
- Aggregate live per-IP connection and bandwidth metrics by ASN using stored IP intelligence.
- Expose top ASN activity through network stats and combined metrics API responses.
- Add a Network Activity table for top ASNs with ASN and organization block actions.
- Add MetricsManager coverage for ASN aggregation.
## 2026-05-24 - 13.35.0
### Features
- switch VPN route authorization to authenticated SmartVPN metadata (vpn)
- configure SmartVPN to forward real client source IPs plus VPN metadata through trusted PROXY v2 headers
- map target profiles to SmartProxy VPN client grants instead of mutating route source IP allow lists
- keep live VPN client source IP tracking as status/UI data while SmartProxy enforces source policy per connection
## 2026-05-21 - 13.34.0
### Features
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.37.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.11.1",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.18.0",
"@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 ""
+21 -19
View File
@@ -1,9 +1,12 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.34.0",
"version": "13.37.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,20 +18,22 @@
"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)",
"watch": "tswatch"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.4.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.0"
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.1",
@@ -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,17 +61,17 @@
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.10.3",
"@push.rocks/smartproxy": "^27.11.1",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.19.4",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.17.1",
"@serve.zone/remoteingress": "^4.18.0",
"@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"
]
+76 -56
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
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.10.3
version: 27.10.3
specifier: ^27.11.1
version: 27.11.1
'@push.rocks/smartradius':
specifier: ^1.1.2
version: 1.1.2
@@ -102,8 +102,8 @@ importers:
specifier: ^3.0.9
version: 3.0.9
'@push.rocks/smartvpn':
specifier: 1.19.4
version: 1.19.4
specifier: 1.20.0
version: 1.20.0
'@push.rocks/taskbuffer':
specifier: ^8.0.2
version: 8.0.2
@@ -114,8 +114,8 @@ importers:
specifier: ^5.8.0
version: 5.8.0
'@serve.zone/remoteingress':
specifier: ^4.17.1
version: 4.17.1
specifier: ^4.18.0
version: 4.18.0
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -133,14 +133,17 @@ importers:
version: 14.0.0
devDependencies:
'@git.zone/tsbuild':
specifier: ^4.4.1
version: 4.4.1
specifier: ^4.4.2
version: 4.4.2
'@git.zone/tsbundle':
specifier: ^2.10.4
version: 2.10.4
'@git.zone/tsdeno':
specifier: ^1.4.0
version: 1.4.0
'@git.zone/tsdocker':
specifier: ^2.3.0
version: 2.3.0
specifier: ^2.4.0
version: 2.4.0
'@git.zone/tsrun':
specifier: ^2.0.4
version: 2.0.4
@@ -151,8 +154,8 @@ importers:
specifier: ^3.3.5
version: 3.3.5(@tiptap/pm@2.27.2)
'@types/node':
specifier: ^25.9.0
version: 25.9.0
specifier: ^25.9.1
version: 25.9.1
packages:
@@ -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==}
@@ -718,16 +721,20 @@ packages:
resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==}
engines: {node: '>=6'}
'@git.zone/tsbuild@4.4.1':
resolution: {integrity: sha512-usxx8BBQsAypxjFOfd1GEV9pL9EUshRKktXtRWHMDByb6ps83+PdUIb3D7O+nkkBp4C9PXo3cfbsR4Asvo33CA==}
'@git.zone/tsbuild@4.4.2':
resolution: {integrity: sha512-v2m0fFYFt3vJZMvNAlrNChHYjZZNOf4iyO0mNNiHeO+sTR3cddkYb++zO/GL3v2UkG3nDRwfEkwUS4UzuXBEWw==}
hasBin: true
'@git.zone/tsbundle@2.10.4':
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
hasBin: true
'@git.zone/tsdocker@2.3.0':
resolution: {integrity: sha512-im2hD3Fu7vSb6qM+WMg2tbvLbFfEpX8qVmjy491R5iELky4Pw9cqRMkwzmxW92etn8v+f53ODUQDOoc9DufX2A==}
'@git.zone/tsdeno@1.4.0':
resolution: {integrity: sha512-84kFa/uKPTlzeLxtHoFxefk6O9khsWWQ2PCWNbCNYIUqWHUvN9COpGq0GXWtsoxLWPhTTIeHsOX4+O55uT2MPw==}
hasBin: true
'@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -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==}
@@ -1422,8 +1429,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.10.3':
resolution: {integrity: sha512-2TvjgXUHtV0s8WH2RbtCS5+yjnFjbvQQ2ROmtVme1lgt2GUaAbekozUJNTE1ZMLEXc4xcZRdXIOfgBcQ6j/dmQ==}
'@push.rocks/smartproxy@27.11.1':
resolution: {integrity: sha512-29THhFUTr9NtU1/UBqqOgcbsHcUMHj7Dhh2XfXp6NP/rfDGUFiFFmCNcAdC3OJ0n6BgwTBOtOzo+4rJbrGJRpw==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1482,8 +1489,8 @@ packages:
'@push.rocks/smartversion@3.1.0':
resolution: {integrity: sha512-qsJb82p8aQzJQ04fLiZsrxarhn+IoOn6v1B869NjH06vOCbCHXNKoS8WPssE6E6zge4NPCCD5WQ2hkyzqxCv9A==}
'@push.rocks/smartvpn@1.19.4':
resolution: {integrity: sha512-Cp6yyzRcZlqQMEWAQ/CG2tvUxSR4eSmzMTDQFVJsPtV+CbhXpulbqqz0penU6drVMiRGzXhwoQZtGYynigIXwA==}
'@push.rocks/smartvpn@1.20.0':
resolution: {integrity: sha512-k5cdbHGtCUMcZTwJr+7BwXNFxbeXZEe5MZ00y/f2Isi8yLAdfmdBJ5o32vwR0LJvWm2ZFn7ST8S1AkCY/K9L3w==}
'@push.rocks/smartwatch@6.4.0':
resolution: {integrity: sha512-KDswRgE/siBmZRCsRA07MtW5oF4c9uQEBkwTGPIWneHzksbCDsvs/7agKFEL7WnNifLNwo8w1K1qoiVWkX1fvw==}
@@ -1712,8 +1719,8 @@ packages:
'@serve.zone/interfaces@5.8.0':
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
'@serve.zone/remoteingress@4.17.1':
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
'@serve.zone/remoteingress@4.18.0':
resolution: {integrity: sha512-/cW9wb/e57u9+715RzV5d8HCezWtR88LcpistTNSl7GACi5ai+C2tPy7ZQprnnrNhqjfgzWiAH4bKZafwONntg==}
'@smithy/chunked-blob-reader-native@4.2.3':
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
@@ -2158,8 +2165,8 @@ packages:
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@25.9.0':
resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
@@ -4376,7 +4383,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 +4917,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
@@ -5194,7 +5201,7 @@ snapshots:
dependencies:
'@fortawesome/fontawesome-common-types': 7.2.0
'@git.zone/tsbuild@4.4.1':
'@git.zone/tsbuild@4.4.2':
dependencies:
'@git.zone/tspublish': 1.11.6
'@push.rocks/early': 4.0.4
@@ -5243,7 +5250,20 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.3.0':
'@git.zone/tsdeno@1.4.0':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.21
'@push.rocks/smartconfig': 6.1.1
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartshell': 3.3.8
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@git.zone/tsdocker@2.4.0':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -5370,7 +5390,7 @@ snapshots:
'@happy-dom/global-registrator@20.9.0':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
happy-dom: 20.9.0
transitivePeerDependencies:
- bufferutil
@@ -6113,7 +6133,7 @@ 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/smartstring': 4.1.1
@@ -6323,7 +6343,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 +6494,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
@@ -6593,7 +6613,7 @@ snapshots:
'@push.rocks/smartnetwork@4.7.2':
dependencies:
'@push.rocks/smartdns': 7.9.2
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartrust': 1.4.0
maxmind: 5.0.6
transitivePeerDependencies:
@@ -6675,7 +6695,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.10.3':
'@push.rocks/smartproxy@27.11.1':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6822,7 +6842,7 @@ snapshots:
'@types/semver': 7.7.1
semver: 7.7.4
'@push.rocks/smartvpn@1.19.4':
'@push.rocks/smartvpn@1.20.0':
dependencies:
'@push.rocks/smartnftables': 1.2.0
'@push.rocks/smartpath': 6.0.0
@@ -7047,7 +7067,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 +7084,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/remoteingress@4.17.1':
'@serve.zone/remoteingress@4.18.0':
dependencies:
'@push.rocks/qenv': 6.1.4
'@push.rocks/smartnftables': 1.2.0
@@ -7583,7 +7603,7 @@ snapshots:
'@types/clean-css@4.2.11':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
source-map: 0.6.1
'@types/debug@4.1.13':
@@ -7611,7 +7631,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/linkify-it@5.0.0': {}
@@ -7632,16 +7652,16 @@ snapshots:
'@types/mute-stream@0.0.4':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
form-data: 4.0.5
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/node@16.9.1': {}
@@ -7653,13 +7673,13 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@25.9.0':
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/randomatic@3.1.5': {}
@@ -7671,7 +7691,7 @@ snapshots:
'@types/through2@2.0.41':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/trusted-types@2.0.7': {}
@@ -7699,11 +7719,11 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
optional: true
'@ungap/structured-clone@1.3.1': {}
@@ -8423,7 +8443,7 @@ snapshots:
happy-dom@20.9.0:
dependencies:
'@types/node': 25.9.0
'@types/node': 25.9.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
mongodb-memory-server: true
puppeteer: true
+31 -2
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
```
@@ -198,9 +212,9 @@ await router.start();
## VPN Target Profiles
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also reach non-`vpnOnly` routes that would have allowed the client's real connecting IP without the VPN.
Target profiles define what a VPN client can reach through `domains`, direct `targets`, and `routeRefs`. Set `allowRoutesByClientSourceIp: true` on a target profile when a VPN client should also be granted to routes whose source policy is meant to evaluate the client's real connecting IP.
dcrouter evaluates the live source IP reported by the VPN transport, such as `remoteAddr` or the WireGuard peer endpoint. If the route source policy allows that real IP, dcrouter injects the client's assigned VPN IP into SmartProxy for that route. The source-IP grant is live-only and is removed or updated when the VPN client disconnects or changes peer endpoint.
dcrouter maps target profiles to SmartProxy VPN client grants. SmartVPN forwards both the real client source IP and authenticated VPN metadata through trusted PROXY v2 headers, so SmartProxy checks source policy and VPN client authorization separately for each connection. Route `security.ipAllowList` and `security.ipBlockList` stay the source of truth for real source-IP policy; `vpnOnly` adds the requirement for authenticated VPN metadata and a matching VPN client grant.
```typescript
const targetProfile = {
@@ -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();
@@ -269,6 +269,7 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
},
securityPolicyManager: {
queueObservedIps: (ips: string[]) => queuedIps.push(ips),
listIpIntelligence: async () => [],
},
} as any);
@@ -279,4 +280,50 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
expect(queuedIps[0]).toContain('1.1.1.1');
});
tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () => {
const proxyMetrics = createProxyMetrics({
connectionsByRoute: new Map(),
throughputByRoute: new Map(),
domainRequestsByIP: new Map(),
connectionsByIP: new Map([
['8.8.8.8', 4],
['8.8.4.4', 3],
['1.1.1.1', 5],
]),
throughputByIP: new Map([
['8.8.8.8', { in: 500, out: 250 }],
['8.8.4.4', { in: 700, out: 350 }],
['1.1.1.1', { in: 2000, out: 1000 }],
]),
});
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
queueObservedIps: () => undefined,
listIpIntelligence: async ({ ipAddresses }: { ipAddresses?: string[] }) => [
{ ipAddress: '8.8.8.8', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '8.8.4.4', asn: 15169, asnOrg: 'Google LLC', countryCode: 'US' },
{ ipAddress: '1.1.1.1', asn: 13335, asnOrg: 'Cloudflare, Inc.', countryCode: 'US' },
].filter((record) => !ipAddresses || ipAddresses.includes(record.ipAddress)),
},
} as any);
const stats = await manager.getNetworkStats();
expect(stats.topASNs).toHaveLength(2);
expect(stats.topASNs[0].asn).toEqual(15169);
expect(stats.topASNs[0].organization).toEqual('Google LLC');
expect(stats.topASNs[0].activeConnections).toEqual(7);
expect(stats.topASNs[0].ipCount).toEqual(2);
expect(stats.topASNs[0].bytesInPerSecond).toEqual(1200);
expect(stats.topASNs[0].bytesOutPerSecond).toEqual(600);
expect(stats.topASNs[0].sampleIps).toContain('8.8.8.8');
expect(stats.topASNs[1].asn).toEqual(13335);
expect(stats.topASNs[1].activeConnections).toEqual(5);
});
export default tap.start();
+27 -30
View File
@@ -77,7 +77,7 @@ tap.test('DcRouter.updateVpnConfig swaps the runtime VPN resolver and restarts V
},
} as any;
(dcRouter as any).routeConfigManager = {
setVpnClientIpsResolver: (resolver: unknown) => {
setVpnClientAccessResolver: (resolver: unknown) => {
resolverValues.push(resolver);
},
applyRoutes: async () => {
@@ -121,15 +121,15 @@ tap.test('RouteConfigManager makes vpnOnly routes fail closed without VPN client
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual([]);
expect(prepared.security.ipBlockList).toContain('*');
expect(prepared.security.ipAllowList).toEqual(['*']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: [] });
});
tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', async () => {
tap.test('RouteConfigManager adds VPN client grants for vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
() => ['client-1'],
);
const route = {
name: 'private-route',
@@ -144,15 +144,16 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['10.8.0.2']);
expect(prepared.security.ipAllowList).toEqual(['*', '203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: true, allowedClients: ['client-1'] });
});
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
const manager = new RouteConfigManager(
() => undefined,
undefined,
() => ['10.8.0.2'],
() => ['client-1'],
);
const route = {
name: 'shared-private-route',
@@ -166,8 +167,9 @@ tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly
const prepared = (manager as any).injectVpnSecurity(route);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10', '10.8.0.2']);
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10']);
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
expect(prepared.security.vpn).toEqual({ required: undefined, allowedClients: ['client-1'] });
});
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
@@ -181,17 +183,17 @@ tap.test('TargetProfileManager matches wildcard profiles against string route do
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'hagen-app',
match: { domains: 'app.hagen.team', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
} as any,
'route-1',
[{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
);
expect(entries).toEqual(['10.8.0.2']);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
@@ -238,7 +240,7 @@ tap.test('TargetProfileManager allows source-IP reachable routes for opted-in pr
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
@@ -248,13 +250,12 @@ tap.test('TargetProfileManager allows source-IP reachable routes for opted-in pr
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual(['10.8.0.2']);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not allow non-matching client source IPs', async () => {
tap.test('TargetProfileManager leaves real source-IP enforcement to SmartProxy', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
@@ -265,7 +266,7 @@ tap.test('TargetProfileManager does not allow non-matching client source IPs', a
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'restricted-public-route',
match: { domains: 'app.example.com', ports: [443] },
@@ -275,13 +276,12 @@ tap.test('TargetProfileManager does not allow non-matching client source IPs', a
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '198.51.100.10']]),
);
expect(entries).toEqual([]);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager source-IP matching respects route block lists', async () => {
tap.test('TargetProfileManager does not grant routes with wildcard source block', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
@@ -292,20 +292,19 @@ tap.test('TargetProfileManager source-IP matching respects route block lists', a
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'blocked-route',
match: { domains: 'app.example.com', ports: [443] },
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
security: {
ipAllowList: ['203.0.113.0/24'],
ipBlockList: ['203.0.113.10'],
ipBlockList: ['*'],
},
} as any,
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual([]);
@@ -322,7 +321,7 @@ tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP rea
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'public-route',
match: { domains: 'public.example.com', ports: [443] },
@@ -331,13 +330,12 @@ tap.test('TargetProfileManager treats public non-vpnOnly routes as source-IP rea
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual(['10.8.0.2']);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP matching alone', async () => {
tap.test('TargetProfileManager grants vpnOnly routes through source-policy profiles', async () => {
const manager = new TargetProfileManager();
(manager as any).profiles.set('profile-1', {
id: 'profile-1',
@@ -348,7 +346,7 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
createdBy: 'test',
});
const entries = manager.getMatchingClientIps(
const entries = manager.getMatchingVpnClients(
{
name: 'vpn-only-route',
vpnOnly: true,
@@ -359,10 +357,9 @@ tap.test('TargetProfileManager does not grant vpnOnly routes through source-IP m
'route-1',
[{ clientId: 'client-1', enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
new Map(),
new Map([['client-1', '203.0.113.10']]),
);
expect(entries).toEqual([]);
expect(entries).toEqual(['client-1']);
});
tap.test('TargetProfileManager includes source-IP reachable route domains in client access specs', async () => {
@@ -393,7 +390,7 @@ tap.test('TargetProfileManager includes source-IP reachable route domains in cli
}],
]) as any;
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes, '203.0.113.10');
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
expect(accessSpec.domains).toContain('app.example.com');
});
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.34.0',
version: '13.37.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+11 -14
View File
@@ -26,7 +26,7 @@ import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import type { TIpAllowEntry } from './config/classes.route-config-manager.js';
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
@@ -605,7 +605,7 @@ export class DcRouter {
this.routeConfigManager = new RouteConfigManager(
() => this.smartProxy,
() => this.options.http3,
this.createVpnRouteAllowListResolver(),
this.createVpnClientAccessResolver(),
this.referenceResolver,
// Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary
@@ -2399,10 +2399,10 @@ export class DcRouter {
/**
* Set up VPN server for VPN-based route access control.
*/
private createVpnRouteAllowListResolver(): ((
private createVpnClientAccessResolver(): ((
route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
routeId?: string,
) => TIpAllowEntry[]) | undefined {
) => TVpnClientAllowEntry[]) | undefined {
if (!this.options.vpnConfig?.enabled) {
return undefined;
}
@@ -2416,12 +2416,11 @@ export class DcRouter {
return [];
}
return this.targetProfileManager.getMatchingClientIps(
return this.targetProfileManager.getMatchingVpnClients(
route,
routeId,
this.vpnManager.listClients(),
this.routeConfigManager?.getRoutes() || new Map(),
this.vpnManager.getClientSourceIpMap(),
);
};
}
@@ -2453,22 +2452,21 @@ export class DcRouter {
bridgeIpRangeStart: this.options.vpnConfig.bridgeIpRangeStart,
bridgeIpRangeEnd: this.options.vpnConfig.bridgeIpRangeEnd,
onClientChanged: () => {
// Re-apply routes so profile-based ipAllowLists get updated
// Re-apply routes so profile-based VPN client grants get updated
// (serialized by RouteConfigManager's mutex — safe as fire-and-forget)
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client change: ${err?.message || err}`);
});
},
onClientSourceIpsChanged: () => {
this.routeConfigManager?.applyRoutes().catch((err) => {
logger.log('warn', `Failed to re-apply routes after VPN client source IP change: ${err?.message || err}`);
});
// SmartProxy now receives the real source IP per connection via PROXY v2.
// Source-IP changes are reflected in status/UI only; route config is static.
},
getClientDirectTargets: (targetProfileIds: string[]) => {
if (!this.targetProfileManager) return [];
return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
},
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, sourceIp?: string) => {
getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
const ips = new Set<string>([subnet]);
@@ -2479,7 +2477,6 @@ export class DcRouter {
const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
targetProfileIds,
allRoutes,
sourceIp,
);
// Add target IPs directly
@@ -2506,7 +2503,7 @@ export class DcRouter {
await this.vpnManager.start();
// Re-apply routes now that VPN clients are loaded — ensures vpnOnly routes
// get correct profile-based ipAllowLists
// get correct profile-based VPN client grants.
await this.routeConfigManager?.applyRoutes();
}
@@ -2602,7 +2599,7 @@ export class DcRouter {
this.options.vpnConfig = config;
this.vpnDomainIpCache.clear();
this.warnedWildcardVpnDomains.clear();
this.routeConfigManager?.setVpnClientIpsResolver(this.createVpnRouteAllowListResolver());
this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
if (this.options.vpnConfig?.enabled) {
await this.setupVpnServer();
+25 -33
View File
@@ -11,8 +11,7 @@ import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingres
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
/** An IP allow entry: plain IP/CIDR or domain-scoped. */
export type TIpAllowEntry = string | { ip: string; domains: string[] };
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
export interface IRouteMutationResult {
success: boolean;
@@ -57,7 +56,7 @@ export class RouteConfigManager {
constructor(
private getSmartProxy: () => plugins.smartproxy.SmartProxy | undefined,
private getHttp3Config?: () => IHttp3Config | undefined,
private getVpnClientIpsForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
@@ -73,10 +72,10 @@ export class RouteConfigManager {
return this.routes.get(id);
}
public setVpnClientIpsResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TIpAllowEntry[],
public setVpnClientAccessResolver(
resolver?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
): void {
this.getVpnClientIpsForRoute = resolver;
this.getVpnClientAccessForRoute = resolver;
}
/**
@@ -608,49 +607,42 @@ export class RouteConfigManager {
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const dcRoute = route as IDcRouterRouteConfig;
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
const vpnEntries = this.getVpnClientAccessForRoute?.(dcRoute, routeId) || [];
if (!dcRoute.vpnOnly) {
const existingAllowList = route.security?.ipAllowList;
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
return route;
}
return {
...route,
security: {
...route.security,
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries),
},
};
if (!dcRoute.vpnOnly && vpnEntries.length === 0) {
return route;
}
const existingBlockList = route.security?.ipBlockList || [];
const ipBlockList = vpnEntries.length
? existingBlockList
: [...new Set([...existingBlockList, '*'])];
const existingVpnSecurity = route.security?.vpn || {};
const mergedAllowedClients = this.mergeVpnClientAllowEntries(
existingVpnSecurity.allowedClients || [],
vpnEntries,
);
return {
...route,
security: {
...route.security,
ipAllowList: vpnEntries,
ipBlockList,
vpn: {
...existingVpnSecurity,
required: dcRoute.vpnOnly ? true : existingVpnSecurity.required,
allowedClients: mergedAllowedClients,
},
},
};
}
private mergeIpAllowEntries(
existingEntries: TIpAllowEntry[],
vpnEntries: TIpAllowEntry[],
): TIpAllowEntry[] {
const merged: TIpAllowEntry[] = [];
private mergeVpnClientAllowEntries(
existingEntries: TVpnClientAllowEntry[],
vpnEntries: TVpnClientAllowEntry[],
): TVpnClientAllowEntry[] {
const merged: TVpnClientAllowEntry[] = [];
const seen = new Set<string>();
for (const entry of [...existingEntries, ...vpnEntries]) {
const key = typeof entry === 'string'
? `ip:${entry}`
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`;
? `client:${entry}`
: `domain:${entry.clientId}:${[...entry.domains].sort().join(',')}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(entry);
+21 -211
View File
@@ -5,7 +5,7 @@ import type { ITargetProfile, ITargetProfileTarget } from '../../ts_interfaces/d
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import type { IRoute } from '../../ts_interfaces/data/route-management.js';
type TIpAllowEntry = string | { ip: string; domains?: string[] };
type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
/**
* Manages TargetProfiles (target-side: what can be accessed).
@@ -206,37 +206,35 @@ export class TargetProfileManager {
}
// =========================================================================
// Core matching: route → client IPs
// Core matching: route → VPN client grants
// =========================================================================
/**
* For a vpnOnly route, find all enabled VPN clients whose assigned TargetProfile
* matches the route. Returns IP allow entries for injection into ipAllowList.
* Find all enabled VPN clients whose assigned TargetProfile matches the route.
* Returns SmartProxy VPN client allow entries for authenticated metadata checks.
*
* Entries are domain-scoped when a profile matches via specific domains that are
* a subset of the route's wildcard. Plain IPs are returned for routeRef/target matches
* or when profile domains exactly equal the route's domains. Profiles can also opt
* into source-IP matching against non-vpnOnly route security.
* into source-policy routes; SmartProxy evaluates the real source IP per connection.
*/
public getMatchingClientIps(
public getMatchingVpnClients(
route: IDcRouterRouteConfig,
routeId: string | undefined,
clients: VpnClientDoc[],
allRoutes: Map<string, IRoute> = new Map(),
clientSourceIps: Map<string, string> = new Map(),
): Array<string | { ip: string; domains: string[] }> {
const entries: Array<string | { ip: string; domains: string[] }> = [];
): TVpnClientAllowEntry[] {
const entries: TVpnClientAllowEntry[] = [];
const routeDomains = this.getRouteDomains(route);
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
for (const client of clients) {
if (!client.enabled || !client.assignedIp) continue;
if (!client.enabled || !client.clientId) continue;
if (!client.targetProfileIds?.length) continue;
// Collect scoped domains from all matching profiles for this client
let fullAccess = false;
const scopedDomains = new Set<string>();
const clientSourceIp = clientSourceIps.get(client.clientId);
for (const profileId of client.targetProfileIds) {
const profile = this.profiles.get(profileId);
@@ -258,10 +256,8 @@ export class TargetProfileManager {
}
if (
!route.vpnOnly
&& profile.allowRoutesByClientSourceIp === true
&& clientSourceIp
&& this.routeAllowsSourceIp(route, clientSourceIp, routeDomains)
profile.allowRoutesByClientSourceIp === true
&& this.routeHasSourcePolicy(route)
) {
fullAccess = true;
break;
@@ -269,9 +265,9 @@ export class TargetProfileManager {
}
if (fullAccess) {
entries.push(client.assignedIp);
entries.push(client.clientId);
} else if (scopedDomains.size > 0) {
entries.push({ ip: client.assignedIp, domains: [...scopedDomains] });
entries.push({ clientId: client.clientId, domains: [...scopedDomains] });
}
}
@@ -285,7 +281,6 @@ export class TargetProfileManager {
public getClientAccessSpec(
targetProfileIds: string[],
allRoutes: Map<string, IRoute>,
clientSourceIp?: string,
): { domains: string[]; targetIps: string[] } {
const domains = new Set<string>();
const targetIps = new Set<string>();
@@ -322,9 +317,7 @@ export class TargetProfileManager {
routeNameIndex,
);
const sourceIpMatchesRoute = profile.allowRoutesByClientSourceIp === true
&& clientSourceIp
&& !dcRoute.vpnOnly
&& this.routeAllowsSourceIp(dcRoute, clientSourceIp, routeDomains);
&& this.routeHasSourcePolicy(dcRoute);
if (profileMatchesRoute || sourceIpMatchesRoute) {
for (const d of routeDomains) {
domains.add(d);
@@ -450,197 +443,14 @@ export class TargetProfileManager {
return false;
}
private routeAllowsSourceIp(
route: IDcRouterRouteConfig,
sourceIp: string,
routeDomains: string[],
): boolean {
private routeHasSourcePolicy(route: IDcRouterRouteConfig): boolean {
const security = (route as any).security;
const ipAllowList = this.normalizeIpEntries(security?.ipAllowList);
const ipBlockList = this.normalizeIpEntries(security?.ipBlockList);
if (this.ipEntriesMatchSource(ipBlockList, sourceIp, routeDomains)) {
return false;
}
if (!ipAllowList.length) {
return true;
}
return this.ipEntriesMatchSource(ipAllowList, sourceIp, routeDomains);
}
private normalizeIpEntries(entries: unknown): TIpAllowEntry[] {
if (!entries) return [];
if (Array.isArray(entries)) return entries as TIpAllowEntry[];
return [entries as TIpAllowEntry];
}
private ipEntriesMatchSource(
entries: TIpAllowEntry[],
sourceIp: string,
routeDomains: string[],
): boolean {
return entries.some((entry) => this.ipEntryMatchesSource(entry, sourceIp, routeDomains));
}
private ipEntryMatchesSource(
entry: TIpAllowEntry,
sourceIp: string,
routeDomains: string[],
): boolean {
const ipPattern = typeof entry === 'string' ? entry : entry.ip;
if (typeof ipPattern !== 'string') return false;
if (!this.ipPatternMatchesSource(ipPattern, sourceIp)) {
return false;
}
if (typeof entry === 'string' || !entry.domains?.length) {
return true;
}
if (!routeDomains.length) {
return false;
}
return routeDomains.some((routeDomain) =>
entry.domains!.some((entryDomain) =>
this.domainMatchesPattern(routeDomain, entryDomain)
|| this.domainMatchesPattern(entryDomain, routeDomain),
),
);
}
private ipPatternMatchesSource(pattern: string, sourceIp: string): boolean {
const trimmedPattern = pattern.trim();
const trimmedSourceIp = sourceIp.trim();
if (!trimmedPattern || !trimmedSourceIp) return false;
if (trimmedPattern === '*') return true;
if (trimmedPattern === trimmedSourceIp) return true;
if (trimmedPattern.includes('/')) {
return this.ipMatchesCidr(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('-')) {
return this.ipMatchesRange(trimmedSourceIp, trimmedPattern);
}
if (trimmedPattern.includes('*')) {
return this.ipMatchesWildcard(trimmedSourceIp, trimmedPattern);
}
return false;
}
private ipMatchesCidr(sourceIp: string, cidr: string): boolean {
const [networkIp, prefixString] = cidr.split('/');
if (!networkIp || !prefixString) return false;
const source = this.ipToComparable(sourceIp);
const network = this.ipToComparable(networkIp);
const prefix = Number(prefixString);
if (!source || !network || source.version !== network.version) return false;
const bitCount = source.version === 4 ? 32 : 128;
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bitCount) return false;
if (prefix === 0) return true;
const shift = BigInt(bitCount - prefix);
return (source.value >> shift) === (network.value >> shift);
}
private ipMatchesRange(sourceIp: string, range: string): boolean {
const [startIp, endIp] = range.split('-').map((part) => part.trim());
if (!startIp || !endIp) return false;
const source = this.ipToComparable(sourceIp);
const start = this.ipToComparable(startIp);
const end = this.ipToComparable(endIp);
if (!source || !start || !end) return false;
if (source.version !== start.version || source.version !== end.version) return false;
return source.value >= start.value && source.value <= end.value;
}
private ipMatchesWildcard(sourceIp: string, pattern: string): boolean {
const sourceParts = sourceIp.split('.');
const patternParts = pattern.split('.');
if (sourceParts.length !== 4 || patternParts.length !== 4) return false;
return patternParts.every((patternPart, index) => {
if (patternPart === '*') return true;
return patternPart === sourceParts[index];
});
}
private ipToComparable(ip: string): { version: 4 | 6; value: bigint } | undefined {
const normalizedIp = this.normalizeIpLiteral(ip);
const ipVersion = plugins.net.isIP(normalizedIp);
if (ipVersion === 4) {
const parts = normalizedIp.split('.').map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return undefined;
}
return {
version: 4,
value: parts.reduce((value, part) => (value << 8n) + BigInt(part), 0n),
};
}
if (ipVersion === 6) {
const parts = this.expandIpv6(normalizedIp);
if (!parts) return undefined;
return {
version: 6,
value: parts.reduce((value, part) => (value << 16n) + BigInt(part), 0n),
};
}
return undefined;
}
private normalizeIpLiteral(ip: string): string {
const trimmed = ip.trim().replace(/^\[|\]$/g, '');
const zoneIndex = trimmed.indexOf('%');
const withoutZone = zoneIndex === -1 ? trimmed : trimmed.slice(0, zoneIndex);
const ipv4MappedPrefix = '::ffff:';
if (withoutZone.toLowerCase().startsWith(ipv4MappedPrefix)) {
const mappedIpv4 = withoutZone.slice(ipv4MappedPrefix.length);
if (plugins.net.isIP(mappedIpv4) === 4) return mappedIpv4;
}
return withoutZone;
}
private expandIpv6(ip: string): number[] | undefined {
let normalizedIp = ip.toLowerCase();
if (normalizedIp.includes('.')) {
const lastColonIndex = normalizedIp.lastIndexOf(':');
const ipv4Part = normalizedIp.slice(lastColonIndex + 1);
const ipv4Comparable = this.ipToComparable(ipv4Part);
if (!ipv4Comparable || ipv4Comparable.version !== 4) return undefined;
const high = Number((ipv4Comparable.value >> 16n) & 0xffffn).toString(16);
const low = Number(ipv4Comparable.value & 0xffffn).toString(16);
normalizedIp = `${normalizedIp.slice(0, lastColonIndex)}:${high}:${low}`;
}
const doubleColonParts = normalizedIp.split('::');
if (doubleColonParts.length > 2) return undefined;
const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : [];
const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : [];
const missingCount = 8 - head.length - tail.length;
if (missingCount < 0 || (doubleColonParts.length === 1 && missingCount !== 0)) return undefined;
const parts = [
...head,
...Array(missingCount).fill('0'),
...tail,
];
if (parts.length !== 8) return undefined;
const numbers = parts.map((part) => Number.parseInt(part || '0', 16));
if (numbers.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) {
return undefined;
}
return numbers;
const blockEntries = Array.isArray(security?.ipBlockList)
? security.ipBlockList
: security?.ipBlockList
? [security.ipBlockList]
: [];
return !blockEntries.some((entry: unknown) => typeof entry === 'string' && entry.trim() === '*');
}
private getRouteDomains(route: IDcRouterRouteConfig): 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}`);
}
+23
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,28 @@ 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
DATA_DIR=<path> Override the writable dcrouter data directory
`);
return;
}
let options: import('./classes.dcrouter.js').IDcRouterOptions = {};
if (process.env.DCROUTER_MODE === 'OCI_CONTAINER') {
+65 -3
View File
@@ -3,6 +3,7 @@ import { DcRouter } from '../classes.dcrouter.js';
import { MetricsCache } from './classes.metricscache.js';
import { SecurityLogger, SecurityEventType } from '../security/classes.securitylogger.js';
import { logger } from '../logger.js';
import type { IAsnActivity } from '../../ts_interfaces/data/stats.js';
export class MetricsManager {
private metricsLogger: plugins.smartlog.Smartlog;
@@ -545,7 +546,7 @@ export class MetricsManager {
// Get network metrics from SmartProxy
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', () => {
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
@@ -554,6 +555,7 @@ export class MetricsManager {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
topASNs: [] as IAsnActivity[],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
@@ -725,10 +727,15 @@ export class MetricsManager {
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
this.dcRouter.securityPolicyManager?.queueObservedIps([
const observedIps = [...new Set([
...connectionsByIP.keys(),
...throughputByIP.keys(),
...topIPs.map((item) => item.ip),
...topIPsByBandwidth.map((item) => item.ip),
]);
])];
this.dcRouter.securityPolicyManager?.queueObservedIps(observedIps);
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
@@ -872,6 +879,7 @@ export class MetricsManager {
throughputRate,
topIPs,
topIPsByBandwidth,
topASNs,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -885,6 +893,60 @@ export class MetricsManager {
}, 1000); // 1s cache — matches typical dashboard poll interval
}
private async buildTopASNs(
observedIps: string[],
allIPData: Map<string, { count: number; bwIn: number; bwOut: number }>,
): Promise<IAsnActivity[]> {
const manager = this.dcRouter.securityPolicyManager;
if (!manager || observedIps.length === 0) {
return [];
}
const intelligenceRecords = await manager.listIpIntelligence({
ipAddresses: observedIps,
limit: Math.max(100, observedIps.length),
});
const asnActivity = new Map<number, IAsnActivity>();
for (const record of intelligenceRecords) {
if (typeof record.asn !== 'number') continue;
const ipData = allIPData.get(record.ipAddress);
if (!ipData) continue;
const existing = asnActivity.get(record.asn);
const activity = existing || {
asn: record.asn,
organization: record.asnOrg || record.registrantOrg || `AS${record.asn}`,
country: record.countryCode || record.country || record.registrantCountry || null,
activeConnections: 0,
ipCount: 0,
bytesInPerSecond: 0,
bytesOutPerSecond: 0,
sampleIps: [],
};
activity.activeConnections += ipData.count;
activity.bytesInPerSecond += ipData.bwIn;
activity.bytesOutPerSecond += ipData.bwOut;
activity.ipCount++;
if (activity.sampleIps.length < 5) {
activity.sampleIps.push(record.ipAddress);
}
asnActivity.set(record.asn, activity);
}
return [...asnActivity.values()]
.sort((a, b) => {
const connectionDiff = b.activeConnections - a.activeConnections;
if (connectionDiff !== 0) return connectionDiff;
const bandwidthA = a.bytesInPerSecond + a.bytesOutPerSecond;
const bandwidthB = b.bytesInPerSecond + b.bytesOutPerSecond;
return bandwidthB - bandwidthA;
})
.slice(0, 10);
}
// --- Time-series helpers ---
private static minuteKey(ts: number = Date.now()): number {
@@ -103,6 +103,7 @@ export class SecurityHandler {
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
topASNs: networkStats.topASNs,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
@@ -121,6 +122,7 @@ export class SecurityHandler {
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
+1
View File
@@ -334,6 +334,7 @@ export class StatsHandler {
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
topASNs: stats.topASNs || [],
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
+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,
+2
View File
@@ -152,6 +152,8 @@ export class VpnManager {
wgListenPort,
clients: clientEntries,
socketForwardProxyProtocol: !isBridge,
socketForwardProxyProtocolSource: 'remoteIp',
socketForwardProxyProtocolVpnMetadata: true,
destinationPolicy: this.getServerDestinationPolicy(forwardingMode, defaultDestinationPolicy),
serverEndpoint,
clientAllowedIPs: [subnet],
+12
View File
@@ -159,6 +159,17 @@ export interface IDomainActivity {
requestsLastMinute?: number;
}
export interface IAsnActivity {
asn: number;
organization: string;
country: string | null;
activeConnections: number;
ipCount: number;
bytesInPerSecond: number;
bytesOutPerSecond: number;
sampleIps: string[];
}
export interface INetworkMetrics {
totalBandwidth: {
in: number;
@@ -186,6 +197,7 @@ export interface INetworkMetrics {
out: number;
};
}>;
topASNs: IAsnActivity[];
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
+1
View File
@@ -190,6 +190,7 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
topASNs: statsInterfaces.IAsnActivity[];
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.34.0',
version: '13.37.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+4
View File
@@ -55,6 +55,7 @@ export interface INetworkState {
totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
topASNs: interfaces.data.IAsnActivity[];
throughputByIP: Array<{ ip: string; in: number; out: number }>;
ipIntelligence: interfaces.data.IIpIntelligenceRecord[];
domainActivity: interfaces.data.IDomainActivity[];
@@ -176,6 +177,7 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
totalBytes: { in: 0, out: 0 },
topIPs: [],
topIPsByBandwidth: [],
topASNs: [],
throughputByIP: [],
ipIntelligence: [],
domainActivity: [],
@@ -689,6 +691,7 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
: { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
topASNs: networkStatsResponse.topASNs || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
ipIntelligence: currentState.ipIntelligence,
domainActivity: networkStatsResponse.domainActivity || [],
@@ -3152,6 +3155,7 @@ async function dispatchCombinedRefreshActionInner() {
bwIn: e.bandwidth?.in || 0,
bwOut: e.bandwidth?.out || 0,
})),
topASNs: network.topASNs || [],
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
domainActivity: network.domainActivity || [],
throughputHistory: network.throughputHistory || [],
@@ -308,6 +308,9 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Top IPs by Connection Count -->
${this.renderTopIPs()}
<!-- Top ASNs by Connection Count -->
${this.renderTopASNs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
@@ -450,6 +453,28 @@ export class OpsViewNetworkActivity extends DeesElement {
];
}
private getAsnDataActions() {
return [
{
name: 'Block ASN',
iconName: 'lucide:radio-tower',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('asn', String(actionData.item.asn), 'Blocked ASN from Network Activity');
},
},
{
name: 'Block Organization',
iconName: 'lucide:building-2',
type: ['contextmenu'] as any,
actionRelevancyCheckFunc: (actionData: any) => Boolean(actionData.item.organization),
actionFunc: async (actionData: any) => {
await this.createBlockRuleDialog('organization', actionData.item.organization, 'Blocked organization from Network Activity');
},
},
];
}
private calculateThroughput(): { in: number; out: number } {
// Use real throughput data from network state
return {
@@ -619,6 +644,40 @@ export class OpsViewNetworkActivity extends DeesElement {
`;
}
private renderTopASNs(): TemplateResult {
if (!this.networkState.topASNs || this.networkState.topASNs.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topASNs}
.rowKey=${'asn'}
.highlightUpdates=${'flash'}
.displayFunction=${(asnData: appstate.INetworkState['topASNs'][number]) => {
return {
'ASN': `AS${asnData.asn}`,
'Organization': this.formatOptional(asnData.organization),
'Connections': asnData.activeConnections,
'IPs': asnData.ipCount,
'Bandwidth In': this.formatBitsPerSecond(asnData.bytesInPerSecond),
'Bandwidth Out': this.formatBitsPerSecond(asnData.bytesOutPerSecond),
'Total Bandwidth': this.formatBitsPerSecond(asnData.bytesInPerSecond + asnData.bytesOutPerSecond),
'Country': this.formatOptional(asnData.country),
'Sample IPs': asnData.sampleIps.join(', '),
};
}}
.dataActions=${this.getAsnDataActions()}
heading1="Top Connected ASNs"
heading2="Organizations causing the most active connections across observed IPs"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ASN"
></dees-table>
`;
}
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
@@ -97,7 +97,7 @@ export class OpsViewTargetProfiles extends DeesElement {
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${this.formatRouteRef(r)}</span>`)}`
: '-',
'Client Source IP Routes': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
'Source-Policy Route Grants': profile.allowRoutesByClientSourceIp ? 'Yes' : 'No',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
@@ -224,7 +224,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${false}></dees-input-checkbox>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${false}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
@@ -287,7 +287,7 @@ export class OpsViewTargetProfiles extends DeesElement {
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
<dees-input-list .key=${'targets'} .label=${'Targets'} .description=${'Format: ip:port, e.g. 10.0.0.1:443'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow routes by VPN client source IP'} .description=${'Also grant access to non-VPN-only routes that would allow the client\'s real connecting IP'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
<dees-input-checkbox .key=${'allowRoutesByClientSourceIp'} .label=${'Allow source-policy route grants'} .description=${'Grant these VPN clients to source-policy routes; SmartProxy still checks their real connecting IP per connection'} .value=${profile.allowRoutesByClientSourceIp === true}></dees-input-checkbox>
</dees-form>
`,
menuOptions: [