@syengup/tunnel-edge
v0.3.1
Published
FridayTunnel public-surface edge: backend routing reverse proxy with path whitelisting, request marking, and App Attest gating.
Readme
@syengup/tunnel-edge
FridayTunnel public-surface edge: a dependency-free (node built-ins only) routing reverse proxy that sits between a TLS-terminating public ingress (frpc in Phase 1) and one or more local agent backends.
frpc ─▶ tunnel-edge(:edgePort)
│ path/backend routing + public marker + App Attest gate
├─ /friday-next* ─▶ OpenClaw core(:corePort)
└─ /cap* ─▶ Conductor(:conductorPort)The package deliberately imports nothing from openclaw-fridaynext-channel,
fridaynext-conductor, or OpenClaw. Phase 1 embedded it in the OpenClaw channel plugin; Phase 2
adds a standalone CLI (fridaynext-tunnel-edge) so the same routing semantics run either
in-process or as a managed external process.
Public API
import {
startTunnelEdge,
resolveBackendForPath,
edgeAttestGateDecision,
normalizedPath,
matchesPrefix,
PUBLIC_MARKER,
ATTEST_HEADER,
ATTEST_COOKIE,
ATTEST_REJECTION_BODY,
} from "@syengup/tunnel-edge";
import type {
TunnelBackend,
TunnelEdge,
TunnelEdgeOptions,
ProxyAttestGate,
} from "@syengup/tunnel-edge";
type TunnelBackend = {
id: string;
pathPrefixes: string[]; // e.g. ["/friday-next", "/gateway", "/__openclaw__"]
localPort: number;
requiresAttest?: boolean; // default true
denyPrefixes?: string[]; // DENY beats ALLOW for this backend
allowedPaths?: string[]; // optional extra whitelist, IN ADDITION to pathPrefixes
attestExemptPaths?: string[]; // paths exempt from the edge App Attest gate
};
const edge = startTunnelEdge({
listenPort: 18790,
backends: [
{
id: "openclaw",
pathPrefixes: ["/friday-next", "/friday-next-admin", "/gateway", "/__openclaw__"],
localPort: 18789,
requiresAttest: true,
denyPrefixes: [
"/__openclaw__/control",
"/__openclaw__/config",
"/__openclaw__/api",
"/__openclaw__",
],
attestExemptPaths: [
"/friday-next/attest",
"/friday-next/health",
"/friday-next/status",
"/friday-next/plugin/info",
"/friday-next/public-access/pairing",
"/friday-next/pair/claim",
],
},
{
id: "conductor",
pathPrefixes: ["/cap"],
localPort: 24080,
requiresAttest: true,
allowedPaths: [
"/cap/hello",
"/cap/health",
"/cap/events",
"/cap/models",
"/cap/cancel",
"/cap/files",
"/cap/approvals",
"/cap/sessions",
"/cap/workspaces",
],
attestExemptPaths: ["/cap/health"],
},
],
attestGate: {
enabled: () => true,
verify: (token) => token === "good-token",
},
log: (message) => console.log(message),
});
const port = await edge.port; // actual bound port (useful when listenPort is 0)
await edge.close();CLI
fridaynext-tunnel-edge --config /path/to/tunnel-edge.json
# or: fridaynext-tunnel-edge -c /path/to/tunnel-edge.json
node dist/cli.js --config /path/to/tunnel-edge.json- Reads the config, starts the edge, prints
edge ready on 127.0.0.1:<port>to stdout. SIGTERM/SIGINTclose the edge cleanly and exit 0.- Config/usage errors print to stderr and exit 2; missing
--configexits 3. --help/-hprints usage and exits 0.
Config file
{
"listenPort": 18790,
"backends": [
{
"id": "openclaw",
"pathPrefixes": ["/friday-next", "/gateway"],
"localPort": 18789,
"requiresAttest": true,
"attestExemptPaths": ["/friday-next/health"]
}
],
"logLevel": "info",
"attest": {
"url": "http://127.0.0.1:18789/friday-next/edge/verify-attest",
"header": "x-fridaynext-attest"
}
}listenPort— integer 0-65535.0binds an ephemeral port (the CLI prints the real port).backends—TunnelBackend[], validated exactly likestartTunnelEdge(duplicate ids, invalid entries, and overlapping prefixes all throw).logLevel—"debug" | "info" | "silent"(optional)."silent"suppresses edge lifecycle messages.attest— optional localhost-only verifier for the standalone CLI.urlmust be http(s) onlocalhost/127.0.0.1(anything else is rejected);headerdefaults tox-fridaynext-attest. The edge sends the caller's attest token there and accepts only HTTP 200 — every other status or network error fails closed.
Library helpers:
import {
loadEdgeConfigFile,
writeEdgeConfigFile,
startEdgeFromConfigFile,
} from "@syengup/tunnel-edge";
import type { EdgeConfigFile } from "@syengup/tunnel-edge";
const cfg = loadEdgeConfigFile("/path/to/tunnel-edge.json"); // throws descriptive errors
writeEdgeConfigFile("/path/to/tunnel-edge.json", cfg); // atomic tmp+rename, mode 0600
const edge = startEdgeFromConfigFile("/path/to/tunnel-edge.json", (msg) => console.log(msg));Updating the routing table at runtime
TunnelEdge.updateBackends(backends) replaces the live routing table without restarting the
listener. It is in-process only; external edge processes are restarted by their manager
after the config file is rewritten.
const edge = startTunnelEdge({
listenPort: 0,
backends: [{ id: "a", pathPrefixes: ["/a"], localPort: 1 }],
});
edge.updateBackends([{ id: "b", pathPrefixes: ["/b"], localPort: 2 }]); // atomic swap
edge.updateBackends([
{ id: "c", pathPrefixes: ["/c"], localPort: 2 },
{ id: "d", pathPrefixes: ["/c/health"], localPort: 3 },
]); // throws — overlapping prefixes; the old table keeps servingValidation is identical to construction, and a failed update leaves the current routing table untouched.
In-process vs external modes
The same routing table can be run two ways:
| Mode | Process | Manager updates routing by |
| ---------- | ------------------------------------------------------- | --------------------------------------------- |
| in-process | embedded in the host (e.g. the OpenClaw channel plugin) | TunnelEdge.updateBackends() |
| external | standalone fridaynext-tunnel-edge CLI child process | rewrite tunnel-edge.json, restart the child |
Both modes produce identical routing, marker, and attest behavior because they run the same
startTunnelEdge/CLI code over the same backend table.
Routing semantics
- Paths are normalized before matching exactly like the legacy filter proxy: URL resolution,
decodeURIComponentretry, and//collapsing. - Prefix matching is segment-boundary:
/capmatches/cap,/cap/,/cap/hellobut NOT/capsule(matchesPrefix). - Backends are matched in declaration order; first match wins.
- Unknown paths 404
not found. - On the matched backend,
denyPrefixesare checked first and return 404. DENY beats ALLOW.- A deny prefix that is identical to one of the backend's own
pathPrefixesis an exact-root deny (the bare path and its trailing-slash form only), not a whole-subtree deny. This is how the bare/__openclaw__index is denied while/__openclaw__/a2ui/*canvas traffic stays routable.
- A deny prefix that is identical to one of the backend's own
allowedPaths, when present, is an additional whitelist: the path must also match one of those prefixes (segment-boundary).- Overlapping
pathPrefixesbetween two backends throw at construction with both backend ids in the message — no silent shadowing. - Requests always get
x-fridaynext-public: 1stamped after any client-supplied marker is stripped (HTTP and upgrade paths). - HTTP is proxied to
127.0.0.1:<backend.localPort>with the raw path/query, headers, marker,upstream.setTimeout(0)for SSE, bidirectional piping, and 502 on upstream error. - WebSocket/upgrade handling mirrors the legacy proxy: denied paths destroy the socket, attest rejections write a 403 before closing, allowed upgrades strip/stamp the marker and tie both socket lifetimes together.
Security invariants
- The public marker is only ever set by the edge; client-supplied values are stripped first.
- DENY beats ALLOW; unknown paths 404.
- Attest is fail-closed: if the injected
verify(orenabled) throws, the request is rejected. - Attest token is read from
x-fridaynext-attestonly, except/__openclaw__/*which also accepts thefn_attestcookie (canvas WKWebView sub-resources cannot set headers). attestExemptPathsis per backend; paths not exempt are gated whenever the backend'srequiresAttest !== falseand the injected gate is enabled.
TunnelRuntime (standalone tunnel lifecycle)
TunnelRuntime owns the full FridayTunnel lifecycle — relay bootstrap, stable subdomain
allocation, certificate issuance/renewal, the standby long-poll against the control plane, frpc
download/checksum/child management, and the public-surface edge (in-process or as the standalone
CLI child). It is host-agnostic: the host supplies a backend-table builder, an App Attest gate
(or an external localhost verifier URL), voucher storage, and a logger.
import { startTunnelRuntime } from "@syengup/tunnel-edge";
import type { TunnelRuntimeConfig, TunnelRuntimeHost } from "@syengup/tunnel-edge";
const runtime = startTunnelRuntime(
{
dataDir: "/var/lib/fridaynext-tunnel",
corePort: 18789, // edge listens on corePort + 1
authToken: "gateway-bearer-token", // sha256(authToken) is the relay gatewayKey
lanUrl: "http://192.168.1.20:18789",
controlPlaneUrl: "https://gw.syengup.host",
allocatorUrl: "https://gw.syengup.host/gw-alloc/allocate",
certSignUrl: "https://gw.syengup.host/gw-alloc/sign-cert",
subDomainHost: "bj.gw.syengup.host",
// subdomain: "my-fixed-sub", // optional explicit override
// relayAddr: "frps.example:7000", // optional explicit frps override
// relayToken: "shared-secret", // optional explicit frps override
edgeMode: "in-process", // or "external"
edgeLogLevel: "info",
},
{
buildBackends: () => [
{ id: "openclaw", pathPrefixes: ["/friday-next", "/gateway"], localPort: 18789 },
],
attestGate: () => ({ enabled: () => true, verify: (token) => token === "good-token" }),
externalAttestUrl: () => "http://127.0.0.1:18789/friday-next/edge/verify-attest",
vouchers: myVoucherStore, // host-owned; the runtime never mints/claims vouchers itself
log: (message) => console.log(message),
},
);
const pairing = await runtime.start(); // null when no relay credentials/subdomain are available
await runtime.reconcile(["base-subdomain"]); // activate the tunnel; [] returns to standby
runtime.status(); // { state, subdomain, publicUrl, ready, publicKeyPin }
runtime.stop();start()brings up standby only — it allocates/validates the stable subdomain, ensures a certificate (Let's Encrypt via the relay signer, self-signed fallback), caches relay bootstrap credentials, registers the gateway with the control plane, and waits for an authoritative desired-subdomain set. It does not download or spawn frpc until a non-empty set arrives.reconcile(subdomains, backends?)applies the authoritative desired set. A non-empty set installs the pinned frpc binary, starts the edge, writesfrpc.toml, and starts frpc; an empty set kills frpc + edge and returns to standby. When the set is unchanged, only the edge routing table is reconciled (updateBackendsin-process, config rewrite + edge-child restart in external mode) — frpc is never touched.- Pairing info is cached by
start()and returned bypairingInfo()while running;stop()clears it.v: 2,publicUrlis the ROOThttps://<sub>.<subDomainHost>(hosts may append their own path prefix). - The health watchdog probes
https://<baseSub>.<subDomainHost>/friday-next/healthand restarts frpc after repeated failures with exponential backoff (seeTunnelWatchdogPolicy). - The runtime logs only through
host.log; it never calls the voucher port.
Data-dir layout
<dataDir>/
relay-bootstrap.json # cached relay addr/token/subDomainHost/region (0600)
frpc-0.69.1 # pinned, checksum-verified frpc binary (0755)
frpc.version # version marker — a version bump re-installs the binary
frpc.toml # generated frpc config (0600, contains the relay token)
frpc.pid # managed frpc child pidfile
frpc.log # frpc's own log target
gateway-key.pem # shared RSA 2048 gateway keypair (0600, all certs share it)
gateway-fullchain.pem # Let's Encrypt fullchain from the relay signer (when available)
gateway-cert.pem # self-signed fallback leaf
gateway.csr # CSR generated for the relay signer
sub-<sub>.pem # self-signed leaves for additional per-Apple-ID subdomains
subdomain.txt # persisted relay allocation
subdomain.key # sha256(authToken) the allocation was made under
tunnel-edge.json # standalone edge config (external mode, 0600 atomic)
tunnel-edge.pid # managed edge child pidfile (external mode)Pure helpers exported for consumers
isValidSubdomainLabel, normalizedServedSubdomains, shouldClearRecordedFrpcPid,
shouldClearRecordedEdgePid, pluginFrpcPidsFromProcessList, parseControlPlaneBackends,
backendTablesEqual, TunnelHealthTracker, isTunnelHealthyStatus, StandbyLoopGuard,
TunnelWatchdogPolicy, and the frpc download-source constants/helpers.
The OpenClaw channel plugin currently ships its own pre-runtime frpc/standby/edge manager (
openclaw-fridaynext-channel/src/public-access/frpc-manager.ts) and will be migrated to consume this runtime in a follow-up.
Publishing note
openclaw-fridaynext-channel consumes this package from the npm registry. The CLI subpath is
part of the package exports, so the plugin can resolve @syengup/tunnel-edge/dist/cli.js for
external-process mode. Keep the plugin on a published semver range (never a file: reference)
so its tarball does not carry a path that cannot resolve from the npm registry.
