Merge remote-tracking branch 'origin/main' into feat/control-api
13 files · +406 -92
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 | ||
| @@ -940,10 +941,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 940 | 941 | return err | |
| 941 | 942 | } | |
| 942 | 943 | defer func() { _ = conn.Close() }() | |
| 943 | - | key := make([]byte, 32) | |
| 944 | - | if _, err := rand.Read(key); err != nil { | |
| 945 | - | return err | |
| 946 | - | } | |
| 947 | 944 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | |
| 948 | 945 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | |
| 949 | 946 | }, realSpawn, defaultBackoff) | |
| @@ -953,26 +950,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||
| 953 | 950 | defer func() { _ = daemonTr.Close() }() | |
| 954 | 951 | udpOptions := udpProxyClientTransportOptions | |
| 955 | 952 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | |
| 956 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |
| 957 | - | if err != nil { | |
| 958 | - | return err | |
| 959 | - | } | |
| 960 | - | defer func() { _ = dg.Close() }() | |
| 961 | - | ||
| 962 | 953 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |
| 963 | 954 | if !ok { | |
| 964 | 955 | return errors.New("vev: udp proxy did not get a UDP address") | |
| 965 | 956 | } | |
| 966 | - | keyText := base64.StdEncoding.EncodeToString(key) | |
| 967 | 957 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | |
| 968 | - | record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText} | |
| 969 | - | if err := registry.Publish(record); err != nil { | |
| 970 | - | return err | |
| 958 | + | ||
| 959 | + | var ( | |
| 960 | + | dg *dgram.Transport | |
| 961 | + | record dgram.ProxyRecord | |
| 962 | + | published bool | |
| 963 | + | setupErr error | |
| 964 | + | ) | |
| 965 | + | pdgram.SecretDo(func() { | |
| 966 | + | key := make([]byte, pdgram.KeySize) | |
| 967 | + | defer pdgram.Erase(key) | |
| 968 | + | if _, setupErr = rand.Read(key); setupErr != nil { | |
| 969 | + | return | |
| 970 | + | } | |
| 971 | + | dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |
| 972 | + | if setupErr != nil { | |
| 973 | + | return | |
| 974 | + | } | |
| 975 | + | record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)} | |
| 976 | + | if setupErr = registry.Publish(record); setupErr != nil { | |
| 977 | + | return | |
| 978 | + | } | |
| 979 | + | published = true | |
| 980 | + | ||
| 981 | + | readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...) | |
| 982 | + | readiness = append(readiness, ' ') | |
| 983 | + | readiness = base64.StdEncoding.AppendEncode(readiness, key) | |
| 984 | + | readiness = append(readiness, '\n') | |
| 985 | + | defer pdgram.Erase(readiness) | |
| 986 | + | _, setupErr = ready.Write(readiness) | |
| 987 | + | pdgram.Erase(readiness) | |
| 988 | + | pdgram.Erase(key) | |
| 989 | + | }) | |
| 990 | + | if setupErr != nil { | |
| 991 | + | if dg != nil { | |
| 992 | + | _ = dg.Close() | |
| 993 | + | } | |
| 994 | + | if published { | |
| 995 | + | _ = registry.RemoveOwned(record) | |
| 996 | + | } | |
| 997 | + | return setupErr | |
| 971 | 998 | } | |
| 999 | + | defer func() { _ = dg.Close() }() | |
| 972 | 1000 | defer func() { _ = registry.RemoveOwned(record) }() | |
| 973 | - | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil { | |
| 974 | - | return err | |
| 975 | - | } | |
| 976 | 1001 | ||
| 977 | 1002 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | |
| 978 | 1003 | } | |
| @@ -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 | ||||
| @@ -940,10 +941,6 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||||
| 940 | return err | 941 | return err | ||
| 941 | } | 942 | } | ||
| 942 | defer func() { _ = conn.Close() }() | 943 | defer func() { _ = conn.Close() }() | ||
| 943 | - | key := make([]byte, 32) | |||
| 944 | - | if _, err := rand.Read(key); err != nil { | |||
| 945 | - | return err | |||
| 946 | - | } | |||
| 947 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | 944 | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), func(ctx context.Context, dir string) (ports.Transport, error) { | ||
| 948 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | 945 | return ipc.DialContext(ctx, dir, ipc.WithRuntimeObserver(observer)) | ||
| 949 | }, realSpawn, defaultBackoff) | 946 | }, realSpawn, defaultBackoff) | ||
| @@ -953,26 +950,54 @@ func runUDPProxy(ctx context.Context, session string, ready io.Writer) (retErr e | |||||
| 953 | defer func() { _ = daemonTr.Close() }() | 950 | defer func() { _ = daemonTr.Close() }() | ||
| 954 | udpOptions := udpProxyClientTransportOptions | 951 | udpOptions := udpProxyClientTransportOptions | ||
| 955 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | 952 | udpOptions.Observe = dgram.DiagnosticLogObserver(log) | ||
| 956 | - | dg, err := dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |||
| 957 | - | if err != nil { | |||
| 958 | - | return err | |||
| 959 | - | } | |||
| 960 | - | defer func() { _ = dg.Close() }() | |||
| 961 | - | ||||
| 962 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | 953 | addr, ok := conn.LocalAddr().(*net.UDPAddr) | ||
| 963 | if !ok { | 954 | if !ok { | ||
| 964 | return errors.New("vev: udp proxy did not get a UDP address") | 955 | return errors.New("vev: udp proxy did not get a UDP address") | ||
| 965 | } | 956 | } | ||
| 966 | - | keyText := base64.StdEncoding.EncodeToString(key) | |||
| 967 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | 957 | registry := dgram.NewProxyRegistry(filepath.Join(ipc.SocketDir(), "udp-proxies")) | ||
| 968 | - | record := dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, Key: keyText} | 958 | + | |
| 969 | - | if err := registry.Publish(record); err != nil { | 959 | + | var ( |
| 970 | - | return err | 960 | + | dg *dgram.Transport |
| 961 | + | record dgram.ProxyRecord | |||
| 962 | + | published bool | |||
| 963 | + | setupErr error | |||
| 964 | + | ) | |||
| 965 | + | pdgram.SecretDo(func() { | |||
| 966 | + | key := make([]byte, pdgram.KeySize) | |||
| 967 | + | defer pdgram.Erase(key) | |||
| 968 | + | if _, setupErr = rand.Read(key); setupErr != nil { | |||
| 969 | + | return | |||
| 970 | + | } | |||
| 971 | + | dg, setupErr = dgram.NewTransportWithOptions(conn, nil, key, 2, 1, udpOptions, dgram.WithRuntimeObserver(observer)) | |||
| 972 | + | if setupErr != nil { | |||
| 973 | + | return | |||
| 974 | + | } | |||
| 975 | + | record = dgram.ProxyRecord{Session: session, PID: os.Getpid(), Port: addr.Port, KeyFingerprint: dgram.KeyFingerprint(key)} | |||
| 976 | + | if setupErr = registry.Publish(record); setupErr != nil { | |||
| 977 | + | return | |||
| 978 | + | } | |||
| 979 | + | published = true | |||
| 980 | + | ||||
| 981 | + | readiness := append([]byte("VEV-UDP "), strconv.Itoa(addr.Port)...) | |||
| 982 | + | readiness = append(readiness, ' ') | |||
| 983 | + | readiness = base64.StdEncoding.AppendEncode(readiness, key) | |||
| 984 | + | readiness = append(readiness, '\n') | |||
| 985 | + | defer pdgram.Erase(readiness) | |||
| 986 | + | _, setupErr = ready.Write(readiness) | |||
| 987 | + | pdgram.Erase(readiness) | |||
| 988 | + | pdgram.Erase(key) | |||
| 989 | + | }) | |||
| 990 | + | if setupErr != nil { | |||
| 991 | + | if dg != nil { | |||
| 992 | + | _ = dg.Close() | |||
| 993 | + | } | |||
| 994 | + | if published { | |||
| 995 | + | _ = registry.RemoveOwned(record) | |||
| 996 | + | } | |||
| 997 | + | return setupErr | |||
| 971 | } | 998 | } | ||
| 999 | + | defer func() { _ = dg.Close() }() | |||
| 972 | defer func() { _ = registry.RemoveOwned(record) }() | 1000 | defer func() { _ = registry.RemoveOwned(record) }() | ||
| 973 | - | if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, keyText); err != nil { | |||
| 974 | - | return err | |||
| 975 | - | } | |||
| 976 | 1001 | ||||
| 977 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | 1002 | return runUDPProxyRuntime(ctx, session, dg, daemonTr, os.Environ(), log) | ||
| 978 | } | 1003 | } | ||
M internal/usecase/daemon/daemon_test.go +18 -4
| @@ -64,12 +64,19 @@ func (t *signalTimer) Reset(time.Duration) bool { return false } | |||
| 64 | 64 | func (t *signalTimer) Stop() bool { return true } | |
| 65 | 65 | ||
| 66 | 66 | func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | |
| 67 | + | t.Helper() | |
| 68 | + | return newTestDaemonWithCleanup(t, ptys, clk, true) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon { | |
| 67 | 72 | t.Helper() | |
| 68 | 73 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 69 | 74 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |
| 70 | 75 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | |
| 71 | - | t.Cleanup(d.serveCancel) | |
| 72 | - | t.Cleanup(d.hardCancel) | |
| 76 | + | if registerCleanup { | |
| 77 | + | t.Cleanup(d.serveCancel) | |
| 78 | + | t.Cleanup(d.hardCancel) | |
| 79 | + | } | |
| 73 | 80 | return d | |
| 74 | 81 | } | |
| 75 | 82 | ||
| @@ -368,7 +375,12 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports. | |||
| 368 | 375 | ||
| 369 | 376 | func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 370 | 377 | t.Helper() | |
| 371 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 378 | + | return newManualSessionWithPTYsCleanup(t, true, ptys...) | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |
| 382 | + | t.Helper() | |
| 383 | + | d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup) | |
| 372 | 384 | tr, sends := newCapturingTransport(t) | |
| 373 | 385 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 374 | 386 | ac.initOverlays() | |
| @@ -387,7 +399,9 @@ func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 387 | 399 | ac.setSession(sess) | |
| 388 | 400 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | |
| 389 | 401 | d.sessions[sess.id] = sess | |
| 390 | - | t.Cleanup(cancel) | |
| 402 | + | if registerCleanup { | |
| 403 | + | t.Cleanup(cancel) | |
| 404 | + | } | |
| 391 | 405 | return d, sess, ac, sends | |
| 392 | 406 | } | |
| 393 | 407 | ||
| @@ -64,12 +64,19 @@ func (t *signalTimer) Reset(time.Duration) bool { return false } | |||||
| 64 | func (t *signalTimer) Stop() bool { return true } | 64 | func (t *signalTimer) Stop() bool { return true } | ||
| 65 | 65 | ||||
| 66 | func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | 66 | func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon { | ||
| 67 | + | t.Helper() | |||
| 68 | + | return newTestDaemonWithCleanup(t, ptys, clk, true) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon { | |||
| 67 | t.Helper() | 72 | t.Helper() | ||
| 68 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | 73 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 69 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | 74 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | ||
| 70 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | 75 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | ||
| 71 | - | t.Cleanup(d.serveCancel) | 76 | + | if registerCleanup { |
| 72 | - | t.Cleanup(d.hardCancel) | 77 | + | t.Cleanup(d.serveCancel) |
| 78 | + | t.Cleanup(d.hardCancel) | |||
| 79 | + | } | |||
| 73 | return d | 80 | return d | ||
| 74 | } | 81 | } | ||
| 75 | 82 | ||||
| @@ -368,7 +375,12 @@ func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports. | |||||
| 368 | 375 | ||||
| 369 | func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | 376 | func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | ||
| 370 | t.Helper() | 377 | t.Helper() | ||
| 371 | - | d := newTestDaemon(t, nil, stubClock{}) | 378 | + | return newManualSessionWithPTYsCleanup(t, true, ptys...) |
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func newManualSessionWithPTYsCleanup(t testing.TB, registerCleanup bool, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) { | |||
| 382 | + | t.Helper() | |||
| 383 | + | d := newTestDaemonWithCleanup(t, nil, stubClock{}, registerCleanup) | |||
| 372 | tr, sends := newCapturingTransport(t) | 384 | tr, sends := newCapturingTransport(t) | ||
| 373 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 385 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 374 | ac.initOverlays() | 386 | ac.initOverlays() | ||
| @@ -387,7 +399,9 @@ func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 387 | ac.setSession(sess) | 399 | ac.setSession(sess) | ||
| 388 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | 400 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, nil) | ||
| 389 | d.sessions[sess.id] = sess | 401 | d.sessions[sess.id] = sess | ||
| 390 | - | t.Cleanup(cancel) | 402 | + | if registerCleanup { |
| 403 | + | t.Cleanup(cancel) | |||
| 404 | + | } | |||
| 391 | return d, sess, ac, sends | 405 | return d, sess, ac, sends | ||
| 392 | } | 406 | } | ||
| 393 | 407 | ||||
M internal/usecase/daemon/performance_benchmark_test.go +101 -23
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "sync" | |
| @@ -149,6 +150,38 @@ func TestPerformanceFixtureCellLimitedHistory(t *testing.T) { | |||
| 149 | 150 | require.LessOrEqual(t, cells, capCells) | |
| 150 | 151 | } | |
| 151 | 152 | ||
| 153 | + | func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) { | |
| 154 | + | fixture := newPerformanceFixtureWithCleanup(t, performanceConfig{}, false) | |
| 155 | + | d, sess := fixture.d, fixture.sess | |
| 156 | + | serveCtx, hardCtx := d.serveCtx, d.hardCtx | |
| 157 | + | killErr := errors.New("scripted kill failure") | |
| 158 | + | fixture.killSession = func(sess *session, reason uint8, purge bool) error { | |
| 159 | + | require.NoError(t, d.killSession(sess, reason, purge)) | |
| 160 | + | return killErr | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | err := fixture.close() | |
| 164 | + | ||
| 165 | + | require.ErrorIs(t, err, killErr) | |
| 166 | + | require.Nil(t, fixture.d) | |
| 167 | + | require.Nil(t, fixture.sess) | |
| 168 | + | require.Nil(t, fixture.activePane) | |
| 169 | + | require.Error(t, sess.ctx.Err()) | |
| 170 | + | require.Error(t, serveCtx.Err()) | |
| 171 | + | require.Error(t, hardCtx.Err()) | |
| 172 | + | d.mu.Lock() | |
| 173 | + | sessionIDs := make([]domain.SessionID, 0, len(d.sessions)) | |
| 174 | + | for id := range d.sessions { | |
| 175 | + | sessionIDs = append(sessionIDs, id) | |
| 176 | + | } | |
| 177 | + | d.mu.Unlock() | |
| 178 | + | require.NotContains(t, sessionIDs, sess.id) | |
| 179 | + | d.snapshotWorkerMu.Lock() | |
| 180 | + | workerCancel := d.snapshotWorkerCancel | |
| 181 | + | d.snapshotWorkerMu.Unlock() | |
| 182 | + | require.Nil(t, workerCancel) | |
| 183 | + | } | |
| 184 | + | ||
| 152 | 185 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | |
| 153 | 186 | transport := &countingOutputTransport{} | |
| 154 | 187 | ||
| @@ -506,29 +539,40 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench | |||
| 506 | 539 | b.ReportAllocs() | |
| 507 | 540 | b.ResetTimer() | |
| 508 | 541 | for operation := 0; b.Loop(); operation++ { | |
| 509 | - | // A new fixture is deliberately outside the timed region. It gives every | |
| 510 | - | // iteration the same filesystem state: initial has no generation, | |
| 511 | - | // unchanged/visible/tail/sealed begin after one persisted generation. | |
| 512 | - | // Reusing one fixture would eventually turn a tail into a sealed chunk | |
| 513 | - | // and dilute initial-write bytes over later idempotent publications. | |
| 514 | - | b.StopTimer() | |
| 515 | - | fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}) | |
| 516 | - | validateSnapshotBenchmarkFixture(b, fixture, scenario) | |
| 517 | - | generation := uint64(1) | |
| 518 | - | if scenario.baseline { | |
| 542 | + | func() { | |
| 543 | + | // A new fixture gives every iteration the same filesystem state: initial | |
| 544 | + | // has no generation, while changed scenarios begin after one persisted | |
| 545 | + | // generation. Keep its expensive 10k-row construction timed so benchmark | |
| 546 | + | // calibration accounts for real wall time instead of scheduling thousands | |
| 547 | + | // of hidden setup iterations. | |
| 548 | + | // | |
| 549 | + | // b.Cleanup retains callbacks until the sub-benchmark ends; deferring an | |
| 550 | + | // explicit close avoids retaining every fixture and OOM. | |
| 551 | + | fixture := newPerformanceFixtureWithCleanup(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}, false) | |
| 552 | + | b.StopTimer() | |
| 553 | + | defer func() { | |
| 554 | + | b.StopTimer() | |
| 555 | + | if err := fixture.close(); err != nil { | |
| 556 | + | b.Fatalf("close performance fixture: %v", err) | |
| 557 | + | } | |
| 558 | + | }() | |
| 559 | + | validateSnapshotBenchmarkFixture(b, fixture, scenario) | |
| 560 | + | generation := uint64(1) | |
| 561 | + | if scenario.baseline { | |
| 562 | + | publishBenchmarkSnapshot(b, fixture, generation) | |
| 563 | + | generation++ | |
| 564 | + | fixture.snapshots.reset() | |
| 565 | + | } | |
| 566 | + | b.StartTimer() | |
| 567 | + | if scenario.mutate != nil { | |
| 568 | + | scenario.mutate(fixture, operation) | |
| 569 | + | } | |
| 519 | 570 | publishBenchmarkSnapshot(b, fixture, generation) | |
| 520 | - | generation++ | |
| 521 | - | fixture.snapshots.reset() | |
| 522 | - | } | |
| 523 | - | b.StartTimer() | |
| 524 | - | if scenario.mutate != nil { | |
| 525 | - | scenario.mutate(fixture, operation) | |
| 526 | - | } | |
| 527 | - | publishBenchmarkSnapshot(b, fixture, generation) | |
| 528 | - | b.StopTimer() | |
| 571 | + | b.StopTimer() | |
| 529 | 572 | ||
| 530 | - | metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics()) | |
| 531 | - | peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess)) | |
| 573 | + | metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics()) | |
| 574 | + | peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess)) | |
| 575 | + | }() | |
| 532 | 576 | // B.Loop requires the timer to be running at the next iteration. | |
| 533 | 577 | b.StartTimer() | |
| 534 | 578 | } | |
| @@ -952,6 +996,7 @@ type performanceFixture struct { | |||
| 952 | 996 | resizedSize domain.Size | |
| 953 | 997 | pty *scriptedPerformancePTY | |
| 954 | 998 | clock *performanceClock | |
| 999 | + | killSession func(*session, uint8, bool) error | |
| 955 | 1000 | resizeRequests uint64 | |
| 956 | 1001 | resizeCommits uint64 | |
| 957 | 1002 | resizeCommitNanos uint64 | |
| @@ -964,6 +1009,11 @@ type performanceFixture struct { | |||
| 964 | 1009 | } | |
| 965 | 1010 | ||
| 966 | 1011 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | |
| 1012 | + | t.Helper() | |
| 1013 | + | return newPerformanceFixtureWithCleanup(t, config, true) | |
| 1014 | + | } | |
| 1015 | + | ||
| 1016 | + | func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, registerCleanup bool) *performanceFixture { | |
| 967 | 1017 | t.Helper() | |
| 968 | 1018 | if !config.size.Valid() { | |
| 969 | 1019 | config.size = domain.Size{Cols: 80, Rows: 24} | |
| @@ -980,7 +1030,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 980 | 1030 | pty := &scriptedPerformancePTY{} | |
| 981 | 1031 | ptys := make([]ports.PTY, config.tabs) | |
| 982 | 1032 | ptys[0] = pty | |
| 983 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | |
| 1033 | + | d, sess, ac, _ := newManualSessionWithPTYsCleanup(t, registerCleanup, ptys...) | |
| 984 | 1034 | clock := &performanceClock{} | |
| 985 | 1035 | d.clock = clock | |
| 986 | 1036 | output := &countingOutputTransport{} | |
| @@ -999,12 +1049,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 999 | 1049 | snapshots: &countingSnapshotRepository{}, | |
| 1000 | 1050 | pty: pty, | |
| 1001 | 1051 | clock: clock, | |
| 1052 | + | killSession: d.killSession, | |
| 1002 | 1053 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | |
| 1003 | 1054 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | |
| 1004 | 1055 | } | |
| 1005 | 1056 | WithSnapshotRepository(fixture.snapshots, nil)(d) | |
| 1006 | 1057 | d.startSnapshotEncodeWorker() | |
| 1007 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 1058 | + | if registerCleanup { | |
| 1059 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |
| 1060 | + | } | |
| 1008 | 1061 | for tabIndex, tb := range sess.tabs { | |
| 1009 | 1062 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | |
| 1010 | 1063 | } | |
| @@ -1029,6 +1082,31 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 1029 | 1082 | return fixture | |
| 1030 | 1083 | } | |
| 1031 | 1084 | ||
| 1085 | + | func (f *performanceFixture) close() error { | |
| 1086 | + | if f.d == nil { | |
| 1087 | + | return nil | |
| 1088 | + | } | |
| 1089 | + | d, sess := f.d, f.sess | |
| 1090 | + | sess.mu.Lock() | |
| 1091 | + | sess.ephemeral = true | |
| 1092 | + | sess.mu.Unlock() | |
| 1093 | + | killErr := f.killSession(sess, ports.ReasonServerShutdown, false) | |
| 1094 | + | d.stopSnapshotEncodeWorker() | |
| 1095 | + | d.serveCancel() | |
| 1096 | + | d.hardCancel() | |
| 1097 | + | ||
| 1098 | + | f.d = nil | |
| 1099 | + | f.sess = nil | |
| 1100 | + | f.ac = nil | |
| 1101 | + | f.output = nil | |
| 1102 | + | f.snapshots = nil | |
| 1103 | + | f.activePane = nil | |
| 1104 | + | f.pty = nil | |
| 1105 | + | f.clock = nil | |
| 1106 | + | f.killSession = nil | |
| 1107 | + | return killErr | |
| 1108 | + | } | |
| 1109 | + | ||
| 1032 | 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | |
| 1033 | 1111 | f.t.Helper() | |
| 1034 | 1112 | tb.mu.Lock() | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "io" | 7 | "io" | ||
| 7 | "sync" | 8 | "sync" | ||
| @@ -149,6 +150,38 @@ func TestPerformanceFixtureCellLimitedHistory(t *testing.T) { | |||||
| 149 | require.LessOrEqual(t, cells, capCells) | 150 | require.LessOrEqual(t, cells, capCells) | ||
| 150 | } | 151 | } | ||
| 151 | 152 | ||||
| 153 | + | func TestPerformanceFixtureExplicitCloseReleasesIterationState(t *testing.T) { | |||
| 154 | + | fixture := newPerformanceFixtureWithCleanup(t, performanceConfig{}, false) | |||
| 155 | + | d, sess := fixture.d, fixture.sess | |||
| 156 | + | serveCtx, hardCtx := d.serveCtx, d.hardCtx | |||
| 157 | + | killErr := errors.New("scripted kill failure") | |||
| 158 | + | fixture.killSession = func(sess *session, reason uint8, purge bool) error { | |||
| 159 | + | require.NoError(t, d.killSession(sess, reason, purge)) | |||
| 160 | + | return killErr | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | err := fixture.close() | |||
| 164 | + | ||||
| 165 | + | require.ErrorIs(t, err, killErr) | |||
| 166 | + | require.Nil(t, fixture.d) | |||
| 167 | + | require.Nil(t, fixture.sess) | |||
| 168 | + | require.Nil(t, fixture.activePane) | |||
| 169 | + | require.Error(t, sess.ctx.Err()) | |||
| 170 | + | require.Error(t, serveCtx.Err()) | |||
| 171 | + | require.Error(t, hardCtx.Err()) | |||
| 172 | + | d.mu.Lock() | |||
| 173 | + | sessionIDs := make([]domain.SessionID, 0, len(d.sessions)) | |||
| 174 | + | for id := range d.sessions { | |||
| 175 | + | sessionIDs = append(sessionIDs, id) | |||
| 176 | + | } | |||
| 177 | + | d.mu.Unlock() | |||
| 178 | + | require.NotContains(t, sessionIDs, sess.id) | |||
| 179 | + | d.snapshotWorkerMu.Lock() | |||
| 180 | + | workerCancel := d.snapshotWorkerCancel | |||
| 181 | + | d.snapshotWorkerMu.Unlock() | |||
| 182 | + | require.Nil(t, workerCancel) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 152 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | 185 | func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) { | ||
| 153 | transport := &countingOutputTransport{} | 186 | transport := &countingOutputTransport{} | ||
| 154 | 187 | ||||
| @@ -506,29 +539,40 @@ func benchmarkIncrementalSnapshotRepository(b *testing.B, scenario snapshotBench | |||||
| 506 | b.ReportAllocs() | 539 | b.ReportAllocs() | ||
| 507 | b.ResetTimer() | 540 | b.ResetTimer() | ||
| 508 | for operation := 0; b.Loop(); operation++ { | 541 | for operation := 0; b.Loop(); operation++ { | ||
| 509 | - | // A new fixture is deliberately outside the timed region. It gives every | 542 | + | func() { |
| 510 | - | // iteration the same filesystem state: initial has no generation, | 543 | + | // A new fixture gives every iteration the same filesystem state: initial |
| 511 | - | // unchanged/visible/tail/sealed begin after one persisted generation. | 544 | + | // has no generation, while changed scenarios begin after one persisted |
| 512 | - | // Reusing one fixture would eventually turn a tail into a sealed chunk | 545 | + | // generation. Keep its expensive 10k-row construction timed so benchmark |
| 513 | - | // and dilute initial-write bytes over later idempotent publications. | 546 | + | // calibration accounts for real wall time instead of scheduling thousands |
| 514 | - | b.StopTimer() | 547 | + | // of hidden setup iterations. |
| 515 | - | fixture := newPerformanceFixture(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}) | 548 | + | // |
| 516 | - | validateSnapshotBenchmarkFixture(b, fixture, scenario) | 549 | + | // b.Cleanup retains callbacks until the sub-benchmark ends; deferring an |
| 517 | - | generation := uint64(1) | 550 | + | // explicit close avoids retaining every fixture and OOM. |
| 518 | - | if scenario.baseline { | 551 | + | fixture := newPerformanceFixtureWithCleanup(b, performanceConfig{size: scenario.size, panes: 1, historyRows: scenario.historyRows}, false) |
| 552 | + | b.StopTimer() | |||
| 553 | + | defer func() { | |||
| 554 | + | b.StopTimer() | |||
| 555 | + | if err := fixture.close(); err != nil { | |||
| 556 | + | b.Fatalf("close performance fixture: %v", err) | |||
| 557 | + | } | |||
| 558 | + | }() | |||
| 559 | + | validateSnapshotBenchmarkFixture(b, fixture, scenario) | |||
| 560 | + | generation := uint64(1) | |||
| 561 | + | if scenario.baseline { | |||
| 562 | + | publishBenchmarkSnapshot(b, fixture, generation) | |||
| 563 | + | generation++ | |||
| 564 | + | fixture.snapshots.reset() | |||
| 565 | + | } | |||
| 566 | + | b.StartTimer() | |||
| 567 | + | if scenario.mutate != nil { | |||
| 568 | + | scenario.mutate(fixture, operation) | |||
| 569 | + | } | |||
| 519 | publishBenchmarkSnapshot(b, fixture, generation) | 570 | publishBenchmarkSnapshot(b, fixture, generation) | ||
| 520 | - | generation++ | 571 | + | b.StopTimer() |
| 521 | - | fixture.snapshots.reset() | |||
| 522 | - | } | |||
| 523 | - | b.StartTimer() | |||
| 524 | - | if scenario.mutate != nil { | |||
| 525 | - | scenario.mutate(fixture, operation) | |||
| 526 | - | } | |||
| 527 | - | publishBenchmarkSnapshot(b, fixture, generation) | |||
| 528 | - | b.StopTimer() | |||
| 529 | 572 | ||||
| 530 | - | metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics()) | 573 | + | metrics = addCountingSnapshotMetrics(metrics, fixture.snapshots.metrics()) |
| 531 | - | peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess)) | 574 | + | peakCacheBytes = max(peakCacheBytes, benchmarkSnapshotCacheBytes(fixture.sess)) |
| 575 | + | }() | |||
| 532 | // B.Loop requires the timer to be running at the next iteration. | 576 | // B.Loop requires the timer to be running at the next iteration. | ||
| 533 | b.StartTimer() | 577 | b.StartTimer() | ||
| 534 | } | 578 | } | ||
| @@ -952,6 +996,7 @@ type performanceFixture struct { | |||||
| 952 | resizedSize domain.Size | 996 | resizedSize domain.Size | ||
| 953 | pty *scriptedPerformancePTY | 997 | pty *scriptedPerformancePTY | ||
| 954 | clock *performanceClock | 998 | clock *performanceClock | ||
| 999 | + | killSession func(*session, uint8, bool) error | |||
| 955 | resizeRequests uint64 | 1000 | resizeRequests uint64 | ||
| 956 | resizeCommits uint64 | 1001 | resizeCommits uint64 | ||
| 957 | resizeCommitNanos uint64 | 1002 | resizeCommitNanos uint64 | ||
| @@ -964,6 +1009,11 @@ type performanceFixture struct { | |||||
| 964 | } | 1009 | } | ||
| 965 | 1010 | ||||
| 966 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | 1011 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | ||
| 1012 | + | t.Helper() | |||
| 1013 | + | return newPerformanceFixtureWithCleanup(t, config, true) | |||
| 1014 | + | } | |||
| 1015 | + | ||||
| 1016 | + | func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, registerCleanup bool) *performanceFixture { | |||
| 967 | t.Helper() | 1017 | t.Helper() | ||
| 968 | if !config.size.Valid() { | 1018 | if !config.size.Valid() { | ||
| 969 | config.size = domain.Size{Cols: 80, Rows: 24} | 1019 | config.size = domain.Size{Cols: 80, Rows: 24} | ||
| @@ -980,7 +1030,7 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 980 | pty := &scriptedPerformancePTY{} | 1030 | pty := &scriptedPerformancePTY{} | ||
| 981 | ptys := make([]ports.PTY, config.tabs) | 1031 | ptys := make([]ports.PTY, config.tabs) | ||
| 982 | ptys[0] = pty | 1032 | ptys[0] = pty | ||
| 983 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...) | 1033 | + | d, sess, ac, _ := newManualSessionWithPTYsCleanup(t, registerCleanup, ptys...) |
| 984 | clock := &performanceClock{} | 1034 | clock := &performanceClock{} | ||
| 985 | d.clock = clock | 1035 | d.clock = clock | ||
| 986 | output := &countingOutputTransport{} | 1036 | output := &countingOutputTransport{} | ||
| @@ -999,12 +1049,15 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 999 | snapshots: &countingSnapshotRepository{}, | 1049 | snapshots: &countingSnapshotRepository{}, | ||
| 1000 | pty: pty, | 1050 | pty: pty, | ||
| 1001 | clock: clock, | 1051 | clock: clock, | ||
| 1052 | + | killSession: d.killSession, | |||
| 1002 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | 1053 | liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")}, | ||
| 1003 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | 1054 | resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size}, | ||
| 1004 | } | 1055 | } | ||
| 1005 | WithSnapshotRepository(fixture.snapshots, nil)(d) | 1056 | WithSnapshotRepository(fixture.snapshots, nil)(d) | ||
| 1006 | d.startSnapshotEncodeWorker() | 1057 | d.startSnapshotEncodeWorker() | ||
| 1007 | - | t.Cleanup(d.stopSnapshotEncodeWorker) | 1058 | + | if registerCleanup { |
| 1059 | + | t.Cleanup(d.stopSnapshotEncodeWorker) | |||
| 1060 | + | } | |||
| 1008 | for tabIndex, tb := range sess.tabs { | 1061 | for tabIndex, tb := range sess.tabs { | ||
| 1009 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | 1062 | fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size)) | ||
| 1010 | } | 1063 | } | ||
| @@ -1029,6 +1082,31 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||||
| 1029 | return fixture | 1082 | return fixture | ||
| 1030 | } | 1083 | } | ||
| 1031 | 1084 | ||||
| 1085 | + | func (f *performanceFixture) close() error { | |||
| 1086 | + | if f.d == nil { | |||
| 1087 | + | return nil | |||
| 1088 | + | } | |||
| 1089 | + | d, sess := f.d, f.sess | |||
| 1090 | + | sess.mu.Lock() | |||
| 1091 | + | sess.ephemeral = true | |||
| 1092 | + | sess.mu.Unlock() | |||
| 1093 | + | killErr := f.killSession(sess, ports.ReasonServerShutdown, false) | |||
| 1094 | + | d.stopSnapshotEncodeWorker() | |||
| 1095 | + | d.serveCancel() | |||
| 1096 | + | d.hardCancel() | |||
| 1097 | + | ||||
| 1098 | + | f.d = nil | |||
| 1099 | + | f.sess = nil | |||
| 1100 | + | f.ac = nil | |||
| 1101 | + | f.output = nil | |||
| 1102 | + | f.snapshots = nil | |||
| 1103 | + | f.activePane = nil | |||
| 1104 | + | f.pty = nil | |||
| 1105 | + | f.clock = nil | |||
| 1106 | + | f.killSession = nil | |||
| 1107 | + | return killErr | |||
| 1108 | + | } | |||
| 1109 | + | ||||
| 1032 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | 1110 | func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) { | ||
| 1033 | f.t.Helper() | 1111 | f.t.Helper() | ||
| 1034 | tb.mu.Lock() | 1112 | tb.mu.Lock() | ||
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 | + | } | |||