Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1944 lines (1818 loc) · 76.3 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·1944 lines (1818 loc) · 76.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
# curl -fsSL https://levelrail.com/install.sh | sudo sh
#
# Installs the levelrail control plane binary, a systemd unit, and Docker
# (if missing) on a single Linux host.
#
# Usage: install.sh [install|check|upgrade|retain|install-cosign|uninstall] [--force] [--purge]
# [--coexist|--no-coexist] [--yes] [--no-cosign] [--http-port N]
# [--https-port N] [--domain HOST] [--profile minimal|standard|suite]
# install (default) preflight checks, then install or repair. Safe to re-run.
# check (also --check) read-only server readiness report: who holds ports
# 80/443, Docker version and isolation, exposed Docker API, disk,
# memory, firewall, and the DNS of --domain. Prints the recommended
# install mode and the exact next command. Changes nothing, runs
# without root (port owners are then only partly visible).
# upgrade replace the binary with a newer release and restart, keeping
# the unit file and data. The previous binaries (last 3) are
# kept in ${LEVELRAIL_INSTALL_DIR}/levelrail.releases for rollback.
# retain download and verify LEVELRAIL_VERSION and keep it beside the
# installed binary, without installing it. Then run
# `sudo levelrail rollback --to <version>` to switch to it.
# install-cosign install the pinned cosign release to /usr/local/bin/cosign
# so release signatures can be verified. install and upgrade do
# this on their own when cosign is missing.
# uninstall remove the service, unit, and binary. Data is kept unless
# --purge is given.
# --force continue even if a preflight check fails.
# --coexist run behind a proxy that already holds ports 80/443: ingress on
# 8088/8443, dashboard on loopback only. Chosen automatically
# when 80/443 are taken by something else.
# --no-coexist do not switch automatically: a taken 80/443 fails preflight
# (same as LEVELRAIL_COEXIST=0).
# --yes, -y accepted for scripts. The installer no longer prompts: it
# switches to coexist mode by itself when 80/443 are taken.
# --no-cosign do not install cosign (same as LEVELRAIL_NO_COSIGN=1). Releases
# are then verified by SHA-256 checksum only.
# --http-port / --https-port ingress ports (override the LEVELRAIL_* env).
# --domain your domain, only used to print the exact proxy command.
# --profile server profile (same as LEVELRAIL_PROFILE): minimal keeps the
# install as light as possible (single node, mesh, plugins and
# experimental features off, short retention), standard is the
# default, suite adds the mesh, plugins and every experimental
# feature. Written to the drop-in as APP_SERVER_PROFILE, which pins
# it: remove that line to change it from Settings. Machines under
# LEVELRAIL_LOW_RAM_MB (default 2048) are shown a minimal
# recommendation, never switched. See docs/profiles.md.
#
# Chosen ports and addresses are written to a systemd drop-in,
# /etc/systemd/system/levelrail.service.d/10-install.conf, which `upgrade`
# reads back and never resets.
#
# Env overrides:
# LEVELRAIL_VERSION release tag to install, e.g. v0.3.0
# LEVELRAIL_CHANNEL stable or beta. Unset means the newest stable
# release, falling back to the newest pre-release
# while no stable release exists.
# LEVELRAIL_INSTALL_DIR where the binary goes (default: /usr/local/bin)
# LEVELRAIL_DATA_DIR control plane data dir (default: /var/lib/levelrail-data)
# LEVELRAIL_BINARY_FILE install this local binary instead of downloading
# LEVELRAIL_BINARY_URL download the binary from this URL instead of a
# GitHub release (no checksum verification)
# LEVELRAIL_SKIP_CHECKSUM=1 install even when checksums.txt is missing or
# does not list the binary (unverified; opt-out only)
# LEVELRAIL_NO_COSIGN=1 never install cosign (see --no-cosign)
# LEVELRAIL_COSIGN_URL download cosign from this URL instead of the pinned
# sigstore release (needs LEVELRAIL_COSIGN_SHA256;
# for tests and air-gapped mirrors)
# LEVELRAIL_COSIGN_BIN where cosign is installed (default: /usr/local/bin/cosign)
# APP_INSTALL_VERIFY signature check on checksums.txt (cosign keyless):
# auto (default) verifies when cosign is installed
# and the release ships a signature; require fails
# unless the signature verifies; off skips it. The
# SHA-256 checksum is always verified. Once a
# signature has verified on this host, a pin under
# the data dir makes auto refuse unsigned releases;
# off still overrides it, with a warning.
# LEVELRAIL_RELEASE_BASE_URL download release assets from this https mirror
# directory instead of GitHub (LEVELRAIL_INSECURE_MIRROR=1
# permits http, for tests only)
# LEVELRAIL_INSTALL_CLI 0 skips installing levelrail-cli to /usr/local/bin
# (default: installed from the same release)
# LEVELRAIL_PUBLIC_IP skip public IP discovery
# LEVELRAIL_SKIP_REACHABILITY=1 skip the external port 80/443 self-test
# LEVELRAIL_MIN_RAM_MB RAM warning threshold (default: 1024)
# LEVELRAIL_MIN_DISK_GB free disk requirement (default: 10)
# LEVELRAIL_MIN_DOCKER_MAJOR minimum Docker major version (default: 24)
# LEVELRAIL_HEALTH_WAIT seconds to wait for the service (default: 60)
# LEVELRAIL_CONFIGURE_UFW set to 1 to allow SSH, 80/tcp, 443/tcp, and 443/udp in
# ufw and enable it if inactive. Off by default.
# LEVELRAIL_DASHBOARD_PORT dashboard/API port (default: 8080). If taken and
# left at its default, the installer picks the
# next free port automatically; set this to pin one.
# LEVELRAIL_HTTP_PORT ingress HTTP port (default: 80)
# LEVELRAIL_HTTPS_PORT ingress HTTPS port (default: 443). Moving either
# ingress port off 80/443 disables automatic ACME
# TLS (Let's Encrypt only validates those two), so
# the installer never does this on its own; it
# fails preflight instead and tells you to set
# these explicitly once you've accepted that trade.
# LEVELRAIL_COEXIST 1 is the same as --coexist
# LEVELRAIL_COEXIST_HTTP_PORT / LEVELRAIL_COEXIST_HTTPS_PORT ingress ports
# coexist mode uses (default: 8088 / 8443)
# LEVELRAIL_RUNTIME auto (default), docker, podman or existing, same as
# --runtime. Podman is never installed for you.
# LEVELRAIL_PODMAN_SOCKET Podman API socket written as APP_CONTAINER_RUNTIME_SOCKET
# (default: unix:///run/podman/podman.sock)
# APP_PUBLIC_HOST written to the drop-in when set
# LEVELRAIL_SOCKET_ACTIVATION systemd owns the ingress sockets, so a
# restart or upgrade of the control plane queues
# connections instead of refusing them (see
# docs/resilience.md). On by default for a new
# install; set 0 to opt out (the control plane
# then binds 80/443 itself). On upgrade, unset
# keeps the current mode, 1 switches an existing
# install over (one brief stop) and 0 switches
# it back.
set -eu
REPO="glincker/levelrail"
BINARY_NAME="levelrail"
SERVICE_NAME="levelrail"
INSTALL_DIR="${LEVELRAIL_INSTALL_DIR:-/usr/local/bin}"
DATA_DIR="${LEVELRAIL_DATA_DIR:-/var/lib/levelrail-data}"
UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service"
SOCKET_ACTIVATION="${LEVELRAIL_SOCKET_ACTIVATION:-}"
HTTP_SOCKET_UNIT="${SERVICE_NAME}-http.socket"
HTTPS_SOCKET_UNIT="${SERVICE_NAME}-https.socket"
BIN_PATH="${INSTALL_DIR}/${BINARY_NAME}"
RELEASES_DIR="${LEVELRAIL_RELEASES_DIR:-${INSTALL_DIR}/${BINARY_NAME}.releases}"
DASHBOARD_PORT="${LEVELRAIL_DASHBOARD_PORT:-8080}"
DASHBOARD_PORT_PINNED=0
[ -z "${LEVELRAIL_DASHBOARD_PORT:-}" ] || DASHBOARD_PORT_PINNED=1
DASHBOARD_BIND=""
HTTP_PORT="${LEVELRAIL_HTTP_PORT:-80}"
HTTPS_PORT="${LEVELRAIL_HTTPS_PORT:-443}"
HTTP_PORT_PINNED=0
HTTPS_PORT_PINNED=0
[ -z "${LEVELRAIL_HTTP_PORT:-}" ] || HTTP_PORT_PINNED=1
[ -z "${LEVELRAIL_HTTPS_PORT:-}" ] || HTTPS_PORT_PINNED=1
COEXIST=0
NO_COEXIST=0
[ "${LEVELRAIL_COEXIST:-}" != "1" ] || COEXIST=1
[ "${LEVELRAIL_COEXIST:-}" != "0" ] || NO_COEXIST=1
COEXIST_ACTIVE=0
SETTINGS_CHANGED=0
COEXIST_HTTP_PORT="${LEVELRAIL_COEXIST_HTTP_PORT:-8088}"
COEXIST_HTTPS_PORT="${LEVELRAIL_COEXIST_HTTPS_PORT:-8443}"
DOMAIN_HINT="${LEVELRAIL_DOMAIN:-}"
PUBLIC_HOST="${APP_PUBLIC_HOST:-}"
PUBLIC_HOST_PINNED=0
[ -z "$PUBLIC_HOST" ] || PUBLIC_HOST_PINNED=1
SERVER_PROFILE="${LEVELRAIL_PROFILE:-}"
PROFILE_PINNED=0
[ -z "$SERVER_PROFILE" ] || PROFILE_PINNED=1
LOW_RAM_MB="${LEVELRAIL_LOW_RAM_MB:-2048}"
RUNTIME_CHOICE="${LEVELRAIL_RUNTIME:-auto}"
RUNTIME_PINNED=0
[ "$RUNTIME_CHOICE" = "auto" ] || RUNTIME_PINNED=1
RUNTIME=""
PODMAN_SOCKET="${LEVELRAIL_PODMAN_SOCKET:-unix:///run/podman/podman.sock}"
DROPIN_DIR="/etc/systemd/system/${SERVICE_NAME}.service.d"
DROPIN_PATH="${DROPIN_DIR}/10-install.conf"
PROXY_DOCS_URL="https://docs.levelrail.com/behind-an-existing-proxy"
# The dashboard is plain HTTP until the operator configures a domain with TLS.
DASHBOARD_SCHEME="http"
HTTPS_ONLY="=https"
MIN_RAM_MB="${LEVELRAIL_MIN_RAM_MB:-1024}"
MIN_DISK_GB="${LEVELRAIL_MIN_DISK_GB:-10}"
MIN_DOCKER_MAJOR="${LEVELRAIL_MIN_DOCKER_MAJOR:-24}"
HEALTH_WAIT="${LEVELRAIL_HEALTH_WAIT:-60}"
VERIFY_MODE="${APP_INSTALL_VERIFY:-auto}"
SIGNATURE_PIN="${DATA_DIR}/release-signature.pin"
PIN_ESCAPE="Install cosign and retry, override once with APP_INSTALL_VERIFY=off, or clear the pin with: sudo ${BINARY_NAME} self-upgrade --unpin-signature"
COSIGN_IDENTITY_REGEXP="https://github.com/${REPO}/\\.github/workflows/release\\.yml@.*"
COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com"
COSIGN_VERSION="v3.1.3"
# SHA-256 of sigstore/cosign ${COSIGN_VERSION} cosign-linux-<arch>, from the release's cosign_checksums.txt.
COSIGN_SHA256_AMD64="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71"
COSIGN_SHA256_ARM64="c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a"
COSIGN_BIN="${LEVELRAIL_COSIGN_BIN:-/usr/local/bin/cosign}"
COSIGN_MARK="${COSIGN_BIN}.installed-by-levelrail"
NO_COSIGN=0
[ "${LEVELRAIL_NO_COSIGN:-0}" != "1" ] || NO_COSIGN=1
log() { printf '%s\n' "$*"; }
warn() { printf 'warning: %s\n' "$*" >&2; }
fatal() {
printf 'error: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage: install.sh [install|check|upgrade|retain|install-cosign|uninstall] [--force] [--purge]
[--coexist|--no-coexist] [--yes] [--no-cosign] [--http-port N]
[--https-port N] [--domain HOST] [--profile minimal|standard|suite]
[--runtime auto|docker|podman|existing]
install (default) preflight checks, then install or repair
check (also --check) read-only readiness report and recommended mode
upgrade replace the binary with a newer release and restart
retain download and verify LEVELRAIL_VERSION and keep it for rollback
install-cosign install cosign so release signatures can be verified
uninstall remove the service, unit, and binary (data kept unless --purge)
--force continue even if a preflight check fails
--coexist ingress on 8088/8443 and dashboard on loopback, for a server
whose ports 80/443 an existing proxy already holds (chosen
automatically when they are taken)
--no-coexist fail preflight instead of switching to coexist mode
--yes accepted for scripts, the installer never prompts
--no-cosign do not install cosign (checksum-only verification)
--profile minimal, standard (default) or suite: which optional subsystems start
--runtime auto (default): use a working Docker, else an existing Podman, else
install Docker. docker: Docker, installed if missing. podman: an
existing Podman (never installed here), best-effort. existing:
Docker or Podman already on the host, install nothing.
Environment (full list in the header of this file):
LEVELRAIL_VERSION=vX.Y.Z install this release instead of the newest
LEVELRAIL_CHANNEL=beta follow pre-releases
LEVELRAIL_BINARY_FILE=P install a local binary (not signature or checksum verified)
LEVELRAIL_DASHBOARD_PORT / LEVELRAIL_HTTP_PORT / LEVELRAIL_HTTPS_PORT
LEVELRAIL_SKIP_REACHABILITY=1 skip the inbound port 80/443 probe
EOF
}
MODE="install"
FORCE=0
PURGE=0
while [ "$#" -gt 0 ]; do
arg="$1"
shift
case "$arg" in
--http-port | --https-port | --domain | --profile | --runtime)
[ "$#" -gt 0 ] || fatal "$arg needs a value"
arg="$arg=$1"
shift
;;
esac
case "$arg" in
install | check | upgrade | retain | install-cosign | uninstall) MODE="$arg" ;;
--check) MODE="check" ;;
--no-coexist) NO_COEXIST=1 ;;
--no-cosign) NO_COSIGN=1 ;;
--force) FORCE=1 ;;
--purge) PURGE=1 ;;
--coexist)
COEXIST=1
NO_COEXIST=0
;;
--yes | -y) ;;
--http-port=*)
HTTP_PORT="${arg#*=}"
HTTP_PORT_PINNED=1
;;
--https-port=*)
HTTPS_PORT="${arg#*=}"
HTTPS_PORT_PINNED=1
;;
--domain=*) DOMAIN_HINT="${arg#*=}" ;;
--runtime=*)
RUNTIME_CHOICE="${arg#*=}"
RUNTIME_PINNED=1
;;
--profile=*)
SERVER_PROFILE="${arg#*=}"
PROFILE_PINNED=1
;;
-h | --help)
usage
exit 0
;;
*) fatal "unknown argument: $arg (see --help)" ;;
esac
done
[ "$PURGE" -eq 0 ] || [ "$MODE" = "uninstall" ] || fatal "--purge only applies to uninstall"
case "$RUNTIME_CHOICE" in
auto | docker | podman | existing) ;;
*) fatal "--runtime must be auto, docker, podman or existing, got: $RUNTIME_CHOICE" ;;
esac
case "$VERIFY_MODE" in
auto | require | off) ;;
*) fatal "APP_INSTALL_VERIFY must be auto, require or off, got: $VERIFY_MODE" ;;
esac
# These values land in a systemd unit and, for DATA_DIR, in rm -rf on purge.
safe_dir() {
case "$2" in
/ | /bin | /boot | /dev | /etc | /home | /lib | /lib64 | /opt | /proc | /root | /run | /sbin | /srv | /sys | /tmp | /usr | /usr/bin | /usr/local | /usr/local/bin | /var | /var/lib)
if [ "$1" = "LEVELRAIL_DATA_DIR" ]; then fatal "$1 must not be a system directory: $2"; fi
;;
esac
case "$2" in
/*) ;;
*) fatal "$1 must be an absolute path: $2" ;;
esac
case "$2" in
*[!A-Za-z0-9._/-]* | *..*) fatal "$1 may only contain letters, digits, dot, dash, underscore and slash, and no '..': $2" ;;
esac
}
safe_port() {
case "$2" in
'' | *[!0-9]*) fatal "$1 must be a port number: $2" ;;
esac
{ [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; } || fatal "$1 must be between 1 and 65535: $2"
}
safe_host() {
case "$2" in
'') return 0 ;;
*[!A-Za-z0-9.:-]*) fatal "$1 may only contain letters, digits, dot, colon and dash: $2" ;;
esac
}
safe_dir LEVELRAIL_DATA_DIR "$DATA_DIR"
safe_dir LEVELRAIL_INSTALL_DIR "$INSTALL_DIR"
safe_port LEVELRAIL_DASHBOARD_PORT "$DASHBOARD_PORT"
safe_port LEVELRAIL_HTTP_PORT "$HTTP_PORT"
safe_port LEVELRAIL_HTTPS_PORT "$HTTPS_PORT"
safe_port LEVELRAIL_COEXIST_HTTP_PORT "$COEXIST_HTTP_PORT"
safe_port LEVELRAIL_COEXIST_HTTPS_PORT "$COEXIST_HTTPS_PORT"
safe_host APP_PUBLIC_HOST "$PUBLIC_HOST"
safe_host --domain "$DOMAIN_HINT"
case "$SERVER_PROFILE" in
'' | minimal | standard | suite) ;;
*) fatal "--profile must be minimal, standard or suite, got: $SERVER_PROFILE" ;;
esac
case "${LEVELRAIL_VERSION:-}" in
*[!A-Za-z0-9._+-]*) fatal "LEVELRAIL_VERSION may only contain letters, digits, dot, dash, plus and underscore" ;;
esac
if [ "$MODE" != "check" ] && [ "$(id -u)" -ne 0 ]; then
if [ -f "$0" ]; then fatal "must run as root, e.g.: sudo sh $0"; fi
fatal "must run as root, e.g.: curl -fsSL <url> | sudo sh"
fi
github_api() {
curl -fsSL --proto "$HTTPS_ONLY" --tlsv1.2 --connect-timeout 10 --max-time 30 \
-H "Accept: application/vnd.github+json" "https://api.github.com/repos/${REPO}/$1" 2>/dev/null
}
# list_releases prints "<tag> <prerelease> <has-asset> <created-at>" per
# release, where has-asset is 1 when the release ships asset $1. Sorted by
# created_at here because the API's own list order is not reliable.
list_releases() {
github_api "releases?per_page=30" | awk -v asset="\"name\": \"$1\"" '
/"tag_name":/ {
if (tag != "" && index(tag, "/") == 0) print tag, pre, has, created
tag = $0; sub(/.*"tag_name": *"/, "", tag); sub(/".*/, "", tag)
pre = "false"; has = 0; created = ""
}
/"prerelease": *true/ { pre = "true" }
/"created_at":/ && created == "" {
created = $0
sub(/.*"created_at": *"/, "", created); sub(/".*/, "", created)
}
index($0, asset) { has = 1 }
END { if (tag != "" && index(tag, "/") == 0) print tag, pre, has, created }' | sort -k4,4r
}
# resolve_version picks the newest release on the channel that actually
# ships a binary for this architecture, skipping releases with no assets.
resolve_version() {
if [ -n "${LEVELRAIL_VERSION:-}" ]; then
VERSION="$LEVELRAIL_VERSION"
return
fi
channel="${LEVELRAIL_CHANNEL:-}"
case "$channel" in
"" | stable | beta) ;;
*) fatal "LEVELRAIL_CHANNEL must be stable or beta, got: $channel" ;;
esac
asset="levelrail-linux-${GOARCH}"
releases="$(list_releases "$asset" || true)"
[ -n "$releases" ] || fatal "could not list releases from GitHub. Set LEVELRAIL_VERSION=vX.Y.Z to pin one."
newest_stable="$(printf '%s\n' "$releases" | awk '$2 == "false" { print $1; exit }')"
stable="$(printf '%s\n' "$releases" | awk '$2 == "false" && $3 == 1 { print $1; exit }')"
newest_any="$(printf '%s\n' "$releases" | awk '{ print $1; exit }')"
any="$(printf '%s\n' "$releases" | awk '$3 == 1 { print $1; exit }')"
case "$channel" in
stable)
VERSION="$stable"
newest="$newest_stable"
;;
beta)
VERSION="$any"
newest="$newest_any"
;;
*)
VERSION="$stable"
newest="$newest_stable"
if [ -z "$VERSION" ]; then
VERSION="$any"
newest="$newest_any"
[ -z "$VERSION" ] || log "No stable release with a ${asset} binary yet, using pre-release ${VERSION}."
fi
;;
esac
[ -n "$VERSION" ] || fatal "no ${channel:-published} release ships ${asset}. Set LEVELRAIL_VERSION=vX.Y.Z to pin one, or LEVELRAIL_BINARY_URL to a binary."
if [ -n "$newest" ] && [ "$newest" != "$VERSION" ]; then
warn "${newest} has no ${asset} binary, installing ${VERSION} instead"
fi
}
detect_arch() {
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) GOARCH="amd64" ;;
aarch64 | arm64) GOARCH="arm64" ;;
*) GOARCH="" ;;
esac
}
# port_listening reports whether anything listens on TCP port $1, read from
# /proc so it works without ss or netstat installed.
port_listening() {
hex="$(printf ':%04X' "$1")"
for table in /proc/net/tcp /proc/net/tcp6; do
[ -r "$table" ] || continue
if awk -v p="$hex" 'NR > 1 && $4 == "0A" && substr($2, length($2) - 4) == p { found = 1 } END { exit !found }' "$table"; then
return 0
fi
done
return 1
}
service_active() {
systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null ||
systemctl is-active --quiet "$HTTP_SOCKET_UNIT" 2>/dev/null
}
# find_free_port scans start..start+19 and prints the first port
# port_listening reports free, or nothing if all 20 are taken.
find_free_port() {
start="$1"
i=0
while [ "$i" -lt 20 ]; do
candidate=$((start + i))
if ! port_listening "$candidate"; then
printf '%s' "$candidate"
return 0
fi
i=$((i + 1))
done
return 1
}
# port_holders describes who listens on TCP port $1: a Docker container
# (name and image) when one publishes it, else the process names ss reports.
# ss shows process names only to root, so this stays best effort.
port_holders() {
found=""
if command -v docker >/dev/null 2>&1; then
found="$(docker ps --filter "publish=$1" --format 'container {{.Names}} (image {{.Image}})' 2>/dev/null | tr '\n' ',' | sed 's/,$//; s/,/, /g' || true)"
fi
if [ -z "$found" ] && command -v ss >/dev/null 2>&1; then
found="$(ss -ltnp "sport = :$1" 2>/dev/null |
sed -n 's/.*users:(("\([^"]*\)",pid=\([0-9]*\).*/\1 (pid \2)/p' | sort -u | tr '\n' ',' | sed 's/,$//; s/,/, /g' || true)"
fi
[ -n "$found" ] || found="an unidentified process (run as root with ss installed to see which)"
printf '%s' "$found"
}
valid_port() {
case "$1" in
'' | *[!0-9]*) return 1 ;;
esac
[ "$1" -ge 1 ] && [ "$1" -le 65535 ]
}
# unit_env prints the last Environment=KEY=value from unit file $1.
unit_env() {
[ -r "$1" ] || return 0
sed -n 's/^Environment="\{0,1\}'"$2"'=\([^"]*\)"\{0,1\}$/\1/p' "$1" | tail -n 1
}
# setting reads KEY from the drop-in first, then from the main unit, where
# installs made before the drop-in existed keep their values.
setting() {
v="$(unit_env "$DROPIN_PATH" "$1")"
[ -n "$v" ] || v="$(unit_env "$UNIT_PATH" "$1")"
printf '%s' "$v"
}
# load_settings reads back what an earlier install chose. Flags and env win.
load_settings() {
v="$(setting APP_INGRESS_HTTP_ADDR)"
if [ "$HTTP_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then HTTP_PORT="${v##*:}"; fi
v="$(setting APP_INGRESS_HTTPS_ADDR)"
if [ "$HTTPS_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then HTTPS_PORT="${v##*:}"; fi
v="$(setting APP_HTTP_ADDR)"
if [ "$DASHBOARD_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then DASHBOARD_PORT="${v##*:}"; fi
case "${v%:*}" in
127.0.0.1 | localhost) DASHBOARD_BIND="127.0.0.1" ;;
esac
if [ "$PUBLIC_HOST_PINNED" -eq 0 ]; then
v="$(setting APP_PUBLIC_HOST)"
case "$v" in
*[!A-Za-z0-9.:-]*) ;;
*) PUBLIC_HOST="$v" ;;
esac
fi
if [ "$PROFILE_PINNED" -eq 0 ]; then
v="$(setting APP_SERVER_PROFILE)"
case "$v" in
minimal | standard | suite) SERVER_PROFILE="$v" ;;
esac
fi
# An earlier Podman install keeps its runtime across upgrades.
v="$(setting APP_CONTAINER_RUNTIME_SOCKET)"
case "$v" in
*podman*)
case "$v" in
*[!A-Za-z0-9/._:-]*) ;;
*)
if [ "$RUNTIME_PINNED" -eq 0 ]; then
RUNTIME="podman"
[ -n "${LEVELRAIL_PODMAN_SOCKET:-}" ] || PODMAN_SOCKET="$v"
fi
;;
esac
;;
esac
if [ "$DASHBOARD_BIND" = "127.0.0.1" ] && [ "$HTTP_PORT" != "80" ] && [ "$HTTPS_PORT" != "443" ]; then
COEXIST_ACTIVE=1
fi
}
# apply_coexist moves ingress off 80/443 (unless a port was set explicitly)
# and binds the dashboard to loopback, the safe default behind another proxy.
apply_coexist() {
if [ "$HTTP_PORT_PINNED" -eq 0 ] && [ "$HTTP_PORT" = "80" ]; then HTTP_PORT="$COEXIST_HTTP_PORT"; fi
if [ "$HTTPS_PORT_PINNED" -eq 0 ] && [ "$HTTPS_PORT" = "443" ]; then HTTPS_PORT="$COEXIST_HTTPS_PORT"; fi
DASHBOARD_BIND="127.0.0.1"
COEXIST_ACTIVE=1
SETTINGS_CHANGED=1
}
# offer_coexist handles ports 80/443 held by something else: it names the
# holder and switches to coexist mode by itself, because the only other
# outcomes are a failed install or a Levelrail that fights your proxy for the
# ports. --no-coexist (LEVELRAIL_COEXIST=0) keeps the old failing preflight.
offer_coexist() {
if [ "$COEXIST" -eq 1 ]; then
apply_coexist
else
[ "$HTTP_PORT_PINNED" -eq 0 ] && [ "$HTTPS_PORT_PINNED" -eq 0 ] || return 0
[ "$COEXIST_ACTIVE" -eq 0 ] || return 0
taken=""
for port in "$HTTP_PORT" "$HTTPS_PORT"; do
if port_listening "$port" && ! service_active; then taken="$taken $port"; fi
done
[ -n "$taken" ] || return 0
log "Ports${taken} are already in use on this server:"
for port in $taken; do log " ${port}: $(port_holders "$port")"; done
if [ "$NO_COEXIST" -eq 1 ]; then
log "Not switching to coexist mode (--no-coexist). Re-run without it to run behind that service."
return 0
fi
if [ "$MODE" = "check" ]; then verb="Would switch"; else verb="Switching"; fi
log "${verb} to coexist mode: ingress on ${COEXIST_HTTP_PORT}/${COEXIST_HTTPS_PORT}, dashboard on loopback only,"
log "and your existing proxy forwards your domains to it (no automatic TLS from Levelrail then)."
apply_coexist
fi
service_active && return 0
if port_listening "$HTTP_PORT" && free="$(find_free_port "$HTTP_PORT")"; then
warn "port ${HTTP_PORT} is also taken, using ${free} for ingress HTTP instead"
HTTP_PORT="$free"
SETTINGS_CHANGED=1
fi
if port_listening "$HTTPS_PORT" && free="$(find_free_port "$HTTPS_PORT")"; then
warn "port ${HTTPS_PORT} is also taken, using ${free} for ingress HTTPS instead"
HTTPS_PORT="$free"
SETTINGS_CHANGED=1
fi
}
SUPPORTED_HOSTS="Supported hosts: Linux (x86_64 or aarch64) with systemd, such as Ubuntu, Debian, Fedora and the RHEL family. OpenRC (Alpine) and Docker-only hosts are not supported."
# require_curl is checked even with --force: every later step downloads with it.
require_curl() {
command -v curl >/dev/null 2>&1 ||
fatal "curl is required and not installed. Install it first (apt-get install -y curl, dnf install -y curl) and re-run."
}
PREFLIGHT_FAILS=0
row() {
printf ' %-10s %-5s %s\n' "$1" "$2" "$3"
[ "$2" != "FAIL" ] || PREFLIGHT_FAILS=$((PREFLIGHT_FAILS + 1))
}
docker_major() {
v="$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)"
[ -n "$v" ] || v="$(docker --version 2>/dev/null | sed -E 's/^[^0-9]*([0-9]+).*/\1/' || true)"
printf '%s' "${v%%.*}"
}
first_existing_dir() {
d="$1"
while [ ! -d "$d" ]; do d="$(dirname "$d")"; done
printf '%s' "$d"
}
# docker_works is true for a Docker engine that answers, not a podman-docker shim.
docker_works() {
command -v docker >/dev/null 2>&1 || return 1
if docker --version 2>&1 | grep -qi podman; then return 1; fi
docker info >/dev/null 2>&1
}
podman_works() {
command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1
}
podman_rootless() {
[ "$(podman info --format '{{.Host.Security.Rootless}}' 2>/dev/null)" = "true" ]
}
podman_version() {
podman --version 2>/dev/null | sed -E 's/^[^0-9]*([0-9][0-9.]*).*/\1/'
}
PODMAN_INSTALL_HINT="apt install podman (Debian, Ubuntu) or dnf install podman (Fedora, RHEL family)"
# resolve_runtime picks docker or podman. It never installs Podman, and it
# only falls back to installing Docker when the choice allows it.
resolve_runtime() {
[ -z "$RUNTIME" ] || [ "$RUNTIME_PINNED" -eq 1 ] || return 0
case "$RUNTIME_CHOICE" in
docker) RUNTIME="docker" ;;
podman) RUNTIME="podman" ;;
existing | auto)
if docker_works; then
RUNTIME="docker"
elif podman_works; then
RUNTIME="podman"
elif [ "$RUNTIME_CHOICE" = "existing" ]; then
RUNTIME="none"
else
RUNTIME="docker"
fi
;;
esac
}
# runtime_rows reports the detected runtime and how well it is supported.
runtime_rows() {
docs="https://docs.levelrail.com/container-runtimes"
case "$RUNTIME" in
podman)
if ! command -v podman >/dev/null 2>&1; then
row runtime FAIL "podman not found. The installer never installs Podman: ${PODMAN_INSTALL_HINT}, or use --runtime docker"
return 0
fi
mode="rootful"
if podman_rootless; then mode="rootless"; fi
row runtime warn "Podman $(podman_version) (${mode}): best-effort, source builds need BuildKit inside dockerd, see ${docs}"
if [ "$mode" = "rootless" ]; then
row podman-mode warn "rootless Podman cannot publish ports below 1024, and its socket is per user: systemctl --user enable --now podman.socket && loginctl enable-linger <user>"
fi
log " Image and compose deploys are expected to work; Dockerfile and Railpack source builds are not verified on Podman yet."
;;
none)
row runtime FAIL "--runtime existing found no working Docker or Podman. Start one, or use --runtime docker to install Docker"
;;
*)
if ! command -v docker >/dev/null 2>&1; then
row runtime ok "Docker will be installed via get.docker.com: supported"
elif docker_rootless; then
row runtime warn "Docker $(docker_major) (rootless): best-effort, source builds need BuildKit inside dockerd and ports below 1024 need a sysctl, see ${docs}"
else
row runtime ok "Docker $(docker_major) (rootful): supported"
fi
;;
esac
}
# docker_rootless is true when the daemon reports rootless mode.
docker_rootless() {
docker info --format '{{.SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless'
}
# docker_tcp_exposed is true when something listens on 2375 (the unencrypted
# Docker API) on an address other than loopback.
docker_tcp_exposed() {
port_listening 2375 || return 1
if command -v ss >/dev/null 2>&1; then
ss -ltnH "sport = :2375" 2>/dev/null | awk '{ print $4 }' | grep -vqE '^(127\.|\[::1\])'
else
return 0
fi
}
# firewall_blocks prints a fix when an active ufw or firewalld does not allow
# ports 80 and 443, and nothing otherwise.
firewall_blocks() {
if command -v ufw >/dev/null 2>&1; then
st="$(ufw status 2>/dev/null || true)"
case "$st" in
*"Status: active"*)
missing=""
for p in 80 443; do
printf '%s\n' "$st" | grep -Eq "^(${p}(/tcp)?|Nginx Full|Apache Full|WWW Full)[[:space:]].*ALLOW" || missing="$missing $p"
done
[ -z "$missing" ] || printf 'ufw is active and does not allow port(s)%s: sudo ufw allow 80/tcp && sudo ufw allow 443/tcp' "$missing"
return 0
;;
esac
elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
svc="$(firewall-cmd --list-services 2>/dev/null) $(firewall-cmd --list-ports 2>/dev/null)"
case "$svc" in
*http*https* | *443/tcp*80/tcp* | *80/tcp*443/tcp*) ;;
*) printf 'firewalld is active and does not list http and https: sudo firewall-cmd --permanent --add-service=http --add-service=https && sudo firewall-cmd --reload' ;;
esac
fi
return 0
}
# preflight_environment adds the checks that are about the surroundings:
# Docker isolation and exposure, the firewall, and the DNS of --domain.
preflight_environment() {
if [ "$RUNTIME" = "docker" ] && command -v docker >/dev/null 2>&1; then
if docker_rootless; then
row docker-mode warn "rootless Docker cannot publish ports 80/443 until net.ipv4.ip_unprivileged_port_start=80 is set"
fi
sock=/var/run/docker.sock
if [ -S "$sock" ]; then
perm="$(stat -c %a "$sock" 2>/dev/null || true)"
case "${perm#"${perm%?}"}" in
2 | 3 | 6 | 7) row docker-socket warn "${sock} is writable by every local user (mode ${perm}), which is root on this host: chmod 660 ${sock}" ;;
esac
fi
fi
if docker_tcp_exposed; then
row docker-api FAIL "the Docker API listens on a network address without TLS (port 2375): anyone who reaches it owns this server. Remove the tcp:// host from the daemon settings"
fi
fw="$(firewall_blocks)"
if [ -n "$fw" ]; then row firewall warn "$fw"; fi
if [ -n "$DOMAIN_HINT" ]; then
resolved="$(getent ahostsv4 "$DOMAIN_HINT" 2>/dev/null | awk '{ print $1 }' | sort -u | tr '\n' ' ' | sed 's/ $//')"
if [ -z "$resolved" ]; then
row dns warn "${DOMAIN_HINT} does not resolve: create an A record pointing at this server's public IPv4 address"
else
here=0
for ip in $(local_ips); do
case " $resolved " in *" $ip "*) here=1 ;; esac
done
if [ "$here" -eq 1 ]; then
row dns ok "${DOMAIN_HINT} points at this server (${resolved})"
else
row dns warn "${DOMAIN_HINT} resolves to ${resolved}, which is not an address on this server (fine behind NAT or a load balancer)"
fi
fi
fi
}
preflight() {
log "Preflight checks:"
if [ "$(id -u)" -eq 0 ]; then
row root ok "running as root"
else
row root warn "not root, so process names of port holders may be hidden (re-run with sudo for full detail)"
fi
detect_arch
if [ -n "$GOARCH" ]; then row arch ok "$(uname -m)"; else row arch FAIL "$(uname -m) (supported: x86_64, aarch64)"; fi
os="$(uname -s)"
distro=""
# shellcheck disable=SC1091
if [ -r /etc/os-release ]; then distro="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-}")"; fi
if [ "$os" = "Linux" ]; then row os ok "${distro:-Linux}"; else row os FAIL "$os (Linux only)"; fi
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
row systemd ok "running"
else
row systemd FAIL "systemd is required as the init system (OpenRC and Alpine are not supported)"
fi
if command -v curl >/dev/null 2>&1; then row curl ok "found"; else row curl FAIL "curl is required: apt-get install -y curl or dnf install -y curl"; fi
resolve_runtime
runtime_rows
if [ "$RUNTIME" != "docker" ]; then
:
elif command -v docker >/dev/null 2>&1; then
major="$(docker_major)"
case "$major" in
'' | *[!0-9]*) row docker warn "installed, version unknown" ;;
*) if [ "$major" -ge "$MIN_DOCKER_MAJOR" ]; then row docker ok "version $major"; else row docker FAIL "version $major, need >= $MIN_DOCKER_MAJOR: upgrade Docker, or lower LEVELRAIL_MIN_DOCKER_MAJOR"; fi ;;
esac
else
row docker ok "not found, will install via get.docker.com"
fi
mem_kb="$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2>/dev/null || echo 0)"
mem_mb=$((${mem_kb:-0} / 1024))
if [ "$mem_mb" -ge "$MIN_RAM_MB" ]; then row ram ok "${mem_mb} MB"; else row ram warn "${mem_mb} MB, ${MIN_RAM_MB} MB or more recommended"; fi
if [ -z "$SERVER_PROFILE" ] && [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -lt "$LOW_RAM_MB" ]; then
row profile ok "standard. This machine has little memory: re-run with --profile minimal for the lightest install"
fi
disk_dir="$(first_existing_dir "$DATA_DIR")"
free_kb="$(df -Pk "$disk_dir" 2>/dev/null | awk 'NR == 2 { print $4 }')"
free_gb=$((${free_kb:-0} / 1024 / 1024))
if [ "$free_gb" -ge "$MIN_DISK_GB" ]; then row disk ok "${free_gb} GB free on ${disk_dir}"; else row disk FAIL "${free_gb} GB free on ${disk_dir}, need ${MIN_DISK_GB} GB"; fi
offer_coexist
for port in "$HTTP_PORT" "$HTTPS_PORT"; do
if ! port_listening "$port"; then
row "port $port" ok "free"
elif service_active; then
row "port $port" ok "held by ${SERVICE_NAME} itself (already installed)"
else
row "port $port" FAIL "already in use by $(port_holders "$port"). Automatic TLS needs 80/443. Re-run with --coexist to run behind it, or set LEVELRAIL_HTTP_PORT/LEVELRAIL_HTTPS_PORT to pick other ports (accepting that automatic ACME certs won't work)"
fi
done
if ! port_listening "$DASHBOARD_PORT"; then
row "port $DASHBOARD_PORT" ok "free"
elif service_active; then
row "port $DASHBOARD_PORT" ok "held by ${SERVICE_NAME} itself (already installed)"
elif [ "$DASHBOARD_PORT_PINNED" -eq 0 ] && free_port="$(find_free_port "$DASHBOARD_PORT")"; then
row "port $DASHBOARD_PORT" ok "taken, using $free_port instead"
DASHBOARD_PORT="$free_port"
SETTINGS_CHANGED=1
else
row "port $DASHBOARD_PORT" FAIL "already in use by another process"
fi
preflight_environment
[ "$MODE" != "check" ] || return 0
require_curl
if [ "$PREFLIGHT_FAILS" -gt 0 ]; then
log "$SUPPORTED_HOSTS"
[ "$FORCE" -eq 1 ] || fatal "${PREFLIGHT_FAILS} preflight check(s) failed. Fix them, or re-run with --force to continue anyway."
warn "${PREFLIGHT_FAILS} preflight check(s) failed, continuing because of --force"
fi
[ -n "$GOARCH" ] || fatal "cannot continue on an unsupported architecture"
}
# ensure_podman wires the Docker-compatible API socket of an existing Podman.
# Rootless sockets are per user, so only the rootful one is enabled here.
ensure_podman() {
command -v podman >/dev/null 2>&1 || fatal "podman not found. The installer never installs Podman: ${PODMAN_INSTALL_HINT}, or use --runtime docker"
if podman_rootless; then
fatal "podman runs rootless for this user. Run as that user: systemctl --user enable --now podman.socket && loginctl enable-linger \$USER, then point APP_CONTAINER_RUNTIME_SOCKET at the user socket"
fi
systemctl enable --now podman.socket
if [ ! -S "${PODMAN_SOCKET#unix://}" ]; then
warn "${PODMAN_SOCKET} is not there yet. Check: systemctl status podman.socket"
fi
}
ensure_runtime() {
case "$RUNTIME" in
podman) ensure_podman ;;
none) fatal "--runtime existing found no working Docker or Podman" ;;
*) ensure_docker ;;
esac
}
ensure_docker() {
if ! command -v docker >/dev/null 2>&1; then
log "Installing Docker via get.docker.com..."
curl -fsSL --proto "$HTTPS_ONLY" --tlsv1.2 --connect-timeout 10 --max-time 120 https://get.docker.com | sh
fi
systemctl enable --now docker
}
SAME_BINARY=0
fetch_binary() {
dest="$1"
if [ -n "${LEVELRAIL_BINARY_FILE:-}" ]; then
[ -f "$LEVELRAIL_BINARY_FILE" ] || fatal "LEVELRAIL_BINARY_FILE not found: $LEVELRAIL_BINARY_FILE"
log "Using local binary ${LEVELRAIL_BINARY_FILE}."
warn "a local binary is not checksum or signature verified"
cp "$LEVELRAIL_BINARY_FILE" "$dest"
VERSION="${LEVELRAIL_VERSION:-local build}"
return
fi
if [ -n "${LEVELRAIL_BINARY_URL:-}" ]; then
log "Downloading ${LEVELRAIL_BINARY_URL}..."
curl -fsSL --connect-timeout 10 --max-time 300 -o "$dest" "$LEVELRAIL_BINARY_URL" || fatal "download failed: $LEVELRAIL_BINARY_URL"
VERSION="${LEVELRAIL_VERSION:-custom build}"
return
fi
if signature_pinned && [ "$VERIFY_MODE" = "off" ]; then
warn "APP_INSTALL_VERIFY=off overrides the signature pin (${SIGNATURE_PIN}): this release is installed without a signature check"
fi
cosign_strictness=""
! signature_pinned || cosign_strictness="strict"
[ "$NO_COSIGN" -eq 1 ] || [ "$VERIFY_MODE" = "off" ] || ensure_cosign "$cosign_strictness"
resolve_version
asset="levelrail-linux-${GOARCH}"
base_url="${LEVELRAIL_RELEASE_BASE_URL:-https://github.com/${REPO}/releases/download/${VERSION}}"
proto="$HTTPS_ONLY"
case "$base_url" in
https://*) ;;
http://*)
[ "${LEVELRAIL_INSECURE_MIRROR:-}" = "1" ] || fatal "LEVELRAIL_RELEASE_BASE_URL must be https (LEVELRAIL_INSECURE_MIRROR=1 allows http for tests)"
proto="=http"
;;
*) fatal "LEVELRAIL_RELEASE_BASE_URL must start with https://" ;;
esac
sums="${dest}.checksums"
expected=""
have_sums=0
if curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 60 -o "$sums" "${base_url}/checksums.txt" 2>/dev/null; then
have_sums=1
verify_signature "$sums" "$base_url" "$proto"
expected="$(grep " ${asset}\$" "$sums" | awk '{ print $1 }')"
fi
if [ -n "$expected" ] && [ -f "$BIN_PATH" ] && [ "$(sha256sum "$BIN_PATH" | awk '{ print $1 }')" = "$expected" ]; then
log "${VERSION} is already installed and its checksum matches, skipping the download."
cp "$BIN_PATH" "$dest"
SAME_BINARY=1
return
fi
log "Downloading ${asset} ${VERSION}..."
curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 300 -o "$dest" "${base_url}/${asset}" ||
fatal "download failed: ${base_url}/${asset}. The release may not ship this binary; pick another with LEVELRAIL_VERSION."
if [ "$have_sums" -eq 0 ]; then
checksum_unavailable "no checksums.txt published for ${VERSION}"
elif [ -n "$expected" ]; then
actual="$(sha256sum "$dest" | awk '{ print $1 }')"
[ "$expected" = "$actual" ] || fatal "checksum mismatch for ${asset}: expected ${expected}, got ${actual}"
log "Checksum verified."
else
checksum_unavailable "${asset} is not listed in checksums.txt for ${VERSION}"
fi
}
# ensure_cosign installs the pinned cosign release when none is on PATH, so
# the release signature can be checked. Unless $1 is "strict" a failure only
# warns: the SHA-256 checksum is still verified and the install carries on.
ensure_cosign() {
strict="${1:-}"
if command -v cosign >/dev/null 2>&1; then
log "cosign already installed ($(command -v cosign))."
return 0
fi
cosign_url="${LEVELRAIL_COSIGN_URL:-}"
cosign_proto="$HTTPS_ONLY"
if [ -n "$cosign_url" ]; then
cosign_sha="${LEVELRAIL_COSIGN_SHA256:-}"
if [ -z "$cosign_sha" ]; then
cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL needs LEVELRAIL_COSIGN_SHA256"
return 0
fi
case "$cosign_url" in
https://*) ;;
http://*)
if [ "${LEVELRAIL_INSECURE_MIRROR:-}" != "1" ]; then
cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL must be https"
return 0
fi
cosign_proto="=http"
;;
*)
cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL must start with https://"
return 0
;;
esac
else
case "$GOARCH" in
amd64) cosign_sha="$COSIGN_SHA256_AMD64" ;;
arm64) cosign_sha="$COSIGN_SHA256_ARM64" ;;
*)
cosign_unavailable "$strict" "no pinned cosign for $(uname -m)"
return 0
;;
esac
cosign_url="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${GOARCH}"
fi
cosign_tmp="$(mktemp)"
log "Installing cosign ${COSIGN_VERSION} to ${COSIGN_BIN}..."
if ! curl -fsSL --proto "$cosign_proto" --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$cosign_tmp" "$cosign_url" 2>/dev/null; then
rm -f "$cosign_tmp"
cosign_unavailable "$strict" "could not download cosign from ${cosign_url}"
return 0
fi
cosign_actual="$(sha256sum "$cosign_tmp" | awk '{ print $1 }')"
if [ "$cosign_actual" != "$cosign_sha" ]; then
rm -f "$cosign_tmp"
cosign_unavailable "$strict" "cosign checksum mismatch: expected ${cosign_sha}, got ${cosign_actual}"
return 0
fi
if ! install -m 0755 "$cosign_tmp" "$COSIGN_BIN" 2>/dev/null; then
rm -f "$cosign_tmp"
cosign_unavailable "$strict" "could not write ${COSIGN_BIN}"
return 0
fi
rm -f "$cosign_tmp"
: >"$COSIGN_MARK" 2>/dev/null || true
log "Installed cosign ${COSIGN_VERSION}."
}
cosign_unavailable() {