eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package tunnel
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "encoding/base64"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16)
17
18// An Eleven-shaped backend the door probe accepts.
19func fakeInstance(t *testing.T) *httptest.Server {
20 t.Helper()
21 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22 if r.URL.Path == "/api/version" {
23 w.Header().Set("Content-Type", "application/json")
24 io.WriteString(w, `{"build":"test","version":"test-1"}`)
25 return
26 }
27 io.WriteString(w, "instance says hi")
28 }))
29 t.Cleanup(s.Close)
30 return s
31}
32
33func relayGateway(t *testing.T) (*Gateway, *httptest.Server) {
34 t.Helper()
35 claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db"))
36 if err != nil {
37 t.Fatal(err)
38 }
39 gw := &Gateway{Domain: "test.local", RelayDomain: "relay.local", ClaimsDB: claims,
40 DialTimeout: 5 * time.Second, RelayGrace: 200 * time.Millisecond}
41 srv := httptest.NewServer(gw)
42 t.Cleanup(srv.Close)
43 u, _ := url.Parse(srv.URL)
44 gw.ControlHost = u.Hostname()
45 return gw, srv
46}
47
48// A relay claims a hostname, loses its connection, and gets the SAME
49// hostname back on reconnect; meanwhile the sleeping hostname answers 503
50// instead of vanishing.
51func TestRelayClaimReconnectAndGrace(t *testing.T) {
52 backend := fakeInstance(t)
53 gw, srv := relayGateway(t)
54 gw.RelayGrace = 10 * time.Second // long enough to observe sleeping
55
56 _, priv, _ := ed25519.GenerateKey(rand.Reader)
57 run := func(ctx context.Context) (string, chan error) {
58 cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
59 ready := make(chan string, 1)
60 done := make(chan error, 1)
61 go func() { done <- cl.Run(ctx, func(h string) { ready <- h }) }()
62 select {
63 case h := <-ready:
64 return h, done
65 case err := <-done:
66 t.Fatalf("client exited early: %v", err)
67 case <-time.After(5 * time.Second):
68 t.Fatal("no ready")
69 }
70 return "", done
71 }
72
73 ctx1, cancel1 := context.WithCancel(context.Background())
74 host1, _ := run(ctx1)
75 if !strings.HasPrefix(host1, "r-") || !strings.HasSuffix(host1, ".relay.local") {
76 t.Fatalf("unexpected relay host %q", host1)
77 }
78
79 // Drop the connection: the hostname sleeps (503) rather than vanishing.
80 cancel1()
81 deadline := time.Now().Add(3 * time.Second)
82 for {
83 gw.mu.Lock()
84 tun := gw.tunnels[host1]
85 sleeping := tun != nil && tun.sleeping
86 gw.mu.Unlock()
87 if sleeping {
88 break
89 }
90 if time.Now().After(deadline) {
91 t.Fatal("tunnel never went to sleep")
92 }
93 time.Sleep(20 * time.Millisecond)
94 }
95 rt := http.DefaultTransport.(*http.Transport).Clone()
96 httpc := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
97 r.Host = host1
98 return rt.RoundTrip(r)
99 })}
100 resp, err := httpc.Get(srv.URL + "/")
101 if err != nil {
102 t.Fatal(err)
103 }
104 resp.Body.Close()
105 if resp.StatusCode != http.StatusServiceUnavailable {
106 t.Fatalf("sleeping relay answered %d, want 503", resp.StatusCode)
107 }
108
109 // Reconnect with the same identity: same hostname, traffic flows again.
110 ctx2, cancel2 := context.WithCancel(context.Background())
111 defer cancel2()
112 host2, _ := run(ctx2)
113 if host2 != host1 {
114 t.Fatalf("reconnect got %q, want the claimed %q", host2, host1)
115 }
116 resp, err = httpc.Get(srv.URL + "/")
117 if err != nil {
118 t.Fatal(err)
119 }
120 body, _ := io.ReadAll(resp.Body)
121 resp.Body.Close()
122 if string(body) != "instance says hi" {
123 t.Fatalf("after reattach body = %q", body)
124 }
125
126 // A different identity gets a different hostname.
127 _, priv2, _ := ed25519.GenerateKey(rand.Reader)
128 cl2 := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv2}
129 ready2 := make(chan string, 1)
130 ctx3, cancel3 := context.WithCancel(context.Background())
131 defer cancel3()
132 go cl2.Run(ctx3, func(h string) { ready2 <- h })
133 select {
134 case h := <-ready2:
135 if h == host1 {
136 t.Fatal("second identity was handed the first identity's hostname")
137 }
138 case <-time.After(5 * time.Second):
139 t.Fatal("second identity never became ready")
140 }
141}
142
143// After the grace window expires, the hostname is fully retired (route
144// withdrawn) — but the claim survives, so the next connect gets it back.
145func TestRelayGraceExpiry(t *testing.T) {
146 backend := fakeInstance(t)
147 gw, srv := relayGateway(t) // grace 200ms
148 closed := make(chan string, 1)
149 gw.OnClose = func(h string) { closed <- h }
150
151 _, priv, _ := ed25519.GenerateKey(rand.Reader)
152 cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
153 ready := make(chan string, 1)
154 ctx, cancel := context.WithCancel(context.Background())
155 go cl.Run(ctx, func(h string) { ready <- h })
156 var host string
157 select {
158 case host = <-ready:
159 case <-time.After(5 * time.Second):
160 t.Fatal("no ready")
161 }
162 cancel()
163 select {
164 case got := <-closed:
165 if got != host {
166 t.Fatalf("OnClose got %q", got)
167 }
168 case <-time.After(5 * time.Second):
169 t.Fatal("grace never expired")
170 }
171 if owner, _ := gw.ClaimsDB.Owner(host); owner == "" {
172 t.Fatal("claim was lost with the grace expiry")
173 }
174}
175
176// A gateway without relay support refuses relay hellos with a clear message.
177func TestRelayRefusedWithoutDomain(t *testing.T) {
178 backend := fakeInstance(t)
179 gw := &Gateway{Domain: "test.local"}
180 srv := httptest.NewServer(gw)
181 defer srv.Close()
182 u, _ := url.Parse(srv.URL)
183 gw.ControlHost = u.Hostname()
184
185 _, priv, _ := ed25519.GenerateKey(rand.Reader)
186 cl := &Client{Gateway: srv.URL, LocalAddr: strings.TrimPrefix(backend.URL, "http://"), Identity: priv}
187 err := cl.Run(context.Background(), func(string) {})
188 if err == nil || !strings.Contains(err.Error(), "doesn't offer relay") {
189 t.Fatalf("expected relay refusal, got %v", err)
190 }
191}
192
193func TestVerifyClaimSig(t *testing.T) {
194 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
195 pk := base64.RawURLEncoding.EncodeToString(pub)
196 sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(priv, []byte("nonce-1")))
197 if err := VerifyClaimSig(pk, "nonce-1", sig); err != nil {
198 t.Fatalf("valid sig rejected: %v", err)
199 }
200 if err := VerifyClaimSig(pk, "nonce-2", sig); err == nil {
201 t.Fatal("replayed sig accepted for a different nonce")
202 }
203 if err := VerifyClaimSig("not-a-key", "nonce-1", sig); err == nil {
204 t.Fatal("garbage key accepted")
205 }
206}
207
208func TestSweepIdleFreesOnlyAbandonedClaims(t *testing.T) {
209 claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db"))
210 if err != nil {
211 t.Fatal(err)
212 }
213 for _, h := range []string{"r-old.relay.local", "r-live.relay.local", "r-fresh.relay.local"} {
214 if err := claims.Claim(h, "pk-"+h); err != nil {
215 t.Fatal(err)
216 }
217 }
218 // Backdate two claims past the idle cutoff; r-live stays connected.
219 old := time.Now().Add(-72 * time.Hour).Unix()
220 for _, h := range []string{"r-old.relay.local", "r-live.relay.local"} {
221 if _, err := claims.sql.Exec(`UPDATE relay_claims SET last_seen = ? WHERE host = ?`, old, h); err != nil {
222 t.Fatal(err)
223 }
224 }
225
226 freed, err := claims.SweepIdle(24*time.Hour, map[string]bool{"r-live.relay.local": true})
227 if err != nil {
228 t.Fatal(err)
229 }
230 if len(freed) != 1 || freed[0] != "r-old.relay.local" {
231 t.Fatalf("expected only r-old freed, got %v", freed)
232 }
233 if h, _ := claims.HostFor("pk-r-live.relay.local"); h != "r-live.relay.local" {
234 t.Fatal("connected claim was swept despite being in keep")
235 }
236 if h, _ := claims.HostFor("pk-r-fresh.relay.local"); h != "r-fresh.relay.local" {
237 t.Fatal("fresh claim was swept")
238 }
239 if h, _ := claims.HostFor("pk-r-old.relay.local"); h != "" {
240 t.Fatal("idle claim survived the sweep")
241 }
242}
243
244func TestDeleteByHostReturnsHostnameToPool(t *testing.T) {
245 claims, err := OpenClaims(filepath.Join(t.TempDir(), "claims.db"))
246 if err != nil {
247 t.Fatal(err)
248 }
249 if err := claims.Claim("r-gone.relay.local", "pk-1"); err != nil {
250 t.Fatal(err)
251 }
252 if err := claims.DeleteByHost("r-gone.relay.local"); err != nil {
253 t.Fatal(err)
254 }
255 if h, _ := claims.HostFor("pk-1"); h != "" {
256 t.Fatal("deleted claim still resolves for its old keypair")
257 }
258 // The hostname and the keypair are both free again — either can re-claim.
259 if err := claims.Claim("r-gone.relay.local", "pk-2"); err != nil {
260 t.Fatalf("freed hostname couldn't be re-claimed: %v", err)
261 }
262 if err := claims.Claim("r-new.relay.local", "pk-1"); err != nil {
263 t.Fatalf("freed keypair couldn't claim a new hostname: %v", err)
264 }
265}
266