-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
Β·1459 lines (1338 loc) Β· 50.3 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
Β·1459 lines (1338 loc) Β· 50.3 KB
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
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/sh
# aimx install script β POSIX sh (dash / busybox compatible).
#
# Usage:
# curl -fsSL https://aimx.email/install.sh | sh
# curl -fsSL https://aimx.email/install.sh | sh -s -- --tag 1.2.3
#
# Thin wrapper around the binary: download β install β exec `aimx setup`.
# The binary owns the operator-facing wizard (welcome banner, six-step
# checklist, agents setup handoff, closing message). Upgrades are
# non-interactive: stop service β swap binary β start service.
#
# Modelled on `just.systems/install.sh` β `say` / `err` / `need` /
# `download` helper idioms, no bashisms, HTTPS-only trust anchor.
set -eu
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GITHUB_REPO="uzyn/aimx"
GITHUB_API="https://api.github.com/repos/${GITHUB_REPO}/releases"
GITHUB_DL="https://github.com/${GITHUB_REPO}/releases/download"
DEFAULT_PREFIX="/usr/local/bin"
UNSUPPORTED_DOC="https://aimx.email/book/installation.html#unsupported-platforms"
# Config path used by backup_existing_config. Overridable for tests via
# AIMX_INSTALL_CONFIG_PATH; production always points at /etc/aimx/config.toml.
AIMX_CONFIG_TOML="${AIMX_INSTALL_CONFIG_PATH:-/etc/aimx/config.toml}"
# ---------------------------------------------------------------------------
# Helpers (say / err / need / download)
# ---------------------------------------------------------------------------
say() {
printf 'install: %s\n' "$1" >&2
}
verbose() {
if [ "${AIMX_VERBOSE:-0}" = "1" ]; then
printf 'install: %s\n' "$1" >&2
fi
}
err() {
printf 'install: error: %s\n' "$1" >&2
cleanup
exit 1
}
need() {
if ! command -v "$1" >/dev/null 2>&1; then
err "required command not found: $1"
fi
}
# Create temp dir and arm cleanup trap. Safe on every exit path.
_td=""
cleanup() {
if [ -n "${_td}" ] && [ -d "${_td}" ]; then
rm -rf "${_td}"
_td=""
fi
}
# ---------------------------------------------------------------------------
# UI helpers (color when TTY + !NO_COLOR, plain otherwise).
# Kept thin: the binary owns the section/step rendering. The shell only
# emits the install-time progress lines (download / extract / install).
# ---------------------------------------------------------------------------
_ui_color_enabled() {
if [ -n "${NO_COLOR:-}" ]; then
return 1
fi
if [ ! -t 2 ]; then
return 1
fi
return 0
}
_ui_paint() {
# $1 = ansi code, $2 = text
if _ui_color_enabled; then
printf '\033[%sm%s\033[0m' "$1" "$2"
else
printf '%s' "$2"
fi
}
ui_info() {
_msg="$1"
printf '%s %s\n' "$(_ui_paint 34 '[info]')" "${_msg}" >&2
}
ui_warn() {
_msg="$1"
printf '%s %s\n' "$(_ui_paint 33 '[warn]')" "${_msg}" >&2
}
ui_error() {
_msg="$1"
printf '%s %s\n' "$(_ui_paint 31 '[error]')" "${_msg}" >&2
}
ui_success() {
_msg="$1"
printf '%s %s\n' "$(_ui_paint 32 '[ok]')" "${_msg}" >&2
}
# Thin two-line install banner. The full six-step checklist + per-step
# ticking lives in the Rust binary's `aimx setup` wizard.
print_install_banner() {
printf '\n' >&2
printf '%s\n' "$(_ui_paint '1;35' 'AIMX installer')" >&2
printf '%s\n' "$(_ui_paint 2 ' downloading and installing AIMX...')" >&2
printf '\n' >&2
}
# Port-check banner. Replaces the install banner when --port-check-only
# is set, so it is visually obvious nothing is installing.
print_port_check_banner() {
printf '\n' >&2
printf '%s\n' "$(_ui_paint '1;35' 'AIMX port 25 connectivity check')" >&2
printf '%s\n' "$(_ui_paint 2 ' no install will be performed')" >&2
printf '\n' >&2
}
# download <url> <path>
# Prefers curl; falls back to wget. Refuses non-HTTPS URLs. Honors
# GITHUB_TOKEN for api.github.com calls so rate-limited CI runs succeed.
download() {
_url="$1"
_dst="$2"
case "${_url}" in
https://*) : ;;
*) err "refusing non-HTTPS URL: ${_url}" ;;
esac
_auth_hdr=""
case "${_url}" in
https://api.github.com/*)
if [ -n "${GITHUB_TOKEN:-}" ]; then
_auth_hdr="Authorization: Bearer ${GITHUB_TOKEN}"
fi
;;
esac
verbose "GET ${_url}"
if command -v curl >/dev/null 2>&1; then
if [ -n "${_auth_hdr}" ]; then
curl --proto '=https' --tlsv1.2 -fsSL -H "${_auth_hdr}" \
-o "${_dst}" "${_url}"
else
curl --proto '=https' --tlsv1.2 -fsSL -o "${_dst}" "${_url}"
fi
elif command -v wget >/dev/null 2>&1; then
if [ -n "${_auth_hdr}" ]; then
wget --https-only -q --header="${_auth_hdr}" \
-O "${_dst}" "${_url}"
else
wget --https-only -q -O "${_dst}" "${_url}"
fi
else
err "need curl or wget on PATH"
fi
}
help() {
cat <<'EOF'
AIMX install script
USAGE:
install.sh [FLAGS]
FLAGS:
-h, --help Print this help and exit
--tag <VERSION> Install a specific release tag (e.g. 1.2.3);
overrides AIMX_VERSION env var. Tags are bare
SemVer (no `v` prefix); a caller-supplied `v`
is stripped leniently.
--target <TRIPLE> Override target auto-detection
(x86_64-unknown-linux-gnu,
aarch64-unknown-linux-gnu,
x86_64-unknown-linux-musl,
aarch64-unknown-linux-musl)
--to <DIR> Install binary into DIR (default /usr/local/bin);
overrides AIMX_PREFIX env var
--force Re-install even if target version already present
--port-check-only Run port-25 outbound + inbound connectivity checks
then exit; no install is performed
--verify-host <URL> Verifier base URL for the inbound /probe call
(default https://check.aimx.email); overrides
AIMX_VERIFY_HOST env var
ENVIRONMENT:
AIMX_VERSION Release tag to install (e.g. 1.2.3)
AIMX_PREFIX Install directory (default /usr/local/bin)
AIMX_DRY_RUN=1 Print every step without downloading or installing
AIMX_VERBOSE=1 Trace HTTP requests and filesystem actions
AIMX_VERIFY_HOST Verifier base URL for --port-check-only (default
https://check.aimx.email)
GITHUB_TOKEN Token for rate-limited GitHub API calls
EXAMPLES:
# Latest stable into /usr/local/bin
curl -fsSL https://aimx.email/install.sh | sh
# Pin a specific tag
curl -fsSL https://aimx.email/install.sh | sh -s -- --tag 1.2.3
# Dry-run: see what would happen without installing
curl -fsSL https://aimx.email/install.sh | AIMX_DRY_RUN=1 sh
# Port-25 connectivity check only (no install)
curl -fsSL https://aimx.email/install.sh | sh -s -- --port-check-only
Trust anchor is HTTPS on the GitHub Releases domain. No signature or
checksum verification in this script; skeptical operators can verify
manually via the 'curl + sha256sum -c' block in the release notes.
EOF
}
# ---------------------------------------------------------------------------
# Privilege / invoker helpers
# ---------------------------------------------------------------------------
# SUDO holds the prefix to use for privileged commands. It is either
# empty (when running as root) or "sudo" (when a non-root invoker has
# sudo on PATH). Populated by resolve_sudo_prefix, which must be called
# once early in main(). Defined here so sourced test harnesses see it.
SUDO=""
# resolve_sudo_prefix β set $SUDO to the right privilege prefix:
# - already root (euid 0) β SUDO="" (run commands directly)
# - non-root with sudo on PATH β SUDO="sudo"
# - non-root without sudo β SUDO="" (call sites will fail with a
# useful error via ensure_sudo before
# ever running a privileged command)
resolve_sudo_prefix() {
_euid="$(id -u 2>/dev/null || echo 0)"
if [ "${_euid}" -eq 0 ]; then
SUDO=""
elif command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
}
# prompt_reinstall β ask the operator whether to re-run `aimx setup`
# when the binary is already at the target version. Returns 0 on yes,
# 1 on no / no usable TTY. Default is no (Enter = no), so non-interactive
# callers (CI, fully-scripted) keep today's exit-0 semantics.
#
# _prompt_read prints the prompt only when a TTY is available for the
# answer. Same TTY logic as ensure_sudo and the post-install handoff:
# prefer the script's own stdin if it's already a terminal; fall back
# to /dev/tty when it's a pipe (curl | sh); otherwise no prompt at all
# (so `curl | sh </dev/null` stays quiet β no stray question in CI logs).
_prompt_read() {
if [ -t 0 ]; then
printf '%s' "$1" >&2
read -r _ans
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
printf '%s' "$1" >&2
read -r _ans </dev/tty
else
return 1
fi
}
prompt_reinstall() {
_ans=""
_prompt_read 'AIMX is already installed. Re-run setup to (re)configure it? [y/N] ' || return 1
case "${_ans}" in
y | Y | yes | YES | Yes) return 0 ;;
*) return 1 ;;
esac
}
ensure_sudo() {
_euid="$(id -u 2>/dev/null || echo 0)"
if [ "${_euid}" -eq 0 ]; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
if ! sudo -n true >/dev/null 2>&1; then
ui_info "Administrator privileges required; enter your password"
# Reattach /dev/tty so `curl | sh` still gets a password prompt.
# Wrap in a subshell + rc capture so a failing redirect or
# wrong password yields a user-visible error instead of a
# silent `set -e` abort.
_sudo_rc=0
# Same logic as the post-install handoff: only re-point stdin
# at /dev/tty when the script's stdin is NOT already a
# terminal. Redirecting an already-terminal stdin breaks
# sudo's use_pty bridge on modern distros.
if [ -t 0 ]; then
sudo -v || _sudo_rc=$?
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
# shellcheck disable=SC2024 # /dev/tty feeds sudo's password prompt under curl|sh, not a privileged file through sudo
(sudo -v </dev/tty) || _sudo_rc=$?
else
sudo -v || _sudo_rc=$?
fi
if [ "${_sudo_rc}" -ne 0 ]; then
ui_error "failed to obtain sudo credentials"
exit 1
fi
fi
return 0
fi
ui_error "sudo is required for system installs on Linux"
say " Install sudo or re-run as root."
exit 1
}
# detect_invoker
# Prints the non-root user that should run `aimx agents setup`.
# Returns 0 with stdout set on success, non-zero when no non-root
# user can be identified. Kept as a helper for tests + possible future
# use; nothing in the live install path calls it today.
detect_invoker() {
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
printf '%s' "${SUDO_USER}"
return 0
fi
_me="$(id -un 2>/dev/null || echo '')"
if [ -n "${_me}" ] && [ "${_me}" != "root" ]; then
printf '%s' "${_me}"
return 0
fi
return 1
}
# backup_existing_config
# If /etc/aimx/config.toml exists, rename it to
# config.toml.bak-YYYYMMDD-HHMMSS-<pid> (UTC). On failure, err out
# rather than silently continuing. Only config.toml is backed up β
# DKIM keys and TLS certs are left in place so deliverability survives
# re-runs. The $$ (pid) suffix prevents collision between concurrent
# invocations that land in the same second.
backup_existing_config() {
_cfg="${AIMX_CONFIG_TOML}"
if [ -f "${_cfg}" ]; then
_ts="$(date -u +%Y%m%d-%H%M%S)"
_bak="${_cfg}.bak-${_ts}-$$"
if ${SUDO} mv -f "${_cfg}" "${_bak}"; then
ui_info "backed up existing config to ${_bak}"
else
err "failed to back up existing ${_cfg}"
fi
fi
}
# ---------------------------------------------------------------------------
# Platform detection
# ---------------------------------------------------------------------------
detect_os() {
_os="$(uname -s)"
case "${_os}" in
Linux) printf 'linux' ;;
*)
err "AIMX is Linux-only; detected ${_os}. See ${UNSUPPORTED_DOC}"
;;
esac
}
detect_arch() {
_arch="$(uname -m)"
case "${_arch}" in
x86_64 | amd64) printf 'x86_64' ;;
aarch64 | arm64) printf 'aarch64' ;;
*)
err "unsupported CPU architecture: ${_arch}. See ${UNSUPPORTED_DOC}"
;;
esac
}
detect_libc() {
# Presence of a musl dynamic loader under /lib/ld-musl-* signals musl.
# Otherwise assume glibc β aimx only ships gnu + musl Linux builds.
for _musl in /lib/ld-musl-* /lib64/ld-musl-*; do
if [ -e "${_musl}" ]; then
printf 'musl'
return 0
fi
done
printf 'gnu'
}
compose_target() {
_arch="$1"
_libc="$2"
printf '%s-unknown-linux-%s' "${_arch}" "${_libc}"
}
# Map a canonical Rust target triple (e.g. `x86_64-unknown-linux-gnu`) to the
# shortened artifact-filename form used by release tarballs
# (`x86_64-linux-gnu`). The canonical triple is still used for
# `cargo build --target`, `aimx --version`, and operator-facing error
# messages β only the tarball filename drops the `-unknown-` vendor field.
artifact_target() {
printf '%s' "$1" | sed 's/-unknown-/-/'
}
# ---------------------------------------------------------------------------
# Version resolution
# ---------------------------------------------------------------------------
# resolve_latest_tag
# Fetch https://api.github.com/repos/uzyn/aimx/releases/latest, pluck the
# "tag_name" value with grep + sed. Deliberately does NOT use jq β matches
# the just.systems installer.
resolve_latest_tag() {
_body="${_td}/release.json"
download "${GITHUB_API}/latest" "${_body}"
_tag="$(grep -m1 '"tag_name":' "${_body}" \
| sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/')"
if [ -z "${_tag}" ]; then
err "could not parse tag_name from GitHub latest-release response"
fi
printf '%s' "${_tag}"
}
# Strip the leading "v" from a tag (v1.2.3 -> 1.2.3) since tarball asset
# names embed the bare version per release.yml. Tags are bare SemVer,
# but this stays lenient against legacy inputs.
tag_to_version() {
printf '%s' "$1" | sed 's/^v//'
}
# ---------------------------------------------------------------------------
# Running-binary version parsing (upgrade path)
# ---------------------------------------------------------------------------
# parse_installed_tag <bin-path>
# Runs <bin-path> --version and extracts the second whitespace-separated
# token, matching the format:
# aimx <tag> (<git-sha>) <target-triple> built <date>
# Returns empty string on any failure.
parse_installed_tag() {
_bin="$1"
if [ ! -x "${_bin}" ]; then
return 0
fi
_out="$("${_bin}" --version 2>/dev/null || true)"
if [ -z "${_out}" ]; then
return 0
fi
case "${_out}" in
aimx\ *) : ;;
*) return 0 ;;
esac
printf '%s' "${_out}" | awk '{print $2}'
}
# Compare two SemVer-ish tags. Prints "older" / "equal" / "newer" describing
# the relationship of $1 relative to $2. Strips the leading "v" and compares
# dot-separated numeric segments pairwise; any pre-release suffix is compared
# lexicographically *only* as a tiebreaker (pre-release < release per SemVer).
compare_tags() {
_a="$(tag_to_version "$1")"
_b="$(tag_to_version "$2")"
_a_core="$(printf '%s' "${_a}" | sed 's/[-+].*//')"
_b_core="$(printf '%s' "${_b}" | sed 's/[-+].*//')"
_a_pre="$(printf '%s' "${_a}" | sed -n 's/^[^-]*-\(.*\)$/\1/p')"
_b_pre="$(printf '%s' "${_b}" | sed -n 's/^[^-]*-\(.*\)$/\1/p')"
_a1="$(printf '%s' "${_a_core}" | cut -d. -f1)"
_a2="$(printf '%s' "${_a_core}" | cut -d. -f2)"
_a3="$(printf '%s' "${_a_core}" | cut -d. -f3)"
_b1="$(printf '%s' "${_b_core}" | cut -d. -f1)"
_b2="$(printf '%s' "${_b_core}" | cut -d. -f2)"
_b3="$(printf '%s' "${_b_core}" | cut -d. -f3)"
: "${_a1:=0}" "${_a2:=0}" "${_a3:=0}"
: "${_b1:=0}" "${_b2:=0}" "${_b3:=0}"
for _pair in "${_a1} ${_b1}" "${_a2} ${_b2}" "${_a3} ${_b3}"; do
# shellcheck disable=SC2086
set -- ${_pair}
if [ "$1" -lt "$2" ]; then
printf 'older'
return 0
fi
if [ "$1" -gt "$2" ]; then
printf 'newer'
return 0
fi
done
if [ -z "${_a_pre}" ] && [ -z "${_b_pre}" ]; then
printf 'equal'
return 0
fi
if [ -z "${_a_pre}" ] && [ -n "${_b_pre}" ]; then
printf 'newer'
return 0
fi
if [ -n "${_a_pre}" ] && [ -z "${_b_pre}" ]; then
printf 'older'
return 0
fi
if [ "${_a_pre}" = "${_b_pre}" ]; then
printf 'equal'
return 0
fi
_first="$(printf '%s\n%s\n' "${_a_pre}" "${_b_pre}" | LC_ALL=C sort | head -n1)"
if [ "${_first}" = "${_a_pre}" ]; then
printf 'older'
else
printf 'newer'
fi
}
# ---------------------------------------------------------------------------
# Setup-completion probe (Ctrl+C recovery)
# ---------------------------------------------------------------------------
# setup_completed
# Returns 0 (true) when there is evidence the operator-facing
# `aimx setup` wizard finished a previous run β specifically, when a
# service unit file is in place on disk. Returns 1 otherwise.
#
# This is the gate that lets a re-run after Ctrl+C fall back to the
# fresh-install path. The binary on disk by itself is not proof that
# setup completed: the installer drops the binary first and only then
# exec's `aimx setup`, which is where the systemd / OpenRC unit file
# actually lands. If the operator Ctrl+Cs out of the wizard before
# that, the binary is on disk but no unit exists; without this gate
# the next run would enter the upgrade branch and die on
# `systemctl start aimx` ("Unit aimx.service not found").
#
# Probe paths are overridable via AIMX_SETUP_COMPLETED_SYSTEMD /
# AIMX_SETUP_COMPLETED_OPENRC so the test harness can exercise the
# helper without touching the host's real `/etc/`.
setup_completed() {
_systemd_unit="${AIMX_SETUP_COMPLETED_SYSTEMD:-/etc/systemd/system/aimx.service}"
_openrc_init="${AIMX_SETUP_COMPLETED_OPENRC:-/etc/init.d/aimx}"
if [ -f "${_systemd_unit}" ] || [ -f "${_openrc_init}" ]; then
return 0
fi
return 1
}
# ---------------------------------------------------------------------------
# Service control (upgrade path)
# ---------------------------------------------------------------------------
stop_service() {
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet aimx 2>/dev/null; then
say "stopping aimx.service (systemd)"
${SUDO} systemctl stop aimx || err "systemctl stop aimx failed"
printf 'systemd'
return 0
fi
# systemd is present but the unit is inactive (manual
# `systemctl stop`, fresh install, etc.). Still emit the
# `systemd` tag so `start_service` is invoked after the swap
# β without it the daemon never restarts on the new binary.
printf 'systemd'
return 0
fi
if command -v rc-service >/dev/null 2>&1; then
say "stopping aimx.service (openrc)"
${SUDO} rc-service aimx stop 2>/dev/null || true
printf 'openrc'
return 0
fi
say "warning: no systemd or OpenRC detected; skipping service stop"
printf 'unknown'
}
start_service() {
_init="$1"
case "${_init}" in
systemd)
say "starting aimx.service (systemd)"
${SUDO} systemctl start aimx
;;
openrc)
say "starting aimx.service (openrc)"
${SUDO} rc-service aimx start
;;
unknown)
say "warning: unrecognized init system; not starting aimx.service"
;;
esac
}
# Detect a manually-launched `aimx serve` process running outside
# systemd / OpenRC. Used on the upgrade path: when no init system
# manages the unit but a stray `aimx serve` is still bound to the
# binary on disk, the operator's swap will leave the OLD process
# running on the new path. We never signal the process β just warn,
# name the PID, and ask the operator to restart it manually.
detect_manual_aimx_serve() {
_binary_path="$1"
if ! command -v pgrep >/dev/null 2>&1; then
return 0
fi
_pids="$(pgrep -f "${_binary_path} serve" 2>/dev/null || true)"
if [ -z "${_pids}" ]; then
return 0
fi
# If systemd or OpenRC manages the unit we trust their lifecycle
# hooks; only warn when neither claims the daemon.
if command -v systemctl >/dev/null 2>&1 \
&& systemctl status aimx >/dev/null 2>&1; then
return 0
fi
if command -v rc-service >/dev/null 2>&1 \
&& rc-service aimx status >/dev/null 2>&1; then
return 0
fi
for _pid in ${_pids}; do
say "warning: detected manually-launched 'aimx serve' (pid ${_pid}) outside systemd/OpenRC"
done
say " the upgrade swaps the binary on disk but cannot restart this process β restart it manually."
}
# ---------------------------------------------------------------------------
# Port-25 connectivity check (--port-check-only)
# ---------------------------------------------------------------------------
#
# Mirrors `aimx portcheck` semantics for evaluators who want to verify a VPS
# can reach SMTP before installing. Outbound: TCP-connect to <host>:25,
# expect 220 banner, send EHLO, accept on 250 SP, send QUIT. Inbound: GET
# ${VERIFY_HOST}/probe; loose substring match for "reachable":true.
# Strip scheme + path/port from a verify-host URL β bare hostname.
derive_smtp_host() {
_vh="$1"
case "${_vh}" in
https://*) _vh="${_vh#https://}" ;;
http://*) _vh="${_vh#http://}" ;;
esac
# Drop trailing path.
_vh="${_vh%%/*}"
# Drop trailing :port (IPv6 in brackets is out of scope per plan).
_vh="${_vh%%:*}"
printf '%s' "${_vh}"
}
port_check_have_python3() {
command -v python3 >/dev/null 2>&1
}
port_check_have_nc() {
command -v nc >/dev/null 2>&1
}
port_check_have_bash() {
command -v bash >/dev/null 2>&1
}
# Outbound EHLO via python3. Returns 0 on success, 1 on protocol fail.
port_check_outbound_python() {
_h="$1"
_p="$2"
python3 - "${_h}" "${_p}" <<'PYEOF'
import socket, sys
host, port = sys.argv[1], int(sys.argv[2])
try:
s = socket.create_connection((host, port), timeout=10)
except Exception:
sys.exit(1)
s.settimeout(5)
try:
f = s.makefile('rwb', buffering=0)
banner = f.readline().decode('latin-1', 'replace')
if not banner.startswith('220'):
sys.exit(1)
f.write(b'EHLO aimx\r\n'); f.flush()
while True:
line = f.readline().decode('latin-1', 'replace')
if not line:
sys.exit(1)
if line.startswith('250 '):
break
if not line.startswith('250-'):
sys.exit(1)
try:
f.write(b'QUIT\r\n'); f.flush()
except Exception:
pass
finally:
try: s.close()
except Exception: pass
sys.exit(0)
PYEOF
}
# Outbound EHLO via nc. Stream-based; tolerates BSD/GNU/ncat quirks by
# avoiding -q / -N / -c entirely.
port_check_outbound_nc() {
_h="$1"
_p="$2"
{ printf 'EHLO aimx\r\n'; sleep 1; printf 'QUIT\r\n'; sleep 1; } \
| nc -w 5 "${_h}" "${_p}" 2>/dev/null \
| awk 'BEGIN{seen=0; ok=0}
/^220 /{seen=1}
/^220-/{seen=1}
/^250 /{if(seen)ok=1}
END{exit ok?0:1}'
}
# Outbound EHLO via bash /dev/tcp. Last resort; uses bash -c so the rest of
# the script stays POSIX sh.
port_check_outbound_bash() {
_h="$1"
_p="$2"
bash -c '
exec 3<>"/dev/tcp/$1/$2" || exit 1
read -r -t 5 banner <&3 || exit 1
case "$banner" in 220*) ;; *) exit 1 ;; esac
printf "EHLO aimx\r\n" >&3
ok=0
while read -r -t 5 line <&3; do
case "$line" in
250\ *) ok=1; break ;;
250-*) ;;
*) exit 1 ;;
esac
done
[ "$ok" = "1" ] || exit 1
printf "QUIT\r\n" >&3 2>/dev/null || true
exit 0
' _ "${_h}" "${_p}"
}
# Run outbound check via the first available tool. Sets _PORT_CHECK_NO_TOOL=1
# when no usable tool is on PATH (caller maps that to exit 2).
_PORT_CHECK_NO_TOOL=0
port_check_outbound() {
_h="$1"
_p="${2:-25}"
_PORT_CHECK_NO_TOOL=0
if port_check_have_python3; then
port_check_outbound_python "${_h}" "${_p}"
return $?
fi
if port_check_have_nc; then
port_check_outbound_nc "${_h}" "${_p}"
return $?
fi
if port_check_have_bash; then
port_check_outbound_bash "${_h}" "${_p}"
return $?
fi
_PORT_CHECK_NO_TOOL=1
return 1
}
# Detect whether port 25 is already bound on this host.
# Echoes "free" | "occupied" | "unknown".
port_check_detect_occupancy() {
if command -v ss >/dev/null 2>&1; then
if ss -tln 2>/dev/null | awk '{print $4}' | grep -E ':25$' >/dev/null 2>&1; then
printf 'occupied'
else
printf 'free'
fi
return 0
fi
if command -v netstat >/dev/null 2>&1; then
if netstat -tln 2>/dev/null | awk '{print $4}' | grep -E ':25$' >/dev/null 2>&1; then
printf 'occupied'
else
printf 'free'
fi
return 0
fi
printf 'unknown'
}
# File where port_check_listener_start writes the python child's stderr.
# Set lazily on first use so non-port-check codepaths don't allocate it.
_PORT_CHECK_LISTENER_STDERR=""
# Privilege prefix the listener spawn / kill / kill-0 use. Set by
# port_check_ensure_inbound_privilege:
# - already root (euid 0) β "" (run directly)
# - non-root + sudo + creds OK β "sudo"
# - non-root + no sudo / creds refused β still "" (caller skips inbound)
_PORT_CHECK_SUDO=""
# Validate that the inbound check has the privilege it needs to bind :25.
# Returns 0 when we can proceed (root, or sudo creds are cached/granted),
# 1 when we cannot (non-root + no sudo, or sudo prompt failed). Mirrors
# install.sh's `ensure_sudo` flow but never `exit`s β port-check failure
# is a soft skip, not a fatal install error.
port_check_ensure_inbound_privilege() {
_PORT_CHECK_SUDO=""
_euid="$(id -u 2>/dev/null || echo 0)"
if [ "${_euid}" -eq 0 ]; then
return 0
fi
if ! command -v sudo >/dev/null 2>&1; then
return 1
fi
if ! sudo -n true >/dev/null 2>&1; then
ui_info "Inbound check needs root to bind port 25; enter your password"
_sudo_rc=0
# Same TTY logic as ensure_sudo: only re-point stdin at /dev/tty
# when the script's stdin is NOT already a terminal. This matters
# for `curl ... | sh` where stdin is the curl pipe.
if [ -t 0 ]; then
sudo -v || _sudo_rc=$?
elif [ -e /dev/tty ] && [ -r /dev/tty ]; then
# shellcheck disable=SC2024 # /dev/tty feeds sudo's password prompt under curl|sh, not a privileged file through sudo
(sudo -v </dev/tty) || _sudo_rc=$?
else
sudo -v || _sudo_rc=$?
fi
if [ "${_sudo_rc}" -ne 0 ]; then
return 1
fi
fi
_PORT_CHECK_SUDO="sudo"
return 0
}
# Spawn a temp Python SMTP listener on :25. Echoes the PID on stdout so the
# caller can kill it after /probe returns. Mirrors src/portcheck.rs:69-129.
#
# Two non-obvious things matter here:
#
# 1. Python's stdout MUST be redirected to /dev/null (not the default
# inherited stdout). When the function is called via `$(...)` the
# subshell's stdout is a capture pipe; the backgrounded python
# child inherits it and holds the write end open for its full
# ~30s lifetime, so the parent's read on `$()` blocks until python
# exits. The result before this redirect: kill -0 ALWAYS reported
# "dead" because $() didn't return until python had already exited.
#
# 2. Python's stderr is captured to a tmpfile so port_check_inbound can
# surface the actual bind error (e.g. "Address already in use") on
# failure instead of a generic "another binder won the race?".
port_check_listener_start() {
if [ -z "${_PORT_CHECK_LISTENER_STDERR}" ]; then
_PORT_CHECK_LISTENER_STDERR="$(mktemp -t aimx-portcheck-listener.XXXXXX 2>/dev/null \
|| echo "/tmp/aimx-portcheck-listener.$$")"
fi
: > "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
${_PORT_CHECK_SUDO} python3 - >/dev/null 2>"${_PORT_CHECK_LISTENER_STDERR}" <<'PYEOF' &
import socket, sys, time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(('0.0.0.0', 25))
except Exception as e:
print("listener bind error:", e, file=sys.stderr)
sys.exit(1)
s.listen(8)
deadline = time.time() + 30
while time.time() < deadline:
s.settimeout(max(0.5, deadline - time.time()))
try:
c, _ = s.accept()
except socket.timeout:
# Idle window expired but the deadline still bounds the loop.
# Continue so a second probe attempt within the deadline still
# gets served (mirrors src/portcheck.rs:73-82).
continue
except Exception:
break
try:
c.settimeout(10)
c.sendall(b'220 chkport25 ESMTP\r\n')
while True:
data = c.recv(1024)
if not data:
break
up = data.upper()
if up.startswith(b'EHLO') or up.startswith(b'HELO'):
c.sendall(b'250 chkport25\r\n')
elif up.startswith(b'QUIT'):
c.sendall(b'221 Bye\r\n'); break
else:
c.sendall(b'502 Not implemented\r\n')
except Exception:
pass
finally:
try: c.close()
except Exception: pass
PYEOF
printf '%s' "$!"
}
# kill-0 + kill use ${_PORT_CHECK_SUDO} so the non-root caller can signal
# the root-owned python child.
port_check_listener_stop() {
_pid="$1"
if [ -z "${_pid}" ]; then
return 0
fi
if ${_PORT_CHECK_SUDO} kill -0 "${_pid}" 2>/dev/null; then
${_PORT_CHECK_SUDO} kill "${_pid}" 2>/dev/null || true
wait "${_pid}" 2>/dev/null || true
fi
if [ -n "${_PORT_CHECK_LISTENER_STDERR}" ] \
&& [ -f "${_PORT_CHECK_LISTENER_STDERR}" ]; then
rm -f "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
fi
}
# Run the inbound /probe call against ${VERIFY_HOST}. Honors http:// for
# self-hosted dev verifiers (matches validate_verify_host in src/setup.rs).
# Echoes the response body on stdout; caller parses it.
port_check_probe() {
_url="${VERIFY_HOST}/probe"
case "${VERIFY_HOST}" in
https://*)
curl --proto '=https' --tlsv1.2 -fsS -m 60 "${_url}" 2>/dev/null || true
;;
*)
curl -fsS -m 60 "${_url}" 2>/dev/null || true
;;
esac
}
# Orchestrate the inbound check. Sets _PORT_CHECK_INBOUND_STATE to one of:
# pass | fail | skip
# When pass, _PORT_CHECK_INBOUND_IP holds the verifier-detected IP if any.
_PORT_CHECK_INBOUND_STATE=""
_PORT_CHECK_INBOUND_IP=""
_PORT_CHECK_INBOUND_MSG=""
port_check_inbound() {
_PORT_CHECK_INBOUND_STATE=""
_PORT_CHECK_INBOUND_IP=""
_PORT_CHECK_INBOUND_MSG=""
# Need root (or sudo) to bind :25. Tries sudo cred refresh when
# non-root + sudo is available; falls back to skip when not.
if ! port_check_ensure_inbound_privilege; then
_PORT_CHECK_INBOUND_STATE="skip"
_PORT_CHECK_INBOUND_MSG="inbound check requires root and sudo is unavailable; re-run as root"
return 0
fi
_occ="$(port_check_detect_occupancy)"
_listener_pid=""
if [ "${_occ}" = "occupied" ]; then
# Existing daemon will reply to /probe. Warn the operator β
# /probe can't tell aimx apart from Postfix/Sendmail/Exim, so a
# green [ok] here is only meaningful if the holder is aimx.
# Mirrors `aimx portcheck`'s Port25Status::OtherProcess handling.
ui_warn "port 25 is held by another process; verify it's AIMX before running setup"
else
# Free or unknown: spawn a temp Python listener if available.
if ! port_check_have_python3; then
_PORT_CHECK_INBOUND_STATE="skip"
_PORT_CHECK_INBOUND_MSG="install python3 or run 'aimx portcheck' after install"
return 0
fi
_listener_pid="$(port_check_listener_start)"
# Give the listener a moment to bind before /probe fires.
sleep 1
# Liveness probe: if the python child died (bind() failed β
# race / EACCES / port stolen), surface the actual python
# stderr rather than a generic "unreachable". Mirrors the
# synchronous bind+error in src/portcheck.rs:44-53.
if ! ${_PORT_CHECK_SUDO} kill -0 "${_listener_pid}" 2>/dev/null; then
_PORT_CHECK_INBOUND_STATE="fail"
_err=""
if [ -n "${_PORT_CHECK_LISTENER_STDERR}" ] \
&& [ -s "${_PORT_CHECK_LISTENER_STDERR}" ]; then
_err=" ($(head -n 1 "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null))"
rm -f "${_PORT_CHECK_LISTENER_STDERR}" 2>/dev/null || true
fi
_PORT_CHECK_INBOUND_MSG="failed to spawn temp listener on :25${_err}"
_listener_pid=""
return 0
fi
fi
_body="$(port_check_probe)"
if [ -n "${_listener_pid}" ]; then
port_check_listener_stop "${_listener_pid}"
fi
case "${_body}" in
*'"reachable":true'* | *'"reachable": true'*)
_PORT_CHECK_INBOUND_STATE="pass"
_PORT_CHECK_INBOUND_IP="$(printf '%s' "${_body}" \
| sed -n 's/.*"ip"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
;;
*)
_PORT_CHECK_INBOUND_STATE="fail"
_PORT_CHECK_INBOUND_MSG="verifier reported port 25 unreachable from the public internet"
;;
esac
}