# Web Terminal

URL: https://tuios.dev/docs/web

> Use TUIOS from a browser with tuios-web, a separate server binary.

`tuios-web` serves TUIOS to a web browser. The browser draws the terminal with WebGL and connects over WebTransport when it can, or WebSocket when it cannot.

It is a separate binary from `tuios`, so the main binary contains no web server. It is built on [sip](https://github.com/Gaurav-Gosain/sip), a library for serving any Bubble Tea program in a browser.

> **tuios-web has no login**
>
> Anyone who can reach the port gets a shell as the user running `tuios-web`. There is no password or token. Keep it on `localhost`, or put a reverse proxy with authentication in front of it. See [Security](https://tuios.dev/docs/web#security).

## Install

> > ```bash
> > brew tap Gaurav-Gosain/tap
> > brew install tuios-web
> > ```
>
> > ```bash
> > yay -S tuios-web-bin
> > ```
>
> > ```bash
> > curl -fsSL https://raw.githubusercontent.com/Gaurav-Gosain/tuios/main/install-web.sh | bash
> > ```
>
> > ```bash
> > go install github.com/Gaurav-Gosain/tuios/cmd/tuios-web@latest
> > ```

## Quick start

> > ### Start the server
> >
> > ```bash
> > tuios-web
> > ```
> >
> > It listens on `http://localhost:7681`, and for WebTransport on UDP port 7682 of the same host.
>
> > ### Open it
> >
> > Go to [http://localhost:7681](http://localhost:7681/). The page picks the best transport on its own.

By default every browser attaches to one daemon session called `web`, created the first time it is needed. Close the tab and the session keeps running, like any [daemon session](https://tuios.dev/docs/sessions). Open a second tab and both show the same session.

## Reaching it from another device

Binding anything other than a loopback address needs TLS, because otherwise every keystroke crosses the network in clear text. `tuios-web` refuses to start without one of these:

```bash
# HTTPS from a self-signed certificate tuios-web generates and keeps
tuios-web --host 0.0.0.0 --auto-tls

# HTTPS from a certificate you already have
tuios-web --host 0.0.0.0 --cert cert.pem --key key.pem

# Plain HTTP, only on a network you trust
tuios-web --host 0.0.0.0 --insecure
```

With `--auto-tls`, each browser shows a certificate warning on its first visit, because the certificate is self-signed. `tuios-web cert info` explains what the warning looks like and how to make it go away. The other `cert` subcommands are `new` (replace the certificate), `path` (print its path) and `rm` (delete it and the key).

An SSH tunnel is another option, and needs no certificate:

```bash
ssh -L 7681:localhost:7681 you@server
```

Then open `http://localhost:7681` on your own machine.

On phones and tablets, `tuios-web` adds a key bar with the prefix key and buttons for common commands (new, close, next, zoom, the command palette and more). It detects touch devices from the browser's request. Use `--touch on` or `--touch off` when it guesses wrong.

## Flags

| Flag                | Default                                 | Description                                                                                    |
| ------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `--host`            | `localhost`                             | Address to bind                                                                                |
| `--port`            | `7681`                                  | HTTP port. WebTransport uses the next port up.                                                 |
| `--auto-tls`        | off                                     | Serve HTTPS from a self-signed certificate that `tuios-web` generates and keeps                |
| `--cert`, `--key`   |                                         | Serve HTTPS from your own certificate and key, in PEM form                                     |
| `--cert-days`       | `365`                                   | Days an `--auto-tls` certificate is valid. Under 14 also keeps WebTransport working in Chrome. |
| `--cert-dir`        | sip's directory in your user config dir | Where `--auto-tls` keeps its certificate                                                       |
| `--cert-host`       |                                         | Extra DNS name or IP for the `--auto-tls` certificate. Repeatable.                             |
| `--insecure`        | off                                     | Allow plain HTTP on a non-loopback address                                                     |
| `--read-only`       | off                                     | Ignore all input from browsers                                                                 |
| `--max-connections` | `0`                                     | Maximum concurrent connections. `0` means no limit.                                            |
| `--default-session` | `web`                                   | Session every browser attaches to                                                              |
| `--ephemeral`       | off                                     | Give each connection its own standalone session, with no daemon. Nothing persists.             |
| `--touch`           | `auto`                                  | Touch mode: `auto`, `on` or `off`                                                              |
| `--debug`           | off                                     | Log connections, sessions and errors                                                           |

All the appearance flags of `tuios` also work here and apply to every browser session: `--theme`, `--border-style`, `--dockbar-position`, `--show-keys`, `--shared-borders`, `--ascii-only`, `--no-animations` and the rest. `tuios-web --help` lists them. They are layered over your config file the same way `tuios` layers them.

```bash
tuios-web --theme nord --show-keys
```

`tuios-web` starts the daemon if it is not running. If the daemon cannot start, it logs a warning and falls back to ephemeral sessions, which do not persist.

Windows opened from the browser get `TERM=xterm-256color` and `COLORTERM=truecolor`.

## Browser settings

The gear button in the corner opens the settings panel. Settings are saved in the browser's local storage.

| Setting   | Options                                                                                                                  |
| --------- | ------------------------------------------------------------------------------------------------------------------------ |
| Transport | Auto (prefers WebTransport), WebTransport, WebSocket                                                                     |
| Renderer  | Auto (prefers WebGL), WebGL, Canvas, DOM, vtgl (experimental)                                                            |
| Font size | 10 to 28 px, default 14                                                                                                  |
| Toggles   | Copy on select, blinking cursor, the browser's right-click menu, and capturing Ctrl+W and similar keys (fullscreen only) |

The page ships with JetBrains Mono Nerd Font, so icons render without anything installed on the client.

## Graphics in the browser

Inline images work, with a smaller part of the Kitty graphics protocol than a native terminal supports.

**Works:** direct base64 transmission (`t=d`), placing and deleting images, RGB, RGBA and PNG data, zlib compression, chunked transmission, source-region clipping, and images moving with scrolling. Sixel images work too.

**Does not work:** file transmission (`t=f`), temporary files (`t=t`), shared memory (`t=s`), animation frame edits (`a=f`), and Unicode placeholders (`U=1`).

TUIOS tells programs what the browser can do. A program that asks whether it can send a file path is told no, and falls back to sending the bytes. A program that insists on shared memory gets no image: `mpv --vo=kitty` is the common case. See [Graphics](https://tuios.dev/docs/architecture#graphics) for how TUIOS handles graphics in a native terminal.

## Security

> **There is no authentication**
>
> `tuios-web` has no login of any kind. Whoever reaches the port gets a full shell with your privileges.
>
> Browser connections from other web sites are refused: a WebSocket or WebTransport connection must come from a page served by `tuios-web` itself. That stops a page you happen to visit from opening a shell on `localhost:7681`, but it is not authentication. Programs that are not browsers send no origin and are accepted.

If anyone but you can reach the port:

1. **Put authentication in a reverse proxy.** `tuios-web` has none of its own.
2. **Bind to localhost** with `--host 127.0.0.1` so that only the proxy can reach it.
3. **Use TLS** end to end, or at least between the browser and the proxy.
4. **Limit connections** with `--max-connections`.
5. **Use `--read-only`** for demos, so viewers cannot type.

### WebTransport and certificates

WebTransport always runs over TLS.

- **On localhost without TLS,** `tuios-web` generates a short-lived self-signed certificate for WebTransport alone. It is valid for 10 days, Chrome's limit for certificates pinned by hash, and the page fetches its hash from `/cert-hash`, so no warning appears.
- **With `--cert` or `--auto-tls`,** WebTransport uses the same certificate as HTTPS. For a self-signed `--auto-tls` certificate, Chrome only accepts WebTransport if the certificate is valid for under 14 days, so set `--cert-days 13` if you want it.
- **With `--insecure`** there is no certificate, so there is no WebTransport. Browsers use WebSocket.

## Behind a reverse proxy

Run `tuios-web` on `127.0.0.1` and let the proxy handle TLS and authentication. Most proxies carry only the WebSocket connection, so browsers use WebSocket rather than WebTransport.

The proxy must pass the original `Host` header through. `tuios-web` compares it with the page's origin and refuses the WebSocket connection if they differ.

### Nginx with basic auth

```nginx
server {
    listen 443 ssl http2;
    server_name tuios.example.com;

    ssl_certificate     /etc/letsencrypt/live/tuios.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tuios.example.com/privkey.pem;

    location / {
        auth_basic           "TUIOS";
        auth_basic_user_file /etc/nginx/.htpasswd;

        proxy_pass http://127.0.0.1:7681;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 86400;
        proxy_send_timeout 86400;
    }
}

server {
    listen 80;
    server_name tuios.example.com;
    return 301 https://$server_name$request_uri;
}
```

Create the password file with `htpasswd -c /etc/nginx/.htpasswd yourname`.

### Caddy with basic auth

Caddy gets a certificate for you:

```text
tuios.example.com {
    basic_auth {
        yourname <hash from "caddy hash-password">
    }
    reverse_proxy localhost:7681
}
```

### Cloudflare Tunnel

A tunnel reaches the server without opening a port. Put [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/) in front of the hostname, or anyone who finds it gets a shell.

> > ### Create the tunnel
> >
> > ```bash
> > cloudflared tunnel login
> > cloudflared tunnel create tuios
> > cloudflared tunnel route dns tuios tuios.example.com
> > ```
>
> > ### Configure it
> >
> > `~/.cloudflared/config.yml`:
> >
> > ```yaml
> > tunnel: <your-tunnel-id>
> > credentials-file: /path/to/credentials.json
> >
> > ingress:
> >   - hostname: tuios.example.com
> >     service: http://localhost:7681
> >   - service: http_status:404
> > ```
>
> > ### Run both
> >
> > ```bash
> > tuios-web --host 127.0.0.1
> > cloudflared tunnel run tuios
> > ```

### Traefik

```yaml
# docker-compose.yml
services:
  tuios:
    # Build this image yourself (see Docker below). No published image contains tuios-web.
    image: tuios-web:local
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.tuios.rule=Host(`tuios.example.com`)"
      - "traefik.http.routers.tuios.entrypoints=websecure"
      - "traefik.http.routers.tuios.tls.certresolver=letsencrypt"
      - "traefik.http.routers.tuios.middlewares=tuios-auth"
      - "traefik.http.middlewares.tuios-auth.basicauth.usersfile=/etc/traefik/htpasswd"
      - "traefik.http.services.tuios.loadbalancer.server.port=7681"
```

The `basicauth` middleware is the only access control here. Without it, this publishes a shell to the internet.

## Running as a service

### Docker

The published `ghcr.io/gaurav-gosain/tuios` image contains only `tuios`. Build your own image for `tuios-web` from a checkout of the repository:

```dockerfile
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN go build -o /out/tuios-web ./cmd/tuios-web

FROM debian:bookworm-slim
COPY --from=build /out/tuios-web /usr/local/bin/tuios-web
EXPOSE 7681
# Plain HTTP inside the container: terminate TLS and authenticate at a proxy in front of it.
ENTRYPOINT ["tuios-web", "--host", "0.0.0.0", "--insecure"]
```

### systemd

```ini
# /etc/systemd/system/tuios-web.service
[Unit]
Description=TUIOS web terminal
After=network.target

[Service]
Type=simple
User=tuios
ExecStart=/usr/local/bin/tuios-web --host 127.0.0.1 --port 7681
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl enable --now tuios-web
```

## How it works

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/docs/web)*

Each browser connection runs a TUIOS client inside `tuios-web`, which attaches to the daemon like any other client. The first byte of every message says what it is. It is an ASCII digit, not a raw byte value:

| Type           | Byte  | Direction         | Content                      |
| -------------- | ----- | ----------------- | ---------------------------- |
| Input          | `'0'` | browser to server | Keyboard and mouse input     |
| Output         | `'1'` | server to browser | Terminal output              |
| Resize         | `'2'` | browser to server | New size in cells and pixels |
| Ping           | `'3'` | browser to server | Keep-alive                   |
| Pong           | `'4'` | server to browser | Keep-alive reply             |
| Title          | `'5'` | server to browser | Window title                 |
| Options        | `'6'` | server to browser | Session settings             |
| Close          | `'7'` | server to browser | The session ended            |
| Kitty keyboard | `'8'` | browser to server | Keyboard protocol flags      |

## Troubleshooting

### It refuses to start on `0.0.0.0`

That is the TLS requirement. Add `--auto-tls`, `--cert` and `--key`, or `--insecure`. The error message lists the exact commands.

### WebTransport does not connect

The page falls back to WebSocket, so this only costs latency. Check that UDP port 7682 (or your `--port` plus one) is reachable, that you are not using `--insecure`, and that your browser supports WebTransport. The browser console shows certificate errors. You can force WebSocket in the settings panel.

### The WebSocket connection is refused behind a proxy

The proxy is probably rewriting the `Host` header. Pass it through unchanged, for example `proxy_set_header Host $host;` in nginx.

### Blank terminal

Check the browser console for errors, try another renderer in the settings panel, and run `tuios-web --debug` to see the server's log.

### The tab stays open after quitting

In a daemon session, Ctrl+B q opens the quit menu, and its default is to detach. Detaching or killing the session from that menu ends the browser connection. If the page does not close, reload it.

## Related

*[An interactive figure goes here. Open the page to use it.](https://tuios.dev/docs/web)*
