Skip to content

Chan manual

Host a gateway

Run your own gateway on one server and publish devservers behind sign-in, for you and the people you share them with.

A devserver added with a connect script is reached through a tunnel you open yourself. A chan gateway turns that around: the devserver dials out to the gateway with a personal access token, and the gateway publishes it at an address of its own, behind sign-in, for you and the people you share it with, from any browser or from Chan Desktop. The devserver's machine needs no inbound port, no DNS name, and no certificate.

The gateway is software you run yourself; chan is not an online service. It is a few small services and a Postgres database:

  • identity, at gw.example.com: sign-in with GitHub, Google, or GitLab, the account page, access tokens, sharing, and the desktop's sign-in.
  • profile: accounts, devservers, shares, and policy, in Postgres. Internal only.
  • devserver-proxy: receives the devservers' tunnels at proxy.example.com and serves each devserver at its own origin under the node's wildcard, such as alice--3f9c2a7b1e04.p1.proxy.example.com.
  • devserver-control: admits devservers and tracks the proxy fleet. Internal only.
  • chan-gateway-admin: the operator's command line.

This page sets all of it up on one Ubuntu 24.04 server, publish a devserver through it, open it from a browser and from Chan Desktop, and share it with another account. You need the server, with ports 80 and 443 open, and a domain whose DNS you control. example.com stands for that domain throughout.

Create three DNS records pointing at the server:

  • gw.example.com: sign-in, accounts, and desktop discovery.
  • proxy.example.com: tunnel ingress, where devservers dial in, and proxy health.
  • *.p1.proxy.example.com: one origin per devserver. A *.example.com record does not cover these names.

All services listen on the server's loopback address without TLS; a web server in front of them terminates TLS. The wildcard name needs a certificate from a DNS challenge, so install certbot with the plugin for your DNS provider. With Cloudflare, for example:

$sudo apt install nginx postgresql git certbot python3-certbot-dns-cloudflare sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /root/cloudflare.ini \ -d gw.example.com -d proxy.example.com -d '*.p1.proxy.example.com'

The certificate lands in /etc/letsencrypt/live/gw.example.com/, and certbot renews it on its own.

The gateway signs people in with GitHub, Google, or GitLab, and needs at least one. For GitHub, open Developer settings, choose New OAuth App, and set:

  • Homepage URL: https://gw.example.com
  • Authorization callback URL: https://gw.example.com/auth/github/callback

Keep the client ID and a client secret for the next steps. For Google or GitLab, the callback ends in /auth/google/callback or /auth/gitlab/callback.

Create the database and an owner role for it. The setup script below uses the owner to create a separate, restricted role for each service, which takes a superuser:

$sudo -u postgres createuser --superuser --pwprompt chan sudo -u postgres createdb --owner=chan --template=template0 --locale=C.UTF-8 chan_gateway

Download the gateway packages for this release, from the install page or with the GitHub CLI, and install the five services. Use arm64 in place of amd64 on an ARM server:

$gh release download v0.126.0 --repo fiorix/chan --pattern 'chan-gateway-*_amd64.deb' sudo apt install ./chan-gateway-profile_*.deb ./chan-gateway-identity_*.deb \ ./chan-gateway-devserver-control_*.deb ./chan-gateway-devserver-proxy_*.deb \ ./chan-gateway-admin_*.deb

Each service gets its own system user and a systemd unit, installed but not started. All gateway services must run the same release.

The repository's setup script generates every service credential and signing key, and writes the configuration under /etc/chan-gateway/. Run it from a checkout of the same release:

$git clone --depth 1 --branch v0.126.0 https://github.com/fiorix/chan sudo chan/packaging/gateway/scripts/configure.sh

Answer its questions like this:

QuestionAnswer
Postgres user, password, databasechan, the password you chose, chan_gateway
Gateway public originhttps://gw.example.com
Proxy namespace originhttps://proxy.example.com
Tunnel ingress originthe default, https://proxy.example.com
This proxy node idp1
This proxy node originthe default, https://p1.proxy.example.com
GitHub OAuth client ID and secretfrom the previous step; press Enter to skip the others

