Skip to content

About

Terminal fleet — share a machine's PTY sessions over one WebSocket to LAN PC and mobile clients (1:1 terminal tabs)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

57 Commits

Folders and files

Repository files navigation

term-fleet

term-fleet

One terminal fleet, controlled from anywhere.

ci latest release

tfd owns the PTY sessions on a machine and serves them over a single WebSocket. A desktop client built with Mygo — a native window, drawn by Mygo's own UI toolkit and terminal rather than a WebView — and a browser or phone client all attach to that same connection, so every terminal tab on the phone is the same shell as the tab on the desktop.

Machines can also dial out to a relay (tfrelay) on a public server, so the fleet is reachable from outside the LAN without a port forward, a public address, or a VPN. The relay routes bytes and nothing else — see PLAN.md §3.

Design notes and the protocol draft live in PLAN.md.

Download

Every release ships two kinds of package, both built by .github/workflows/release.yml from the pushed tag:

what assets
the desktop app macOS .dmg (universal), Windows Setup .exe plus a portable .zip, Linux .deb and .tar.gz
the command-line tools tfd (the agent) and tfrelay (the relay) for macOS, Linux and Windows

checksums.txt holds the SHA-256 of every asset. The desktop app carries its own daemon, so a machine that runs the window needs no separate tfd; take a tool archive for a headless member, and take tfrelay for the host that routes.

Release assets carry no Apple or Microsoft signature, so macOS quarantines the downloaded .dmg and Windows SmartScreen warns about the installer: right-click → Open on macOS, or More info → Run anyway on Windows.

Status

