OpenClaw on another machine¶
The reference deployment runs OpenClaw and Smoking Pi on the same Raspberry
Pi, and everything in openclaw-integration.md
assumes that: the MCP server listens on 127.0.0.1:8090, the OpenClaw
gateway on 127.0.0.1:18789, and neither is reachable from anywhere else,
which is the whole security model. This page is for the other shape — the
Pi does the measuring, OpenClaw lives somewhere else (a desktop, a VPS, a
second Pi) — and shows how to connect the two without giving up that
model.
The rule every option here follows: nothing new listens on a public address. Both services stay bound to loopback on their own host; a tunnel or a private network carries the traffic between the two loopbacks. The options differ in what carries it and who else can see it, and are listed from least exposed to most.
What actually crosses the wire¶
Two flows, in opposite directions. Both are needed for the full experience; the first alone gives you "ask about the network", the second alone gives you "be told about the network".
| Direction | Client | Server | Port | Authentication |
|---|---|---|---|---|
| OpenClaw → Smoking Pi | the gateway's MCP client | mcp-server (POST /mcp) |
8090 | Authorization: Bearer $MCP_API_TOKEN |
| Smoking Pi → OpenClaw | alerter (incidents, digest) and mcp-server (get_chart(deliver=true)) |
the gateway (POST /tools/invoke) |
18789 | Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN |
Both are plain HTTP with a bearer token, so both can ride any TCP tunnel
unchanged. Both Pi-side services run with network_mode: host, so "the Pi's
loopback" means the host's 127.0.0.1, which is exactly where an SSH -R
or a cloudflared access listener lands.
Not part of this: the deep links in chat answers (PUBLIC_BASE_HOST and
friends in .env.template). Those are for the person reading the chat and
have to be an address that person can open; they are independent of how the
two machines talk to each other.
Option A — SSH tunnel (recommended)¶
One SSH session carries both directions. Nothing is installed, nothing new listens, and — the part that makes this the default recommendation — both sides end up configured exactly as if they were on the same machine, so every command in the co-located guide works verbatim.
Run this on the OpenClaw host (it dials the Pi):
ssh -N \
-L 8090:127.0.0.1:8090 \
-R 18789:127.0.0.1:18789 \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes \
smokingpi@smoking-pi
-L 8090:…— the OpenClaw host's127.0.0.1:8090is now the Pi's MCP server. Register it withopenclaw mcp set smokeping '{"url": "http://127.0.0.1:8090/mcp", …}'as in the co-located guide.-R 18789:…— the Pi's127.0.0.1:18789is now the OpenClaw gateway.OPENCLAW_URLon the Pi keeps its default ofhttp://127.0.0.1:18789; onlyOPENCLAW_GATEWAY_TOKENandOPENCLAW_TOneed to be set, as always. sshd's defaultGatewayPorts nokeeps that reverse listener on loopback; do not turn it on.-N— no shell, just the forwards.
If the Pi can reach the OpenClaw host but not the other way round¶
Typical when OpenClaw is on a VPS and the Pi is behind a home NAT. Same idea, mirrored, run on the Pi:
ssh -N \
-R 8090:127.0.0.1:8090 \
-L 18789:127.0.0.1:18789 \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes \
openclaw@openclaw-host
Either way, only one side needs an SSH port reachable from the other. If that reachability would mean an SSH port on the public internet, read Which one first — SSH on a public IP is the one public listener in this option, and a VPN (Option B) removes it.
Lock the key down¶
Give the tunnel its own key and let it do nothing but these two forwards.
On the side being dialed into, in ~/.ssh/authorized_keys:
restrict,port-forwarding,permitopen="127.0.0.1:8090",permitlisten="localhost:18789" ssh-ed25519 AAAA… openclaw-tunnel
restrict turns off everything (pty, agent/X11 forwarding, commands),
port-forwarding turns forwarding back on, and the two permit* entries
limit it to exactly the ports above. Swap the two port numbers when the Pi
is the dialing side. A dedicated user with /usr/sbin/nologin as its shell
is the next step if you want one.
The two entries are spelled differently on purpose. permitopen is
matched against the destination you typed in -L — 127.0.0.1:8090,
literally. permitlisten is matched against the listen address the client
sends, and for an -R with no bind address ssh sends the name
localhost, which sshd treats as distinct from 127.0.0.1 and ::1
(authorized_keys(5)). Write permitlisten="127.0.0.1:18789" and sshd
refuses the very forward this line exists to allow, with nothing in the
client's output to say why except ExitOnForwardFailure.
Keep it up¶
A tunnel that dies with a laptop lid is a monitor that silently stops
alerting. examples/openclaw/smoking-pi-tunnel.service is a systemd unit
that restarts it forever; install it on the dialing host as a user service:
mkdir -p ~/.config/systemd/user
cp examples/openclaw/smoking-pi-tunnel.service ~/.config/systemd/user/
systemctl --user edit smoking-pi-tunnel # set the host and, if needed, the key path
systemctl --user enable --now smoking-pi-tunnel
loginctl enable-linger "$USER" # keep user services running with no session open
autossh is the traditional alternative; the unit's Restart=always plus
ServerAliveInterval does the same job with one fewer package.
Verify¶
# On the OpenClaw host: MCP reachable through the tunnel, and it demands the token
curl -s -o /dev/null -w '%{http_code}\n' -XPOST http://127.0.0.1:8090/mcp # 401
openclaw mcp probe smokeping # lists the tools
# On the Pi: the gateway reachable through the tunnel
cd editions/pro && docker compose logs alerter | grep -i preflight # "Delivery preflight: … reachable, 'message' tool permitted" or the specific reason
get_chart(target, deliver=true) from the chat is the end-to-end check: it
uses both directions.
Option B — a private network (Tailscale or WireGuard)¶
Put both machines on a mesh VPN and neither has to accept a connection from the internet at all. Tailscale (WireGuard underneath) needs no open port on either side — connections are brokered and, failing NAT traversal, relayed; plain WireGuard needs one UDP port open on one side, but a WireGuard listener does not answer packets that do not carry a valid key, so there is nothing for a scanner to find and nothing for a flood to exhaust but bandwidth.
Once the two are on the same tailnet, the simplest thing is Option A over it — SSH to the tailnet address — and you are done, with no port exposed even inside the tailnet. The two variants below skip SSH:
B1 — keep loopback, let Tailscale front it (Serve). On the Pi:
tailscale serve --bg --https=8443 8090
The MCP server is now https://<pi-magicdns>:8443/mcp, with a certificate
Tailscale issues and manages, reachable only from the tailnet; the process
itself still listens on 127.0.0.1. Register that URL with OpenClaw. On the
OpenClaw host, set gateway.tailscale.mode: "serve" in openclaw.json
(OpenClaw's own Tailscale page
covers it) and point the Pi at it: OPENCLAW_URL=https://<openclaw-magicdns>.
B2 — bind to the tailnet address directly. No Serve, no TLS, one fewer
moving part; the price is that the services now listen on an address other
than loopback, and only the tailnet's ACL keeps that private. On the Pi, in
editions/pro/.env:
MCP_HOST=100.x.y.z # the Pi's own tailnet IPv4 (`tailscale ip -4`), NEVER 0.0.0.0
and docker compose up -d mcp-server. On the OpenClaw host,
gateway.bind: "tailnet" with token auth, and on the Pi
OPENCLAW_URL=http://<openclaw-tailnet-ip>:18789.
Either way, write a tailnet ACL that lets exactly these two nodes talk on exactly these ports, and nothing else; a tailnet that lets every device reach every port is a LAN with extra steps.
A trap specific to the Pi. Docker copies the host's /etc/resolv.conf
into a container once, when the container is created. Tailscale's
MagicDNS puts 100.100.100.100 in that file while it is up. If the
SmokePing container is created while Tailscale is logged in and Tailscale is
later logged out, every hostname target goes to 100 % loss and every raw-IP
target stays fine — for as long as nobody notices. The reference Pi lost
nine of eighteen targets for ten days this way. Tell: hostname targets dead,
IP targets healthy. Fix: docker compose up -d --force-recreate --no-deps
smokeping. Or keep Tailscale's DNS out of the host resolver
(tailscale up --accept-dns=false) so the container never inherits it.
Option C — Cloudflare Tunnel with an Access service token¶
For when neither machine can reach the other and you would rather not run a VPN: both sides make only outbound connections to Cloudflare, and Cloudflare's edge does the listening. A flood hits Cloudflare, not you, and a request without a valid service token is rejected at the edge and never reaches the Pi.
What you accept in exchange: a public hostname exists for each service, and a third party terminates TLS — Cloudflare sees the traffic, which here means your latency figures, target names and alert text. For many people that is fine; it should be a decision, not a surprise.
Pi → public hostname for the MCP server. The repository's
permanent tunnel guide sets up
cloudflared and a named tunnel for Grafana and web-admin; add an ingress
rule for the MCP server to the same tunnel:
ingress:
- hostname: mcp.example.com
service: http://127.0.0.1:8090
# …existing rules…
- service: http_status:404
In Zero Trust, create a self-hosted Access application for
mcp.example.com, a service token, and a policy of type Service Auth
that allows it. OpenClaw sends the token as two headers next to the bearer:
openclaw mcp set smokeping '{
"url": "https://mcp.example.com/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer <your-mcp-token>",
"CF-Access-Client-Id": "<service-token-id>",
"CF-Access-Client-Secret": "<service-token-secret>"
},
"connectionTimeoutMs": 10000,
"requestTimeoutMs": 30000
}'
Keep MCP_API_TOKEN set even though Access is in front: two independent
checks, one of which you control entirely.
OpenClaw host → public hostname for the gateway, dialed from the Pi. The
alerter sends only the bearer header, so it cannot present a service token
itself. Instead, run cloudflared on the Pi in client mode, which
presents the token and exposes the far gateway on the Pi's loopback — where
OPENCLAW_URL already points:
# On the OpenClaw host: tunnel ingress for the gateway, as a raw TCP service
# - hostname: openclaw-gw.example.com
# service: tcp://127.0.0.1:18789
# plus an Access application + service-token policy for that hostname.
# On the Pi:
TUNNEL_SERVICE_TOKEN_ID=<id> TUNNEL_SERVICE_TOKEN_SECRET=<secret> \
cloudflared access tcp --hostname openclaw-gw.example.com --url 127.0.0.1:18789
That client process needs the same keep-alive treatment as the SSH tunnel;
a user unit for it is the SSH one with a different ExecStart:
[Service]
Environment=TUNNEL_SERVICE_TOKEN_ID=<id>
Environment=TUNNEL_SERVICE_TOKEN_SECRET=<secret>
ExecStart=/usr/local/bin/cloudflared access tcp --hostname openclaw-gw.example.com --url 127.0.0.1:18789
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
(Put the token in the unit's drop-in via systemctl --user edit, not in a
file you might commit.)
OpenClaw's own Cloudflare Access page
describes the same arrangement from its side and, importantly, says to keep
gateway.bind: "loopback" while doing it.
What not to do¶
- Forward 8090 or 18789 on your router. A bearer token is one secret between the internet and a server that can add targets and restart SmokePing; it was designed to guard a loopback port from other local processes, not a public one from the world.
MCP_HOST=0.0.0.0, orgateway.bind: "lan"on a machine you do not fully trust the LAN of.MCP_HOSTexists for B2 (a tailnet address) and nothing else.- Tailscale Funnel or a Cloudflare tunnel with no Access policy for either service. That is a public port with a nicer name.
- A reverse proxy with basic auth on a public IP. It works until the day it is the thing being brute-forced, and it puts the proxy's vulnerabilities in front of yours.
Which one¶
| A · SSH tunnel | B · Tailscale / WireGuard | C · Cloudflare Access | |
|---|---|---|---|
| Public listener | SSH on one side, only if that side is on the internet; none on a LAN or VPN | None (Tailscale) / one silent UDP port (WireGuard) | None on your machines; hostnames at Cloudflare's edge |
| Exposure to floods | The SSH port, if public | Bandwidth only | Absorbed by Cloudflare |
| Third party in the path | None | Tailscale's control plane (coordination, not traffic; DERP relays see only encrypted traffic) | Cloudflare terminates TLS and sees the traffic |
| Extra software | None | Tailscale/WireGuard on both | cloudflared on both, a Cloudflare account and zone |
| Config on the Pi | None beyond the co-located guide | MCP_HOST (B2 only) or tailscale serve (B1) |
An ingress rule and a client-mode cloudflared |
| Fits | Same LAN; anything already behind a VPN; a VPS you can SSH to | Two homes, laptop that roams, no port-forwarding allowed | Neither side can reach the other and you do not want a VPN |
Pick A when the two machines can already reach each other by any private
route. Pick B when they cannot, and A over B once they can. Pick C when you
have ruled out a VPN and are comfortable with Cloudflare in the path. In
every case the co-located guide's checks still apply afterwards: the
401 without a token, openclaw mcp probe, the alerter's preflight line,
and a delivered chart.