The gateway public origin and proxy namespace origin require explicit answers, each an http:// or https:// origin with a host and optional port, without a path or trailing slash. The tunnel ingress defaults to the namespace origin. Its host needs a DNS record and certificate, with the TLS edge routing /v1/tunnel to port 7100 as h2c and / to port 7002 for health and readiness. The node origin defaults to the chosen node id, p1 here, under the namespace; its wildcard needs DNS, a certificate, and an edge route to port 7002 for devserver pages and WebSockets.

Prepare the database schema, then start the services:

$sudo systemctl restart chan-gateway-migrate sudo systemctl enable --now chan-gateway-profile chan-gateway-identity \ chan-gateway-devserver-control chan-gateway-devserver-proxy

The schema unit runs once and must succeed before identity and profile start. Run it again after each upgrade, even when nothing changed. Follow the services with journalctl -u 'chan-gateway-*' -f. The proxy starts admitting devservers about 30 seconds after the controller comes up; check it with the tunnel ingress name as the host:

$curl -H 'Host: proxy.example.com' http://127.0.0.1:7002/readyz

nginx terminates TLS and routes three things: account requests to identity on port 7000, devserver pages and WebSockets to the proxy on port 7002, and the devservers' tunnels, which are long-lived HTTP/2 streams, to the proxy's tunnel listener on port 7100. Save this as /etc/nginx/sites-available/chan-gateway, with your domain in place of example.com:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name gw.example.com proxy.example.com *.p1.proxy.example.com;
    return 301 https://$host$request_uri;
}