M0 through M5 are done: the walking skeleton, the PC client, the relay, the client's tunnel mode, Windows hosts, and the deployment pieces.

  • PTY sessions, byte-offset output history, one multiplexed WebSocket, and a single-controller model with viewers.
  • The browser client: tabs, session inventory, keyboard-driven new/close, selection with a copy button at it, reopen-from-cursor after a dropped link, and a machine-grouped sidebar when more than one machine is reachable.
  • It is served by the daemon and by the relay, from one build: the server reports which transport it speaks (/tf/config), so a browser tab and a phone load the same code. The desktop window is native, and attaches to the same sessions rather than loading that code.
  • tfdesktop: the same daemon behind a native Mygo window — mygo's ui controls and its terminal plugin draw the tabs, so there is no WebView and no xterm in the PC client. A tab there is a real shell on the machine, and the daemon underneath is what lets a browser drive the very shell on screen. The column beside the strip holds two panels with a draggable line between them — the shells on top, the machines under them — and the 会话 panel's title row carries 新建标签页 / 关闭标签页 as icon buttons. The 机器 panel lists the machines this window can reach, as a list you switch between: this machine first, then every other machine on the relay. The one that is picked is the machine the 会话 panel is about, and the one its 新建标签页 opens a shell on, so driving another machine is a matter of picking it. The relay itself gets no row — it is the pipe, not a machine — and 新建 on another machine asks that machine's hub for a shell over the relay, which lands in a tab here under that machine's arbitration. A Settings window (the strip's 设置 button, at the bottom under the shells) picks the terminal's font and size, stores this machine's relay credential, turns the local service on or off, and decides whether other clients may take this machine's shells — the same surface Gloss keeps, drawn the same way. Reaching the other machines needs nothing typed: a window asks the relay for a short-lived control token, proving itself with the device credential it already joins the fleet with (/relay/control-token), and that credential — not the fleet's shared secret — is what its client link presents. The font field suggests the families installed on the machine and takes any name, so a Nerd Font build ("Maple Mono NF" beside "Maple Mono") is selectable. Edited text fields are saved by 应用, which the toolbar offers only while there is something to apply; a tab is named after its shell ("zsh 1") until the shell sets a title of its own.
  • tfrelay + agent: machines dial out to a public relay and are controllable from anywhere; a dropped link is a routing outage, not data loss — sessions and their output history live in the member and survive it.
  • Windows hosts, through ConPTY: internal/session abstracts a pty behind one interface, because a Unix pty is a file descriptor and a ConPTY is a console object that owns the process it launched.
  • A phone-shaped client: a drawer sidebar, and an accessory key bar for the keys a soft keyboard does not have.

deploy/ holds a systemd unit for the relay, a Caddyfile that terminates TLS in front of it, a user unit for members, and a runbook covering the security model. The relay rate-limits failed credentials per address and takes the client token in the first frame rather than in the URL.

Layout

cmd/tfd/               the daemon
cmd/tfdesktop/         the desktop shell: same daemon, plus a native Mygo window
cmd/tfrelay/           the public-side router: enroll members, route streams
internal/protocol/     the wire format, shared with clients
internal/tunnel/       the relay envelope; the session protocol rides inside it
internal/session/      PTY, output ring buffer, session manager
internal/hub/          WebSocket fan-out and who-may-type policy (the lease lives here)
internal/relayclient/  the client half of the relay protocol, for a program that drives a fleet
internal/relay/        member registry, stream routing, presence
internal/agent/        dials out to a relay, projects streams onto the hub
internal/daemon/       hub + client wiring shared by both entry points
internal/web/          go:embed of the built client
web/                   the client itself (Vite + TypeScript + xterm)
deploy/                systemd units, Caddyfile, and the deployment runbook
scripts/acceptance.mjs headless end-to-end run against a real browser
scripts/relay-acceptance.mjs  the same, across a relay, against the real binaries
scripts/relay-browser.mjs     a real browser through a relay, as desktop and as phone

Run

The client is embedded with go:embed, so build the web step first. A bare go build still succeeds and serves a page telling you to do this.

pnpm --dir web install
pnpm run build            # web + tfd + tfdesktop into bin/
./bin/tfd                 # listens on 127.0.0.1:7678, serves the client at /
./bin/tfdesktop           # a native window on the same sessions, free loopback port

The desktop shell is also a Mygo CLI project. mygo.json names cmd/tfdesktop as the app, so the window can be built and run through the toolchain Mygo ships instead of plain go build: go tool mygo dev builds, launches, and rebuilds on Go changes, and go tool mygo build writes an app bundle to dist/. The other two binaries (tfd, tfrelay) are not Mygo apps and stay on go build.

pnpm run build:web        # the embedded client, once
go tool mygo dev          # build + launch tfdesktop, rebuild on change
go tool mygo build        # a distributable bundle in dist/

For UI work on the browser client, skip the recompile: build the assets and point the server at them. The window's own UI is Go, so changing it needs pnpm run build:desktop.

pnpm run build:web
./bin/tfd -addr 127.0.0.1:7678 -web-dir internal/web/dist

tfd refuses to listen on a non-loopback address unless you pass -token. It runs arbitrary commands and serves a shell; that is not a service to expose by accident.

flag meaning
-addr listen address, loopback by default
-token shared secret every client must present
-allow-origin extra WebSocket Origin patterns, for a dev server on another port
-idle-controller how long a silent controller keeps control (default 30m)
-web-dir serve the client from disk instead of the embedded copy
-relay dial this relay member endpoint; empty keeps the machine LAN-only
-device-id / -device-secret this machine's relay credential
-machine-name the name shown in the relay's machine list
-relay-ca PEM file to trust as the relay's CA, for a relay behind a private certificate
-allow-control whether other clients (a browser, a phone, another machine) may drive the shells here; default true, false leaves them read-only

tfrelay also takes -control-token-ttl (default 15m), the lifetime of the credential an enrolled machine gets from /relay/control-token. It is how a desktop reaches the rest of the fleet without the shared client token, which stays what a browser or a phone is given.

tfdesktop takes -addr and -web-dir as well, plus -shell (the shell new tabs run) and -cwd (their working directory). Its relay credential, the terminal's font and size, and whether the local service listens are not flags: the Settings window writes them to settings.json under the OS config directory (TERM_FLEET_CONFIG_HOME overrides it) and applies them as you change them, so this desktop shell can join a relay without a command line. A new tab runs PowerShell on Windows when it is installed (pwsh, else powershell), falling back to cmd.exe only if neither is present.

Enrolled with a relay and holding its client token, the window drives the rest of the fleet: the 机器 panel lists every machine on the relay, and a shell on another machine opens in a tab here under that machine's own arbitration — one writer at a time, on a lease with an epoch. A shell someone else holds glows around its pane and offers 拿回控制权; the machine the shell runs on takes it back with one click, because it is that machine's shell. The token is the fleet's client secret, not this machine's device credential: keep it like a password, because today it is the whole authority over every machine on the relay. docs/takeover.md has the design and what is still missing.

A relay that terminates TLS with a private certificate needs a member to pin that certificate, so the Remote pane has a CA certificate row. The value is a file path; the Get from server button fills it for you by fetching the certificate the relay presents and writing it next to settings.json, and Browse… opens a file picker if you already have the relay.crt from the admin page's download link. Without it a connection fails with x509: certificate is not standards compliant — browsers offer to click through such a certificate, Go does not, and the desktop is Go.

The same pane shows what the link is actually doing — connecting…, connected as …, or cannot reach it: … — because a missing CA and a wrong device secret otherwise look identical: nothing happens, and the pane used to say only that the form had been saved.

Endpoints: GET / (client), GET /healthz, GET /ws (WebSocket).

Reach it from anywhere

A relay is a router, not a hub. It never parses a session frame, never holds a PTY, and never decides who controls a session; it pairs a client stream with a member stream and forwards bytes. Members dial out, so nothing listens on the machine itself.

./bin/tfrelay -addr 127.0.0.1:7698 -devices devices.json \
              -client-token "$TOKEN" -public-url wss://relay.example/relay/agent \
              -tls-cert cert.pem -tls-key key.pem

./bin/tfd -addr 127.0.0.1:7678 \
          -relay wss://relay.example/relay/agent \
          -device-id mac-mini-1a2b3c -device-secret "$SECRET"

The admin panel

The relay serves its own management page at /admin (-admin-path). Open it once and it asks you to create an admin account; after that it is where you add a machine, read the credential back, rotate the client token, and download the CA certificate for a member to pin. tfrelay enroll and tfrelay list still exist for scripted setup, but day-to-day there is no reason to reach for a shell — and enroll from the panel takes effect on the next dial, no restart.

A fresh relay therefore starts with an empty registry: the first machine is created from the page. It still refuses to start without a client token, still refuses to listen off loopback without TLS unless you say -insecure, and the admin account is protected by a rate-limited login and a PBKDF2 hash, because it is the one credential that can mint all the others.

Each machine gets its own credential rather than one shared fleet secret, so a laptop can be revoked without re-enrolling everything.

The relay also serves the client bundle, so one URL is the whole thing: open https://relay.example/ and it asks for the token, then lists every enrolled machine with its sessions. The token is sent in the first frame, never as a query parameter — a URL ends up in access logs, in intermediate proxies and in browser history, none of which should hold the key to every machine. Failed attempts are rate-limited per address, and the address is taken from the last X-Forwarded-For hop (the one a proxy appends) so a forged header cannot invent a fresh identity per try.

deploy/README.md is the full runbook: build, token, systemd, TLS, enrolling machines, verification, upgrades, revocation, and an honest account of what the relay can see (everything — it terminates TLS and carries your keystrokes).

Tests

ci.yml runs the Go build, vet and test suite on Linux, macOS and Windows (with the race detector on Linux) plus the web typecheck and build, on every push to main and every pull request.

pnpm run test          # go test ./...
pnpm run check         # go vet + tsc --noEmit
pnpm run acceptance    # builds, then drives real Chrome against a real daemon
pnpm run acceptance:relay  # real tfrelay + tfd binaries, a session across the tunnel
pnpm run acceptance:relay-browser  # a real browser through the relay, desktop and phone
pnpm run acceptance:all            # all three

scripts/acceptance.mjs starts bin/tfd, opens two Chrome pages over CDP, and asserts the things that only break in a browser: the prompt renders, typing reaches the shell, ⌘T opens a second tab, closing a tab detaches without killing the session, a dropped socket reconnects without replaying the screen, and a second client attaches read-only, cannot type, and can take control.

scripts/relay-acceptance.mjs runs the real tfrelay and tfd binaries, enrolls a machine, and drives a shell through the tunnel. It exists for the failures the in-process Go tests cannot see: flag wiring, the enroll → configure handshake, and the base64 layering a client has to get right (a tunnel payload is bytes, and so is a session frame's data).

scripts/relay-browser.mjs is the one that checks what the phone actually runs: Chrome, through a real relay, with a real member behind it. It connects with a token, creates a session, types into it, then opens a second page with phone metrics and a pointer: coarse media override. That second page has to see the same session id, attach read-only, refuse a touch key while it is a viewer, take over, and then drive the host. A scripted WebSocket client cannot prove any of that — only the real client can.

On Windows, go test ./internal/session is worth running natively: it starts a ConPTY, runs a command through it, resizes it, and checks the exit code. None of that can be verified by cross-compiling.

The Go side covers the routing itself, including the case that matters most: TestAgentRedialsAfterLinkLoss drops every link the relay holds, waits for the member to redial on its own, and asserts a session created before the outage is still alive and attachable afterwards.

Releasing

release.yml fires on a vX.Y.Z tag. It checks the tag against package.json and mygo.json (all three must agree), lifts the matching ## X.Y.Z section out of CHANGELOG.md for the release notes, and publishes the release up front. Each platform then attaches its own assets, so one failing platform cannot take the rest of the download with it, and a final job hashes whatever landed into checksums.txt.

# 1. agree on the version
$EDITOR package.json mygo.json CHANGELOG.md
git commit -am "chore(release): v0.2.0"
# 2. tag and push — the tag is what triggers the build
git tag v0.2.0 && git push origin main --tags

Nothing is code-signed, so there is no updater key to configure: the workflow uses nothing but the GITHUB_TOKEN GitHub hands it. To exercise the whole flow without cutting a tag, dispatch release from the Actions tab with an existing tag as input.

Two rules that shape everything

Attaching is not creating. A client that disconnects — phone slept, Wi-Fi dropped, laptop lid closed — is detached, never closed. The shell keeps running and the client reattaches by cursor, receiving only the bytes it missed.

One controller per session. A PTY has one size and one stdin. If every client could resize, the screen would thrash as the desktop and the phone fought over cols/rows; if every client could type, a phone in a pocket would interfere with real work. So a session has at most one controller that may type, resize, and close, and any number of viewers that get a read-only mirror. Input from a viewer is refused with not_controller rather than dropped, so a client can offer to take control. Control is taken with claim and given back with release, and a controller that sits silent for -idle-controller loses it automatically.

Closing a tab is not killing a session. The tab's × detaches — the shell keeps running and stays in the session list, which is what you want when you were only peeking. Ending the process is a separate button, because that one is irreversible for every other client looking at it.

About

Terminal fleet — share a machine's PTY sessions over one WebSocket to LAN PC and mobile clients (1:1 terminal tabs)

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages