Remote access
Connect to your gateway from another device — phone, laptop, or a remote server — without exposing more than you need.
Settings UI: Gateway console → Settings → Remote access (#/settings/remote-access).
Only one public exposure mode should be active at a time (Tailscale Serve or the public FRP tunnel). The Overview tab shows conflicts when both are enabled.
Choose a method
| Scenario | Method | Settings tab |
|---|---|---|
| Personal devices on your Tailscale tailnet | Tailscale Serve (recommended) | Tailscale |
| Mobile app / public HTTPS URL from anywhere | Public tunnel (FRP) | Public internet |
| Self-hosted HTTPS at your own domain | Reverse proxy (Caddy / nginx / Cloudflare Tunnel) | Reverse proxy |
| CLI/TUI on a laptop, SSH to the host | SSH tunnel | SSH tunnel |
| Phone on the same Wi‑Fi | LAN bind | Local network → Gateway settings |
| Enterprise SSO in front of the gateway | Trusted-proxy auth (advanced) | (see advanced section below) |
See also the network hub for how these layers fit together.
Overview tab
The Overview tab summarizes what is active on this gateway:
- Tailscale Serve — tailnet HTTPS status
- Public tunnel — FRP connection state
- Reverse proxy — configured
gateway.publicUrl(self-deployed HTTPS) - SSH tunnel — CLI port-forwarding command
- Local network — LAN bind shortcut to Gateway settings
Pick a method card to jump to the matching tab. If Tailscale and the public tunnel are both enabled, fix the conflict before switching.
Tailscale Serve
Best when every client is on your Tailscale tailnet. The gateway process stays on 127.0.0.1; Tailscale publishes HTTPS on your MagicDNS hostname.
In the settings UI
- Install Tailscale on the gateway host and sign in.
- Open Remote access → Tailscale.
- Click Enable Serve.
- Copy the
https://<hostname>/URL and open it from any device on the tailnet. - Sign in to the web console with your gateway Bearer token (API routes always require the token).
Configuration
{
gateway: {
bind: "loopback",
port: 18790,
auth: { mode: "token", token: "…" },
tailscale: { mode: "serve", resetOnExit: true },
},
}CLI one-shot:
xopc gateway --tailscale serve --tailscale-reset-on-exit
xopc tailscale statusNotes
- Serve requires
gateway.bind=loopback. - Funnel (public HTTPS via Tailscale) is high risk and requires password auth — see Tailscale.
- Tailscale exposure and
tunnel.autoStartare mutually exclusive.
More detail: Tailscale Serve / Funnel.
Public internet (FRP tunnel)
Use when you need a public HTTPS URL — for example mobile app pairing or reaching the gateway from outside your tailnet.
Traffic is proxied through frp.xopc.ai. Treat this as high risk: anyone with the URL or pairing QR may reach your gateway if they obtain your Bearer token.
In the settings UI
- Open Remote access → Public internet.
- Read the security notice and click Start remote access (consent required on first start).
- Wait for the public URL (first start can take 1–3 minutes while HTTPS is provisioned).
- Open Mobile app pairing below the control card and scan the QR (or copy the pairing link).
- Stop the tunnel when you no longer need remote access.
Broker registration secret
Connecting to the production broker requires a registration secret (not the gateway token):
| Source | Priority |
|---|---|
XOPC_TUNNEL_REGISTRATION_SECRET env | 1 (wins) |
tunnel.registrationSecret in xopc.json | 2 |
| Dev default | 3 (non-production brokers only) |
Set the secret under Advanced settings on the Public internet tab, or:
xopc tunnel secret setOptions
- Auto-start — start the tunnel whenever the gateway launches (requires valid consent and a prior successful start).
- Release public URL — deregister the subdomain on the broker; the next start gets a new URL.
Configuration (summary)
| Field | Meaning |
|---|---|
tunnel.enabled | User turned remote access on |
tunnel.autoStart | Start tunnel when gateway listens |
tunnel.consent | Record of accepting the security notice |
tunnel.registrationSecret | Broker registration secret |
Full security model, API, and CLI: FRP tunnel security.
SSH tunnel (CLI)
When you can SSH to the host but do not want a public URL, open Remote access → SSH tunnel for the copy-ready command, or run:
xopc gateway ssh-tunnel --target user@your-host --local-port 18790 --remote-port 18790
# equivalent:
ssh -N -L 18790:127.0.0.1:18790 user@your-hostThen open http://127.0.0.1:18790 locally.
Persistent CLI remote mode
{
gateway: {
mode: "remote",
remote: {
url: "http://127.0.0.1:18790",
token: "your-token",
transport: "ssh",
sshTarget: "user@gateway-host",
},
},
}CLI/TUI/MCP use gateway.remote when gateway.mode=remote. Override with XOPC_GATEWAY_URL.
More: Remote access (SSH + CLI).
Reverse proxy (self-hosted HTTPS)
Front the gateway with your own HTTPS reverse proxy (Caddy, nginx, Cloudflare Tunnel, etc.) at a custom domain such as https://gateway.example.com. The proxy terminates TLS and forwards to the loopback gateway; the mobile app pairs over the public URL.
This is distinct from trusted-proxy auth (see Advanced): here the proxy is just a TLS / domain shim — the gateway still authenticates clients with its own Bearer token. No SSO required.
When to use it
- You already run a domain + TLS cert (Let's Encrypt, Cloudflare, commercial CA).
- You want a memorable URL like
https://xopc.yourdomain.cominstead of*.frp.xopc.ai. - You need IP-allowlisting / WAF in front of the gateway.
- You're behind CGNAT and use Cloudflare Tunnel / Tailscale Funnel as the reverse proxy.
In the settings UI
- Open Remote access → Reverse proxy.
- If you already accessed the console through the proxy (the URL bar shows
https://gateway.example.com), the tab auto-detects the URL and shows a pairing QR immediately — no save required. - Click Test to round-trip
/api/tunnel/pair/pingand verify TLS + DNS + reachability. - Click Save as default to persist as
gateway.publicUrl. Then other clients (and restarts) see the same URL. - Scan the QR from the xopc mobile app.
The mobile app stores the URL as the primary baseUrl and falls back to LAN / FRP if reachable.
Configuration
{
gateway: {
bind: "loopback", // proxy connects via localhost
port: 18790,
auth: { mode: "token", token: "…" },
publicUrl: "https://gateway.example.com", // NEW
},
}publicUrlmust use https for public hostnames;httpis allowed only for RFC1918 /.local.- No path, no query, no userinfo. Trailing
/is stripped. - The configured URL is automatically added to the CORS/CSRF allowlist.
- When both reverse-proxy and FRP are active, reverse-proxy is preferred by the mobile app; FRP remains as a fallback in
connectUrls.
Reverse-proxy templates
Caddy (auto Let's Encrypt):
gateway.example.com {
reverse_proxy 127.0.0.1:18790 {
flush_interval -1 # SSE: disable response buffering
transport http { keepalive 90s }
}
}nginx:
server {
listen 443 ssl http2;
server_name gateway.example.com;
ssl_certificate /etc/letsencrypt/live/gateway.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gateway.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:18790;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off; # SSE: disable response buffering
proxy_read_timeout 3600s; # long-lived SSE / WS
}
}Requirements
| Requirement | Why |
|---|---|
| System-trusted TLS cert (Let's Encrypt / commercial CA) | Mobile apps reject self-signed certs (iOS ATS, Android default config). Self-signed is not supported in v1. |
proxy_buffering off (nginx) / flush_interval -1 (Caddy) | SSE streams (/api/events, /api/agent/stream) must flush immediately. |
| Long idle timeout (≥ 60s, ideally hours) | Chat and event streams stay open. |
Do not strip Authorization header | The gateway authenticates with its own Bearer token. |
/health and /api/tunnel/pair/ping must be reachable | Used for mobile preflight + the "Test" button in the UI. |
Troubleshooting
| Symptom | Check |
|---|---|
| Mobile shows "Reverse proxy did not respond" | Run the Test button. TLS cert valid? DNS resolves? /api/tunnel/pair/ping reachable? |
| Browser console shows CORS / 403 errors | Make sure your TCP connection comes from loopback (proxy on same host) or add the proxy IP to gateway.trustedProxies. |
| SSE streams stall after a few seconds | Disable proxy buffering. nginx: proxy_buffering off. Caddy: flush_interval -1. |
| Mobile pairs but disconnects after a minute | Increase proxy idle timeout. |
| The QR works locally but not over cellular | Public DNS / firewall — the URL must resolve and be reachable from outside your network. |
Same network (LAN)
For phones or laptops on the same Wi‑Fi without public exposure, open Remote access → Local network, then:
- Open Settings → Gateway.
- Set Bind to your LAN IP or
0.0.0.0(with token auth and sensible firewall rules). - Connect with
http://<lan-ip>:<port>.
The Public internet tab can suggest LAN addresses for mobile pairing when the gateway is still on loopback.
Advanced: trusted-proxy authentication
For the case where the reverse proxy itself authenticates users (Pomerium, oauth2-proxy, enterprise SSO, etc.) and you want the gateway to trust the user identity from request headers — distinct from the Reverse proxy tab, which keeps Bearer-token auth:
- Keep
gateway.bind=loopback. - Configure trusted proxy auth:
gateway.auth.mode = "trusted-proxy", settrustedProxiesCIDRs, andauth.trustedProxy.userHeader. - Block direct access to the gateway port from the internet.
The mobile pairing flow does not use this mode (the phone has no SSO identity to present).
Troubleshooting
| Symptom | Check |
|---|---|
| Overview shows a conflict | Disable Tailscale Serve or stop the public tunnel |
| Public tunnel won’t start | Registration secret set? Consent accepted? See logs TunnelAudit |
| Mobile QR says localhost blocked | Enable LAN bind, start the public tunnel, or configure reverse proxy |
| Tailscale Enable fails | Tailscale installed and logged in? gateway.bind=loopback? |
| CLI can’t reach remote gateway | gateway.mode=remote, SSH tunnel running, token in gateway.remote |
| Reverse-proxy: "Test" button fails with TLS error | Cert not trusted (self-signed?), wrong SNI, or expired cert |
| Reverse-proxy: SSE streams cut after seconds | proxy_buffering off (nginx) / flush_interval -1 (Caddy) missing |