# Sign-in, accounts, and desktop discovery.
server {
    listen 443 ssl http2;
    server_name gw.example.com;
    ssl_certificate     /etc/letsencrypt/live/gw.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/gw.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:7000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Tunnel ingress and proxy health/readiness.
server {
    listen 443 ssl http2;
    server_name proxy.example.com;
    ssl_certificate     /etc/letsencrypt/live/gw.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/gw.example.com/privkey.pem;
    client_max_body_size 0;

    # A tunnel is one request that lasts as long as the devserver.
    location /v1/tunnel {
        grpc_pass grpc://127.0.0.1:7100;
        client_body_timeout 1h;
        grpc_read_timeout 1h;
        grpc_send_timeout 1h;
        grpc_socket_keepalive on;
    }

    location / {
        proxy_pass http://127.0.0.1:7002;
        proxy_set_header Host $host;
    }
}

# One origin per devserver: pages, terminals, and file transfers.
server {
    listen 443 ssl http2;
    server_name *.p1.proxy.example.com;
    ssl_certificate     /etc/letsencrypt/live/gw.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/gw.example.com/privkey.pem;
    client_max_body_size 0;

    location / {
        proxy_pass http://127.0.0.1:7002;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_buffering off;
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }
}
$sudo ln -s /etc/nginx/sites-available/chan-gateway /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx

A new gateway lets nobody sign in: the oauth_login flag is off for every account until the operator grants it, and so is share_workspaces, which shows the Devservers tab. Grant both to your own email, the one your GitHub account uses. As root, load the admin credentials that the setup script wrote:

#set -a; . /etc/chan-gateway/admin.env; set +a chan-gateway-admin user create --email you@example.com chan-gateway-admin flag grant oauth_login you@example.com chan-gateway-admin flag grant share_workspaces you@example.com

Now open https://gw.example.com and sign in with GitHub. The account page has three tabs: Profile, Tokens, and Devservers.

Your username starts as a placeholder, and it is part of every devserver address. Change it in the Profile tab now, before you share anything: renames are limited to four, and each one disconnects your devservers.

To let anyone with an account at your sign-in provider in, without granting each person: chan-gateway-admin flag create oauth_login --default-on.

Each devserver dials in with a token of its own. In the Tokens tab, choose New token, label it build-host, pick an expiry, and copy the token: it is shown only once. A token is the devserver's identity at the gateway: a new token makes a new devserver, with a new address.

On the devserver's machine, a Linux host with chan installed as in Install on the host, start the devserver with the token in its environment:

$export CHAN_TUNNEL_TOKEN=chan_pat_... chan devserver start --port 8787 \ --tunnel-url https://proxy.example.com/v1/tunnel \ --tunnel-devserver-name build-host
  • The systemd service keeps the token and the URL, so later start, restart, and join reuse them.
  • --port 8787 keeps the devserver's local listener on its usual port, so Chan Desktop can still connect over ssh as in Connect from the main window. Without it, a tunneled devserver picks a free port.
  • Pass the token in the environment, not as --tunnel-token: a flag is visible to every user in the process list.
  • On macOS, the launchd service cannot carry a tunnel: use chan devserver run with the same flags, or --service=chan.

The devserver's log confirms the connection, with your username:

$journalctl --user -u chan-devserver -f
chan devserver: tunnel connected as user alice

On the gateway, chan-gateway-admin tunnel ps lists it too. If the tunnel drops, the devserver dials again by itself.

On https://gw.example.com, the Devservers tab lists build-host under My devservers, online. Click Open. The browser lands on the devserver's own origin, https://alice--3f9c2a7b1e04.p1.proxy.example.com/, on the same Computers screen described in Chan in a browser. There is no token in the URL this time: your gateway sign-in is the key. Everything there works here, from any computer with a browser.

  1. Open the Computers screen and press g, or click its title, to flip to Gateways.
  2. Turn to the Add gateway card, enter https://gw.example.com and, optionally, a name.
  3. Connect it with its on/off control, or o. Your browser opens on the gateway's sign-in and asks you to approve the desktop. One sign-in covers the whole account.
  4. Press g to flip back to Computers. build-host is there, marked as reached via your gateway. Connect it like any devserver; its windows open as native windows.

No connect script and no control terminal are involved: the gateway carries the connection. Tools that need your desktop, such as cs tunnel, work through it too.

Sharing gives another account the whole devserver: its terminals, files, and workspaces. That is the same as a shell account on the machine, so share only with people you would give one.

  1. Let the other person in: chan-gateway-admin flag grant oauth_login friend@example.com.
  2. In Devservers, click Share on build-host, enter their email, and click Add. The share reads pending sign-in until they sign in with that verified email, then active.
  3. They add your gateway to their Chan Desktop, as above. Your devserver is listed on their Computers screen; the first time they open it, Chan Desktop asks them to grant it native access.

To take access away, click Revoke on the share. Their sessions on that devserver end.

chan-gateway-admin is the operator's view of the whole gateway. With the admin environment loaded as above:

  • chan-gateway-admin overview --since 24h: accounts, devservers, and sign-ins at a glance.
  • chan-gateway-admin tunnel ps and proxy ps: live devservers and proxy nodes.
  • chan-gateway-admin policy set alice --enabled --max-connected-devservers 3: limit one account.
  • chan-gateway-admin user block alice@example.com --reason "...": revoke every token and refuse new sign-ins.
  • chan-gateway-admin fleet pause --drain: cut every session and tunnel at once, then fleet resume.

With more proxy nodes, point proxy.example.com at each node through DNS or a TCP pass-through balancer, keeping the same tunnel URL for every devserver. A balancer that terminates TLS carries every tunnel byte. Each node still needs DNS and a certificate for its own tenant wildcard, such as *.p2.proxy.example.com.

Add --json to any command for scripts. To publish more machines, give each one its own token and run the same chan devserver start on it: build hosts, GPU boxes, or a VM per person, each with your team's tools installed.

The gateway guide shows the architecture and the WireGuard option. gateway/README.md covers every service, the admin CLI, and Kubernetes deployment, and gateway/design.md the design.