eleven / messenger Public

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>
Paul Campbell 855718371277bf82ab91f73a6ffa81dc1ee2a1ec parent 75cf552
6 files changed, +157 −4
  • docs/relay-mode.md +8 −2
  • internal/tunnel/claims.go +37 −0
  • internal/tunnel/gateway.go +1 −0
  • internal/tunnel/relay.go +33 −0
  • internal/tunnel/relay_test.go +59 −0
  • trycmd.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)")
}