An Ethernet-over-WebSocket relay for emulators that run in a web browser.
Browsers don't let web pages open raw network connections, so emulators like jor1k and v86 can't reach a network on their own. Instead, the emulator's virtual network card sends raw Ethernet frames to this relay over a WebSocket. The relay puts every connected emulator on a shared virtual LAN and connects that LAN to the outside world: it hands out addresses (DHCP), answers DNS, and gives guests internet access through the host (NAT).
In v86's documentation this is a wsproxy backend. It's the software behind
the public relay at wss://relay.widgetry.org/.
- Per-client rate limiting (configurable, or off; see Configuration)
- Optional DNS-maintained destination blocking and destination-wide connection, packet and byte limits (see Abuse mitigation)
- Guests can reach the public internet, but not private addresses such as your local network or the Docker host (see Guest network access)
- Runs in Docker or directly on a Linux host; serve
wss://behind a TLS reverse proxy (see Serving over TLS)
It's quite simple. The program starts off by creating a TAP device and listening for websocket connections on port 80. When clients connect, ethernet frames received via the websocket are switched between connected clients and the TAP device. All communication is done via raw ethernet frames.
To use this in support of a virtual network you must set up the host system as a DHCP server and router. The Docker image does this for you; to do it on a host yourself, see Running on a Linux host.
TLS is not built in. To serve wss://, put the relay behind a reverse proxy;
see Serving over TLS.
This project uses uv for dependency management.
uv syncThe relay requires root privileges (for TAP device creation) and a Linux host. See Running on a Linux host to run it outside Docker.
The easiest way to get up and running is via its public docker image. This image will set up a fully contained router environment using IPTables for basic NAT functionality and dnsmasq for DHCP support.
To set up the relay via docker simply run
docker run --privileged -p 8080:80 --name relay benjamincburns/jor1k-relay:latestIf you'd like to build the image yourself instead:
docker build -t websockproxy .
docker run --privileged -p 8080:80 --name relay websockproxyThen point jor1k, your VPN client, or your emulator of choice at ws://YOUR_HOSTNAME:8080/
Note that the container must be run in privileged mode so that it can create its TAP device and set up IPv4 masquerading.
For a public relay, serve it over TLS from behind a reverse proxy; see Serving over TLS.
This applies both to the Docker image and to host installs using
scripts/setup-network.sh.
By default, guests can reach the public internet, and can use the relay's machine (or container) only for DHCP, DNS and ping. Traffic to non-public addresses is dropped, including the docker host, other containers, your local network, cloud metadata services (169.254.169.254) and CGNAT/VPN ranges. Nothing outside can open connections to guests. Guests on the relay can always reach each other, since the relay switches their frames directly.
WEBSOCKPROXY_EGRESS_INTERFACES and WEBSOCKPROXY_ALLOWED_PRIVATE_NETS
adjust this (see Configuration). For example, to let guests
reach one machine on your LAN:
docker run --privileged -p 8080:80 -e WEBSOCKPROXY_ALLOWED_PRIVATE_NETS=192.168.1.20/32 --name relay websockproxyIn Docker, the container can't see the host's own public addresses, so guests can still reach services the host exposes on them. Use host-side firewall rules to block those. (On a host install, traffic to the host's own addresses is covered by the rules above.)
All abuse mitigation features are disabled by default and can be enabled
independently. They apply to guest traffic routed out of tap0. Each rate
budget is shared by all guests and destination ports for one destination
IPv4 address, across all configured egress interfaces. They do not limit
other programs on the host or the WebSocket transport itself.
For example (choose rates appropriate to your deployment):
docker run --privileged -p 8080:80 --name relay \
-v relay-blocklist:/var/lib/websockproxy \
-e 'WEBSOCKPROXY_BLOCKED_DOMAINS=example.net,*.example.net,api.example.net' \
-e WEBSOCKPROXY_DESTINATION_CONNECT_RATE=30/minute \
-e WEBSOCKPROXY_DESTINATION_CONNECT_BURST=10 \
-e WEBSOCKPROXY_DESTINATION_PACKET_RATE=100/second \
-e WEBSOCKPROXY_DESTINATION_PACKET_BURST=200 \
-e WEBSOCKPROXY_DESTINATION_BYTE_RATE=65536 \
-e WEBSOCKPROXY_DESTINATION_BYTE_BURST=131072 \
websockproxyThe domain list is a list of DNS names to poll, not a URL filter. A
background process resolves every configured name itself using the container's
resolver (or WEBSOCKPROXY_BLOCKLIST_DNS_SERVERS), follows CNAMEs, and collects
all returned A and AAAA addresses. Guests can use their own DNS or connect to
an IP directly; traffic to an address already in the denylist is still blocked
on every port and protocol, including on existing connections. Guest IPv6 is
blocked entirely, as with the normal network setup. Enabling any mitigation
requires a working IPv6 firewall, so it cannot silently bypass the policy.
Wildcard coverage: *.example.net polls that literal DNS wildcard record.
It does not enumerate all names beneath example.net, and the wildcard
need not exist. List known subdomains with their own addresses explicitly,
such as api.example.net. Names behind geographic or rotating DNS may return
addresses this resolver has never seen. Complete zone coverage requires an
inventory from the DNS operator; ordinary DNS polling cannot guarantee it.
Blocking an address also blocks unrelated services sharing that address.
The poller refreshes at the shorter of the DNS TTL and
WEBSOCKPROXY_BLOCKLIST_REFRESH, with a minimum interval of five seconds.
New addresses are added at the next successful refresh. When a successful
lookup stops returning an address, its retirement grace period begins. The
address is removed only on a later successful lookup that still omits it,
after both its previously observed TTL and the grace period have expired,
and only if no other configured name still requires it. Reappearing addresses
reset that grace period. A confirmed NXDOMAIN or NODATA answer can retire
addresses; a timeout or SERVFAIL preserves the previous addresses and retries.
This is an observation policy, not proof that an address has disappeared from
every possible DNS answer.
State is saved atomically to WEBSOCKPROXY_BLOCKLIST_STATE; mount the directory
as shown above to retain it when replacing containers. Removing a name from
the configuration removes its contribution on restart. Corrupt state, failed
firewall updates, or initial resolution failures with no saved result prevent
startup. During temporary DNS failures, saved addresses remain blocked and
warnings appear in the logs. The live IP set is replaced atomically. The
container stops if its poller, dnsmasq, or relay exits.
Connection limits count TCP SYN attempts, including retransmissions before a reply. Packet and byte limits cover all outbound guest IP packets, including UDP and established TCP traffic. Excess packets are dropped, not queued, so connections can slow down or time out. Byte limits use the kernel's accounting granularity. Shared hosting also shares a destination's rate budget. None of these settings count HTTP requests or inspect/decrypt TLS.
Inspect the running policy with:
docker exec relay ipset list wsp_block4
docker exec relay iptables -nvxL GUEST_ABUSE
docker logs relayTo run the relay without Docker you need a Linux host with root access,
uv, a C compiler and kernel headers (to build
python-pytun), and iproute2, iptables and dnsmasq. From a checkout of
this repository:
uv sync --no-dev
# tap0 (10.5.0.1/16), IPv4 forwarding, NAT and the guest firewall
sudo scripts/setup-network.sh
# DHCP and DNS for guests, on tap0 only
sudo dnsmasq -C /dev/null --conf-dir="$PWD/docker-image-config/dnsmasq" \
--pid-file=/run/websockproxy-dnsmasq.pid
# The relay itself (root is needed to open and configure tap0)
sudo env WEBSOCKPROXY_PORT=8080 .venv/bin/websockproxyThen point clients at ws://YOUR_HOSTNAME:8080/, or better, put the relay
behind a TLS reverse proxy (see below) and set WEBSOCKPROXY_HOST=127.0.0.1
so it isn't reachable directly.
What scripts/setup-network.sh changes on the host:
- It enables IPv4 forwarding system-wide.
- It adds iptables rules in their own
GUEST_*chains, which only apply to traffic to or fromtap0, plus NAT for the guest subnet. It doesn't change any chain policies or other rules. - It's safe to re-run. The rules don't persist across reboots, and firewall managers such as firewalld or ufw may remove them when they reload; re-run the script if that happens.
To undo it, stop the relay and dnsmasq, then remove the rules and tap0
(IPv4 forwarding is left enabled):
sudo kill "$(cat /run/websockproxy-dnsmasq.pid)"
sudo scripts/setup-network.sh --removeThe destination rate limits also work on host installs. For DNS blocking,
install ipset and run uv sync --extra abuse. Pass the same
WEBSOCKPROXY_BLOCKED_DOMAINS setting to the network setup and to
.venv/bin/websockproxy-blocklist: run the helper with --once before opening
guest access, then supervise it without --once alongside the relay. The
Docker image handles that lifecycle automatically. Stop the helper before
removing the network rules. --remove retains the helper's saved state and
IP sets; after stopping it and removing the rules, the sets can be deleted
with ipset destroy wsp_block4 and ipset destroy wsp_block4_next.
The relay only speaks plain ws://. For a public relay, put it behind a
reverse proxy that terminates TLS, and don't expose the relay's own port.
Caddy and Traefik
are both designed to be public-facing, proxy websockets without extra
configuration, and get and renew Let's Encrypt
certificates automatically using ACME.
Whichever you use, set WEBSOCKPROXY_TRUSTED_PROXIES to the proxy's address
as the relay sees it, so the relay logs each client's real IP from the
X-Forwarded-For header. The header is ignored from any other peer, since
clients could otherwise forge it.
Both need a DNS record pointing your domain (relay.example.com below) at the
server, and port 443 (plus port 80 for Caddy) open to the internet.
-
Run the relay, listening on localhost only:
docker run -d --privileged -p 127.0.0.1:8080:80 \ -e WEBSOCKPROXY_TRUSTED_PROXIES=172.17.0.1 \ --name relay benjamincburns/jor1k-relay:latestConnections through a published port reach the container from the Docker bridge's gateway,
172.17.0.1on the default bridge. For a host install, useWEBSOCKPROXY_HOST=127.0.0.1,WEBSOCKPROXY_PORT=8080andWEBSOCKPROXY_TRUSTED_PROXIES=127.0.0.1instead. -
Put this in your Caddyfile (
/etc/caddy/Caddyfilefor the packaged service) and reload Caddy:relay.example.com { reverse_proxy 127.0.0.1:8080 }
Caddy gets a certificate for relay.example.com on first use and renews it
automatically. Clients connect to wss://relay.example.com/. See Caddy's
reverse proxy quick-start
and Automatic HTTPS docs for
details.
Traefik suits Docker setups, since it's configured with container labels.
This runs Traefik with a Let's Encrypt certificate resolver using the
TLS-ALPN challenge (port 443 only), and routes relay.example.com to the
relay:
docker network create proxy
docker run -d --name traefik --network proxy -p 443:443 \
-v /var/run/docker.sock:/var/run/docker.sock:ro -v traefik-acme:/acme \
traefik:v3.4 \
--providers.docker=true --providers.docker.exposedbydefault=false \
--entrypoints.websecure.address=:443 \
--certificatesresolvers.myresolver.acme.email=you@example.com \
--certificatesresolvers.myresolver.acme.storage=/acme/acme.json \
--certificatesresolvers.myresolver.acme.tlschallenge=true
docker run -d --privileged --name relay --network proxy \
-e WEBSOCKPROXY_TRUSTED_PROXIES="$(docker network inspect proxy -f '{{(index .IPAM.Config 0).Subnet}}')" \
-l traefik.enable=true \
-l 'traefik.http.routers.relay.rule=Host(`relay.example.com`)' \
-l traefik.http.routers.relay.entrypoints=websecure \
-l traefik.http.routers.relay.tls.certresolver=myresolver \
-l traefik.http.services.relay.loadbalancer.server.port=80 \
benjamincburns/jor1k-relay:latestGiving Traefik the Docker socket gives it control of Docker; see Traefik's Docker provider docs for safer alternatives, and its ACME reference for other challenge types and options.
Unit tests for the relay and the test client run without root or a TAP device:
uv run pytestThe firewall integration tests send real packets in disposable Linux network
namespaces. They are skipped by default and require root, iproute2,
iptables/ip6tables, and ipset:
sudo env WEBSOCKPROXY_RUN_NETWORK_TESTS=1 \
.venv/bin/python -m pytest tests/test_network_integration.pyAn end-to-end test script is included that connects to the relay via WebSocket, obtains a DHCP lease, resolves a hostname with DNS, and sends ICMP pings through the proxy. It uses PEP 723 inline metadata, so uv handles its dependencies automatically:
uv run test_ping.py [ws://host:port] [hostname_or_ip]For example:
uv run test_ping.py ws://localhost:8080 www.google.com
uv run test_ping.py ws://localhost:8080 1.2.3.4If no arguments are provided, it defaults to ws://localhost:8080 and
www.google.com.
Everything is configured with environment variables. With Docker, pass them
with -e NAME=value. On a host, set them for the command that reads them,
e.g. sudo env WEBSOCKPROXY_PORT=8080 .venv/bin/websockproxy. An empty
variable means the default. Invalid values stop the relay (or the network
setup) at startup with an error.
Read by the relay (websockproxy) when it starts.
| Variable | Default | Allowed values | Description |
|---|---|---|---|
WEBSOCKPROXY_HOST |
0.0.0.0 |
An IP address or hostname | Address to listen for websocket connections on. 0.0.0.0 listens on all IPv4 addresses, :: on all IPv6 addresses (IPv6 only), 127.0.0.1 only on localhost (e.g. behind a reverse proxy on the same machine). |
WEBSOCKPROXY_PORT |
80 |
An integer from 1 to 65535 | Port to listen on. The Docker image listens on 80 inside the container; choose the public port with -p. |
WEBSOCKPROXY_RATE_LIMIT |
40980 |
A number ≥ 0 (decimals allowed) | Per-client limit in bytes per second, applied separately to traffic from and to each client. Clients may burst up to one second's worth. 0 disables rate limiting. |
WEBSOCKPROXY_TRUSTED_PROXIES |
(none) | Comma-separated IPv4/IPv6 addresses or CIDR ranges | Reverse proxies whose X-Forwarded-For header is trusted for logging client IPs. The header is ignored from any other peer. See Serving over TLS for values to use. |
Read by scripts/setup-network.sh, which the Docker image runs at startup;
see Guest network access.
| Variable | Default | Allowed values | Description |
|---|---|---|---|
WEBSOCKPROXY_EGRESS_INTERFACES |
The interface(s) carrying the IPv4 default route | Comma- or space-separated names of existing network interfaces, other than tap0 |
Interfaces guest traffic may leave through; NAT is applied on these. Traffic to any other interface is dropped. Setup fails if there's no default route and this isn't set. |
WEBSOCKPROXY_ALLOWED_PRIVATE_NETS |
(none) | Comma- or space-separated IPv4 CIDRs (e.g. 192.168.1.20/32) |
Non-public destinations guests may reach anyway. All other non-public addresses (private ranges, loopback, link-local, CGNAT and so on) are blocked. |
WEBSOCKPROXY_BLOCKED_DOMAINS |
(none) | Comma- or space-separated DNS names; at most 256 | Enables the DNS-maintained IP denylist. Include individual subdomains and, optionally, literal wildcard records. The denylist takes precedence over allowed private networks. See Abuse mitigation. |
WEBSOCKPROXY_DESTINATION_CONNECT_RATE |
0 (disabled) |
0, N/second, or N/minute |
Maximum sustained TCP connection-attempt rate per destination IP. N is a positive integer of at most seven digits. |
WEBSOCKPROXY_DESTINATION_CONNECT_BURST |
10 |
Integer 1–1000000 | Initial and maximum connection-attempt bucket capacity. Ignored when its rate is disabled. |
WEBSOCKPROXY_DESTINATION_PACKET_RATE |
0 (disabled) |
0, N/second, or N/minute |
Maximum sustained packet rate per destination IP, across all IP protocols. |
WEBSOCKPROXY_DESTINATION_PACKET_BURST |
100 |
Integer 1–1000000 | Initial and maximum packet bucket capacity. Ignored when its rate is disabled. |
WEBSOCKPROXY_DESTINATION_BYTE_RATE |
0 (disabled) |
0 or integer 1024–1000000000 |
Maximum sustained bytes per second per destination IP, across all IP protocols. |
WEBSOCKPROXY_DESTINATION_BYTE_BURST |
65536 |
Integer 1500–1000000000 | Byte burst allowance. Ignored when its rate is disabled. |
Some rate/burst combinations exceed kernel limits and are rejected at startup. Unused destination buckets expire only after enough idle time to refill them. Each rate limiter tracks at most 65536 destinations.
Read by websockproxy-blocklist, which Docker starts automatically when
WEBSOCKPROXY_BLOCKED_DOMAINS is nonempty. These settings do not change the
DNS servers advertised to guests.
| Variable | Default | Allowed values | Description |
|---|---|---|---|
WEBSOCKPROXY_BLOCKLIST_REFRESH |
300 |
Integer 5–86400 | Maximum seconds between successful refreshes; shorter TTLs trigger earlier lookups. Failed lookups retry within 30 seconds. |
WEBSOCKPROXY_BLOCKLIST_RETIRE_GRACE |
3600 |
Integer 0–604800 | Seconds an address must remain absent from successful lookups before retirement; its last observed TTL must also have expired. |
WEBSOCKPROXY_BLOCKLIST_STATE |
/var/lib/websockproxy/blocklist.json |
File path | Persistent address history. Only one poller may use this state file at a time. |
WEBSOCKPROXY_BLOCKLIST_DNS_SERVERS |
System resolver | Comma- or space-separated resolver IP addresses | Resolvers used by the poller, independently of guest DNS. |
These aren't configurable with environment variables:
- Guest network: TAP device
tap0, gateway10.5.0.1/16, MTU 1500. These are set inscripts/setup-network.sh,src/websockproxy/switchedrelay.pyanddocker-image-config/dnsmasq/interface. - DHCP and DNS for guests: addresses
10.5.0.2–10.5.254.254with 15-minute leases; guests are told to use10.5.0.1,8.8.8.8and8.8.4.4for DNS. Editdocker-image-config/dnsmasq/dhcpto change these, keeping the range inside10.5.0.0/16(and rebuild the image if you use Docker). - Relay internals (constants in
src/websockproxy/switchedrelay.py): keepalive pings every 30 seconds, with clients that don't answer within 30 seconds disconnected; at most 128 queued frames per client, beyond which frames are dropped.