@omni-work/relay-server
v0.1.1
Published
WebSocket relay server for OmniWork applications and desktop agents.
Maintainers
Readme
OmniWork Relay Server
Minimal company-network relay for the native OmniWork App and Desktop Agent.
The server does not store the temporary key. It brokers the challenge flow:
- Desktop Agent proves its device identity with
agent.auth.init,agent.auth.challenge, and a signedagent.hello. - App sends
mobile.connectfor a Desktop Agentdevice_id. - Relay sends
auth.challengeto the App. - App sends
auth.proof; Relay forwardsauth.verifyto the Desktop Agent. - Desktop Agent verifies the proof with the local startup key and returns
auth.okorauth.failed.
Install and run with Node.js 22.6 or newer:
npm install --global @omni-work/relay-server
omniwork-relay --config /path/to/config.ymlRun from the repository:
pnpm --filter @omni-work/relay-server devSmoke-check the configuration without binding the port:
pnpm verify:relayConfiguration
Relay reads config.yml in this order:
1. Explicit path from --config / -c
2. config.yml in the current working directory
3. config.yml next to the running relay server program
4. config.yml in the relay/server package root
5. System global config:
- macOS: ~/Library/Application Support/OmniWork/relay/config.yml
- Linux: ${XDG_CONFIG_HOME:-~/.config}/omniwork/relay/config.yml
- Windows: %APPDATA%/OmniWork/relay/config.ymlThe config is intentionally sparse: omitted fields use safe local defaults.
Use pnpm relay:start -- --config /path/to/config.yml for a one-off explicit
config path. See config.example.yml for a fully annotated template. A minimal local config:
server:
host: 127.0.0.1
port: 8787
admin:
host: 127.0.0.1
port: 8788
paths:
runtimeDir: .omniwork-relay
auth:
mode: noneLegacy OMNIWORK_* environment variables remain supported as fallbacks when the
same value is not present in config.yml.
Plaintext WS and E2E
The server treats ws:// and wss:// as transport only. Business security is
declared per Agent: the same relay process can carry e2e_required Agents whose
business traffic is inside e2e.message, and plaintext_allowed Agents started
with OMNIWORK_AGENT_REQUIRE_E2E=false.
Loopback hosts allow plaintext ws:// by default for local development. Any
non-loopback host must explicitly set OMNIWORK_RELAY_ALLOW_PLAINTEXT_WS=true.
OMNIWORK_RELAY_REQUIRE_E2E is retained only as a legacy compatibility setting;
business encryption is no longer enforced globally by the relay. wss:// is
still recommended to reduce network metadata exposure, but it is not the
business security boundary.
Optional user registration
User registration is disabled by default with OMNIWORK_RELAY_AUTH_MODE=none.
In this mode Relay keeps the current behavior and does not require mail
configuration.
Set OMNIWORK_RELAY_AUTH_MODE=email_link to enable email-link login and
device ownership checks. Startup then requires OMNIWORK_PUBLIC_BASE_URL and
OMNIWORK_MAIL_FROM; non-loopback hosts must use an HTTPS public base URL and
cannot use the console mail provider. OMNIWORK_MAIL_PROVIDER=smtp also
requires SMTP host, port, user and password. A personal Gmail account with an
App Password can be used for low-volume deployments.
Users register and manage device enrollment from the Relay website:
https://relay.example.com/auth/The page sends the email magic link, stores the login cookie after verification, lists enrolled devices, revokes devices, and creates a short-lived device token. The Desktop Agent can consume that token:
omniwork-agent enroll \
--relay-url wss://relay.example.com/relay/ws/agent \
--token <device-enrollment-token>The enrollment command generates an Ed25519 key pair, registers the public key
with Relay, and stores the local private key plus Relay-owned device_id in
<OMNIWORK_APP_SUPPORT_DIR>/relay-device.json.
Auth data is stored in OMNIWORK_RELAY_AUTH_DB_PATH (default
<OMNIWORK_RELAY_RUNTIME_DIR>/relay-auth.sqlite). Public endpoints:
POST /auth/email/start— body{ "email": "[email protected]" }; sends a magic login link and always returns202for rate-limited valid requests.GET /auth/email/verify?token=...— consumes the one-time link, creates the user if needed, and returns a session token while also setting a cookie.GET /auth/me,POST /auth/logout— session inspection and logout.POST /auth/devices/enrollments— authenticated user creates a short-lived device enrollment token.POST /auth/devices— Agent/client exchanges an enrollment token and Ed25519 public key for a Relay-owneddevice_id.GET /auth/devices,POST /auth/devices/:device_id/revoke— list and revoke user devices.
Cookie-authenticated state-changing requests must include x-csrf-token from
GET /auth/me. Bearer-token API calls are not subject to this CSRF check.
When enabled, Agent device auth uses two signatures. agent.auth.init carries
device_id, registered device_public_key, timestamp, and a signature over
those fields so Relay can reject spoofed challenge requests early. Relay then
returns an opaque stateless agent.auth.challenge string. The final
agent.hello.relay_auth signs device_id|challenge|timestamp, verified against
the registered device public key. The challenge is not stored; it carries an
HMAC-protected expiry and connection binding, and defaults to a 60s TTL via
auth.agentAuthChallengeTtlMs. Init/proof timestamps use the separate
auth.agentAuthClockSkewMs window, also defaulting to 60s. mobile.connect
must include a user session_token, and Relay only allows access when the
session user owns the target device.
Relay 校验顺序:
agent.auth.init只能从未鉴权连接进入,失败按agent|device_id|public_remote_ip和agent_ip|public_remote_ip两层限流;成功发出 challenge 也会消耗公网 IP-only 桶,最终agent.hello成功后只重置agent|device_id|public_remote_ip。public_remote_ip只来自 Relay 连接层观测;内网、loopback、链路本地和保留地址不进入 Agent 准入限流。device_id已登记且未撤销。agent.auth.init.device_public_key与登记公钥规范化后匹配。agent.auth.init.timestamp在允许窗口内,init 签名有效。- 无状态
agent.auth.challenge的 HMAC、过期时间和 connection 绑定有效。 agent.hello.relay_auth.timestamp在允许窗口内,proof 签名有效。agent.hello只允许从pending进入verified;重复agent.hello被忽略并记录agent.hello.ignored审计日志。- 校验通过后分配
agent_connection_id,并执行同一device_id单 Agent 在线策略。
auth.proof rate limiting
auth.proof failures are rate limited per (device_id, remote_ip) with a
token bucket:
OMNIWORK_RELAY_AUTH_RATE_CAPACITY(default5): bucket capacity, i.e. the maximum number of failed attempts allowed before the bucket is drained.OMNIWORK_RELAY_AUTH_RATE_REFILL_PER_SEC(default2): tokens refilled per second once the bucket is no longer blocked.OMNIWORK_RELAY_AUTH_RATE_BLOCK_MS(default120000): cool-down window in milliseconds after the bucket drains; further attempts are rejected during this window. After the window elapses the bucket is fully refilled.
When the limiter rejects a request, the relay responds with auth.failed and
reason too_many_attempts. Only failed auth.proof consume a token (either a
malformed proof rejected at the relay, or auth.failed returned by the agent
after key verification); legitimate auth.proof that lead to auth.ok do not
consume the bucket, so frequent reconnects and transport-preference switches
are not throttled. A successful auth.ok also resets the bucket so subsequent
attempts are not affected by past failures.
WebSocket keepalive
Relay actively probes every Agent/App WebSocket with ping frames to avoid stale RuntimeTopology entries when a reverse proxy or load balancer silently drops an idle connection:
OMNIWORK_RELAY_WS_KEEPALIVE_INTERVAL_MS(default3300000): ping interval. This is 55 minutes, intended to sit below a 1 hour Nginxproxy_read_timeout.OMNIWORK_RELAY_WS_PONG_TIMEOUT_MS(default30000): how long Relay waits for pong before closing the socket and unregistering the connection.
If Nginx fronts Relay, keep proxy_read_timeout above the ping interval. The
deployment example uses 3600s.
Agent shutdown close code
Relay uses WebSocket close code 4404 only when it intentionally asks the
Desktop Agent service to stop and exit its process. Reason agent_disabled
means an operator disabled the active Agent instance; reason ip_banned means
an operator banned the Agent's source IP. Ordinary disconnects, keepalive
timeouts, and generic policy rejections use other close codes and must not stop
the Agent process.
IP bans are enforced through the RelayAuthGuard policy chain before the
connection enters App/Agent business routing. Banned Mobile/App upgrades are
rejected with 403 ip_banned; banned Agent upgrades complete the WebSocket only
long enough to deliver 4404 / ip_banned, so the Agent can exit intentionally.
RelayAuthGuard is the Relay-local auth orchestrator, not the rule container.
Stable policy modules own the concrete checks: IP bans, Agent instance disable,
email_link Agent device lookup/revoke, init/proof device signatures and
stateless challenge checks, plus Mobile user session and device ownership
checks. Admission modules consume the guard decision and then only advance
connection state or create the App pairing challenge.
P2P upgrade orchestrator
The relay coordinates optional WebRTC DataChannel upgrades between the App and Desktop Agent. Configuration:
OMNIWORK_UPGRADE_ENABLED=true
OMNIWORK_UPGRADE_ROLLOUT=100
OMNIWORK_UPGRADE_DEVICE_BLOCKLIST=
OMNIWORK_UPGRADE_ICE_SERVERS_JSON=[{"urls":"stun:stun.l.google.com:19302"}]
OMNIWORK_UPGRADE_PROPOSE_DELAY_MS=3000
OMNIWORK_UPGRADE_RESPECT_CLIENT_PREF=trueOMNIWORK_UPGRADE_ENABLED(true/false, defaulttrue): global kill switch.OMNIWORK_UPGRADE_ROLLOUT(0..100, default100): percent rollout, hashed bysha1(device_id).OMNIWORK_UPGRADE_DEVICE_BLOCKLIST: comma-separated device IDs that must never upgrade.OMNIWORK_UPGRADE_ICE_SERVERS_JSON: JSON array of{ urls, username?, credential? }sent to clients intunnel.upgrade.propose.OMNIWORK_UPGRADE_PROPOSE_DELAY_MS(default3000): stable window between mobile auth success and the propose.OMNIWORK_UPGRADE_RESPECT_CLIENT_PREF(true/false, defaulttrue): honour the App'smobile.connect.transport_preferencefield. Set tofalseto force every connection to be treated asauto(and never propagatestrict: trueon propose). Seedocs/relay-architecture.md §6.1.
When the App connects with transport_preference=prefer_p2p, the relay sets
strict: true on the tunnel.upgrade.propose payload sent to both peers.
Strict P2P clients only allow control-plane traffic on the relay path; any
upgrade negotiation or runtime failure (timeout, peer_unavailable,
ice_failed, pong_timeout, etc.) closes the session instead of falling
back to relay. The relay still records the failure under failed[reason] and
applies the same backoff policy as auto.
Operational endpoints:
GET /metrics— JSON snapshot withrelaycontrol-plane counters andupgradeorchestrator counters.relayincludes runtime uptime, device / Agent / App / link / connection totals, traffic bytes/messages, auth failures, routing drops, and protocol errors sent.upgradeincludesproposed,committed,failed[reason],downgrade[reason],prefs[preference],skipped_by_pref,in_flight,active_p2p, anddurations(p50/p95/max over the last 100 successful upgrades).- Business listener (
OMNIWORK_RELAY_HOST/OMNIWORK_RELAY_PORT):GET /healthz,GET /readyz,GET /metrics,POST /debug/upgrade, andGET /relay/ws/*WebSocket upgrades. POST /debug/upgrade?device_id=<id>&app_connection_id=<connection_id>— manually triggers an upgrade for one E2E-ready App connection under a paired device; included in metrics and logs.- Admin listener (
OMNIWORK_RELAY_ADMIN_HOST/OMNIWORK_RELAY_ADMIN_PORT): all/admin/api/*routes and, in development mode,/admin/web. GET /admin/web— development-only Relay admin web page for viewing online Agents and Apps. Requires HTTPS and a valid admin session.GET /admin/api/status— Relay admin status summary with active device / Agent / App / link / connection totals, persisted known/offline device counts, and traffic counters.GET /admin/api/devices?include_offline=true&limit=100— Relay-visible device summary. Active devices come from in-memory runtime state; offline devices come from the persisted device-status summary and contain only minimal metadata.GET /admin/api/agents— online Agent list with current App counts.GET /admin/api/agent-connections/:connection_id/apps— Relay-visible App connections under one online Agent connection.GET /admin/api/links— current Relay-visible Agent/App links, including E2E and transport path state.GET /admin/api/traffic— highest-traffic online Agent/App connections.GET /admin/api/traffic-map— map-ready location and flow aggregates for the Admin traffic board. Nodes are aggregated location buckets, not individual Agent/App connections. Flow edges are aggregated byfrom_location_id -> to_location_id, with link/device counts and transport-path distribution. Node area represents active connection count; directional bytes are counted from Relay ingress so App-to-Agent and Agent-to-App traffic are not double-counted. Relay resolves public IPs with the bundled local GeoIP database and falls back to private/reserved/unknown buckets when no location is available.GET /admin/api/controls— active disabled-Agent and IP-ban rules.POST /admin/api/login— consumes the current one-time admin token and sets a secure 30-minute session cookie.POST /admin/api/logout— clears the current admin session. Requires a valid admin session.GET /admin/api/me— reports the current admin session state. Requires a valid admin session.POST /admin/api/controls/agent-devices/device-op— disable Agent devices or delete disable rules. Body:{ "action": "disable", "agent_device_ids": ["..."], "reason": "..." }or{ "action": "delete", "agent_device_ids": ["..."] }. Disable rules are temporary and default toOMNIWORK_RELAY_AGENT_DEVICE_DISABLE_DEFAULT_MS(1 day). Add"permanent": trueor"duration": "permanent"to persist them in SQLite. Requires a valid admin session.POST /admin/api/controls/ip-bans— ban or unban IPs. Body:{ "action": "ban", "ips": ["..."], "reason": "..." }or{ "action": "unban", "ips": ["..."] }. Default ban duration isOMNIWORK_RELAY_IP_BAN_DEFAULT_MS(1 day). Add"permanent": trueor"duration": "permanent"to persist them in SQLite. Requires a valid admin session.
Relay Admin requires HTTPS by default. When the server is behind a trusted TLS
terminating proxy, set OMNIWORK_RELAY_ADMIN_TRUST_PROXY=true and include the
proxy IPs in OMNIWORK_RELAY_ADMIN_TRUSTED_PROXY_IPS; only those proxy
connections may assert X-Forwarded-Proto: https or X-Forwarded-For. The
fronting Nginx config must overwrite X-Forwarded-For with $remote_addr
rather than appending $proxy_add_x_forwarded_for, so client-supplied forwarded
chains cannot affect GeoIP, IP-ban, or auth rate-limit attribution.
Relay Admin API is provided only by the separate admin listener under
/admin/api/...; the business listener intentionally returns 404 for admin
routes. The Node-served admin web page under /admin/web is a development
convenience and is disabled by default with
OMNIWORK_RELAY_ADMIN_WEB_ENABLED=false. Use pnpm dev:relay or set
OMNIWORK_RELAY_ADMIN_WEB_ENABLED=true explicitly when you want the relay
process to serve Admin Web on the admin listener. When enabled, startup logs
include an admin.web.ready record with the local /admin/web access URL.
On startup the server writes runtime artifacts under
OMNIWORK_RELAY_RUNTIME_DIR (default .omniwork-relay in the current working
directory). The 64-character one-time admin token is written to
admin-token.json in that directory by default, and the initial startup token
is also emitted once in the admin.token.ready structured log record for
operator convenience. Set
OMNIWORK_RELAY_ADMIN_TOKEN_DIR to write the token file elsewhere. The token
directory uses mode 0700 when the server creates it, and the token file uses
mode 0600. The token rotates every
OMNIWORK_RELAY_ADMIN_TOKEN_ROTATE_MS (default 1 hour). A successful login
immediately consumes the token, rotates a new one, and creates a secure
HttpOnly; Secure; SameSite=Strict session cookie that expires after
OMNIWORK_RELAY_ADMIN_SESSION_TTL_MS (default 30 minutes).
Permanent Agent disable and IP-ban rules are stored in
OMNIWORK_RELAY_ADMIN_CONTROLS_DB_PATH (default
<OMNIWORK_RELAY_RUNTIME_DIR>/admin-controls.sqlite) and reloaded on startup.
Temporary rules with
ttl_ms, expires_in_ms, expires_at, or the default TTL stay in memory only.
Relay active connection state is memory-only: closed Agent/App/link records are
removed from runtime maps immediately. Device-level minimal status is persisted
to OMNIWORK_RELAY_DEVICE_STATUS_DB_PATH (default
<OMNIWORK_RELAY_RUNTIME_DIR>/relay-device-status.sqlite) so Admin can show
recent offline devices without retaining connection objects. The persisted
record stores only device_id, status, first/last seen timestamps, offline
timestamp, last Agent/App remote IPs, last Agent instance ID, close role/reason,
and device-level byte/message counters. It does not store connection history,
link history, App info, session data, E2E details, or business payload.
Offline device summaries are pruned after
OMNIWORK_RELAY_DEVICE_STATUS_RETENTION_MS (default 7 days). Device counters
flush every OMNIWORK_RELAY_DEVICE_STATUS_FLUSH_INTERVAL_MS (default 5s);
expired pending auth entries and Relay app delivery contexts are swept by
OMNIWORK_RELAY_STATE_SWEEP_INTERVAL_MS (default 30s).
The admin web source lives in relay/server/admin-web. Production deployments
should serve that source through the web build output and Nginx at /admin/,
while relay development mode may read the same source and inject /admin/web
as the local base path. Production keeps /admin/login.html as the static
login route; relay dev uses /admin/web for both the page and login fallback.
Keep UI HTML/CSS/JS out of src/relayServer.ts. The traffic board world map
uses admin-web/world-land-110m.geojson, derived from Natural Earth 110m land
data, as a local static asset rather than a runtime CDN dependency.
Admin HTTP routing, auth checks, snapshots, and control-rule mutations live in
src/relayAdminController.ts; keep src/relayServer.ts focused on Relay
connections and protocol routing.
E2E handshake, ready-state validation, and encrypted message routing live in
src/relayE2EController.ts. Business payload encryption policy is owned by App
and Agent. Relay logging helpers live in src/relayLog.ts.
When Agent needs Relay to return an App-scoped protocol error, it sends
relay.app.deliver with the Relay-issued relay_context_id plus the
protocol.error content. Relay resolves the target App from its own delivery
context, binds the handle to the Agent connection that received the original
request, and rejects content that tries to carry an App target.
Full architecture, downgrade triggers, and a troubleshooting runbook live in docs/relay-architecture.md.
