Compare commits

..

4 Commits

Author SHA1 Message Date
jkunz a4f78d0361 docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
jkunz 57f32661f3 fix: modernize docker publishing 2026-04-29 12:58:06 +00:00
jkunz 27d4a5d3c1 v4.17.1
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-26 15:08:37 +00:00
jkunz 627603532d fix(remoteingressedge): reset nftables state on startup and restart before reapplying hub firewall config 2026-04-26 15:08:37 +00:00
17 changed files with 490 additions and 650 deletions
+5
View File
@@ -1 +1,6 @@
.git/
.nogit/
dist/
dist_*/
node_modules/ node_modules/
rust/target/
-66
View File
@@ -1,66 +0,0 @@
name: Default (not tags)
on:
push:
tags-ignore:
- '**'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
- name: Run npm prepare
run: npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build
-124
View File
@@ -1,124 +0,0 @@
name: Default (tags)
on:
push:
tags:
- '*'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_URL_CLOUDLY: ${{secrets.NPMCI_URL_CLOUDLY}}
jobs:
security:
runs-on: ubuntu-latest
continue-on-error: true
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
if: ${{ always() }}
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci npm build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Release
run: |
npmci node install stable
npmci npm publish
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Code quality
run: |
npmci command npm install -g typescript
npmci npm install
- name: Trigger
run: npmci trigger
- name: Build docs and upload artifacts
run: |
npmci node install stable
npmci npm install
pnpm install -g @git.zone/tsdoc
npmci command tsdoc
continue-on-error: true
+36
View File
@@ -0,0 +1,36 @@
name: Docker (non-tag pushes)
on:
push:
tags-ignore:
- '**'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
test:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker@latest
pnpm config set registry https://verdaccio.lossless.digital
pnpm install
- name: Test
run: pnpm test
- name: Build image
run: tsdocker build
- name: Test image
run: tsdocker test
+42
View File
@@ -0,0 +1,42 @@
name: Docker (tags)
on:
push:
tags:
- '*'
env:
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
release:
runs-on: ubuntu-latest
container:
image: code.foss.global/host.today/ht-docker-dbase:szci
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @git.zone/tsdocker@latest
pnpm config set registry https://verdaccio.lossless.digital
pnpm install
- name: Login to registries
run: tsdocker login
- name: List images
run: tsdocker list
- name: Build images
run: tsdocker build
- name: Test images
run: tsdocker test
- name: Push to code.foss.global
run: tsdocker push code.foss.global
+7
View File
@@ -5,6 +5,13 @@
"linux_arm64" "linux_arm64"
] ]
}, },
"@git.zone/tsdocker": {
"registries": ["code.foss.global"],
"registryRepoMap": {
"code.foss.global": "serve.zone/remoteingress"
},
"platforms": ["linux/amd64", "linux/arm64"]
},
"@git.zone/cli": { "@git.zone/cli": {
"projectType": "npm", "projectType": "npm",
"module": { "module": {
+27 -35
View File
@@ -1,46 +1,38 @@
# gitzone dockerfile_service # gitzone dockerfile_service
## STAGE 1 // BUILD ## STAGE 1 // BUILD
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1 FROM code.foss.global/host.today/ht-docker-node:lts AS build
COPY ./ /app
WORKDIR /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
RUN pnpm run build
# gitzone dockerfile_service
## STAGE 2 // install production
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
WORKDIR /app WORKDIR /app
COPY --from=node1 /app /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm config set store-dir .pnpm-store
RUN pnpm config set registry https://verdaccio.lossless.digital
RUN pnpm install --frozen-lockfile
COPY . ./
# The npm package builds both Rust targets; each OCI image only needs its native binary.
RUN node -e "const fs=require('node:fs');const p='.smartconfig.json';const c=JSON.parse(fs.readFileSync(p,'utf8'));c['@git.zone/tsrust']={...(c['@git.zone/tsrust']||{}),targets:[]};fs.writeFileSync(p,JSON.stringify(c));" \
&& pnpm exec tsbuild tsfolders --allowimplicitany \
&& pnpm exec tsrust
RUN rm -rf .pnpm-store RUN rm -rf .pnpm-store
ARG NPMCI_TOKEN_NPM2 RUN pnpm prune --prod
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules/ && pnpm install --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:alpine-node AS production
## STAGE 3 // rebuild dependencies for alpine
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
WORKDIR /app WORKDIR /app
COPY --from=node2 /app /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN pnpm rebuild -r
## STAGE 4 // the final production image with all dependencies in place ENV NODE_ENV=production
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
WORKDIR /app
COPY --from=node3 /app /app
### Healthchecks COPY --from=build /app/package.json ./package.json
RUN pnpm install -g @servezone/healthy COPY --from=build /app/node_modules ./node_modules
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ] COPY --from=build /app/cli.js ./cli.js
COPY --from=build /app/dist_ts ./dist_ts
COPY --from=build /app/dist_rust ./dist_rust
EXPOSE 80 LABEL org.opencontainers.image.title="remoteingress" \
CMD ["npm", "start"] org.opencontainers.image.description="serve.zone edge ingress tunnel" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/remoteingress"
EXPOSE 80 443 8443 53/udp
CMD ["node", "cli.js"]
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-04-26 - 4.17.1 - fix(remoteingressedge)
reset nftables state on startup and restart before reapplying hub firewall config
- upgrade @push.rocks/smartnftables to ^1.2.0 to use forced cleanup and IP set blocking
- queue firewall updates until nftables is initialized and apply pending config afterward
- replace per-IP blocking with blockIPSet for the hub blocklist
- force nftables cleanup during startup, restart, firewall replacement, and shutdown to remove stale kernel rules
## 2026-04-26 - 4.17.0 - feat(core) ## 2026-04-26 - 4.17.0 - feat(core)
add performance profiles, transport observability, and edge stream budget controls add performance profiles, transport observability, and edge stream budget controls
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/remoteingress", "name": "@serve.zone/remoteingress",
"version": "4.17.0", "version": "4.17.1",
"private": false, "private": false,
"description": "Edge ingress tunnel for DcRouter - tunnels TCP and UDP traffic from the network edge to SmartProxy over TLS or QUIC, preserving client IP via PROXY protocol.", "description": "Edge ingress tunnel for DcRouter - tunnels TCP and UDP traffic from the network edge to SmartProxy over TLS or QUIC, preserving client IP via PROXY protocol.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -9,6 +9,7 @@
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"start": "(node ./cli.js)",
"test": "(pnpm run build && tstest test/ --verbose --logfile --timeout 60)", "test": "(pnpm run build && tstest test/ --verbose --logfile --timeout 60)",
"build": "(tsbuild tsfolders --allowimplicitany && tsrust)", "build": "(tsbuild tsfolders --allowimplicitany && tsrust)",
"buildDocs": "(tsdoc)" "buildDocs": "(tsdoc)"
@@ -24,7 +25,7 @@
}, },
"dependencies": { "dependencies": {
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartnftables": "^1.0.1", "@push.rocks/smartnftables": "^1.2.0",
"@push.rocks/smartrust": "^1.3.2" "@push.rocks/smartrust": "^1.3.2"
}, },
"repository": { "repository": {
+5 -5
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
'@push.rocks/smartnftables': '@push.rocks/smartnftables':
specifier: ^1.0.1 specifier: ^1.2.0
version: 1.0.1 version: 1.2.0
'@push.rocks/smartrust': '@push.rocks/smartrust':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2 version: 1.3.2
@@ -1207,8 +1207,8 @@ packages:
'@push.rocks/smartnetwork@4.4.0': '@push.rocks/smartnetwork@4.4.0':
resolution: {integrity: sha512-OvFtz41cvQ7lcXwaIOhghNUUlNoMxvwKDctbDvMyuZyEH08SpLjhyv2FuKbKL/mgwA/WxakTbohoC8SW7t+kiw==} resolution: {integrity: sha512-OvFtz41cvQ7lcXwaIOhghNUUlNoMxvwKDctbDvMyuZyEH08SpLjhyv2FuKbKL/mgwA/WxakTbohoC8SW7t+kiw==}
'@push.rocks/smartnftables@1.0.1': '@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-o822GH4J8dlEBvNLbm+CwU4h6isMUEh03tf2ZnOSWXc5iewRDdKdOCDwI/e+WdnGYWyv7gvH0DHztCmne6rTCg==} resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
'@push.rocks/smartnpm@2.0.6': '@push.rocks/smartnpm@2.0.6':
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==} resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
@@ -6439,7 +6439,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@push.rocks/smartnftables@1.0.1': '@push.rocks/smartnftables@1.2.0':
dependencies: dependencies:
'@push.rocks/smartlog': 3.2.1 '@push.rocks/smartlog': 3.2.1
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
+128 -402
View File
@@ -1,477 +1,179 @@
# @serve.zone/remoteingress # @serve.zone/remoteingress
Edge ingress tunnel for DcRouter — tunnels **TCP and UDP** traffic from the network edge to a private DcRouter/SmartProxy cluster over encrypted TLS or QUIC connections, preserving the original client IP via PROXY protocol. Includes **hub-controlled nftables firewall** for IP blocking, rate limiting, and custom firewall rules applied directly at the edge. `@serve.zone/remoteingress` is a Rust-powered, TypeScript-controlled edge tunnel for moving TCP and UDP traffic from public edge nodes into a private dcrouter or SmartProxy host while preserving the original client IP through PROXY protocol.
## Issue Reporting and Security ## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What It Does
Remote ingress solves the common edge problem: your workload or gateway lives behind NAT, a firewall, or a private network, but public traffic should enter through one or more hardened edge VPS nodes.
```
Internet clients
|
v
public edge node: RemoteIngressEdge
|
| TLS or QUIC tunnel
v
private site: RemoteIngressHub -> dcrouter / SmartProxy
```
The edge binds public TCP and UDP ports assigned by the hub. Each accepted connection or datagram is tunneled to the hub, which forwards it to a local target host with PROXY headers so downstream routing can still see the real client address.
## Highlights
- ⚡ Rust networking core managed from TypeScript through `@push.rocks/smartrust`
- 🔁 Hub/edge model with dynamic `updateAllowedEdges()` reconciliation
- 🌐 TCP forwarding over frame-multiplexed TLS or native QUIC streams
- 📡 UDP forwarding over TCP frames or QUIC datagrams
- 🧾 PROXY protocol preservation for client IP visibility at SmartProxy
- 🛡️ Hub-pushed nftables firewall snapshots for blocklists, rate limits, and custom rules
- 🔐 Shared-secret edge authentication and compact connection tokens
- 🚦 Transport modes: `tcpTls`, `quic`, and `quicWithFallback`
- 📊 EventEmitter status events for edge lifecycle, streams, port assignments, and crash recovery
## Install ## Install
```sh ```bash
pnpm install @serve.zone/remoteingress pnpm add @serve.zone/remoteingress
``` ```
## Architecture ## Hub Side
`@serve.zone/remoteingress` uses a **Hub/Edge** topology with a high-performance Rust core and a TypeScript API surface: Run the hub next to the private service you want to expose. In dcrouter deployments the target is usually SmartProxy on `127.0.0.1`.
```
TLS or QUIC Tunnel
┌─────────────────────┐ ◄══════════════════════════► ┌─────────────────────┐
│ Network Edge │ TCP+TLS: frame mux │ Private Cluster │
│ │ QUIC: native streams │ │
│ RemoteIngressEdge │ UDP: QUIC datagrams │ RemoteIngressHub │
│ │ │ │
│ • TCP/UDP listeners│ ◄─── FRAME_CONFIG pushes ─── │ • Port assignments │
│ • nftables firewall│ ports + firewall rules │ • Firewall config │
│ • Rate limiting │ at any time │ • Rate limit rules │
└─────────────────────┘ └─────────────────────┘
▲ │
│ TCP + UDP from end users ▼
Internet DcRouter / SmartProxy
```
| Component | Role |
|-----------|------|
| **RemoteIngressEdge** | Deployed at the network edge (VPS, cloud instance). Runs as root. Listens on hub-assigned TCP/UDP ports, tunnels traffic to the hub, and applies hub-pushed nftables rules (IP blocking, rate limiting). All config is hot-reloadable at runtime. |
| **RemoteIngressHub** | Deployed alongside DcRouter/SmartProxy in a private cluster. Accepts edge connections, demuxes streams/datagrams, and forwards each to SmartProxy with PROXY protocol headers so the real client IP is preserved. Pushes all edge config (ports, firewall) via a single API. |
| **Rust Binary** (`remoteingress-bin`) | The performance-critical networking core. Managed via `@push.rocks/smartrust` RustBridge IPC — you never interact with it directly. Cross-compiled for `linux/amd64` and `linux/arm64`. |
### ⚡ Key Features
- **Dual transport** — choose between TCP+TLS (frame-multiplexed) or QUIC (native stream multiplexing, zero head-of-line blocking)
- **TCP + UDP tunneling** — tunnel any TCP connection or UDP datagram through the same edge/hub pair
- **PROXY protocol v1 & v2** — SmartProxy sees the real client IP for both TCP (v1 text) and UDP (v2 binary)
- **Hub-controlled firewall** — push nftables rules (IP blocking, rate limiting, custom firewall rules) to edges as part of the same config update that assigns ports — powered by `@push.rocks/smartnftables`
- **Multiplexed streams** — thousands of concurrent TCP connections over a single tunnel
- **QUIC datagrams** — UDP traffic forwarded via QUIC unreliable datagrams for lowest possible latency
- **Shared-secret authentication** — edges must present valid credentials to connect
- **Connection tokens** — encode all connection details into a single opaque base64url string
- **STUN-based public IP discovery** — edges automatically discover their public IP via Cloudflare STUN
- **Auto-reconnect** with exponential backoff if the tunnel drops
- **Dynamic runtime configuration** — the hub pushes ports, firewall rules, and rate limits to edges at any time via a single `updateAllowedEdges()` call
- **Event-driven** — both Hub and Edge extend `EventEmitter` for real-time monitoring
- **3-tier QoS** — control frames, normal data, and sustained (elephant flow) traffic each get their own priority queue
- **Adaptive flow control** — per-stream windows scale with active stream count to prevent memory overuse
- **UDP session management** — automatic session tracking with 60s idle timeout and cleanup
- **Crash recovery** — automatic restart with exponential backoff if the Rust binary crashes unexpectedly
## Usage
Both classes are imported from the package and communicate with the Rust binary under the hood.
### Setting Up the Hub (Private Cluster Side)
```typescript ```typescript
import { RemoteIngressHub } from '@serve.zone/remoteingress'; import { RemoteIngressHub } from '@serve.zone/remoteingress';
const hub = new RemoteIngressHub(); const hub = new RemoteIngressHub();
// Listen for events hub.on('edgeConnected', ({ edgeId }) => console.log('edge connected', edgeId));
hub.on('edgeConnected', ({ edgeId }) => console.log(`Edge ${edgeId} connected`)); hub.on('edgeDisconnected', ({ edgeId }) => console.log('edge disconnected', edgeId));
hub.on('edgeDisconnected', ({ edgeId }) => console.log(`Edge ${edgeId} disconnected`)); hub.on('streamOpened', ({ edgeId, streamId }) => console.log('stream', edgeId, streamId));
hub.on('streamOpened', ({ edgeId, streamId }) => console.log(`Stream ${streamId} from ${edgeId}`));
hub.on('streamClosed', ({ edgeId, streamId }) => console.log(`Stream ${streamId} closed`));
// Start the hub — listens for edge connections on both TCP and QUIC (same port)
await hub.start({ await hub.start({
tunnelPort: 8443, // port edges connect to (default: 8443) tunnelPort: 8443,
targetHost: '127.0.0.1', // SmartProxy host to forward traffic to targetHost: '127.0.0.1',
}); });
// Register allowed edges with TCP and UDP listen ports + firewall config
await hub.updateAllowedEdges([ await hub.updateAllowedEdges([
{ {
id: 'edge-nyc-01', id: 'edge-fra-01',
secret: 'supersecrettoken1', secret: 'replace-with-a-long-random-secret',
listenPorts: [80, 443], // TCP ports the edge should listen on listenPorts: [80, 443],
listenPortsUdp: [53, 51820], // UDP ports (e.g., DNS, WireGuard) listenPortsUdp: [443],
stunIntervalSecs: 300, stunIntervalSecs: 300,
firewallConfig: { firewallConfig: {
blockedIps: ['192.168.1.100', '10.0.0.0/8'], blockedIps: ['198.51.100.25'],
rateLimits: [ rateLimits: [
{ id: 'http-rate', port: 80, protocol: 'tcp', rate: '100/second', perSourceIP: true }, { id: 'https-per-ip', port: 443, protocol: 'tcp', rate: '200/second', burst: 100, perSourceIP: true },
],
rules: [
{ id: 'allow-ssh', direction: 'input', action: 'accept', sourceIP: '10.0.0.0/24', destPort: 22, protocol: 'tcp' },
],
},
},
{
id: 'edge-fra-02',
secret: 'supersecrettoken2',
listenPorts: [443, 8080],
},
]);
// Dynamically update ports and firewall — changes are pushed instantly to connected edges
await hub.updateAllowedEdges([
{
id: 'edge-nyc-01',
secret: 'supersecrettoken1',
listenPorts: [80, 443, 8443], // added TCP port 8443
listenPortsUdp: [53], // removed WireGuard UDP port
firewallConfig: {
blockedIps: ['192.168.1.100', '10.0.0.0/8', '203.0.113.50'], // added new blocked IP
rateLimits: [
{ id: 'http-rate', port: 80, protocol: 'tcp', rate: '200/second', perSourceIP: true },
], ],
}, },
}, },
]); ]);
// Check status
const status = await hub.getStatus(); const status = await hub.getStatus();
// { running: true, tunnelPort: 8443, connectedEdges: [...] } console.log(status.connectedEdges);
await hub.stop(); await hub.stop();
``` ```
### Setting Up the Edge (Network Edge Side) ## Edge Side
The edge connects via **QUIC with TCP+TLS fallback** by default. Edges run as **root** so they can bind to privileged ports and apply nftables firewall rules. The edge runs on the public node. It normally needs root privileges to bind privileged ports and apply nftables rules. If nftables cannot be initialized, the tunnel can still run, but kernel-level edge firewalling is skipped.
#### Option A: Connection Token (Recommended)
```typescript ```typescript
import { RemoteIngressEdge } from '@serve.zone/remoteingress'; import { RemoteIngressEdge } from '@serve.zone/remoteingress';
const edge = new RemoteIngressEdge(); const edge = new RemoteIngressEdge();
edge.on('tunnelConnected', () => console.log('Tunnel established')); edge.on('tunnelConnected', () => console.log('tunnel connected'));
edge.on('tunnelDisconnected', () => console.log('Tunnel lost — will auto-reconnect')); edge.on('portsAssigned', ({ listenPorts }) => console.log('TCP ports', listenPorts));
edge.on('publicIpDiscovered', ({ ip }) => console.log(`Public IP: ${ip}`)); edge.on('firewallConfigUpdated', () => console.log('firewall snapshot applied'));
edge.on('portsAssigned', ({ listenPorts }) => console.log(`TCP ports: ${listenPorts}`));
edge.on('firewallConfigUpdated', () => console.log('Firewall rules applied'));
await edge.start({ await edge.start({
token: 'eyJoIjoiaHViLmV4YW1wbGUuY29tIiwi...', hubHost: 'ingress-hub.example.com',
});
```
#### Option B: Explicit Config with QUIC Transport
```typescript
import { RemoteIngressEdge } from '@serve.zone/remoteingress';
const edge = new RemoteIngressEdge();
await edge.start({
hubHost: 'hub.example.com',
hubPort: 8443, hubPort: 8443,
edgeId: 'edge-nyc-01', edgeId: 'edge-fra-01',
secret: 'supersecrettoken1', secret: 'replace-with-a-long-random-secret',
transportMode: 'quic', // 'tcpTls' | 'quic' | 'quicWithFallback' (default) transportMode: 'quicWithFallback',
}); });
const edgeStatus = await edge.getStatus(); console.log(await edge.getStatus());
// { running: true, connected: true, publicIp: '203.0.113.42', activeStreams: 5, listenPorts: [80, 443] }
await edge.stop(); await edge.stop();
``` ```
#### Transport Modes ## Connection Tokens
| Mode | Description | Tokens are base64url-encoded compact JSON. They are useful when the hub operator provisions an edge and wants to hand over one opaque string.
|------|-------------|
| `'tcpTls'` | Single TLS connection with frame-based multiplexing. Universal compatibility. |
| `'quic'` | QUIC with native stream multiplexing. Eliminates head-of-line blocking. Uses QUIC datagrams for UDP traffic. |
| `'quicWithFallback'` | **Default.** Tries QUIC first (5s timeout), falls back to TCP+TLS if UDP is blocked by the network. |
### Connection Tokens
Encode all connection details into a single opaque string for easy distribution:
```typescript ```typescript
import { encodeConnectionToken, decodeConnectionToken } from '@serve.zone/remoteingress'; import {
RemoteIngressEdge,
// Hub operator generates a token encodeConnectionToken,
const token = encodeConnectionToken({ decodeConnectionToken,
hubHost: 'hub.example.com', } from '@serve.zone/remoteingress';
hubPort: 8443,
edgeId: 'edge-nyc-01',
secret: 'supersecrettoken1',
});
// => 'eyJoIjoiaHViLmV4YW1wbGUuY29tIiwi...'
// Edge operator decodes (optional — start() does this automatically)
const data = decodeConnectionToken(token);
// { hubHost: 'hub.example.com', hubPort: 8443, edgeId: 'edge-nyc-01', secret: '...' }
```
Tokens are base64url-encoded — safe for environment variables, CLI arguments, and config files.
## 🔥 Firewall Config
The `firewallConfig` field in `updateAllowedEdges()` works exactly like `listenPorts` — it travels in the same `FRAME_CONFIG` frame, is delivered on initial handshake and on every subsequent update, and is applied atomically at the edge using `@push.rocks/smartnftables`. Each update fully replaces the previous ruleset.
Since edges run as root, the rules are applied directly to the Linux kernel via nftables. If the edge isn't root or nftables is unavailable, it logs a warning and continues — the tunnel works fine, just without kernel-level firewall rules.
### Config Structure
```typescript
interface IFirewallConfig {
blockedIps?: string[]; // IPs or CIDRs to block (e.g., '1.2.3.4', '10.0.0.0/8')
rateLimits?: IFirewallRateLimit[];
rules?: IFirewallRule[];
}
interface IFirewallRateLimit {
id: string; // unique identifier for this rate limit
port: number; // port to rate-limit
protocol?: 'tcp' | 'udp'; // default: both
rate: string; // e.g., '100/second', '1000/minute'
burst?: number; // burst allowance
perSourceIP?: boolean; // per-client rate limiting (recommended)
}
interface IFirewallRule {
id: string; // unique identifier for this rule
direction: 'input' | 'output' | 'forward';
action: 'accept' | 'drop' | 'reject';
sourceIP?: string; // source IP or CIDR
destPort?: number; // destination port
protocol?: 'tcp' | 'udp';
comment?: string;
}
```
### Example: Rate Limiting + IP Blocking
```typescript
await hub.updateAllowedEdges([
{
id: 'edge-nyc-01',
secret: 'secret',
listenPorts: [80, 443],
firewallConfig: {
// Block known bad actors
blockedIps: ['198.51.100.0/24', '203.0.113.50'],
// Rate limit HTTP traffic per source IP
rateLimits: [
{ id: 'http', port: 80, protocol: 'tcp', rate: '100/second', burst: 50, perSourceIP: true },
{ id: 'https', port: 443, protocol: 'tcp', rate: '200/second', burst: 100, perSourceIP: true },
],
// Allow monitoring from trusted subnet
rules: [
{ id: 'monitoring', direction: 'input', action: 'accept', sourceIP: '10.0.0.0/24', destPort: 9090, protocol: 'tcp', comment: 'Prometheus scraping' },
],
},
},
]);
```
## API Reference
### `RemoteIngressHub`
| Method / Property | Description |
|-------------------|-------------|
| `start(config?)` | Start the hub. Config: `{ tunnelPort?, targetHost?, tls?: { certPem?, keyPem? } }`. Listens on both TCP and UDP (QUIC) on the tunnel port. |
| `stop()` | Graceful shutdown. |
| `updateAllowedEdges(edges)` | Set authorized edges. Each: `{ id, secret, listenPorts?, listenPortsUdp?, stunIntervalSecs?, firewallConfig? }`. Port and firewall changes are pushed to connected edges in real time. |
| `getStatus()` | Returns `{ running, tunnelPort, connectedEdges: [...] }`. |
| `running` | `boolean` — whether the Rust binary is alive. |
**Events:** `edgeConnected`, `edgeDisconnected`, `streamOpened`, `streamClosed`, `crashRecovered`, `crashRecoveryFailed`
### `RemoteIngressEdge`
| Method / Property | Description |
|-------------------|-------------|
| `start(config)` | Connect to hub. Accepts `{ token }` or `{ hubHost, hubPort, edgeId, secret, bindAddress?, transportMode? }`. |
| `stop()` | Graceful shutdown. Cleans up all nftables rules. |
| `getStatus()` | Returns `{ running, connected, publicIp, activeStreams, listenPorts }`. |
| `running` | `boolean` — whether the Rust binary is alive. |
**Events:** `tunnelConnected`, `tunnelDisconnected`, `publicIpDiscovered`, `portsAssigned`, `portsUpdated`, `firewallConfigUpdated`, `crashRecovered`, `crashRecoveryFailed`
### Token Utilities
| Function | Description |
|----------|-------------|
| `encodeConnectionToken(data)` | Encodes connection info into a base64url token. |
| `decodeConnectionToken(token)` | Decodes a token. Throws on malformed input. |
### Interfaces
```typescript
interface IHubConfig {
tunnelPort?: number; // default: 8443
targetHost?: string; // default: '127.0.0.1'
tls?: {
certPem?: string; // PEM-encoded TLS certificate
keyPem?: string; // PEM-encoded TLS private key
};
}
interface IEdgeConfig {
hubHost: string;
hubPort?: number; // default: 8443
edgeId: string;
secret: string;
bindAddress?: string;
transportMode?: 'tcpTls' | 'quic' | 'quicWithFallback';
}
interface IConnectionTokenData {
hubHost: string;
hubPort: number;
edgeId: string;
secret: string;
}
```
## Wire Protocol
### TCP+TLS Transport (Frame Protocol)
The tunnel uses a custom binary frame protocol over a single TLS connection:
```
[stream_id: 4 bytes BE][type: 1 byte][length: 4 bytes BE][payload: N bytes]
```
| Frame Type | Value | Direction | Purpose |
|------------|-------|-----------|---------|
| `OPEN` | `0x01` | Edge → Hub | Open TCP stream; payload is PROXY v1 header |
| `DATA` | `0x02` | Edge → Hub | Client data (upload) |
| `CLOSE` | `0x03` | Edge → Hub | Client closed connection |
| `DATA_BACK` | `0x04` | Hub → Edge | Response data (download) |
| `CLOSE_BACK` | `0x05` | Hub → Edge | Upstream closed connection |
| `CONFIG` | `0x06` | Hub → Edge | Runtime config update (JSON: ports + firewall config) |
| `PING` | `0x07` | Hub → Edge | Heartbeat probe (every 15s) |
| `PONG` | `0x08` | Edge → Hub | Heartbeat response |
| `WINDOW_UPDATE` | `0x09` | Edge → Hub | Flow control: edge consumed N bytes |
| `WINDOW_UPDATE_BACK` | `0x0A` | Hub → Edge | Flow control: hub consumed N bytes |
| `UDP_OPEN` | `0x0B` | Edge → Hub | Open UDP session; payload is PROXY v2 header |
| `UDP_DATA` | `0x0C` | Edge → Hub | UDP datagram (upload) |
| `UDP_DATA_BACK` | `0x0D` | Hub → Edge | UDP datagram (download) |
| `UDP_CLOSE` | `0x0E` | Either | Close UDP session |
### QUIC Transport
When using QUIC, the frame protocol is replaced by native QUIC primitives:
- **TCP connections:** Each tunneled TCP connection gets its own QUIC bidirectional stream. No framing overhead.
- **UDP datagrams:** Forwarded via QUIC unreliable datagrams (RFC 9221). Format: `[session_id: 4 bytes][payload]`. Session open uses magic byte `0xFF`: `[session_id: 4][0xFF][PROXY v2 header]`.
- **Control channel:** First QUIC bidirectional stream carries auth handshake + config updates using `[type: 1][length: 4][payload]` format.
### Handshake Sequence
1. Edge opens a TLS or QUIC connection to the hub
2. Edge sends: `EDGE <edgeId> <secret>\n`
3. Hub verifies credentials (constant-time comparison) and responds with JSON:
`{"listenPorts":[...],"listenPortsUdp":[...],"stunIntervalSecs":300,"firewallConfig":{...}}\n`
4. Edge starts TCP and UDP listeners on the assigned ports
5. Edge applies firewall config via nftables (if present and running as root)
6. Data flows — TCP frames/QUIC streams for TCP traffic, UDP frames/QUIC datagrams for UDP traffic
## QoS & Flow Control
### Priority Tiers (TCP+TLS Transport)
| Tier | Frames | Behavior |
|------|--------|----------|
| **Control** | PING, PONG, WINDOW_UPDATE, OPEN, CLOSE, CONFIG | Always drained first. Never delayed. |
| **Data** | DATA/DATA_BACK from normal streams, UDP frames | Drained when control queue is empty. |
| **Sustained** | DATA/DATA_BACK from elephant flows | Lowest priority with guaranteed **1 MB/s** drain rate. |
### Sustained Stream Classification
A TCP stream is classified as **sustained** (elephant flow) when:
- Active for **>10 seconds**, AND
- Average throughput exceeds **20 Mbit/s** (2.5 MB/s)
Once classified, its flow control window locks to 1 MB and data frames move to the lowest-priority queue.
### Adaptive Per-Stream Windows
Each TCP stream has a send window from a shared **200 MB budget**:
| Active Streams | Window per Stream |
|---|---|
| 150 | 4 MB (maximum) |
| 51200 | Scales down (4 MB → 1 MB) |
| 200+ | 1 MB (floor) |
UDP traffic uses no flow control — datagrams are fire-and-forget, matching UDP semantics.
## Example Scenarios
### 1. 🌐 Expose a Private Cluster to the Internet
Deploy an Edge on a public VPS, point DNS to its IP. The Edge tunnels all TCP and UDP traffic to the Hub running inside your private cluster. No public ports needed on the cluster.
### 2. 🗺️ Multi-Region Edge Ingress
Run Edges in NYC, Frankfurt, and Tokyo — all connecting to a single Hub. Use GeoDNS to route users to their nearest Edge. PROXY protocol ensures the Hub sees real client IPs regardless of which Edge they entered through.
### 3. 📡 UDP Forwarding (DNS, Gaming, VoIP)
Configure UDP listen ports alongside TCP ports. DNS queries, game server traffic, or VoIP packets are tunneled through the same edge/hub connection and forwarded to SmartProxy with a PROXY v2 binary header preserving the client's real IP.
```typescript
await hub.updateAllowedEdges([
{
id: 'edge-nyc-01',
secret: 'secret',
listenPorts: [80, 443], // TCP
listenPortsUdp: [53, 27015], // DNS + game server
},
]);
```
### 4. 🚀 QUIC Transport for Low-Latency
Use QUIC transport to eliminate head-of-line blocking — a lost packet on one stream doesn't stall others. QUIC also enables 0-RTT reconnection and connection migration.
```typescript
await edge.start({
hubHost: 'hub.example.com',
hubPort: 8443,
edgeId: 'edge-01',
secret: 'secret',
transportMode: 'quicWithFallback', // try QUIC, fall back to TLS if UDP blocked
});
```
### 5. 🔑 Token-Based Edge Provisioning
Generate connection tokens on the hub side and distribute them to edge operators:
```typescript
import { encodeConnectionToken, RemoteIngressEdge } from '@serve.zone/remoteingress';
const token = encodeConnectionToken({ const token = encodeConnectionToken({
hubHost: 'hub.prod.example.com', hubHost: 'ingress-hub.example.com',
hubPort: 8443, hubPort: 8443,
edgeId: 'edge-tokyo-01', edgeId: 'edge-fra-01',
secret: 'generated-secret-abc123', secret: 'replace-with-a-long-random-secret',
}); });
// Send `token` to the edge operator — a single string is all they need
console.log(decodeConnectionToken(token));
const edge = new RemoteIngressEdge(); const edge = new RemoteIngressEdge();
await edge.start({ token }); await edge.start({ token });
``` ```
### 6. 🛡️ Centralized Firewall Management ## CLI Mode
Push firewall rules from the hub to all your edge nodes. Block bad actors, rate-limit abusive traffic, and whitelist trusted subnets — all from a single control plane: The package entry point exports `runCli()`, and the repo's `cli.js` wrapper can run hub or edge mode.
```bash
node cli.js hub --tunnel-port 8443 --target-host 127.0.0.1
node cli.js edge --token eyJoIjoiaW5ncmVzcy1odWIuZXhhbXBsZS5jb20i...
```
Environment-based startup is also supported:
| Variable | Purpose |
| --- | --- |
| `REMOTEINGRESS_MODE` | `hub` or `edge` |
| `REMOTEINGRESS_TOKEN` | Edge connection token |
| `REMOTEINGRESS_HUB_HOST` / `REMOTEINGRESS_HUB_PORT` | Explicit edge connection target |
| `REMOTEINGRESS_EDGE_ID` / `REMOTEINGRESS_SECRET` | Explicit edge credentials |
| `REMOTEINGRESS_TARGET_HOST` | Hub-side forwarding target, default `127.0.0.1` |
| `REMOTEINGRESS_ALLOWED_EDGES_JSON` | Hub-side allowed edge list |
| `REMOTEINGRESS_PERFORMANCE_JSON` | Optional performance configuration |
## Transport Modes
| Mode | Behavior |
| --- | --- |
| `tcpTls` | Single TLS connection with frame-based stream multiplexing. Good for conservative networks. |
| `quic` | QUIC streams for TCP and QUIC datagrams for UDP. Best latency and no TCP head-of-line blocking. |
| `quicWithFallback` | Default edge mode. Tries QUIC and falls back to TCP/TLS when UDP is blocked. |
## Firewall Config
`firewallConfig` travels in the same hub-to-edge configuration update as port assignments. Each update is a full desired-state snapshot.
```typescript ```typescript
await hub.updateAllowedEdges([ await hub.updateAllowedEdges([
{ {
id: 'edge-nyc-01', id: 'edge-fra-01',
secret: 'secret', secret: 'secret',
listenPorts: [80, 443], listenPorts: [80, 443],
firewallConfig: { firewallConfig: {
blockedIps: ['198.51.100.0/24'], blockedIps: ['203.0.113.0/24'],
rateLimits: [ rateLimits: [
{ id: 'https', port: 443, protocol: 'tcp', rate: '500/second', perSourceIP: true, burst: 100 }, { id: 'http', port: 80, protocol: 'tcp', rate: '100/second', perSourceIP: true },
], ],
rules: [ rules: [
{ id: 'allow-monitoring', direction: 'input', action: 'accept', sourceIP: '10.0.0.0/8', destPort: 9090, protocol: 'tcp' }, { id: 'allow-monitoring', direction: 'input', action: 'accept', sourceIP: '10.0.0.0/8', destPort: 9090, protocol: 'tcp' },
@@ -479,12 +181,36 @@ await hub.updateAllowedEdges([
}, },
}, },
]); ]);
// Firewall rules are applied at the edge via nftables within seconds
``` ```
## API Surface
| Export | Purpose |
| --- | --- |
| `RemoteIngressHub` | Starts/stops the private hub, authorizes edges, pushes runtime config, and reports connected edges. |
| `RemoteIngressEdge` | Starts/stops the public edge, connects to the hub, binds assigned ports, and applies firewall rules. |
| `encodeConnectionToken()` | Encodes hub host, port, edge ID, and secret into a token. |
| `decodeConnectionToken()` | Decodes and validates a token. |
| `IHubConfig`, `IEdgeConfig`, `TAllowedEdge` | Primary TypeScript shapes for integrating the module. |
## Development
```bash
pnpm run build
pnpm test
```
Useful source entry points:
- `ts/index.ts` exports the public API and CLI runner.
- `ts/classes.remoteingresshub.ts` wraps hub management commands and hub events.
- `ts/classes.remoteingressedge.ts` wraps edge management commands, nftables application, and edge events.
- `ts/classes.token.ts` implements compact connection tokens.
- `rust/` contains the performance-critical tunnel implementation compiled by `tsrust`.
## License and Legal Information ## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file. This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
node --input-type=module <<'NODE'
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
const checks = {
packageVersion: readJson('/app/package.json').version,
hasCli: fs.existsSync('/app/cli.js'),
hasRustBinary: fs.existsSync(`/app/dist_rust/remoteingress-bin_linux_${arch}`) || fs.existsSync('/app/dist_rust/remoteingress-bin'),
};
await import('/app/dist_ts/index.js');
execFileSync('node', ['/app/cli.js', '--help'], { stdio: 'pipe' });
if (checks.packageVersion !== '4.17.1') {
throw new Error(`Unexpected remoteingress package version ${checks.packageVersion}`);
}
if (!checks.hasCli) {
throw new Error('Missing cli.js');
}
if (!checks.hasRustBinary) {
throw new Error(`Missing Rust binary for ${arch}`);
}
console.log(JSON.stringify(checks));
NODE
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/remoteingress', name: '@serve.zone/remoteingress',
version: '4.17.0', version: '4.17.1',
description: 'Edge ingress tunnel for DcRouter - tunnels TCP and UDP traffic from the network edge to SmartProxy over TLS or QUIC, preserving client IP via PROXY protocol.' description: 'Edge ingress tunnel for DcRouter - tunnels TCP and UDP traffic from the network edge to SmartProxy over TLS or QUIC, preserving client IP via PROXY protocol.'
} }
+30 -13
View File
@@ -57,6 +57,7 @@ export class RemoteIngressEdge extends EventEmitter {
private restartAttempts = 0; private restartAttempts = 0;
private statusInterval: ReturnType<typeof setInterval> | undefined; private statusInterval: ReturnType<typeof setInterval> | undefined;
private nft: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null; private nft: InstanceType<typeof plugins.smartnftables.SmartNftables> | null = null;
private pendingFirewallConfig: IFirewallConfig | null = null;
constructor() { constructor() {
super(); super();
@@ -114,7 +115,9 @@ export class RemoteIngressEdge extends EventEmitter {
}); });
this.bridge.on('management:firewallConfigUpdated', (data: { firewallConfig: IFirewallConfig }) => { this.bridge.on('management:firewallConfigUpdated', (data: { firewallConfig: IFirewallConfig }) => {
console.log(`[RemoteIngressEdge] Firewall config updated from hub`); console.log(`[RemoteIngressEdge] Firewall config updated from hub`);
this.applyFirewallConfig(data.firewallConfig); void this.applyFirewallConfig(data.firewallConfig).catch((err) => {
console.error(`[RemoteIngressEdge] Failed to apply firewall config: ${err}`);
});
this.emit('firewallConfigUpdated', data); this.emit('firewallConfigUpdated', data);
}); });
} }
@@ -122,14 +125,22 @@ export class RemoteIngressEdge extends EventEmitter {
/** /**
* Initialize the nftables manager. Fails gracefully if not running as root. * Initialize the nftables manager. Fails gracefully if not running as root.
*/ */
private async initNft(): Promise<void> { private async initNft(options: { reset?: boolean } = {}): Promise<void> {
try { try {
this.nft = new plugins.smartnftables.SmartNftables({ this.nft = new plugins.smartnftables.SmartNftables({
tableName: 'remoteingress', tableName: 'remoteingress',
dryRun: false, dryRun: false,
}); });
if (options.reset) {
await (this.nft as any).cleanup({ force: true });
}
await this.nft.initialize(); await this.nft.initialize();
console.log('[RemoteIngressEdge] SmartNftables initialized'); console.log('[RemoteIngressEdge] SmartNftables initialized');
if (this.pendingFirewallConfig) {
const pending = this.pendingFirewallConfig;
this.pendingFirewallConfig = null;
await this.applyFirewallConfig(pending);
}
} catch (err) { } catch (err) {
console.warn(`[RemoteIngressEdge] Failed to initialize nftables (not root?): ${err}`); console.warn(`[RemoteIngressEdge] Failed to initialize nftables (not root?): ${err}`);
this.nft = null; this.nft = null;
@@ -142,19 +153,22 @@ export class RemoteIngressEdge extends EventEmitter {
*/ */
private async applyFirewallConfig(config: IFirewallConfig): Promise<void> { private async applyFirewallConfig(config: IFirewallConfig): Promise<void> {
if (!this.nft) { if (!this.nft) {
this.pendingFirewallConfig = config;
return; return;
} }
try { try {
// Full cleanup and reinitialize to replace all rules atomically // Full cleanup and reinitialize to replace all rules atomically
await this.nft.cleanup(); await (this.nft as any).cleanup({ force: true });
await this.nft.initialize(); await this.nft.initialize();
// Apply blocked IPs // Apply blocked IPs
if (config.blockedIps && config.blockedIps.length > 0) { if (config.blockedIps && config.blockedIps.length > 0) {
for (const ip of config.blockedIps) { await (this.nft.firewall as any).blockIPSet('hub-blocklist', {
await this.nft.firewall.blockIP(ip); setName: 'blocked_ipv4',
} ips: config.blockedIps,
comment: 'RemoteIngress hub blocklist',
});
console.log(`[RemoteIngressEdge] Blocked ${config.blockedIps.length} IPs`); console.log(`[RemoteIngressEdge] Blocked ${config.blockedIps.length} IPs`);
} }
@@ -213,6 +227,10 @@ export class RemoteIngressEdge extends EventEmitter {
this.savedConfig = edgeConfig; this.savedConfig = edgeConfig;
this.stopping = false; this.stopping = false;
// Clear any stale nftables state left by a prior process before the edge
// can accept hub config or bind public listener ports.
await this.initNft({ reset: true });
const spawned = await this.bridge.spawn(); const spawned = await this.bridge.spawn();
if (!spawned) { if (!spawned) {
throw new Error('Failed to spawn remoteingress-bin'); throw new Error('Failed to spawn remoteingress-bin');
@@ -242,9 +260,6 @@ export class RemoteIngressEdge extends EventEmitter {
this.restartAttempts = 0; this.restartAttempts = 0;
this.restartBackoffMs = 1000; this.restartBackoffMs = 1000;
// Initialize nftables (graceful degradation if not root)
await this.initNft();
// Start periodic status logging // Start periodic status logging
this.statusInterval = setInterval(async () => { this.statusInterval = setInterval(async () => {
try { try {
@@ -272,7 +287,7 @@ export class RemoteIngressEdge extends EventEmitter {
// Clean up nftables rules before stopping // Clean up nftables rules before stopping
if (this.nft) { if (this.nft) {
try { try {
await this.nft.cleanup(); await (this.nft as any).cleanup({ force: true });
} catch (err) { } catch (err) {
console.warn(`[RemoteIngressEdge] nftables cleanup error: ${err}`); console.warn(`[RemoteIngressEdge] nftables cleanup error: ${err}`);
} }
@@ -289,6 +304,7 @@ export class RemoteIngressEdge extends EventEmitter {
this.started = false; this.started = false;
} }
this.savedConfig = null; this.savedConfig = null;
this.pendingFirewallConfig = null;
// Remove all listeners to prevent memory buildup // Remove all listeners to prevent memory buildup
this.bridge.removeAllListeners(); this.bridge.removeAllListeners();
this.removeAllListeners(); this.removeAllListeners();
@@ -344,6 +360,10 @@ export class RemoteIngressEdge extends EventEmitter {
this.restartAttempts++; this.restartAttempts++;
try { try {
// Drop stale kernel rules before reconnecting. The hub will send the
// current full firewall snapshot during handshake/config refresh.
await this.initNft({ reset: true });
const spawned = await this.bridge.spawn(); const spawned = await this.bridge.spawn();
if (!spawned) { if (!spawned) {
console.error('[RemoteIngressEdge] Failed to respawn binary'); console.error('[RemoteIngressEdge] Failed to respawn binary');
@@ -366,9 +386,6 @@ export class RemoteIngressEdge extends EventEmitter {
this.restartAttempts = 0; this.restartAttempts = 0;
this.restartBackoffMs = 1000; this.restartBackoffMs = 1000;
// Re-initialize nftables (hub will re-push config via handshake)
await this.initNft();
// Restart periodic status logging // Restart periodic status logging
this.statusInterval = setInterval(async () => { this.statusInterval = setInterval(async () => {
try { try {
+1 -1
View File
@@ -135,7 +135,7 @@ export interface IUdpStatus {
droppedDatagrams: number; droppedDatagrams: number;
} }
type TAllowedEdge = { id: string; secret: string; listenPorts?: number[]; listenPortsUdp?: number[]; stunIntervalSecs?: number; firewallConfig?: IFirewallConfig; performance?: IPerformanceConfig }; export type TAllowedEdge = { id: string; secret: string; listenPorts?: number[]; listenPortsUdp?: number[]; stunIntervalSecs?: number; firewallConfig?: IFirewallConfig; performance?: IPerformanceConfig };
const MAX_RESTART_ATTEMPTS = 10; const MAX_RESTART_ATTEMPTS = 10;
const MAX_RESTART_BACKOFF_MS = 30_000; const MAX_RESTART_BACKOFF_MS = 30_000;
+144
View File
@@ -1,3 +1,147 @@
import { RemoteIngressEdge } from './classes.remoteingressedge.js';
import { RemoteIngressHub, type IHubConfig, type TAllowedEdge } from './classes.remoteingresshub.js';
export * from './classes.remoteingresshub.js'; export * from './classes.remoteingresshub.js';
export * from './classes.remoteingressedge.js'; export * from './classes.remoteingressedge.js';
export * from './classes.token.js'; export * from './classes.token.js';
const usage = `remoteingress
Usage:
remoteingress hub [--tunnel-port 8443] [--target-host 127.0.0.1]
remoteingress edge --token <connection-token>
remoteingress edge --hub-host <host> --edge-id <id> --secret <secret> [--hub-port 8443]
Environment:
REMOTEINGRESS_MODE=hub|edge
REMOTEINGRESS_TOKEN=<connection-token>
REMOTEINGRESS_HUB_HOST=<host>
REMOTEINGRESS_HUB_PORT=8443
REMOTEINGRESS_EDGE_ID=<id>
REMOTEINGRESS_SECRET=<secret>
REMOTEINGRESS_TARGET_HOST=127.0.0.1
REMOTEINGRESS_ALLOWED_EDGES_JSON='[{"id":"edge-1","secret":"secret","listenPorts":[80,443]}]'
`;
const readArg = (args: string[], name: string): string | undefined => {
const prefix = `--${name}=`;
const inlineValue = args.find((arg) => arg.startsWith(prefix));
if (inlineValue) {
return inlineValue.slice(prefix.length);
}
const index = args.indexOf(`--${name}`);
if (index >= 0) {
return args[index + 1];
}
return undefined;
};
const readNumber = (value: string | undefined, fallback: number): number => {
if (!value) {
return fallback;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error(`Invalid port: ${value}`);
}
return parsed;
};
const readJson = <T>(value: string | undefined, fallback: T): T => {
if (!value) {
return fallback;
}
return JSON.parse(value) as T;
};
const waitForever = async (stop: () => Promise<void>) => {
let stopping = false;
const handleStop = async () => {
if (stopping) {
return;
}
stopping = true;
await stop();
process.exit(0);
};
process.once('SIGINT', () => void handleStop());
process.once('SIGTERM', () => void handleStop());
await new Promise(() => {});
};
export const runCli = async () => {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(usage);
return;
}
const positionalMode = args[0]?.startsWith('--') ? undefined : args[0];
const mode = readArg(args, 'mode') ?? positionalMode ?? process.env.REMOTEINGRESS_MODE;
if (mode === 'hub') {
const hub = new RemoteIngressHub();
const config: IHubConfig = {
tunnelPort: readNumber(readArg(args, 'tunnel-port') ?? process.env.REMOTEINGRESS_TUNNEL_PORT, 8443),
targetHost: readArg(args, 'target-host') ?? process.env.REMOTEINGRESS_TARGET_HOST ?? '127.0.0.1',
tls: {
certPem: readArg(args, 'tls-cert-pem') ?? process.env.REMOTEINGRESS_TLS_CERT_PEM,
keyPem: readArg(args, 'tls-key-pem') ?? process.env.REMOTEINGRESS_TLS_KEY_PEM,
},
performance: readJson(readArg(args, 'performance-json') ?? process.env.REMOTEINGRESS_PERFORMANCE_JSON, undefined),
};
await hub.start(config);
const allowedEdges = readJson<TAllowedEdge[]>(
readArg(args, 'allowed-edges-json') ?? process.env.REMOTEINGRESS_ALLOWED_EDGES_JSON,
[],
);
if (allowedEdges.length > 0) {
await hub.updateAllowedEdges(allowedEdges);
}
console.log(`RemoteIngress hub listening on ${config.tunnelPort}`);
await waitForever(() => hub.stop());
return;
}
if (mode === 'edge') {
const edge = new RemoteIngressEdge();
const token = readArg(args, 'token') ?? process.env.REMOTEINGRESS_TOKEN;
if (token) {
await edge.start({ token });
} else {
const hubHost = readArg(args, 'hub-host') ?? process.env.REMOTEINGRESS_HUB_HOST;
const edgeId = readArg(args, 'edge-id') ?? process.env.REMOTEINGRESS_EDGE_ID;
const secret = readArg(args, 'secret') ?? process.env.REMOTEINGRESS_SECRET;
if (!hubHost || !edgeId || !secret) {
throw new Error('Edge mode requires --token or --hub-host, --edge-id, and --secret');
}
await edge.start({
hubHost,
hubPort: readNumber(readArg(args, 'hub-port') ?? process.env.REMOTEINGRESS_HUB_PORT, 8443),
edgeId,
secret,
bindAddress: readArg(args, 'bind-address') ?? process.env.REMOTEINGRESS_BIND_ADDRESS,
transportMode: readArg(args, 'transport-mode') as any,
});
}
console.log('RemoteIngress edge started');
await waitForever(() => edge.stop());
return;
}
console.log(usage);
};