eleven / messenger Public

server: lchat relay — stable keypair-claimed hostnames over the tunnel (#738 Phase 1) (#748)

A relay is a trial made permanent: the client holds an Ed25519 identity
(<db>.relay-id), claims its hostname by signing a gateway challenge, and
the hostname belongs to that keypair from then on. No 24h age-out; a
dropped connection puts the hostname to sleep (503, honest copy) for a
15-minute grace window while the client auto-reconnects with backoff;
reconnects of an existing claim are never rate-limited. Claims persist
in the gateway's own sqlite; routes stay ordinary registry service rows
(idempotent re-assert for our own socket — a reattach re-registers a
route that never went away). Old clients are untouched: a hello without
mode is a trial, byte-for-byte.

Ships dormant: relays need lchat tunnel serve --relay-domain, which the
fleet gateway doesn't set yet (dedicated domain first — reputation
isolation, docs/relay-mode.md).

Tests: claim → reconnect → same hostname; sleeping hostname answers 503
then revives on reattach; grace expiry withdraws the route but keeps
the claim; second identity gets its own hostname; relay hello refused
without a relay domain; claim signature verification.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f344fb154b8905c10c5431201b8ed95fb8776ba8 parent fd6be7d
10 files changed, +841 −9
  • docs/relay-mode.md +70 −0
  • internal/tunnel/claims.go +100 −0
  • internal/tunnel/client.go +20 −1
  • internal/tunnel/gateway.go +37 −3
  • internal/tunnel/relay.go +199 −0
  • internal/tunnel/relay_test.go +206 −0
  • internal/tunnel/tunnel.go +9 −0
  • main.go +3 −0
  • relaycmd.go +175 −0
  • trycmd.go +22 −5
