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>
10 files changed,
+841
−9
docs/relay-mode.md+70 −0internal/tunnel/claims.go+100 −0internal/tunnel/client.go+20 −1internal/tunnel/gateway.go+37 −3internal/tunnel/relay.go+199 −0internal/tunnel/relay_test.go+206 −0internal/tunnel/tunnel.go+9 −0main.go+3 −0relaycmd.go+175 −0trycmd.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 |
| } |