ping-rust 0.1.13

Menu-driven installer and manager for the shoes proxy server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
name: Ubuntu 24.04 acceptance

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths:
      - .github/workflows/ubuntu-acceptance.yml
      - Cargo.toml
      - Cargo.lock
      - src/**
      - scripts/install.sh

permissions:
  contents: read

env:
  PUBLIC_VERSION: 0.1.6

jobs:
  root-systemd:
    name: Public install and root/systemd runtime
    runs-on: ubuntu-24.04
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

      - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable

      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2

      - name: Install PTY test dependency
        shell: bash
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends expect

      - name: Verify clean Ubuntu 24.04 x86_64 baseline
        shell: bash
        run: |
          set -euo pipefail
          . /etc/os-release
          test "$ID" = ubuntu
          test "$VERSION_ID" = 24.04
          test "$(uname -m)" = x86_64
          systemctl --version
          test ! -e /usr/local/bin/shoes
          test ! -e /etc/shoes
          test ! -e /etc/systemd/system/shoes.service

      - name: Verify public crates.io installation
        shell: bash
        run: |
          set -euo pipefail
          public_root="$RUNNER_TEMP/ping-rust-public"
          cargo install ping-rust \
            --version "$PUBLIC_VERSION" \
            --locked \
            --root "$public_root"
          test "$("$public_root/bin/ping-rust" --version)" = \
            "ping-rust $PUBLIC_VERSION"

      - name: Install current source under test
        shell: bash
        run: |
          set -euo pipefail
          current_root="$RUNNER_TEMP/ping-rust-current"
          cargo install --path . --locked --root "$current_root"
          ping_rust="$current_root/bin/ping-rust"
          ln -s ping-rust "$current_root/bin/prs"
          "$ping_rust" --version
          printf 'PING_RUST=%s\n' "$ping_rust" >> "$GITHUB_ENV"
          printf 'PRS=%s\n' "$current_root/bin/prs" >> "$GITHUB_ENV"

      - name: Installer bootstrap deploys random-port Reality without input
        id: reality
        shell: bash
        run: |
          set -euo pipefail
          started_at="$(date +%s)"

          bootstrap_output="$RUNNER_TEMP/reality-bootstrap.txt"
          sudo "$PING_RUST" bootstrap | sudo tee "$bootstrap_output" >/dev/null \
            || { echo 'bootstrap command failed' >&2; exit 1; }
          ! sudo grep -Eq '选择协议|输入端口' "$bootstrap_output" \
            || { echo 'bootstrap prompted unexpectedly' >&2; exit 1; }
          sudo grep -q '首次安装:自动部署 VLESS-REALITY' "$bootstrap_output" \
            || { echo 'bootstrap marker missing' >&2; exit 1; }
          sudo grep -q 'vless://' "$bootstrap_output" \
            || { echo 'bootstrap URI missing' >&2; exit 1; }

          reality_uri="$(sudo "$PING_RUST" url)"
          sudo rm -f "$bootstrap_output"
          printf '%s' "$reality_uri" | grep -q '^vless://' \
            || { echo 'saved URI missing' >&2; exit 1; }
          for parameter in security=reality flow=xtls-rprx-vision pbk= sid=; do
            printf '%s' "$reality_uri" | grep -q "$parameter" \
              || { echo 'saved URI parameter missing' >&2; exit 1; }
          done
          read -r reality_sni reality_fingerprint < <(
            REALITY_URI="$reality_uri" python3 - <<'PY'
          import os
          from urllib.parse import parse_qs, urlparse

          query = parse_qs(urlparse(os.environ['REALITY_URI']).query)
          print(query.get('sni', [''])[0], query.get('fp', [''])[0])
          PY
          )
          case "$reality_sni" in
            www.amazon.com|www.ebay.com|www.paypal.com|www.cloudflare.com|dash.cloudflare.com|aws.amazon.com) ;;
            *) echo "unexpected Reality SNI: $reality_sni" >&2; exit 1 ;;
          esac
          test "$reality_fingerprint" = chrome \
            || { echo "unexpected Reality fingerprint: $reality_fingerprint" >&2; exit 1; }
          test "$(sudo "$PING_RUST" qr 2>/dev/null)" = "$reality_uri" \
            || { echo 'QR fallback URI mismatch' >&2; exit 1; }
          info_output="$(sudo "$PING_RUST" info)"
          printf '%s' "$info_output" | grep -q '客户端地址:' \
            || { echo 'client address missing from info' >&2; exit 1; }
          printf '%s' "$info_output" | grep -Fq "| $reality_sni" \
            || { echo 'Reality SNI differs between info summary and URI' >&2; exit 1; }
          sudo grep -Fq "$reality_sni:" /etc/shoes/config.yaml \
            || { echo 'Reality SNI missing from shoes YAML' >&2; exit 1; }
          sudo grep -Fq "\"server_name\": \"$reality_sni\"" \
            /etc/shoes/ping-rust-state.json \
            || { echo 'Reality SNI missing from managed state' >&2; exit 1; }
          reality_port="$(
            printf '%s' "$info_output" \
              | sed -nE 's/.*0\.0\.0\.0:([0-9]+).*/\1/p' \
              | head -n 1
          )"
          test -n "$reality_port" \
            || { echo 'Reality port missing from info' >&2; exit 1; }
          test "$reality_port" -ge 20000 \
            || { echo 'Reality port outside random range' >&2; exit 1; }
          printf 'REALITY_PORT=%s\n' "$reality_port" >> "$GITHUB_ENV"

          elapsed="$(( $(date +%s) - started_at ))"
          test "$elapsed" -le 180 \
            || { echo 'Reality bootstrap exceeded three minutes' >&2; exit 1; }
          printf 'elapsed=%s\n' "$elapsed" >> "$GITHUB_OUTPUT"

          sudo systemctl is-active --quiet shoes.service \
            || { echo 'shoes service is not active' >&2; exit 1; }
          sudo systemctl is-enabled --quiet shoes.service \
            || { echo 'shoes service is not enabled' >&2; exit 1; }
          for _ in {1..20}; do
            if sudo ss -H -lnt | awk '{print $4}' | grep -Eq "(^|:)${reality_port}$"; then
              break
            fi
            sleep 0.5
          done
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq "(^|:)${reality_port}$" \
            || { echo 'Reality listener missing' >&2; exit 1; }
          sudo test -s "/etc/shoes/profiles/VLESS-REALITY-${reality_port}.yaml" \
            || { echo 'Reality profile file missing' >&2; exit 1; }

          reality_profile="$(
            printf '%s' "$info_output" \
              | grep -Eo '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}' \
              | head -n 1
          )"
          test -n "$reality_profile" \
            || { echo 'Reality profile UUID missing' >&2; exit 1; }
          printf 'REALITY_PROFILE=%s\n' "$reality_profile" >> "$GITHUB_ENV"

      - name: View the only profile without a selection menu
        shell: bash
        run: |
          set -euo pipefail
          sudo env PRS_COMMAND="$PRS" expect <<'EXPECT'
          log_user 0
          set timeout 180
          spawn $env(PRS_COMMAND)
          expect -re {请选择 \[0-10\]}
          send "3\r"
          expect {
            -re {请选择配置} {
              puts stderr "single profile unexpectedly showed a selection menu"
              exit 41
            }
            -re {协议 \(protocol\)[^\r\n]*= vless} {}
            eof {
              puts stderr "view exited before rendering the only profile"
              exit 42
            }
            timeout {
              puts stderr "timeout waiting for the only profile details"
              exit 43
            }
          }
          expect {
            -re {vless://} {}
            eof {
              puts stderr "view exited before rendering the share URI"
              exit 44
            }
            timeout {
              puts stderr "timeout waiting for the only profile URI"
              exit 45
            }
          }
          expect {
            eof {}
            timeout {
              puts stderr "single-profile view did not exit"
              exit 46
            }
          }
          EXPECT

      - name: Add Hysteria2 and confirm service reload
        shell: bash
        run: |
          set -euo pipefail
          previous_pid="$(sudo systemctl show --property MainPID --value shoes.service)"
          test "$previous_pid" -gt 0

          hysteria_output="$RUNNER_TEMP/hysteria2-generation.txt"
          sudo "$PING_RUST" generate hysteria2 \
            --name acceptance-hysteria2 \
            --port 24444 \
            --server-name localhost \
            | sudo tee "$hysteria_output" >/dev/null

          current_pid="$(sudo systemctl show --property MainPID --value shoes.service)"
          test "$current_pid" -gt 0
          test "$current_pid" != "$previous_pid"
          sudo systemctl is-active --quiet shoes.service
          for _ in {1..20}; do
            if sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24444$'; then
              break
            fi
            sleep 0.5
          done
          sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24444$'

          hysteria_profile="$(
            grep -Eo '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}' \
              "$hysteria_output" | head -n 1
          )"
          test -n "$hysteria_profile"
          printf 'HYSTERIA_PROFILE=%s\n' "$hysteria_profile" >> "$GITHUB_ENV"
          sudo rm -f "$hysteria_output"

      - name: Add TUIC and confirm service reload
        shell: bash
        run: |
          set -euo pipefail
          previous_pid="$(sudo systemctl show --property MainPID --value shoes.service)"
          test "$previous_pid" -gt 0

          tuic_output="$RUNNER_TEMP/tuic-generation.txt"
          sudo "$PING_RUST" generate tuic \
            --name acceptance-tuic \
            --port 24445 \
            --server-name localhost \
            | sudo tee "$tuic_output" >/dev/null

          current_pid="$(sudo systemctl show --property MainPID --value shoes.service)"
          test "$current_pid" -gt 0
          test "$current_pid" != "$previous_pid"
          sudo systemctl is-active --quiet shoes.service
          for _ in {1..20}; do
            if sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24445$'; then
              break
            fi
            sleep 0.5
          done
          sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24445$'

          tuic_profile="$(
            grep -Eo '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}' \
              "$tuic_output" | head -n 1
          )"
          test -n "$tuic_profile"
          printf 'TUIC_PROFILE=%s\n' "$tuic_profile" >> "$GITHUB_ENV"
          sudo rm -f "$tuic_output"

      - name: Add Shadowsocks and AnyTLS and confirm core protocol runtime
        shell: bash
        run: |
          set -euo pipefail
          shadowsocks_output="$RUNNER_TEMP/shadowsocks-generation.txt"
          sudo "$PING_RUST" generate shadowsocks \
            --name acceptance-shadowsocks \
            --port 24446 \
            | sudo tee "$shadowsocks_output" >/dev/null
          for _ in {1..20}; do
            if sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24446$'; then
              break
            fi
            sleep 0.5
          done
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24446$'
          shadowsocks_profile="$(
            grep -Eo '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}' \
              "$shadowsocks_output" | head -n 1
          )"
          test -n "$shadowsocks_profile"
          printf 'SHADOWSOCKS_PROFILE=%s\n' "$shadowsocks_profile" >> "$GITHUB_ENV"
          sudo rm -f "$shadowsocks_output"

          anytls_output="$RUNNER_TEMP/anytls-generation.txt"
          sudo "$PING_RUST" generate anytls \
            --name acceptance-anytls \
            --port 24447 \
            --server-name localhost \
            --user acceptance:anytls-acceptance-password \
            --padding stop=8 \
            --padding 0=30-30 \
            | sudo tee "$anytls_output" >/dev/null
          sudo systemctl is-active --quiet shoes.service
          for _ in {1..20}; do
            if sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24447$'; then
              break
            fi
            sleep 0.5
          done
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24447$'
          anytls_profile="$(
            grep -Eo '[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}' \
              "$anytls_output" | head -n 1
          )"
          test -n "$anytls_profile"
          printf 'ANYTLS_PROFILE=%s\n' "$anytls_profile" >> "$GITHUB_ENV"
          sudo rm -f "$anytls_output"

          for profile_file in \
            "VLESS-REALITY-${REALITY_PORT}.yaml" \
            HYSTERIA2-24444.yaml \
            TUIC-24445.yaml \
            SHADOWSOCKS-24446.yaml \
            ANYTLS-24447.yaml; do
            sudo test -s "/etc/shoes/profiles/$profile_file"
          done

      - name: Verify prs numeric PTY flow for all protocol presets
        shell: bash
        run: |
          set -euo pipefail
          config_before_migration="$(sudo sha256sum /etc/shoes/config.yaml | awk '{print $1}')"
          state_before_migration="$(sudo sha256sum /etc/shoes/ping-rust-state.json | awk '{print $1}')"
          sudo mv /etc/shoes/profiles "$RUNNER_TEMP/legacy-profiles"
          sudo env PRS_COMMAND="$PRS" expect <<'EXPECT'
          log_user 0
          set timeout 180
          spawn $env(PRS_COMMAND)
          expect -re {请选择 \[0-10\]}
          send "0\r"
          expect eof
          EXPECT
          test "$(sudo sha256sum /etc/shoes/config.yaml | awk '{print $1}')" = \
            "$config_before_migration"
          test "$(sudo sha256sum /etc/shoes/ping-rust-state.json | awk '{print $1}')" = \
            "$state_before_migration"
          for profile_file in \
            "VLESS-REALITY-${REALITY_PORT}.yaml" \
            HYSTERIA2-24444.yaml \
            TUIC-24445.yaml \
            SHADOWSOCKS-24446.yaml \
            ANYTLS-24447.yaml; do
            sudo test -s "/etc/shoes/profiles/$profile_file"
          done
          sudo rm -r "$RUNNER_TEMP/legacy-profiles"

          run_menu_add() {
            local protocol_number="$1"
            local port="$2"
            local scheme="$3"
            sudo env PRS_COMMAND="$PRS" PROTOCOL_NUMBER="$protocol_number" \
              MENU_PORT="$port" SHARE_SCHEME="$scheme" expect <<'EXPECT'
          log_user 0
          set timeout 180
          proc wait_for {pattern stage} {
            expect {
              -re $pattern { return }
              eof { puts stderr "unexpected EOF at $stage"; exit 21 }
              timeout { puts stderr "timeout at $stage"; exit 22 }
            }
          }
          spawn $env(PRS_COMMAND)
          wait_for {请选择 \[0-10\]} main-menu
          send "1\r"
          wait_for {请选择 \[0-10\]} protocol-menu
          send "$env(PROTOCOL_NUMBER)\r"
          wait_for {输入端口} port-prompt
          send "$env(MENU_PORT)\r"
          if {$env(PROTOCOL_NUMBER) == "3"} {
            wait_for {请选择加密方式} shadowsocks-cipher-menu
            send "\r"
            wait_for {请设置密码} shadowsocks-password-prompt
            send "\r"
          }
          wait_for "$env(SHARE_SCHEME)://" share-uri
          expect { eof {} timeout { puts stderr "timeout waiting for add exit"; exit 23 } }
          EXPECT
          }

          run_menu_add 4 25448 vless
          run_menu_add 3 25449 ss
          run_menu_add 6 25451 vless
          run_menu_add 7 25452 vless
          run_menu_add 8 25453 trojan
          run_menu_add 9 25454 trojan
          run_menu_add 10 25455 vmess
          sudo systemctl is-active --quiet shoes.service
          wait_listener() {
            local kind="$1"
            local port="$2"
            for _ in {1..20}; do
              if sudo ss -H "-ln${kind}" | awk '{print $4}' | grep -Eq "(^|:)${port}$"; then
                return 0
              fi
              sleep 0.5
            done
            echo "missing ${kind} listener on ${port}" >&2
            return 1
          }
          wait_listener t 25448
          wait_listener t 25449
          wait_listener t 25451
          wait_listener t 25452
          wait_listener t 25453
          wait_listener t 25454
          wait_listener t 25455

          profile_id_for_port() {
            local port="$1"
            sudo python3 - "$port" <<'PY'
          import json
          import sys

          with open('/etc/shoes/ping-rust-state.json', encoding='utf-8') as stream:
              profiles = json.load(stream)['profiles']
          print(next(profile['id'] for profile in profiles if profile['port'] == int(sys.argv[1])))
          PY
          }
          printf 'VLESS_TLS_PROFILE=%s\n' "$(profile_id_for_port 25451)" >> "$GITHUB_ENV"
          printf 'VLESS_WS_TLS_PROFILE=%s\n' "$(profile_id_for_port 25452)" >> "$GITHUB_ENV"
          printf 'TROJAN_TLS_PROFILE=%s\n' "$(profile_id_for_port 25453)" >> "$GITHUB_ENV"
          printf 'TROJAN_REALITY_PROFILE=%s\n' "$(profile_id_for_port 25454)" >> "$GITHUB_ENV"
          printf 'VMESS_WS_TLS_PROFILE=%s\n' "$(profile_id_for_port 25455)" >> "$GITHUB_ENV"

          for profile_file in \
            VLESS-TLS-VISION-25451.yaml \
            VLESS-WS-TLS-25452.yaml \
            TROJAN-TLS-25453.yaml \
            TROJAN-REALITY-25454.yaml \
            VMESS-WS-TLS-25455.yaml; do
            sudo test -s "/etc/shoes/profiles/$profile_file"
          done

          profile_number="$(sudo python3 - <<'PY'
          import json
          with open('/etc/shoes/ping-rust-state.json', encoding='utf-8') as stream:
              profiles = json.load(stream)['profiles']
          print(next(index for index, profile in enumerate(profiles, 1) if profile['port'] == 25448))
          PY
          )"
          sudo env PRS_COMMAND="$PRS" PROFILE_NUMBER="$profile_number" expect <<'EXPECT'
          log_user 0
          set timeout 180
          proc wait_for {pattern stage} {
            expect {
              -re $pattern { return }
              eof { puts stderr "unexpected EOF at $stage"; exit 31 }
              timeout { puts stderr "timeout at $stage"; exit 32 }
            }
          }
          spawn $env(PRS_COMMAND)
          wait_for {请选择 \[0-10\]} main-menu
          send "2\r"
          wait_for {请选择配置} profile-menu
          wait_for {请选择} profile-prompt
          send "$env(PROFILE_NUMBER)\r"
          wait_for {选择更改项目} change-menu
          wait_for {请选择 \[0-5\]} change-prompt
          send "1\r"
          wait_for {输入新端口} port-prompt
          send "25450\r"
          wait_for {配置更改成功} change-success
          wait_for {请选择 \[0-10\]} main-menu-return
          send "0\r"
          wait_for {已退出} exit-message
          expect { eof {} timeout { puts stderr "timeout waiting for change exit"; exit 33 } }
          EXPECT
          wait_listener t 25450
          ! sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)25448$'
          sudo test ! -e /etc/shoes/profiles/VLESS-REALITY-25448.yaml
          sudo test -s /etc/shoes/profiles/VLESS-REALITY-25450.yaml
          sudo test -s /etc/shoes/profiles/SHADOWSOCKS-25449.yaml
          sudo grep -q '^address: 0.0.0.0:25450$' \
            /etc/shoes/profiles/VLESS-REALITY-25450.yaml

          ss_profile_number="$(sudo python3 - <<'PY'
          import json
          with open('/etc/shoes/ping-rust-state.json', encoding='utf-8') as stream:
              profiles = json.load(stream)['profiles']
          print(next(index for index, profile in enumerate(profiles, 1) if profile['port'] == 25449))
          PY
          )"
          sudo env PRS_COMMAND="$PRS" PROFILE_NUMBER="$profile_number" \
            SS_PROFILE_NUMBER="$ss_profile_number" expect <<'EXPECT'
          log_user 0
          set timeout 180
          proc wait_for {pattern stage} {
            expect {
              -re $pattern { return }
              eof { puts stderr "unexpected EOF at $stage"; exit 34 }
              timeout { puts stderr "timeout at $stage"; exit 35 }
            }
          }
          spawn $env(PRS_COMMAND)
          wait_for {请选择 \[0-10\]} main-menu
          send "3\r"
          wait_for {请选择配置} view-profile-menu
          wait_for {VLESS-REALITY-25450\.yaml} view-profile-file
          wait_for {请选择} view-profile-prompt
          send "$env(PROFILE_NUMBER)\r"
          wait_for {协议 \(protocol\)[^\r\n]*= vless} view-info
          wait_for {vless://} view-uri
          expect { eof {} timeout { puts stderr "timeout waiting for view exit"; exit 36 } }

          spawn $env(PRS_COMMAND)
          wait_for {请选择 \[0-10\]} main-menu-after-view
          send "4\r"
          wait_for {请选择配置} delete-profile-menu
          wait_for {VLESS-REALITY-25450\.yaml} delete-profile-file
          wait_for {请选择} delete-profile-prompt
          send "0\r"
          wait_for {请选择 \[0-10\]} main-menu-after-delete-return
          send "4\r"
          wait_for {请选择配置} delete-profile-menu-again
          wait_for {请选择} delete-profile-prompt-again
          send "$env(SS_PROFILE_NUMBER)\r"
          wait_for {确认删除 SHADOWSOCKS-25449\.yaml} delete-confirm
          send "y\r"
          wait_for {已删除.*SHADOWSOCKS-25449\.yaml} delete-success
          wait_for {请选择 \[0-10\]} main-menu-after-delete
          send "0\r"
          wait_for {已退出} exit-message
          expect { eof {} timeout { puts stderr "timeout waiting for delete exit"; exit 37 } }
          EXPECT
          sudo test ! -e /etc/shoes/profiles/SHADOWSOCKS-25449.yaml
          ! sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)25449$'

      - name: Roll back config and systemd state when activation fails
        shell: bash
        run: |
          set -euo pipefail
          config_before="$(sudo sha256sum /etc/shoes/config.yaml | awk '{print $1}')"
          state_before="$(sudo sha256sum /etc/shoes/ping-rust-state.json | awk '{print $1}')"
          profiles_before="$(sudo find /etc/shoes/profiles -maxdepth 1 -type f \
            -print0 | sort -z | sudo xargs -0 -r sha256sum | sha256sum | awk '{print $1}')"
          unit_before="$(sudo sha256sum /etc/systemd/system/shoes.service | awk '{print $1}')"
          sudo systemctl is-active --quiet shoes.service
          sudo systemctl is-enabled --quiet shoes.service

          sudo tee /run/ping-rust-fail-once.sh >/dev/null <<'FAIL_ONCE'
          #!/usr/bin/env bash
          marker=/dev/shm/ping-rust-activation-fault
          if [ ! -e "$marker" ]; then
            touch "$marker"
            exit 42
          fi
          exit 0
          FAIL_ONCE
          sudo chmod 0755 /run/ping-rust-fail-once.sh
          sudo mkdir -p /etc/systemd/system/shoes.service.d
          sudo tee /etc/systemd/system/shoes.service.d/activation-fault.conf >/dev/null <<'FAULT_DROPIN'
          [Service]
          ExecStartPre=/run/ping-rust-fail-once.sh
          FAULT_DROPIN
          sudo systemctl daemon-reload

          rollback_port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')"
          failure_log="$RUNNER_TEMP/activation-failure.txt"
          set +e
          sudo "$PING_RUST" add reality \
            --name must-rollback \
            --port "$rollback_port" \
            --server-address 203.0.113.10 \
            --plain >"$failure_log" 2>&1
          add_status="$?"
          set -e
          sudo test -e /dev/shm/ping-rust-activation-fault \
            || { echo 'activation fault did not execute' >&2; exit 1; }
          sudo rm -f /etc/systemd/system/shoes.service.d/activation-fault.conf \
            /run/ping-rust-fail-once.sh /dev/shm/ping-rust-activation-fault
          sudo rmdir /etc/systemd/system/shoes.service.d
          sudo systemctl daemon-reload
          test "$add_status" -ne 0 \
            || { echo 'activation failure injection unexpectedly succeeded' >&2; exit 1; }
          grep -q '回滚' "$failure_log" || { echo 'missing rollback marker' >&2; exit 1; }
          ! grep -q 'vless://' "$failure_log" || { echo 'failure leaked share URI' >&2; exit 1; }
          test "$(sudo sha256sum /etc/shoes/config.yaml | awk '{print $1}')" = "$config_before" \
            || { echo 'config hash mismatch' >&2; exit 1; }
          test "$(sudo sha256sum /etc/shoes/ping-rust-state.json | awk '{print $1}')" = "$state_before" \
            || { echo 'state hash mismatch' >&2; exit 1; }
          test "$(sudo find /etc/shoes/profiles -maxdepth 1 -type f \
            -print0 | sort -z | sudo xargs -0 -r sha256sum | sha256sum | awk '{print $1}')" = "$profiles_before" \
            || { echo 'profile directory hash mismatch' >&2; exit 1; }
          test "$(sudo sha256sum /etc/systemd/system/shoes.service | awk '{print $1}')" = "$unit_before" \
            || { echo 'unit hash mismatch' >&2; exit 1; }
          sudo systemctl is-active --quiet shoes.service \
            || { echo 'service not active after rollback' >&2; exit 1; }
          sudo systemctl is-enabled --quiet shoes.service \
            || { echo 'service not enabled after rollback' >&2; exit 1; }
          ! sudo ss -H -lnt | awk '{print $4}' | grep -Eq "(^|:)${rollback_port}$" \
            || { echo 'rolled-back port still listening' >&2; exit 1; }
          rm -f "$failure_log"

      - name: Verify client exports and operations
        shell: bash
        run: |
          set -euo pipefail
          for entry in \
            "reality:$REALITY_PROFILE" \
            "hysteria2:$HYSTERIA_PROFILE" \
            "tuic:$TUIC_PROFILE" \
            "shadowsocks:$SHADOWSOCKS_PROFILE" \
            "anytls:$ANYTLS_PROFILE" \
            "vless-tls:$VLESS_TLS_PROFILE" \
            "vless-ws-tls:$VLESS_WS_TLS_PROFILE" \
            "trojan-tls:$TROJAN_TLS_PROFILE" \
            "trojan-reality:$TROJAN_REALITY_PROFILE" \
            "vmess-ws-tls:$VMESS_WS_TLS_PROFILE"; do
            name="${entry%%:*}"
            profile="${entry#*:}"
            sudo "$PING_RUST" export clash-meta \
              --profile "$profile" \
              --server 127.0.0.1 \
              --output "$RUNNER_TEMP/$name-clash.yaml"
            sudo "$PING_RUST" export sing-box \
              --profile "$profile" \
              --server 127.0.0.1 \
              --output "$RUNNER_TEMP/$name-sing-box.json"
            sudo "$PING_RUST" export nekobox \
              --profile "$profile" \
              --server 127.0.0.1 \
              --output "$RUNNER_TEMP/$name-nekobox.txt"
            sudo test -s "$RUNNER_TEMP/$name-clash.yaml"
            sudo test -s "$RUNNER_TEMP/$name-sing-box.json"
            sudo test -s "$RUNNER_TEMP/$name-nekobox.txt"
            sudo python3 -m json.tool \
              "$RUNNER_TEMP/$name-sing-box.json" >/dev/null
          done

          backup="$RUNNER_TEMP/shoes-backup.tar.gz"
          sudo "$PING_RUST" backup "$backup"
          sudo test -s "$backup"
          sudo tar -tzf "$backup" | grep -q 'shoes/profiles/VLESS-REALITY-'
          sudo tar -tzf "$backup" | grep -q 'shoes/profiles/HYSTERIA2-'
          sudo "$PING_RUST" restore "$backup"
          sudo test -s "/etc/shoes/profiles/VLESS-REALITY-${REALITY_PORT}.yaml"
          sudo "$PING_RUST" update --method release
          sudo "$PING_RUST" service restart
          sudo "$PING_RUST" service status
          sudo "$PING_RUST" logs --lines 20
          sudo "$PING_RUST" info

          sudo systemctl is-active --quiet shoes.service
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq "(^|:)${REALITY_PORT}$"
          sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24444$'
          sudo ss -H -lun | awk '{print $4}' | grep -Eq '(^|:)24445$'
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24446$'
          sudo ss -H -lnt | awk '{print $4}' | grep -Eq '(^|:)24447$'
          for port in 25451 25452 25453 25454 25455; do
            sudo ss -H -lnt | awk '{print $4}' | grep -Eq "(^|:)${port}$"
          done

      - name: Record acceptance summary
        shell: bash
        run: |
          {
            echo '### Ubuntu 24.04 acceptance'
            echo
            echo "- Public crates.io version: $PUBLIC_VERSION"
            echo "- Reality deployment: ${{ steps.reality.outputs.elapsed }} seconds"
            echo '- systemd: enabled, active, restart verified'
            echo '- Protocol listeners: all ten menu presets'
            echo '- Client exports: all ten menu presets to Clash Meta, sing-box, Nekobox'
            echo '- Operations: logs, backup, restore, update, restart, info'
          } >> "$GITHUB_STEP_SUMMARY"

      - name: Uninstall and verify cleanup
        if: always()
        shell: bash
        run: |
          set +e
          if [ -n "${PING_RUST:-}" ] && [ -x "$PING_RUST" ]; then
            sudo "$PING_RUST" uninstall --purge
          fi
          sudo systemctl disable --now shoes.service 2>/dev/null || true
          sudo rm -f /etc/systemd/system/shoes.service /usr/local/bin/shoes
          sudo rm -f /run/ping-rust-fail-once.sh /dev/shm/ping-rust-activation-fault
          sudo rm -rf /etc/systemd/system/shoes.service.d
          rm -f "${SB:-}"
          sudo rm -rf /etc/shoes
          sudo systemctl daemon-reload
          set -e
          test ! -e /usr/local/bin/shoes
          test ! -e /etc/shoes
          test ! -e /etc/systemd/system/shoes.service