mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
982c7830b1
* fix(mesh): buffer early TcpData on reverse-relay path
The reverse-relay code dropped the local-shaped reservation and any
TcpData frames buffered in it before acceptReverseRelay had wired up
the target stream. A peer that sent a request body immediately after
tcp_open_reverse lost those bytes when the target lived on a third
node, since reverse_local buffers them but reverse_relay did not.
Carry the reservation through acceptReverseRelay: transplant its
pendingData and pendingBytes into the new reverse_relay state, gate
TcpData writes on targetOpen, and flush buffered frames into the
target stream before sending tcp_open_ack. Mirrors the reverse_local
pattern. Regression test fires tcp_open_reverse plus an immediate
TcpData while ensureBridge is in flight and verifies the bytes
arrive intact, in order, before the ack.
* fix(mesh): recompose affected stacks when node-level mesh is disabled
disableForNode used to clear DB rows and override files but leave the
running containers attached to sencho_mesh with stale /etc/hosts
alias entries until an operator redeployed every stack by hand.
Mirror optOutStack: after the existing alias/forwarder cleanup, call
regenerateOverridesAcrossFleet, cascadeRecomposeAcrossFleet, and
triggerRedeploy for each previously meshed stack on the disabled
node so containers detach from sencho_mesh and shed the alias
entries they owned. The disabled node's mesh_stacks rows are deleted
before the cascade so listMeshStacks returns the right set with no
skip tuple required. Route threads the actor through actorFor(req)
for parity with optInStack/optOutStack. Tests cover the redeploy
fan-out, the cascade no-skip-tuple invariant, and the default actor
fallback for non-route callers.
* fix(mesh): require Admiral on the WS proxy-tunnel upgrade
HTTP mesh routes in routes/mesh.ts all enforce requireAdmiral, but
the /api/mesh/proxy-tunnel WS upgrade accepted any node_proxy or
full-admin api_token regardless of the receiver's license. A node
downgraded from Admiral kept serving mesh data-plane traffic to a
sibling central while refusing every mesh management call.
Read the receiver's local LicenseService at the upgrade and 403 when
the tier is not paid+admiral. The check sits after the existing
credential gate and uses LicenseService directly rather than
effectiveTier (which trusts forwarded proxy headers); a remote peer
dialing in cannot be trusted to assert our entitlement. Dialer and
node_proxy token format are unchanged. Three regression tests cover
community-tier node_proxy, skipper-tier node_proxy, and
community-tier full-admin api_token all rejected with 403.
* fix(mesh): handle no_target alongside push_failed in inspectStackServices
proxyFetch throws MeshError('no_target') when getProxyTarget returns
null (pilot tunnel offline, proxy bridge unreachable), but the
inspectStackServices catch branch only matched push_failed. Offline
remotes fell through to the generic 'remote unreachable' error log,
which the Routing tab surfaces as an unexpected fault.
Match both error codes and emit the operator-friendly warn message
that names the unreachable node and the error code. Regression test
spies on console.warn/console.error to pin the branch.
* docs(mesh): align env defaults and forwarder comments with current architecture
SENCHO_MESH_PROXY_TUNNEL_IDLE_MS in .env.example carried the old
five-minute idle-close value (=300000), but the code default is
DEFAULT_IDLE_TTL_MS=0 (persistent tunnel). Copying the example
silently reintroduced the idle-close behavior the dialer removed.
MeshForwarder.ts's leading docblock and inline listen comment still
described host-network mode plus extra_hosts: host-gateway as
required for forwarder reachability. Sencho runs in standard bridge
mode and attaches to the shared sencho_mesh network at a stable IP;
meshed user containers reach the forwarder by that IP directly.
Flip the env default to 0, rewrite the env comment to describe the
persistent behavior and the opt-in for idle teardown, and rewrite
both forwarder comments to match the bridge-network reality.
* fix(mesh): trust forwarded tier on proxy-tunnel WS and remove remote overrides on disable
Admiral entitlement on the WS data plane now follows the same trust model
as the HTTP mesh routes: the central asserts its tier via x-sencho-tier
and x-sencho-variant on the WS handshake, and the receiver trusts those
headers only when the upgrade carries a node_proxy credential. When no
headers are present or the credential is a full-admin api_token, the
receiver falls back to its own local license. Without this an Admiral
central could be rejected by a Community remote and a Community central
could dial a locally-Admiral remote.
disableForNode now routes through removeOverrideFromNode so override
files pushed earlier via applyLocalOverride are removed on remote nodes
via DELETE /api/mesh/local-override/:stack. Sequential awaits are wrapped
in Promise.allSettled to match regenerateOverridesForNode's parallel
push pattern.
Other changes:
- Cover the post-state-swap buffering window in the reverse-relay test
(TcpData arriving after openTcpStream returns but before target open).
- Refresh stale MeshService comments that still referenced the removed
sidecar layer and host-network listener model.
* test(mesh): pin removeOverrideFromNode remote HTTP shape
The disableForNode regression test mocks removeOverrideFromNode itself,
so a regression inside the helper would not be caught. Add a narrow
contract test that spies on global fetch and asserts the request shape:
DELETE /api/mesh/local-override/:stack against the resolved proxy
target, with Authorization Bearer plus the x-sencho-tier and
x-sencho-variant headers. Also covers the encodeURIComponent path and
the swallowed-network-error behavior the disable cascade relies on.
* test(mesh): use createTestApiToken helper in proxy-tunnel api_token gate test
The full-admin api_token branch of the WS Admiral gate test inlined the
canonical generateApiToken + sha256 + addApiToken triple that already
lives in the createTestApiToken helper. Switching to the helper removes
the duplicated insertion logic and aligns this test with the helper used
by the other api_token call sites.
101 lines
4.2 KiB
TypeScript
101 lines
4.2 KiB
TypeScript
import net from 'net';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
/**
|
|
* In-process mesh TCP forwarder. Owns per-port `net.Server` listeners and
|
|
* delegates accepted sockets to the host (MeshService) for resolve +
|
|
* splice. All routing and splicing logic lives on MeshService;
|
|
* MeshForwarder is a thin lifecycle layer that opens and closes ports.
|
|
*
|
|
* Sencho runs in standard Docker bridge mode and attaches its own
|
|
* container to the shared `sencho_mesh` network at a stable IP. Meshed
|
|
* user containers are attached to the same network, so they reach the
|
|
* forwarder by the Sencho IP without `network_mode: host` or the
|
|
* `extra_hosts: <alias>:host-gateway` indirection an older sidecar
|
|
* design required.
|
|
*/
|
|
|
|
export interface MeshForwarderHost {
|
|
/** Called on each accepted inbound socket. The host owns the splice
|
|
* lifecycle; MeshForwarder only manages listener boilerplate. */
|
|
handleAccept(port: number, source: net.Socket): Promise<void>;
|
|
}
|
|
|
|
export class MeshForwarder {
|
|
private readonly listeners = new Map<number, net.Server>();
|
|
/**
|
|
* In-flight `listen(port)` promises so concurrent callers race-safely
|
|
* deduplicate. Without this guard, two concurrent calls to listen on
|
|
* the same port would both pass the `listeners.has(port)` check (which
|
|
* is only populated after the listening event resolves) and the second
|
|
* would fail with EADDRINUSE.
|
|
*/
|
|
private readonly pending = new Map<number, Promise<void>>();
|
|
private shuttingDown = false;
|
|
|
|
constructor(private readonly host: MeshForwarderHost) {}
|
|
|
|
public async listen(port: number): Promise<void> {
|
|
if (this.shuttingDown) return;
|
|
if (this.listeners.has(port)) return;
|
|
const inflight = this.pending.get(port);
|
|
if (inflight) return inflight;
|
|
const promise = (async () => {
|
|
const server = net.createServer((socket) => this.acceptConnection(port, socket));
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => { server.removeListener('listening', onListening); reject(err); };
|
|
const onListening = () => { server.removeListener('error', onError); resolve(); };
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
// Bind on all interfaces inside the Sencho container's
|
|
// networking namespace. The sencho_mesh bridge attaches
|
|
// both Sencho and meshed user containers, so peers reach
|
|
// this listener at Sencho's mesh-network IP.
|
|
server.listen(port, '0.0.0.0');
|
|
});
|
|
this.listeners.set(port, server);
|
|
} finally {
|
|
this.pending.delete(port);
|
|
}
|
|
})();
|
|
this.pending.set(port, promise);
|
|
return promise;
|
|
}
|
|
|
|
public async unlisten(port: number): Promise<void> {
|
|
const server = this.listeners.get(port);
|
|
if (!server) return;
|
|
this.listeners.delete(port);
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
|
|
public async shutdown(): Promise<void> {
|
|
this.shuttingDown = true;
|
|
const ports = Array.from(this.listeners.keys());
|
|
await Promise.all(ports.map((p) => this.unlisten(p)));
|
|
}
|
|
|
|
public getListenerPorts(): number[] {
|
|
return Array.from(this.listeners.keys());
|
|
}
|
|
|
|
public isListening(port: number): boolean {
|
|
return this.listeners.has(port);
|
|
}
|
|
|
|
private acceptConnection(port: number, source: net.Socket): void {
|
|
if (this.shuttingDown) {
|
|
try { source.destroy(); } catch { /* ignore */ }
|
|
return;
|
|
}
|
|
// Defer to the host for resolve + splice. MeshForwarder itself does
|
|
// not look at the source bytes; routing lives on MeshService where
|
|
// the alias map and the cross-node bridge dispatch are.
|
|
this.host.handleAccept(port, source).catch((err) => {
|
|
console.warn('[MeshForwarder] accept handler failed:', sanitizeForLog((err as Error).message));
|
|
try { source.destroy(); } catch { /* ignore */ }
|
|
});
|
|
}
|
|
}
|