test(resilience): add visual QUIC/SSH impairment harness
Userspace UDP/TCP relays, NeferWL headless + foot captures, pixel comparator and semantic checks for degraded links, blackouts, cancellation and held-input replay. Run with make remote-resilience-visual.
- commit
- 4c975c70715b33fa42fd960b68b6705795d1a6f5
- author
- brice · · 2026-10-07 05:35 UTC
- parent
- 1999182
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1038 -2
M Makefile +4 -0
| @@ -31,6 +31,10 @@ mocks: | |||
| 31 | 31 | remote-acceptance: | |
| 32 | 32 | scripts/remote-picker-harness/run.sh | |
| 33 | 33 | ||
| 34 | + | .PHONY: remote-resilience-visual | |
| 35 | + | remote-resilience-visual: | |
| 36 | + | python3 scripts/remote-resilience-harness/visual.py | |
| 37 | + | ||
| 34 | 38 | # protocol regenerates the Protobuf wire contract from | |
| 35 | 39 | # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go). | |
| 36 | 40 | protocol: | |
| @@ -31,6 +31,10 @@ mocks: | |||||
| 31 | remote-acceptance: | 31 | remote-acceptance: | ||
| 32 | scripts/remote-picker-harness/run.sh | 32 | scripts/remote-picker-harness/run.sh | ||
| 33 | 33 | ||||
| 34 | + | .PHONY: remote-resilience-visual | |||
| 35 | + | remote-resilience-visual: | |||
| 36 | + | python3 scripts/remote-resilience-harness/visual.py | |||
| 37 | + | ||||
| 34 | # protocol regenerates the Protobuf wire contract from | 38 | # protocol regenerates the Protobuf wire contract from | ||
| 35 | # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go). | 39 | # internal/protocol/wire/schema using the pinned Go tools (buf + protoc-gen-go). | ||
| 36 | protocol: | 40 | protocol: | ||
M docs/remote-resilience.md +3 -1
| @@ -30,6 +30,8 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru | |||
| 30 | 30 | - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it. | |
| 31 | 31 | - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode. | |
| 32 | 32 | ||
| 33 | + | The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage. | |
| 34 | + | ||
| 33 | 35 | ## Connection states | |
| 34 | 36 | ||
| 35 | 37 | | State | Meaning | | |
| @@ -79,6 +81,6 @@ VEV_LOG=debug vev attach user@host | |||
| 79 | 81 | scripts/debug-remote-attach.sh user@host # collects redacted connection health | |
| 80 | 82 | ``` | |
| 81 | 83 | ||
| 82 | - | Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. | |
| 84 | + | Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. For isolated QUIC/SSH impairment and real Wayland terminal captures, see the [visual resilience harness](../scripts/remote-resilience-harness/README.md). | |
| 83 | 85 | ||
| 84 | 86 | Keep hostnames, usernames, and keys out of shared logs and screenshots. | |
| @@ -30,6 +30,8 @@ The transport is fixed when the host is added (`quic` by default). To switch, ru | |||||
| 30 | - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it. | 30 | - If the reconnect window has expired but the session still exists, the client opens a fresh attachment to it. | ||
| 31 | - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode. | 31 | - Several clients can attach to the same session. Each keeps its own window, tab, focus, and copy mode. | ||
| 32 | 32 | ||||
| 33 | + | The command palette is rendered by the remote daemon, so opening it needs a working link. A client-owned session picker that is already open remains locally interactive during an outage. | |||
| 34 | + | ||||
| 33 | ## Connection states | 35 | ## Connection states | ||
| 34 | 36 | ||||
| 35 | | State | Meaning | | 37 | | State | Meaning | | ||
| @@ -79,6 +81,6 @@ VEV_LOG=debug vev attach user@host | |||||
| 79 | scripts/debug-remote-attach.sh user@host # collects redacted connection health | 81 | scripts/debug-remote-attach.sh user@host # collects redacted connection health | ||
| 80 | ``` | 82 | ``` | ||
| 81 | 83 | ||||
| 82 | - | Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. | 84 | + | Useful manual checks: block UDP, switch networks or VPN, suspend the laptop, then reattach to the same session. For isolated QUIC/SSH impairment and real Wayland terminal captures, see the [visual resilience harness](../scripts/remote-resilience-harness/README.md). |
| 83 | 85 | ||||
| 84 | Keep hostnames, usernames, and keys out of shared logs and screenshots. | 86 | Keep hostnames, usernames, and keys out of shared logs and screenshots. | ||
M scripts/demo/Dockerfile +1 -1
| @@ -7,7 +7,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \ | |||
| 7 | 7 | vhs ttyd ffmpeg chromium \ | |
| 8 | 8 | htop fastfetch gifsicle imagemagick \ | |
| 9 | 9 | eza fzf neovim man-db man-pages coreutils \ | |
| 10 | - | ncurses openssh util-linux \ | |
| 10 | + | ncurses openssh util-linux python \ | |
| 11 | 11 | ttf-jetbrains-mono-nerd | |
| 12 | 12 | RUN useradd -m -u 1000 demo | |
| 13 | 13 | # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a | |
| @@ -7,7 +7,7 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \ | |||||
| 7 | vhs ttyd ffmpeg chromium \ | 7 | vhs ttyd ffmpeg chromium \ | ||
| 8 | htop fastfetch gifsicle imagemagick \ | 8 | htop fastfetch gifsicle imagemagick \ | ||
| 9 | eza fzf neovim man-db man-pages coreutils \ | 9 | eza fzf neovim man-db man-pages coreutils \ | ||
| 10 | - | ncurses openssh util-linux \ | 10 | + | ncurses openssh util-linux python \ |
| 11 | ttf-jetbrains-mono-nerd | 11 | ttf-jetbrains-mono-nerd | ||
| 12 | RUN useradd -m -u 1000 demo | 12 | RUN useradd -m -u 1000 demo | ||
| 13 | # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a | 13 | # P8.2 uses the exact worktree binary built by sandbox-run.sh, never a | ||
A scripts/remote-resilience-harness/README.md +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | # Visual remote resilience acceptance | |
| 2 | + | ||
| 3 | + | `make remote-resilience-visual` runs a disposable remote session in a real foot terminal under NeferWL headless. Physical keys enter through the compositor. PNG differences and passive vev captures jointly verify local picker response within two seconds. A stopped client is the negative control. | |
| 4 | + | ||
| 5 | + | Prerequisites: Go 1.27, rootless Docker, Python 3, ssh-keygen, foot, and a GPU supported by NeferWL. Set `NEFERWL_SOURCE` to the compositor checkout or `NEFERWL_BIN` to its built binary. No desktop Wayland socket, user credentials or host network configuration is shared with containers. | |
| 6 | + | ||
| 7 | + | ```sh | |
| 8 | + | make remote-resilience-visual | |
| 9 | + | VEV_RESILIENCE_TRANSPORT=ssh make remote-resilience-visual | |
| 10 | + | VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual | |
| 11 | + | VEV_RESILIENCE_TRANSPORT=ssh VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual | |
| 12 | + | VEV_RESILIENCE_LINK=udp make remote-resilience-visual | |
| 13 | + | VEV_RESILIENCE_LINK=tcp make remote-resilience-visual | |
| 14 | + | VEV_RESILIENCE_LONG=1 make remote-resilience-visual | |
| 15 | + | VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_RESUME=1 make remote-resilience-visual | |
| 16 | + | VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_CANCEL=Escape make remote-resilience-visual | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | QUIC is routed through the existing bounded seeded UDP simulator. A fixture-only SSH wrapper preserves authentication fields and rewrites the dynamic bootstrap port to its relay. SSH runs through a TCP relay. The degraded profile applies 150 ms delay, up to 50 ms jitter and 5% datagram loss to UDP; TCP waits 150 ms per forwarded read and is paced to 64 KiB/s, without dropping stream bytes; many small reads therefore add up to more than 150 ms. Both transports can be blacked out together without privileges. | |
| 20 | + | ||
| 21 | + | The short scenario opens the client picker while connected, edits it during blackout, restores the route and checks the named session identity and recovery output. Opening the daemon-rendered palette during blackout is not a local responsiveness guarantee. The optional long scenario asserts actual Connecting state and Ctrl-C or Esc cancellation. It cannot tell backoff from a blocked stream open; fake-clock client tests cover cancellation during stream opening. It fails if cancellation cannot reach the resume owner. | |
| 22 | + | ||
| 23 | + | Artifacts are printed at exit: captures, pixel counts, observation contexts, relay counters and fixture logs. Containers, image, network and temporary credentials are removed. Unsupported rendering and failed assertions exit nonzero. | |
| 24 | + | ||
| 25 | + | This is not a mobile-network emulator: TCP impairments model stream backpressure, not packet retransmissions; IP migration, CGNAT and exactly-once replay are not validated. `VEV_RESILIENCE_LINK=udp|tcp` isolates short asymmetric blackouts. `VEV_RESILIENCE_RESUME=1` restores a long outage and checks two held fixture commands in order. Detach timing is covered by fake-clock client tests, not this visual entrypoint. | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | # Visual remote resilience acceptance | |||
| 2 | + | ||||
| 3 | + | `make remote-resilience-visual` runs a disposable remote session in a real foot terminal under NeferWL headless. Physical keys enter through the compositor. PNG differences and passive vev captures jointly verify local picker response within two seconds. A stopped client is the negative control. | |||
| 4 | + | ||||
| 5 | + | Prerequisites: Go 1.27, rootless Docker, Python 3, ssh-keygen, foot, and a GPU supported by NeferWL. Set `NEFERWL_SOURCE` to the compositor checkout or `NEFERWL_BIN` to its built binary. No desktop Wayland socket, user credentials or host network configuration is shared with containers. | |||
| 6 | + | ||||
| 7 | + | ```sh | |||
| 8 | + | make remote-resilience-visual | |||
| 9 | + | VEV_RESILIENCE_TRANSPORT=ssh make remote-resilience-visual | |||
| 10 | + | VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual | |||
| 11 | + | VEV_RESILIENCE_TRANSPORT=ssh VEV_RESILIENCE_DEGRADED=1 make remote-resilience-visual | |||
| 12 | + | VEV_RESILIENCE_LINK=udp make remote-resilience-visual | |||
| 13 | + | VEV_RESILIENCE_LINK=tcp make remote-resilience-visual | |||
| 14 | + | VEV_RESILIENCE_LONG=1 make remote-resilience-visual | |||
| 15 | + | VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_RESUME=1 make remote-resilience-visual | |||
| 16 | + | VEV_RESILIENCE_LONG=1 VEV_RESILIENCE_CANCEL=Escape make remote-resilience-visual | |||
| 17 | + | ``` | |||
| 18 | + | ||||
| 19 | + | QUIC is routed through the existing bounded seeded UDP simulator. A fixture-only SSH wrapper preserves authentication fields and rewrites the dynamic bootstrap port to its relay. SSH runs through a TCP relay. The degraded profile applies 150 ms delay, up to 50 ms jitter and 5% datagram loss to UDP; TCP waits 150 ms per forwarded read and is paced to 64 KiB/s, without dropping stream bytes; many small reads therefore add up to more than 150 ms. Both transports can be blacked out together without privileges. | |||
| 20 | + | ||||
| 21 | + | The short scenario opens the client picker while connected, edits it during blackout, restores the route and checks the named session identity and recovery output. Opening the daemon-rendered palette during blackout is not a local responsiveness guarantee. The optional long scenario asserts actual Connecting state and Ctrl-C or Esc cancellation. It cannot tell backoff from a blocked stream open; fake-clock client tests cover cancellation during stream opening. It fails if cancellation cannot reach the resume owner. | |||
| 22 | + | ||||
| 23 | + | Artifacts are printed at exit: captures, pixel counts, observation contexts, relay counters and fixture logs. Containers, image, network and temporary credentials are removed. Unsupported rendering and failed assertions exit nonzero. | |||
| 24 | + | ||||
| 25 | + | This is not a mobile-network emulator: TCP impairments model stream backpressure, not packet retransmissions; IP migration, CGNAT and exactly-once replay are not validated. `VEV_RESILIENCE_LINK=udp|tcp` isolates short asymmetric blackouts. `VEV_RESILIENCE_RESUME=1` restores a long outage and checks two held fixture commands in order. Detach timing is covered by fake-clock client tests, not this visual entrypoint. | |||
A scripts/remote-resilience-harness/pixels/main.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | // Command pixels compares the fixed 1280x720 fixture's terminal content. | |
| 2 | + | package main | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "image" | |
| 9 | + | _ "image/png" | |
| 10 | + | "os" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const fixtureMargin = 100 | |
| 14 | + | const minimumChangedPixels = 20 | |
| 15 | + | ||
| 16 | + | func load(path string) (image.Image, error) { | |
| 17 | + | f, err := os.Open(path) | |
| 18 | + | if err != nil { | |
| 19 | + | return nil, err | |
| 20 | + | } | |
| 21 | + | defer f.Close() | |
| 22 | + | im, _, err := image.Decode(f) | |
| 23 | + | return im, err | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func compare(a, b image.Image) (int, error) { | |
| 27 | + | if a.Bounds() != b.Bounds() { | |
| 28 | + | return 0, errors.New("capture dimensions changed") | |
| 29 | + | } | |
| 30 | + | bounds := a.Bounds() | |
| 31 | + | if bounds.Dx() <= 2*fixtureMargin || bounds.Dy() <= 2*fixtureMargin { | |
| 32 | + | return 0, errors.New("capture has no comparison region") | |
| 33 | + | } | |
| 34 | + | changed := 0 | |
| 35 | + | for y := bounds.Min.Y + fixtureMargin; y < bounds.Max.Y-fixtureMargin; y++ { | |
| 36 | + | for x := bounds.Min.X + fixtureMargin; x < bounds.Max.X-fixtureMargin; x++ { | |
| 37 | + | ar, ag, ab, _ := a.At(x, y).RGBA() | |
| 38 | + | br, bg, bb, _ := b.At(x, y).RGBA() | |
| 39 | + | if ar != br || ag != bg || ab != bb { | |
| 40 | + | changed++ | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | return changed, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func execute(args []string) (int, error) { | |
| 48 | + | if len(args) != 2 { | |
| 49 | + | return 2, errors.New("usage: pixels before.png after.png") | |
| 50 | + | } | |
| 51 | + | a, err := load(args[0]) | |
| 52 | + | if err != nil { | |
| 53 | + | return 2, err | |
| 54 | + | } | |
| 55 | + | b, err := load(args[1]) | |
| 56 | + | if err != nil { | |
| 57 | + | return 2, err | |
| 58 | + | } | |
| 59 | + | changed, err := compare(a, b) | |
| 60 | + | if err != nil { | |
| 61 | + | return 2, err | |
| 62 | + | } | |
| 63 | + | if err := json.NewEncoder(os.Stdout).Encode(map[string]int{"changed_pixels": changed}); err != nil { | |
| 64 | + | return 2, err | |
| 65 | + | } | |
| 66 | + | if changed < minimumChangedPixels { | |
| 67 | + | return 1, nil | |
| 68 | + | } | |
| 69 | + | return 0, nil | |
| 70 | + | } | |
| 71 | + | func main() { | |
| 72 | + | code, err := execute(os.Args[1:]) | |
| 73 | + | if err != nil { | |
| 74 | + | fmt.Fprintln(os.Stderr, err) | |
| 75 | + | } | |
| 76 | + | os.Exit(code) | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | // Command pixels compares the fixed 1280x720 fixture's terminal content. | |||
| 2 | + | package main | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "image" | |||
| 9 | + | _ "image/png" | |||
| 10 | + | "os" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const fixtureMargin = 100 | |||
| 14 | + | const minimumChangedPixels = 20 | |||
| 15 | + | ||||
| 16 | + | func load(path string) (image.Image, error) { | |||
| 17 | + | f, err := os.Open(path) | |||
| 18 | + | if err != nil { | |||
| 19 | + | return nil, err | |||
| 20 | + | } | |||
| 21 | + | defer f.Close() | |||
| 22 | + | im, _, err := image.Decode(f) | |||
| 23 | + | return im, err | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func compare(a, b image.Image) (int, error) { | |||
| 27 | + | if a.Bounds() != b.Bounds() { | |||
| 28 | + | return 0, errors.New("capture dimensions changed") | |||
| 29 | + | } | |||
| 30 | + | bounds := a.Bounds() | |||
| 31 | + | if bounds.Dx() <= 2*fixtureMargin || bounds.Dy() <= 2*fixtureMargin { | |||
| 32 | + | return 0, errors.New("capture has no comparison region") | |||
| 33 | + | } | |||
| 34 | + | changed := 0 | |||
| 35 | + | for y := bounds.Min.Y + fixtureMargin; y < bounds.Max.Y-fixtureMargin; y++ { | |||
| 36 | + | for x := bounds.Min.X + fixtureMargin; x < bounds.Max.X-fixtureMargin; x++ { | |||
| 37 | + | ar, ag, ab, _ := a.At(x, y).RGBA() | |||
| 38 | + | br, bg, bb, _ := b.At(x, y).RGBA() | |||
| 39 | + | if ar != br || ag != bg || ab != bb { | |||
| 40 | + | changed++ | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | return changed, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func execute(args []string) (int, error) { | |||
| 48 | + | if len(args) != 2 { | |||
| 49 | + | return 2, errors.New("usage: pixels before.png after.png") | |||
| 50 | + | } | |||
| 51 | + | a, err := load(args[0]) | |||
| 52 | + | if err != nil { | |||
| 53 | + | return 2, err | |||
| 54 | + | } | |||
| 55 | + | b, err := load(args[1]) | |||
| 56 | + | if err != nil { | |||
| 57 | + | return 2, err | |||
| 58 | + | } | |||
| 59 | + | changed, err := compare(a, b) | |||
| 60 | + | if err != nil { | |||
| 61 | + | return 2, err | |||
| 62 | + | } | |||
| 63 | + | if err := json.NewEncoder(os.Stdout).Encode(map[string]int{"changed_pixels": changed}); err != nil { | |||
| 64 | + | return 2, err | |||
| 65 | + | } | |||
| 66 | + | if changed < minimumChangedPixels { | |||
| 67 | + | return 1, nil | |||
| 68 | + | } | |||
| 69 | + | return 0, nil | |||
| 70 | + | } | |||
| 71 | + | func main() { | |||
| 72 | + | code, err := execute(os.Args[1:]) | |||
| 73 | + | if err != nil { | |||
| 74 | + | fmt.Fprintln(os.Stderr, err) | |||
| 75 | + | } | |||
| 76 | + | os.Exit(code) | |||
| 77 | + | } | |||
A scripts/remote-resilience-harness/pixels/main_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "image/color" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestCompare(t *testing.T) { | |
| 12 | + | for _, n := range []int{0, 19, 20} { | |
| 13 | + | a := image.NewRGBA(image.Rect(0, 0, 300, 300)) | |
| 14 | + | b := image.NewRGBA(a.Bounds()) | |
| 15 | + | for x := 0; x < n; x++ { | |
| 16 | + | b.Set(100+x, 100, color.White) | |
| 17 | + | } | |
| 18 | + | got, err := compare(a, b) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | require.Equal(t, n, got) | |
| 21 | + | } | |
| 22 | + | for _, bounds := range []image.Rectangle{image.Rect(0, 0, 200, 300), image.Rect(0, 0, 300, 200)} { | |
| 23 | + | _, err := compare(image.NewRGBA(bounds), image.NewRGBA(bounds)) | |
| 24 | + | require.Error(t, err) | |
| 25 | + | } | |
| 26 | + | _, err := compare(image.NewRGBA(image.Rect(0, 0, 300, 300)), image.NewRGBA(image.Rect(0, 0, 400, 300))) | |
| 27 | + | require.Error(t, err) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "image/color" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCompare(t *testing.T) { | |||
| 12 | + | for _, n := range []int{0, 19, 20} { | |||
| 13 | + | a := image.NewRGBA(image.Rect(0, 0, 300, 300)) | |||
| 14 | + | b := image.NewRGBA(a.Bounds()) | |||
| 15 | + | for x := 0; x < n; x++ { | |||
| 16 | + | b.Set(100+x, 100, color.White) | |||
| 17 | + | } | |||
| 18 | + | got, err := compare(a, b) | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | require.Equal(t, n, got) | |||
| 21 | + | } | |||
| 22 | + | for _, bounds := range []image.Rectangle{image.Rect(0, 0, 200, 300), image.Rect(0, 0, 300, 200)} { | |||
| 23 | + | _, err := compare(image.NewRGBA(bounds), image.NewRGBA(bounds)) | |||
| 24 | + | require.Error(t, err) | |||
| 25 | + | } | |||
| 26 | + | _, err := compare(image.NewRGBA(image.Rect(0, 0, 300, 300)), image.NewRGBA(image.Rect(0, 0, 400, 300))) | |||
| 27 | + | require.Error(t, err) | |||
| 28 | + | } | |||
A scripts/remote-resilience-harness/relay/main.go +289 -0
| @@ -0,0 +1,289 @@ | |||
| 1 | + | // Command relay is disposable acceptance infrastructure, never a production proxy. | |
| 2 | + | package main | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "bufio" | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "flag" | |
| 11 | + | "fmt" | |
| 12 | + | "io" | |
| 13 | + | "net" | |
| 14 | + | "os" | |
| 15 | + | "os/signal" | |
| 16 | + | "path/filepath" | |
| 17 | + | "sync" | |
| 18 | + | "sync/atomic" | |
| 19 | + | "syscall" | |
| 20 | + | "time" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/vev/internal/adapters/quicnettest" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | type command struct { | |
| 26 | + | ID uint64 `json:"id"` | |
| 27 | + | Op string `json:"op"` | |
| 28 | + | Port int `json:"port,omitempty"` | |
| 29 | + | UDP bool `json:"udp,omitempty"` | |
| 30 | + | TCP bool `json:"tcp,omitempty"` | |
| 31 | + | Active bool `json:"active,omitempty"` | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type relay struct { | |
| 35 | + | ctx context.Context | |
| 36 | + | cancel context.CancelFunc | |
| 37 | + | remote string | |
| 38 | + | degraded bool | |
| 39 | + | blackout atomic.Bool | |
| 40 | + | bytes atomic.Uint64 | |
| 41 | + | mu sync.Mutex | |
| 42 | + | connections map[net.Conn]struct{} | |
| 43 | + | proxies []*quicnettest.Proxy | |
| 44 | + | udpBlackout bool | |
| 45 | + | wg sync.WaitGroup | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func main() { | |
| 49 | + | remote := flag.String("remote", "remote", "fixture upstream host") | |
| 50 | + | control := flag.String("control", "", "required private Unix control path") | |
| 51 | + | tcp := flag.String("tcp", ":2222", "SSH listener") | |
| 52 | + | degraded := flag.Bool("degraded", false, "enable seeded degraded profile") | |
| 53 | + | flag.Parse() | |
| 54 | + | if *control == "" { | |
| 55 | + | fmt.Fprintln(os.Stderr, "-control is required") | |
| 56 | + | os.Exit(2) | |
| 57 | + | } | |
| 58 | + | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | |
| 59 | + | r := &relay{ctx: ctx, cancel: cancel, remote: *remote, degraded: *degraded, connections: make(map[net.Conn]struct{})} | |
| 60 | + | if err := r.serve(*tcp, *control); err != nil { | |
| 61 | + | fmt.Fprintln(os.Stderr, err) | |
| 62 | + | os.Exit(1) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (r *relay) serve(addr, path string) error { | |
| 67 | + | listener, err := net.Listen("tcp", addr) | |
| 68 | + | if err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | defer listener.Close() | |
| 72 | + | parent, err := os.Stat(filepath.Dir(path)) | |
| 73 | + | if err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | if !parent.IsDir() || parent.Mode().Perm() != 0700 { | |
| 77 | + | return errors.New("control socket requires a private 0700 directory") | |
| 78 | + | } | |
| 79 | + | owner, ok := parent.Sys().(*syscall.Stat_t) | |
| 80 | + | if !ok || owner.Uid != uint32(os.Getuid()) { | |
| 81 | + | return errors.New("control directory must belong to the current user") | |
| 82 | + | } | |
| 83 | + | control, err := net.Listen("unix", path) | |
| 84 | + | if err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | defer control.Close() | |
| 88 | + | watchDone := make(chan struct{}) | |
| 89 | + | defer close(watchDone) | |
| 90 | + | go func() { | |
| 91 | + | select { | |
| 92 | + | case <-r.ctx.Done(): | |
| 93 | + | listener.Close() | |
| 94 | + | control.Close() | |
| 95 | + | case <-watchDone: | |
| 96 | + | } | |
| 97 | + | }() | |
| 98 | + | defer os.Remove(path) | |
| 99 | + | if err := os.Chmod(path, 0600); err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | defer func() { listener.Close(); r.close() }() | |
| 103 | + | r.wg.Add(1) | |
| 104 | + | go func() { | |
| 105 | + | defer r.wg.Done() | |
| 106 | + | for { | |
| 107 | + | conn, err := listener.Accept() | |
| 108 | + | if err != nil { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | r.mu.Lock() | |
| 112 | + | if len(r.connections) >= 64 { | |
| 113 | + | r.mu.Unlock() | |
| 114 | + | conn.Close() | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | r.connections[conn] = struct{}{} | |
| 118 | + | r.mu.Unlock() | |
| 119 | + | r.wg.Add(1) | |
| 120 | + | go func() { defer r.wg.Done(); r.forward(conn) }() | |
| 121 | + | } | |
| 122 | + | }() | |
| 123 | + | for { | |
| 124 | + | conn, err := control.Accept() | |
| 125 | + | if err != nil { | |
| 126 | + | if r.ctx.Err() != nil { | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | // Commands are bounded and handled serially; a stalled caller gets 2s. | |
| 132 | + | conn.SetDeadline(time.Now().Add(2 * time.Second)) | |
| 133 | + | scanner := bufio.NewScanner(conn) | |
| 134 | + | scanner.Buffer(make([]byte, 4096), 4096) | |
| 135 | + | if scanner.Scan() { | |
| 136 | + | var c command | |
| 137 | + | err := json.Unmarshal(scanner.Bytes(), &c) | |
| 138 | + | var result any | |
| 139 | + | if err == nil { | |
| 140 | + | result, err = r.apply(c) | |
| 141 | + | } | |
| 142 | + | reply := map[string]any{"id": c.ID, "result": result} | |
| 143 | + | if err != nil { | |
| 144 | + | reply["error"] = err.Error() | |
| 145 | + | } | |
| 146 | + | json.NewEncoder(conn).Encode(reply) | |
| 147 | + | } | |
| 148 | + | conn.Close() | |
| 149 | + | if r.ctx.Err() != nil { | |
| 150 | + | listener.Close() | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (r *relay) apply(c command) (any, error) { | |
| 157 | + | switch c.Op { | |
| 158 | + | case "udp": | |
| 159 | + | if c.Port <= 0 || c.Port > 65535 { | |
| 160 | + | return nil, errors.New("invalid port") | |
| 161 | + | } | |
| 162 | + | upstream, err := net.ResolveUDPAddr("udp", net.JoinHostPort(r.remote, fmt.Sprint(c.Port))) | |
| 163 | + | if err != nil { | |
| 164 | + | return nil, err | |
| 165 | + | } | |
| 166 | + | link := quicnettest.LinkConfig{} | |
| 167 | + | if r.degraded { | |
| 168 | + | link = quicnettest.LinkConfig{BaseLatency: 150 * time.Millisecond, Jitter: 50 * time.Millisecond, LossPercent: 5} | |
| 169 | + | } | |
| 170 | + | r.mu.Lock() | |
| 171 | + | defer r.mu.Unlock() | |
| 172 | + | if len(r.proxies) >= 32 { | |
| 173 | + | return nil, errors.New("UDP listener limit") | |
| 174 | + | } | |
| 175 | + | p, err := quicnettest.New(quicnettest.Config{ServerAddr: upstream, ListenAddr: &net.UDPAddr{IP: net.IPv4zero}, Seed: 317, ToServer: link, ToClient: link}) | |
| 176 | + | if err != nil { | |
| 177 | + | return nil, err | |
| 178 | + | } | |
| 179 | + | p.SetBlackout(r.udpBlackout) | |
| 180 | + | r.proxies = append(r.proxies, p) | |
| 181 | + | return p.Addr().Port, nil | |
| 182 | + | case "blackout": | |
| 183 | + | if c.TCP { | |
| 184 | + | r.blackout.Store(c.Active) | |
| 185 | + | } | |
| 186 | + | if c.UDP { | |
| 187 | + | r.mu.Lock() | |
| 188 | + | r.udpBlackout = c.Active | |
| 189 | + | for _, p := range r.proxies { | |
| 190 | + | p.SetBlackout(c.Active) | |
| 191 | + | } | |
| 192 | + | r.mu.Unlock() | |
| 193 | + | } | |
| 194 | + | return nil, nil | |
| 195 | + | case "disconnect": | |
| 196 | + | r.mu.Lock() | |
| 197 | + | for conn := range r.connections { | |
| 198 | + | conn.Close() | |
| 199 | + | } | |
| 200 | + | r.mu.Unlock() | |
| 201 | + | return nil, nil | |
| 202 | + | case "stats": | |
| 203 | + | r.mu.Lock() | |
| 204 | + | defer r.mu.Unlock() | |
| 205 | + | stats := make([]quicnettest.Stats, 0, len(r.proxies)) | |
| 206 | + | for _, p := range r.proxies { | |
| 207 | + | stats = append(stats, p.Stats()) | |
| 208 | + | } | |
| 209 | + | return map[string]any{"udp": stats, "tcp_bytes": r.bytes.Load()}, nil | |
| 210 | + | case "stop": | |
| 211 | + | r.cancel() | |
| 212 | + | return nil, nil | |
| 213 | + | default: | |
| 214 | + | return nil, errors.New("unknown command") | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (r *relay) forward(client net.Conn) { | |
| 219 | + | defer func() { client.Close(); r.mu.Lock(); delete(r.connections, client); r.mu.Unlock() }() | |
| 220 | + | server, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(r.ctx, "tcp", net.JoinHostPort(r.remote, "22")) | |
| 221 | + | if err != nil { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | defer server.Close() | |
| 225 | + | r.mu.Lock() | |
| 226 | + | r.connections[server] = struct{}{} | |
| 227 | + | r.mu.Unlock() | |
| 228 | + | defer func() { r.mu.Lock(); delete(r.connections, server); r.mu.Unlock() }() | |
| 229 | + | done := make(chan struct{}) | |
| 230 | + | go func() { r.copy(server, client); server.Close(); client.Close(); close(done) }() | |
| 231 | + | r.copy(client, server) | |
| 232 | + | server.Close() | |
| 233 | + | client.Close() | |
| 234 | + | <-done | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (r *relay) wait(delay time.Duration) bool { | |
| 238 | + | timer := time.NewTimer(delay) | |
| 239 | + | defer timer.Stop() | |
| 240 | + | select { | |
| 241 | + | case <-timer.C: | |
| 242 | + | return true | |
| 243 | + | case <-r.ctx.Done(): | |
| 244 | + | return false | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (r *relay) copy(dst, src net.Conn) { | |
| 249 | + | buf := make([]byte, 32<<10) | |
| 250 | + | for { | |
| 251 | + | for r.blackout.Load() { | |
| 252 | + | if !r.wait(10 * time.Millisecond) { | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | n, err := src.Read(buf) | |
| 257 | + | if n > 0 { | |
| 258 | + | for r.blackout.Load() { | |
| 259 | + | if !r.wait(10 * time.Millisecond) { | |
| 260 | + | return | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | if r.degraded && !r.wait(150*time.Millisecond+time.Duration(n)*time.Second/(64<<10)) { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | written, writeErr := io.Copy(dst, bytes.NewReader(buf[:n])) | |
| 267 | + | r.bytes.Add(uint64(written)) | |
| 268 | + | if writeErr != nil { | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | if err != nil { | |
| 273 | + | return | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func (r *relay) close() { | |
| 279 | + | r.cancel() | |
| 280 | + | r.mu.Lock() | |
| 281 | + | for conn := range r.connections { | |
| 282 | + | conn.Close() | |
| 283 | + | } | |
| 284 | + | for _, p := range r.proxies { | |
| 285 | + | p.Close() | |
| 286 | + | } | |
| 287 | + | r.mu.Unlock() | |
| 288 | + | r.wg.Wait() | |
| 289 | + | } | |
| @@ -0,0 +1,289 @@ | |||||
| 1 | + | // Command relay is disposable acceptance infrastructure, never a production proxy. | |||
| 2 | + | package main | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "bufio" | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "flag" | |||
| 11 | + | "fmt" | |||
| 12 | + | "io" | |||
| 13 | + | "net" | |||
| 14 | + | "os" | |||
| 15 | + | "os/signal" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | "sync" | |||
| 18 | + | "sync/atomic" | |||
| 19 | + | "syscall" | |||
| 20 | + | "time" | |||
| 21 | + | ||||
| 22 | + | "github.com/bnema/vev/internal/adapters/quicnettest" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | type command struct { | |||
| 26 | + | ID uint64 `json:"id"` | |||
| 27 | + | Op string `json:"op"` | |||
| 28 | + | Port int `json:"port,omitempty"` | |||
| 29 | + | UDP bool `json:"udp,omitempty"` | |||
| 30 | + | TCP bool `json:"tcp,omitempty"` | |||
| 31 | + | Active bool `json:"active,omitempty"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type relay struct { | |||
| 35 | + | ctx context.Context | |||
| 36 | + | cancel context.CancelFunc | |||
| 37 | + | remote string | |||
| 38 | + | degraded bool | |||
| 39 | + | blackout atomic.Bool | |||
| 40 | + | bytes atomic.Uint64 | |||
| 41 | + | mu sync.Mutex | |||
| 42 | + | connections map[net.Conn]struct{} | |||
| 43 | + | proxies []*quicnettest.Proxy | |||
| 44 | + | udpBlackout bool | |||
| 45 | + | wg sync.WaitGroup | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func main() { | |||
| 49 | + | remote := flag.String("remote", "remote", "fixture upstream host") | |||
| 50 | + | control := flag.String("control", "", "required private Unix control path") | |||
| 51 | + | tcp := flag.String("tcp", ":2222", "SSH listener") | |||
| 52 | + | degraded := flag.Bool("degraded", false, "enable seeded degraded profile") | |||
| 53 | + | flag.Parse() | |||
| 54 | + | if *control == "" { | |||
| 55 | + | fmt.Fprintln(os.Stderr, "-control is required") | |||
| 56 | + | os.Exit(2) | |||
| 57 | + | } | |||
| 58 | + | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | |||
| 59 | + | r := &relay{ctx: ctx, cancel: cancel, remote: *remote, degraded: *degraded, connections: make(map[net.Conn]struct{})} | |||
| 60 | + | if err := r.serve(*tcp, *control); err != nil { | |||
| 61 | + | fmt.Fprintln(os.Stderr, err) | |||
| 62 | + | os.Exit(1) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (r *relay) serve(addr, path string) error { | |||
| 67 | + | listener, err := net.Listen("tcp", addr) | |||
| 68 | + | if err != nil { | |||
| 69 | + | return err | |||
| 70 | + | } | |||
| 71 | + | defer listener.Close() | |||
| 72 | + | parent, err := os.Stat(filepath.Dir(path)) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | if !parent.IsDir() || parent.Mode().Perm() != 0700 { | |||
| 77 | + | return errors.New("control socket requires a private 0700 directory") | |||
| 78 | + | } | |||
| 79 | + | owner, ok := parent.Sys().(*syscall.Stat_t) | |||
| 80 | + | if !ok || owner.Uid != uint32(os.Getuid()) { | |||
| 81 | + | return errors.New("control directory must belong to the current user") | |||
| 82 | + | } | |||
| 83 | + | control, err := net.Listen("unix", path) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | defer control.Close() | |||
| 88 | + | watchDone := make(chan struct{}) | |||
| 89 | + | defer close(watchDone) | |||
| 90 | + | go func() { | |||
| 91 | + | select { | |||
| 92 | + | case <-r.ctx.Done(): | |||
| 93 | + | listener.Close() | |||
| 94 | + | control.Close() | |||
| 95 | + | case <-watchDone: | |||
| 96 | + | } | |||
| 97 | + | }() | |||
| 98 | + | defer os.Remove(path) | |||
| 99 | + | if err := os.Chmod(path, 0600); err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | defer func() { listener.Close(); r.close() }() | |||
| 103 | + | r.wg.Add(1) | |||
| 104 | + | go func() { | |||
| 105 | + | defer r.wg.Done() | |||
| 106 | + | for { | |||
| 107 | + | conn, err := listener.Accept() | |||
| 108 | + | if err != nil { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | r.mu.Lock() | |||
| 112 | + | if len(r.connections) >= 64 { | |||
| 113 | + | r.mu.Unlock() | |||
| 114 | + | conn.Close() | |||
| 115 | + | continue | |||
| 116 | + | } | |||
| 117 | + | r.connections[conn] = struct{}{} | |||
| 118 | + | r.mu.Unlock() | |||
| 119 | + | r.wg.Add(1) | |||
| 120 | + | go func() { defer r.wg.Done(); r.forward(conn) }() | |||
| 121 | + | } | |||
| 122 | + | }() | |||
| 123 | + | for { | |||
| 124 | + | conn, err := control.Accept() | |||
| 125 | + | if err != nil { | |||
| 126 | + | if r.ctx.Err() != nil { | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | // Commands are bounded and handled serially; a stalled caller gets 2s. | |||
| 132 | + | conn.SetDeadline(time.Now().Add(2 * time.Second)) | |||
| 133 | + | scanner := bufio.NewScanner(conn) | |||
| 134 | + | scanner.Buffer(make([]byte, 4096), 4096) | |||
| 135 | + | if scanner.Scan() { | |||
| 136 | + | var c command | |||
| 137 | + | err := json.Unmarshal(scanner.Bytes(), &c) | |||
| 138 | + | var result any | |||
| 139 | + | if err == nil { | |||
| 140 | + | result, err = r.apply(c) | |||
| 141 | + | } | |||
| 142 | + | reply := map[string]any{"id": c.ID, "result": result} | |||
| 143 | + | if err != nil { | |||
| 144 | + | reply["error"] = err.Error() | |||
| 145 | + | } | |||
| 146 | + | json.NewEncoder(conn).Encode(reply) | |||
| 147 | + | } | |||
| 148 | + | conn.Close() | |||
| 149 | + | if r.ctx.Err() != nil { | |||
| 150 | + | listener.Close() | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (r *relay) apply(c command) (any, error) { | |||
| 157 | + | switch c.Op { | |||
| 158 | + | case "udp": | |||
| 159 | + | if c.Port <= 0 || c.Port > 65535 { | |||
| 160 | + | return nil, errors.New("invalid port") | |||
| 161 | + | } | |||
| 162 | + | upstream, err := net.ResolveUDPAddr("udp", net.JoinHostPort(r.remote, fmt.Sprint(c.Port))) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return nil, err | |||
| 165 | + | } | |||
| 166 | + | link := quicnettest.LinkConfig{} | |||
| 167 | + | if r.degraded { | |||
| 168 | + | link = quicnettest.LinkConfig{BaseLatency: 150 * time.Millisecond, Jitter: 50 * time.Millisecond, LossPercent: 5} | |||
| 169 | + | } | |||
| 170 | + | r.mu.Lock() | |||
| 171 | + | defer r.mu.Unlock() | |||
| 172 | + | if len(r.proxies) >= 32 { | |||
| 173 | + | return nil, errors.New("UDP listener limit") | |||
| 174 | + | } | |||
| 175 | + | p, err := quicnettest.New(quicnettest.Config{ServerAddr: upstream, ListenAddr: &net.UDPAddr{IP: net.IPv4zero}, Seed: 317, ToServer: link, ToClient: link}) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return nil, err | |||
| 178 | + | } | |||
| 179 | + | p.SetBlackout(r.udpBlackout) | |||
| 180 | + | r.proxies = append(r.proxies, p) | |||
| 181 | + | return p.Addr().Port, nil | |||
| 182 | + | case "blackout": | |||
| 183 | + | if c.TCP { | |||
| 184 | + | r.blackout.Store(c.Active) | |||
| 185 | + | } | |||
| 186 | + | if c.UDP { | |||
| 187 | + | r.mu.Lock() | |||
| 188 | + | r.udpBlackout = c.Active | |||
| 189 | + | for _, p := range r.proxies { | |||
| 190 | + | p.SetBlackout(c.Active) | |||
| 191 | + | } | |||
| 192 | + | r.mu.Unlock() | |||
| 193 | + | } | |||
| 194 | + | return nil, nil | |||
| 195 | + | case "disconnect": | |||
| 196 | + | r.mu.Lock() | |||
| 197 | + | for conn := range r.connections { | |||
| 198 | + | conn.Close() | |||
| 199 | + | } | |||
| 200 | + | r.mu.Unlock() | |||
| 201 | + | return nil, nil | |||
| 202 | + | case "stats": | |||
| 203 | + | r.mu.Lock() | |||
| 204 | + | defer r.mu.Unlock() | |||
| 205 | + | stats := make([]quicnettest.Stats, 0, len(r.proxies)) | |||
| 206 | + | for _, p := range r.proxies { | |||
| 207 | + | stats = append(stats, p.Stats()) | |||
| 208 | + | } | |||
| 209 | + | return map[string]any{"udp": stats, "tcp_bytes": r.bytes.Load()}, nil | |||
| 210 | + | case "stop": | |||
| 211 | + | r.cancel() | |||
| 212 | + | return nil, nil | |||
| 213 | + | default: | |||
| 214 | + | return nil, errors.New("unknown command") | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (r *relay) forward(client net.Conn) { | |||
| 219 | + | defer func() { client.Close(); r.mu.Lock(); delete(r.connections, client); r.mu.Unlock() }() | |||
| 220 | + | server, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(r.ctx, "tcp", net.JoinHostPort(r.remote, "22")) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | defer server.Close() | |||
| 225 | + | r.mu.Lock() | |||
| 226 | + | r.connections[server] = struct{}{} | |||
| 227 | + | r.mu.Unlock() | |||
| 228 | + | defer func() { r.mu.Lock(); delete(r.connections, server); r.mu.Unlock() }() | |||
| 229 | + | done := make(chan struct{}) | |||
| 230 | + | go func() { r.copy(server, client); server.Close(); client.Close(); close(done) }() | |||
| 231 | + | r.copy(client, server) | |||
| 232 | + | server.Close() | |||
| 233 | + | client.Close() | |||
| 234 | + | <-done | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (r *relay) wait(delay time.Duration) bool { | |||
| 238 | + | timer := time.NewTimer(delay) | |||
| 239 | + | defer timer.Stop() | |||
| 240 | + | select { | |||
| 241 | + | case <-timer.C: | |||
| 242 | + | return true | |||
| 243 | + | case <-r.ctx.Done(): | |||
| 244 | + | return false | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (r *relay) copy(dst, src net.Conn) { | |||
| 249 | + | buf := make([]byte, 32<<10) | |||
| 250 | + | for { | |||
| 251 | + | for r.blackout.Load() { | |||
| 252 | + | if !r.wait(10 * time.Millisecond) { | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | n, err := src.Read(buf) | |||
| 257 | + | if n > 0 { | |||
| 258 | + | for r.blackout.Load() { | |||
| 259 | + | if !r.wait(10 * time.Millisecond) { | |||
| 260 | + | return | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | if r.degraded && !r.wait(150*time.Millisecond+time.Duration(n)*time.Second/(64<<10)) { | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | written, writeErr := io.Copy(dst, bytes.NewReader(buf[:n])) | |||
| 267 | + | r.bytes.Add(uint64(written)) | |||
| 268 | + | if writeErr != nil { | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | if err != nil { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func (r *relay) close() { | |||
| 279 | + | r.cancel() | |||
| 280 | + | r.mu.Lock() | |||
| 281 | + | for conn := range r.connections { | |||
| 282 | + | conn.Close() | |||
| 283 | + | } | |||
| 284 | + | for _, p := range r.proxies { | |||
| 285 | + | p.Close() | |||
| 286 | + | } | |||
| 287 | + | r.mu.Unlock() | |||
| 288 | + | r.wg.Wait() | |||
| 289 | + | } | |||
A scripts/remote-resilience-harness/relay/main_test.go +162 -0
| @@ -0,0 +1,162 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "net" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestTCPRelayPreservesBytesAndStops(t *testing.T) { | |
| 18 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 19 | + | defer cancel() | |
| 20 | + | r := &relay{ctx: ctx, cancel: cancel} | |
| 21 | + | source, writer := net.Pipe() | |
| 22 | + | reader, destination := net.Pipe() | |
| 23 | + | defer source.Close() | |
| 24 | + | defer writer.Close() | |
| 25 | + | defer reader.Close() | |
| 26 | + | defer destination.Close() | |
| 27 | + | done := make(chan struct{}) | |
| 28 | + | go func() { defer close(done); r.copy(destination, source) }() | |
| 29 | + | payload := []byte("fixture input in order") | |
| 30 | + | go func() { writer.Write(payload); writer.Close() }() | |
| 31 | + | reader.SetReadDeadline(time.Now().Add(time.Second)) | |
| 32 | + | got := make([]byte, len(payload)) | |
| 33 | + | _, err := io.ReadFull(reader, got) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.Equal(t, payload, got) | |
| 36 | + | select { | |
| 37 | + | case <-done: | |
| 38 | + | case <-time.After(time.Second): | |
| 39 | + | t.Fatal("relay did not stop after EOF") | |
| 40 | + | } | |
| 41 | + | require.Equal(t, uint64(len(payload)), r.bytes.Load()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestRelayShutdownClosesListeners(t *testing.T) { | |
| 45 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 46 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |
| 47 | + | directory := t.TempDir() | |
| 48 | + | require.NoError(t, os.Chmod(directory, 0700)) | |
| 49 | + | path := filepath.Join(directory, "control.sock") | |
| 50 | + | done := make(chan error, 1) | |
| 51 | + | go func() { done <- r.serve("127.0.0.1:0", path) }() | |
| 52 | + | require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond) | |
| 53 | + | cancel() | |
| 54 | + | select { | |
| 55 | + | case err := <-done: | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | case <-time.After(time.Second): | |
| 58 | + | t.Fatal("relay shutdown hung") | |
| 59 | + | } | |
| 60 | + | _, err := os.Stat(path) | |
| 61 | + | require.True(t, os.IsNotExist(err)) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestTCPRelayBlackoutHoldsBytes(t *testing.T) { | |
| 65 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 66 | + | defer cancel() | |
| 67 | + | r := &relay{ctx: ctx, cancel: cancel} | |
| 68 | + | _, err := r.apply(command{Op: "blackout", TCP: true, Active: true}) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | source, writer := net.Pipe() | |
| 71 | + | reader, destination := net.Pipe() | |
| 72 | + | defer source.Close() | |
| 73 | + | defer writer.Close() | |
| 74 | + | defer reader.Close() | |
| 75 | + | defer destination.Close() | |
| 76 | + | done := make(chan struct{}) | |
| 77 | + | go func() { defer close(done); r.copy(destination, source) }() | |
| 78 | + | payload := []byte("held") | |
| 79 | + | wrote := make(chan error, 1) | |
| 80 | + | go func() { _, err := writer.Write(payload); wrote <- err }() | |
| 81 | + | select { | |
| 82 | + | case err := <-wrote: | |
| 83 | + | t.Fatalf("blackout read the source: %v", err) | |
| 84 | + | case <-time.After(50 * time.Millisecond): | |
| 85 | + | } | |
| 86 | + | require.Zero(t, r.bytes.Load()) | |
| 87 | + | _, err = r.apply(command{Op: "blackout", TCP: true, Active: false}) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | reader.SetReadDeadline(time.Now().Add(time.Second)) | |
| 90 | + | got := make([]byte, len(payload)) | |
| 91 | + | _, err = io.ReadFull(reader, got) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | require.Equal(t, payload, got) | |
| 94 | + | require.NoError(t, <-wrote) | |
| 95 | + | cancel() | |
| 96 | + | writer.Close() | |
| 97 | + | <-done | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestRelayDisconnectClosesConnections(t *testing.T) { | |
| 101 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 102 | + | defer cancel() | |
| 103 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |
| 104 | + | local, peer := net.Pipe() | |
| 105 | + | defer peer.Close() | |
| 106 | + | r.connections[local] = struct{}{} | |
| 107 | + | _, err := r.apply(command{Op: "disconnect"}) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | peer.SetReadDeadline(time.Now().Add(time.Second)) | |
| 110 | + | _, err = peer.Read(make([]byte, 1)) | |
| 111 | + | require.ErrorIs(t, err, io.EOF) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRelayControlRoundTrip(t *testing.T) { | |
| 115 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 116 | + | defer cancel() | |
| 117 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |
| 118 | + | directory := t.TempDir() | |
| 119 | + | require.NoError(t, os.Chmod(directory, 0700)) | |
| 120 | + | path := filepath.Join(directory, "control.sock") | |
| 121 | + | done := make(chan error, 1) | |
| 122 | + | go func() { done <- r.serve("127.0.0.1:0", path) }() | |
| 123 | + | require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond) | |
| 124 | + | send := func(c command) map[string]any { | |
| 125 | + | conn, err := net.Dial("unix", path) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | defer conn.Close() | |
| 128 | + | require.NoError(t, json.NewEncoder(conn).Encode(c)) | |
| 129 | + | line, err := bufio.NewReader(conn).ReadBytes('\n') | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | var reply map[string]any | |
| 132 | + | require.NoError(t, json.Unmarshal(line, &reply)) | |
| 133 | + | return reply | |
| 134 | + | } | |
| 135 | + | reply := send(command{ID: 7, Op: "blackout", TCP: true, Active: true}) | |
| 136 | + | require.EqualValues(t, 7, reply["id"]) | |
| 137 | + | require.NotContains(t, reply, "error") | |
| 138 | + | require.True(t, r.blackout.Load()) | |
| 139 | + | reply = send(command{ID: 8, Op: "unknown"}) | |
| 140 | + | require.Equal(t, "unknown command", reply["error"]) | |
| 141 | + | reply = send(command{ID: 9, Op: "stop"}) | |
| 142 | + | require.NotContains(t, reply, "error") | |
| 143 | + | select { | |
| 144 | + | case err := <-done: | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | case <-time.After(time.Second): | |
| 147 | + | t.Fatal("stop command did not shut the relay down") | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestRelayRejectsInvalidControl(t *testing.T) { | |
| 152 | + | ctx, cancel := context.WithCancel(t.Context()) | |
| 153 | + | defer cancel() | |
| 154 | + | r := &relay{ctx: ctx, cancel: cancel} | |
| 155 | + | for _, c := range []command{{Op: "unknown"}, {Op: "udp", Port: 0}, {Op: "udp", Port: 65536}} { | |
| 156 | + | _, err := r.apply(c) | |
| 157 | + | require.Error(t, err) | |
| 158 | + | } | |
| 159 | + | _, err := r.apply(command{Op: "stop"}) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | require.ErrorIs(t, ctx.Err(), context.Canceled) | |
| 162 | + | } | |
| @@ -0,0 +1,162 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "io" | |||
| 8 | + | "net" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestTCPRelayPreservesBytesAndStops(t *testing.T) { | |||
| 18 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 19 | + | defer cancel() | |||
| 20 | + | r := &relay{ctx: ctx, cancel: cancel} | |||
| 21 | + | source, writer := net.Pipe() | |||
| 22 | + | reader, destination := net.Pipe() | |||
| 23 | + | defer source.Close() | |||
| 24 | + | defer writer.Close() | |||
| 25 | + | defer reader.Close() | |||
| 26 | + | defer destination.Close() | |||
| 27 | + | done := make(chan struct{}) | |||
| 28 | + | go func() { defer close(done); r.copy(destination, source) }() | |||
| 29 | + | payload := []byte("fixture input in order") | |||
| 30 | + | go func() { writer.Write(payload); writer.Close() }() | |||
| 31 | + | reader.SetReadDeadline(time.Now().Add(time.Second)) | |||
| 32 | + | got := make([]byte, len(payload)) | |||
| 33 | + | _, err := io.ReadFull(reader, got) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.Equal(t, payload, got) | |||
| 36 | + | select { | |||
| 37 | + | case <-done: | |||
| 38 | + | case <-time.After(time.Second): | |||
| 39 | + | t.Fatal("relay did not stop after EOF") | |||
| 40 | + | } | |||
| 41 | + | require.Equal(t, uint64(len(payload)), r.bytes.Load()) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestRelayShutdownClosesListeners(t *testing.T) { | |||
| 45 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 46 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |||
| 47 | + | directory := t.TempDir() | |||
| 48 | + | require.NoError(t, os.Chmod(directory, 0700)) | |||
| 49 | + | path := filepath.Join(directory, "control.sock") | |||
| 50 | + | done := make(chan error, 1) | |||
| 51 | + | go func() { done <- r.serve("127.0.0.1:0", path) }() | |||
| 52 | + | require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond) | |||
| 53 | + | cancel() | |||
| 54 | + | select { | |||
| 55 | + | case err := <-done: | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | case <-time.After(time.Second): | |||
| 58 | + | t.Fatal("relay shutdown hung") | |||
| 59 | + | } | |||
| 60 | + | _, err := os.Stat(path) | |||
| 61 | + | require.True(t, os.IsNotExist(err)) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestTCPRelayBlackoutHoldsBytes(t *testing.T) { | |||
| 65 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 66 | + | defer cancel() | |||
| 67 | + | r := &relay{ctx: ctx, cancel: cancel} | |||
| 68 | + | _, err := r.apply(command{Op: "blackout", TCP: true, Active: true}) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | source, writer := net.Pipe() | |||
| 71 | + | reader, destination := net.Pipe() | |||
| 72 | + | defer source.Close() | |||
| 73 | + | defer writer.Close() | |||
| 74 | + | defer reader.Close() | |||
| 75 | + | defer destination.Close() | |||
| 76 | + | done := make(chan struct{}) | |||
| 77 | + | go func() { defer close(done); r.copy(destination, source) }() | |||
| 78 | + | payload := []byte("held") | |||
| 79 | + | wrote := make(chan error, 1) | |||
| 80 | + | go func() { _, err := writer.Write(payload); wrote <- err }() | |||
| 81 | + | select { | |||
| 82 | + | case err := <-wrote: | |||
| 83 | + | t.Fatalf("blackout read the source: %v", err) | |||
| 84 | + | case <-time.After(50 * time.Millisecond): | |||
| 85 | + | } | |||
| 86 | + | require.Zero(t, r.bytes.Load()) | |||
| 87 | + | _, err = r.apply(command{Op: "blackout", TCP: true, Active: false}) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | reader.SetReadDeadline(time.Now().Add(time.Second)) | |||
| 90 | + | got := make([]byte, len(payload)) | |||
| 91 | + | _, err = io.ReadFull(reader, got) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | require.Equal(t, payload, got) | |||
| 94 | + | require.NoError(t, <-wrote) | |||
| 95 | + | cancel() | |||
| 96 | + | writer.Close() | |||
| 97 | + | <-done | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestRelayDisconnectClosesConnections(t *testing.T) { | |||
| 101 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 102 | + | defer cancel() | |||
| 103 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |||
| 104 | + | local, peer := net.Pipe() | |||
| 105 | + | defer peer.Close() | |||
| 106 | + | r.connections[local] = struct{}{} | |||
| 107 | + | _, err := r.apply(command{Op: "disconnect"}) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | peer.SetReadDeadline(time.Now().Add(time.Second)) | |||
| 110 | + | _, err = peer.Read(make([]byte, 1)) | |||
| 111 | + | require.ErrorIs(t, err, io.EOF) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRelayControlRoundTrip(t *testing.T) { | |||
| 115 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 116 | + | defer cancel() | |||
| 117 | + | r := &relay{ctx: ctx, cancel: cancel, connections: make(map[net.Conn]struct{})} | |||
| 118 | + | directory := t.TempDir() | |||
| 119 | + | require.NoError(t, os.Chmod(directory, 0700)) | |||
| 120 | + | path := filepath.Join(directory, "control.sock") | |||
| 121 | + | done := make(chan error, 1) | |||
| 122 | + | go func() { done <- r.serve("127.0.0.1:0", path) }() | |||
| 123 | + | require.Eventually(t, func() bool { _, err := os.Stat(path); return err == nil }, time.Second, time.Millisecond) | |||
| 124 | + | send := func(c command) map[string]any { | |||
| 125 | + | conn, err := net.Dial("unix", path) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | defer conn.Close() | |||
| 128 | + | require.NoError(t, json.NewEncoder(conn).Encode(c)) | |||
| 129 | + | line, err := bufio.NewReader(conn).ReadBytes('\n') | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | var reply map[string]any | |||
| 132 | + | require.NoError(t, json.Unmarshal(line, &reply)) | |||
| 133 | + | return reply | |||
| 134 | + | } | |||
| 135 | + | reply := send(command{ID: 7, Op: "blackout", TCP: true, Active: true}) | |||
| 136 | + | require.EqualValues(t, 7, reply["id"]) | |||
| 137 | + | require.NotContains(t, reply, "error") | |||
| 138 | + | require.True(t, r.blackout.Load()) | |||
| 139 | + | reply = send(command{ID: 8, Op: "unknown"}) | |||
| 140 | + | require.Equal(t, "unknown command", reply["error"]) | |||
| 141 | + | reply = send(command{ID: 9, Op: "stop"}) | |||
| 142 | + | require.NotContains(t, reply, "error") | |||
| 143 | + | select { | |||
| 144 | + | case err := <-done: | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | case <-time.After(time.Second): | |||
| 147 | + | t.Fatal("stop command did not shut the relay down") | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestRelayRejectsInvalidControl(t *testing.T) { | |||
| 152 | + | ctx, cancel := context.WithCancel(t.Context()) | |||
| 153 | + | defer cancel() | |||
| 154 | + | r := &relay{ctx: ctx, cancel: cancel} | |||
| 155 | + | for _, c := range []command{{Op: "unknown"}, {Op: "udp", Port: 0}, {Op: "udp", Port: 65536}} { | |||
| 156 | + | _, err := r.apply(c) | |||
| 157 | + | require.Error(t, err) | |||
| 158 | + | } | |||
| 159 | + | _, err := r.apply(command{Op: "stop"}) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | require.ErrorIs(t, ctx.Err(), context.Canceled) | |||
| 162 | + | } | |||
A scripts/remote-resilience-harness/ssh.py +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Fixture-only SSH wrapper: pin SSH and rewrite only QUIC readiness port.""" | |
| 3 | + | import json | |
| 4 | + | import os | |
| 5 | + | import socket | |
| 6 | + | import subprocess | |
| 7 | + | import sys | |
| 8 | + | ||
| 9 | + | ||
| 10 | + | def control(request): | |
| 11 | + | with socket.socket(socket.AF_UNIX) as conn: | |
| 12 | + | conn.settimeout(2) | |
| 13 | + | conn.connect(os.environ.get("RESILIENCE_CONTROL", "/home/demo/relay/control.sock")) | |
| 14 | + | conn.sendall(json.dumps(request).encode() + b"\n") | |
| 15 | + | reply = json.loads(conn.makefile("rb").readline(4096)) | |
| 16 | + | if "error" in reply: | |
| 17 | + | raise RuntimeError(reply["error"]) | |
| 18 | + | return reply["result"] | |
| 19 | + | ||
| 20 | + | ||
| 21 | + | def main(): | |
| 22 | + | args = sys.argv[1:] | |
| 23 | + | command = ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote"] + args | |
| 24 | + | if not any("_broker-mux-quic-bootstrap" in arg for arg in args): | |
| 25 | + | os.execv(command[0], command) | |
| 26 | + | process = subprocess.Popen(command, stdin=sys.stdin.buffer, stdout=subprocess.PIPE) | |
| 27 | + | try: | |
| 28 | + | line = process.stdout.readline(4097) | |
| 29 | + | if len(line) > 4096 or not line.endswith(b"\n"): | |
| 30 | + | raise RuntimeError("invalid bounded bootstrap readiness") | |
| 31 | + | readiness = json.loads(line) | |
| 32 | + | port = readiness.get("port") | |
| 33 | + | if not isinstance(port, int) or not 0 < port <= 65535: | |
| 34 | + | raise RuntimeError("invalid bootstrap port") | |
| 35 | + | readiness["port"] = control({"id": 1, "op": "udp", "port": port}) | |
| 36 | + | sys.stdout.buffer.write(json.dumps(readiness, separators=(",", ":")).encode() + b"\n") | |
| 37 | + | sys.stdout.buffer.flush() | |
| 38 | + | # No readiness or encrypted payload is recorded in artifacts. | |
| 39 | + | while chunk := process.stdout.read1(32768): | |
| 40 | + | sys.stdout.buffer.write(chunk) | |
| 41 | + | sys.stdout.buffer.flush() | |
| 42 | + | return process.wait() | |
| 43 | + | finally: | |
| 44 | + | if process.poll() is None: | |
| 45 | + | process.terminate() | |
| 46 | + | try: | |
| 47 | + | process.wait(timeout=2) | |
| 48 | + | except subprocess.TimeoutExpired: | |
| 49 | + | process.kill() | |
| 50 | + | process.wait() | |
| 51 | + | ||
| 52 | + | ||
| 53 | + | if __name__ == "__main__": | |
| 54 | + | try: | |
| 55 | + | sys.exit(main()) | |
| 56 | + | except (OSError, ValueError, RuntimeError): | |
| 57 | + | print("resilience SSH fixture failed", file=sys.stderr) | |
| 58 | + | sys.exit(1) | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | #!/usr/bin/env python3 | |||
| 2 | + | """Fixture-only SSH wrapper: pin SSH and rewrite only QUIC readiness port.""" | |||
| 3 | + | import json | |||
| 4 | + | import os | |||
| 5 | + | import socket | |||
| 6 | + | import subprocess | |||
| 7 | + | import sys | |||
| 8 | + | ||||
| 9 | + | ||||
| 10 | + | def control(request): | |||
| 11 | + | with socket.socket(socket.AF_UNIX) as conn: | |||
| 12 | + | conn.settimeout(2) | |||
| 13 | + | conn.connect(os.environ.get("RESILIENCE_CONTROL", "/home/demo/relay/control.sock")) | |||
| 14 | + | conn.sendall(json.dumps(request).encode() + b"\n") | |||
| 15 | + | reply = json.loads(conn.makefile("rb").readline(4096)) | |||
| 16 | + | if "error" in reply: | |||
| 17 | + | raise RuntimeError(reply["error"]) | |||
| 18 | + | return reply["result"] | |||
| 19 | + | ||||
| 20 | + | ||||
| 21 | + | def main(): | |||
| 22 | + | args = sys.argv[1:] | |||
| 23 | + | command = ["/usr/bin/ssh-real", "-p", "2222", "-o", "HostKeyAlias=remote"] + args | |||
| 24 | + | if not any("_broker-mux-quic-bootstrap" in arg for arg in args): | |||
| 25 | + | os.execv(command[0], command) | |||
| 26 | + | process = subprocess.Popen(command, stdin=sys.stdin.buffer, stdout=subprocess.PIPE) | |||
| 27 | + | try: | |||
| 28 | + | line = process.stdout.readline(4097) | |||
| 29 | + | if len(line) > 4096 or not line.endswith(b"\n"): | |||
| 30 | + | raise RuntimeError("invalid bounded bootstrap readiness") | |||
| 31 | + | readiness = json.loads(line) | |||
| 32 | + | port = readiness.get("port") | |||
| 33 | + | if not isinstance(port, int) or not 0 < port <= 65535: | |||
| 34 | + | raise RuntimeError("invalid bootstrap port") | |||
| 35 | + | readiness["port"] = control({"id": 1, "op": "udp", "port": port}) | |||
| 36 | + | sys.stdout.buffer.write(json.dumps(readiness, separators=(",", ":")).encode() + b"\n") | |||
| 37 | + | sys.stdout.buffer.flush() | |||
| 38 | + | # No readiness or encrypted payload is recorded in artifacts. | |||
| 39 | + | while chunk := process.stdout.read1(32768): | |||
| 40 | + | sys.stdout.buffer.write(chunk) | |||
| 41 | + | sys.stdout.buffer.flush() | |||
| 42 | + | return process.wait() | |||
| 43 | + | finally: | |||
| 44 | + | if process.poll() is None: | |||
| 45 | + | process.terminate() | |||
| 46 | + | try: | |||
| 47 | + | process.wait(timeout=2) | |||
| 48 | + | except subprocess.TimeoutExpired: | |||
| 49 | + | process.kill() | |||
| 50 | + | process.wait() | |||
| 51 | + | ||||
| 52 | + | ||||
| 53 | + | if __name__ == "__main__": | |||
| 54 | + | try: | |||
| 55 | + | sys.exit(main()) | |||
| 56 | + | except (OSError, ValueError, RuntimeError): | |||
| 57 | + | print("resilience SSH fixture failed", file=sys.stderr) | |||
| 58 | + | sys.exit(1) | |||
A scripts/remote-resilience-harness/ssh_test.py +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | """Fixture readiness relay tests; no network or production credentials.""" | |
| 2 | + | import importlib.util | |
| 3 | + | from pathlib import Path | |
| 4 | + | import unittest | |
| 5 | + | from unittest.mock import patch, MagicMock | |
| 6 | + | ||
| 7 | + | spec = importlib.util.spec_from_file_location("fixture_ssh", Path(__file__).with_name("ssh.py")) | |
| 8 | + | wrapper = importlib.util.module_from_spec(spec) | |
| 9 | + | spec.loader.exec_module(wrapper) | |
| 10 | + | ||
| 11 | + | ||
| 12 | + | class ControlTests(unittest.TestCase): | |
| 13 | + | def test_control_failure_is_not_a_port(self): | |
| 14 | + | conn = MagicMock() | |
| 15 | + | conn.__enter__.return_value = conn | |
| 16 | + | conn.makefile.return_value.readline.return_value = b'{"id":1,"error":"listener limit"}\n' | |
| 17 | + | with patch.object(wrapper.socket, "socket", return_value=conn): | |
| 18 | + | with self.assertRaises(RuntimeError): | |
| 19 | + | wrapper.control({"id": 1, "op": "udp", "port": 1234}) | |
| 20 | + | ||
| 21 | + | def test_control_returns_fixture_port(self): | |
| 22 | + | conn = MagicMock() | |
| 23 | + | conn.__enter__.return_value = conn | |
| 24 | + | conn.makefile.return_value.readline.return_value = b'{"id":1,"result":4321}\n' | |
| 25 | + | with patch.object(wrapper.socket, "socket", return_value=conn): | |
| 26 | + | self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321) | |
| 27 | + | ||
| 28 | + | ||
| 29 | + | if __name__ == "__main__": | |
| 30 | + | unittest.main() | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | """Fixture readiness relay tests; no network or production credentials.""" | |||
| 2 | + | import importlib.util | |||
| 3 | + | from pathlib import Path | |||
| 4 | + | import unittest | |||
| 5 | + | from unittest.mock import patch, MagicMock | |||
| 6 | + | ||||
| 7 | + | spec = importlib.util.spec_from_file_location("fixture_ssh", Path(__file__).with_name("ssh.py")) | |||
| 8 | + | wrapper = importlib.util.module_from_spec(spec) | |||
| 9 | + | spec.loader.exec_module(wrapper) | |||
| 10 | + | ||||
| 11 | + | ||||
| 12 | + | class ControlTests(unittest.TestCase): | |||
| 13 | + | def test_control_failure_is_not_a_port(self): | |||
| 14 | + | conn = MagicMock() | |||
| 15 | + | conn.__enter__.return_value = conn | |||
| 16 | + | conn.makefile.return_value.readline.return_value = b'{"id":1,"error":"listener limit"}\n' | |||
| 17 | + | with patch.object(wrapper.socket, "socket", return_value=conn): | |||
| 18 | + | with self.assertRaises(RuntimeError): | |||
| 19 | + | wrapper.control({"id": 1, "op": "udp", "port": 1234}) | |||
| 20 | + | ||||
| 21 | + | def test_control_returns_fixture_port(self): | |||
| 22 | + | conn = MagicMock() | |||
| 23 | + | conn.__enter__.return_value = conn | |||
| 24 | + | conn.makefile.return_value.readline.return_value = b'{"id":1,"result":4321}\n' | |||
| 25 | + | with patch.object(wrapper.socket, "socket", return_value=conn): | |||
| 26 | + | self.assertEqual(wrapper.control({"id": 1, "op": "udp", "port": 1234}), 4321) | |||
| 27 | + | ||||
| 28 | + | ||||
| 29 | + | if __name__ == "__main__": | |||
| 30 | + | unittest.main() | |||
A scripts/remote-resilience-harness/visual.py +361 -0
| @@ -0,0 +1,361 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """Disposable dual-transport fixture and physical Wayland input acceptance.""" | |
| 3 | + | import importlib.util | |
| 4 | + | import json | |
| 5 | + | import os | |
| 6 | + | from pathlib import Path | |
| 7 | + | import shutil | |
| 8 | + | import signal | |
| 9 | + | import subprocess | |
| 10 | + | import tempfile | |
| 11 | + | import time | |
| 12 | + | import uuid | |
| 13 | + | ||
| 14 | + | ROOT = Path(__file__).resolve().parents[2] | |
| 15 | + | ||
| 16 | + | ||
| 17 | + | def run(*args, **kwargs): | |
| 18 | + | return subprocess.run(args, check=True, timeout=kwargs.pop("timeout", 60), **kwargs) | |
| 19 | + | ||
| 20 | + | ||
| 21 | + | def docker(*args, **kwargs): | |
| 22 | + | return run("docker", *args, **kwargs) | |
| 23 | + | ||
| 24 | + | ||
| 25 | + | def main(): | |
| 26 | + | name = "vev-resilience-" + uuid.uuid4().hex[:10] | |
| 27 | + | artifacts = Path(tempfile.mkdtemp(prefix="vev-resilience-artifacts-")) | |
| 28 | + | processes = [] | |
| 29 | + | containers = [] | |
| 30 | + | network = False | |
| 31 | + | def interrupt(signum, frame): | |
| 32 | + | raise KeyboardInterrupt("fixture interrupted") | |
| 33 | + | signal.signal(signal.SIGTERM, interrupt) | |
| 34 | + | with tempfile.TemporaryDirectory(prefix="vev-resilience-private-") as directory: | |
| 35 | + | private = Path(directory) | |
| 36 | + | runtime = private / "runtime" | |
| 37 | + | runtime.mkdir(mode=0o700) | |
| 38 | + | try: | |
| 39 | + | for tool in ("docker", "foot", "ssh-keygen", "go"): | |
| 40 | + | if shutil.which(tool) is None: | |
| 41 | + | raise RuntimeError("missing prerequisite: " + tool) | |
| 42 | + | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT) | |
| 43 | + | for helper in ("relay", "pixels"): | |
| 44 | + | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT) | |
| 45 | + | nefer = os.environ.get("NEFERWL_BIN") | |
| 46 | + | if not nefer: | |
| 47 | + | nefer = str(private / "neferwl") | |
| 48 | + | common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip() | |
| 49 | + | source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl"))) | |
| 50 | + | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}) | |
| 51 | + | docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600) | |
| 52 | + | docker("network", "create", name) | |
| 53 | + | network = True | |
| 54 | + | for role in ("client", "remote"): | |
| 55 | + | container = name + "-" + role | |
| 56 | + | docker("run", "-d", "--name", container, "--network", name, "--network-alias", "upstream" if role == "remote" else "remote", "--entrypoint", "sleep", name, "infinity") | |
| 57 | + | containers.append(container) | |
| 58 | + | client, remote = containers | |
| 59 | + | for key in ("client", "host"): | |
| 60 | + | run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f", str(private / key)) | |
| 61 | + | docker("cp", str(private / "host"), remote + ":/etc/ssh/ssh_host_ed25519_key") | |
| 62 | + | docker("cp", str(private / "host.pub"), remote + ":/etc/ssh/ssh_host_ed25519_key.pub") | |
| 63 | + | docker("cp", str(private / "client.pub"), remote + ":/home/demo/.ssh/authorized_keys") | |
| 64 | + | docker("exec", "--user", "root", remote, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*; chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; mkdir -p /run/sshd; /usr/sbin/sshd") | |
| 65 | + | docker("exec", "--user", "root", client, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*") | |
| 66 | + | docker("cp", str(private / "client"), client + ":/home/demo/.ssh/id_ed25519") | |
| 67 | + | public = (private / "host.pub").read_text().split() | |
| 68 | + | (private / "known_hosts").write_text("remote " + " ".join(public[:2]) + "\n") | |
| 69 | + | (private / "config").write_text("Host remote\n HostName remote\n Port 2222\n User demo\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n HostKeyAlias remote\n UserKnownHostsFile ~/.ssh/known_hosts\n") | |
| 70 | + | for file in ("known_hosts", "config"): | |
| 71 | + | docker("cp", str(private / file), client + ":/home/demo/.ssh/" + file) | |
| 72 | + | docker("cp", str(ROOT / "build/resilience-relay"), client + ":/tmp/relay") | |
| 73 | + | docker("cp", str(ROOT / "scripts/remote-resilience-harness/ssh.py"), client + ":/tmp/ssh") | |
| 74 | + | docker("exec", "--user", "root", client, "sh", "-c", "chown demo:demo /home/demo/.ssh/*; chmod 600 /home/demo/.ssh/*; chmod 755 /tmp/ssh; install -d -m 700 -o demo -g demo /home/demo/relay; mkdir -p /tmp/bin; ln -s /tmp/ssh /tmp/bin/ssh; mv /usr/bin/ssh /usr/bin/ssh-real; ln -s /tmp/ssh /usr/bin/ssh") | |
| 75 | + | transport = os.environ.get("VEV_RESILIENCE_TRANSPORT", "quic") | |
| 76 | + | if transport not in ("quic", "ssh"): | |
| 77 | + | raise ValueError("VEV_RESILIENCE_TRANSPORT must be quic or ssh") | |
| 78 | + | relay_args = ["-degraded"] if os.environ.get("VEV_RESILIENCE_DEGRADED") == "1" else [] | |
| 79 | + | relay = subprocess.Popen(["docker", "exec", client, "/tmp/relay", "-remote", "upstream", "-control", "/home/demo/relay/control.sock", *relay_args]) | |
| 80 | + | processes.append(relay) | |
| 81 | + | control_id = 0 | |
| 82 | + | def control(op, **fields): | |
| 83 | + | nonlocal control_id | |
| 84 | + | control_id += 1 | |
| 85 | + | request = {"id": control_id, "op": op, **fields} | |
| 86 | + | code = "import socket,json; s=socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect('/home/demo/relay/control.sock'); s.sendall(" + repr(json.dumps(request).encode() + b"\n") + "); print(s.makefile().readline())" | |
| 87 | + | result = docker("exec", client, "python3", "-c", code, capture_output=True, text=True) | |
| 88 | + | reply = json.loads(result.stdout) | |
| 89 | + | assert reply["id"] == control_id and "error" not in reply, reply | |
| 90 | + | return reply["result"] | |
| 91 | + | # Fixture image must explicitly supply Python rather than using host tools. | |
| 92 | + | docker("exec", "--user", "root", client, "sh", "-c", "command -v python3", capture_output=True) | |
| 93 | + | deadline = time.monotonic() + 5 | |
| 94 | + | while True: | |
| 95 | + | ready = subprocess.run(["docker", "exec", client, "test", "-S", "/home/demo/relay/control.sock"], capture_output=True, timeout=3) | |
| 96 | + | if ready.returncode == 0: | |
| 97 | + | break | |
| 98 | + | if time.monotonic() > deadline: | |
| 99 | + | raise RuntimeError("relay control socket did not become ready") | |
| 100 | + | time.sleep(0.05) | |
| 101 | + | probe = subprocess.run(["docker", "exec", client, "/usr/bin/ssh", "-v", "remote", "true"], capture_output=True, text=True, timeout=10) | |
| 102 | + | if probe.returncode: | |
| 103 | + | (artifacts / "ssh-probe.log").write_text(probe.stderr) | |
| 104 | + | raise RuntimeError("fixture SSH authentication failed") | |
| 105 | + | env_args = ["-e", "PATH=/tmp/bin:/usr/local/bin:/usr/bin", "-e", "VEV_LOG=debug", "-e", "SSH_AUTH_SOCK="] | |
| 106 | + | spec = importlib.util.spec_from_file_location("acceptance", ROOT / "scripts/remote-picker-harness/acceptance.py") | |
| 107 | + | acceptance = importlib.util.module_from_spec(spec) | |
| 108 | + | spec.loader.exec_module(acceptance) | |
| 109 | + | fixture = acceptance.Driver(remote, ["--session", "resilience"]) | |
| 110 | + | fixture.close() | |
| 111 | + | docker("exec", *env_args, client, "vev", "host", "add", "--transport", transport, "remote") | |
| 112 | + | # Wait for the broker publication before resolving direct attach. | |
| 113 | + | deadline = time.monotonic() + 30 | |
| 114 | + | while True: | |
| 115 | + | inventory = docker("exec", *env_args, client, "vev", "ls", "remote", capture_output=True, text=True) | |
| 116 | + | if "resilience" in inventory.stdout: | |
| 117 | + | break | |
| 118 | + | (artifacts / "inventory.txt").write_text(inventory.stdout + inventory.stderr) | |
| 119 | + | if time.monotonic() > deadline: | |
| 120 | + | raise RuntimeError("remote fixture was not published") | |
| 121 | + | time.sleep(0.2) | |
| 122 | + | shots = artifacts / "shots" | |
| 123 | + | shots.mkdir() | |
| 124 | + | docker_endpoint = run("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}", capture_output=True, text=True).stdout.strip() | |
| 125 | + | env = {**os.environ, "DOCKER_HOST": docker_endpoint, "XDG_RUNTIME_DIR": str(runtime), "XDG_CONFIG_HOME": str(private / "config-home"), "XDG_STATE_HOME": str(private / "state"), "HOME": str(private)} | |
| 126 | + | env["TERMINAL"] = "foot --config=/dev/null --override=cursor.blink=no --log-level=info --hold -- docker --host=" + docker_endpoint + " exec -it " + " ".join(env_args) + " " + client + " vev attach demo@remote:resilience --ui-observe --ui-socket /home/demo/visual/ui.sock" | |
| 127 | + | log = (artifacts / "compositor.log").open("w") | |
| 128 | + | compositor = subprocess.Popen([nefer, "--backend=headless", "--size=1280x720", "--input=-", "--screenshot", str(shots), "--timeout", "180s"], stdin=subprocess.PIPE, stdout=log, stderr=log, text=True, env=env) | |
| 129 | + | processes.append(compositor) | |
| 130 | + | deadline = time.monotonic() + 15 | |
| 131 | + | while not list(runtime.glob("wayland-*")): | |
| 132 | + | if compositor.poll() is not None or time.monotonic() > deadline: | |
| 133 | + | raise RuntimeError("headless compositor did not create socket") | |
| 134 | + | time.sleep(0.05) | |
| 135 | + | sockets = [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")] | |
| 136 | + | env["WAYLAND_DISPLAY"] = str(sockets[0]) | |
| 137 | + | env.pop("WAYLAND_SOCKET", None) | |
| 138 | + | env.pop("DISPLAY", None) | |
| 139 | + | def capture(): | |
| 140 | + | bridge = subprocess.Popen(["docker", "exec", "-i", client, "vev", "--ui-driver", "--socket", "/home/demo/visual/ui.sock"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) | |
| 141 | + | try: | |
| 142 | + | import select | |
| 143 | + | if not select.select([bridge.stdout], [], [], 1)[0]: | |
| 144 | + | raise RuntimeError("observation discovery timed out") | |
| 145 | + | discovery = json.loads(bridge.stdout.readline()) | |
| 146 | + | request = {"version": 1, "id": 1, "op": "capture", "attachment": discovery["result"]["attachment"]} | |
| 147 | + | output, error = bridge.communicate(json.dumps(request) + "\n", timeout=1) | |
| 148 | + | envelopes = [json.loads(line) for line in output.splitlines()] | |
| 149 | + | if not envelopes or "error" in envelopes[-1]: | |
| 150 | + | raise RuntimeError("observation capture failed: " + str(envelopes) + error) | |
| 151 | + | return envelopes[-1]["result"] | |
| 152 | + | finally: | |
| 153 | + | if bridge.poll() is None: | |
| 154 | + | bridge.kill() | |
| 155 | + | bridge.communicate() | |
| 156 | + | compositor.stdin.write("sleep 1s\nkey Return\n") | |
| 157 | + | compositor.stdin.flush() | |
| 158 | + | deadline = time.monotonic() + 30 | |
| 159 | + | while True: | |
| 160 | + | try: | |
| 161 | + | state = capture() | |
| 162 | + | if state.get("context", {}).get("status") == "attached" and state.get("context", {}).get("session", {}).get("session_name") == "resilience": | |
| 163 | + | break | |
| 164 | + | except (subprocess.SubprocessError, ValueError, RuntimeError) as error: | |
| 165 | + | (artifacts / "capture-error.txt").write_text(str(error) + "\n" + str(getattr(error, "stderr", ""))) | |
| 166 | + | if time.monotonic() > deadline: | |
| 167 | + | diagnosis = subprocess.run(["docker", "exec", client, "sh", "-c", "ps -ef; ls -l /home/demo/visual/ui.sock"], capture_output=True, text=True, timeout=5) | |
| 168 | + | (artifacts / "client-processes.txt").write_text(diagnosis.stdout + diagnosis.stderr) | |
| 169 | + | (artifacts / "relay-stats.json").write_text(json.dumps(control("stats"))) | |
| 170 | + | (artifacts / "last-capture.json").write_text(json.dumps(locals().get("state"))) | |
| 171 | + | raise RuntimeError("visual client did not attach") | |
| 172 | + | time.sleep(0.1) | |
| 173 | + | (artifacts / "initial.json").write_text(json.dumps(state)) | |
| 174 | + | def key(command): | |
| 175 | + | compositor.stdin.write(command + "\n") | |
| 176 | + | compositor.stdin.flush() | |
| 177 | + | def responsive(label): | |
| 178 | + | latest = shots / "latest.png" | |
| 179 | + | before = artifacts / (label + "-before.png") | |
| 180 | + | shutil.copyfile(latest, before) | |
| 181 | + | start = time.monotonic() | |
| 182 | + | stamp = latest.stat().st_mtime_ns | |
| 183 | + | key("key Alt+space") | |
| 184 | + | key("type SSP") | |
| 185 | + | key("key Return") | |
| 186 | + | while time.monotonic() - start < 2: | |
| 187 | + | if latest.stat().st_mtime_ns != stamp: | |
| 188 | + | after = artifacts / (label + "-after.png") | |
| 189 | + | shutil.copyfile(latest, after) | |
| 190 | + | changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True) | |
| 191 | + | observed = capture() | |
| 192 | + | (artifacts / (label + "-observed.json")).write_text(json.dumps(observed)) | |
| 193 | + | if changed.returncode == 0 and "Sessions" in observed.get("text", "") and "Esc close" in observed.get("text", ""): | |
| 194 | + | key("key Escape") | |
| 195 | + | return {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout), "context": observed["context"]} | |
| 196 | + | time.sleep(0.05) | |
| 197 | + | raise RuntimeError(label + ": local picker failed 2s response gate") | |
| 198 | + | evidence = {"healthy": responsive("healthy")} | |
| 199 | + | time.sleep(0.5) | |
| 200 | + | key("key Alt+space") | |
| 201 | + | key("type SSP") | |
| 202 | + | key("key Return") | |
| 203 | + | deadline = time.monotonic() + 5 | |
| 204 | + | while "Sessions" not in capture().get("text", ""): | |
| 205 | + | if time.monotonic() > deadline: | |
| 206 | + | raise RuntimeError("picker did not open before blackout") | |
| 207 | + | time.sleep(0.1) | |
| 208 | + | before = artifacts / "blackout-before.png" | |
| 209 | + | shutil.copyfile(shots / "latest.png", before) | |
| 210 | + | assert "Sessions" in capture().get("text", ""), "picker was not open before blackout" | |
| 211 | + | impairment = os.environ.get("VEV_RESILIENCE_LINK", "both") | |
| 212 | + | if impairment not in ("both", "udp", "tcp"): | |
| 213 | + | raise ValueError("VEV_RESILIENCE_LINK must be both, udp or tcp") | |
| 214 | + | control("blackout", udp=impairment != "tcp", tcp=impairment != "udp", active=True) | |
| 215 | + | start = time.monotonic() | |
| 216 | + | key("type /resilience") | |
| 217 | + | deadline = start + 2 | |
| 218 | + | while time.monotonic() < deadline: | |
| 219 | + | after = artifacts / "blackout-after.png" | |
| 220 | + | shutil.copyfile(shots / "latest.png", after) | |
| 221 | + | changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True) | |
| 222 | + | observed = capture() | |
| 223 | + | if changed.returncode == 0 and "1 matches" in observed.get("text", ""): | |
| 224 | + | evidence["blackout"] = {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout)} | |
| 225 | + | break | |
| 226 | + | time.sleep(0.05) | |
| 227 | + | else: | |
| 228 | + | raise RuntimeError("open picker search froze during blackout") | |
| 229 | + | time.sleep(3) | |
| 230 | + | control("blackout", udp=True, tcp=True, active=False) | |
| 231 | + | key("key Escape") | |
| 232 | + | time.sleep(0.2) | |
| 233 | + | key("key Escape") | |
| 234 | + | deadline = time.monotonic() + 5 | |
| 235 | + | while "Sessions" in capture().get("text", ""): | |
| 236 | + | if time.monotonic() > deadline: | |
| 237 | + | raise RuntimeError("picker did not close after restoration") | |
| 238 | + | time.sleep(0.1) | |
| 239 | + | key("key Ctrl+c") | |
| 240 | + | time.sleep(1) | |
| 241 | + | initial_session = state["context"]["session"] | |
| 242 | + | key("type printf 'RESILIENCE_RECOVERED\\n'") | |
| 243 | + | key("key Return") | |
| 244 | + | deadline = time.monotonic() + 10 | |
| 245 | + | while time.monotonic() < deadline: | |
| 246 | + | recovered = capture() | |
| 247 | + | if "\nRESILIENCE_RECOVERED" in recovered.get("text", "") and recovered["context"]["session"] == initial_session: | |
| 248 | + | evidence["recovered"] = recovered["context"] | |
| 249 | + | break | |
| 250 | + | time.sleep(0.1) | |
| 251 | + | else: | |
| 252 | + | raise RuntimeError("session did not recover after blackout") | |
| 253 | + | # Negative control: compositor can still render while client is | |
| 254 | + | # stopped. No client observation is allowed to satisfy the gate. | |
| 255 | + | pid_result = docker("exec", client, "pgrep", "-f", "^vev attach", capture_output=True, text=True) | |
| 256 | + | pid = pid_result.stdout.strip().splitlines()[0] | |
| 257 | + | docker("exec", client, "kill", "-STOP", pid) | |
| 258 | + | frozen = artifacts / "frozen.png" | |
| 259 | + | shutil.copyfile(shots / "latest.png", frozen) | |
| 260 | + | try: | |
| 261 | + | key("key Alt+space") | |
| 262 | + | time.sleep(0.3) | |
| 263 | + | check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True) | |
| 264 | + | assert check.returncode == 1, "visual gate accepted a stopped client" | |
| 265 | + | evidence["negative_control"] = "stopped client rejected" | |
| 266 | + | finally: | |
| 267 | + | docker("exec", client, "kill", "-CONT", pid) | |
| 268 | + | if os.environ.get("VEV_RESILIENCE_LONG") == "1": | |
| 269 | + | # The negative control's Alt+space was buffered while the | |
| 270 | + | # client was stopped and opens the palette once it resumes. | |
| 271 | + | # Close it on observed state only: a blind extra Escape would | |
| 272 | + | # reach the shell as a lone ESC, which readline keeps as a | |
| 273 | + | # Meta prefix and uses to eat the first replayed key. | |
| 274 | + | deadline = time.monotonic() + 5 | |
| 275 | + | while "Commands" not in capture().get("text", ""): | |
| 276 | + | if time.monotonic() > deadline: | |
| 277 | + | raise RuntimeError("palette did not open after the stopped client resumed") | |
| 278 | + | time.sleep(0.1) | |
| 279 | + | key("key Escape") | |
| 280 | + | deadline = time.monotonic() + 5 | |
| 281 | + | while "Commands" in capture().get("text", ""): | |
| 282 | + | if time.monotonic() > deadline: | |
| 283 | + | raise RuntimeError("palette did not close before the long outage") | |
| 284 | + | time.sleep(0.1) | |
| 285 | + | time.sleep(0.5) | |
| 286 | + | control("blackout", udp=True, tcp=True, active=True) | |
| 287 | + | if transport == "ssh": | |
| 288 | + | control("disconnect") | |
| 289 | + | deadline = time.monotonic() + 90 | |
| 290 | + | while time.monotonic() < deadline: | |
| 291 | + | observed = capture() | |
| 292 | + | if observed.get("context", {}).get("status") == "connecting": | |
| 293 | + | break | |
| 294 | + | time.sleep(0.2) | |
| 295 | + | else: | |
| 296 | + | raise RuntimeError("long outage did not enter resume") | |
| 297 | + | if os.environ.get("VEV_RESILIENCE_RESUME") == "1": | |
| 298 | + | key("type echo HELD_ONE; echo HELD_TWO") | |
| 299 | + | key("key Return") | |
| 300 | + | # Keep the route down until the compositor has delivered | |
| 301 | + | # the complete physical input script to the resume owner. | |
| 302 | + | time.sleep(2) | |
| 303 | + | control("blackout", udp=True, tcp=True, active=False) | |
| 304 | + | deadline = time.monotonic() + 30 | |
| 305 | + | while time.monotonic() < deadline: | |
| 306 | + | resumed = capture() | |
| 307 | + | text = resumed.get("text", "") | |
| 308 | + | if "\nHELD_ONE" in text and "\nHELD_TWO" in text and resumed.get("context", {}).get("session") == initial_session: | |
| 309 | + | assert text.index("\nHELD_ONE") < text.index("\nHELD_TWO"), "held input reordered" | |
| 310 | + | # The echoed command line proves the first held | |
| 311 | + | # byte was not swallowed before the shell saw it. | |
| 312 | + | assert "$ echo HELD_ONE; echo HELD_TWO" in text, "held input replayed incompletely" | |
| 313 | + | evidence["resumed"] = resumed["context"] | |
| 314 | + | break | |
| 315 | + | time.sleep(0.1) | |
| 316 | + | else: | |
| 317 | + | raise RuntimeError("long outage did not replay held input") | |
| 318 | + | else: | |
| 319 | + | start = time.monotonic() | |
| 320 | + | cancel_key = os.environ.get("VEV_RESILIENCE_CANCEL", "Ctrl+c") | |
| 321 | + | if cancel_key not in ("Ctrl+c", "Escape"): | |
| 322 | + | raise ValueError("cancel must be Ctrl+c or Escape") | |
| 323 | + | key("key " + cancel_key) | |
| 324 | + | deadline = start + 2 | |
| 325 | + | while time.monotonic() < deadline: | |
| 326 | + | observed = capture() | |
| 327 | + | if observed.get("context", {}).get("status") == "picker": | |
| 328 | + | evidence["resume_cancel"] = {"elapsed": time.monotonic()-start} | |
| 329 | + | break | |
| 330 | + | time.sleep(0.05) | |
| 331 | + | else: | |
| 332 | + | raise RuntimeError(cancel_key + " did not cancel resume") | |
| 333 | + | control("blackout", udp=True, tcp=True, active=False) | |
| 334 | + | evidence["stats"] = control("stats") | |
| 335 | + | if transport == "quic": | |
| 336 | + | assert evidence["stats"]["udp"], "QUIC bypassed impairment relay" | |
| 337 | + | assert evidence["stats"]["tcp_bytes"] > 0, "SSH bypassed impairment relay" | |
| 338 | + | (artifacts / "evidence.json").write_text(json.dumps(evidence, indent=2)) | |
| 339 | + | control("stop") | |
| 340 | + | finally: | |
| 341 | + | for process in reversed(processes): | |
| 342 | + | if process.poll() is None: | |
| 343 | + | process.terminate() | |
| 344 | + | try: | |
| 345 | + | process.wait(timeout=5) | |
| 346 | + | except subprocess.TimeoutExpired: | |
| 347 | + | process.kill() | |
| 348 | + | process.wait() | |
| 349 | + | for container in containers: | |
| 350 | + | role = container.rsplit("-", 1)[-1] | |
| 351 | + | for filename in ("vev-client.log", "vev-daemon.log", "broker/log/vev-broker.log"): | |
| 352 | + | subprocess.run(["docker", "cp", container + ":/home/demo/.local/state/vev/" + filename, str(artifacts / (role + "-" + filename.replace("/", "-")))], timeout=30, capture_output=True) | |
| 353 | + | subprocess.run(["docker", "rm", "-f", container], timeout=30, capture_output=True) | |
| 354 | + | if network: | |
| 355 | + | subprocess.run(["docker", "network", "rm", name], timeout=30, capture_output=True) | |
| 356 | + | subprocess.run(["docker", "image", "rm", name], timeout=30, capture_output=True) | |
| 357 | + | print("artifacts:", artifacts) | |
| 358 | + | ||
| 359 | + | ||
| 360 | + | if __name__ == "__main__": | |
| 361 | + | main() | |
| @@ -0,0 +1,361 @@ | |||||
| 1 | + | #!/usr/bin/env python3 | |||
| 2 | + | """Disposable dual-transport fixture and physical Wayland input acceptance.""" | |||
| 3 | + | import importlib.util | |||
| 4 | + | import json | |||
| 5 | + | import os | |||
| 6 | + | from pathlib import Path | |||
| 7 | + | import shutil | |||
| 8 | + | import signal | |||
| 9 | + | import subprocess | |||
| 10 | + | import tempfile | |||
| 11 | + | import time | |||
| 12 | + | import uuid | |||
| 13 | + | ||||
| 14 | + | ROOT = Path(__file__).resolve().parents[2] | |||
| 15 | + | ||||
| 16 | + | ||||
| 17 | + | def run(*args, **kwargs): | |||
| 18 | + | return subprocess.run(args, check=True, timeout=kwargs.pop("timeout", 60), **kwargs) | |||
| 19 | + | ||||
| 20 | + | ||||
| 21 | + | def docker(*args, **kwargs): | |||
| 22 | + | return run("docker", *args, **kwargs) | |||
| 23 | + | ||||
| 24 | + | ||||
| 25 | + | def main(): | |||
| 26 | + | name = "vev-resilience-" + uuid.uuid4().hex[:10] | |||
| 27 | + | artifacts = Path(tempfile.mkdtemp(prefix="vev-resilience-artifacts-")) | |||
| 28 | + | processes = [] | |||
| 29 | + | containers = [] | |||
| 30 | + | network = False | |||
| 31 | + | def interrupt(signum, frame): | |||
| 32 | + | raise KeyboardInterrupt("fixture interrupted") | |||
| 33 | + | signal.signal(signal.SIGTERM, interrupt) | |||
| 34 | + | with tempfile.TemporaryDirectory(prefix="vev-resilience-private-") as directory: | |||
| 35 | + | private = Path(directory) | |||
| 36 | + | runtime = private / "runtime" | |||
| 37 | + | runtime.mkdir(mode=0o700) | |||
| 38 | + | try: | |||
| 39 | + | for tool in ("docker", "foot", "ssh-keygen", "go"): | |||
| 40 | + | if shutil.which(tool) is None: | |||
| 41 | + | raise RuntimeError("missing prerequisite: " + tool) | |||
| 42 | + | run("go", "build", "-o", str(ROOT / "build/vev"), ".", cwd=ROOT) | |||
| 43 | + | for helper in ("relay", "pixels"): | |||
| 44 | + | run("go", "build", "-o", str(ROOT / f"build/resilience-{helper}"), f"./scripts/remote-resilience-harness/{helper}", cwd=ROOT) | |||
| 45 | + | nefer = os.environ.get("NEFERWL_BIN") | |||
| 46 | + | if not nefer: | |||
| 47 | + | nefer = str(private / "neferwl") | |||
| 48 | + | common = run("git", "rev-parse", "--path-format=absolute", "--git-common-dir", cwd=ROOT, capture_output=True, text=True).stdout.strip() | |||
| 49 | + | source = Path(os.environ.get("NEFERWL_SOURCE", str(Path(common).parent.parent / "neferwl"))) | |||
| 50 | + | run("go", "build", "-o", nefer, "./cmd/neferwl", cwd=source, env={**os.environ, "CGO_ENABLED": "0"}) | |||
| 51 | + | docker("build", "-f", str(ROOT / "scripts/demo/Dockerfile"), "-t", name, str(ROOT), timeout=600) | |||
| 52 | + | docker("network", "create", name) | |||
| 53 | + | network = True | |||
| 54 | + | for role in ("client", "remote"): | |||
| 55 | + | container = name + "-" + role | |||
| 56 | + | docker("run", "-d", "--name", container, "--network", name, "--network-alias", "upstream" if role == "remote" else "remote", "--entrypoint", "sleep", name, "infinity") | |||
| 57 | + | containers.append(container) | |||
| 58 | + | client, remote = containers | |||
| 59 | + | for key in ("client", "host"): | |||
| 60 | + | run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f", str(private / key)) | |||
| 61 | + | docker("cp", str(private / "host"), remote + ":/etc/ssh/ssh_host_ed25519_key") | |||
| 62 | + | docker("cp", str(private / "host.pub"), remote + ":/etc/ssh/ssh_host_ed25519_key.pub") | |||
| 63 | + | docker("cp", str(private / "client.pub"), remote + ":/home/demo/.ssh/authorized_keys") | |||
| 64 | + | docker("exec", "--user", "root", remote, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*; chmod 600 /etc/ssh/ssh_host_ed25519_key /home/demo/.ssh/authorized_keys; chown demo:demo /home/demo/.ssh/authorized_keys; mkdir -p /run/sshd; /usr/sbin/sshd") | |||
| 65 | + | docker("exec", "--user", "root", client, "sh", "-c", "rm -f /home/demo/.ssh/id_ed25519*") | |||
| 66 | + | docker("cp", str(private / "client"), client + ":/home/demo/.ssh/id_ed25519") | |||
| 67 | + | public = (private / "host.pub").read_text().split() | |||
| 68 | + | (private / "known_hosts").write_text("remote " + " ".join(public[:2]) + "\n") | |||
| 69 | + | (private / "config").write_text("Host remote\n HostName remote\n Port 2222\n User demo\n IdentityFile ~/.ssh/id_ed25519\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n HostKeyAlias remote\n UserKnownHostsFile ~/.ssh/known_hosts\n") | |||
| 70 | + | for file in ("known_hosts", "config"): | |||
| 71 | + | docker("cp", str(private / file), client + ":/home/demo/.ssh/" + file) | |||
| 72 | + | docker("cp", str(ROOT / "build/resilience-relay"), client + ":/tmp/relay") | |||
| 73 | + | docker("cp", str(ROOT / "scripts/remote-resilience-harness/ssh.py"), client + ":/tmp/ssh") | |||
| 74 | + | docker("exec", "--user", "root", client, "sh", "-c", "chown demo:demo /home/demo/.ssh/*; chmod 600 /home/demo/.ssh/*; chmod 755 /tmp/ssh; install -d -m 700 -o demo -g demo /home/demo/relay; mkdir -p /tmp/bin; ln -s /tmp/ssh /tmp/bin/ssh; mv /usr/bin/ssh /usr/bin/ssh-real; ln -s /tmp/ssh /usr/bin/ssh") | |||
| 75 | + | transport = os.environ.get("VEV_RESILIENCE_TRANSPORT", "quic") | |||
| 76 | + | if transport not in ("quic", "ssh"): | |||
| 77 | + | raise ValueError("VEV_RESILIENCE_TRANSPORT must be quic or ssh") | |||
| 78 | + | relay_args = ["-degraded"] if os.environ.get("VEV_RESILIENCE_DEGRADED") == "1" else [] | |||
| 79 | + | relay = subprocess.Popen(["docker", "exec", client, "/tmp/relay", "-remote", "upstream", "-control", "/home/demo/relay/control.sock", *relay_args]) | |||
| 80 | + | processes.append(relay) | |||
| 81 | + | control_id = 0 | |||
| 82 | + | def control(op, **fields): | |||
| 83 | + | nonlocal control_id | |||
| 84 | + | control_id += 1 | |||
| 85 | + | request = {"id": control_id, "op": op, **fields} | |||
| 86 | + | code = "import socket,json; s=socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect('/home/demo/relay/control.sock'); s.sendall(" + repr(json.dumps(request).encode() + b"\n") + "); print(s.makefile().readline())" | |||
| 87 | + | result = docker("exec", client, "python3", "-c", code, capture_output=True, text=True) | |||
| 88 | + | reply = json.loads(result.stdout) | |||
| 89 | + | assert reply["id"] == control_id and "error" not in reply, reply | |||
| 90 | + | return reply["result"] | |||
| 91 | + | # Fixture image must explicitly supply Python rather than using host tools. | |||
| 92 | + | docker("exec", "--user", "root", client, "sh", "-c", "command -v python3", capture_output=True) | |||
| 93 | + | deadline = time.monotonic() + 5 | |||
| 94 | + | while True: | |||
| 95 | + | ready = subprocess.run(["docker", "exec", client, "test", "-S", "/home/demo/relay/control.sock"], capture_output=True, timeout=3) | |||
| 96 | + | if ready.returncode == 0: | |||
| 97 | + | break | |||
| 98 | + | if time.monotonic() > deadline: | |||
| 99 | + | raise RuntimeError("relay control socket did not become ready") | |||
| 100 | + | time.sleep(0.05) | |||
| 101 | + | probe = subprocess.run(["docker", "exec", client, "/usr/bin/ssh", "-v", "remote", "true"], capture_output=True, text=True, timeout=10) | |||
| 102 | + | if probe.returncode: | |||
| 103 | + | (artifacts / "ssh-probe.log").write_text(probe.stderr) | |||
| 104 | + | raise RuntimeError("fixture SSH authentication failed") | |||
| 105 | + | env_args = ["-e", "PATH=/tmp/bin:/usr/local/bin:/usr/bin", "-e", "VEV_LOG=debug", "-e", "SSH_AUTH_SOCK="] | |||
| 106 | + | spec = importlib.util.spec_from_file_location("acceptance", ROOT / "scripts/remote-picker-harness/acceptance.py") | |||
| 107 | + | acceptance = importlib.util.module_from_spec(spec) | |||
| 108 | + | spec.loader.exec_module(acceptance) | |||
| 109 | + | fixture = acceptance.Driver(remote, ["--session", "resilience"]) | |||
| 110 | + | fixture.close() | |||
| 111 | + | docker("exec", *env_args, client, "vev", "host", "add", "--transport", transport, "remote") | |||
| 112 | + | # Wait for the broker publication before resolving direct attach. | |||
| 113 | + | deadline = time.monotonic() + 30 | |||
| 114 | + | while True: | |||
| 115 | + | inventory = docker("exec", *env_args, client, "vev", "ls", "remote", capture_output=True, text=True) | |||
| 116 | + | if "resilience" in inventory.stdout: | |||
| 117 | + | break | |||
| 118 | + | (artifacts / "inventory.txt").write_text(inventory.stdout + inventory.stderr) | |||
| 119 | + | if time.monotonic() > deadline: | |||
| 120 | + | raise RuntimeError("remote fixture was not published") | |||
| 121 | + | time.sleep(0.2) | |||
| 122 | + | shots = artifacts / "shots" | |||
| 123 | + | shots.mkdir() | |||
| 124 | + | docker_endpoint = run("docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}", capture_output=True, text=True).stdout.strip() | |||
| 125 | + | env = {**os.environ, "DOCKER_HOST": docker_endpoint, "XDG_RUNTIME_DIR": str(runtime), "XDG_CONFIG_HOME": str(private / "config-home"), "XDG_STATE_HOME": str(private / "state"), "HOME": str(private)} | |||
| 126 | + | env["TERMINAL"] = "foot --config=/dev/null --override=cursor.blink=no --log-level=info --hold -- docker --host=" + docker_endpoint + " exec -it " + " ".join(env_args) + " " + client + " vev attach demo@remote:resilience --ui-observe --ui-socket /home/demo/visual/ui.sock" | |||
| 127 | + | log = (artifacts / "compositor.log").open("w") | |||
| 128 | + | compositor = subprocess.Popen([nefer, "--backend=headless", "--size=1280x720", "--input=-", "--screenshot", str(shots), "--timeout", "180s"], stdin=subprocess.PIPE, stdout=log, stderr=log, text=True, env=env) | |||
| 129 | + | processes.append(compositor) | |||
| 130 | + | deadline = time.monotonic() + 15 | |||
| 131 | + | while not list(runtime.glob("wayland-*")): | |||
| 132 | + | if compositor.poll() is not None or time.monotonic() > deadline: | |||
| 133 | + | raise RuntimeError("headless compositor did not create socket") | |||
| 134 | + | time.sleep(0.05) | |||
| 135 | + | sockets = [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")] | |||
| 136 | + | env["WAYLAND_DISPLAY"] = str(sockets[0]) | |||
| 137 | + | env.pop("WAYLAND_SOCKET", None) | |||
| 138 | + | env.pop("DISPLAY", None) | |||
| 139 | + | def capture(): | |||
| 140 | + | bridge = subprocess.Popen(["docker", "exec", "-i", client, "vev", "--ui-driver", "--socket", "/home/demo/visual/ui.sock"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) | |||
| 141 | + | try: | |||
| 142 | + | import select | |||
| 143 | + | if not select.select([bridge.stdout], [], [], 1)[0]: | |||
| 144 | + | raise RuntimeError("observation discovery timed out") | |||
| 145 | + | discovery = json.loads(bridge.stdout.readline()) | |||
| 146 | + | request = {"version": 1, "id": 1, "op": "capture", "attachment": discovery["result"]["attachment"]} | |||
| 147 | + | output, error = bridge.communicate(json.dumps(request) + "\n", timeout=1) | |||
| 148 | + | envelopes = [json.loads(line) for line in output.splitlines()] | |||
| 149 | + | if not envelopes or "error" in envelopes[-1]: | |||
| 150 | + | raise RuntimeError("observation capture failed: " + str(envelopes) + error) | |||
| 151 | + | return envelopes[-1]["result"] | |||
| 152 | + | finally: | |||
| 153 | + | if bridge.poll() is None: | |||
| 154 | + | bridge.kill() | |||
| 155 | + | bridge.communicate() | |||
| 156 | + | compositor.stdin.write("sleep 1s\nkey Return\n") | |||
| 157 | + | compositor.stdin.flush() | |||
| 158 | + | deadline = time.monotonic() + 30 | |||
| 159 | + | while True: | |||
| 160 | + | try: | |||
| 161 | + | state = capture() | |||
| 162 | + | if state.get("context", {}).get("status") == "attached" and state.get("context", {}).get("session", {}).get("session_name") == "resilience": | |||
| 163 | + | break | |||
| 164 | + | except (subprocess.SubprocessError, ValueError, RuntimeError) as error: | |||
| 165 | + | (artifacts / "capture-error.txt").write_text(str(error) + "\n" + str(getattr(error, "stderr", ""))) | |||
| 166 | + | if time.monotonic() > deadline: | |||
| 167 | + | diagnosis = subprocess.run(["docker", "exec", client, "sh", "-c", "ps -ef; ls -l /home/demo/visual/ui.sock"], capture_output=True, text=True, timeout=5) | |||
| 168 | + | (artifacts / "client-processes.txt").write_text(diagnosis.stdout + diagnosis.stderr) | |||
| 169 | + | (artifacts / "relay-stats.json").write_text(json.dumps(control("stats"))) | |||
| 170 | + | (artifacts / "last-capture.json").write_text(json.dumps(locals().get("state"))) | |||
| 171 | + | raise RuntimeError("visual client did not attach") | |||
| 172 | + | time.sleep(0.1) | |||
| 173 | + | (artifacts / "initial.json").write_text(json.dumps(state)) | |||
| 174 | + | def key(command): | |||
| 175 | + | compositor.stdin.write(command + "\n") | |||
| 176 | + | compositor.stdin.flush() | |||
| 177 | + | def responsive(label): | |||
| 178 | + | latest = shots / "latest.png" | |||
| 179 | + | before = artifacts / (label + "-before.png") | |||
| 180 | + | shutil.copyfile(latest, before) | |||
| 181 | + | start = time.monotonic() | |||
| 182 | + | stamp = latest.stat().st_mtime_ns | |||
| 183 | + | key("key Alt+space") | |||
| 184 | + | key("type SSP") | |||
| 185 | + | key("key Return") | |||
| 186 | + | while time.monotonic() - start < 2: | |||
| 187 | + | if latest.stat().st_mtime_ns != stamp: | |||
| 188 | + | after = artifacts / (label + "-after.png") | |||
| 189 | + | shutil.copyfile(latest, after) | |||
| 190 | + | changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True) | |||
| 191 | + | observed = capture() | |||
| 192 | + | (artifacts / (label + "-observed.json")).write_text(json.dumps(observed)) | |||
| 193 | + | if changed.returncode == 0 and "Sessions" in observed.get("text", "") and "Esc close" in observed.get("text", ""): | |||
| 194 | + | key("key Escape") | |||
| 195 | + | return {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout), "context": observed["context"]} | |||
| 196 | + | time.sleep(0.05) | |||
| 197 | + | raise RuntimeError(label + ": local picker failed 2s response gate") | |||
| 198 | + | evidence = {"healthy": responsive("healthy")} | |||
| 199 | + | time.sleep(0.5) | |||
| 200 | + | key("key Alt+space") | |||
| 201 | + | key("type SSP") | |||
| 202 | + | key("key Return") | |||
| 203 | + | deadline = time.monotonic() + 5 | |||
| 204 | + | while "Sessions" not in capture().get("text", ""): | |||
| 205 | + | if time.monotonic() > deadline: | |||
| 206 | + | raise RuntimeError("picker did not open before blackout") | |||
| 207 | + | time.sleep(0.1) | |||
| 208 | + | before = artifacts / "blackout-before.png" | |||
| 209 | + | shutil.copyfile(shots / "latest.png", before) | |||
| 210 | + | assert "Sessions" in capture().get("text", ""), "picker was not open before blackout" | |||
| 211 | + | impairment = os.environ.get("VEV_RESILIENCE_LINK", "both") | |||
| 212 | + | if impairment not in ("both", "udp", "tcp"): | |||
| 213 | + | raise ValueError("VEV_RESILIENCE_LINK must be both, udp or tcp") | |||
| 214 | + | control("blackout", udp=impairment != "tcp", tcp=impairment != "udp", active=True) | |||
| 215 | + | start = time.monotonic() | |||
| 216 | + | key("type /resilience") | |||
| 217 | + | deadline = start + 2 | |||
| 218 | + | while time.monotonic() < deadline: | |||
| 219 | + | after = artifacts / "blackout-after.png" | |||
| 220 | + | shutil.copyfile(shots / "latest.png", after) | |||
| 221 | + | changed = subprocess.run([str(ROOT / "build/resilience-pixels"), str(before), str(after)], capture_output=True, text=True) | |||
| 222 | + | observed = capture() | |||
| 223 | + | if changed.returncode == 0 and "1 matches" in observed.get("text", ""): | |||
| 224 | + | evidence["blackout"] = {"elapsed": time.monotonic()-start, "pixels": json.loads(changed.stdout)} | |||
| 225 | + | break | |||
| 226 | + | time.sleep(0.05) | |||
| 227 | + | else: | |||
| 228 | + | raise RuntimeError("open picker search froze during blackout") | |||
| 229 | + | time.sleep(3) | |||
| 230 | + | control("blackout", udp=True, tcp=True, active=False) | |||
| 231 | + | key("key Escape") | |||
| 232 | + | time.sleep(0.2) | |||
| 233 | + | key("key Escape") | |||
| 234 | + | deadline = time.monotonic() + 5 | |||
| 235 | + | while "Sessions" in capture().get("text", ""): | |||
| 236 | + | if time.monotonic() > deadline: | |||
| 237 | + | raise RuntimeError("picker did not close after restoration") | |||
| 238 | + | time.sleep(0.1) | |||
| 239 | + | key("key Ctrl+c") | |||
| 240 | + | time.sleep(1) | |||
| 241 | + | initial_session = state["context"]["session"] | |||
| 242 | + | key("type printf 'RESILIENCE_RECOVERED\\n'") | |||
| 243 | + | key("key Return") | |||
| 244 | + | deadline = time.monotonic() + 10 | |||
| 245 | + | while time.monotonic() < deadline: | |||
| 246 | + | recovered = capture() | |||
| 247 | + | if "\nRESILIENCE_RECOVERED" in recovered.get("text", "") and recovered["context"]["session"] == initial_session: | |||
| 248 | + | evidence["recovered"] = recovered["context"] | |||
| 249 | + | break | |||
| 250 | + | time.sleep(0.1) | |||
| 251 | + | else: | |||
| 252 | + | raise RuntimeError("session did not recover after blackout") | |||
| 253 | + | # Negative control: compositor can still render while client is | |||
| 254 | + | # stopped. No client observation is allowed to satisfy the gate. | |||
| 255 | + | pid_result = docker("exec", client, "pgrep", "-f", "^vev attach", capture_output=True, text=True) | |||
| 256 | + | pid = pid_result.stdout.strip().splitlines()[0] | |||
| 257 | + | docker("exec", client, "kill", "-STOP", pid) | |||
| 258 | + | frozen = artifacts / "frozen.png" | |||
| 259 | + | shutil.copyfile(shots / "latest.png", frozen) | |||
| 260 | + | try: | |||
| 261 | + | key("key Alt+space") | |||
| 262 | + | time.sleep(0.3) | |||
| 263 | + | check = subprocess.run([str(ROOT / "build/resilience-pixels"), str(frozen), str(shots / "latest.png")], capture_output=True, text=True) | |||
| 264 | + | assert check.returncode == 1, "visual gate accepted a stopped client" | |||
| 265 | + | evidence["negative_control"] = "stopped client rejected" | |||
| 266 | + | finally: | |||
| 267 | + | docker("exec", client, "kill", "-CONT", pid) | |||
| 268 | + | if os.environ.get("VEV_RESILIENCE_LONG") == "1": | |||
| 269 | + | # The negative control's Alt+space was buffered while the | |||
| 270 | + | # client was stopped and opens the palette once it resumes. | |||
| 271 | + | # Close it on observed state only: a blind extra Escape would | |||
| 272 | + | # reach the shell as a lone ESC, which readline keeps as a | |||
| 273 | + | # Meta prefix and uses to eat the first replayed key. | |||
| 274 | + | deadline = time.monotonic() + 5 | |||
| 275 | + | while "Commands" not in capture().get("text", ""): | |||
| 276 | + | if time.monotonic() > deadline: | |||
| 277 | + | raise RuntimeError("palette did not open after the stopped client resumed") | |||
| 278 | + | time.sleep(0.1) | |||
| 279 | + | key("key Escape") | |||
| 280 | + | deadline = time.monotonic() + 5 | |||
| 281 | + | while "Commands" in capture().get("text", ""): | |||
| 282 | + | if time.monotonic() > deadline: | |||
| 283 | + | raise RuntimeError("palette did not close before the long outage") | |||
| 284 | + | time.sleep(0.1) | |||
| 285 | + | time.sleep(0.5) | |||
| 286 | + | control("blackout", udp=True, tcp=True, active=True) | |||
| 287 | + | if transport == "ssh": | |||
| 288 | + | control("disconnect") | |||
| 289 | + | deadline = time.monotonic() + 90 | |||
| 290 | + | while time.monotonic() < deadline: | |||
| 291 | + | observed = capture() | |||
| 292 | + | if observed.get("context", {}).get("status") == "connecting": | |||
| 293 | + | break | |||
| 294 | + | time.sleep(0.2) | |||
| 295 | + | else: | |||
| 296 | + | raise RuntimeError("long outage did not enter resume") | |||
| 297 | + | if os.environ.get("VEV_RESILIENCE_RESUME") == "1": | |||
| 298 | + | key("type echo HELD_ONE; echo HELD_TWO") | |||
| 299 | + | key("key Return") | |||
| 300 | + | # Keep the route down until the compositor has delivered | |||
| 301 | + | # the complete physical input script to the resume owner. | |||
| 302 | + | time.sleep(2) | |||
| 303 | + | control("blackout", udp=True, tcp=True, active=False) | |||
| 304 | + | deadline = time.monotonic() + 30 | |||
| 305 | + | while time.monotonic() < deadline: | |||
| 306 | + | resumed = capture() | |||
| 307 | + | text = resumed.get("text", "") | |||
| 308 | + | if "\nHELD_ONE" in text and "\nHELD_TWO" in text and resumed.get("context", {}).get("session") == initial_session: | |||
| 309 | + | assert text.index("\nHELD_ONE") < text.index("\nHELD_TWO"), "held input reordered" | |||
| 310 | + | # The echoed command line proves the first held | |||
| 311 | + | # byte was not swallowed before the shell saw it. | |||
| 312 | + | assert "$ echo HELD_ONE; echo HELD_TWO" in text, "held input replayed incompletely" | |||
| 313 | + | evidence["resumed"] = resumed["context"] | |||
| 314 | + | break | |||
| 315 | + | time.sleep(0.1) | |||
| 316 | + | else: | |||
| 317 | + | raise RuntimeError("long outage did not replay held input") | |||
| 318 | + | else: | |||
| 319 | + | start = time.monotonic() | |||
| 320 | + | cancel_key = os.environ.get("VEV_RESILIENCE_CANCEL", "Ctrl+c") | |||
| 321 | + | if cancel_key not in ("Ctrl+c", "Escape"): | |||
| 322 | + | raise ValueError("cancel must be Ctrl+c or Escape") | |||
| 323 | + | key("key " + cancel_key) | |||
| 324 | + | deadline = start + 2 | |||
| 325 | + | while time.monotonic() < deadline: | |||
| 326 | + | observed = capture() | |||
| 327 | + | if observed.get("context", {}).get("status") == "picker": | |||
| 328 | + | evidence["resume_cancel"] = {"elapsed": time.monotonic()-start} | |||
| 329 | + | break | |||
| 330 | + | time.sleep(0.05) | |||
| 331 | + | else: | |||
| 332 | + | raise RuntimeError(cancel_key + " did not cancel resume") | |||
| 333 | + | control("blackout", udp=True, tcp=True, active=False) | |||
| 334 | + | evidence["stats"] = control("stats") | |||
| 335 | + | if transport == "quic": | |||
| 336 | + | assert evidence["stats"]["udp"], "QUIC bypassed impairment relay" | |||
| 337 | + | assert evidence["stats"]["tcp_bytes"] > 0, "SSH bypassed impairment relay" | |||
| 338 | + | (artifacts / "evidence.json").write_text(json.dumps(evidence, indent=2)) | |||
| 339 | + | control("stop") | |||
| 340 | + | finally: | |||
| 341 | + | for process in reversed(processes): | |||
| 342 | + | if process.poll() is None: | |||
| 343 | + | process.terminate() | |||
| 344 | + | try: | |||
| 345 | + | process.wait(timeout=5) | |||
| 346 | + | except subprocess.TimeoutExpired: | |||
| 347 | + | process.kill() | |||
| 348 | + | process.wait() | |||
| 349 | + | for container in containers: | |||
| 350 | + | role = container.rsplit("-", 1)[-1] | |||
| 351 | + | for filename in ("vev-client.log", "vev-daemon.log", "broker/log/vev-broker.log"): | |||
| 352 | + | subprocess.run(["docker", "cp", container + ":/home/demo/.local/state/vev/" + filename, str(artifacts / (role + "-" + filename.replace("/", "-")))], timeout=30, capture_output=True) | |||
| 353 | + | subprocess.run(["docker", "rm", "-f", container], timeout=30, capture_output=True) | |||
| 354 | + | if network: | |||
| 355 | + | subprocess.run(["docker", "network", "rm", name], timeout=30, capture_output=True) | |||
| 356 | + | subprocess.run(["docker", "image", "rm", name], timeout=30, capture_output=True) | |||
| 357 | + | print("artifacts:", artifacts) | |||
| 358 | + | ||||
| 359 | + | ||||
| 360 | + | if __name__ == "__main__": | |||
| 361 | + | main() | |||