Merge pull request #122 from bnema/feat/udp-key-memory-only
feat(dgram): keep UDP bootstrap keys memory-only
11 files · +287 -65
M docs/remote-resilience.md +2 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers. | |
| 4 | 4 | ||
| 5 | + | The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state. | |
| 6 | + | ||
| 5 | 7 | ## Operational model | |
| 6 | 8 | ||
| 7 | 9 | - SSH bootstrap starts or finds the remote UDP endpoint. | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers. | 3 | Remote attach starts with SSH bootstrap and then carries the vev session over authenticated UDP. The design is inspired by mosh's mobile-shell resilience, but vev uses its own protocol and is not compatible with mosh clients or servers. | ||
| 4 | 4 | ||||
| 5 | + | The raw UDP AEAD key is never persisted by vev; the proxy registry stores only its SHA-256 ownership fingerprint. During bootstrap, the key briefly exists in memory in the proxy, the `_udp-bootstrap` relay, the client, and associated SSH/readiness I/O and string buffers. vev promptly clears reachable key buffers after handoff on a best-effort basis. Builds made with Go 1.26 and `GOEXPERIMENT=runtimesecret` additionally protect key-handling call trees with `runtime/secret` on linux/amd64 and linux/arm64; on unsupported platforms, `runtime/secret` calls the function directly. These protections do not cover globals, goroutines started by protected calls, copies retained by I/O implementations, or key material retained in cipher state. | |||
| 6 | + | ||||
| 5 | ## Operational model | 7 | ## Operational model | ||
| 6 | 8 | ||||
| 7 | - SSH bootstrap starts or finds the remote UDP endpoint. | 9 | - SSH bootstrap starts or finds the remote UDP endpoint. | ||
M internal/adapters/dgram/proxy_registry.go +14 -8
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/sha256" | |
| 4 | 5 | "encoding/base64" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "errors" | |
| 7 | - | "fmt" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "syscall" | |
| @@ -14,14 +14,20 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | type ProxyRecord struct { | |
| 17 | - | Session string `json:"session"` | |
| 18 | - | PID int `json:"pid"` | |
| 19 | - | Port int `json:"port"` | |
| 20 | - | Key string `json:"key"` | |
| 21 | - | Created time.Time `json:"created"` | |
| 17 | + | Session string `json:"session"` | |
| 18 | + | PID int `json:"pid"` | |
| 19 | + | Port int `json:"port"` | |
| 20 | + | // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key). | |
| 21 | + | // The raw key must never be persisted by vev. | |
| 22 | + | KeyFingerprint string `json:"key_fingerprint"` | |
| 23 | + | Created time.Time `json:"created"` | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | - | func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) } | |
| 26 | + | // KeyFingerprint derives the non-secret registry ownership tag. | |
| 27 | + | func KeyFingerprint(key []byte) string { | |
| 28 | + | sum := sha256.Sum256(key) | |
| 29 | + | return base64.StdEncoding.EncodeToString(sum[:]) | |
| 30 | + | } | |
| 25 | 31 | ||
| 26 | 32 | type ProxyRegistry struct{ dir string } | |
| 27 | 33 | ||
| @@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error { | |||
| 88 | 94 | if err != nil { | |
| 89 | 95 | return err | |
| 90 | 96 | } | |
| 91 | - | if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key { | |
| 97 | + | if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint { | |
| 92 | 98 | return nil | |
| 93 | 99 | } | |
| 94 | 100 | return r.removeLocked(rec.Session) | |
| @@ -1,10 +1,10 @@ | |||||
| 1 | package dgram | 1 | package dgram | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "crypto/sha256" | |||
| 4 | "encoding/base64" | 5 | "encoding/base64" | ||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | - | "fmt" | |||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "syscall" | 10 | "syscall" | ||
| @@ -14,14 +14,20 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | type ProxyRecord struct { | 16 | type ProxyRecord struct { | ||
| 17 | - | Session string `json:"session"` | 17 | + | Session string `json:"session"` |
| 18 | - | PID int `json:"pid"` | 18 | + | PID int `json:"pid"` |
| 19 | - | Port int `json:"port"` | 19 | + | Port int `json:"port"` |
| 20 | - | Key string `json:"key"` | 20 | + | // KeyFingerprint is a non-secret ownership tag (SHA-256 of the AEAD key). |
| 21 | - | Created time.Time `json:"created"` | 21 | + | // The raw key must never be persisted by vev. |
| 22 | + | KeyFingerprint string `json:"key_fingerprint"` | |||
| 23 | + | Created time.Time `json:"created"` | |||
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | - | func (r ProxyRecord) ReadyLine() string { return fmt.Sprintf("VEV-UDP %d %s\n", r.Port, r.Key) } | 26 | + | // KeyFingerprint derives the non-secret registry ownership tag. |
| 27 | + | func KeyFingerprint(key []byte) string { | |||
| 28 | + | sum := sha256.Sum256(key) | |||
| 29 | + | return base64.StdEncoding.EncodeToString(sum[:]) | |||
| 30 | + | } | |||
| 25 | 31 | ||||
| 26 | type ProxyRegistry struct{ dir string } | 32 | type ProxyRegistry struct{ dir string } | ||
| 27 | 33 | ||||
| @@ -88,7 +94,7 @@ func (r *ProxyRegistry) RemoveOwned(rec ProxyRecord) error { | |||||
| 88 | if err != nil { | 94 | if err != nil { | ||
| 89 | return err | 95 | return err | ||
| 90 | } | 96 | } | ||
| 91 | - | if cur.PID != rec.PID || cur.Port != rec.Port || cur.Key != rec.Key { | 97 | + | if cur.PID != rec.PID || cur.Port != rec.Port || cur.KeyFingerprint != rec.KeyFingerprint { |
| 92 | return nil | 98 | return nil | ||
| 93 | } | 99 | } | ||
| 94 | return r.removeLocked(rec.Session) | 100 | return r.removeLocked(rec.Session) | ||
M internal/adapters/dgram/proxy_registry_test.go +60 -11
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package dgram | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "encoding/json" | |
| 4 | 8 | "os" | |
| 5 | 9 | "path/filepath" | |
| 6 | 10 | "testing" | |
| @@ -11,10 +15,55 @@ func privateDir(t *testing.T) string { | |||
| 11 | 15 | return filepath.Join(t.TempDir(), "vev") | |
| 12 | 16 | } | |
| 13 | 17 | ||
| 18 | + | func TestKeyFingerprint(t *testing.T) { | |
| 19 | + | key := bytes.Repeat([]byte{0xA5}, 32) | |
| 20 | + | sum := sha256.Sum256(key) | |
| 21 | + | want := base64.StdEncoding.EncodeToString(sum[:]) | |
| 22 | + | if got := KeyFingerprint(key); got != want { | |
| 23 | + | t.Fatalf("KeyFingerprint()=%q, want %q", got, want) | |
| 24 | + | } | |
| 25 | + | other := bytes.Repeat([]byte{0xA6}, 32) | |
| 26 | + | if KeyFingerprint(key) == KeyFingerprint(other) { | |
| 27 | + | t.Fatal("distinct keys produced identical fingerprints") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) { | |
| 32 | + | r := NewProxyRegistry(privateDir(t)) | |
| 33 | + | key := bytes.Repeat([]byte{0xA5}, 32) | |
| 34 | + | fingerprint := KeyFingerprint(key) | |
| 35 | + | rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint} | |
| 36 | + | if err := r.Publish(rec); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | data, err := os.ReadFile(r.path("work")) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) { | |
| 45 | + | t.Fatalf("registry file contains raw key material: %s", data) | |
| 46 | + | } | |
| 47 | + | var fields map[string]json.RawMessage | |
| 48 | + | if err := json.Unmarshal(data, &fields); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if _, ok := fields["key"]; ok { | |
| 52 | + | t.Fatalf("registry file contains raw key field: %s", data) | |
| 53 | + | } | |
| 54 | + | var got string | |
| 55 | + | if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil { | |
| 56 | + | t.Fatal(err) | |
| 57 | + | } | |
| 58 | + | if got != fingerprint { | |
| 59 | + | t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 14 | 63 | func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |
| 15 | 64 | t.Run("reuses live matching record", func(t *testing.T) { | |
| 16 | 65 | r := NewProxyRegistry(privateDir(t)) | |
| 17 | - | want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"} | |
| 66 | + | want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"} | |
| 18 | 67 | if err := r.Publish(want); err != nil { | |
| 19 | 68 | t.Fatal(err) | |
| 20 | 69 | } | |
| @@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 22 | 71 | if !ok { | |
| 23 | 72 | t.Fatal("Lookup ok=false, want true") | |
| 24 | 73 | } | |
| 25 | - | if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID { | |
| 74 | + | if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID { | |
| 26 | 75 | t.Fatalf("Lookup()=%+v, want %+v", got, want) | |
| 27 | 76 | } | |
| 28 | 77 | }) | |
| 29 | 78 | t.Run("removes stale record", func(t *testing.T) { | |
| 30 | 79 | r := NewProxyRegistry(privateDir(t)) | |
| 31 | - | if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil { | |
| 80 | + | if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil { | |
| 32 | 81 | t.Fatal(err) | |
| 33 | 82 | } | |
| 34 | - | if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil { | |
| 83 | + | if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil { | |
| 35 | 84 | t.Fatal(err) | |
| 36 | 85 | } | |
| 37 | 86 | if _, ok := r.Lookup("work"); ok { | |
| @@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 43 | 92 | }) | |
| 44 | 93 | t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) { | |
| 45 | 94 | r := NewProxyRegistry(privateDir(t)) | |
| 46 | - | old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"} | |
| 95 | + | old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"} | |
| 47 | 96 | if err := r.Publish(old); err != nil { | |
| 48 | 97 | t.Fatal(err) | |
| 49 | 98 | } | |
| 50 | - | newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"} | |
| 99 | + | newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"} | |
| 51 | 100 | if err := r.Publish(newRec); err != nil { | |
| 52 | 101 | t.Fatal(err) | |
| 53 | 102 | } | |
| @@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||
| 58 | 107 | if !ok { | |
| 59 | 108 | t.Fatal("new live record removed by stale owner") | |
| 60 | 109 | } | |
| 61 | - | if got.Port != newRec.Port || got.Key != newRec.Key { | |
| 110 | + | if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint { | |
| 62 | 111 | t.Fatalf("Lookup()=%+v, want %+v", got, newRec) | |
| 63 | 112 | } | |
| 64 | 113 | if err := r.RemoveOwned(newRec); err != nil { | |
| @@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) { | |||
| 84 | 133 | if r.path(tt.a) == r.path(tt.b) { | |
| 85 | 134 | t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a)) | |
| 86 | 135 | } | |
| 87 | - | first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"} | |
| 88 | - | second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"} | |
| 136 | + | first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"} | |
| 137 | + | second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"} | |
| 89 | 138 | if err := r.Publish(first); err != nil { | |
| 90 | 139 | t.Fatal(err) | |
| 91 | 140 | } | |
| @@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) { | |||
| 93 | 142 | t.Fatal(err) | |
| 94 | 143 | } | |
| 95 | 144 | gotFirst, ok := r.Lookup(tt.a) | |
| 96 | - | if !ok || gotFirst.Key != first.Key { | |
| 145 | + | if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint { | |
| 97 | 146 | t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok) | |
| 98 | 147 | } | |
| 99 | 148 | gotSecond, ok := r.Lookup(tt.b) | |
| 100 | - | if !ok || gotSecond.Key != second.Key { | |
| 149 | + | if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint { | |
| 101 | 150 | t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok) | |
| 102 | 151 | } | |
| 103 | 152 | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package dgram | 1 | package dgram | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "crypto/sha256" | |||
| 6 | + | "encoding/base64" | |||
| 7 | + | "encoding/json" | |||
| 4 | "os" | 8 | "os" | ||
| 5 | "path/filepath" | 9 | "path/filepath" | ||
| 6 | "testing" | 10 | "testing" | ||
| @@ -11,10 +15,55 @@ func privateDir(t *testing.T) string { | |||||
| 11 | return filepath.Join(t.TempDir(), "vev") | 15 | return filepath.Join(t.TempDir(), "vev") | ||
| 12 | } | 16 | } | ||
| 13 | 17 | ||||
| 18 | + | func TestKeyFingerprint(t *testing.T) { | |||
| 19 | + | key := bytes.Repeat([]byte{0xA5}, 32) | |||
| 20 | + | sum := sha256.Sum256(key) | |||
| 21 | + | want := base64.StdEncoding.EncodeToString(sum[:]) | |||
| 22 | + | if got := KeyFingerprint(key); got != want { | |||
| 23 | + | t.Fatalf("KeyFingerprint()=%q, want %q", got, want) | |||
| 24 | + | } | |||
| 25 | + | other := bytes.Repeat([]byte{0xA6}, 32) | |||
| 26 | + | if KeyFingerprint(key) == KeyFingerprint(other) { | |||
| 27 | + | t.Fatal("distinct keys produced identical fingerprints") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestProxyRegistryFileHoldsNoKeyMaterial(t *testing.T) { | |||
| 32 | + | r := NewProxyRegistry(privateDir(t)) | |||
| 33 | + | key := bytes.Repeat([]byte{0xA5}, 32) | |||
| 34 | + | fingerprint := KeyFingerprint(key) | |||
| 35 | + | rec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: fingerprint} | |||
| 36 | + | if err := r.Publish(rec); err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | data, err := os.ReadFile(r.path("work")) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | if bytes.Contains(data, key) || bytes.Contains(data, []byte(base64.StdEncoding.EncodeToString(key))) { | |||
| 45 | + | t.Fatalf("registry file contains raw key material: %s", data) | |||
| 46 | + | } | |||
| 47 | + | var fields map[string]json.RawMessage | |||
| 48 | + | if err := json.Unmarshal(data, &fields); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | if _, ok := fields["key"]; ok { | |||
| 52 | + | t.Fatalf("registry file contains raw key field: %s", data) | |||
| 53 | + | } | |||
| 54 | + | var got string | |||
| 55 | + | if err := json.Unmarshal(fields["key_fingerprint"], &got); err != nil { | |||
| 56 | + | t.Fatal(err) | |||
| 57 | + | } | |||
| 58 | + | if got != fingerprint { | |||
| 59 | + | t.Fatalf("key_fingerprint=%q, want %q", got, fingerprint) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 14 | func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | 63 | func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | ||
| 15 | t.Run("reuses live matching record", func(t *testing.T) { | 64 | t.Run("reuses live matching record", func(t *testing.T) { | ||
| 16 | r := NewProxyRegistry(privateDir(t)) | 65 | r := NewProxyRegistry(privateDir(t)) | ||
| 17 | - | want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"} | 66 | + | want := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"} |
| 18 | if err := r.Publish(want); err != nil { | 67 | if err := r.Publish(want); err != nil { | ||
| 19 | t.Fatal(err) | 68 | t.Fatal(err) | ||
| 20 | } | 69 | } | ||
| @@ -22,16 +71,16 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||||
| 22 | if !ok { | 71 | if !ok { | ||
| 23 | t.Fatal("Lookup ok=false, want true") | 72 | t.Fatal("Lookup ok=false, want true") | ||
| 24 | } | 73 | } | ||
| 25 | - | if got.Port != want.Port || got.Key != want.Key || got.PID != want.PID { | 74 | + | if got.Port != want.Port || got.KeyFingerprint != want.KeyFingerprint || got.PID != want.PID { |
| 26 | t.Fatalf("Lookup()=%+v, want %+v", got, want) | 75 | t.Fatalf("Lookup()=%+v, want %+v", got, want) | ||
| 27 | } | 76 | } | ||
| 28 | }) | 77 | }) | ||
| 29 | t.Run("removes stale record", func(t *testing.T) { | 78 | t.Run("removes stale record", func(t *testing.T) { | ||
| 30 | r := NewProxyRegistry(privateDir(t)) | 79 | r := NewProxyRegistry(privateDir(t)) | ||
| 31 | - | if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "abc"}); err != nil { | 80 | + | if err := r.Publish(ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "abc"}); err != nil { |
| 32 | t.Fatal(err) | 81 | t.Fatal(err) | ||
| 33 | } | 82 | } | ||
| 34 | - | if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key":"abc"}`), 0o600); err != nil { | 83 | + | if err := os.WriteFile(r.path("work"), []byte(`{"session":"work","pid":-1,"port":61000,"key_fingerprint":"abc"}`), 0o600); err != nil { |
| 35 | t.Fatal(err) | 84 | t.Fatal(err) | ||
| 36 | } | 85 | } | ||
| 37 | if _, ok := r.Lookup("work"); ok { | 86 | if _, ok := r.Lookup("work"); ok { | ||
| @@ -43,11 +92,11 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||||
| 43 | }) | 92 | }) | ||
| 44 | t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) { | 93 | t.Run("supersedes without pid-based kill and cleanup is owned", func(t *testing.T) { | ||
| 45 | r := NewProxyRegistry(privateDir(t)) | 94 | r := NewProxyRegistry(privateDir(t)) | ||
| 46 | - | old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, Key: "old"} | 95 | + | old := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61000, KeyFingerprint: "old"} |
| 47 | if err := r.Publish(old); err != nil { | 96 | if err := r.Publish(old); err != nil { | ||
| 48 | t.Fatal(err) | 97 | t.Fatal(err) | ||
| 49 | } | 98 | } | ||
| 50 | - | newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, Key: "new"} | 99 | + | newRec := ProxyRecord{Session: "work", PID: os.Getpid(), Port: 61001, KeyFingerprint: "new"} |
| 51 | if err := r.Publish(newRec); err != nil { | 100 | if err := r.Publish(newRec); err != nil { | ||
| 52 | t.Fatal(err) | 101 | t.Fatal(err) | ||
| 53 | } | 102 | } | ||
| @@ -58,7 +107,7 @@ func TestProxyRegistryReuseStaleCleanupAndSupersede(t *testing.T) { | |||||
| 58 | if !ok { | 107 | if !ok { | ||
| 59 | t.Fatal("new live record removed by stale owner") | 108 | t.Fatal("new live record removed by stale owner") | ||
| 60 | } | 109 | } | ||
| 61 | - | if got.Port != newRec.Port || got.Key != newRec.Key { | 110 | + | if got.Port != newRec.Port || got.KeyFingerprint != newRec.KeyFingerprint { |
| 62 | t.Fatalf("Lookup()=%+v, want %+v", got, newRec) | 111 | t.Fatalf("Lookup()=%+v, want %+v", got, newRec) | ||
| 63 | } | 112 | } | ||
| 64 | if err := r.RemoveOwned(newRec); err != nil { | 113 | if err := r.RemoveOwned(newRec); err != nil { | ||
| @@ -84,8 +133,8 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) { | |||||
| 84 | if r.path(tt.a) == r.path(tt.b) { | 133 | if r.path(tt.a) == r.path(tt.b) { | ||
| 85 | t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a)) | 134 | t.Fatalf("path(%q) collides with path(%q): %s", tt.a, tt.b, r.path(tt.a)) | ||
| 86 | } | 135 | } | ||
| 87 | - | first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, Key: "first"} | 136 | + | first := ProxyRecord{Session: tt.a, PID: os.Getpid(), Port: 61000, KeyFingerprint: "first"} |
| 88 | - | second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, Key: "second"} | 137 | + | second := ProxyRecord{Session: tt.b, PID: os.Getpid(), Port: 61001, KeyFingerprint: "second"} |
| 89 | if err := r.Publish(first); err != nil { | 138 | if err := r.Publish(first); err != nil { | ||
| 90 | t.Fatal(err) | 139 | t.Fatal(err) | ||
| 91 | } | 140 | } | ||
| @@ -93,11 +142,11 @@ func TestProxyRegistrySessionPathIsCollisionFree(t *testing.T) { | |||||
| 93 | t.Fatal(err) | 142 | t.Fatal(err) | ||
| 94 | } | 143 | } | ||
| 95 | gotFirst, ok := r.Lookup(tt.a) | 144 | gotFirst, ok := r.Lookup(tt.a) | ||
| 96 | - | if !ok || gotFirst.Key != first.Key { | 145 | + | if !ok || gotFirst.KeyFingerprint != first.KeyFingerprint { |
| 97 | t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok) | 146 | t.Fatalf("Lookup(%q)=%+v ok=%v, want first record", tt.a, gotFirst, ok) | ||
| 98 | } | 147 | } | ||
| 99 | gotSecond, ok := r.Lookup(tt.b) | 148 | gotSecond, ok := r.Lookup(tt.b) | ||
| 100 | - | if !ok || gotSecond.Key != second.Key { | 149 | + | if !ok || gotSecond.KeyFingerprint != second.KeyFingerprint { |
| 101 | t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok) | 150 | t.Fatalf("Lookup(%q)=%+v ok=%v, want second record", tt.b, gotSecond, ok) | ||
| 102 | } | 151 | } | ||
| 103 | } | 152 | } | ||
M internal/adapters/dgram/ssh_dialer.go +56 -24
| @@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 158 | 158 | cleanup = false | |
| 159 | 159 | return nil, udpUnavailable("read bootstrap readiness", err, &stderr) | |
| 160 | 160 | } | |
| 161 | + | defer pdgram.Erase(ready.key) | |
| 161 | 162 | if err := waitBootstrapContext(bootstrapCtx, proc); err != nil { | |
| 162 | 163 | waited = true | |
| 163 | 164 | cleanup = false | |
| @@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 182 | 183 | return listenUDPPacket(ctx) | |
| 183 | 184 | }, | |
| 184 | 185 | }, WithRuntimeObserver(d.RuntimeObserver)) | |
| 186 | + | pdgram.Erase(ready.key) | |
| 185 | 187 | if err != nil { | |
| 186 | 188 | _ = pc.Close() | |
| 187 | 189 | return nil, udpUnavailable("create UDP transport", err, &stderr) | |
| @@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 196 | 198 | return t, nil | |
| 197 | 199 | } | |
| 198 | 200 | ||
| 199 | - | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | |
| 200 | - | type result struct { | |
| 201 | - | ready udpReady | |
| 202 | - | err error | |
| 201 | + | type udpReadyResult struct { | |
| 202 | + | ready udpReady | |
| 203 | + | err error | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) { | |
| 207 | + | select { | |
| 208 | + | case ch <- res: | |
| 209 | + | case <-ctx.Done(): | |
| 210 | + | pdgram.Erase(res.ready.key) | |
| 203 | 211 | } | |
| 204 | - | ch := make(chan result, 1) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | |
| 215 | + | ch := make(chan udpReadyResult) | |
| 205 | 216 | go func() { | |
| 206 | 217 | ready, err := readUDPReady(r) | |
| 207 | - | ch <- result{ready: ready, err: err} | |
| 218 | + | deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err}) | |
| 208 | 219 | }() | |
| 209 | 220 | select { | |
| 210 | 221 | case res := <-ch: | |
| @@ -247,26 +258,47 @@ type udpReady struct { | |||
| 247 | 258 | } | |
| 248 | 259 | ||
| 249 | 260 | func readUDPReady(r io.Reader) (udpReady, error) { | |
| 250 | - | line, err := bufio.NewReader(r).ReadString('\n') | |
| 251 | - | if err != nil && line == "" { | |
| 252 | - | return udpReady{}, err | |
| 253 | - | } | |
| 254 | - | fields := strings.Fields(line) | |
| 255 | - | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |
| 256 | - | return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line)) | |
| 257 | - | } | |
| 258 | - | port, err := strconv.Atoi(fields[1]) | |
| 259 | - | if err != nil || port < 1 || port > 65535 { | |
| 260 | - | return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1]) | |
| 261 | - | } | |
| 262 | - | key, err := base64.StdEncoding.DecodeString(fields[2]) | |
| 263 | - | if err != nil { | |
| 264 | - | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | |
| 261 | + | out := make([]byte, pdgram.KeySize) | |
| 262 | + | var ( | |
| 263 | + | port int | |
| 264 | + | keyLen int | |
| 265 | + | parseErr error | |
| 266 | + | ) | |
| 267 | + | pdgram.SecretDo(func() { | |
| 268 | + | line, err := bufio.NewReader(r).ReadString('\n') | |
| 269 | + | if err != nil && line == "" { | |
| 270 | + | parseErr = err | |
| 271 | + | return | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | fields := strings.Fields(line) | |
| 275 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |
| 276 | + | parseErr = fmt.Errorf("malformed UDP readiness line") | |
| 277 | + | return | |
| 278 | + | } | |
| 279 | + | port, parseErr = strconv.Atoi(fields[1]) | |
| 280 | + | if parseErr != nil || port < 1 || port > 65535 { | |
| 281 | + | parseErr = fmt.Errorf("invalid UDP port %q", fields[1]) | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | key, decodeErr := base64.StdEncoding.DecodeString(fields[2]) | |
| 285 | + | defer pdgram.Erase(key) | |
| 286 | + | if decodeErr != nil { | |
| 287 | + | parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr) | |
| 288 | + | return | |
| 289 | + | } | |
| 290 | + | keyLen = len(key) | |
| 291 | + | copy(out, key) | |
| 292 | + | }) | |
| 293 | + | if parseErr != nil { | |
| 294 | + | pdgram.Erase(out) | |
| 295 | + | return udpReady{}, parseErr | |
| 265 | 296 | } | |
| 266 | - | if len(key) != pdgram.KeySize { | |
| 267 | - | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | |
| 297 | + | if keyLen != pdgram.KeySize { | |
| 298 | + | pdgram.Erase(out) | |
| 299 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen) | |
| 268 | 300 | } | |
| 269 | - | return udpReady{port: port, key: key}, nil | |
| 301 | + | return udpReady{port: port, key: out}, nil | |
| 270 | 302 | } | |
| 271 | 303 | ||
| 272 | 304 | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | |
| @@ -158,6 +158,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 158 | cleanup = false | 158 | cleanup = false | ||
| 159 | return nil, udpUnavailable("read bootstrap readiness", err, &stderr) | 159 | return nil, udpUnavailable("read bootstrap readiness", err, &stderr) | ||
| 160 | } | 160 | } | ||
| 161 | + | defer pdgram.Erase(ready.key) | |||
| 161 | if err := waitBootstrapContext(bootstrapCtx, proc); err != nil { | 162 | if err := waitBootstrapContext(bootstrapCtx, proc); err != nil { | ||
| 162 | waited = true | 163 | waited = true | ||
| 163 | cleanup = false | 164 | cleanup = false | ||
| @@ -182,6 +183,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 182 | return listenUDPPacket(ctx) | 183 | return listenUDPPacket(ctx) | ||
| 183 | }, | 184 | }, | ||
| 184 | }, WithRuntimeObserver(d.RuntimeObserver)) | 185 | }, WithRuntimeObserver(d.RuntimeObserver)) | ||
| 186 | + | pdgram.Erase(ready.key) | |||
| 185 | if err != nil { | 187 | if err != nil { | ||
| 186 | _ = pc.Close() | 188 | _ = pc.Close() | ||
| 187 | return nil, udpUnavailable("create UDP transport", err, &stderr) | 189 | return nil, udpUnavailable("create UDP transport", err, &stderr) | ||
| @@ -196,15 +198,24 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||||
| 196 | return t, nil | 198 | return t, nil | ||
| 197 | } | 199 | } | ||
| 198 | 200 | ||||
| 199 | - | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | 201 | + | type udpReadyResult struct { |
| 200 | - | type result struct { | 202 | + | ready udpReady |
| 201 | - | ready udpReady | 203 | + | err error |
| 202 | - | err error | 204 | + | } |
| 205 | + | ||||
| 206 | + | func deliverUDPReady(ctx context.Context, ch chan<- udpReadyResult, res udpReadyResult) { | |||
| 207 | + | select { | |||
| 208 | + | case ch <- res: | |||
| 209 | + | case <-ctx.Done(): | |||
| 210 | + | pdgram.Erase(res.ready.key) | |||
| 203 | } | 211 | } | ||
| 204 | - | ch := make(chan result, 1) | 212 | + | } |
| 213 | + | ||||
| 214 | + | func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) { | |||
| 215 | + | ch := make(chan udpReadyResult) | |||
| 205 | go func() { | 216 | go func() { | ||
| 206 | ready, err := readUDPReady(r) | 217 | ready, err := readUDPReady(r) | ||
| 207 | - | ch <- result{ready: ready, err: err} | 218 | + | deliverUDPReady(ctx, ch, udpReadyResult{ready: ready, err: err}) |
| 208 | }() | 219 | }() | ||
| 209 | select { | 220 | select { | ||
| 210 | case res := <-ch: | 221 | case res := <-ch: | ||
| @@ -247,26 +258,47 @@ type udpReady struct { | |||||
| 247 | } | 258 | } | ||
| 248 | 259 | ||||
| 249 | func readUDPReady(r io.Reader) (udpReady, error) { | 260 | func readUDPReady(r io.Reader) (udpReady, error) { | ||
| 250 | - | line, err := bufio.NewReader(r).ReadString('\n') | 261 | + | out := make([]byte, pdgram.KeySize) |
| 251 | - | if err != nil && line == "" { | 262 | + | var ( |
| 252 | - | return udpReady{}, err | 263 | + | port int |
| 253 | - | } | 264 | + | keyLen int |
| 254 | - | fields := strings.Fields(line) | 265 | + | parseErr error |
| 255 | - | if len(fields) != 3 || fields[0] != "VEV-UDP" { | 266 | + | ) |
| 256 | - | return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line)) | 267 | + | pdgram.SecretDo(func() { |
| 257 | - | } | 268 | + | line, err := bufio.NewReader(r).ReadString('\n') |
| 258 | - | port, err := strconv.Atoi(fields[1]) | 269 | + | if err != nil && line == "" { |
| 259 | - | if err != nil || port < 1 || port > 65535 { | 270 | + | parseErr = err |
| 260 | - | return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1]) | 271 | + | return |
| 261 | - | } | 272 | + | } |
| 262 | - | key, err := base64.StdEncoding.DecodeString(fields[2]) | 273 | + | |
| 263 | - | if err != nil { | 274 | + | fields := strings.Fields(line) |
| 264 | - | return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err) | 275 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { |
| 276 | + | parseErr = fmt.Errorf("malformed UDP readiness line") | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | port, parseErr = strconv.Atoi(fields[1]) | |||
| 280 | + | if parseErr != nil || port < 1 || port > 65535 { | |||
| 281 | + | parseErr = fmt.Errorf("invalid UDP port %q", fields[1]) | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 284 | + | key, decodeErr := base64.StdEncoding.DecodeString(fields[2]) | |||
| 285 | + | defer pdgram.Erase(key) | |||
| 286 | + | if decodeErr != nil { | |||
| 287 | + | parseErr = fmt.Errorf("invalid bootstrap key: %w", decodeErr) | |||
| 288 | + | return | |||
| 289 | + | } | |||
| 290 | + | keyLen = len(key) | |||
| 291 | + | copy(out, key) | |||
| 292 | + | }) | |||
| 293 | + | if parseErr != nil { | |||
| 294 | + | pdgram.Erase(out) | |||
| 295 | + | return udpReady{}, parseErr | |||
| 265 | } | 296 | } | ||
| 266 | - | if len(key) != pdgram.KeySize { | 297 | + | if keyLen != pdgram.KeySize { |
| 267 | - | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key)) | 298 | + | pdgram.Erase(out) |
| 299 | + | return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", keyLen) | |||
| 268 | } | 300 | } | ||
| 269 | - | return udpReady{port: port, key: key}, nil | 301 | + | return udpReady{port: port, key: out}, nil |
| 270 | } | 302 | } | ||
| 271 | 303 | ||||
| 272 | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | 304 | func udpUnavailable(action string, err error, stderr fmt.Stringer) error { | ||
M internal/adapters/dgram/ssh_dialer_test.go +33 -5
| @@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||
| 60 | 60 | wantStdoutClosed bool | |
| 61 | 61 | }{ | |
| 62 | 62 | {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true}, | |
| 63 | - | {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true}, | |
| 63 | + | {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true}, | |
| 64 | 64 | {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true}, | |
| 65 | 65 | {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true}, | |
| 66 | 66 | } | |
| @@ -174,24 +174,52 @@ type closeTrackPacketConn struct { | |||
| 174 | 174 | ||
| 175 | 175 | func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() } | |
| 176 | 176 | ||
| 177 | + | func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) { | |
| 178 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 179 | + | cancel() | |
| 180 | + | key := make([]byte, pdgram.KeySize) | |
| 181 | + | for i := range key { | |
| 182 | + | key[i] = byte(i + 1) | |
| 183 | + | } | |
| 184 | + | ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n")) | |
| 185 | + | if err != nil { | |
| 186 | + | t.Fatal(err) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready}) | |
| 190 | + | ||
| 191 | + | for i, b := range ready.key { | |
| 192 | + | if b != 0 { | |
| 193 | + | t.Fatalf("key[%d] = %d, want erased", i, b) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 177 | 198 | func TestReadUDPReady(t *testing.T) { | |
| 178 | 199 | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | |
| 179 | 200 | tests := []struct { | |
| 180 | 201 | name, line string | |
| 181 | 202 | wantPort int | |
| 182 | 203 | wantErr string | |
| 204 | + | exactErr bool | |
| 183 | 205 | }{ | |
| 184 | 206 | {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234}, | |
| 185 | - | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"}, | |
| 186 | - | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"}, | |
| 207 | + | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true}, | |
| 208 | + | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true}, | |
| 187 | 209 | {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"}, | |
| 188 | 210 | } | |
| 189 | 211 | for _, tt := range tests { | |
| 190 | 212 | t.Run(tt.name, func(t *testing.T) { | |
| 191 | 213 | got, err := readUDPReady(strings.NewReader(tt.line)) | |
| 192 | 214 | if tt.wantErr != "" { | |
| 193 | - | if err == nil || !strings.Contains(err.Error(), tt.wantErr) { | |
| 194 | - | t.Fatalf("err=%v, want %q", err, tt.wantErr) | |
| 215 | + | if err == nil { | |
| 216 | + | t.Fatalf("err=nil, want %q", tt.wantErr) | |
| 217 | + | } | |
| 218 | + | if tt.exactErr && err.Error() != tt.wantErr { | |
| 219 | + | t.Fatalf("err=%q, want exactly %q", err, tt.wantErr) | |
| 220 | + | } | |
| 221 | + | if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) { | |
| 222 | + | t.Fatalf("err=%v, want containing %q", err, tt.wantErr) | |
| 195 | 223 | } | |
| 196 | 224 | return | |
| 197 | 225 | } | |
| @@ -60,7 +60,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) { | |||||
| 60 | wantStdoutClosed bool | 60 | wantStdoutClosed bool | ||
| 61 | }{ | 61 | }{ | ||
| 62 | {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true}, | 62 | {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true}, | ||
| 63 | - | {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true}, | 63 | + | {name: "malformed readiness", stdout: "hello\n", want: "malformed UDP readiness line", killed: true, waited: true, wantStdoutClosed: true}, |
| 64 | {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true}, | 64 | {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true}, | ||
| 65 | {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true}, | 65 | {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true}, | ||
| 66 | } | 66 | } | ||
| @@ -174,24 +174,52 @@ type closeTrackPacketConn struct { | |||||
| 174 | 174 | ||||
| 175 | func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() } | 175 | func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() } | ||
| 176 | 176 | ||||
| 177 | + | func TestDeliverUDPReadyErasesParsedKeyAfterCancellation(t *testing.T) { | |||
| 178 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 179 | + | cancel() | |||
| 180 | + | key := make([]byte, pdgram.KeySize) | |||
| 181 | + | for i := range key { | |||
| 182 | + | key[i] = byte(i + 1) | |||
| 183 | + | } | |||
| 184 | + | ready, err := readUDPReady(strings.NewReader("VEV-UDP 1234 " + base64.StdEncoding.EncodeToString(key) + "\n")) | |||
| 185 | + | if err != nil { | |||
| 186 | + | t.Fatal(err) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | deliverUDPReady(ctx, make(chan udpReadyResult), udpReadyResult{ready: ready}) | |||
| 190 | + | ||||
| 191 | + | for i, b := range ready.key { | |||
| 192 | + | if b != 0 { | |||
| 193 | + | t.Fatalf("key[%d] = %d, want erased", i, b) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | ||||
| 177 | func TestReadUDPReady(t *testing.T) { | 198 | func TestReadUDPReady(t *testing.T) { | ||
| 178 | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | 199 | key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize)) | ||
| 179 | tests := []struct { | 200 | tests := []struct { | ||
| 180 | name, line string | 201 | name, line string | ||
| 181 | wantPort int | 202 | wantPort int | ||
| 182 | wantErr string | 203 | wantErr string | ||
| 204 | + | exactErr bool | |||
| 183 | }{ | 205 | }{ | ||
| 184 | {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234}, | 206 | {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234}, | ||
| 185 | - | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"}, | 207 | + | {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed UDP readiness line", exactErr: true}, |
| 186 | - | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"}, | 208 | + | {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: `invalid UDP port "99999"`, exactErr: true}, |
| 187 | {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"}, | 209 | {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"}, | ||
| 188 | } | 210 | } | ||
| 189 | for _, tt := range tests { | 211 | for _, tt := range tests { | ||
| 190 | t.Run(tt.name, func(t *testing.T) { | 212 | t.Run(tt.name, func(t *testing.T) { | ||
| 191 | got, err := readUDPReady(strings.NewReader(tt.line)) | 213 | got, err := readUDPReady(strings.NewReader(tt.line)) | ||
| 192 | if tt.wantErr != "" { | 214 | if tt.wantErr != "" { | ||
| 193 | - | if err == nil || !strings.Contains(err.Error(), tt.wantErr) { | 215 | + | if err == nil { |
| 194 | - | t.Fatalf("err=%v, want %q", err, tt.wantErr) | 216 | + | t.Fatalf("err=nil, want %q", tt.wantErr) |
| 217 | + | } | |||
| 218 | + | if tt.exactErr && err.Error() != tt.wantErr { | |||
| 219 | + | t.Fatalf("err=%q, want exactly %q", err, tt.wantErr) | |||
| 220 | + | } | |||
| 221 | + | if !tt.exactErr && !strings.Contains(err.Error(), tt.wantErr) { | |||
| 222 | + | t.Fatalf("err=%v, want containing %q", err, tt.wantErr) | |||
| 195 | } | 223 | } | ||
| 196 | return | 224 | return | ||
| 197 | } | 225 | } | ||
M internal/app/run.go +42 -17
| @@ -48,6 +48,7 @@ import ( | |||
| 48 | 48 | "github.com/bnema/vev/internal/usecase/client" | |
| 49 | 49 | "github.com/bnema/vev/internal/usecase/confirm" | |
| 50 | 50 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 51 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 51 | 52 | "github.com/bnema/vev/pkg/kv" | |
| 52 | 53 | ) | |
| 53 | 54 | ||
| @@ -929,10 +930,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 929 | 930 | return err | |
| 930 | 931 | } | |
| 931 | 932 | defer func() { _ = conn.Close() }() | |
| 932 | - | key := make([]byte, 32) | |
| 933 | - | if _, err := rand.Read(key); err != nil { | |
| 934 | - | return err | |
| 935 | - | } | |
| 936 | 933 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | |
| 937 | 934 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | |
| 938 | 935 | }, realSpawn, defaultBackoff) | |
| @@ -942,26 +939,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 942 | 939 | defer func() { _ = daemonTr.Close() }() | |
| 943 | 940 | udpOptions := udpProxyClientTransportOptions | |
| 944 | 941 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | |
| 945 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |
| 946 | - | if err != nil { | |
| 947 | - | return err | |
| 948 | - | } | |
| 949 | - | defer func() { _ = dg.Close() }() | |
| 950 | - | ||
| 951 | 942 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |
| 952 | 943 | if !ok { | |
| 953 | 944 | return errors.New("vev: udp proxy did not get a UDP address") | |
| 954 | 945 | } | |
| 955 | - | keyText := base64.StdEncoding.EncodeToString(key) | |
| 956 | 946 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | |
| 957 | - | record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText} | |
| 958 | - | if err := registry.Publish(record); err != nil { | |
| 959 | - | return err | |
| 947 | + | ||
| 948 | + | var ( | |
| 949 | + | dg *dgram.Transport | |
| 950 | + | record dgram.ProxyRecord | |
| 951 | + | published bool | |
| 952 | + | setupErr error | |
| 953 | + | ) | |
| 954 | + | pdgram.SecretDo(func() { | |
| 955 | + | key := make([]byte, pdgram.KeySize) | |
| 956 | + | defer pdgram.Erase(key) | |
| 957 | + | if _, setupErr = rand.Read(key); setupErr != nil { | |
| 958 | + | return | |
| 959 | + | } | |
| 960 | + | dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |
| 961 | + | if setupErr != nil { | |
| 962 | + | return | |
| 963 | + | } | |
| 964 | + | record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)} | |
| 965 | + | if setupErr = registry.Publish(record); setupErr != nil { | |
| 966 | + | return | |
| 967 | + | } | |
| 968 | + | published = true | |
| 969 | + | ||
| 970 | + | readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...) | |
| 971 | + | readiness = append(readiness, ' ') | |
| 972 | + | readiness = base64.StdEncoding.AppendEncode(readiness, key) | |
| 973 | + | readiness = append(readiness, '\n') | |
| 974 | + | defer pdgram.Erase(readiness) | |
| 975 | + | _, setupErr = ready.Write(readiness) | |
| 976 | + | pdgram.Erase(readiness) | |
| 977 | + | pdgram.Erase(key) | |
| 978 | + | }) | |
| 979 | + | if setupErr != nil { | |
| 980 | + | if dg != nil { | |
| 981 | + | _ = dg.Close() | |
| 982 | + | } | |
| 983 | + | if published { | |
| 984 | + | _ = registry.RemoveOwned(record) | |
| 985 | + | } | |
| 986 | + | return setupErr | |
| 960 | 987 | } | |
| 988 | + | defer func() { _ = dg.Close() }() | |
| 961 | 989 | defer func() { _ = registry.RemoveOwned(record) }() | |
| 962 | - | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil { | |
| 963 | - | return err | |
| 964 | - | } | |
| 965 | 990 | ||
| 966 | 991 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | |
| 967 | 992 | } | |
| @@ -48,6 +48,7 @@ import ( | |||||
| 48 | "github.com/bnema/vev/internal/usecase/client" | 48 | "github.com/bnema/vev/internal/usecase/client" | ||
| 49 | "github.com/bnema/vev/internal/usecase/confirm" | 49 | "github.com/bnema/vev/internal/usecase/confirm" | ||
| 50 | "github.com/bnema/vev/internal/usecase/daemon" | 50 | "github.com/bnema/vev/internal/usecase/daemon" | ||
| 51 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 51 | "github.com/bnema/vev/pkg/kv" | 52 | "github.com/bnema/vev/pkg/kv" | ||
| 52 | ) | 53 | ) | ||
| 53 | 54 | ||||
| @@ -929,10 +930,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||||
| 929 | return err | 930 | return err | ||
| 930 | } | 931 | } | ||
| 931 | defer func() { _ = conn.Close() }() | 932 | defer func() { _ = conn.Close() }() | ||
| 932 | - | key := make([]byte, 32) | |||
| 933 | - | if _, err := rand.Read(key); err != nil { | |||
| 934 | - | return err | |||
| 935 | - | } | |||
| 936 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | 933 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | ||
| 937 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | 934 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | ||
| 938 | }, realSpawn, defaultBackoff) | 935 | }, realSpawn, defaultBackoff) | ||
| @@ -942,26 +939,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||||
| 942 | defer func() { _ = daemonTr.Close() }() | 939 | defer func() { _ = daemonTr.Close() }() | ||
| 943 | udpOptions := udpProxyClientTransportOptions | 940 | udpOptions := udpProxyClientTransportOptions | ||
| 944 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | 941 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | ||
| 945 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |||
| 946 | - | if err != nil { | |||
| 947 | - | return err | |||
| 948 | - | } | |||
| 949 | - | defer func() { _ = dg.Close() }() | |||
| 950 | - | ||||
| 951 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | 942 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| 952 | if !ok { | 943 | if !ok { | ||
| 953 | return errors.New("vev: udp proxy did not get a UDP address") | 944 | return errors.New("vev: udp proxy did not get a UDP address") | ||
| 954 | } | 945 | } | ||
| 955 | - | keyText := base64.StdEncoding.EncodeToString(key) | |||
| 956 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | 946 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | ||
| 957 | - | record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText} | 947 | + | |
| 958 | - | if err := registry.Publish(record); err != nil { | 948 | + | var ( |
| 959 | - | return err | 949 | + | dg *dgram.Transport |
| 950 | + | record dgram.ProxyRecord | |||
| 951 | + | published bool | |||
| 952 | + | setupErr error | |||
| 953 | + | ) | |||
| 954 | + | pdgram.SecretDo(func() { | |||
| 955 | + | key := make([]byte, pdgram.KeySize) | |||
| 956 | + | defer pdgram.Erase(key) | |||
| 957 | + | if _, setupErr = rand.Read(key); setupErr != nil { | |||
| 958 | + | return | |||
| 959 | + | } | |||
| 960 | + | dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |||
| 961 | + | if setupErr != nil { | |||
| 962 | + | return | |||
| 963 | + | } | |||
| 964 | + | record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)} | |||
| 965 | + | if setupErr = registry.Publish(record); setupErr != nil { | |||
| 966 | + | return | |||
| 967 | + | } | |||
| 968 | + | published = true | |||
| 969 | + | ||||
| 970 | + | readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...) | |||
| 971 | + | readiness = append(readiness, ' ') | |||
| 972 | + | readiness = base64.StdEncoding.AppendEncode(readiness, key) | |||
| 973 | + | readiness = append(readiness, '\n') | |||
| 974 | + | defer pdgram.Erase(readiness) | |||
| 975 | + | _, setupErr = ready.Write(readiness) | |||
| 976 | + | pdgram.Erase(readiness) | |||
| 977 | + | pdgram.Erase(key) | |||
| 978 | + | }) | |||
| 979 | + | if setupErr != nil { | |||
| 980 | + | if dg != nil { | |||
| 981 | + | _ = dg.Close() | |||
| 982 | + | } | |||
| 983 | + | if published { | |||
| 984 | + | _ = registry.RemoveOwned(record) | |||
| 985 | + | } | |||
| 986 | + | return setupErr | |||
| 960 | } | 987 | } | ||
| 988 | + | defer func() { _ = dg.Close() }() | |||
| 961 | defer func() { _ = registry.RemoveOwned(record) }() | 989 | defer func() { _ = registry.RemoveOwned(record) }() | ||
| 962 | - | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil { | |||
| 963 | - | return err | |||
| 964 | - | } | |||
| 965 | 990 | ||||
| 966 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | 991 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | ||
| 967 | } | 992 | } | ||
M pkg/dgram/dgram.go +6 -0
| @@ -24,6 +24,12 @@ var ( | |||
| 24 | 24 | ErrFragment = errors.New("dgram: invalid fragment") | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | + | // Erase overwrites b with zeroes. This is best effort: copies made by the | |
| 28 | + | // compiler, runtime, or called code may retain the original data. | |
| 29 | + | func Erase(b []byte) { | |
| 30 | + | clear(b) | |
| 31 | + | } | |
| 32 | + | ||
| 27 | 33 | type Codec struct { | |
| 28 | 34 | gcm cipher.AEAD | |
| 29 | 35 | } | |
| @@ -24,6 +24,12 @@ var ( | |||||
| 24 | ErrFragment = errors.New("dgram: invalid fragment") | 24 | ErrFragment = errors.New("dgram: invalid fragment") | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | + | // Erase overwrites b with zeroes. This is best effort: copies made by the | |||
| 28 | + | // compiler, runtime, or called code may retain the original data. | |||
| 29 | + | func Erase(b []byte) { | |||
| 30 | + | clear(b) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 27 | type Codec struct { | 33 | type Codec struct { | ||
| 28 | gcm cipher.AEAD | 34 | gcm cipher.AEAD | ||
| 29 | } | 35 | } | ||
A pkg/dgram/erase_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestErase(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | buf []byte | |
| 12 | + | }{ | |
| 13 | + | {name: "nil", buf: nil}, | |
| 14 | + | {name: "empty", buf: []byte{}}, | |
| 15 | + | {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | for _, tt := range tests { | |
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |
| 20 | + | Erase(tt.buf) | |
| 21 | + | ||
| 22 | + | if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) { | |
| 23 | + | t.Fatalf("Erase() left data in buffer: %x", tt.buf) | |
| 24 | + | } | |
| 25 | + | }) | |
| 26 | + | } | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestErase(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | buf []byte | |||
| 12 | + | }{ | |||
| 13 | + | {name: "nil", buf: nil}, | |||
| 14 | + | {name: "empty", buf: []byte{}}, | |||
| 15 | + | {name: "key", buf: bytes.Repeat([]byte{0xa5}, KeySize)}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | for _, tt := range tests { | |||
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 20 | + | Erase(tt.buf) | |||
| 21 | + | ||||
| 22 | + | if !bytes.Equal(tt.buf, make([]byte, len(tt.buf))) { | |||
| 23 | + | t.Fatalf("Erase() left data in buffer: %x", tt.buf) | |||
| 24 | + | } | |||
| 25 | + | }) | |||
| 26 | + | } | |||
| 27 | + | } | |||
A pkg/dgram/secret_disabled.go +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | //go:build !goexperiment.runtimesecret | |
| 2 | + | ||
| 3 | + | package dgram | |
| 4 | + | ||
| 5 | + | // SecretDo invokes f directly when runtime/secret is not in this build. | |
| 6 | + | // Callers still explicitly clear every reachable key buffer with Erase. | |
| 7 | + | func SecretDo(f func()) { f() } | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | //go:build !goexperiment.runtimesecret | |||
| 2 | + | ||||
| 3 | + | package dgram | |||
| 4 | + | ||||
| 5 | + | // SecretDo invokes f directly when runtime/secret is not in this build. | |||
| 6 | + | // Callers still explicitly clear every reachable key buffer with Erase. | |||
| 7 | + | func SecretDo(f func()) { f() } | |||
A pkg/dgram/secret_enabled.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | //go:build goexperiment.runtimesecret | |
| 2 | + | ||
| 3 | + | package dgram | |
| 4 | + | ||
| 5 | + | import "runtime/secret" | |
| 6 | + | ||
| 7 | + | // SecretDo invokes f through runtime/secret.Do. On supported Go 1.26 | |
| 8 | + | // platforms this protects temporary stack/register storage for f's call tree | |
| 9 | + | // and marks its allocations for clearing after they become unreachable and | |
| 10 | + | // the GC notices. On unsupported platforms runtime/secret.Do invokes f | |
| 11 | + | // directly. It does not protect globals or goroutines started by f. | |
| 12 | + | func SecretDo(f func()) { secret.Do(f) } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | //go:build goexperiment.runtimesecret | |||
| 2 | + | ||||
| 3 | + | package dgram | |||
| 4 | + | ||||
| 5 | + | import "runtime/secret" | |||
| 6 | + | ||||
| 7 | + | // SecretDo invokes f through runtime/secret.Do. On supported Go 1.26 | |||
| 8 | + | // platforms this protects temporary stack/register storage for f's call tree | |||
| 9 | + | // and marks its allocations for clearing after they become unreachable and | |||
| 10 | + | // the GC notices. On unsupported platforms runtime/secret.Do invokes f | |||
| 11 | + | // directly. It does not protect globals or goroutines started by f. | |||
| 12 | + | func SecretDo(f func()) { secret.Do(f) } | |||
A pkg/dgram/secret_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestSecretDoRunsSynchronously(t *testing.T) { | |
| 6 | + | called := false | |
| 7 | + | ||
| 8 | + | SecretDo(func() { | |
| 9 | + | called = true | |
| 10 | + | }) | |
| 11 | + | ||
| 12 | + | if !called { | |
| 13 | + | t.Fatal("SecretDo returned before callback ran") | |
| 14 | + | } | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestSecretDoPropagatesPanic(t *testing.T) { | |
| 18 | + | want := &struct{ message string }{message: "secret callback panic"} | |
| 19 | + | defer func() { | |
| 20 | + | if got := recover(); got != want { | |
| 21 | + | t.Fatalf("recover() = %#v, want %#v", got, want) | |
| 22 | + | } | |
| 23 | + | }() | |
| 24 | + | ||
| 25 | + | SecretDo(func() { | |
| 26 | + | panic(want) | |
| 27 | + | }) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestSecretDoRunsSynchronously(t *testing.T) { | |||
| 6 | + | called := false | |||
| 7 | + | ||||
| 8 | + | SecretDo(func() { | |||
| 9 | + | called = true | |||
| 10 | + | }) | |||
| 11 | + | ||||
| 12 | + | if !called { | |||
| 13 | + | t.Fatal("SecretDo returned before callback ran") | |||
| 14 | + | } | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestSecretDoPropagatesPanic(t *testing.T) { | |||
| 18 | + | want := &struct{ message string }{message: "secret callback panic"} | |||
| 19 | + | defer func() { | |||
| 20 | + | if got := recover(); got != want { | |||
| 21 | + | t.Fatalf("recover() = %#v, want %#v", got, want) | |||
| 22 | + | } | |||
| 23 | + | }() | |||
| 24 | + | ||||
| 25 | + | SecretDo(func() { | |||
| 26 | + | panic(want) | |||
| 27 | + | }) | |||
| 28 | + | } | |||