underpost 3.2.21 → 3.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/workflows/ghpkg.ci.yml +1 -1
  2. package/.github/workflows/gitlab.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +2 -2
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +178 -1
  8. package/CLI-HELP.md +58 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +13 -1
  12. package/docker-compose.yml +224 -0
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +27 -15
  17. package/scripts/kubeadm-node-setup.sh +317 -0
  18. package/scripts/rocky-kickstart.sh +877 -185
  19. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  20. package/scripts/test-monitor.sh +242 -78
  21. package/src/cli/baremetal.js +717 -16
  22. package/src/cli/cluster.js +3 -1
  23. package/src/cli/deploy.js +11 -10
  24. package/src/cli/docker-compose.js +591 -0
  25. package/src/cli/fs.js +35 -11
  26. package/src/cli/index.js +83 -0
  27. package/src/cli/kickstart.js +142 -20
  28. package/src/cli/monitor.js +114 -29
  29. package/src/cli/repository.js +75 -11
  30. package/src/cli/run.js +275 -6
  31. package/src/cli/ssh.js +190 -0
  32. package/src/cli/static.js +2 -2
  33. package/src/client/components/core/PanelForm.js +44 -44
  34. package/src/{server → client-builder}/client-build-docs.js +15 -5
  35. package/src/{server → client-builder}/client-build-live.js +3 -3
  36. package/src/{server → client-builder}/client-build.js +25 -22
  37. package/src/{server → client-builder}/client-dev-server.js +3 -3
  38. package/src/{server → client-builder}/client-icons.js +2 -2
  39. package/src/{server → client-builder}/ssr.js +5 -5
  40. package/src/client.build.js +1 -1
  41. package/src/client.dev.js +1 -1
  42. package/src/index.js +12 -1
  43. package/src/mailer/EmailRender.js +1 -1
  44. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  45. package/src/runtime/express/Express.js +2 -2
  46. package/src/runtime/nginx/Nginx.js +250 -0
  47. package/src/server/catalog.js +7 -14
  48. package/src/server/conf.js +61 -12
  49. package/src/server/start.js +17 -5
  50. package/src/server.js +1 -1
  51. package/test/deploy-monitor.test.js +33 -5
  52. package/typedoc.json +3 -1
  53. package/src/server/ipfs-client.js +0 -597
  54. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  55. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -1,106 +1,254 @@
1
1
  #!/bin/bash
2
- # Rocky Linux 9 - Anaconda %pre Ephemeral Commissioning Script
3
- # Variables ROOT_PASS, AUTHORIZED_KEYS, ADMIN_USER must be set before this script runs.
2
+ # Rocky Linux 9 - Anaconda %pre Ephemeral Commissioning + Disk Install Script
3
+ #
4
+ # This script runs inside the ephemeral Anaconda live environment (booted via
5
+ # iPXE/HTTP with inst.sshd). It drives the full unattended lifecycle:
6
+ #
7
+ # 1. ephemeral boot up (Anaconda live, this %pre script starts)
8
+ # 2. ssh ready (key-only sshd up and verified listening)
9
+ # 3. metadata posted (async POST stage=ssh-ready to bootstrap server)
10
+ # 4. remote install executed (controller SSHes in and runs the installer,
11
+ # or AUTO_INSTALL fallback self-triggers it)
12
+ # 5. disk installation (POST stage=installing; Rocky written to disk)
13
+ # 6. install completed (POST stage=completed)
14
+ # 7. reboot (boot order points at the freshly written disk)
15
+ # 8. boot into deployed OS
16
+ #
17
+ # Variables injected by the kickstart %pre header (kickstart.js):
18
+ # ROOT_PASS, AUTHORIZED_KEYS, ADMIN_USER,
19
+ # BOOTSTRAP_URL, WORKFLOW_ID, SYSTEM_ID, TARGET_HOSTNAME,
20
+ # SSH_PORT, INSTALL_DISK_HINT, AUTO_INSTALL
4
21
 
5
22
  set +e
6
23
 
7
- # 1. Set root password
24
+ SSH_PORT="${SSH_PORT:-22}"
25
+ AUTO_INSTALL="${AUTO_INSTALL:-1}"
26
+ # Fallback: if no remote install trigger arrives within this window, the
27
+ # ephemeral runtime self-installs so a single missed handshake never bricks the
28
+ # flow. The controller normally triggers far sooner over SSH.
29
+ AUTO_INSTALL_FALLBACK_SECONDS="${AUTO_INSTALL_FALLBACK_SECONDS:-600}"
30
+
31
+ KS_LOG=/tmp/ks-pre.log
32
+ READY_FLAG=/tmp/.underpost-ssh-ready-posted
33
+ TRIGGER_FILE=/tmp/.underpost-install-trigger
34
+ INSTALL_LOCK=/tmp/.underpost-install-running
35
+ INSTALL_DONE=/tmp/.underpost-install-done
36
+ INSTALLER=/usr/local/bin/underpost-install.sh
37
+ TARGET_MNT=/mnt/sysimage-underpost
38
+
39
+ log() { echo "$(date): $*" | tee -a "$KS_LOG"; }
40
+
41
+ # Reference feedback on the physical/serial console. Use printf with \r\n so
42
+ # lines render correctly on serial and physical terminals. Best-effort: never
43
+ # fails if /dev/console is unavailable.
44
+ console_log() { printf "[underpost] %s\r\n" "$*" > /dev/console 2>/dev/null || true; }
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # 1. Root password (emergency console fallback only; SSH stays key-only)
48
+ # ---------------------------------------------------------------------------
8
49
  if [ -n "$ROOT_PASS" ]; then
9
- echo "root:$ROOT_PASS" | chpasswd 2>/dev/null || \
50
+ echo "root:$ROOT_PASS" | chpasswd 2>/dev/null || \
10
51
  echo "$ROOT_PASS" | passwd --stdin root 2>/dev/null || {
11
- HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \
12
- openssl passwd -6 "$ROOT_PASS" 2>/dev/null)
13
- [ -n "$HASH" ] && sed -i "s|^root:[^:]*:|root:$HASH:|" /etc/shadow 2>/dev/null
52
+ HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \
53
+ openssl passwd -6 "$ROOT_PASS" 2>/dev/null)
54
+ [ -n "$HASH" ] && sed -i "s|^root:[^:]*:|root:$HASH:|" /etc/shadow 2>/dev/null
14
55
  }
15
56
  fi
16
57
 
17
- # 2. SSH authorized_keys for root
18
58
  if [ -n "$AUTHORIZED_KEYS" ]; then
19
- mkdir -p /root/.ssh && chmod 700 /root/.ssh
20
- echo "$AUTHORIZED_KEYS" > /root/.ssh/authorized_keys
21
- chmod 600 /root/.ssh/authorized_keys
59
+ mkdir -p /root/.ssh && chmod 700 /root/.ssh
60
+ echo "$AUTHORIZED_KEYS" > /root/.ssh/authorized_keys
61
+ chmod 600 /root/.ssh/authorized_keys
22
62
  fi
23
63
 
24
- # 3. Create admin user
64
+ # ---------------------------------------------------------------------------
65
+ # 2. Admin user (parity with previous behavior)
66
+ # ---------------------------------------------------------------------------
25
67
  if command -v useradd >/dev/null 2>&1; then
26
- useradd -m -G wheel "$ADMIN_USER" 2>/dev/null || true
68
+ useradd -m -G wheel "$ADMIN_USER" 2>/dev/null || true
27
69
  else
28
- NEXT_UID=$(awk -F: 'BEGIN{max=999} $3>max && $3<60000{max=$3} END{print max+1}' /etc/passwd 2>/dev/null || echo 1001)
29
- if ! grep -q "^$ADMIN_USER:" /etc/passwd 2>/dev/null; then
30
- echo "$ADMIN_USER:x:$NEXT_UID:$NEXT_UID:$ADMIN_USER:/home/$ADMIN_USER:/bin/bash" >> /etc/passwd
31
- echo "$ADMIN_USER:x:$NEXT_UID:" >> /etc/group 2>/dev/null
32
- echo "$ADMIN_USER:!:19000:0:99999:7:::" >> /etc/shadow 2>/dev/null
33
- mkdir -p /home/$ADMIN_USER
34
- fi
70
+ NEXT_UID=$(awk -F: 'BEGIN{max=999} $3>max && $3<60000{max=$3} END{print max+1}' /etc/passwd 2>/dev/null || echo 1001)
71
+ if ! grep -q "^$ADMIN_USER:" /etc/passwd 2>/dev/null; then
72
+ echo "$ADMIN_USER:x:$NEXT_UID:$NEXT_UID:$ADMIN_USER:/home/$ADMIN_USER:/bin/bash" >> /etc/passwd
73
+ echo "$ADMIN_USER:x:$NEXT_UID:" >> /etc/group 2>/dev/null
74
+ echo "$ADMIN_USER:!:19000:0:99999:7:::" >> /etc/shadow 2>/dev/null
75
+ mkdir -p /home/$ADMIN_USER
76
+ fi
35
77
  fi
36
78
 
