| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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/.
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:
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.1 on the default bridge. For a host install, use WEBSOCKPROXY_HOST=127.0.0.1, WEBSOCKPROXY_PORT=8080 and WEBSOCKPROXY_TRUSTED_PROXIES=127.0.0.1 instead.
Put this in your Caddyfile (/etc/caddy/Caddyfile for 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:
| Back | FazBrowse Home | New Git URL |