diff --git a/docs/relay-mode.md b/docs/relay-mode.md
new file mode 100644
index 0000000..aaaa3d9
--- /dev/null
+++ b/docs/relay-mode.md
@@ -0,0 +1,70 @@
+# 🤖 Relay mode — self-hosting with zero routing
+
+`lchat relay` is the permanent sibling of `lchat try` (issue #738, Phase 1):
+your instance runs on your own machine, dials out, and keeps a **stable
+public hostname** — no port forward, no domain, no DNS, no certificate, no
+root. Ships dormant: it only works against a gateway that offers a relay
+domain, and the fleet gateway doesn't yet.
+
+```sh
+lchat relay # run a space from ./lchat-relay.db, keep it relayed
+lchat relay --local 127.0.0.1:1747 # relay an instance you already run
+```
+
+## What makes a relay different from a trial
+
+| | `lchat try` | `lchat relay` |
+|---|---|---|
+| Hostname | random, dies with the process | claimed by your keypair, permanent |
+| Lifetime | 24 hours max | unlimited |
+| Disconnect | address retires | address sleeps (503) for a grace window, then retires; reconnect revives it |
+| Reconnect | new address | same address, automatic (backoff ≤ ~32s) |
+| Admission | 5/IP/hour | same for *new* claims; reconnects are never rate-limited |
+
+Everything else is identical: the door probe (only Eleven instances), the
+per-tunnel connection cap, the re-probe every 10 minutes, and the E2EE
+posture — the relay carries the same ciphertext any Eleven server carries.
+
+## The identity
+
+On first run the client writes an Ed25519 seed to `<db>.relay-id` (or
+`lchat-relay.id` with `--local`, or `--identity <path>`). **The file is the
+hostname**: claiming works by signing a gateway nonce, so whoever holds the
+key holds the name. Back it up; lose it and the name is gone (the gateway
+operator can free an orphaned claim by deleting its row in the claims db).
+
+Wire shape (internal/tunnel): hello carries `mode:"relay"`; the gateway
+answers `challenge{nonce}`; the client sends `claim{pubkey,sig}`. A hello
+without `mode` is a trial and follows the original flow byte-for-byte, so
+old clients are unaffected.
+
+## Gateway operations
+
+```sh
+lchat tunnel serve --relay-domain <domain> [--claims /data/tunnel-claims.db] [--max-relays 200]
+```
+
+- **Use a dedicated domain**, not the trial/instance domain — relayed
+ content shares its reputation. It needs a wildcard A record and a
+ wildcard cert (same DNS-01 setup 11ch.at uses).
+- Claims persist in the gateway's own sqlite (`relay_claims`: host, pubkey,
+ timestamps). Routes are the ordinary registry `service` rows; on gateway
+ restart stale `r-%` rows are swept and reappear as clients reconnect
+ (within ~32s).
+- Grace window: 15 minutes by default. A sleeping relay answers 503 with an
+ honest "this space's machine is offline" message.
+- `lchat tunnel kill r-….<domain>` withdraws the route; the claim stays.
+
+## Honest limits (put these on any page that sells it)
+
+- Your space's uptime is your machine's uptime. Lid closes, space naps.
+- Throughput is your home uplink, relayed twice through the gateway box.
+- TLS terminates at the gateway (hosted-grade transport trust; content is
+ E2EE regardless). Phase 2 — SNI passthrough, where your machine holds its
+ own certificate and the relay sees only opaque bytes — is designed in
+ #738 and is the reason relay routes stay a distinct concept.
+
+## Not in Phase 1 (see #738)
+
+Memorable/chosen slugs, bring-your-own-domain (CNAME), SNI passthrough,
+relay plans/billing, and gateway sharding.
diff --git a/internal/tunnel/claims.go b/internal/tunnel/claims.go
new file mode 100644
index 0000000..a152c2e
--- /dev/null
+++ b/internal/tunnel/claims.go
@@ -0,0 +1,100 @@
+package tunnel
+
+// Relay claims: the persistence behind relay mode (docs/relay-mode.md,
+// issue #738 Phase 1). A relay is a tunnel with a stable identity: the client
+// holds an Ed25519 keypair, and the hostname it is assigned on first claim
+// belongs to that keypair from then on. Reconnects prove the key and get the
+// same hostname back; nobody else can take it.
+//
+// Claims live in the gateway's own small SQLite database — deliberately not
+// the router registry, which stays a routing table. The gateway remains the
+// only writer.
+
+import (
+ "crypto/ed25519"
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+type Claims struct{ sql *sql.DB }
+
+func OpenClaims(path string) (*Claims, error) {
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ d.SetMaxOpenConns(1)
+ for _, p := range []string{
+ "PRAGMA journal_mode=WAL",
+ "PRAGMA busy_timeout=5000",
+ } {
+ if _, err := d.Exec(p); err != nil {
+ return nil, err
+ }
+ }
+ if _, err := d.Exec(`
+CREATE TABLE IF NOT EXISTS relay_claims (
+ host TEXT PRIMARY KEY,
+ pubkey TEXT NOT NULL UNIQUE, -- base64url raw ed25519 public key
+ created_at INTEGER NOT NULL,
+ last_seen INTEGER NOT NULL
+);`); err != nil {
+ return nil, err
+ }
+ return &Claims{sql: d}, nil
+}
+
+// HostFor returns the hostname already claimed by pubkey ("" if none).
+func (c *Claims) HostFor(pubkey string) (string, error) {
+ var host string
+ err := c.sql.QueryRow(`SELECT host FROM relay_claims WHERE pubkey = ?`, pubkey).Scan(&host)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return host, err
+}
+
+// Owner returns the pubkey that owns host ("" if unclaimed).
+func (c *Claims) Owner(host string) (string, error) {
+ var pk string
+ err := c.sql.QueryRow(`SELECT pubkey FROM relay_claims WHERE host = ?`, host).Scan(&pk)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return pk, err
+}
+
+// Claim records host as owned by pubkey (first come, first kept).
+func (c *Claims) Claim(host, pubkey string) error {
+ now := time.Now().Unix()
+ _, err := c.sql.Exec(
+ `INSERT INTO relay_claims(host, pubkey, created_at, last_seen) VALUES(?,?,?,?)`,
+ host, pubkey, now, now)
+ return err
+}
+
+// Touch updates last_seen for pubkey's claim.
+func (c *Claims) Touch(pubkey string) {
+ _, _ = c.sql.Exec(`UPDATE relay_claims SET last_seen = ? WHERE pubkey = ?`, time.Now().Unix(), pubkey)
+}
+
+// VerifyClaimSig checks that sig (base64url) signs nonce with pubkey
+// (base64url raw ed25519).
+func VerifyClaimSig(pubkey, nonce, sig string) error {
+ pk, err := base64.RawURLEncoding.DecodeString(pubkey)
+ if err != nil || len(pk) != ed25519.PublicKeySize {
+ return fmt.Errorf("bad public key")
+ }
+ sg, err := base64.RawURLEncoding.DecodeString(sig)
+ if err != nil {
+ return fmt.Errorf("bad signature encoding")
+ }
+ if !ed25519.Verify(ed25519.PublicKey(pk), []byte(nonce), sg) {
+ return fmt.Errorf("signature doesn't verify")
+ }
+ return nil
+}
diff --git a/internal/tunnel/client.go b/internal/tunnel/client.go
index 59b9741..ec36356 100644
--- a/internal/tunnel/client.go
+++ b/internal/tunnel/client.go
@@ -2,6 +2,8 @@ package tunnel
import (
"context"
+ "crypto/ed25519"
+ "encoding/base64"
"fmt"
"io"
"net"
@@ -19,6 +21,10 @@ type Client struct {
Gateway string // gateway origin, e.g. "https://tunnel.11ch.at" (or http:// in tests)
LocalAddr string // where the local instance listens, e.g. "127.0.0.1:52801"
Version string // informative, sent in the hello
+ // Relay mode: with Identity set the client says hello with Mode "relay"
+ // and answers the gateway's challenge, claiming (or reclaiming) the
+ // stable hostname that belongs to this keypair. Nil Identity = a trial.
+ Identity ed25519.PrivateKey
}
// Run registers, reports the assigned public host on ready, then serves dials
@@ -34,7 +40,11 @@ func (cl *Client) Run(ctx context.Context, ready func(host string)) error {
defer c.CloseNow()
c.SetReadLimit(1 << 16)
- if err := writeJSON(ctx, c, ControlMsg{Type: "hello", Version: cl.Version}); err != nil {
+ hello := ControlMsg{Type: "hello", Version: cl.Version}
+ if cl.Identity != nil {
+ hello.Mode = "relay"
+ }
+ if err := writeJSON(ctx, c, hello); err != nil {
return err
}
@@ -68,6 +78,15 @@ func (cl *Client) Run(ctx context.Context, ready func(host string)) error {
return fmt.Errorf("tunnel closed: %w", err)
}
switch m.Type {
+ case "challenge":
+ if cl.Identity == nil {
+ return fmt.Errorf("gateway sent a challenge but no relay identity is set")
+ }
+ pub := base64.RawURLEncoding.EncodeToString(cl.Identity.Public().(ed25519.PublicKey))
+ sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(cl.Identity, []byte(m.Nonce)))
+ if err := writeJSON(ctx, c, ControlMsg{Type: "claim", PubKey: pub, Sig: sig}); err != nil {
+ return err
+ }
case "dial":
go cl.serveDial(ctx, m.ID)
case "ready":
diff --git a/internal/tunnel/gateway.go b/internal/tunnel/gateway.go
index 60324ec..8676217 100644
--- a/internal/tunnel/gateway.go
+++ b/internal/tunnel/gateway.go
@@ -34,6 +34,14 @@ type Gateway struct {
OnRegister func(host string) error
OnClose func(host string)
+ // Relay mode: stable keypair-claimed hostnames (docs/relay-mode.md).
+ // Off unless RelayDomain and ClaimsDB are both set — the gateway then
+ // refuses "relay" hellos, and trials behave exactly as before.
+ RelayDomain string // e.g. "example-relay.net" — relay hosts are r-<slug>.<RelayDomain>
+ ClaimsDB *Claims // claim persistence (gateway-owned sqlite)
+ MaxRelays int // claimed hostnames (0 = default 200)
+ RelayGrace time.Duration // route lifetime after a relay disconnects (0 = default 15m)
+
MaxTunnels int // concurrent tunnels (0 = default 50)
MaxConns int // concurrent data connections per tunnel (0 = default 32)
MaxPerIP int // new tunnels per IP per hour (0 = default 5)
@@ -53,6 +61,13 @@ type liveTunnel struct {
created time.Time
closed chan struct{}
once sync.Once
+ // relay tunnels survive a disconnect: sleeping keeps the hostname
+ // routable (answering 503) for RelayGrace so a home connection blip
+ // isn't an outage; reattach cancels the grace timer.
+ relay bool
+ pubkey string
+ sleeping bool
+ grace *time.Timer
// conns counts live + in-flight data connections, bounding how much of the
// trial machine's uplink one hostile visitor can claim.
conns atomic.Int32
@@ -106,6 +121,13 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "this trial space isn't running — its lchat try command has stopped", http.StatusBadGateway)
return
}
+ g.mu.Lock()
+ asleep := t.sleeping
+ g.mu.Unlock()
+ if asleep {
+ http.Error(w, "this space's machine is offline right now — it reconnects automatically when it's back", http.StatusServiceUnavailable)
+ return
+ }
t.proxy.ServeHTTP(w, r)
}
@@ -132,6 +154,11 @@ func (g *Gateway) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
+ if hello.Mode == "relay" {
+ g.handleRelayRegister(ctx, c, ip)
+ return
+ }
+
host, err := g.newHost()
if err != nil {
writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()})
@@ -368,20 +395,27 @@ func (g *Gateway) probeBackend(ctx context.Context, t *liveTunnel) error {
return nil
}
-// drop removes the tunnel everywhere, once.
+// drop removes the tunnel everywhere, once. The map guard matters for
+// relays: a superseded tunnel dropping late must not take down the
+// reattached one that now owns the hostname.
func (g *Gateway) drop(t *liveTunnel) {
t.once.Do(func() {
close(t.closed)
g.mu.Lock()
- delete(g.tunnels, t.host)
+ owns := g.tunnels[t.host] == t
+ if owns {
+ delete(g.tunnels, t.host)
+ }
g.mu.Unlock()
- if g.OnClose != nil {
+ if owns && g.OnClose != nil {
g.OnClose(t.host)
}
t.control.CloseNow()
})
}
+var errNoFreeHost = fmt.Errorf("couldn't find a free address")
+
// admit enforces the per-IP rate and the global cap. Guardrails, not a wall:
// this is a free trial surface on a shared box, and the failure mode we're
// preventing is someone scripting a thousand tunnels, not a family trying it
diff --git a/internal/tunnel/relay.go b/internal/tunnel/relay.go
new file mode 100644
index 0000000..8c2ef37
--- /dev/null
+++ b/internal/tunnel/relay.go
@@ -0,0 +1,199 @@
+package tunnel
+
+// The relay half of the gateway (docs/relay-mode.md, issue #738 Phase 1):
+// stable, keypair-claimed hostnames with a disconnect grace window. Trials
+// (gateway.go) stay untouched; a relay differs in exactly three ways — its
+// hostname belongs to its keypair, it has no 24-hour age-out, and a dropped
+// connection puts the hostname to sleep instead of retiring it.
+
+import (
+ "context"
+ "crypto/rand"
+ "log"
+ "math/big"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+func (g *Gateway) maxRelays() int { return orInt(g.MaxRelays, 200) }
+func (g *Gateway) relayGrace() time.Duration {
+ if g.RelayGrace > 0 {
+ return g.RelayGrace
+ }
+ return 15 * time.Minute
+}
+
+// handleRelayRegister runs after a hello with Mode "relay": challenge the
+// client to prove its keypair, look up (or mint) its hostname, then serve the
+// same probe/publish/hold flow trials get — minus the age-out, plus the
+// sleep-on-disconnect grace.
+func (g *Gateway) handleRelayRegister(ctx context.Context, c *websocket.Conn, ip string) {
+ if g.RelayDomain == "" || g.ClaimsDB == nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "this gateway doesn't offer relay hostnames"})
+ c.Close(websocket.StatusPolicyViolation, "no relay support")
+ return
+ }
+
+ nonce, err := randToken(24)
+ if err != nil {
+ return
+ }
+ if err := writeJSON(ctx, c, ControlMsg{Type: "challenge", Nonce: nonce}); err != nil {
+ return
+ }
+ var claim ControlMsg
+ if err := readJSON(ctx, c, &claim); err != nil || claim.Type != "claim" {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "expected claim"})
+ return
+ }
+ if err := VerifyClaimSig(claim.PubKey, nonce, claim.Sig); err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "claim rejected: " + err.Error()})
+ c.Close(websocket.StatusPolicyViolation, "bad claim")
+ return
+ }
+
+ host, err := g.ClaimsDB.HostFor(claim.PubKey)
+ if err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "claims store unavailable"})
+ return
+ }
+ if host == "" {
+ // A brand-new claim pays the same admission toll as a trial; a
+ // reconnect of an existing claim never does (a flaky home line must
+ // not rate-limit itself out of its own hostname).
+ if err := g.admit(ip); err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()})
+ return
+ }
+ count := 0
+ g.ClaimsDB.sql.QueryRow(`SELECT COUNT(*) FROM relay_claims`).Scan(&count)
+ if count >= g.maxRelays() {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "this gateway has no free relay hostnames"})
+ return
+ }
+ if host, err = g.newRelayHost(); err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()})
+ return
+ }
+ if err := g.ClaimsDB.Claim(host, claim.PubKey); err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't record the claim, try again"})
+ return
+ }
+ log.Printf("tunnel: relay %s claimed (from %s)", host, ip)
+ }
+ g.ClaimsDB.Touch(claim.PubKey)
+
+ t := &liveTunnel{host: host, control: c, created: time.Now(), closed: make(chan struct{}),
+ relay: true, pubkey: claim.PubKey}
+ t.proxy = g.proxyFor(t)
+
+ // Reattach: replace a sleeping (or superseded) tunnel for this host.
+ g.mu.Lock()
+ if old := g.tunnels[host]; old != nil {
+ if old.grace != nil {
+ old.grace.Stop()
+ }
+ // A previous live connection for the same key is superseded — the
+ // newest device wins, matching how people actually move machines.
+ go g.drop(old)
+ }
+ g.tunnels[host] = t
+ g.mu.Unlock()
+
+ if err := g.probeBackend(ctx, t); err != nil {
+ msg := "only Eleven instances can be relayed: " + err.Error()
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: msg})
+ c.Close(websocket.StatusPolicyViolation, "refused")
+ g.drop(t)
+ log.Printf("tunnel: relay %s refused (probe): %v", host, err)
+ return
+ }
+ if g.OnRegister != nil {
+ if err := g.OnRegister(host); err != nil {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't publish the address, try again"})
+ g.drop(t)
+ log.Printf("tunnel: relay register %s: %v", host, err)
+ return
+ }
+ }
+ log.Printf("tunnel: relay %s connected (from %s)", host, ip)
+ if err := writeJSON(ctx, c, ControlMsg{Type: "ready", Host: host}); err != nil {
+ g.sleepTunnel(t)
+ return
+ }
+
+ // Hold: no age-out; re-probe like trials.
+ reprobe := time.NewTicker(10 * time.Minute)
+ defer reprobe.Stop()
+ probeFails := 0
+ readErr := make(chan error, 1)
+ go func() {
+ for {
+ if _, _, err := c.Read(ctx); err != nil {
+ readErr <- err
+ return
+ }
+ }
+ }()
+ for {
+ select {
+ case <-readErr:
+ g.sleepTunnel(t)
+ log.Printf("tunnel: relay %s sleeping (disconnected; grace %s)", host, g.relayGrace())
+ return
+ case <-reprobe.C:
+ if err := g.probeBackend(ctx, t); err != nil {
+ probeFails++
+ if probeFails >= 2 {
+ writeJSON(ctx, c, ControlMsg{Type: "error", Error: "the relayed backend stopped looking like an Eleven instance"})
+ g.drop(t)
+ log.Printf("tunnel: relay %s dropped (re-probe): %v", host, err)
+ return
+ }
+ } else {
+ probeFails = 0
+ }
+ case <-t.closed:
+ return
+ case <-ctx.Done():
+ g.sleepTunnel(t)
+ return
+ }
+ }
+}
+
+// sleepTunnel keeps a relay's hostname routable-but-503 for the grace window,
+// then retires it fully. The claim itself persists either way — the hostname
+// still belongs to the keypair and comes back on the next connect.
+func (g *Gateway) sleepTunnel(t *liveTunnel) {
+ g.mu.Lock()
+ if g.tunnels[t.host] != t { // superseded by a reattach
+ g.mu.Unlock()
+ return
+ }
+ t.sleeping = true
+ t.grace = time.AfterFunc(g.relayGrace(), func() { g.drop(t) })
+ g.mu.Unlock()
+ t.control.CloseNow()
+}
+
+func (g *Gateway) newRelayHost() (string, error) {
+ for range 20 {
+ slug := make([]byte, 6)
+ for i := range slug {
+ n, err := rand.Int(rand.Reader, big.NewInt(int64(len(slugAlphabet))))
+ if err != nil {
+ return "", err
+ }
+ slug[i] = slugAlphabet[n.Int64()]
+ }
+ host := "r-" + string(slug) + "." + g.RelayDomain
+ if owner, err := g.ClaimsDB.Owner(host); err != nil {
+ return "", err
+ } else if owner == "" {
+ return host, nil
+ }
+ }
+ return "", errNoFreeHost
+}
diff --git a/internal/tunnel/relay_test.go b/internal/tunnel/relay_test.go
new file mode 100644
index 0000000..7588a5a
--- /dev/null
+++ b/internal/tunnel/relay_test.go
@@ -0,0 +1,206 @@
+package tunnel
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/base64"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// An Eleven-shaped backend the door probe accepts.
+func fakeInstance(t *testing.T) *httptest.Server {
+ t.Helper()
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/api/version" {
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"build":"test","version":"test-1"}`)
+ return
+ }
+ io.WriteString(w, "instance says hi")
+ }))
+ t.Cleanup(s.Close)
+ return s
+}
+
+func relayGateway(t *testing.T) (*Gateway, *httptest.Server) {
+ t.Helper()
+ claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ gw := &Gateway{Domain: "test.local", RelayDomain: "relay.local", ClaimsDB: claims,
+ DialTimeout: 5 * time.Second, RelayGrace: 200 * time.Millisecond}
+ srv := httptest.NewServer(gw)
+ t.Cleanup(srv.Close)
+ u, _ := url.Parse(srv.URL)
+ gw.ControlHost = u.Hostname()
+ return gw, srv
+}
+
+// A relay claims a hostname, loses its connection, and gets the SAME
+// hostname back on reconnect; meanwhile the sleeping hostname answers 503
+// instead of vanishing.
+func TestRelayClaimReconnectAndGrace(t *testing.T) {
+ backend := fakeInstance(t)
+ gw, srv := relayGateway(t)
+ gw.RelayGrace = 10 * time.Second // long enough to observe sleeping
+
+ _, priv, _ := ed25519.GenerateKey(rand.Reader)
+ run := func(ctx context.Context) (string, chan error) {
+ cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
+ ready := make(chan string, 1)
+ done := make(chan error, 1)
+ go func() { done <- cl.Run(ctx, func(h string) { ready <- h }) }()
+ select {
+ case h := <-ready:
+ return h, done
+ case err := <-done:
+ t.Fatalf("client exited early: %v", err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("no ready")
+ }
+ return "", done
+ }
+
+ ctx1, cancel1 := context.WithCancel(context.Background())
+ host1, _ := run(ctx1)
+ if !strings.HasPrefix(host1, "r-") || !strings.HasSuffix(host1, ".relay.local") {
+ t.Fatalf("unexpected relay host %q", host1)
+ }
+
+ // Drop the connection: the hostname sleeps (503) rather than vanishing.
+ cancel1()
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ gw.mu.Lock()
+ tun := gw.tunnels[host1]
+ sleeping := tun != nil && tun.sleeping
+ gw.mu.Unlock()
+ if sleeping {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("tunnel never went to sleep")
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ rt := http.DefaultTransport.(*http.Transport).Clone()
+ httpc := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ r.Host = host1
+ return rt.RoundTrip(r)
+ })}
+ resp, err := httpc.Get(srv.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("sleeping relay answered %d, want 503", resp.StatusCode)
+ }
+
+ // Reconnect with the same identity: same hostname, traffic flows again.
+ ctx2, cancel2 := context.WithCancel(context.Background())
+ defer cancel2()
+ host2, _ := run(ctx2)
+ if host2 != host1 {
+ t.Fatalf("reconnect got %q, want the claimed %q", host2, host1)
+ }
+ resp, err = httpc.Get(srv.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if string(body) != "instance says hi" {
+ t.Fatalf("after reattach body = %q", body)
+ }
+
+ // A different identity gets a different hostname.
+ _, priv2, _ := ed25519.GenerateKey(rand.Reader)
+ cl2 := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv2}
+ ready2 := make(chan string, 1)
+ ctx3, cancel3 := context.WithCancel(context.Background())
+ defer cancel3()
+ go cl2.Run(ctx3, func(h string) { ready2 <- h })
+ select {
+ case h := <-ready2:
+ if h == host1 {
+ t.Fatal("second identity was handed the first identity's hostname")
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("second identity never became ready")
+ }
+}
+
+// After the grace window expires, the hostname is fully retired (route
+// withdrawn) — but the claim survives, so the next connect gets it back.
+func TestRelayGraceExpiry(t *testing.T) {
+ backend := fakeInstance(t)
+ gw, srv := relayGateway(t) // grace 200ms
+ closed := make(chan string, 1)
+ gw.OnClose = func(h string) { closed <- h }
+
+ _, priv, _ := ed25519.GenerateKey(rand.Reader)
+ cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
+ ready := make(chan string, 1)
+ ctx, cancel := context.WithCancel(context.Background())
+ go cl.Run(ctx, func(h string) { ready <- h })
+ var host string
+ select {
+ case host = <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("no ready")
+ }
+ cancel()
+ select {
+ case got := <-closed:
+ if got != host {
+ t.Fatalf("OnClose got %q", got)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("grace never expired")
+ }
+ if owner, _ := gw.ClaimsDB.Owner(host); owner == "" {
+ t.Fatal("claim was lost with the grace expiry")
+ }
+}
+
+// A gateway without relay support refuses relay hellos with a clear message.
+func TestRelayRefusedWithoutDomain(t *testing.T) {
+ backend := fakeInstance(t)
+ gw := &Gateway{Domain: "test.local"}
+ srv := httptest.NewServer(gw)
+ defer srv.Close()
+ u, _ := url.Parse(srv.URL)
+ gw.ControlHost = u.Hostname()
+
+ _, priv, _ := ed25519.GenerateKey(rand.Reader)
+ cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
+ err := cl.Run(context.Background(), func(string) {})
+ if err == nil || !strings.Contains(err.Error(), "doesn't offer relay") {
+ t.Fatalf("expected relay refusal, got %v", err)
+ }
+}
+
+func TestVerifyClaimSig(t *testing.T) {
+ pub, priv, _ := ed25519.GenerateKey(rand.Reader)
+ pk := base64.RawURLEncoding.EncodeToString(pub)
+ sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(priv, []byte("nonce-1")))
+ if err := VerifyClaimSig(pk, "nonce-1", sig); err != nil {
+ t.Fatalf("valid sig rejected: %v", err)
+ }
+ if err := VerifyClaimSig(pk, "nonce-2", sig); err == nil {
+ t.Fatal("replayed sig accepted for a different nonce")
+ }
+ if err := VerifyClaimSig("not-a-key", "nonce-1", sig); err == nil {
+ t.Fatal("garbage key accepted")
+ }
+}
diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go
index 929fc84..3105560 100644
--- a/internal/tunnel/tunnel.go
+++ b/internal/tunnel/tunnel.go
@@ -36,6 +36,15 @@ type ControlMsg struct {
Host string `json:"host,omitempty"`
ID string `json:"id,omitempty"`
Error string `json:"error,omitempty"`
+ // Relay mode (docs/relay-mode.md): a client claiming a stable hostname
+ // sends hello with Mode "relay"; the gateway answers with a "challenge"
+ // carrying Nonce, and the client proves its keypair with a "claim"
+ // carrying PubKey + Sig over the nonce. Absent Mode means a trial —
+ // exactly the original flow, so older clients are unaffected.
+ Mode string `json:"mode,omitempty"`
+ Nonce string `json:"nonce,omitempty"`
+ PubKey string `json:"pubkey,omitempty"`
+ Sig string `json:"sig,omitempty"`
}
// slugAlphabet is lowercase and unambiguous (no 0/o, 1/l/i) — these end up in
diff --git a/main.go b/main.go
index 773c9d0..38fa4d8 100644
--- a/main.go
+++ b/main.go
@@ -181,6 +181,9 @@ func main() {
case "try":
runTryCmd(os.Args[2:])
return
+ case "relay":
+ runRelayCmd(os.Args[2:])
+ return
case "tunnel":
runTunnelCmd(os.Args[2:])
return
diff --git a/relaycmd.go b/relaycmd.go
new file mode 100644
index 0000000..c7a57da
--- /dev/null
+++ b/relaycmd.go
@@ -0,0 +1,175 @@
+package main
+
+// `lchat relay` — self-hosting with zero routing (docs/relay-mode.md,
+// issue #738 Phase 1). Like `lchat try`, but permanent: the client holds an
+// Ed25519 identity, the hostname it claims belongs to that identity, there
+// is no 24-hour age-out, and the connection heals itself — a dropped line
+// reconnects with backoff, and the gateway keeps the hostname parked (503)
+// for a grace window meanwhile.
+//
+// Dormant until a gateway offers a relay domain (`lchat tunnel serve
+// --relay-domain …`); the fleet gateway doesn't yet.
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/base64"
+ "flag"
+ "fmt"
+ "math/big"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "lchat/internal/tunnel"
+)
+
+func runRelayCmd(args []string) {
+ fs := flag.NewFlagSet("relay", flag.ExitOnError)
+ gateway := fs.String("gateway", envOr("LCHAT_TUNNEL_GATEWAY", defaultTunnelGateway), "tunnel gateway origin")
+ dbPath := fs.String("db", "lchat-relay.db", "where the space's data lives")
+ local := fs.String("local", "", "relay an ALREADY-RUNNING local instance at this addr instead of starting one")
+ idPath := fs.String("identity", "", "relay identity file (default: <db>.relay-id, or lchat-relay.id with --local)")
+ _ = fs.Parse(reorderFlagsFirst(fs, args))
+
+ keyFile := *idPath
+ if keyFile == "" {
+ if *local != "" {
+ keyFile = "lchat-relay.id"
+ } else {
+ keyFile = *dbPath + ".relay-id"
+ }
+ }
+ identity, freshIdentity, err := loadOrCreateRelayIdentity(keyFile)
+ if err != nil {
+ die("relay: %v", err)
+ }
+ if freshIdentity {
+ fmt.Printf(" New relay identity saved to %s — it IS your hostname; back it up.\n", keyFile)
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ // The instance: an existing one (--local), or a subprocess of this binary
+ // exactly like `lchat try` runs.
+ localAddr := *local
+ adminToken := ""
+ freshDB := false
+ if localAddr == "" {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ die("relay: %v", err)
+ }
+ localAddr = l.Addr().String()
+ l.Close()
+
+ tokenFile := *dbPath + ".admin-token"
+ adminToken = strings.TrimSpace(readFileOr(tokenFile, ""))
+ freshDB = !fileExists(*dbPath)
+ if adminToken == "" {
+ if adminToken, err = randSlugToken(); err != nil {
+ die("relay: %v", err)
+ }
+ }
+ fmt.Println(" Starting your space…")
+ child := exec.Command(mustExecutable(), "-addr", localAddr, "-db", *dbPath)
+ child.Env = append(os.Environ(), "LCHAT_ADMIN_TOKEN="+adminToken)
+ logFile, err := os.OpenFile(*dbPath+".log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+ if err != nil {
+ die("relay: %v", err)
+ }
+ child.Stdout, child.Stderr = logFile, logFile
+ if err := child.Start(); err != nil {
+ die("relay: start local instance: %v", err)
+ }
+ defer func() {
+ _ = child.Process.Signal(syscall.SIGTERM)
+ _, _ = child.Process.Wait()
+ }()
+ if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 15*time.Second); err != nil {
+ die("relay: the local instance didn't come up (%v) — see %s.log", err, *dbPath)
+ }
+ if freshDB {
+ if err := os.WriteFile(tokenFile, []byte(adminToken+"\n"), 0o600); err != nil {
+ die("relay: %v", err)
+ }
+ }
+ } else if err := tunnel.WaitReachable("http://"+localAddr+"/api/version", 5*time.Second); err != nil {
+ die("relay --local: nothing answering at %s (%v)", localAddr, err)
+ }
+
+ // Connect, and keep connecting: relay mode's contract is that a dropped
+ // line is a blip, not an outage. Backoff doubles to 30s with jitter; the
+ // gateway parks the hostname for its grace window between attempts.
+ printedBanner := false
+ attempt := 0
+ for {
+ cl := &tunnel.Client{Gateway: *gateway, LocalAddr: localAddr, Version: buildVersion, Identity: identity}
+ err := cl.Run(ctx, func(host string) {
+ attempt = 0
+ origin := "https://" + host
+ if printedBanner {
+ fmt.Printf(" ✔ reconnected — %s is live again\n", origin)
+ return
+ }
+ printedBanner = true
+ if err := tunnel.WaitReachable(origin+"/api/version", 30*time.Second); err != nil {
+ fmt.Printf(" ! %s isn't answering yet (%v) — it usually settles in seconds\n", origin, err)
+ }
+ fmt.Printf("\n ✔ Your space is live: %s\n", origin)
+ fmt.Println(" This hostname is yours: it belongs to your relay identity and")
+ fmt.Println(" survives restarts, reboots, and moving house.")
+ if freshDB && adminToken != "" {
+ if link, err := mintSetupInvite(http.DefaultClient, "http://"+localAddr, origin, adminToken); err == nil {
+ fmt.Printf(" ✔ Claim it (you become the admin): %s\n", link)
+ }
+ }
+ fmt.Println()
+ })
+ if ctx.Err() != nil {
+ fmt.Println(" Stopping — your hostname keeps waiting for you.")
+ return
+ }
+ if err != nil {
+ fmt.Printf(" tunnel dropped: %v — reconnecting…\n", err)
+ }
+ attempt++
+ delay := time.Duration(1<<min(attempt, 5)) * time.Second // 2s..32s
+ if j, err := rand.Int(rand.Reader, big.NewInt(int64(delay/4)+1)); err == nil {
+ delay += time.Duration(j.Int64())
+ }
+ select {
+ case <-time.After(delay):
+ case <-ctx.Done():
+ fmt.Println(" Stopping — your hostname keeps waiting for you.")
+ return
+ }
+ }
+}
+
+// loadOrCreateRelayIdentity reads (or mints) the Ed25519 seed that owns the
+// relay hostname. The file is the identity: lose it, lose the name.
+func loadOrCreateRelayIdentity(path string) (ed25519.PrivateKey, bool, error) {
+ if b, err := os.ReadFile(path); err == nil {
+ seed, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(b)))
+ if err != nil || len(seed) != ed25519.SeedSize {
+ return nil, false, fmt.Errorf("%s doesn't look like a relay identity", path)
+ }
+ return ed25519.NewKeyFromSeed(seed), false, nil
+ }
+ seed := make([]byte, ed25519.SeedSize)
+ if _, err := rand.Read(seed); err != nil {
+ return nil, false, err
+ }
+ if err := os.WriteFile(path, []byte(base64.RawURLEncoding.EncodeToString(seed)+"\n"), 0o600); err != nil {
+ return nil, false, err
+ }
+ return ed25519.NewKeyFromSeed(seed), true, nil
+}
diff --git a/trycmd.go b/trycmd.go
index d2329e3..7132a9a 100644
--- a/trycmd.go
+++ b/trycmd.go
@@ -195,6 +195,9 @@ func runTunnelCmd(args []string) {
host := fs.String("host", envOr("LCHAT_TUNNEL_HOST", "tunnel.11ch.at"), "the gateway's own control hostname")
domain := fs.String("domain", envOr("LCHAT_TUNNEL_DOMAIN", "11ch.at"), "domain trial hosts are minted under (needs the wildcard cert)")
maxTunnels := fs.Int("max", 50, "maximum concurrent trial tunnels")
+ relayDomain := fs.String("relay-domain", envOr("LCHAT_TUNNEL_RELAY_DOMAIN", ""), "offer stable relay hostnames under this domain (empty = relays off; docs/relay-mode.md)")
+ claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store (with --relay-domain)")
+ maxRelays := fs.Int("max-relays", 200, "maximum claimed relay hostnames")
_ = fs.Parse(reorderFlagsFirst(fs, args))
reg, err := openRegistry(*registry)
@@ -211,11 +214,15 @@ func runTunnelCmd(args []string) {
ControlHost: *host,
Domain: *domain,
MaxTunnels: *maxTunnels,
+ MaxRelays: *maxRelays,
OnRegister: func(h string) error {
if existing, err := reg.get(h); err != nil {
return err
- } else if existing != nil {
- return fmt.Errorf("host %s already routed", h)
+ } else if existing != nil && existing.Socket != *socket {
+ // Never steal a hostname something else answers for. Our own
+ // rows are fine to re-assert: a relay reattaching within its
+ // grace window re-registers a route that never went away.
+ return fmt.Errorf("host %s already routed elsewhere", h)
}
// The router's registry watcher (router.go) notices the write and
// reloads within a couple of seconds — no signal needed from here.
@@ -228,6 +235,15 @@ func runTunnelCmd(args []string) {
},
}
+ if *relayDomain != "" {
+ claims, err := tunnel.OpenClaims(*claimsPath)
+ if err != nil {
+ die("tunnel serve: open claims: %v", err)
+ }
+ gw.RelayDomain = *relayDomain
+ gw.ClaimsDB = claims
+ }
+
_ = os.Remove(*socket)
ln, err := net.Listen("unix", *socket)
if err != nil {
@@ -249,8 +265,8 @@ func runTunnelKill(args []string) {
registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry")
_ = fs.Parse(reorderFlagsFirst(fs, args))
host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
- if !strings.HasPrefix(host, "try-") {
- die("tunnel kill: %q isn't a trial host (try-…)", host)
+ if !strings.HasPrefix(host, "try-") && !strings.HasPrefix(host, "r-") {
+ die("tunnel kill: %q isn't a tunnel host (try-… or r-…)", host)
}
reg, err := openRegistry(*registry)
if err != nil {
@@ -301,6 +317,7 @@ func randSlugToken() (string, error) {
// never touched.
func (r *Registry) deleteTunnelRoutes(socket string) error {
_, err := r.sql.Exec(
- `DELETE FROM routes WHERE kind = 'service' AND socket = ? AND host LIKE 'try-%'`, socket)
+ `DELETE FROM routes WHERE kind = 'service' AND socket = ?
+ AND (host LIKE 'try-%' OR host LIKE 'r-%')`, socket)
return err
}