Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/relay/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# ─── Build stage ─────────────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app

RUN corepack enable && corepack prepare pnpm@latest --activate

# Workspace config + lockfile first for layer caching
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
COPY packages/relay/package.json packages/relay/package.json

# Install only the relay workspace (and its deps)
RUN pnpm install --frozen-lockfile --ignore-scripts --filter @axiom-labs/arc-relay...

# Copy source
COPY packages/relay/ packages/relay/

# Build
RUN pnpm --filter @axiom-labs/arc-relay run build

# ─── Runtime stage ───────────────────────────────────────────────
FROM node:22-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production
ENV RELAY_PORT=8765
ENV RELAY_HOST=0.0.0.0

# Copy the built relay + its pruned node_modules
COPY --from=build /app/packages/relay/dist ./dist
COPY --from=build /app/packages/relay/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules

EXPOSE 8765

# Minimal HEALTHCHECK: relay exposes GET /health
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- "http://127.0.0.1:${RELAY_PORT}/health" > /dev/null || exit 1

ENTRYPOINT ["node", "dist/cli.js"]
CMD ["start", "--port", "8765", "--host", "0.0.0.0"]
112 changes: 107 additions & 5 deletions packages/relay/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,110 @@
# @axiom-labs/arc-relay

Self-hosted, end-to-end-encrypted tunnel for remote ARC daemon access.
Self-hosted, zero-knowledge WebSocket multiplexer that routes opaque bytes
between two endpoints sharing a `pairCode`.

**Status:** placeholder. Implementation lands in Phase 10 of the v3 plan
(see `docs/plans/arc-v3-daemon.md`). The relay is a stateless WebSocket
multiplexer that routes opaque NaCl-box-encrypted frames between a daemon
and its paired clients.
The relay has **no crypto awareness** — payloads are forwarded verbatim.
End-to-end encryption (NaCl box) is performed by the ARC daemon and its
clients; the relay never sees plaintext.

---

## Protocol

Endpoint: `ws(s)://<relay-host>/?pair=<code>`

| Behavior | Detail |
| --- | --- |
| Pair registry | In-memory `Map<pairCode, { a, b, createdAt }>`. |
| First connect | Becomes side **A**. |
| Second connect | Becomes side **B**. |
| Third connect | Rejected with HTTP `409 Conflict`. |
| Binary frame | Forwarded verbatim to the peer side. |
| Text frame | Dropped (payloads are ciphertext, not text). |
| Disconnect | Closes the peer with code `1000` and deletes the pair. |
| TTL | Pairs that sit with only one side connected for **5 minutes** are swept; the lone socket is closed with `1001` and the entry removed. |
| Persistence | None. Restarting the process drops every live pair. |
| Logs | None of payload content. |

`GET /health` returns `{"ok": true, "pairs": <count>}` — useful for
container health checks and LB probes.

---

## Run locally

```bash
pnpm install
pnpm --filter @axiom-labs/arc-relay run build
node packages/relay/dist/cli.js start --port 8765
```

Or during development without a build:

```bash
npx tsx packages/relay/src/cli.ts start --port 8765
```

CLI:

```
arc-relay start [--port 8765] [--host 0.0.0.0]
```

---

## Run in Docker

```bash
# Build from the repo root (multi-stage, pruned to the relay workspace)
docker build -f packages/relay/Dockerfile -t arc-relay:local .

# Run
docker run --rm -p 8765:8765 arc-relay:local
```

A ready-to-edit compose example with a TLS-terminating proxy (nginx+certbot
or Caddy) lives in [`docker-compose.yml`](./docker-compose.yml). The relay
itself speaks plain `ws://`; TLS is intentionally delegated to the edge.

---

## Smoke test

With the relay running on `:8765`:

```bash
npx tsx -e "
import WebSocket from 'ws';
const a = new WebSocket('ws://127.0.0.1:8765/?pair=demo');
const b = new WebSocket('ws://127.0.0.1:8765/?pair=demo');
a.on('open', () => a.send(Buffer.from('hi-from-a')));
b.on('message', (d) => { console.log('b got', d.toString()); process.exit(0); });
setTimeout(() => process.exit(1), 3000);
"
```

Expected: `b got hi-from-a`.

---

## What the relay does **not** do

- No user accounts, tokens, or authz beyond the shared `pairCode`.
- No persistent storage — everything is in-memory.
- No TLS — terminate at a proxy or cloud LB.
- No inspection of payloads — they are opaque bytes.

Pair codes are a thin rendezvous mechanism, not a security primitive.
Authentication, integrity, and confidentiality are the endpoints' job
(NaCl box in the ARC daemon/client).

---

## Status

Part of the v3 daemon pivot — see
[`docs/plans/arc-v3-daemon.md`](../../docs/plans/arc-v3-daemon.md) Phase 10.

