ALL

For reading and verification only. You may not copy, use, modify or redistribute this code. Full notice on the main page.

tunnel/config_test.go

SHA-256: bc1a8173937b6ab7e718b187b553c76f6e3820f221cd794e24f46a92852fc394
410 lines · 15821 bytes · View on GitHub

1package main
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/ed25519"
7 "crypto/rand"
8 "crypto/tls"
9 "crypto/x509"
10 "encoding/json"
11 "io"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strconv"
16 "strings"
17 "sync"
18 "syscall"
19 "testing"
20 "time"
21)
22
23func TestConfig_RefusesWhatTheNodeCannotRunSafely(t *testing.T) {
24 e := newTestEnv(t, nil)
25 good := func() Config {
26 cfg := DefaultConfig()
27 cfg.NodeID, cfg.NodeHost, cfg.Keys, cfg.Control = testNodeID, testNodeHost, e.keys, e.cp
28 cfg.CertSource = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, nil }
29 return cfg
30 }
31 cfg := good()
32 if _, err := NewServer(cfg); err != nil {
33 t.Fatalf("the baseline configuration: %v", err)
34 }
35 for name, mutate := range map[string]func(*Config){
36 "no node id": func(c *Config) { c.NodeID = "" },
37 "a node id with a capital": func(c *Config) { c.NodeID = "T1" },
38 "a node id of 17 characters": func(c *Config) { c.NodeID = strings.Repeat("a", 17) },
39 "no node host": func(c *Config) { c.NodeHost = "" },
40 "a node host with a capital": func(c *Config) { c.NodeHost = "T1.nodes.all-e2e.test" },
41 "a node host with a trailing dot": func(c *Config) { c.NodeHost = testNodeHost + "." },
42 "a node host that is an address": func(c *Config) { c.NodeHost = "192.0.2.1" },
43 "a node host that is a device host": func(c *Config) {
44 c.NodeHost = label20() + "." + testZone
45 },
46 "no certificate source": func(c *Config) { c.CertSource = nil },
47 "no key source": func(c *Config) { c.Keys = nil },
48 "no control plane": func(c *Config) { c.Control = nil },
49 "port zero": func(c *Config) { c.PublicPort = 0 },
50 "port 70000": func(c *Config) { c.PublicPort = 70000 },
51 "a negative skew": func(c *Config) { c.ClockSkew = -time.Second },
52 "no stream limit": func(c *Config) { c.MaxStreams = 0 },
53 // WP06: nothing the policy bounds may be configured beyond it
54 "a grace longer than the policy's": func(c *Config) { c.Grace = secs(policyData.Authorization.GracePaidS) + time.Second },
55 "a negative grace": func(c *Config) { c.Grace = -time.Second },
56 "a feed polled less often than the policy": func(c *Config) { c.Feed, c.FeedPoll = &scriptedFeed{}, secs(policyData.Timing.FeedPollS)+time.Second },
57 "a feed polled in no time": func(c *Config) { c.Feed, c.FeedPoll = &scriptedFeed{}, 0 },
58 "a negative drain": func(c *Config) { c.DrainQuiet = -time.Second },
59 "a drain of two days": func(c *Config) { c.DrainQuiet = 48 * time.Hour },
60 } {
61 cfg := good()
62 mutate(&cfg)
63 if _, err := NewServer(cfg); err == nil {
64 t.Errorf("%s: accepted", name)
65 }
66 }
67 // a configuration with the public listener off needs no own name or certificate
68 cfg = good()
69 cfg.HTTPSAddr, cfg.NodeHost, cfg.CertSource = "", "", nil
70 if _, err := NewServer(cfg); err != nil {
71 t.Errorf("no public listener: %v", err)
72 }
73}
74
75func TestRun_TheProductionBuildNeedsItsSigningKeyAndTheOld11FlagsAreGone(t *testing.T) {
76 var out, errb bytes.Buffer
77 if rc := run([]string{"-version"}, &out, &errb); rc != 0 || !strings.HasPrefix(out.String(), "all-tunnel ") {
78 t.Fatalf("-version: %d %q %q", rc, out.String(), errb.String())
79 }
80 errb.Reset()
81 // without its signing key (or, in the test node's build, without its fakes) the node does not start: it never runs without verifying anyone
82 if rc := run([]string{"-node-id", "t1", "-node-host", testNodeHost}, io.Discard, &errb); rc != 1 ||
83 (!strings.Contains(errb.String(), "signing key") && !strings.Contains(errb.String(), "needs -test-jwks")) {
84 t.Fatalf("no backend configured: %d %q", rc, errb.String())
85 }
86 if rc := run([]string{"-no-such-flag"}, io.Discard, io.Discard); rc != 2 {
87 t.Fatalf("unknown flag: %d", rc)
88 }
89 if rc := run([]string{"-log-level", "loud"}, io.Discard, io.Discard); rc != 2 {
90 t.Fatalf("bad log level: %d", rc)
91 }
92 for _, old := range []string{"-control-addr", "-state-dir", "-token-file", "-devices-file", "-host", "-gen-token"} {
93 if rc := run([]string{old, "x"}, io.Discard, io.Discard); rc != 2 {
94 t.Errorf("the 1.1 flag %s is still accepted (exit %d)", old, rc)
95 }
96 }
97}
98
99func TestFileCert_ReloadsWhenTheFilesChangeAndKeepsTheOneThatWorks(t *testing.T) {
100 dir := t.TempDir()
101 write := func(name string) {
102 _, cert := makeCA(t, name)
103 certPEM, keyPEM := pemOf(t, cert)
104 if err := os.WriteFile(filepath.Join(dir, "c.pem"), certPEM, 0o600); err != nil {
105 t.Fatal(err)
106 }
107 if err := os.WriteFile(filepath.Join(dir, "k.pem"), keyPEM, 0o600); err != nil {
108 t.Fatal(err)
109 }
110 }
111 write("one.example.test")
112 fc, err := newCertReloader(filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem"), "", clock{}, nil)
113 if err != nil {
114 t.Fatal(err)
115 }
116 fc.lookGap = 0 // the files are looked at on every call, so the test does not wait for the gap
117 first, _ := fc.get(nil)
118 again, _ := fc.get(nil)
119 if first != again {
120 t.Fatal("an unchanged file was parsed again")
121 }
122 time.Sleep(20 * time.Millisecond)
123 write("two.example.test")
124 second, err := fc.get(nil)
125 if err != nil || fingerprintOf(second.Certificate[0]) == fingerprintOf(first.Certificate[0]) {
126 t.Fatalf("a renewed certificate was not picked up: %v", err)
127 }
128 // a half-written renewal does not take the node down: the working certificate stays
129 if err := os.WriteFile(filepath.Join(dir, "c.pem"), []byte("garbage"), 0o600); err != nil {
130 t.Fatal(err)
131 }
132 kept, err := fc.get(nil)
133 if err != nil || kept != second {
134 t.Fatalf("a failed reload must keep the certificate that works: %v", err)
135 }
136 if _, err := newCertReloader(filepath.Join(dir, "missing"), filepath.Join(dir, "k.pem"), "", clock{}, nil); err == nil {
137 t.Fatal("a node cannot start without its certificate")
138 }
139}
140
141// ------------------------------------------------------------------------------------------------------------------------------------
142// The test-node binary (ALL_TEST_NODE_BIN)
143// ------------------------------------------------------------------------------------------------------------------------------------
144
145var (
146 testNodeOnce sync.Once
147 testNodePath string
148 testNodeErr error
149)
150
151// testNodeBinary is the binary the device's tests run against: ALL_TEST_NODE_BIN when set, otherwise this package built with -tags testnode.
152func testNodeBinary(t *testing.T) string {
153 t.Helper()
154 if p := os.Getenv("ALL_TEST_NODE_BIN"); p != "" {
155 return p
156 }
157 testNodeOnce.Do(func() {
158 goBin, err := exec.LookPath("go")
159 if err != nil {
160 if _, serr := os.Stat("/opt/homebrew/bin/go"); serr != nil {
161 testNodeErr = err
162 return
163 }
164 goBin = "/opt/homebrew/bin/go"
165 }
166 dir, err := os.MkdirTemp("", "all-tunnel-testnode-*")
167 if err != nil {
168 testNodeErr = err
169 return
170 }
171 testNodePath = filepath.Join(dir, "all-tunnel-testnode")
172 out, err := exec.Command(goBin, "build", "-tags", "testnode", "-o", testNodePath, ".").CombinedOutput()
173 if err != nil {
174 testNodeErr = &exec.ExitError{Stderr: out}
175 t.Logf("go build: %s", out)
176 }
177 })
178 if testNodeErr != nil {
179 t.Skipf("cannot build the test node: %v", testNodeErr)
180 }
181 return testNodePath
182}
183
184var (
185 prodBinOnce sync.Once
186 prodBinPath string
187 prodBinErr error
188)
189
190// productionBinary is this package built the way it is deployed (no test tags): the node a device really meets.
191func productionBinary(t *testing.T) string {
192 t.Helper()
193 prodBinOnce.Do(func() {
194 goBin, err := exec.LookPath("go")
195 if err != nil {
196 if _, serr := os.Stat("/opt/homebrew/bin/go"); serr != nil {
197 prodBinErr = err
198 return
199 }
200 goBin = "/opt/homebrew/bin/go"
201 }
202 dir, err := os.MkdirTemp("", "all-tunnel-prod-*")
203 if err != nil {
204 prodBinErr = err
205 return
206 }
207 prodBinPath = filepath.Join(dir, "all-tunnel")
208 if out, err := exec.Command(goBin, "build", "-o", prodBinPath, ".").CombinedOutput(); err != nil {
209 prodBinErr = &exec.ExitError{Stderr: out}
210 t.Logf("go build: %s", out)
211 }
212 })
213 if prodBinErr != nil {
214 t.Skipf("cannot build the production node: %v", prodBinErr)
215 }
216 return prodBinPath
217}
218
219func TestMain(m *testing.M) {
220 code := m.Run()
221 if testNodePath != "" {
222 _ = os.RemoveAll(filepath.Dir(testNodePath))
223 }
224 if prodBinPath != "" {
225 _ = os.RemoveAll(filepath.Dir(prodBinPath))
226 }
227 os.Exit(code)
228}
229
230func jwksFile(t *testing.T, path, kid string, pub ed25519.PublicKey) {
231 t.Helper()
232 doc := map[string]any{"keys": []any{map[string]any{"kty": "OKP", "crv": "Ed25519", "x": b64Encode(pub), "kid": kid, "alg": "EdDSA", "use": "sig", "key_ops": []any{"verify"}}}}
233 if err := os.WriteFile(path, []byte(canonicalJSON(doc)), 0o600); err != nil {
234 t.Fatal(err)
235 }
236}
237
238func writeControl(t *testing.T, path string, devices map[string]any) {
239 t.Helper()
240 b, _ := json.Marshal(map[string]any{"devices": devices})
241 tmp := path + ".tmp"
242 if err := os.WriteFile(tmp, b, 0o600); err != nil {
243 t.Fatal(err)
244 }
245 if err := os.Rename(tmp, path); err != nil {
246 t.Fatal(err)
247 }
248}
249
250// TestTestNodeBinary_RunsTheRealProtocolAgainstAFileDrivenControlPlane starts the test-node program, plays a device against it and a client of
251// the device, changes the fake control plane while it runs and stops it with SIGTERM.
252func TestTestNodeBinary_RunsTheRealProtocolAgainstAFileDrivenControlPlane(t *testing.T) {
253 bin := testNodeBinary(t)
254 dir := t.TempDir()
255 pub, priv, _ := ed25519.GenerateKey(rand.Reader)
256 jwks, control, calls, certOut := filepath.Join(dir, "jwks.json"), filepath.Join(dir, "control.json"), filepath.Join(dir, "calls.jsonl"), filepath.Join(dir, "node.pem")
257 jwksFile(t, jwks, testKID, pub)
258
259 e := &testEnv{t: t, cpKey: priv, tapByID: map[string]*bytes.Buffer{}}
260 e.srv = &Server{nodeURL: nodeProofURL(testNodeHost, 443)}
261 d := e.newDeviceNoPlan()
262 writeControl(t, control, map[string]any{d.id: map[string]any{"host": d.host, "status": "active", "plan": "paid", "valid_for_s": 60}})
263
264 cmd := exec.Command(bin, "-https-addr", "127.0.0.1:0", "-http-addr", "127.0.0.1:0", "-node-id", testNodeID, "-node-host", testNodeHost,
265 "-test-jwks", jwks, "-test-control", control, "-test-calls", calls, "-test-cert-out", certOut, "-log-level", "info")
266 stdout, _ := cmd.StdoutPipe()
267 var logs syncBuf
268 cmd.Stderr = &logs
269 if err := cmd.Start(); err != nil {
270 t.Fatal(err)
271 }
272 exited := make(chan error, 1)
273 go func() { exited <- cmd.Wait() }()
274 t.Cleanup(func() { _ = cmd.Process.Kill() })
275
276 ready := make(chan map[string]any, 1)
277 go func() {
278 line, _ := bufio.NewReader(stdout).ReadString('\n')
279 var m map[string]any
280 if json.Unmarshal([]byte(line), &m) == nil {
281 ready <- m
282 }
283 }()
284 var info map[string]any
285 select {
286 case info = <-ready:
287 case <-time.After(20 * time.Second):
288 t.Fatalf("the test node did not announce itself:\n%s", logs.String())
289 }
290 if info["ready"] != true || info["node_id"] != testNodeID || info["node_host"] != testNodeHost {
291 t.Fatalf("ready line %v", info)
292 }
293 e.httpsAddr, e.httpAddr = info["https"].(string), info["http"].(string)
294 certPEM, err := os.ReadFile(certOut)
295 if err != nil {
296 t.Fatal(err)
297 }
298 e.nodePool = x509.NewCertPool()
299 if !e.nodePool.AppendCertsFromPEM(certPEM) {
300 t.Fatal("the node's certificate file is not PEM")
301 }
302
303 dc := e.connectOK(d)
304 if body := dc.ok["body"].(map[string]any); body["host"] != d.host || body["status"] != "active" {
305 t.Fatalf("OK %v", body)
306 }
307 ds := dc.serve(nil)
308 c, err := e.clientTLS(d)
309 if err != nil {
310 t.Fatal(err)
311 }
312 if resp, err := postOver(c, d.host, []byte("via-binary-"+marker)); err != nil || string(resp) != "echo:via-binary-"+marker {
313 t.Fatalf("resp=%q err=%v\nlogs:\n%s", resp, err, logs.String())
314 }
315 _ = c.Close()
316
317 // the file is read again at every authorize: this renewal is refused, the next one answers gated
318 writeControl(t, control, map[string]any{d.id: map[string]any{"host": d.host, "error": map[string]any{"status": 402, "code": "license_inactive"}}})
319 if r := dc.renew(); r != "ERR license_inactive" {
320 t.Fatalf("renew with the control plane refusing: %q", r)
321 }
322 writeControl(t, control, map[string]any{d.id: map[string]any{"host": d.host, "status": "gated", "plan": "trial", "valid_for_s": 900}})
323 if r := dc.renew(); r != "ERR busy" { // the production gap between two RENEWs
324 t.Fatalf("a renew right after another: %q", r)
325 }
326 time.Sleep(1200 * time.Millisecond)
327 if r := dc.renew(); !strings.HasPrefix(r, "OK ") || !strings.Contains(r, `"status":"gated"`) {
328 t.Fatalf("renew answering gated: %q", r)
329 }
330 // a device the file does not know is refused
331 stranger := e.newDeviceNoPlan()
332 sc := e.connect(stranger)
333 if r := sc.hello(func() string { return e.session(stranger, nil) }, func(s, n string) string { return e.proof(stranger, s, n, nil) }); r != "ERR device_revoked" {
334 t.Fatalf("unknown device: %q", r)
335 }
336 // every authorize was logged with its reason
337 raw, _ := os.ReadFile(calls)
338 if n := strings.Count(string(raw), `"reason":"renew"`); n != 2 || strings.Count(string(raw), `"reason":"hello"`) != 2 {
339 t.Fatalf("calls file:\n%s", raw)
340 }
341 // ACME path through the binary: only the node's own name, and nothing without a webroot
342 if out := acmeGet(t, e, "GET", "/.well-known/acme-challenge/anything", testNodeHost); !strings.HasPrefix(out, "HTTP/1.1 404") {
343 t.Fatalf("acme: %q", out)
344 }
345
346 if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
347 t.Fatal(err)
348 }
349 select {
350 case err := <-exited:
351 if err != nil {
352 t.Fatalf("exit: %v\nlogs:\n%s", err, logs.String())
353 }
354 case <-time.After(20 * time.Second):
355 t.Fatalf("did not exit on SIGTERM\nlogs:\n%s", logs.String())
356 }
357 <-ds.done
358 // SIGTERM drains (WP06): the device is told to prefer the other node, and the node closes once its streams are gone
359 if !containsLine(ds.otherLines(), "DRAIN "+strconv.Itoa(policyData.Timing.DrainQuietS)) || !containsLine(ds.otherLines(), "CLOSE drain") {
360 t.Fatalf("the device was not told the node is draining: %q", ds.otherLines())
361 }
362 out := logs.String()
363 for _, want := range []string{`"msg":"draining"`, `"msg":"drained"`, `"msg":"control_up"`, `"msg":"stream_close"`} {
364 if !strings.Contains(out, want) {
365 t.Errorf("missing %s in logs", want)
366 }
367 }
368 for name, secret := range map[string]string{"marker": marker, "host": d.host, "device id": d.id, "session": dc.session} {
369 if strings.Contains(out, secret) {
370 t.Fatalf("the process logs name the %s", name)
371 }
372 }
373}
374
375func TestTestNodeBinary_RefusesToStartWithoutItsFakesOrWithABadConfiguration(t *testing.T) {
376 bin := testNodeBinary(t)
377 run := func(args ...string) (string, int) {
378 cmd := exec.Command(bin, args...)
379 var out bytes.Buffer
380 cmd.Stderr, cmd.Stdout = &out, &out
381 err := cmd.Run()
382 if ee, ok := err.(*exec.ExitError); ok {
383 return out.String(), ee.ExitCode()
384 }
385 return out.String(), 0
386 }
387 dir := t.TempDir()
388 jwks, control := filepath.Join(dir, "j.json"), filepath.Join(dir, "c.json")
389 pub, _, _ := ed25519.GenerateKey(rand.Reader)
390 jwksFile(t, jwks, testKID, pub)
391 writeControl(t, control, map[string]any{})
392 base := []string{"-https-addr", "127.0.0.1:0", "-http-addr", "127.0.0.1:0", "-node-id", testNodeID, "-node-host", testNodeHost}
393 for name, tc := range map[string]struct {
394 args []string
395 want string
396 rc int
397 }{
398 "no fakes": {base, "needs -test-jwks and -test-control", 1},
399 "skew too wide": {append(append([]string{}, base...), "-test-jwks", jwks, "-test-control", control, "-clock-skew", "999"), "clock skew", 1},
400 "bad node id": {[]string{"-node-id", "T1", "-node-host", testNodeHost, "-test-jwks", jwks, "-test-control", control}, "node id", 1},
401 "version": {[]string{"-version"}, "all-tunnel", 0},
402 "unknown flag": {[]string{"-bogus"}, "flag provided but not defined", 2},
403 "the old 1.1 pin": {[]string{"-control-addr", ":8443"}, "flag provided but not defined", 2},
404 } {
405 out, rc := run(tc.args...)
406 if rc != tc.rc || !strings.Contains(out, tc.want) {
407 t.Errorf("%s: exit %d, output %q", name, rc, out)
408 }
409 }
410}