server: tunnel kill --forget + idle-claim janitor for relay hostnames (#773)
kill --forget deletes the relay claim alongside the route, returning the hostname to the pool — the operator kick for test/abusive relays. An hourly gateway janitor frees claims whose owner hasn't connected for --claim-idle-days (default 45); currently-connected relays are never swept, however old their last_seen (Touch only runs at claim time).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+157
−4
docs/relay-mode.md+8 −2internal/tunnel/claims.go+37 −0internal/tunnel/gateway.go+1 −0internal/tunnel/relay.go+33 −0internal/tunnel/relay_test.go+59 −0trycmd.go+19 −2
diff --git a/docs/relay-mode.md b/docs/relay-mode.md| index aaaa3d9..bbf57eb 100644 |
| --- a/docs/relay-mode.md |
| +++ b/docs/relay-mode.md |
| @@ -41,7 +41,7 @@ old clients are unaffected. |
| ## Gateway operations |
| ```sh |
| -lchat tunnel serve --relay-domain <domain> [--claims /data/tunnel-claims.db] [--max-relays 200] |
| +lchat tunnel serve --relay-domain <domain> [--claims /data/tunnel-claims.db] [--max-relays 200] [--claim-idle-days 45] |
| ``` |
| - **Use a dedicated domain**, not the trial/instance domain — relayed |
| @@ -53,7 +53,13 @@ lchat tunnel serve --relay-domain <domain> [--claims /data/tunnel-claims.db] [-- |
| (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. |
| +- `lchat tunnel kill r-….<domain>` withdraws the route; the claim stays, so |
| + the same keypair gets the same hostname back on reconnect. Add `--forget` |
| + to also delete the claim — the hostname returns to the pool and the |
| + keypair starts fresh. That's the kick for a test or abusive relay. |
| +- Abandoned claims free themselves: an hourly janitor deletes claims whose |
| + owner hasn't connected for `--claim-idle-days` (default 45). |
| + Currently-connected relays are never swept, however old their claim. |
| ## Honest limits (put these on any page that sells it) |
diff --git a/internal/tunnel/claims.go b/internal/tunnel/claims.go| index a152c2e..9e6fc6c 100644 |
| --- a/internal/tunnel/claims.go |
| +++ b/internal/tunnel/claims.go |
| @@ -82,6 +82,43 @@ func (c *Claims) Touch(pubkey string) { |
| _, _ = c.sql.Exec(`UPDATE relay_claims SET last_seen = ? WHERE pubkey = ?`, time.Now().Unix(), pubkey) |
| } |
| +// DeleteByHost frees a claim: the hostname returns to the pool and whoever |
| +// held it can no longer reclaim it. |
| +func (c *Claims) DeleteByHost(host string) error { |
| + _, err := c.sql.Exec(`DELETE FROM relay_claims WHERE host = ?`, host) |
| + return err |
| +} |
| + |
| +// SweepIdle frees claims not seen for longer than idle, skipping any host in |
| +// keep (the currently-connected tunnels — a relay that has been online for |
| +// months without reconnecting is the opposite of abandoned). Returns the |
| +// freed hostnames. |
| +func (c *Claims) SweepIdle(idle time.Duration, keep map[string]bool) ([]string, error) { |
| + cutoff := time.Now().Add(-idle).Unix() |
| + rows, err := c.sql.Query(`SELECT host FROM relay_claims WHERE last_seen < ?`, cutoff) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var stale []string |
| + for rows.Next() { |
| + var h string |
| + if err := rows.Scan(&h); err != nil { |
| + rows.Close() |
| + return nil, err |
| + } |
| + if !keep[h] { |
| + stale = append(stale, h) |
| + } |
| + } |
| + rows.Close() |
| + for _, h := range stale { |
| + if err := c.DeleteByHost(h); err != nil { |
| + return stale, err |
| + } |
| + } |
| + return stale, nil |
| +} |
| + |
| // VerifyClaimSig checks that sig (base64url) signs nonce with pubkey |
| // (base64url raw ed25519). |
| func VerifyClaimSig(pubkey, nonce, sig string) error { |
diff --git a/internal/tunnel/gateway.go b/internal/tunnel/gateway.go| index 8676217..a675ab7 100644 |
| --- a/internal/tunnel/gateway.go |
| +++ b/internal/tunnel/gateway.go |
| @@ -41,6 +41,7 @@ type Gateway struct { |
| 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) |
| + ClaimIdle time.Duration // free claims idle this long (0 = default 45 days) |
| MaxTunnels int // concurrent tunnels (0 = default 50) |
| MaxConns int // concurrent data connections per tunnel (0 = default 32) |
diff --git a/internal/tunnel/relay.go b/internal/tunnel/relay.go| index 8c2ef37..c69da11 100644 |
| --- a/internal/tunnel/relay.go |
| +++ b/internal/tunnel/relay.go |
| @@ -17,6 +17,39 @@ import ( |
| ) |
| func (g *Gateway) maxRelays() int { return orInt(g.MaxRelays, 200) } |
| +func (g *Gateway) claimIdle() time.Duration { |
| + if g.ClaimIdle > 0 { |
| + return g.ClaimIdle |
| + } |
| + return 45 * 24 * time.Hour |
| +} |
| + |
| +// StartClaimJanitor frees claims whose owners haven't connected for ClaimIdle |
| +// (default 45 days), hourly. Currently-connected tunnels are never swept, no |
| +// matter how old their last_seen — Touch only runs at claim time. |
| +func (g *Gateway) StartClaimJanitor() { |
| + if g.ClaimsDB == nil { |
| + return |
| + } |
| + go func() { |
| + for range time.Tick(time.Hour) { |
| + g.mu.Lock() |
| + keep := make(map[string]bool, len(g.tunnels)) |
| + for h := range g.tunnels { |
| + keep[h] = true |
| + } |
| + g.mu.Unlock() |
| + freed, err := g.ClaimsDB.SweepIdle(g.claimIdle(), keep) |
| + if err != nil { |
| + log.Printf("tunnel: claim janitor: %v", err) |
| + continue |
| + } |
| + for _, h := range freed { |
| + log.Printf("tunnel: claim janitor freed %s (idle > %s)", h, g.claimIdle()) |
| + } |
| + } |
| + }() |
| +} |
| func (g *Gateway) relayGrace() time.Duration { |
| if g.RelayGrace > 0 { |
| return g.RelayGrace |
diff --git a/internal/tunnel/relay_test.go b/internal/tunnel/relay_test.go| index 7588a5a..ce83790 100644 |
| --- a/internal/tunnel/relay_test.go |
| +++ b/internal/tunnel/relay_test.go |
| @@ -204,3 +204,62 @@ func TestVerifyClaimSig(t *testing.T) { |
| t.Fatal("garbage key accepted") |
| } |
| } |
| + |
| +func TestSweepIdleFreesOnlyAbandonedClaims(t *testing.T) { |
| + claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, h := range []string{"r-old.relay.local", "r-live.relay.local", "r-fresh.relay.local"} { |
| + if err := claims.Claim(h, "pk-"+h); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Backdate two claims past the idle cutoff; r-live stays connected. |
| + old := time.Now().Add(-72 * time.Hour).Unix() |
| + for _, h := range []string{"r-old.relay.local", "r-live.relay.local"} { |
| + if _, err := claims.sql.Exec(`UPDATE relay_claims SET last_seen = ? WHERE host = ?`, old, h); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + |
| + freed, err := claims.SweepIdle(24*time.Hour, map[string]bool{"r-live.relay.local": true}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(freed) != 1 || freed[0] != "r-old.relay.local" { |
| + t.Fatalf("expected only r-old freed, got %v", freed) |
| + } |
| + if h, _ := claims.HostFor("pk-r-live.relay.local"); h != "r-live.relay.local" { |
| + t.Fatal("connected claim was swept despite being in keep") |
| + } |
| + if h, _ := claims.HostFor("pk-r-fresh.relay.local"); h != "r-fresh.relay.local" { |
| + t.Fatal("fresh claim was swept") |
| + } |
| + if h, _ := claims.HostFor("pk-r-old.relay.local"); h != "" { |
| + t.Fatal("idle claim survived the sweep") |
| + } |
| +} |
| + |
| +func TestDeleteByHostReturnsHostnameToPool(t *testing.T) { |
| + claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := claims.Claim("r-gone.relay.local", "pk-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := claims.DeleteByHost("r-gone.relay.local"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h, _ := claims.HostFor("pk-1"); h != "" { |
| + t.Fatal("deleted claim still resolves for its old keypair") |
| + } |
| + // The hostname and the keypair are both free again — either can re-claim. |
| + if err := claims.Claim("r-gone.relay.local", "pk-2"); err != nil { |
| + t.Fatalf("freed hostname couldn't be re-claimed: %v", err) |
| + } |
| + if err := claims.Claim("r-new.relay.local", "pk-1"); err != nil { |
| + t.Fatalf("freed keypair couldn't claim a new hostname: %v", err) |
| + } |
| +} |
diff --git a/trycmd.go b/trycmd.go| index 7132a9a..d24b274 100644 |
| --- a/trycmd.go |
| +++ b/trycmd.go |
| @@ -187,7 +187,7 @@ func runTunnelCmd(args []string) { |
| return |
| } |
| if len(args) < 1 || args[0] != "serve" { |
| - die("usage: lchat tunnel serve [--socket P] [--registry P] [--host H] [--domain D]\n lchat tunnel kill <try-…host> [--registry P]") |
| + die("usage: lchat tunnel serve [--socket P] [--registry P] [--host H] [--domain D]\n lchat tunnel kill <host> [--registry P] [--forget]") |
| } |
| fs := flag.NewFlagSet("tunnel serve", flag.ExitOnError) |
| socket := fs.String("socket", envOr("LCHAT_TUNNEL_SOCKET", "/run/lchat/tunnel.sock"), "unix socket to serve on (the router fronts it)") |
| @@ -198,6 +198,7 @@ func runTunnelCmd(args []string) { |
| 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") |
| + claimIdleDays := fs.Int("claim-idle-days", 45, "free a relay hostname whose owner hasn't connected for this many days") |
| _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| reg, err := openRegistry(*registry) |
| @@ -242,6 +243,8 @@ func runTunnelCmd(args []string) { |
| } |
| gw.RelayDomain = *relayDomain |
| gw.ClaimsDB = claims |
| + gw.ClaimIdle = time.Duration(*claimIdleDays) * 24 * time.Hour |
| + gw.StartClaimJanitor() |
| } |
| _ = os.Remove(*socket) |
| @@ -257,12 +260,16 @@ func runTunnelCmd(args []string) { |
| } |
| } |
| -// runTunnelKill is the operator kill-switch for one trial host: removing its |
| +// runTunnelKill is the operator kill-switch for one tunnel host: removing its |
| // registry row makes the router stop serving it within a couple of seconds. |
| // (Restarting lchat-tunnel additionally drops every live tunnel connection.) |
| +// --forget additionally deletes a relay's claim, so the hostname returns to |
| +// the pool and the keypair that held it starts fresh next time it connects. |
| func runTunnelKill(args []string) { |
| fs := flag.NewFlagSet("tunnel kill", flag.ExitOnError) |
| registry := fs.String("registry", envOr("LCHAT_REGISTRY", "/data/registry.db"), "router registry") |
| + claimsPath := fs.String("claims", envOr("LCHAT_TUNNEL_CLAIMS", "/data/tunnel-claims.db"), "relay claim store") |
| + forget := fs.Bool("forget", false, "also delete the relay claim (frees the hostname permanently)") |
| _ = fs.Parse(reorderFlagsFirst(fs, args)) |
| host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) |
| if !strings.HasPrefix(host, "try-") && !strings.HasPrefix(host, "r-") { |
| @@ -276,6 +283,16 @@ func runTunnelKill(args []string) { |
| die("tunnel kill: %v", err) |
| } |
| fmt.Printf("removed %s — the router drops it within a couple of seconds.\n", host) |
| + if *forget { |
| + claims, err := tunnel.OpenClaims(*claimsPath) |
| + if err != nil { |
| + die("tunnel kill: open claims: %v", err) |
| + } |
| + if err := claims.DeleteByHost(host); err != nil { |
| + die("tunnel kill: forget claim: %v", err) |
| + } |
| + fmt.Printf("forgot the claim on %s — the hostname is back in the pool.\n", host) |
| + } |
| fmt.Println("(to also cut the live tunnel connection: systemctl restart lchat-tunnel)") |
| } |