Actual crypto wiring between daemon ↔ client ↔ relay is tracked separately.
This package's job is routing only.
80 changes: 80 additions & 0 deletions packages/relay/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Example compose for self-hosting arc-relay.
#
# The relay itself speaks plain `ws://` on port 8765 — terminate TLS at an
# edge proxy (nginx, Caddy, or a cloud LB) and forward to the relay.
#
# ┌─────────┐ wss://my-relay.example.com ┌───────────┐ ws://relay:8765 ┌───────┐
# │ clients │ ────────────────────────────▶ │ tls proxy │ ─────────────────▶ │ relay │
# └─────────┘ └───────────┘ └───────┘
#
# Uncomment one of the proxy blocks below (nginx or caddy) and point it at
# a real domain + cert. Both are battle-tested; pick whichever you prefer.

version: "3.9"

services:
relay:
image: ghcr.io/axiom-labs/arc-relay:latest
# Or build locally from this workspace:
# build:
# context: ../../
# dockerfile: packages/relay/Dockerfile
container_name: arc-relay
restart: unless-stopped
environment:
RELAY_PORT: "8765"
RELAY_HOST: "0.0.0.0"
# Expose directly if you terminate TLS elsewhere (cloud LB, existing nginx).
# For a fully self-contained stack, comment the ports block and use the
# proxy service below.
ports:
- "8765:8765"

# ── Option A: nginx reverse proxy with Let's Encrypt via certbot ────────
# Uncomment and edit `server_name` + email.
#
# proxy:
# image: nginx:alpine
# container_name: arc-relay-proxy
# restart: unless-stopped
# depends_on: [relay]
# ports:
# - "443:443"
# - "80:80"
# volumes:
# - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
# - ./certs:/etc/letsencrypt:ro
#
# certbot:
# image: certbot/certbot:latest
# volumes:
# - ./certs:/etc/letsencrypt
# entrypoint: >
# sh -c "certbot certonly --webroot -w /var/www/certbot
# --email you@example.com --agree-tos --no-eff-email
# -d relay.example.com"

# ── Option B: Caddy (automatic HTTPS, zero config) ──────────────────────
# Uncomment and edit `relay.example.com` in Caddyfile below.
#
# proxy:
# image: caddy:alpine
# container_name: arc-relay-proxy
# restart: unless-stopped
# depends_on: [relay]
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./Caddyfile:/etc/caddy/Caddyfile:ro
# - caddy_data:/data
# - caddy_config:/config
#
# Example Caddyfile:
# relay.example.com {
# reverse_proxy /* relay:8765
# }

# volumes:
# caddy_data:
# caddy_config:
19 changes: 17 additions & 2 deletions packages/relay/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,23 @@
"version": "1.0.0-alpha.0",
"private": true,
"type": "module",
"description": "ARC relay — self-hosted, E2E-encrypted tunnel for remote daemon access. Placeholder; implementation lands in Phase 10.",
"description": "ARC relay — self-hosted, stateless WebSocket multiplexer that routes opaque E2E-encrypted frames between paired endpoints.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"arc-relay": "./dist/cli.js"
},
"scripts": {
"typecheck": "echo 'relay stub — no code yet'"
"build": "tsup src/index.ts src/cli.ts --format esm --target node20 --clean --dts",
"dev": "tsx src/cli.ts start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"commander": "^14",
"ws": "^8.18.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/ws": "^8.5.12"
}
}
47 changes: 47 additions & 0 deletions packages/relay/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env node
import { Command } from "commander";
import { startRelay } from "./index.js";

async function main(): Promise<void> {
const program = new Command();
program
.name("arc-relay")
.description(
"ARC relay — stateless WebSocket multiplexer that routes opaque bytes between paired endpoints.",
);

program
.command("start")
.description("Start the relay server")
.option("-p, --port <port>", "TCP port to bind", "8765")
.option("-H, --host <host>", "Host/interface to bind", "0.0.0.0")
.action(async (opts: { port: string; host: string }) => {
const port = Number.parseInt(opts.port, 10);
if (!Number.isFinite(port) || port < 0 || port > 65535) {
process.stderr.write(`invalid port: ${opts.port}\n`);
process.exit(2);
}
const handle = await startRelay({ port, host: opts.host });
process.stdout.write(
`arc-relay listening on ws://${handle.host}:${handle.port} (health at /health)\n`,
);

const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
process.stdout.write(`\narc-relay received ${signal}, shutting down...\n`);
try {
await handle.stop();
} finally {
process.exit(0);
}
};
process.once("SIGINT", () => void shutdown("SIGINT"));
process.once("SIGTERM", () => void shutdown("SIGTERM"));
});

await program.parseAsync(process.argv);
}

main().catch((err) => {
process.stderr.write(`arc-relay: ${(err as Error).message}\n`);
process.exit(1);
});
Loading
Loading