37
- if [ -n "$ROOT_PASS" ]; then
38
- echo "$ADMIN_USER:$ROOT_PASS" | chpasswd 2>/dev/null || \
39
- echo "$ROOT_PASS" | passwd --stdin "$ADMIN_USER" 2>/dev/null || {
40
- HASH=$(python3 -c "import crypt; print(crypt.crypt('$ROOT_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \
41
- openssl passwd -6 "$ROOT_PASS" 2>/dev/null)
42
- [ -n "$HASH" ] && sed -i "s|^$ADMIN_USER:[^:]*:|$ADMIN_USER:$HASH:|" /etc/shadow 2>/dev/null
79
+ if [ -n "$ADMIN_PASS" ]; then
80
+ echo "$ADMIN_USER:$ADMIN_PASS" | chpasswd 2>/dev/null || \
81
+ echo "$ADMIN_PASS" | passwd --stdin "$ADMIN_USER" 2>/dev/null || {
82
+ HASH=$(python3 -c "import crypt; print(crypt.crypt('$ADMIN_PASS', crypt.mksalt(crypt.METHOD_SHA512)))" 2>/dev/null || \
83
+ openssl passwd -6 "$ADMIN_PASS" 2>/dev/null)
84
+ [ -n "$HASH" ] && sed -i "s|^$ADMIN_USER:[^:]*:|$ADMIN_USER:$HASH:|" /etc/shadow 2>/dev/null
43
85
  }
44
86
  fi
45
87
 
46
88
  if [ -n "$AUTHORIZED_KEYS" ]; then
47
- mkdir -p /home/$ADMIN_USER/.ssh && chmod 700 /home/$ADMIN_USER/.ssh
48
- echo "$AUTHORIZED_KEYS" > /home/$ADMIN_USER/.ssh/authorized_keys
49
- chmod 600 /home/$ADMIN_USER/.ssh/authorized_keys
50
- chown -R $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.ssh 2>/dev/null || true
89
+ mkdir -p /home/$ADMIN_USER/.ssh && chmod 700 /home/$ADMIN_USER/.ssh
90
+ echo "$AUTHORIZED_KEYS" > /home/$ADMIN_USER/.ssh/authorized_keys
91
+ chmod 600 /home/$ADMIN_USER/.ssh/authorized_keys
92
+ chown -R $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.ssh 2>/dev/null || true
51
93
  fi
52
94
 
53
95
  if [ -d /etc/sudoers.d ]; then
54
- echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER
55
- chmod 0440 /etc/sudoers.d/$ADMIN_USER
56
- elif [ -f /etc/sudoers ]; then
57
- echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
96
+ echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER
97
+ chmod 0440 /etc/sudoers.d/$ADMIN_USER
98
+ elif [ -f /etc/sudoers ]; then
99
+ echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
100
+ fi
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # 3. Configure sshd for key-only automation
104
+ # ---------------------------------------------------------------------------
105
+ log "[sshd] Starting SSH configuration..."
106
+
107
+ mkdir -p /etc/ssh /root/.ssh /var/lib/anaconda/ssh /run/install/ssh
108
+ chmod 755 /etc/ssh
109
+ chmod 700 /root/.ssh
110
+ chmod 700 /var/lib/anaconda/ssh 2>/dev/null || true
111
+ chmod 700 /run/install/ssh 2>/dev/null || true
112
+
113
+ for KEY_DEST in \
114
+ /root/.ssh/authorized_keys \
115
+ /var/lib/anaconda/ssh/authorized_keys \
116
+ /run/install/ssh/authorized_keys; do
117
+ mkdir -p "$(dirname "$KEY_DEST")"
118
+ chmod 700 "$(dirname "$KEY_DEST")" 2>/dev/null || true
119
+ if [ -n "$AUTHORIZED_KEYS" ]; then
120
+ echo "$AUTHORIZED_KEYS" > "$KEY_DEST"
121
+ chmod 600 "$KEY_DEST"
122
+ chown root:root "$KEY_DEST" 2>/dev/null || true
123
+ log "[sshd] Wrote authorized_keys to $KEY_DEST"
124
+ fi
125
+ done
126
+
127
+ if [ -n "$AUTHORIZED_KEYS" ] && [ -n "$ADMIN_USER" ]; then
128
+ mkdir -p "/home/$ADMIN_USER/.ssh"
129
+ chmod 700 "/home/$ADMIN_USER/.ssh"
130
+ echo "$AUTHORIZED_KEYS" > "/home/$ADMIN_USER/.ssh/authorized_keys"
131
+ chmod 600 "/home/$ADMIN_USER/.ssh/authorized_keys"
132
+ chown -R "$ADMIN_USER:$ADMIN_USER" "/home/$ADMIN_USER/.ssh" 2>/dev/null || true
133
+ log "[sshd] Wrote authorized_keys to /home/$ADMIN_USER/.ssh/authorized_keys"
58
134
  fi
59
135
 
60
- # 4. Configure sshd
61
- if [ -f /etc/ssh/sshd_config ]; then
62
- sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
63
- sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
64
- sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
65
- sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication yes/' /etc/ssh/sshd_config
66
- sed -i 's/^#*UsePAM.*/UsePAM yes/' /etc/ssh/sshd_config
67
- sed -i 's/^#*KbdInteractiveAuthentication.*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config
68
- grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
69
- grep -q "^PermitRootLogin" /etc/ssh/sshd_config || echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
70
- grep -q "^PubkeyAuthentication" /etc/ssh/sshd_config || echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config
136
+ # Key-only when keys are present; fall back to password auth only if no key was
137
+ # injected (so automation is never fully locked out). Root login is permitted
138
+ # strictly via authorized keys (prohibit-password) for non-interactive batch
139
+ # command execution from the controller.
140
+ if [ -n "$AUTHORIZED_KEYS" ]; then
141
+ SSHD_PASSWORD_AUTH="no"
142
+ SSHD_PERMIT_ROOT="prohibit-password"
143
+ SSHD_KBD_INTERACTIVE="no"
144
+ log "[sshd] authorized_keys present -> key-only mode (PasswordAuthentication no, PermitRootLogin prohibit-password)"
71
145
  else
72
- mkdir -p /etc/ssh
73
- cat > /etc/ssh/sshd_config << 'SSHEOF'
74
- Port 22
75
- PermitRootLogin yes
146
+ SSHD_PASSWORD_AUTH="yes"
147
+ SSHD_PERMIT_ROOT="yes"
148
+ SSHD_KBD_INTERACTIVE="yes"
149
+ log "[sshd] WARNING: no authorized_keys injected -> falling back to password auth"
150
+ fi
151
+
152
+ cat > /etc/ssh/sshd_config << SSHEOF
153
+ Port ${SSH_PORT}
154
+ Protocol 2
155
+ HostKey /etc/ssh/ssh_host_rsa_key
156
+ HostKey /etc/ssh/ssh_host_ecdsa_key
157
+ HostKey /etc/ssh/ssh_host_ed25519_key
158
+ SyslogFacility AUTH
159
+ LogLevel INFO
160
+ LoginGraceTime 120
161
+ PermitRootLogin ${SSHD_PERMIT_ROOT}
162
+ StrictModes no
76
163
  PubkeyAuthentication yes
77
- AuthorizedKeysFile .ssh/authorized_keys
78
- PasswordAuthentication yes
79
- ChallengeResponseAuthentication yes
80
- KbdInteractiveAuthentication yes
81
- UsePAM yes
164
+ AuthorizedKeysFile /root/.ssh/authorized_keys /var/lib/anaconda/ssh/authorized_keys /run/install/ssh/authorized_keys .ssh/authorized_keys
165
+ IgnoreRhosts yes
166
+ HostbasedAuthentication no
167
+ PermitEmptyPasswords no
168
+ PasswordAuthentication ${SSHD_PASSWORD_AUTH}
169
+ ChallengeResponseAuthentication ${SSHD_KBD_INTERACTIVE}
170
+ KbdInteractiveAuthentication ${SSHD_KBD_INTERACTIVE}
171
+ UsePAM no
172
+ X11Forwarding no
173
+ PrintMotd no
174
+ TCPKeepAlive yes
175
+ AcceptEnv LANG LC_*
82
176
  Subsystem sftp /usr/libexec/openssh/sftp-server
177
+ UseDNS no
178
+ AllowTcpForwarding yes
179
+ ClientAliveInterval 30
180
+ ClientAliveCountMax 3
83
181
  SSHEOF
84
- fi
85
182
 
86
183
  if [ -d /etc/ssh/sshd_config.d ]; then
87
- cat > /etc/ssh/sshd_config.d/00-underpost.conf << 'SSHCONF'
88
- PermitRootLogin yes
89
- PasswordAuthentication yes
90
- PubkeyAuthentication yes
91
- KbdInteractiveAuthentication yes
92
- SSHCONF
184
+ rm -f /etc/ssh/sshd_config.d/*.conf 2>/dev/null || true
93
185
  fi
94
186
 
95
- if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then
96
- ssh-keygen -A 2>/dev/null || {
97
- ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N "" 2>/dev/null
98
- ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N "" 2>/dev/null
99
- ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" 2>/dev/null
100
- }
187
+ log "[sshd] Checking/Generating SSH host keys..."
188
+
189
+ _generate_host_key() {
190
+ local TYPE=$1
191
+ local FILE=$2
192
+ local BITS=$3
193
+
194
+ if [ -f "$FILE" ]; then
195
+ log "[sshd] Host key $FILE already exists, skipping."
196
+ return 0
197
+ fi
198
+
199
+ log "[sshd] Generating $TYPE host key: $FILE ..."
200
+
201
+ if command -v ssh-keygen >/dev/null 2>&1; then
202
+ ssh-keygen -t "$TYPE" -f "$FILE" -N "" >> "$KS_LOG" 2>&1
203
+ [ -f "$FILE" ] && { log "[sshd] ssh-keygen succeeded for $TYPE"; return 0; }
204
+ fi
205
+
206
+ if command -v openssl >/dev/null 2>&1; then
207
+ case "$TYPE" in
208
+ rsa) openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:${BITS:-2048} -out "$FILE" >> "$KS_LOG" 2>&1 ;;
209
+ ecdsa) openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:prime256v1 -out "$FILE" >> "$KS_LOG" 2>&1 ;;
210
+ ed25519) openssl genpkey -algorithm ED25519 -out "$FILE" >> "$KS_LOG" 2>&1 ;;
211
+ esac
212
+ openssl pkey -in "$FILE" -pubout > "${FILE}.pub" 2>> "$KS_LOG"
213
+ [ -f "$FILE" ] && { log "[sshd] openssl succeeded for $TYPE"; return 0; }
214
+ fi
215
+
216
+ log "[sshd] WARNING: key generation fallback for $TYPE (placeholder)"
217
+ dd if=/dev/urandom bs=1024 count=4 of="$FILE" 2>/dev/null
218
+ echo "placeholder public key" > "${FILE}.pub"
219
+ [ -f "$FILE" ]
220
+ }
221
+
222
+ _generate_host_key rsa /etc/ssh/ssh_host_rsa_key 2048
223
+ _generate_host_key ecdsa /etc/ssh/ssh_host_ecdsa_key 256
224
+ _generate_host_key ed25519 /etc/ssh/ssh_host_ed25519_key 256
225
+
226
+ chmod 600 /etc/ssh/ssh_host_*_key 2>/dev/null || true
227
+ chmod 644 /etc/ssh/ssh_host_*_key.pub 2>/dev/null || true
228
+
229
+ log "[sshd] Restarting sshd to apply key-only config..."
230
+ if command -v systemctl >/dev/null 2>&1; then
231
+ systemctl restart sshd 2>/dev/null || systemctl restart sshd.service 2>/dev/null || true
101
232
  fi
233
+ if ! pidof sshd >/dev/null 2>&1; then
234
+ SSH_OLD_PID=$(pidof sshd 2>/dev/null | awk '{print $1}')
235
+ [ -n "$SSH_OLD_PID" ] && { kill "$SSH_OLD_PID" 2>/dev/null || true; sleep 1; }
236
+ /usr/sbin/sshd >> "$KS_LOG" 2>&1 || sshd >> "$KS_LOG" 2>&1 || true
237
+ fi
238
+ pidof sshd >/dev/null 2>&1 && log "[sshd] sshd running (pid: $(pidof sshd))" || log "[sshd] WARNING: sshd not running"
102
239
 
103
- # 5. Initialize rpmdb and dnf repos
240
+ # Disable firewall so the controller can reach sshd / the install can fetch packages.
241
+ if command -v systemctl >/dev/null 2>&1; then
242
+ systemctl stop firewalld 2>/dev/null || systemctl stop iptables 2>/dev/null || true
243
+ systemctl disable firewalld 2>/dev/null || systemctl disable iptables 2>/dev/null || true
244
+ fi
245
+ firewall-cmd --set-default-zone=trusted 2>/dev/null || true
246
+ iptables -F 2>/dev/null || true
247
+ ip6tables -F 2>/dev/null || true
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # 4. dnf repos (used by the live environment for the installer's package pull)
251
+ # ---------------------------------------------------------------------------
104
252
  mkdir -p /var/lib/rpm /var/cache/dnf /var/log/dnf /etc/yum.repos.d /etc/pki/rpm-gpg /etc/dnf/vars
105
253
  echo "9" > /etc/dnf/vars/releasever
106
254
 
@@ -120,44 +268,10 @@ fi
120
268
 
121
269
  [ ! -f /etc/system-release ] && echo "Rocky Linux release 9 (Ephemeral)" > /etc/system-release
122
270
 
123
- rpm --initdb 2>/dev/null || rpmdb --initdb 2>/dev/null || {
124
- if command -v python3 >/dev/null 2>&1; then
125
- python3 -c "
126
- import sqlite3, os
127
- db_path = '/var/lib/rpm/rpmdb.sqlite'
128
- if not os.path.exists(db_path):
129
- conn = sqlite3.connect(db_path)
130
- conn.execute('CREATE TABLE IF NOT EXISTS Packages (key BLOB NOT NULL, data BLOB NOT NULL)')
131
- conn.execute('CREATE TABLE IF NOT EXISTS Name (key BLOB NOT NULL, data BLOB NOT NULL)')
132
- conn.execute('CREATE TABLE IF NOT EXISTS Basenames (key BLOB NOT NULL, data BLOB NOT NULL)')
133
- conn.execute('CREATE TABLE IF NOT EXISTS Installtid (key BLOB NOT NULL, data BLOB NOT NULL)')
134
- conn.execute('CREATE TABLE IF NOT EXISTS Providename (key BLOB NOT NULL, data BLOB NOT NULL)')
135
- conn.execute('CREATE TABLE IF NOT EXISTS Requirename (key BLOB NOT NULL, data BLOB NOT NULL)')
136
- conn.execute('CREATE TABLE IF NOT EXISTS Dirnames (key BLOB NOT NULL, data BLOB NOT NULL)')
137
- conn.execute('CREATE TABLE IF NOT EXISTS Sha1header (key BLOB NOT NULL, data BLOB NOT NULL)')
138
- conn.execute('CREATE TABLE IF NOT EXISTS Sigmd5 (key BLOB NOT NULL, data BLOB NOT NULL)')
139
- conn.commit()
140
- conn.close()
141
- " 2>/dev/null
142
- fi
143
- }
144
- chmod -R 755 /var/lib/rpm 2>/dev/null
145
-
146
271
  REPO_ARCH=$(uname -m)
147
272
 
148
273
  rpm --import https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 2>/dev/null || \
149
- curl -fsSL https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 2>/dev/null || true
150
-
151
- cat > /etc/dnf/dnf.conf << 'DNFCONF'
152
- [main]
153
- gpgcheck=1
154
- installonly_limit=3
155
- clean_requirements_on_remove=True
156
- best=True
157
- skip_if_unavailable=True
158
- install_weak_deps=False
159
- tsflags=nodocs
160
- DNFCONF
274
+ curl -fsSL https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9 2>/dev/null || true
161
275
 
162
276
  cat > /etc/yum.repos.d/rocky-baseos.repo << REPOEOF
163
277
  [baseos]
@@ -181,114 +295,692 @@ gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
181
295
  skip_if_unavailable=1
182
296
  REPOEOF2
183
297
 
184
- cat > /etc/yum.repos.d/rocky-extras.repo << REPOEOF3
185
- [extras]
186
- name=Rocky Linux 9 - Extras
187
- baseurl=http://dl.rockylinux.org/pub/rocky/9/extras/$REPO_ARCH/os/
188
- gpgcheck=1
189
- enabled=1
190
- gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
191
- https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9
192
- skip_if_unavailable=1
193
- REPOEOF3
298
+ # ---------------------------------------------------------------------------
299
+ # 5. Bootstrap status POST helper (async, idempotent per stage, retry+backoff)
300
+ # ---------------------------------------------------------------------------
301
+ detect_ip() {
302
+ local ip=""
303
+ ip=$(ip addr show 2>/dev/null | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -1)
304
+ [ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
305
+ [ -z "$ip" ] && ip=$(grep -oP '32 host \K[0-9.]+' /proc/net/fib_trie 2>/dev/null | grep -v '127.0.0.1' | head -1)
306
+ [ -z "$ip" ] && ip="UNKNOWN"
307
+ echo "$ip"
308
+ }
194
309
 
195
- cat > /etc/yum.repos.d/rocky-crb.repo << REPOEOF4
196
- [crb]
197
- name=Rocky Linux 9 - CRB
198
- baseurl=http://dl.rockylinux.org/pub/rocky/9/CRB/$REPO_ARCH/os/
199
- gpgcheck=1
200
- enabled=0
201
- gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
202
- https://dl.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-9
203
- skip_if_unavailable=1
204
- REPOEOF4
310
+ detect_mac() {
311
+ local iface
312
+ iface=$(ip -o link show up 2>/dev/null | awk -F': ' '$2!="lo"{print $2; exit}')
313
+ [ -n "$iface" ] && cat "/sys/class/net/$iface/address" 2>/dev/null || echo "UNKNOWN"
314
+ }
205
315
 
206
- cat > /etc/yum.repos.d/epel.repo << REPOEOF5
207
- [epel]
208
- name=Extra Packages for Enterprise Linux 9
209
- metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-9&arch=$REPO_ARCH
210
- gpgcheck=1
211
- enabled=1
212
- gpgkey=https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9
213
- skip_if_unavailable=1
214
- REPOEOF5
316
+ detect_vendor_model() {
317
+ local vendor product
318
+ vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null | tr -d '\n')
319
+ product=$(cat /sys/class/dmi/id/product_name 2>/dev/null | tr -d '\n')
320
+ echo "${vendor} ${product}" | sed 's/^ *//;s/ *$//'
321
+ }
322
+
323
+ # Pure-bash HTTP POST over /dev/tcp. Last-resort transport when neither curl nor
324
+ # wget is present in the minimal Anaconda %pre environment.
325
+ _raw_http_post() {
326
+ local url="$1" body="$2"
327
+ local rest host port path
328
+ rest="${url#http://}"
329
+ path="/${rest#*/}"
330
+ rest="${rest%%/*}"
331
+ host="${rest%%:*}"
332
+ port="${rest##*:}"
333
+ [ "$host" = "$port" ] && port=80
334
+ exec 3<>"/dev/tcp/${host}/${port}" 2>/dev/null || return 1
335
+ printf 'POST %s HTTP/1.0\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \
336
+ "$path" "$host" "${#body}" "$body" >&3 2>/dev/null || { exec 3>&- 2>/dev/null; return 1; }
337
+ # Read (and discard) the response so the server flushes; ignore failures.
338
+ timeout 8 cat <&3 >/dev/null 2>&1
339
+ exec 3>&- 2>/dev/null
340
+ return 0
341
+ }
342
+
343
+ # post_status <stage> [extra_json_fields]
344
+ # Posts a lifecycle event to ${BOOTSTRAP_URL}/status. Tries curl, then wget, then
345
+ # a raw /dev/tcp HTTP POST so a missing http client never silently drops events.
346
+ # Retries with backoff but never blocks installation forever (capped attempts).
347
+ post_status() {
348
+ local stage="$1"; shift
349
+ local extra="$1"
350
+ if [ -z "$BOOTSTRAP_URL" ]; then
351
+ log "[post] BOOTSTRAP_URL unset, skipping stage=$stage"
352
+ return 0
353
+ fi
354
+
355
+ local ip mac model ts payload url
356
+ ip=$(detect_ip)
357
+ mac=$(detect_mac)
358
+ model=$(detect_vendor_model)
359
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
360
+ payload="{\"stage\":\"${stage}\",\"workflowId\":\"${WORKFLOW_ID}\",\"hostname\":\"${TARGET_HOSTNAME}\",\"systemId\":\"${SYSTEM_ID}\",\"ip\":\"${ip}\",\"mac\":\"${mac}\",\"sshPort\":${SSH_PORT},\"hardware\":\"${model}\",\"timestamp\":\"${ts}\"${extra:+,${extra}}}"
361
+ url="${BOOTSTRAP_URL%/}/status"
362
+
363
+ # Reference feedback on the physical console for every lifecycle stage.
364
+ console_log "stage=${stage} ip=${ip}${extra:+ ${extra}}"
365
+
366
+ log "[post] stage=$stage -> $url (curl=$(command -v curl >/dev/null 2>&1 && echo y || echo n) wget=$(command -v wget >/dev/null 2>&1 && echo y || echo n))"
367
+ log "[post] payload=$payload"
368
+
369
+ local attempt=1 max=4 delay=3
370
+ while [ "$attempt" -le "$max" ]; do
371
+ if command -v curl >/dev/null 2>&1 && \
372
+ curl -fsS -m 10 -X POST -H 'Content-Type: application/json' -d "$payload" "$url" >> "$KS_LOG" 2>&1; then
373
+ log "[post] stage=$stage delivered via curl (attempt $attempt)"
374
+ return 0
375
+ fi
376
+ if command -v wget >/dev/null 2>&1 && \
377
+ wget -q -T 10 -O - --header='Content-Type: application/json' --post-data="$payload" "$url" >> "$KS_LOG" 2>&1; then
378
+ log "[post] stage=$stage delivered via wget (attempt $attempt)"
379
+ return 0
380
+ fi
381
+ if _raw_http_post "$url" "$payload"; then
382
+ log "[post] stage=$stage delivered via /dev/tcp (attempt $attempt)"
383
+ return 0
384
+ fi
385
+ log "[post] stage=$stage attempt $attempt/$max failed; retrying in ${delay}s"
386
+ sleep "$delay"
387
+ delay=$((delay * 2))
388
+ attempt=$((attempt + 1))
389
+ done
390
+ log "[post] stage=$stage giving up after $max attempts (non-fatal)"
391
+ return 1
392
+ }
393
+
394
+ # Early heartbeat: announce the ephemeral runtime is up and that %pre reached the
395
+ # networking/POST stage. If even this never reaches the controller, the problem
396
+ # is transport/network — not disk detection or sshd.
397
+ post_status "ephemeral-boot" "\"detail\":\"%pre reached network/post stage\""
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # 6. Safe install-disk detection for a real physical machine
401
+ # ---------------------------------------------------------------------------
402
+ # Rules (deterministic):
403
+ # - honor INSTALL_DISK_HINT if it is a valid block device
404
+ # - only whole disks (TYPE=disk), writable (RO=0), non-removable (RM=0)
405
+ # - skip USB-attached media (TRAN=usb) and zram/loop/ram devices
406
+ # - skip the device backing the live/ephemeral root, if any is disk-backed
407
+ # - among the remaining, prefer internal bus order (nvme > sata/sas) then
408
+ # largest size, and pick the smallest stable /dev/disk/by-id name for it
409
+ detect_install_disk() {
410
+ if [ -n "$INSTALL_DISK_HINT" ] && [ -b "$INSTALL_DISK_HINT" ]; then
411
+ echo "$INSTALL_DISK_HINT"
412
+ return 0
413
+ fi
414
+
415
+ # Device currently backing the live root filesystem (avoid wiping ourselves).
416
+ local live_src live_disk=""
417
+ live_src=$(findmnt -n -o SOURCE / 2>/dev/null)
418
+ if [ -n "$live_src" ] && [ -b "$live_src" ]; then
419
+ live_disk=$(lsblk -no PKNAME "$live_src" 2>/dev/null | head -1)
420
+ [ -z "$live_disk" ] && live_disk=$(basename "$live_src")
421
+ fi
422
+
423
+ # Emit "score name size" per candidate, sort, take the top.
424
+ local best=""
425
+ best=$(lsblk -dn -o NAME,TYPE,SIZE,RM,RO,TRAN -b 2>/dev/null | while read -r name type size rm ro tran; do
426
+ [ "$type" = "disk" ] || continue
427
+ [ "$ro" = "0" ] || continue
428
+ [ "$rm" = "0" ] || continue
429
+ case "$name" in
430
+ loop*|ram*|zram*|sr*|fd*) continue ;;
431
+ esac
432
+ [ "$tran" = "usb" ] && continue
433
+ [ -n "$live_disk" ] && [ "$name" = "$live_disk" ] && continue
434
+
435
+ # Bus preference weight (higher = preferred), then size as tiebreaker.
436
+ local weight=0
437
+ case "$tran" in
438
+ nvme) weight=3 ;;
439
+ sata|sas|ata) weight=2 ;;
440
+ *) weight=1 ;;
441
+ esac
442
+ # score = weight * 1e15 + size ; keeps weight dominant, size as tiebreak
443
+ printf '%d %s\n' "$(( weight * 1000000000000000 + ${size:-0} ))" "/dev/$name"
444
+ done | sort -rn | head -1 | awk '{print $2}')
445
+
446
+ [ -z "$best" ] && return 1
447
+
448
+ # Prefer a stable /dev/disk/by-id symlink for the chosen device.
449
+ local byid
450
+ byid=$(for l in /dev/disk/by-id/*; do
451
+ [ -e "$l" ] || continue
452
+ case "$l" in *-part*) continue ;; esac
453
+ if [ "$(readlink -f "$l")" = "$(readlink -f "$best")" ]; then
454
+ echo "$l"
455
+ fi
456
+ done 2>/dev/null | grep -v '/wwn-' | head -1)
457
+
458
+ [ -n "$byid" ] && echo "$byid" || echo "$best"
459
+ }
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # 7. Write the unattended installer (run by the controller over SSH, or by the
463
+ # AUTO_INSTALL fallback). It detects the disk, installs Rocky 9, posts
464
+ # completion, and reboots into the deployed OS.
465
+ # ---------------------------------------------------------------------------
466
+ mkdir -p /usr/local/bin
467
+ cat > "$INSTALLER" << INSTALLEOF
468
+ #!/bin/bash
469
+ # Underpost unattended Rocky Linux 9 installer (runs inside the ephemeral env).
470
+ set +e
471
+
472
+ KS_LOG="$KS_LOG"
473
+ INSTALL_LOCK="$INSTALL_LOCK"
474
+ INSTALL_DONE="$INSTALL_DONE"
475
+ TARGET_MNT="$TARGET_MNT"
476
+ AUTHORIZED_KEYS='$AUTHORIZED_KEYS'
477
+ ADMIN_USER='$ADMIN_USER'
478
+ DEPLOY_USER='$DEPLOY_USER'
479
+ TARGET_HOSTNAME='$TARGET_HOSTNAME'
480
+ NET_IP='$NET_IP'
481
+ NET_PREFIX='$NET_PREFIX'
482
+ NET_GATEWAY='$NET_GATEWAY'
483
+ NET_DNS='$NET_DNS'
484
+ TIMEZONE='$TIMEZONE'
485
+ KEYBOARD_LAYOUT='$KEYBOARD_LAYOUT'
486
+ CHRONY_CONF_PATH='$CHRONY_CONF_PATH'
487
+ # Passwords are carried base64-encoded so any character survives intact. Decoding
488
+ # tries base64 then python3 so a minimal environment never yields empty passwords.
489
+ ks_b64d() { printf %s "\$1" | base64 -d 2>/dev/null || printf %s "\$1" | python3 -c 'import sys,base64;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read().strip()))' 2>/dev/null; }
490
+ ROOT_PASS_B64='$ROOT_PASS_B64'
491
+ ADMIN_PASS_B64='$ADMIN_PASS_B64'
492
+ DEPLOY_PASS_B64='$DEPLOY_PASS_B64'
493
+ ROOT_PASS="\$(ks_b64d "\$ROOT_PASS_B64")"
494
+ ADMIN_PASS="\$(ks_b64d "\$ADMIN_PASS_B64")"
495
+ DEPLOY_PASS="\$(ks_b64d "\$DEPLOY_PASS_B64")"
496
+ [ -z "\$ADMIN_PASS" ] && ADMIN_PASS="\$ROOT_PASS"
497
+ [ -z "\$DEPLOY_PASS" ] && DEPLOY_PASS="\$ROOT_PASS"
498
+
499
+ ilog() {
500
+ echo "\$(date): [install] \$*" | tee -a "\$KS_LOG"
501
+ printf "[underpost-install] %s\r\n" "\$*" > /dev/console 2>/dev/null || true
502
+ }
503
+
504
+ # Prove the installer actually launched (distinguishes "never started" from
505
+ # "started and exited early"). Posted before any guard so it always fires once.
506
+ ilog "installer process started (pid \$\$)"
507
+ post_status "install-start" "\"detail\":\"installer launched\""
215
508
 
216
- rpm --import https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9 2>/dev/null || \
217
- curl -fsSL https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9 -o /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-9 2>/dev/null || true
509
+ # Single-flight guard.
510
+ if [ -f "\$INSTALL_LOCK" ]; then
511
+ ilog "installer already running (lock present), exiting"
512
+ exit 0
513
+ fi
514
+ if [ -f "\$INSTALL_DONE" ]; then
515
+ ilog "install already completed, exiting"
516
+ exit 0
517
+ fi
518
+ touch "\$INSTALL_LOCK"
218
519
 
219
- dnf makecache --releasever=9 --quiet 2>/dev/null || dnf makecache --releasever=9 2>/dev/null || true
520
+ post_status "installing" "\"detail\":\"partitioning\""
220
521
 
221
- # Install sudo
222
- if ! command -v sudo >/dev/null 2>&1; then
223
- dnf install -y --releasever=9 --nogpgcheck sudo 2>/dev/null || \
224
- dnf install -y --releasever=9 --nogpgcheck --disableplugin='*' sudo 2>/dev/null || true
522
+ DISK="\$(detect_install_disk)"
523
+ if [ -z "\$DISK" ] || [ ! -b "\$DISK" ]; then
524
+ ilog "FATAL: no valid install disk detected"
525
+ post_status "failed" "\"detail\":\"no install disk detected\""
526
+ rm -f "\$INSTALL_LOCK"
527
+ exit 1
225
528
  fi
529
+ REAL_DISK="\$(readlink -f "\$DISK")"
530
+ ilog "Selected install disk: \$DISK (\$REAL_DISK)"
531
+ post_status "installing" "\"detail\":\"target \$REAL_DISK\",\"disk\":\"\$REAL_DISK\""
226
532
 
227
- if command -v sudo >/dev/null 2>&1; then
228
- mkdir -p /etc/sudoers.d
229
- echo "$ADMIN_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$ADMIN_USER
230
- chmod 0440 /etc/sudoers.d/$ADMIN_USER
533
+ # Detect firmware mode.
534
+ if [ -d /sys/firmware/efi ]; then
535
+ FW="uefi"
536
+ else
537
+ FW="bios"
231
538
  fi
539
+ ilog "Firmware mode: \$FW"
540
+
541
+ # Partition naming helper (nvme0n1p1 vs sda1).
542
+ part() {
543
+ case "\$REAL_DISK" in
544
+ *[0-9]) echo "\${REAL_DISK}p\$1" ;;
545
+ *) echo "\${REAL_DISK}\$1" ;;
546
+ esac
547
+ }
232
548
 
233
- cat > /home/$ADMIN_USER/.bash_profile 2>/dev/null << PROFILEEOF
234
- if ! command -v sudo >/dev/null 2>&1; then
235
- echo ""
236
- echo "NOTE: sudo is not available. Use 'su -' to switch to root."
237
- echo ""
549
+ ilog "Wiping and partitioning \$REAL_DISK ..."
550
+ umount -R "\$TARGET_MNT" 2>/dev/null || true
551
+ swapoff -a 2>/dev/null || true
552
+ wipefs -a "\$REAL_DISK" >> "\$KS_LOG" 2>&1
553
+ sgdisk --zap-all "\$REAL_DISK" >> "\$KS_LOG" 2>&1 || dd if=/dev/zero of="\$REAL_DISK" bs=1M count=10 2>/dev/null
554
+
555
+ if [ "\$FW" = "uefi" ]; then
556
+ sgdisk -n 1:0:+600M -t 1:ef00 -c 1:"EFI System" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
557
+ sgdisk -n 2:0:+1024M -t 2:8300 -c 2:"boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
558
+ sgdisk -n 3:0:0 -t 3:8300 -c 3:"root" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
559
+ EFI_PART="\$(part 1)"; BOOT_PART="\$(part 2)"; ROOT_PART="\$(part 3)"
560
+ else
561
+ sgdisk -n 1:0:+2M -t 1:ef02 -c 1:"BIOS boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
562
+ sgdisk -n 2:0:+1024M -t 2:8300 -c 2:"boot" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
563
+ sgdisk -n 3:0:0 -t 3:8300 -c 3:"root" "\$REAL_DISK" >> "\$KS_LOG" 2>&1
564
+ BOOT_PART="\$(part 2)"; ROOT_PART="\$(part 3)"
238
565
  fi
239
- PROFILEEOF
240
- chown $ADMIN_USER:$ADMIN_USER /home/$ADMIN_USER/.bash_profile 2>/dev/null || true
241
566
 
242
- # Restart sshd
243
- if command -v systemctl >/dev/null 2>&1; then
244
- systemctl restart sshd 2>/dev/null || systemctl restart sshd.service 2>/dev/null
567
+ partprobe "\$REAL_DISK" >> "\$KS_LOG" 2>&1 || true
568
+ udevadm settle 2>/dev/null || sleep 3
569
+
570
+ ilog "Creating filesystems ..."
571
+ [ "\$FW" = "uefi" ] && mkfs.vfat -F32 "\$EFI_PART" >> "\$KS_LOG" 2>&1
572
+ mkfs.xfs -f "\$BOOT_PART" >> "\$KS_LOG" 2>&1
573
+ mkfs.xfs -f "\$ROOT_PART" >> "\$KS_LOG" 2>&1
574
+
575
+ ilog "Mounting target ..."
576
+ mkdir -p "\$TARGET_MNT"
577
+ mount "\$ROOT_PART" "\$TARGET_MNT" || { ilog "FATAL: mount root failed"; post_status "failed" "\"detail\":\"mount root failed\""; rm -f "\$INSTALL_LOCK"; exit 1; }
578
+ mkdir -p "\$TARGET_MNT/boot"
579
+ mount "\$BOOT_PART" "\$TARGET_MNT/boot"
580
+ if [ "\$FW" = "uefi" ]; then
581
+ mkdir -p "\$TARGET_MNT/boot/efi"
582
+ mount "\$EFI_PART" "\$TARGET_MNT/boot/efi"
245
583
  fi
246
- SSHD_PID=$(cat /var/run/sshd.pid 2>/dev/null || pidof sshd 2>/dev/null | awk '{print $1}')
247
- [ -n "$SSHD_PID" ] && kill -HUP "$SSHD_PID" 2>/dev/null
248
- if ! pidof sshd >/dev/null 2>&1; then
249
- /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null
584
+
585
+ post_status "installing" "\"detail\":\"installing packages\",\"disk\":\"\$REAL_DISK\""
586
+
587
+ ilog "Installing base packages with dnf --installroot ..."
588
+ PKGS="@core kernel grub2-tools openssh-server NetworkManager chrony dracut-config-generic rocky-release"
589
+ if [ "\$FW" = "uefi" ]; then
590
+ PKGS="\$PKGS grub2-efi-x64 shim-x64 efibootmgr"
591
+ else
592
+ PKGS="\$PKGS grub2-pc"
593
+ fi
594
+
595
+ dnf -y --installroot="\$TARGET_MNT" --releasever=9 \
596
+ --setopt=install_weak_deps=False --nogpgcheck \
597
+ install \$PKGS >> "\$KS_LOG" 2>&1
598
+ DNF_RC=\$?
599
+ if [ "\$DNF_RC" -ne 0 ]; then
600
+ ilog "FATAL: dnf install failed (rc=\$DNF_RC)"
601
+ post_status "failed" "\"detail\":\"dnf install rc=\$DNF_RC\""
602
+ rm -f "\$INSTALL_LOCK"
603
+ exit 1
604
+ fi
605
+
606
+ ilog "Generating fstab ..."
607
+ ROOT_UUID=\$(blkid -s UUID -o value "\$ROOT_PART")
608
+ BOOT_UUID=\$(blkid -s UUID -o value "\$BOOT_PART")
609
+ {
610
+ echo "UUID=\$ROOT_UUID / xfs defaults 0 0"
611
+ echo "UUID=\$BOOT_UUID /boot xfs defaults 0 0"
612
+ if [ "\$FW" = "uefi" ]; then
613
+ EFI_UUID=\$(blkid -s UUID -o value "\$EFI_PART")
614
+ echo "UUID=\$EFI_UUID /boot/efi vfat umask=0077,shortname=winnt 0 2"
615
+ fi
616
+ } > "\$TARGET_MNT/etc/fstab"
617
+
618
+ # Bind mounts for chroot.
619
+ for d in dev proc sys run; do mount --bind /\$d "\$TARGET_MNT/\$d"; done
620
+
621
+ ilog "Configuring installed system (hostname, network, ssh, users) ..."
622
+ echo "\${TARGET_HOSTNAME:-rocky9}" > "\$TARGET_MNT/etc/hostname"
623
+
624
+ chroot "\$TARGET_MNT" /bin/bash -s << CHROOTEOF >> "\$KS_LOG" 2>&1
625
+ set +e
626
+ systemctl enable sshd NetworkManager chronyd 2>/dev/null
627
+
628
+ # SELinux -> permissive in the DEPLOYED OS. Critical: with SELinux enforcing on
629
+ # first boot, files written from this installer chroot (authorized_keys, home
630
+ # dirs) carry the wrong security context, so sshd drops sessions ("Connection
631
+ # reset by peer") and PAM can block console logins. Permissive ignores contexts
632
+ # (no relabel reboot needed) and matches what kubeadm/cluster.js expect.
633
+ sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config 2>/dev/null || true
634
+
635
+ # Ensure sshd host keys exist so sshd actually starts on first boot.
636
+ ssh-keygen -A 2>/dev/null || true
637
+
638
+ # Disable firewalld so the controller can reach sshd on first boot.
639
+ systemctl disable firewalld 2>/dev/null || true
640
+ systemctl mask firewalld 2>/dev/null || true
641
+
642
+ # Static networking bound to the primary wired NIC by MAC so the deployed OS is
643
+ # reachable at the same IP the commission used (NetworkManager keyfile).
644
+ if [ -n "\${NET_IP}" ]; then
645
+ PRIMARY_IF=\\\$(ls /sys/class/net | grep -E '^(en|eth)' | head -1)
646
+ PRIMARY_MAC=\\\$(cat /sys/class/net/\\\$PRIMARY_IF/address 2>/dev/null)
647
+ mkdir -p /etc/NetworkManager/system-connections
648
+ NMFILE=/etc/NetworkManager/system-connections/underpost.nmconnection
649
+ {
650
+ echo "[connection]"
651
+ echo "id=underpost"
652
+ echo "type=ethernet"
653
+ echo "autoconnect=true"
654
+ echo "autoconnect-priority=999"
655
+ echo "[ethernet]"
656
+ [ -n "\\\$PRIMARY_MAC" ] && echo "mac-address=\\\$PRIMARY_MAC"
657
+ echo "[ipv4]"
658
+ echo "method=manual"
659
+ echo "addresses=\${NET_IP}/\${NET_PREFIX}"
660
+ echo "gateway=\${NET_GATEWAY}"
661
+ echo "dns=\${NET_DNS};"
662
+ echo "[ipv6]"
663
+ echo "method=ignore"
664
+ } > "\\\$NMFILE"
665
+ chmod 600 "\\\$NMFILE"
666
+ fi
667
+
668
+ # Timezone + NTP (chrony) for the deployed OS. Mirrors the Rocky branch of
669
+ # src/cli/system.js (rocky.timezone): localtime symlink, /etc/timezone, a chrony
670
+ # config with a local + public NTP pool, and chronyd enabled on boot.
671
+ if [ -n "\${TIMEZONE}" ]; then
672
+ ln -sf /usr/share/zoneinfo/\${TIMEZONE} /etc/localtime
673
+ echo "\${TIMEZONE}" > /etc/timezone
674
+ timedatectl set-timezone \${TIMEZONE} 2>/dev/null || true
675
+ {
676
+ echo "# Underpost-managed chrony configuration"
677
+ [ -n "\${NET_GATEWAY}" ] && echo "server \${NET_GATEWAY} iburst prefer"
678
+ echo "server 0.pool.ntp.org iburst"
679
+ echo "server 1.pool.ntp.org iburst"
680
+ echo "server 2.pool.ntp.org iburst"
681
+ echo "server 3.pool.ntp.org iburst"
682
+ echo "driftfile /var/lib/chrony/drift"
683
+ echo "makestep 1.0 3"
684
+ echo "rtcsync"
685
+ echo "logdir /var/log/chrony"
686
+ } > "\${CHRONY_CONF_PATH:-/etc/chrony.conf}"
687
+ systemctl enable chronyd 2>/dev/null || true
688
+ fi
689
+
690
+ # Keyboard layout + locale for the deployed OS. Mirrors the Rocky branch of
691
+ # src/cli/system.js (rocky.keyboard): vconsole.conf, locale.conf, X11 keymap.
692
+ if [ -n "\${KEYBOARD_LAYOUT}" ]; then
693
+ echo "KEYMAP=\${KEYBOARD_LAYOUT}" > /etc/vconsole.conf
694
+ echo "FONT=latarcyrheb-sun16" >> /etc/vconsole.conf
695
+ echo "LANG=en_US.UTF-8" > /etc/locale.conf
696
+ mkdir -p /etc/X11/xorg.conf.d
697
+ {
698
+ echo 'Section "InputClass"'
699
+ echo ' Identifier "system-keyboard"'
700
+ echo ' MatchIsKeyboard "on"'
701
+ echo ' Option "XkbLayout" "\${KEYBOARD_LAYOUT}"'
702
+ echo 'EndSection'
703
+ } > /etc/X11/xorg.conf.d/00-keyboard.conf
704
+ localectl set-keymap \${KEYBOARD_LAYOUT} 2>/dev/null || true
705
+ localectl set-x11-keymap \${KEYBOARD_LAYOUT} 2>/dev/null || true
706
+ fi
707
+
708
+ # Key-only SSH on the installed system.
709
+ mkdir -p /etc/ssh/sshd_config.d
710
+ cat > /etc/ssh/sshd_config.d/00-underpost.conf <<SSHDEOF
711
+ PermitRootLogin prohibit-password
712
+ PubkeyAuthentication yes
713
+ PasswordAuthentication no
714
+ SSHDEOF
715
+
716
+ # Create the admin login accounts. create_user <name> writes the authorized key,
717
+ # wheel membership and passwordless sudo. Passwords are applied after the chroot.
718
+ create_user() {
719
+ [ -z "\\\$1" ] && return 0
720
+ id "\\\$1" >/dev/null 2>&1 || useradd -m -G wheel "\\\$1"
721
+ usermod -aG wheel "\\\$1" 2>/dev/null || true
722
+ mkdir -p /home/\\\$1/.ssh && chmod 700 /home/\\\$1/.ssh
723
+ cat > /home/\\\$1/.ssh/authorized_keys <<KEYEOF
724
+ \${AUTHORIZED_KEYS}
725
+ KEYEOF
726
+ chmod 600 /home/\\\$1/.ssh/authorized_keys
727
+ chown -R "\\\$1:\\\$1" /home/\\\$1/.ssh
728
+ echo "\\\$1 ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/\\\$1
729
+ chmod 0440 /etc/sudoers.d/\\\$1
730
+ }
731
+
732
+ mkdir -p /root/.ssh && chmod 700 /root/.ssh
733
+ cat > /root/.ssh/authorized_keys <<KEYEOF3
734
+ \${AUTHORIZED_KEYS}
735
+ KEYEOF3
736
+ chmod 600 /root/.ssh/authorized_keys
737
+
738
+ create_user "\${ADMIN_USER}"
739
+ create_user "\${DEPLOY_USER}"
740
+
741
+ # Build initramfs for the installed kernel.
742
+ KVER=\\\$(ls /lib/modules | head -1)
743
+ [ -n "\\\$KVER" ] && dracut -f /boot/initramfs-\\\$KVER.img "\\\$KVER"
744
+ CHROOTEOF
745
+
746
+ # Set console passwords AFTER the chroot heredoc, from decoded installer vars.
747
+ # Primary path: pipe 'user:password' into 'chroot chpasswd' (keeps password
748
+ # characters out of any heredoc). Fallback: hash with openssl and apply via
749
+ # 'usermod -p' so a chpasswd/PAM quirk in the installroot never leaves an
750
+ # account locked. Verifies the shadow field is a real hash afterwards.
751
+ set_password() {
752
+ local u="\$1" p="\$2"
753
+ [ -z "\$u" ] || [ -z "\$p" ] && return 0
754
+ printf '%s:%s\n' "\$u" "\$p" | chroot "\$TARGET_MNT" chpasswd 2>>"\$KS_LOG"
755
+ local field
756
+ field=\$(chroot "\$TARGET_MNT" getent shadow "\$u" 2>/dev/null | cut -d: -f2)
757
+ case "\$field" in
758
+ '\$'*) ilog "\$u password set (chpasswd)"; return 0 ;;
759
+ esac
760
+ local hash
761
+ hash=\$(printf %s "\$p" | openssl passwd -6 -stdin 2>/dev/null)
762
+ if [ -n "\$hash" ]; then
763
+ chroot "\$TARGET_MNT" usermod -p "\$hash" "\$u" 2>>"\$KS_LOG" && ilog "\$u password set (openssl hash fallback)"
764
+ else
765
+ ilog "WARNING: failed to set password for \$u"
766
+ fi
767
+ }
768
+ set_password root "\$ROOT_PASS"
769
+ set_password "\$ADMIN_USER" "\$ADMIN_PASS"
770
+ set_password "\$DEPLOY_USER" "\$DEPLOY_PASS"
771
+
772
+ post_status "installing" "\"detail\":\"installing bootloader\",\"disk\":\"\$REAL_DISK\""
773
+
774
+ ilog "Installing bootloader (\$FW) on \$REAL_DISK ..."
775
+ if [ "\$FW" = "uefi" ]; then
776
+ chroot "\$TARGET_MNT" grub2-mkconfig -o /boot/grub2/grub.cfg >> "\$KS_LOG" 2>&1
777
+ chroot "\$TARGET_MNT" mkdir -p /boot/efi/EFI/rocky
778
+ chroot "\$TARGET_MNT" grub2-mkconfig -o /boot/efi/EFI/rocky/grub.cfg >> "\$KS_LOG" 2>&1
779
+ # Drop any stale Rocky Linux UEFI entries to avoid duplicates.
780
+ for b in \$(chroot "\$TARGET_MNT" efibootmgr 2>/dev/null | awk '/Rocky Linux/{print substr(\$1,5,4)}'); do
781
+ chroot "\$TARGET_MNT" efibootmgr -b "\$b" -B >> "\$KS_LOG" 2>&1
782
+ done
783
+ # Create the boot entry (also prepends it to BootOrder).
784
+ chroot "\$TARGET_MNT" efibootmgr -c -d "\$REAL_DISK" -p 1 -L "Rocky Linux" -l "\\\\EFI\\\\rocky\\\\shimx64.efi" >> "\$KS_LOG" 2>&1
785
+ BOOT_RC=\$?
786
+ # Set BootNext so the immediate post-install reboot boots the DISK, not the
787
+ # USB iPXE installer — this overrides a USB-first firmware order for one boot,
788
+ # so no manual BIOS change is needed to land in the freshly installed OS.
789
+ NEW_BOOT=\$(chroot "\$TARGET_MNT" efibootmgr 2>/dev/null | awk '/Rocky Linux/{print substr(\$1,5,4); exit}')
790
+ if [ -n "\$NEW_BOOT" ]; then
791
+ chroot "\$TARGET_MNT" efibootmgr -n "\$NEW_BOOT" >> "\$KS_LOG" 2>&1
792
+ ilog "Set UEFI BootNext=\$NEW_BOOT (Rocky Linux on \$REAL_DISK); remove USB after this boot to stay on disk"
793
+ fi
794
+ else
795
+ chroot "\$TARGET_MNT" /bin/bash -c "grub2-install --target=i386-pc \$REAL_DISK && \
796
+ grub2-mkconfig -o /boot/grub2/grub.cfg" >> "\$KS_LOG" 2>&1
797
+ BOOT_RC=\$?
250
798
  fi
251
799
 
252
- # 6. Status report
253
- DETECTED_IP=$(ip -4 addr show | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | cut -d/ -f1 | head -1)
800
+ # Teardown mounts.
801
+ for d in dev proc sys run; do umount -l "\$TARGET_MNT/\$d" 2>/dev/null; done
802
+ umount -R "\$TARGET_MNT" 2>/dev/null || true
803
+
804
+ if [ "\$BOOT_RC" -ne 0 ]; then
805
+ ilog "FATAL: bootloader install failed (rc=\$BOOT_RC)"
806
+ post_status "failed" "\"detail\":\"bootloader rc=\$BOOT_RC\""
807
+ rm -f "\$INSTALL_LOCK"
808
+ exit 1
809
+ fi
810
+
811
+ touch "\$INSTALL_DONE"
812
+ ilog "Install completed on \$REAL_DISK. Rebooting into deployed OS (BootNext set to disk)."
813
+ ilog "If it boots the USB again, remove the USB stick now — the OS is on \$REAL_DISK."
814
+ ilog "Console login: 'root', '\${ADMIN_USER}'\$([ -n "\$DEPLOY_USER" ] && echo " or '\$DEPLOY_USER'") (password \$([ -n "\$ROOT_PASS" ] && echo set || echo NOT-set)). SSH is key-only at \${NET_IP:-dhcp}."
815
+ post_status "completed" "\"detail\":\"install ok, rebooting into disk\",\"disk\":\"\$REAL_DISK\",\"loginUser\":\"\${ADMIN_USER}\",\"deployUser\":\"\${DEPLOY_USER}\",\"passwordSet\":\$([ -n "\$ROOT_PASS" ] && echo true || echo false),\"staticIp\":\"\${NET_IP}\",\"bootNext\":\"\${NEW_BOOT:-}\""
816
+ rm -f "\$INSTALL_LOCK"
817
+ sync
818
+ sleep 3
819
+ reboot -f || systemctl reboot || echo b > /proc/sysrq-trigger
820
+ INSTALLEOF
821
+ chmod +x "$INSTALLER"
822
+ log "[install] Wrote unattended installer to $INSTALLER"
823
+
824
+ # Export helpers used by the installer (it sources nothing; functions are
825
+ # re-declared above). The installer relies on post_status/detect_install_disk
826
+ # being defined in its own process, so embed minimal copies inline.
827
+ # To keep one source of truth, re-export them via a sourced fragment.
828
+ cat > /usr/local/bin/underpost-install-helpers.sh << HELPEREOF
829
+ $(declare -f log)
830
+ $(declare -f console_log)
831
+ $(declare -f detect_ip)
832
+ $(declare -f detect_mac)
833
+ $(declare -f detect_vendor_model)
834
+ $(declare -f post_status)
835
+ $(declare -f detect_install_disk)
836
+ BOOTSTRAP_URL='$BOOTSTRAP_URL'
837
+ WORKFLOW_ID='$WORKFLOW_ID'
838
+ SYSTEM_ID='$SYSTEM_ID'
839
+ TARGET_HOSTNAME='$TARGET_HOSTNAME'
840
+ SSH_PORT='$SSH_PORT'
841
+ INSTALL_DISK_HINT='$INSTALL_DISK_HINT'
842
+ HELPEREOF
843
+ # Prepend a source of the helpers into the installer so its post_status /
844
+ # detect_install_disk calls resolve.
845
+ sed -i "2i source /usr/local/bin/underpost-install-helpers.sh" "$INSTALLER"
846
+
847
+ # ---------------------------------------------------------------------------
848
+ # 8. Physical console banner + SSH-ready handshake
849
+ # ---------------------------------------------------------------------------
850
+ DETECTED_IP=$(detect_ip)
254
851
 
255
852
  echo ""
256
853
  echo "=============================================="
257
854
  echo "Underpost: Ephemeral SSHD Setup Complete"
258
855
  echo "=============================================="
259
- echo "Root login: root / (password set: $([ -n "$ROOT_PASS" ] && echo 'yes' || echo 'no'))"
260
- echo "Admin user: $ADMIN_USER / (password set: $([ -n "$ROOT_PASS" ] && echo 'yes' || echo 'no'))"
856
+ echo "Root login: root (key-only: $([ -n "$AUTHORIZED_KEYS" ] && echo yes || echo no))"
857
+ echo "Admin user: $ADMIN_USER"
261
858
  echo "SSH keys: $([ -n "$AUTHORIZED_KEYS" ] && echo 'configured' || echo 'NOT configured')"
262
859
  echo "sshd status: $(pidof sshd >/dev/null 2>&1 && echo "running (pid $(pidof sshd))" || echo 'NOT running')"
263
- echo "sudo: $(command -v sudo >/dev/null 2>&1 && echo 'installed' || echo 'NOT available (use su -)')"
264
860
  echo "IP address: $DETECTED_IP"
861
+ echo "Installer: $INSTALLER"
265
862
  echo "=============================================="
266
863
 
267
- # Physical console display (printf with \r\n for proper line breaks on serial/physical consoles)
268
864
  {
269
- printf "\r\n"
270
- printf "██╗░░░██╗███╗░░██╗██████╗░███████╗██████╗░██████╗░░█████╗░░██████╗████████╗\r\n"
271
- printf "██║░░░██║████╗░██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝\r\n"
272
- printf "██║░░░██║██╔██╗██║██║░░██║█████╗░░██████╔╝██████╔╝██║░░██║╚█████╗░░░░██║░░░\r\n"
273
- printf "██║░░░██║██║╚████║██║░░██║██╔══╝░░██╔══██╗██╔═══╝░██║░░██║░╚═══██╗░░░██║░░░\r\n"
274
- printf "╚██████╔╝██║░╚███║██████╔╝███████╗██║░░██║██║░░░░░╚█████╔╝██████╔╝░░░██║░░░\r\n"
275
- printf "░╚═════╝░╚═╝░░╚══╝╚═════╝░╚══════╝╚═╝░░╚═╝╚═╝░░░░░░╚════╝░╚═════╝░░░░╚═╝░░░\r\n"
276
- printf "==============================================\r\n"
277
- printf " Underpost Ephemeral Commissioning Active\r\n"
278
- printf "==============================================\r\n"
279
- printf " SSH as: root@%s\r\n" "$DETECTED_IP"
280
- printf " or: %s@%s\r\n" "$ADMIN_USER" "$DETECTED_IP"
281
- printf " Key: %s\r\n" "$([ -n "$AUTHORIZED_KEYS" ] && echo 'pubkey auth enabled' || echo 'password only')"
282
- printf " dnf: ready (BaseOS, AppStream, Extras, EPEL)\r\n"
283
- printf "==============================================\r\n"
284
- printf "\r\n"
865
+ printf "\r\n"
866
+ printf "██╗░░░██╗███╗░░██╗██████╗░███████╗██████╗░██████╗░░█████╗░░██████╗████████╗\r\n"
867
+ printf "██║░░░██║████╗░██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝╚══██╔══╝\r\n"
868
+ printf "██║░░░██║██╔██╗██║██║░░██║█████╗░░██████╔╝██████╔╝██║░░██║╚█████╗░░░░██║░░░\r\n"
869
+ printf "██║░░░██║██║╚████║██║░░██║██╔══╝░░██╔══██╗██╔═══╝░██║░░██║░╚═══██╗░░░██║░░░\r\n"
870
+ printf "╚██████╔╝██║░╚███║██████╔╝███████╗██║░░██║██║░░░░░╚█████╔╝██████╔╝░░░██║░░░\r\n"
871
+ printf "░╚═════╝░╚═╝░░╚══╝╚═════╝░╚══════╝╚═╝░░╚═╝╚═╝░░░░░░╚════╝░╚═════╝░░░░╚═╝░░░\r\n"
872
+ printf "==============================================\r\n"
873
+ printf " Underpost Network Ephemeral Commissioning Active\r\n"
874
+ printf "==============================================\r\n"
875
+ printf " SSH as: root@%s -p %s (key-only)\r\n" "$DETECTED_IP" "$SSH_PORT"
876
+ printf " Stage: awaiting remote install trigger\r\n"
877
+ printf " Disk: auto-detect (hint: %s)\r\n" "${INSTALL_DISK_HINT:-none}"
878
+ printf "==============================================\r\n"
879
+ printf "\r\n"
285
880
  } > /dev/console 2>/dev/null || true
286
881
 
287
- # 7. Infinite wait loop - keep Anaconda live environment active
882
+ # Best-effort check that sshd is accepting connections on the configured port.
883
+ # Reported as metadata in the ssh-ready event but does NOT gate the POST: the
884
+ # controller needs the announcement regardless, and inst.sshd may name/track the
885
+ # daemon in ways pidof misses.
886
+ ssh_is_ready() {
887
+ if command -v ss >/dev/null 2>&1; then
888
+ ss -lnt 2>/dev/null | grep -q ":${SSH_PORT} " && return 0
889
+ fi
890
+ if command -v netstat >/dev/null 2>&1; then
891
+ netstat -lnt 2>/dev/null | grep -q ":${SSH_PORT} " && return 0
892
+ fi
893
+ timeout 3 bash -c "</dev/tcp/127.0.0.1/${SSH_PORT}" >/dev/null 2>&1
894
+ }
895
+
896
+ # Give sshd a brief window to come up, but never block the announcement.
897
+ for _ in $(seq 1 15); do
898
+ ssh_is_ready && break
899
+ sleep 2
900
+ done
901
+
902
+ SSHD_LISTENING=$(ssh_is_ready && echo true || echo false)
903
+ SSHD_PID=$(pidof sshd 2>/dev/null | awk '{print $1}')
904
+ log "[ready] sshd_listening=${SSHD_LISTENING} sshd_pid=${SSHD_PID:-none} port=${SSH_PORT}"
905
+
906
+ # Idempotent: only POST ssh-ready once per boot/session. Posted unconditionally
907
+ # (best effort) so a strict readiness probe never swallows the handshake.
908
+ if [ ! -f "$READY_FLAG" ]; then
909
+ if post_status "ssh-ready" "\"detail\":\"ephemeral runtime ready\",\"sshdListening\":${SSHD_LISTENING},\"sshdPid\":\"${SSHD_PID:-}\",\"installer\":\"${INSTALLER}\""; then
910
+ touch "$READY_FLAG"
911
+ log "[ready] ssh-ready handshake posted"
912
+ else
913
+ log "[ready] ssh-ready POST failed; will retry in lifecycle loop"
914
+ fi
915
+ fi
916
+
917
+ # ---------------------------------------------------------------------------
918
+ # 9. Lifecycle wait loop
919
+ # - keep sshd alive (inst.sshd watchdog should also handle this)
920
+ # - run the installer when the controller drops a trigger file over SSH,
921
+ # e.g. ssh root@host 'touch /tmp/.underpost-install-trigger'
922
+ # or directly: ssh root@host '/usr/local/bin/underpost-install.sh'
923
+ # - AUTO_INSTALL fallback self-triggers after AUTO_INSTALL_FALLBACK_SECONDS
924
+ # ---------------------------------------------------------------------------
925
+ INSTALL_LOG=/tmp/underpost-install.log
926
+ LAUNCHED_FLAG=/tmp/.underpost-install-launched
927
+
928
+ # Launch the installer fully detached from this loop process via setsid + closed
929
+ # stdio, so it keeps running independently of any sshd session or signal. Guarded
930
+ # by LAUNCHED_FLAG so trigger + fallback never double-launch.
931
+ launch_installer() {
932
+ [ -f "$LAUNCHED_FLAG" ] && return 0
933
+ touch "$LAUNCHED_FLAG"
934
+ log "[loop] launching installer ($INSTALLER), log -> $INSTALL_LOG"
935
+ console_log "remote command received: starting disk install ($INSTALLER)"
936
+ if command -v setsid >/dev/null 2>&1; then
937
+ setsid bash "$INSTALLER" >> "$INSTALL_LOG" 2>&1 < /dev/null &
938
+ else
939
+ nohup bash "$INSTALLER" >> "$INSTALL_LOG" 2>&1 < /dev/null &
940
+ fi
941
+ }
942
+
943
+ START_TS=$(date +%s)
944
+ LOOP_N=0
288
945
  while true; do
289
- sleep 60
290
- if ! pidof sshd >/dev/null 2>&1; then
291
- echo "$(date): sshd not running, restarting..." >> /tmp/ks-pre.log
292
- /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null
293
- fi
946
+ [ -f "$INSTALL_DONE" ] && { log "[loop] install done; idle"; sleep 60; continue; }
947
+
948
+ LOOP_N=$((LOOP_N + 1))
949
+
950
+ # Keep retrying the ssh-ready handshake until it is acknowledged so a flaky
951
+ # first POST never strands the controller.
952
+ if [ ! -f "$READY_FLAG" ]; then
953
+ if post_status "ssh-ready" "\"detail\":\"retry from lifecycle loop\",\"installer\":\"${INSTALLER}\""; then
954
+ touch "$READY_FLAG"
955
+ log "[loop] ssh-ready handshake posted (retry)"
956
+ fi
957
+ fi
958
+
959
+ # Periodic heartbeat so the controller log shows the runtime is alive while
960
+ # it waits for a trigger.
961
+ if [ "$((LOOP_N % 4))" -eq 0 ]; then
962
+ post_status "heartbeat" "\"detail\":\"awaiting install trigger\",\"uptimeSec\":$(( $(date +%s) - START_TS ))" >/dev/null 2>&1 || true
963
+ fi
964
+
965
+ if ! pidof sshd >/dev/null 2>&1; then
966
+ log "[loop] sshd not running, restarting..."
967
+ /usr/sbin/sshd 2>/dev/null || sshd 2>/dev/null
968
+ fi
969
+
970
+ if [ -f "$TRIGGER_FILE" ] && [ ! -f "$INSTALL_DONE" ]; then
971
+ log "[loop] install trigger detected"
972
+ console_log "ssh install trigger detected"
973
+ rm -f "$TRIGGER_FILE"
974
+ launch_installer
975
+ fi
976
+
977
+ if [ "$AUTO_INSTALL" = "1" ] && [ ! -f "$LAUNCHED_FLAG" ] && [ ! -f "$INSTALL_DONE" ]; then
978
+ NOW=$(date +%s)
979
+ if [ "$((NOW - START_TS))" -ge "$AUTO_INSTALL_FALLBACK_SECONDS" ]; then
980
+ log "[loop] AUTO_INSTALL fallback timeout reached; self-triggering installer"
981
+ launch_installer
982
+ fi
983
+ fi
984
+
985
+ sleep 15
294
986
  done