| 1 | package tunnel |
| 2 | |
| 3 | // The relay half of the gateway (docs/relay-mode.md, issue #738 Phase 1): |
| 4 | // stable, keypair-claimed hostnames with a disconnect grace window. Trials |
| 5 | // (gateway.go) stay untouched; a relay differs in exactly three ways — its |
| 6 | // hostname belongs to its keypair, it has no 24-hour age-out, and a dropped |
| 7 | // connection puts the hostname to sleep instead of retiring it. |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "crypto/rand" |
| 12 | "log" |
| 13 | "math/big" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/coder/websocket" |
| 17 | ) |
| 18 | |
| 19 | func (g *Gateway) maxRelays() int { return orInt(g.MaxRelays, 200) } |
| 20 | func (g *Gateway) claimIdle() time.Duration { |
| 21 | if g.ClaimIdle > 0 { |
| 22 | return g.ClaimIdle |
| 23 | } |
| 24 | return 45 * 24 * time.Hour |
| 25 | } |
| 26 | |
| 27 | // StartClaimJanitor frees claims whose owners haven't connected for ClaimIdle |
| 28 | // (default 45 days), hourly. Currently-connected tunnels are never swept, no |
| 29 | // matter how old their last_seen — Touch only runs at claim time. |
| 30 | func (g *Gateway) StartClaimJanitor() { |
| 31 | if g.ClaimsDB == nil { |
| 32 | return |
| 33 | } |
| 34 | go func() { |
| 35 | for range time.Tick(time.Hour) { |
| 36 | g.mu.Lock() |
| 37 | keep := make(map[string]bool, len(g.tunnels)) |
| 38 | for h := range g.tunnels { |
| 39 | keep[h] = true |
| 40 | } |
| 41 | g.mu.Unlock() |
| 42 | freed, err := g.ClaimsDB.SweepIdle(g.claimIdle(), keep) |
| 43 | if err != nil { |
| 44 | log.Printf("tunnel: claim janitor: %v", err) |
| 45 | continue |
| 46 | } |
| 47 | for _, h := range freed { |
| 48 | log.Printf("tunnel: claim janitor freed %s (idle > %s)", h, g.claimIdle()) |
| 49 | } |
| 50 | } |
| 51 | }() |
| 52 | } |
| 53 | func (g *Gateway) relayGrace() time.Duration { |
| 54 | if g.RelayGrace > 0 { |
| 55 | return g.RelayGrace |
| 56 | } |
| 57 | return 15 * time.Minute |
| 58 | } |
| 59 | |
| 60 | // handleRelayRegister runs after a hello with Mode "relay": challenge the |
| 61 | // client to prove its keypair, look up (or mint) its hostname, then serve the |
| 62 | // same probe/publish/hold flow trials get — minus the age-out, plus the |
| 63 | // sleep-on-disconnect grace. |
| 64 | func (g *Gateway) handleRelayRegister(ctx context.Context, c *websocket.Conn, ip string) { |
| 65 | if g.RelayDomain == "" || g.ClaimsDB == nil { |
| 66 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "this gateway doesn't offer relay hostnames"}) |
| 67 | c.Close(websocket.StatusPolicyViolation, "no relay support") |
| 68 | return |
| 69 | } |
| 70 | |
| 71 | nonce, err := randToken(24) |
| 72 | if err != nil { |
| 73 | return |
| 74 | } |
| 75 | if err := writeJSON(ctx, c, ControlMsg{Type: "challenge", Nonce: nonce}); err != nil { |
| 76 | return |
| 77 | } |
| 78 | var claim ControlMsg |
| 79 | if err := readJSON(ctx, c, &claim); err != nil || claim.Type != "claim" { |
| 80 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "expected claim"}) |
| 81 | return |
| 82 | } |
| 83 | if err := VerifyClaimSig(claim.PubKey, nonce, claim.Sig); err != nil { |
| 84 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "claim rejected: " + err.Error()}) |
| 85 | c.Close(websocket.StatusPolicyViolation, "bad claim") |
| 86 | return |
| 87 | } |
| 88 | |
| 89 | host, err := g.ClaimsDB.HostFor(claim.PubKey) |
| 90 | if err != nil { |
| 91 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "claims store unavailable"}) |
| 92 | return |
| 93 | } |
| 94 | if host == "" { |
| 95 | // A brand-new claim pays the same admission toll as a trial; a |
| 96 | // reconnect of an existing claim never does (a flaky home line must |
| 97 | // not rate-limit itself out of its own hostname). |
| 98 | if err := g.admit(ip); err != nil { |
| 99 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()}) |
| 100 | return |
| 101 | } |
| 102 | count := 0 |
| 103 | g.ClaimsDB.sql.QueryRow(`SELECT COUNT(*) FROM relay_claims`).Scan(&count) |
| 104 | if count >= g.maxRelays() { |
| 105 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "this gateway has no free relay hostnames"}) |
| 106 | return |
| 107 | } |
| 108 | if host, err = g.newRelayHost(); err != nil { |
| 109 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: err.Error()}) |
| 110 | return |
| 111 | } |
| 112 | if err := g.ClaimsDB.Claim(host, claim.PubKey); err != nil { |
| 113 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't record the claim, try again"}) |
| 114 | return |
| 115 | } |
| 116 | log.Printf("tunnel: relay %s claimed (from %s)", host, ip) |
| 117 | } |
| 118 | g.ClaimsDB.Touch(claim.PubKey) |
| 119 | |
| 120 | t := &liveTunnel{host: host, control: c, created: time.Now(), closed: make(chan struct{}), |
| 121 | relay: true, pubkey: claim.PubKey} |
| 122 | t.proxy = g.proxyFor(t) |
| 123 | |
| 124 | // Reattach: replace a sleeping (or superseded) tunnel for this host. |
| 125 | g.mu.Lock() |
| 126 | if old := g.tunnels[host]; old != nil { |
| 127 | if old.grace != nil { |
| 128 | old.grace.Stop() |
| 129 | } |
| 130 | // A previous live connection for the same key is superseded — the |
| 131 | // newest device wins, matching how people actually move machines. |
| 132 | go g.drop(old) |
| 133 | } |
| 134 | g.tunnels[host] = t |
| 135 | g.mu.Unlock() |
| 136 | |
| 137 | if err := g.probeBackend(ctx, t); err != nil { |
| 138 | msg := "only Eleven instances can be relayed: " + err.Error() |
| 139 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: msg}) |
| 140 | c.Close(websocket.StatusPolicyViolation, "refused") |
| 141 | g.drop(t) |
| 142 | log.Printf("tunnel: relay %s refused (probe): %v", host, err) |
| 143 | return |
| 144 | } |
| 145 | if g.OnRegister != nil { |
| 146 | if err := g.OnRegister(host); err != nil { |
| 147 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "couldn't publish the address, try again"}) |
| 148 | g.drop(t) |
| 149 | log.Printf("tunnel: relay register %s: %v", host, err) |
| 150 | return |
| 151 | } |
| 152 | } |
| 153 | log.Printf("tunnel: relay %s connected (from %s)", host, ip) |
| 154 | if err := writeJSON(ctx, c, ControlMsg{Type: "ready", Host: host}); err != nil { |
| 155 | g.sleepTunnel(t) |
| 156 | return |
| 157 | } |
| 158 | |
| 159 | // Hold: no age-out; re-probe like trials. |
| 160 | reprobe := time.NewTicker(10 * time.Minute) |
| 161 | defer reprobe.Stop() |
| 162 | probeFails := 0 |
| 163 | readErr := make(chan error, 1) |
| 164 | go func() { |
| 165 | for { |
| 166 | if _, _, err := c.Read(ctx); err != nil { |
| 167 | readErr <- err |
| 168 | return |
| 169 | } |
| 170 | } |
| 171 | }() |
| 172 | for { |
| 173 | select { |
| 174 | case <-readErr: |
| 175 | g.sleepTunnel(t) |
| 176 | log.Printf("tunnel: relay %s sleeping (disconnected; grace %s)", host, g.relayGrace()) |
| 177 | return |
| 178 | case <-reprobe.C: |
| 179 | if err := g.probeBackend(ctx, t); err != nil { |
| 180 | probeFails++ |
| 181 | if probeFails >= 2 { |
| 182 | writeJSON(ctx, c, ControlMsg{Type: "error", Error: "the relayed backend stopped looking like an Eleven instance"}) |
| 183 | g.drop(t) |
| 184 | log.Printf("tunnel: relay %s dropped (re-probe): %v", host, err) |
| 185 | return |
| 186 | } |
| 187 | } else { |
| 188 | probeFails = 0 |
| 189 | } |
| 190 | case <-t.closed: |
| 191 | return |
| 192 | case <-ctx.Done(): |
| 193 | g.sleepTunnel(t) |
| 194 | return |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // sleepTunnel keeps a relay's hostname routable-but-503 for the grace window, |
| 200 | // then retires it fully. The claim itself persists either way — the hostname |
| 201 | // still belongs to the keypair and comes back on the next connect. |
| 202 | func (g *Gateway) sleepTunnel(t *liveTunnel) { |
| 203 | g.mu.Lock() |
| 204 | if g.tunnels[t.host] != t { // superseded by a reattach |
| 205 | g.mu.Unlock() |
| 206 | return |
| 207 | } |
| 208 | t.sleeping = true |
| 209 | t.grace = time.AfterFunc(g.relayGrace(), func() { g.drop(t) }) |
| 210 | g.mu.Unlock() |
| 211 | t.control.CloseNow() |
| 212 | } |
| 213 | |
| 214 | func (g *Gateway) newRelayHost() (string, error) { |
| 215 | for range 20 { |
| 216 | slug := make([]byte, 6) |
| 217 | for i := range slug { |
| 218 | n, err := rand.Int(rand.Reader, big.NewInt(int64(len(slugAlphabet)))) |
| 219 | if err != nil { |
| 220 | return "", err |
| 221 | } |
| 222 | slug[i] = slugAlphabet[n.Int64()] |
| 223 | } |
| 224 | host := "r-" + string(slug) + "." + g.RelayDomain |
| 225 | if owner, err := g.ClaimsDB.Owner(host); err != nil { |
| 226 | return "", err |
| 227 | } else if owner == "" { |
| 228 | return host, nil |
| 229 | } |
| 230 | } |
| 231 | return "", errNoFreeHost |
| 232 | } |
| 233 | |