-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathinit.sh
More file actions
4155 lines (3806 loc) · 174 KB
/
Copy pathinit.sh
File metadata and controls
4155 lines (3806 loc) · 174 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
# =========================================================
# linux-ssh-init-sh
# Server Init & SSH Hardening Script
#
# Author: 247like
# GitHub: https://github.com/247like/linux-ssh-init-sh
# License: MIT
#
# Release: v4.7.8 (Enterprise Hardening — 18th-round red-team / scenario fixes)
#
# POSIX sh compatible (Debian dash / CentOS / Alpine / Ubuntu / RHEL)
#
# Changelog v4.7.8 (post-v4.7.7 18th-round red-team + scenario walk-through):
# - [HIGH] PATH inheritance hardening: previously, $PATH was inherited
# unsanitized from the caller. In CI / sudo-with-env_keep+=PATH
# scenarios an attacker controlling the calling shell's env could
# plant fake passwd/useradd/stat/whoami/etc binaries that the
# script then executes as root. Now sets a fixed PATH at the top.
# - [HIGH] restart_sshd refuses early when in a chroot. v4.7.7 only blocked
# the direct-spawn fallback; the systemctl path could still cross
# the chroot boundary via /run/dbus and restart the HOST's sshd
# with the CHROOT's modified config. Now bails at the function
# entry via in_chroot_or_unknown.
# - [MED W1-1] update_system: SYSTEM_UPDATE_START + SYSTEM_UPDATE_DONE
# audit_log entries (apt/dnf/yum/apk upgrade is a major mutation).
# - [MED W2-2] FIREWALL_OPENED audit only fires when rule was ACTUALLY
# installed; pre-existing rules emit FIREWALL_NOOP. Stops every
# idempotent re-run from polluting the audit log.
# - [MED W6-1] KEY_FETCH_FAILED audit entry after 3 retries exhausted
# (forensic breadcrumb for why STRICT mode aborted).
# - [MED W8-1] STRANDED_MUTATIONS_DETECTED audit when --yes + non-empty
# prev-run artifact mirror. Cron-driven runs no longer silently
# accumulate detected-but-unreconciled mutations.
# - [LOW] --key-gh=USERNAME value redacted in START audit entry (parity
# with --key-url and --key-raw).
# - [doc] README "公钥失败" / "Key Failure" table row clarified: managed
# block writes PasswordAuthentication=yes (preserves existing
# password-login path) AND new user is kept locked.
# - [doc] README audit-log enumeration completed — all 37+ ACTION names
# now listed, with high-priority alert candidates bold-marked
# (ROLLBACK_RM_FAILED / STRANDED_MUTATIONS_DETECTED / etc).
# - [doc] README "Files modified" table now lists script.lock and
# last-artifacts-UID paths (v4.7.6 additions previously omitted).
#
# Changelog v4.7.7 (post-v4.7.6 16th-round perf/FS/container/exotic-shell audit):
# - [HIGH] Direct-spawn sshd fallback now refuses ALSO when in a chroot
# without separate PID namespace (e.g. operator did
# `chroot /mnt/target` from host console). Previously, only the
# SSH-into-host scenario was guarded; chroot-from-console would
# let `pkill -x sshd` reach the HOST's sshd and drop every SSH
# session on the machine. New in_chroot_or_unknown helper does
# this via /proc/1/root inode comparison.
# - [MED] Script-level mutex liveness check now uses both /proc/$pid AND
# kill -0 as a fallback, and REFUSES when the holder's status is
# ambiguous (chroot without /proc, etc) rather than reclaiming
# and letting two concurrent runs race-corrupt sshd_config.
# - [MED] setup_rollback's snapshot cp now fails LOUDLY on ENOSPC / NFS
# errors AND verifies non-empty result. Previously a silent cp
# failure left an empty snapshot — and the eventual rollback would
# cp that empty snapshot OVER /etc/ssh/sshd_config, bricking the
# box. Refuses to proceed without a valid snapshot.
# - [MED] backup_config_persistent's snapshot cp same hardening; on
# failure removes the partial file (restore.sh would otherwise
# install a 0-byte sshd_config) and warns.
# - [MED] record_last_applied now writes atomically via tmp+mv. Previous
# direct truncate-then-write left partial state on signal/ENOSPC.
# - [LOW] audit_log falls back to stderr (`AUDIT-FALLBACK ...`) when
# /var/log is unwritable. The ROLLBACK record is the most
# important forensic event; losing it silently would leave the
# operator with no idea why rollback fired.
# - [INFO] README adds a logrotate(8) snippet for /var/log/server-init*.log
# (zh+en). Operators of high-frequency installs (CI / fleet) need
# a hint that there's no built-in rotation.
# - [CLEAN] Exotic shells (yash, ksh93, mksh, posh, OpenBSD pdksh) and old
# tool versions verified: no new portability issues found.
#
# Changelog v4.7.6 (post-v4.7.5 13th-round adversarial audit):
# - [CRIT] handle_selinux now initializes __selinux_done="n" at function
# entry. Previously, the "all 3 semanage attempts failed" path
# left it unset; under `set -u` the subsequent test crashed the
# script and triggered a full rollback — defeating the non-strict
# graceful-degrade design.
# - [HIGH N1] Script-level mutex at $RUNTIME_DIR/script.lock prevents
# concurrent invocations from interleave-corrupting sshd_config.
# Stale-PID detection allows recovery from crashed prior runs.
# - [HIGH N2] artifact_track now ALSO mirrors to $RUNTIME_DIR/last-artifacts-UID
# (RUNTIME_DIR survives across script invocations; TMP_DIR doesn't).
# On SIGKILL/OOM/power-loss, the next script run surfaces the
# stranded artifacts so the operator knows to manually clean.
# - [HIGH N3] audit_log strips newlines from $action and $details. Without
# this, a $details derived from a tampered /etc/passwd field 6
# could inject forged "ACTION: FAKE" entries into the audit log.
# - [MED] validate_port rejects leading-zero ports. `--port=03306` used to
# decimal-pass to 3306, bypass is_hard_reserved's string-only
# case match, and write `Port 03306` to sshd_config — silently
# shadowing MySQL on that port. Same vector for `--port=0022`
# that script-treated as non-standard while sshd parsed as 22,
# racing firewall/SELinux state vs the daemon's reality.
# - [MED N5] Rollback rm-failure now audit-logged (ROLLBACK_RM_FAILED) and
# sets rollback_clean=0. Read-only /etc previously left stale
# sudoers entries after user deletion — a privilege primitive
# for any future re-created user with the same name.
# - [MED N6] MOTD_BAK-missing during rollback now surfaces as ROLLBACK_
# MOTD_BAK_MISSING audit + non-clean rollback (was silent).
# - [MED] Strip block for SELINUX_* lines: previous `grep -v ... && mv ||
# rm` broke when grep matched zero lines (file was all SELINUX_*).
# Now uses `|| true` after grep so mv always runs.
# - [MED] Duplicate `--user=` / `--port=` / `--key-*` now hard-fails instead
# of silently last-wins. Empty `--port=` and `--key-X=` values
# are also rejected (previously caused interactive-prompt EOF →
# silent port-22 downgrade).
# - [LOW] validate_username + --key-gh now reject embedded newlines BEFORE
# grep (POSIX grep is per-line-anchored — multi-line values
# silently bypassed). Closes the log-disclosure / future-refactor
# footgun.
# - [LOW] public_ip from ipify is sanitized to digits/dots only (≤15 char).
# Prevents a MITM'd response from injecting ANSI CSI sequences
# into the operator's terminal scrollback via the summary box.
#
# Changelog v4.7.5 (post-v4.7.4 11th-round audit):
# - [MED] SELinux NOOP-vs-ADD provenance: v4.7.4 tracked NOOP labels the
# same as ADDED labels, which let rollback and stale-cleanup
# `semanage port -d` labels we never installed (sysadmin-set or
# cross-tool-set). NOOP path no longer artifact_tracks; persisted
# state gains `SELINUX_OWNED=y/n` so stale-cleanup only deletes
# OWNED labels and emits STALE_SELINUX_KEPT for the rest.
# - [MED] Stale SELinux residue across mid-state crash: after a successful
# cross-run `semanage port -d`, the SELINUX_PORT= and SELINUX_OWNED=
# lines are STRIPPED from LAST_APPLIED_FILE immediately (not just
# overwritten by the post-handle_selinux record_last_applied call).
# Eliminates the brief window where the file claimed ownership of a
# label that had just been deleted.
# - [MED] CI: test-distros now asserts the v4.7.4 audit entries
# (USER_CREATED / PASSWORD_CLEARED / KEYS_DEPLOYED /
# MANAGED_BLOCK_INSTALLED) actually fire on the happy path.
# - [MED] CI: new test-restore-checksum-gate job verifies restore.sh
# refuses missing checksums.sha256 AND that FORCE=1 bypasses.
# - [LOW] Forensic gap: `INSECURE_KEY_URL` audit entry fires when a
# plaintext http:// key URL is fetched in non-strict mode.
# - [LOW] STALE_SELINUX_KEPT / STALE_SELINUX_REMOVE_FAILED /
# STALE_SELINUX_SKIP audit entries for full state coverage.
# - [INFO] Both READMEs now document the audit-log action names (for SIEM
# integration) and the v4.7.5 SELinux ownership semantics.
#
# Changelog v4.7.4 (post-v4.7.3 9th-round audit):
# - [LOW] --key-raw \c/\0 check now uses raw printf+exit (the previous
# `die` call was unreachable: die() is defined AFTER the argv loop,
# so an undefined-function "command not found" would silently
# proceed, bypassing the defense-in-depth check).
# - [MED] SELinux port label leak across runs: LAST_APPLIED_FILE now
# records SELINUX_PORT=N; remove_stale_firewall_port also calls
# `semanage port -d` on the OLD port. No more cruft accumulation.
# - [MED] Idempotent same-port SELinux re-run: handle_selinux now
# pre-checks `semanage port -l` and emits SELINUX_PORT_NOOP
# instead of the misleading "previous SELinux type was
# overwritten" warning.
# - [MED] Forensic audit gap: USER_CREATED, PASSWORD_CLEARED, KEYS_DEPLOYED
# now in the audit trail (previously only artifact_track + info).
# - [MED] README documents `FORCE=1 sh restore.sh` for the case where
# checksums.sha256 was lost and operator must bypass verification.
# - [LOW] Symlink-defense hardening: 5 sites now call unlink_if_symlink
# before write (init_log_files / record_last_applied / sudoers /
# systemd override / motd banner). BBR sysctl path refuses to
# append when /etc/sysctl.conf is a symlink. All require prior
# root-equiv compromise to weaponize but tightens defense.
#
# Changelog v4.7.3 (post-v4.7.2 fresh-eyes audit):
# - [HIGH H2] finalize_user_password_policy now surfaces double-failure
# (passwd -d AND usermod -U both failing) via ACCOUNT_LOCKED_REASON
# + audit_log instead of silently leaving account broken.
# - [HIGH H3] deploy_keys defensively rejects home directories that are
# not owned by the target user (defends against tampered
# /etc/passwd → privilege-escalation primitive).
# - [HIGH H4] cleanup_sshd_config_d will not overwrite a pre-existing
# .bak_server_init from a prior crashed run — uses timestamped
# name instead.
# - [HIGH H5] Persist last-applied port/backend in /var/lib/server-init/last-applied;
# on next run with different --port, clean the stale firewall
# rule (no more "every prior port stays open").
# - [HIGH H6] setup_rollback skips .bak_server_init files when snapshotting
# so stage-3 rollback restore cannot reintroduce stale data.
# - [HIGH H7] SELinux `semanage port -m` path audit-logs the overwrite +
# warns loudly; STRICT mode refuses to silently overwrite.
# - [HIGH H8/H9] CI: existing-user-safe now asserts `passwd -S` reports P;
# cross-user comment corrected.
# - [INFO C1] enhanced_ssh_test ssh-client login test is INHERENTLY
# inconclusive (root has no client privkey) — kept warn+return-0
# behavior but added LOGIN_TEST_PASSED / LOGIN_TEST_INCONCLUSIVE
# audit_log entries.
# - [MED] --key-raw rejects literal \\c and \\0 escapes (printf %b truncation
# hazard); interactive raw paste uses real newline (no %b at all).
# - [MED] cleanup_old_backups grep -c fix — empty backup_list no longer
# produces "0\\n0" that broke numeric -gt compare.
# - [MED] ensure_port_tools tries multiple nc package names (netcat-openbsd,
# ncat, nmap-ncat, netcat) for cross-distro coverage.
# - [MED] restore.sh REFUSES when checksums.sha256 is missing or sha256sum
# unavailable; set FORCE=1 to override.
# - [MED] fetch_keys via wget uses --quota=$KEY_MAX_BYTES (wget has no
# --max-filesize) to bound on-disk damage.
# - [MED] sanitize_sshd_config stops at first `Match` line — operator's
# Match-block-scoped directives are no longer silently commented out.
# - [MED] update_motd grep anchored to start-of-line; operator's MOTD lines
# containing "Login User:" etc. are no longer accidentally stripped.
# - [MED] print_final_summary suppresses ssh command when ACCOUNT_LOCKED.
# - [MED] --delay-restart explicitly tells the operator about the
# systemd override.conf that will apply on next manual restart.
# - [MED] restart_sshd direct-spawn fallback REFUSES when SSH_CONNECTION
# is set (would kill operator's own session in a chroot/container).
# - [MED] audit_log added for BBR_ENABLED, FIREWALL_OPENED, SUDOERS_WRITTEN,
# DROPIN_RENAMED, MANAGED_BLOCK_INSTALLED, SYSTEMD_OVERRIDE_WRITTEN,
# MOTD_BANNER_WRITTEN, SELINUX_PORT_ADDED/MODIFIED/FAIL.
# - [LOW] STALE_FIREWALL_REMOVED audit entry on port-change cleanup.
# - [LOW] DIRECT_SPAWN / DIRECT_SPAWN_REFUSED audit entries.
#
# Changelog v4.7.2 (post-v4.7.1 audit fixes):
# - [HIGH] safe_configure_sudo: validate + write the NEW stable file BEFORE
# touching any legacy files (prevents lockout-of-admin when visudo
# fails on the new template).
# - [HIGH] Legacy-sudoers cleanup glob now requires digits-only suffix,
# preventing cross-user deletion (running script for "admin" no
# longer deletes "server-init-admin-bot"). Operator-managed files
# like "server-init-<user>-special-policy" are preserved.
# - [MED] rollback_handler skips restart_sshd when restored sshd_config
# fails `sshd -t` — pushing a broken config to the live daemon
# could crash sshd and leave the machine unreachable.
# - [MED] finalize_user_password_policy: keep new account LOCKED when
# sudo deployment also failed (avoid passwordless+no-sudo combo).
# - [LOW] CI test-existing-user-safe comment corrected (sudoers is NEVER
# written for pre-existing accounts — that's by design).
# - [LOW] CI: new test-cross-user-sudoers-safe job (regression test for
# the HIGH glob bug).
# - [LOW] CI: new test-legacy-sudoers-cleanup job (verifies v4.6.x
# timestamped files are removed AND audit-logged).
#
# Changelog v4.7.1 (post-v4.7.0 audit fixes):
# - [CRIT R1] passwd -d regression: only clear password for NEW accounts;
# pre-existing admin passwords are preserved
# - [CRIT R2] rollback now RENAMES .bak_server_init back instead of deleting
# (prevents permanent loss of original drop-in content)
# - [CRIT R3] update_motd modifications tracked via MOTD_BAK artifact;
# rollback restores from .bak
# - [CRIT R4] restart_sshd grew a direct-spawn fallback (pkill+sshd) for
# minimal/no-init environments; CI live-restart job now passes
# - [HIGH H1] safe_configure_sudo failure is no longer silent (audit_log +
# STRICT abort)
# - [HIGH H2] FIREWALL_PORT artifact tagged with backend (ufw/firewalld/
# iptables) — rollback only undoes that backend
# - [HIGH H3] sshd -T fallback warns loudly + STRICT mode refuses approximate
# grep-based validation
# - [HIGH H4] Rollback path forces sshd restart even under --delay-restart
# - [HIGH H6] safe_configure_sudo cleans up pre-v4.7.0 timestamped sudoers
# variants (one stable file per user)
# - [MED M1] Stdout AUTO_SKIP message redacts --key-raw / --key-url values
# - [MED M2] --key-gh validates GitHub-username regex before URL substitution
# - [MED M3] fetch_keys downloads via temp file (max-filesize errors no
# longer masked by SIGPIPE → curl exit 0)
# - [MED M4] rollback while-read tolerates torn last-line writes (no LF)
# - [MED M5] pkill -KILL -u $user before userdel in rollback
# - [MED M6] iptables-restore explicit warning about wiping other rules
# - [MED M8] ipify probe uses --proto '=https' --max-redirs 2
# - [LOW] --version / --help clean up TMP_DIR before exit
# - [LOW] dropped GNU-only `xargs -r` (BusyBox compat)
#
# Changelog v4.7.0 (Enterprise Hardening):
# - [CRIT] Defer `passwd -d` until key deployment confirmed (avoids passwordless+password-auth window)
# - [CRIT] Rollback now tracks all created artifacts (sudoers, systemd override, motd, BBR, user)
# - [CRIT] Rollback restores iptables.backup + re-validates sshd -t + listening port
# - [CRIT] Clean `.bak_server_init` drop-ins on rollback
# - [CRIT] All color output via printf '%b' (fixes dash literal-escape output)
# - [CRIT] `--key-raw=` argv: apply printf %b for literal \n support
# - [HIGH] restore.sh now verifies checksums.sha256 before applying
# - [HIGH] Backup dir umask 077 + explicit chmod 700 (no more world-readable history)
# - [HIGH] /tmp backup-dir fallback hardened (refuses pre-existing dir with wrong owner)
# - [HIGH] fetch_keys: --max-filesize, --max-redirs, strict mode requires https
# - [HIGH] validate_ssh_config uses sshd -T (Match-block aware) instead of grep|tail
# - [HIGH] safe_configure_sudo checks wheel/sudo group membership
# - [HIGH] Trap handler blocks re-entrant signals + explicit INT/TERM handlers
# - [HIGH] update_motd preserves a real backup (.bak retained for one cycle)
# - [MED] Stable sudoers.d filename per user (no more orphan files on re-run)
# - [MED] Port lock cleanup on success
# - [MED] `protect_sshd_service` runs even with --delay-restart
# - [MED] chown verification post-deploy_keys (catches NFS root_squash silent failures)
# - [MED] install_managed_block: mv guarded with die
# - [MED] remove_managed_block tolerant of CRLF/whitespace markers
# - [MED] Replace `echo "$user_input" | ...` with printf '%s\n' (bash/dash echo divergence)
# - [MED] Health report uses if/then/else (no &&...|| precedence trap)
# - [LOW] iptables.backup / backup.info / checksums.sha256 explicit chmod 600
# - [LOW] LC_ALL=C exported (deterministic sort/awk regex)
# - [LOW] Optional external IP lookup gated by --no-ip-probe
# =========================================================
set -u
# [v4.7.0] Deterministic locale for sort/awk/sed/grep semantics
LC_ALL=C
LANGUAGE=C
export LC_ALL LANGUAGE
# [v4.7.8 FIX-HIGH] PATH hardening. The script invokes ~245 binaries; many
# without `command -v` guards. If PATH is attacker-controlled (e.g. CI runner
# env, sudo with env_keep+=PATH, a poisoned .bashrc sourced before sudo), an
# attacker can plant fake `passwd`/`useradd`/`stat`/`whoami`/etc in $PATH
# and the script will run them as root. Reset PATH to a known-safe default
# BEFORE any external command runs. /usr/local/* first so legitimate local
# installs (e.g. AlmaLinux's curl) still work. PATH=. is NEVER included.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
print_usage() {
cat <<EOF
linux-ssh-init-sh v4.7.8 — Server Init & SSH Hardening
USAGE:
./init.sh [OPTIONS]
CONTROL:
--lang=zh|en Force locale (default: zh)
--yes Skip the final "proceed?" prompt
--strict Abort on any critical failure (no fallback)
--delay-restart Write config but do not restart sshd / run tests
--no-ip-probe Skip public IP lookup (offline-friendly)
-V, --version Print version and exit
-h, --help This help
USER & PORT:
--user=NAME Login user (default: deploy; "root" allowed)
--port=22|random|N SSH port (random uses 49152-65535)
KEY SOURCE (pick one):
--key-gh=USERNAME Pull from https://github.com/USERNAME.keys
--key-url=URL Fetch from URL (https://; http:// only outside strict)
--key-raw="ssh-..." Inline key(s); use literal \\n between multiple lines
SYSTEM:
--update / --no-update Upgrade installed packages
--bbr / --no-bbr Enable TCP BBR congestion control
LOGS:
/var/log/server-init.log (debug log)
/var/log/server-init-audit.log (audit trail)
/var/log/server-init-health.log (post-run snapshot)
/var/backups/ssh-config/<TS>/ (restore.sh per-run)
EOF
}
# --help / --version should not require root and should not acquire locks.
for __early_arg in "$@"; do
case "$__early_arg" in
--version|-V)
echo "linux-ssh-init-sh v4.7.8 Enterprise Hardening"
exit 0 ;;
--help|-h)
print_usage
exit 0 ;;
esac
done
unset __early_arg
if [ "$(id -u)" -ne 0 ]; then
__early_lang="zh"
for __early_arg in "$@"; do
case "$__early_arg" in
--lang=en) __early_lang="en" ;;
--lang=zh) __early_lang="zh" ;;
esac
done
if [ "$__early_lang" = "en" ]; then
echo "Must be run as root"
else
echo "必须以 root 权限运行此脚本"
fi
exit 1
fi
unset __early_lang __early_arg
SCRIPT_START_TIME=$(date +%s)
# ---------------- Configuration ----------------
LANG_CUR="zh"
LOG_FILE="/var/log/server-init.log"
AUDIT_FILE="/var/log/server-init-audit.log"
BACKUP_REPO="/var/backups/ssh-config"
SSH_CONF="/etc/ssh/sshd_config"
SSH_CONF_D="/etc/ssh/sshd_config.d"
DEFAULT_USER="deploy"
BLOCK_BEGIN="# BEGIN SERVER-INIT MANAGED BLOCK"
BLOCK_END="# END SERVER-INIT MANAGED BLOCK"
# [v4.7.3 FIX-H5] Persist the last-applied SSH port across script runs so
# that on the next run with a DIFFERENT port we can clean the firewall rule
# we previously installed. The file lives under /var/lib/server-init (root-
# only, persists across reboots).
LAST_APPLIED_FILE="/var/lib/server-init/last-applied"
# ---------------- [SEC] Atomic Secure Temp Directory ----------------
old_umask=$(umask)
umask 077
TMP_DIR=""
if command -v mktemp >/dev/null 2>&1; then
TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t ssh-init-XXXXXX 2>/dev/null || echo "")
fi
if [ -z "$TMP_DIR" ]; then
rand_suffix=""
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
rand_suffix=$(od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
fi
[ -z "$rand_suffix" ] && rand_suffix="$$"
TMP_DIR="/tmp/ssh-init.${$}.${rand_suffix}.$(date +%s 2>/dev/null || echo 0)"
# [SEC-FIX] Use mkdir without -p to avoid reusing existing directories
mkdir "$TMP_DIR" 2>/dev/null || {
# Retry with different name
TMP_DIR="/tmp/ssh-init.${rand_suffix}.$$"
mkdir "$TMP_DIR" 2>/dev/null || { echo "FATAL: Cannot create temp directory: $TMP_DIR" >&2; exit 1; }
}
fi
chmod 700 "$TMP_DIR" 2>/dev/null || true
umask "$old_umask"
# ---------------- [SEC] State & Lock Management ----------------
RUNTIME_DIR=""
for try_dir in "/run/server-init" "/var/lib/server-init"; do
if mkdir -p "$try_dir" 2>/dev/null; then
RUNTIME_DIR="$try_dir"
break
fi
done
if [ -z "$RUNTIME_DIR" ]; then
rand_rt=""
if [ -r /dev/urandom ] && command -v od >/dev/null 2>&1; then
rand_rt=$(od -An -N4 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
fi
[ -z "$rand_rt" ] && rand_rt="$$"
RUNTIME_DIR="/tmp/server-init.${rand_rt}.$(date +%s 2>/dev/null || echo 0)"
# [SEC-FIX] Use mkdir without -p
if ! mkdir "$RUNTIME_DIR" 2>/dev/null; then
if command -v mktemp >/dev/null 2>&1; then
RUNTIME_DIR=$(mktemp -d /tmp/server-init.XXXXXX 2>/dev/null || echo "")
fi
fi
[ -n "$RUNTIME_DIR" ] && [ -d "$RUNTIME_DIR" ] || { echo "FATAL: Cannot create runtime directory" >&2; exit 1; }
fi
chmod 700 "$RUNTIME_DIR" 2>/dev/null || true
STATE_FILE="$RUNTIME_DIR/state-$(id -u)"
LOCK_DIR="$RUNTIME_DIR/locks-$(id -u)"
# [v4.7.6 FIX-HIGH-N1] Script-level mutex. Two concurrent invocations of
# this script can interleave-corrupt /etc/ssh/sshd_config (both rename
# drop-ins, both install managed blocks, one's rollback wipes the other's
# work). The lock is a directory (mkdir is atomic) holding the active PID.
# Cleared by cleanup_locks() on normal exit. Stale entries (PID gone) are
# auto-reclaimed by the next start.
SCRIPT_LOCK_DIR="$RUNTIME_DIR/script.lock"
[ -L "$STATE_FILE" ] && rm -f "$STATE_FILE" 2>/dev/null || true
if [ -e "$LOCK_DIR" ] && [ ! -d "$LOCK_DIR" ]; then
rm -f "$LOCK_DIR" 2>/dev/null || true
fi
# [v4.7.6] Acquire script-level mutex. We do this BEFORE any state mutation
# (well before setup_rollback). If we fail to acquire, refuse to start.
if mkdir "$SCRIPT_LOCK_DIR" 2>/dev/null; then
echo "$$" > "$SCRIPT_LOCK_DIR/pid" 2>/dev/null || true
else
# Lock exists. Check if the holder is still alive.
holder_pid=""
if [ -r "$SCRIPT_LOCK_DIR/pid" ]; then
holder_pid=$(head -1 "$SCRIPT_LOCK_DIR/pid" 2>/dev/null | tr -d '\n\r ')
fi
# [v4.7.7 FIX-MED] Liveness detection uses BOTH /proc/$pid AND kill -0.
# The kill -0 fallback works in chroots without /proc (where the previous
# form falsely declared "stale" → reclaim → two scripts both proceed →
# sshd_config corruption). The unknown-result third branch refuses rather
# than racing.
holder_alive="unknown"
if [ -n "$holder_pid" ]; then
if [ -d "/proc/$holder_pid" ]; then
holder_alive="yes"
elif kill -0 "$holder_pid" 2>/dev/null; then
holder_alive="yes"
elif [ -d "/proc/1" ] || command -v kill >/dev/null 2>&1; then
# /proc is available (or we have kill) AND neither method found the
# PID → genuinely stale, safe to reclaim.
holder_alive="no"
fi
fi
case "$holder_alive" in
yes)
echo "FATAL: Another linux-ssh-init-sh run is in progress (PID $holder_pid)" >&2
echo " If you're sure it crashed, remove $SCRIPT_LOCK_DIR and retry." >&2
exit 1 ;;
no)
rm -rf "$SCRIPT_LOCK_DIR" 2>/dev/null || true
if ! mkdir "$SCRIPT_LOCK_DIR" 2>/dev/null; then
echo "FATAL: Cannot acquire script lock at $SCRIPT_LOCK_DIR" >&2
exit 1
fi
echo "$$" > "$SCRIPT_LOCK_DIR/pid" 2>/dev/null || true ;;
unknown)
# Cannot determine liveness (no /proc, no kill, or empty pid file).
# Refuse rather than risk a concurrent-corruption race.
echo "FATAL: Lock $SCRIPT_LOCK_DIR exists but liveness check is ambiguous" >&2
echo " (no /proc and/or empty PID file). Verify no other run is in" >&2
echo " progress, then remove $SCRIPT_LOCK_DIR manually and retry." >&2
exit 1 ;;
esac
fi
# [v4.7.8 FIX] The script-level mutex is acquired before argv parsing so
# concurrent invocations are blocked early. That also means every pre-rollback
# exit path (--help/--version, bad flags, root/preflight/input failures) must
# release it. Once setup_rollback() installs the real rollback trap, this
# lightweight cleanup trap is replaced.
early_cleanup_before_rollback() {
[ -n "${STATE_FILE:-}" ] && rm -f "$STATE_FILE" 2>/dev/null || true
[ -n "${ARTIFACT_MIRROR:-}" ] && rm -f "$ARTIFACT_MIRROR" 2>/dev/null || true
[ -n "${LOCK_DIR:-}" ] && [ -d "$LOCK_DIR" ] && rm -rf "$LOCK_DIR" 2>/dev/null || true
[ -n "${SCRIPT_LOCK_DIR:-}" ] && [ -d "$SCRIPT_LOCK_DIR" ] && rm -rf "$SCRIPT_LOCK_DIR" 2>/dev/null || true
[ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null || true
case "${RUNTIME_DIR:-}" in
/tmp/*) [ -d "$RUNTIME_DIR" ] && rm -rf "$RUNTIME_DIR" 2>/dev/null || true ;;
esac
}
trap 'early_cleanup_before_rollback' EXIT
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# ---------------- Initialize Variables ----------------
TARGET_USER=""
SSH_PORT="22"
KEY_OK="n"
PORT_OPT="1"
KEY_TYPE=""
KEY_VAL=""
DO_UPDATE="n"
DO_BBR="n"
# [v4.7.1] Track whether THIS script run created the target user. Used so that
# finalize_user_password_policy does not wipe an existing admin's password.
USER_WAS_CREATED="n"
# [v4.7.2] Track whether sudo configuration succeeded for the (new) user.
# Used so that finalize_user_password_policy does not clear the password on
# a brand-new account that ALSO lacks sudo — that combo gives a key-only
# account with no admin privileges, which is rarely what the operator wanted.
SUDO_DEPLOYED="n"
# [v4.7.2] Surfaces "the target account is locked and cannot SSH right now"
# in print_final_summary + generate_health_report + audit_log. Otherwise the
# operator sees the success banner with the ssh command line and only later
# discovers the account is unusable.
ACCOUNT_LOCKED_REASON=""
# [v4.7.4] "Should record_last_applied persist SELINUX_PORT=N for this run?"
# Set "y" by handle_selinux if NOOP/ADDED/MODIFIED (anything that means our
# port is currently labeled ssh_port_t).
SELINUX_LABEL_APPLIED="n"
# [v4.7.5] "Did THIS run install or retype the label, i.e. may rollback
# `semanage port -d` it without destroying pre-existing state?" "y" only on
# ADDED or MODIFIED paths; "n" on NOOP (label pre-existed independently).
SELINUX_LABEL_OWNED_BY_RUN="n"
# Note: a previous v4.7.5 draft introduced SELINUX_LABEL_PREEXISTED here, but
# the actual provenance signal that downstream code consumes is
# SELINUX_LABEL_OWNED_BY_RUN above (which is the inverse). The PREEXISTED
# flag is therefore intentionally NOT defined to avoid SC2034 unused-var.
OPENSSH_VER_MAJOR=0
OPENSSH_VER_MINOR=0
KEX_LINE=""
CIPHERS_LINE=""
MACS_LINE=""
CRYPTO_MODE="skip"
IPV6_ENABLED="n"
ROOT_KEY_PRESENT="n"
SUPPORTS_KBD_INTERACTIVE="n"
# ---------------- Automation Variables ----------------
ARG_USER=""
ARG_PORT=""
ARG_KEY_TYPE=""
ARG_KEY_VAL=""
ARG_UPDATE=""
ARG_BBR=""
AUTO_CONFIRM="n"
STRICT_MODE="n"
ARG_DELAY_RESTART="n"
ARG_NO_IP_PROBE="n"
# Parse Arguments
# [v4.7.6 FIX-MED] Track first-set state so duplicate --user / --port /
# --key-* don't silently last-win. Operators copy-pasting CI templates can
# unintentionally specify both --key-gh and --key-raw; refuse instead.
__seen_user=""; __seen_port=""; __seen_key=""
for a in "$@"; do
case "$a" in
--lang=zh) LANG_CUR="zh" ;;
--lang=en) LANG_CUR="en" ;;
--strict) STRICT_MODE="y" ;;
--yes) AUTO_CONFIRM="y" ;;
--user=*)
if [ -n "$__seen_user" ]; then
printf '[FATAL] --user specified more than once\n' >&2; exit 1
fi
__seen_user="y"; ARG_USER="${a#*=}" ;;
--port=random)
if [ -n "$__seen_port" ]; then
printf '[FATAL] --port specified more than once\n' >&2; exit 1
fi
__seen_port="y"; ARG_PORT="random" ;;
--port=*)
if [ -n "$__seen_port" ]; then
printf '[FATAL] --port specified more than once\n' >&2; exit 1
fi
__seen_port="y"; ARG_PORT="${a#*=}" ;;
--key-gh=*)
if [ -n "$__seen_key" ]; then
printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
fi
__seen_key="y"; ARG_KEY_TYPE="gh"; ARG_KEY_VAL="${a#*=}" ;;
--key-url=*)
if [ -n "$__seen_key" ]; then
printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
fi
__seen_key="y"; ARG_KEY_TYPE="url"; ARG_KEY_VAL="${a#*=}" ;;
--key-raw=*)
if [ -n "$__seen_key" ]; then
printf '[FATAL] only one of --key-gh / --key-url / --key-raw may be given\n' >&2; exit 1
fi
__seen_key="y"
ARG_KEY_TYPE="raw"
# [v4.7.0] Interpret literal \n / \t in argv so multi-line keys can be
# passed as a single shell-quoted argument.
# [v4.7.3 FIX-MED] Reject \c and \0 escapes BEFORE printf %b — `\c`
# would silently truncate the rest of the key material. Real SSH keys
# never contain these, so refusal is safe.
# [v4.7.4 FIX-LOW] Cannot use die() here — argv parsing runs BEFORE
# function definitions; an undefined die call would "command not found"
# silently and proceed (bypassing the check). Use raw printf+exit.
__raw_in="${a#*=}"
case "$__raw_in" in
*'\c'*|*'\0'*)
printf '[FATAL] Refusing --key-raw containing \\c or \\0 escape (truncation hazard)\n' >&2
exit 1 ;;
esac
ARG_KEY_VAL="$(printf '%b' "$__raw_in")"
unset __raw_in
;;
--update) ARG_UPDATE="y" ;;
--no-update) ARG_UPDATE="n" ;;
--bbr) ARG_BBR="y" ;;
--no-bbr) ARG_BBR="n" ;;
--delay-restart) ARG_DELAY_RESTART="y" ;;
--no-ip-probe) ARG_NO_IP_PROBE="y" ;;
--version|-V)
echo "linux-ssh-init-sh v4.7.8 Enterprise Hardening"
# [v4.7.1] Clean up tmp/runtime dirs created at script entry so that
# scripted `--version` invocations don't accumulate empty directories.
[ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null
[ -n "${RUNTIME_DIR:-}" ] && [ -d "$RUNTIME_DIR" ] && case "$RUNTIME_DIR" in /tmp/*) rm -rf "$RUNTIME_DIR" 2>/dev/null ;; esac
exit 0 ;;
--help|-h)
cat <<EOF
linux-ssh-init-sh v4.7.8 — Server Init & SSH Hardening
USAGE:
./init.sh [OPTIONS]
CONTROL:
--lang=zh|en Force locale (default: zh)
--yes Skip the final "proceed?" prompt
--strict Abort on any critical failure (no fallback)
--delay-restart Write config but do not restart sshd / run tests
--no-ip-probe Skip public IP lookup (offline-friendly)
-V, --version Print version and exit
-h, --help This help
USER & PORT:
--user=NAME Login user (default: deploy; "root" allowed)
--port=22|random|N SSH port (random uses 49152-65535)
KEY SOURCE (pick one):
--key-gh=USERNAME Pull from https://github.com/USERNAME.keys
--key-url=URL Fetch from URL (https://; http:// only outside strict)
--key-raw="ssh-..." Inline key(s); use literal \\n between multiple lines
SYSTEM:
--update / --no-update Upgrade installed packages
--bbr / --no-bbr Enable TCP BBR congestion control
LOGS:
/var/log/server-init.log (debug log)
/var/log/server-init-audit.log (audit trail)
/var/log/server-init-health.log (post-run snapshot)
/var/backups/ssh-config/<TS>/ (restore.sh per-run)
EOF
# [v4.7.1] Same tmp-dir cleanup as --version.
[ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ] && rm -rf "$TMP_DIR" 2>/dev/null
[ -n "${RUNTIME_DIR:-}" ] && [ -d "$RUNTIME_DIR" ] && case "$RUNTIME_DIR" in /tmp/*) rm -rf "$RUNTIME_DIR" 2>/dev/null ;; esac
exit 0 ;;
esac
done
# [v4.7.6 FIX-LOW] Reject empty --port=, --key-* values explicitly.
# Without this, `--port= --yes` falls through to interactive prompt → EOF →
# default to port 22 → silent hardening downgrade.
if [ "${__seen_port:-}" = "y" ] && [ -z "$ARG_PORT" ]; then
printf '[FATAL] --port= requires a value (22, random, or 1024-65535)\n' >&2; exit 1
fi
if [ -n "$ARG_KEY_TYPE" ] && [ -z "$ARG_KEY_VAL" ]; then
printf '[FATAL] --key-%s= requires a value\n' "$ARG_KEY_TYPE" >&2; exit 1
fi
unset __seen_user __seen_port __seen_key
# ---------------- Internationalization ----------------
msg() {
key="$1"
if [ "$LANG_CUR" = "zh" ]; then
case "$key" in
MUST_ROOT) echo "必须以 root 权限运行此脚本" ;;
BANNER) echo "服务器初始化 & SSH 安全加固 (v4.7.8 Enterprise Hardening)" ;;
STRICT_ON) echo "STRICT 模式已开启:任何关键错误将直接退出" ;;
ASK_USER) echo "SSH 登录用户 (默认 " ;;
ERR_USER_INV) echo "❌ 用户名无效 (仅限小写字母/数字/下划线,且避开系统保留名)" ;;
ASK_PORT_T) echo "SSH 端口配置:" ;;
OPT_PORT_1) echo "1) 使用 22 (默认)" ;;
OPT_PORT_2) echo "2) 随机高端口 (49152+, 自动避开 K8s)" ;;
OPT_PORT_3) echo "3) 手动指定" ;;
SELECT) echo "请选择 [1-3]: " ;;
INPUT_PORT) echo "请输入端口号 (1024-65535): " ;;
PORT_ERR) echo "❌ 端口输入无效 (非数字或超范围)" ;;
PORT_RES) echo "❌ 端口被系统保留或不建议使用 (如 80, 443, 3306 等)" ;;
PORT_K8S) echo "⚠️ 警告: 此端口位于 Kubernetes NodePort 常用范围 (30000-32767),可能冲突" ;;
ASK_KEY_T) echo "SSH 公钥来源:" ;;
OPT_KEY_1) echo "1) GitHub 用户导入" ;;
OPT_KEY_2) echo "2) URL 下载" ;;
OPT_KEY_3) echo "3) 手动粘贴" ;;
INPUT_GH) echo "请输入 GitHub 用户名: " ;;
INPUT_URL) echo "请输入公钥 URL: " ;;
INPUT_RAW) echo "请粘贴公钥内容 (空行结束输入): " ;;
ASK_UPD) echo "是否更新系统软件包? [y/n] (默认 n): " ;;
ASK_BBR) echo "是否开启 BBR 加速? [y/n] (默认 n): " ;;
CONFIRM_T) echo "---------------- 执行确认 ----------------" ;;
C_USER) echo "登录用户: " ;;
C_PORT) echo "端口模式: " ;;
C_KEY) echo "密钥来源: " ;;
C_UPD) echo "系统更新: " ;;
C_BBR) echo "开启 BBR: " ;;
WARN_FW) echo "⚠ 注意:修改端口前,请确认云厂商防火墙/安全组已放行对应 TCP 端口" ;;
ASK_SURE) echo "确认执行? [y/n]: " ;;
CANCEL) echo "已取消操作" ;;
I_INSTALL) echo "正在安装基础依赖..." ;;
I_UPD) echo "正在更新系统..." ;;
I_BBR) echo "正在配置 BBR..." ;;
I_USER) echo "正在配置用户..." ;;
I_SSH_INSTALL) echo "未检测到 OpenSSH,正在安装..." ;;
I_KEY_OK) echo "公钥部署成功" ;;
W_KEY_FAIL) echo "公钥部署失败,将启用安全回退策略以避免失联" ;;
I_BACKUP) echo "已全量备份配置 (SSH/User/Firewall): " ;;
E_SSHD_CHK) echo "sshd 配置校验失败,正在回滚..." ;;
E_GREP_FAIL) echo "配置验证失败:关键参数未生效,正在回滚..." ;;
E_RESTART) echo "SSH 服务重启失败,正在回滚..." ;;
W_RESTART) echo "无法自动重启 SSH 服务,请手动重启" ;;
W_LISTEN_FAIL) echo "SSHD 已重启但端口未监听,可能启动失败,正在回滚..." ;;
DONE_T) echo "================ 完成 ================" ;;
DONE_MSG1) echo "请【不要关闭】当前窗口。" ;;
DONE_MSG2) echo "请新开一个终端窗口测试登录:" ;;
DONE_FW) echo "⚠ 若无法连接,请再次检查防火墙设置" ;;
AUTO_SKIP) echo "检测到参数输入,跳过询问: " ;;
RB_START) echo "脚本执行出现关键错误,开始自动回滚..." ;;
RB_DONE) echo "回滚完成。系统状态已恢复。" ;;
RB_FAIL) echo "致命错误:回滚失败!请立即手动检查 /etc/ssh/sshd_config" ;;
SELINUX_DET) echo "检测到 SELinux Enforcing 模式,正在配置端口规则..." ;;
SELINUX_OK) echo "SELinux 端口规则添加成功" ;;
SELINUX_FAIL) echo "SELinux 规则添加失败,请手动执行: semanage port -a -t ssh_port_t -p tcp PORT" ;;
SELINUX_INS) echo "正在安装 SELinux 管理工具..." ;;
CLEAN_D) echo "检测到冲突的配置片段,已备份并移除: " ;;
TEST_CONN) echo "正在进行 SSH 连接测试 (IPv4/Local)..." ;;
TEST_OK) echo "SSH 连接测试通过" ;;
TEST_FAIL) echo "SSH 连接测试全部失败!新配置可能无法连接,正在回滚..." ;;
IPV6_CFG) echo "检测到全局 IPv6 环境,已添加 :: 监听支持" ;;
SYS_PROT) echo "正在添加 systemd 服务防误杀保护..." ;;
MOTD_UPD) echo "正在更新登录提示信息 (MotD)..." ;;
COMPAT_WARN) echo "检测到兼容性限制,已自动调整配置..." ;;
AUDIT_START) echo "开始执行审计记录..." ;;
BOX_TITLE) echo "初始化完成 - 安全配置已生效" ;;
BOX_SSH) echo "SSH 连接信息:" ;;
BOX_KEY_ON) echo "🔐 密钥认证: 已启用 (密码登录已禁用)" ;;
BOX_KEY_OFF) echo "⚠️ 密钥认证: 未启用 (密码登录保持可用/回退策略已启用)" ;;
BOX_PORT) echo "📍 端口变更: 22 → " ;;
BOX_FW) echo "⚠️ 请确认防火墙已开放 TCP 端口" ;;
BOX_WARN) echo "重要: 请在新窗口中测试连接,确认成功后再关闭此窗口!" ;;
BOX_K8S_WARN) echo "⚠️ 注意: 使用了 Kubernetes NodePort 范围端口" ;;
ERR_MISSING) echo "❌ 缺少必要命令,无法继续: " ;;
ERR_MISSING_SSHD) echo "❌ 未找到 sshd 命令,请先安装 OpenSSH Server" ;;
WARN_DISK) echo "⚠️ 磁盘空间不足: " ;;
WARN_MEM) echo "⚠️ 可用内存不足: " ;;
WARN_RESUME) echo "检测到未完成的初始化,可能上次执行异常终止" ;;
ASK_RESUME) echo "检测到未完成的操作,是否继续? [y/N]: " ;;
ERR_BACKUP_DIR) echo "❌ 无法创建备份目录:" ;;
ERR_BACKUP_DIR_ALT) echo "❌ 无法创建备用备份目录" ;;
ERR_BACKUP_SUBDIR) echo "❌ 无法创建备份子目录:" ;;
INFO_BACKUP_CREATED) echo "✅ 备份已创建:" ;;
INFO_CLEANING_BACKUPS) echo "🧹 正在清理" ;;
INFO_OLD_BACKUPS) echo "个旧备份..." ;;
ERR_LOCK_DIR) echo "❌ 无法创建锁目录:" ;;
WARN_LOCK_DIR_PERM) echo "⚠️ 无法设置锁目录权限,继续尝试..." ;;
WARN_CLEAN_LOCKS) echo "⚠️ 清理旧的锁文件..." ;;
WARN_INVALID_KEY) echo "⚠️ 跳过无效的SSH密钥行" ;;
WARN_SHORT_RSA_KEY) echo "⚠️ RSA密钥过短:" ;;
WARN_SHORT_ED25519_KEY) echo "⚠️ Ed25519密钥过短:" ;;
WARN_SHORT_DSA_KEY) echo "⚠️ DSA密钥过短:" ;;
ERR_INVALID_KEY_FORMAT) echo "❌ SSH密钥格式无效" ;;
ERR_MISSING_BASE64) echo "❌ SSH密钥缺少base64部分" ;;
ERR_INVALID_BASE64) echo "❌ SSH密钥base64编码无效" ;;
WARN_NO_BASE64_SKIPLEN) echo "⚠️ 未检测到 base64 命令:将跳过密钥长度校验,仅做格式校验" ;;
WARN_USER_SHELL) echo "⚠️ 用户shell不允许登录:" ;;
ASK_CHANGE_SHELL) echo "是否更改用户的shell为/bin/bash? [y/N]: " ;;
WARN_CHANGE_SHELL_FAIL) echo "⚠️ 更改shell失败" ;;
WARN_UNUSUAL_SHELL) echo "⚠️ 用户使用非常规shell:" ;;
WARN_HOME_OWNER) echo "⚠️ 用户家目录所有者异常:" ;;
WARN_HOME_NOT_WRITABLE) echo "⚠️ 用户家目录不可写" ;;
ERR_USER_CREATE_FAIL) echo "❌ 创建用户失败" ;;
ERR_USER_VERIFY_FAIL) echo "❌ 用户创建后验证失败" ;;
WARN_NO_SUDOERS_DIR) echo "⚠️ 没有/etc/sudoers.d目录,跳过sudo配置" ;;
INFO_SUDO_EXISTS) echo "ℹ️ 用户已配置sudo权限" ;;
ERR_SUDOERS_SYNTAX) echo "❌ sudoers文件语法错误,已删除" ;;
ERR_SUDOERS_PERM) echo "❌ 无法设置sudoers文件权限" ;;
INFO_SUDO_CONFIGURED) echo "✅ 为用户配置了sudo权限" ;;
WARN_SSH_PROTOCOL) echo "⚠️ SSH协议握手失败或超时" ;;
INFO_SSH_PROTOCOL_OK) echo "✅ SSH协议握手成功" ;;
WARN_PORT_OPEN_BUT_FAIL) echo "⚠️ 端口已打开,但SSH客户端连接失败(通常因无私钥或默认私钥不匹配)。此非错误,请务必人工测试连接!" ;;
WARN_X11_FORWARDING) echo "⚠️ X11转发已启用,可能存在安全风险" ;;
WARN_EMPTY_PASSWORDS) echo "⚠️ 允许空密码,存在安全风险" ;;
WARN_INSECURE_OPTIONS) echo "⚠️ 检测到非关键的不安全选项 (仅提示,不影响安装)" ;;
ERR_DEADLOCK) echo "❌ 致命错误:密码和密钥认证同时被禁用,将导致锁定!" ;;
ERR_PASSWORD_NO_KEY) echo "❌ 致命错误:密码认证已禁用但未成功部署SSH密钥" ;;
ERR_ROOT_NO_KEY) echo "❌ 致命错误:root密码登录已禁用但未部署SSH密钥" ;;
WARN_PORT_MISMATCH) echo "⚠️ 配置中的端口与目标端口不匹配" ;;
ERR_CANNOT_RESERVE_PORT) echo "❌ 无法预留端口,端口可能已被占用" ;;
INFO_OLD_SSH_SKIP_ALGO) echo "ℹ️ OpenSSH较旧或无法检测支持列表:跳过现代加密算法强制配置" ;;
INFO_SANITIZE_DUP) echo "ℹ️ 清理原配置文件中的重复指令..." ;;
INFO_MATCH_INSERT) echo "ℹ️ 检测到 Match 块:托管配置将插入到首个 Match 之前,以避免语法/作用域问题" ;;
ERR_NO_BANNER) echo "❌ 未能获取 SSH-2.0 协议 banner,服务可能未正常启动" ;;
INFO_KEYS_DEPLOYED) echo "✅ 成功部署密钥数量:" ;;
WARN_NO_VALID_KEYS) echo "⚠️ 没有有效的SSH密钥被部署" ;;
ERR_HOME_SYMLINK) echo "❌ 拒绝:用户家目录是符号链接" ;;
ERR_SSH_DIR_SYMLINK) echo "❌ 拒绝:.ssh 目录是符号链接" ;;
ERR_AUTH_KEYS_SYMLINK) echo "❌ 拒绝:authorized_keys 是符号链接" ;;
ERR_HOME_NOT_DIR) echo "❌ 拒绝:用户家目录不是目录" ;;
ERR_SSH_DIR_NOT_DIR) echo "❌ 拒绝:.ssh 存在但不是目录" ;;
ERR_AUTH_KEYS_NOT_FILE) echo "❌ 拒绝:authorized_keys 存在但不是普通文件" ;;
DELAY_RESTART_MSG) echo "⚠️ 延迟重启模式:配置已写入,请手动重启 sshd 并测试连接" ;;
*) echo "$key" ;;
esac
else
case "$key" in
MUST_ROOT) echo "Must be run as root" ;;
BANNER) echo "Server Init & SSH Hardening (v4.7.8 Enterprise Hardening)" ;;
STRICT_ON) echo "STRICT mode ON: Critical errors will abort" ;;
ASK_USER) echo "SSH Login User (default " ;;
ERR_USER_INV) echo "❌ Invalid username (lowercase/digits/underscore only, no reserved words)" ;;
ASK_PORT_T) echo "SSH Port Configuration:" ;;
OPT_PORT_1) echo "1) Use 22 (Default)" ;;
OPT_PORT_2) echo "2) Random High Port (49152+, avoids K8s)" ;;
OPT_PORT_3) echo "3) Manual Input" ;;
SELECT) echo "Select [1-3]: " ;;
INPUT_PORT) echo "Enter Port (1024-65535): " ;;
PORT_ERR) echo "❌ Invalid port (not numeric or out of range)" ;;
PORT_RES) echo "❌ Port is reserved (e.g. 80, 443, 3306)" ;;
PORT_K8S) echo "⚠️ Warning: Port falls in Kubernetes NodePort range (30000-32767)" ;;
ASK_KEY_T) echo "SSH Public Key Source:" ;;
OPT_KEY_1) echo "1) GitHub User" ;;
OPT_KEY_2) echo "2) URL Download" ;;
OPT_KEY_3) echo "3) Manual Paste" ;;
INPUT_GH) echo "Enter GitHub Username: " ;;
INPUT_URL) echo "Enter Key URL: " ;;
INPUT_RAW) echo "Paste Key (Empty line to finish): " ;;
ASK_UPD) echo "Update system packages? [y/n] (default n): " ;;
ASK_BBR) echo "Enable TCP BBR? [y/n] (default n): " ;;
CONFIRM_T) echo "---------------- Confirmation ----------------" ;;
C_USER) echo "User: " ;;
C_PORT) echo "Port: " ;;
C_KEY) echo "Key Source: " ;;
C_UPD) echo "Update: " ;;
C_BBR) echo "Enable BBR: " ;;
WARN_FW) echo "⚠ WARNING: Ensure Cloud Firewall/Security Group allows the new TCP port" ;;
ASK_SURE) echo "Proceed? [y/n]: " ;;
CANCEL) echo "Cancelled." ;;
I_INSTALL) echo "Installing dependencies..." ;;
I_UPD) echo "Updating system..." ;;
I_BBR) echo "Configuring BBR..." ;;
I_USER) echo "Configuring user..." ;;
I_SSH_INSTALL) echo "OpenSSH not found, installing..." ;;
I_KEY_OK) echo "SSH Key deployed successfully" ;;
W_KEY_FAIL) echo "Key deployment failed; enabling fallback policy to avoid lockout" ;;
I_BACKUP) echo "Full backup created (SSH/User/Firewall): " ;;
E_SSHD_CHK) echo "sshd config validation failed, rolling back..." ;;
E_GREP_FAIL) echo "Config validation failed: Critical settings not active. Rolling back..." ;;
E_RESTART) echo "SSH service restart failed, rolling back..." ;;
W_RESTART) echo "Could not restart sshd automatically. Please restart manually." ;;
W_LISTEN_FAIL) echo "SSHD restarted but port is not listening. Rolling back..." ;;
DONE_T) echo "================ DONE ================" ;;
DONE_MSG1) echo "Please DO NOT close this window yet." ;;
DONE_MSG2) echo "Open a NEW terminal to test login:" ;;
DONE_FW) echo "⚠ If connection fails, check your Firewall settings." ;;
AUTO_SKIP) echo "Argument detected, skipping prompt: " ;;
RB_START) echo "Critical error. Starting automatic rollback..." ;;
RB_DONE) echo "Rollback complete. System state restored." ;;
RB_FAIL) echo "FATAL: Rollback failed! Manually check /etc/ssh/sshd_config" ;;
SELINUX_DET) echo "SELinux Enforcing detected. Configuring port rules..." ;;
SELINUX_OK) echo "SELinux port rule added successfully." ;;
SELINUX_FAIL) echo "SELinux rule failed. Manually run: semanage port -a -t ssh_port_t -p tcp PORT" ;;
SELINUX_INS) echo "Installing SELinux management tools..." ;;
CLEAN_D) echo "Detected conflicting config fragment, backed up and removed: " ;;
TEST_CONN) echo "Testing SSH connection (IPv4/Local)..." ;;
TEST_OK) echo "SSH connection test passed." ;;
TEST_FAIL) echo "SSH connection test FAILED! Rolling back..." ;;
IPV6_CFG) echo "Global IPv6 detected. Added listen address :: support." ;;
SYS_PROT) echo "Adding systemd service protection (anti-kill)..." ;;
MOTD_UPD) echo "Updating Message of the Day (MotD)..." ;;
COMPAT_WARN) echo "Compatibility limits detected; adjusted configuration automatically..." ;;
AUDIT_START) echo "Starting audit logging..." ;;
BOX_TITLE) echo "Init Complete - Security Applied" ;;
BOX_SSH) echo "SSH Connection Info:" ;;
BOX_KEY_ON) echo "🔐 Key Auth: ENABLED (Password Disabled)" ;;
BOX_KEY_OFF) echo "⚠️ Key Auth: DISABLED (Password/Fallback Enabled)" ;;
BOX_PORT) echo "📍 Port Change: 22 → " ;;
BOX_FW) echo "⚠️ Verify Firewall Open for TCP Port" ;;
BOX_WARN) echo "IMPORTANT: Test connection in NEW window before closing this one!" ;;
BOX_K8S_WARN) echo "⚠️ NOTE: Using K8s NodePort range" ;;
ERR_MISSING) echo "❌ Missing essential commands: " ;;
ERR_MISSING_SSHD) echo "❌ sshd command not found, please install OpenSSH Server first" ;;
WARN_DISK) echo "⚠️ Low disk space: " ;;
WARN_MEM) echo "⚠️ Low memory: " ;;
WARN_RESUME) echo "Detected incomplete initialization, last execution may have crashed" ;;
ASK_RESUME) echo "Detected incomplete operation, continue? [y/N]: " ;;
ERR_BACKUP_DIR) echo "❌ Cannot create backup directory:" ;;
ERR_BACKUP_DIR_ALT) echo "❌ Cannot create alternative backup directory" ;;
ERR_BACKUP_SUBDIR) echo "❌ Cannot create backup subdirectory:" ;;
INFO_BACKUP_CREATED) echo "✅ Backup created:" ;;
INFO_CLEANING_BACKUPS) echo "🧹 Cleaning" ;;
INFO_OLD_BACKUPS) echo "old backups..." ;;
ERR_LOCK_DIR) echo "❌ Cannot create lock directory:" ;;
WARN_LOCK_DIR_PERM) echo "⚠️ Cannot set lock directory permissions, continuing..." ;;
WARN_CLEAN_LOCKS) echo "⚠️ Cleaning old lock files..." ;;
WARN_INVALID_KEY) echo "⚠️ Skipping invalid SSH key line" ;;
WARN_SHORT_RSA_KEY) echo "⚠️ RSA key too short:" ;;
WARN_SHORT_ED25519_KEY) echo "⚠️ Ed25519 key too short:" ;;
WARN_SHORT_DSA_KEY) echo "⚠️ DSA key too short:" ;;
ERR_INVALID_KEY_FORMAT) echo "❌ SSH key format invalid" ;;
ERR_MISSING_BASE64) echo "❌ SSH key missing base64 part" ;;
ERR_INVALID_BASE64) echo "❌ SSH key base64 encoding invalid" ;;
WARN_NO_BASE64_SKIPLEN) echo "⚠️ base64 not found: skipping key length checks (format-only validation)" ;;
WARN_USER_SHELL) echo "⚠️ User shell does not allow login:" ;;
ASK_CHANGE_SHELL) echo "Change user's shell to /bin/bash? [y/N]: " ;;
WARN_CHANGE_SHELL_FAIL) echo "⚠️ Failed to change shell" ;;
WARN_UNUSUAL_SHELL) echo "⚠️ User uses unusual shell:" ;;
WARN_HOME_OWNER) echo "⚠️ User home directory owner mismatch:" ;;
WARN_HOME_NOT_WRITABLE) echo "⚠️ User home directory not writable" ;;
ERR_USER_CREATE_FAIL) echo "❌ Failed to create user" ;;
ERR_USER_VERIFY_FAIL) echo "❌ User verification failed after creation" ;;
WARN_NO_SUDOERS_DIR) echo "⚠️ No /etc/sudoers.d directory, skipping sudo config" ;;
INFO_SUDO_EXISTS) echo "ℹ️ User already has sudo permissions" ;;
ERR_SUDOERS_SYNTAX) echo "❌ sudoers file syntax error, deleted" ;;
ERR_SUDOERS_PERM) echo "❌ Cannot set sudoers file permissions" ;;
INFO_SUDO_CONFIGURED) echo "✅ Configured sudo permissions for user" ;;
WARN_SSH_PROTOCOL) echo "⚠️ SSH protocol handshake failed or timed out" ;;
INFO_SSH_PROTOCOL_OK) echo "✅ SSH protocol handshake successful" ;;
WARN_PORT_OPEN_BUT_FAIL) echo "⚠️ Port is open, but SSH connection failed (likely due to missing/mismatched private key). This is NOT an error. Please verify connection manually!" ;;
WARN_X11_FORWARDING) echo "⚠️ X11 forwarding enabled, potential security risk" ;;
WARN_EMPTY_PASSWORDS) echo "⚠️ Empty passwords allowed, security risk" ;;
WARN_INSECURE_OPTIONS) echo "⚠️ Found non-critical insecure options (Info only, proceeding)" ;;
ERR_DEADLOCK) echo "❌ FATAL: Both password and key authentication disabled, will cause lockout!" ;;
ERR_PASSWORD_NO_KEY) echo "❌ FATAL: Password auth disabled but no SSH key deployed" ;;
ERR_ROOT_NO_KEY) echo "❌ FATAL: Root password login disabled but no SSH key deployed" ;;
WARN_PORT_MISMATCH) echo "⚠️ Port in config does not match target port" ;;
ERR_CANNOT_RESERVE_PORT) echo "❌ Cannot reserve port, port may be occupied" ;;
INFO_OLD_SSH_SKIP_ALGO) echo "ℹ️ Old OpenSSH or unable to detect supported lists: skipping forced crypto algorithms" ;;
INFO_SANITIZE_DUP) echo "ℹ️ Sanitizing duplicate directives in original config..." ;;
INFO_MATCH_INSERT) echo "ℹ️ Match blocks detected: inserting managed block before first Match to avoid scope issues" ;;
ERR_NO_BANNER) echo "❌ Failed to get SSH-2.0 protocol banner, service may not be running properly" ;;
INFO_KEYS_DEPLOYED) echo "✅ Number of keys deployed:" ;;
WARN_NO_VALID_KEYS) echo "⚠️ No valid SSH keys were deployed" ;;
ERR_HOME_SYMLINK) echo "❌ Refuse: user home is symlink" ;;
ERR_SSH_DIR_SYMLINK) echo "❌ Refuse: .ssh is symlink" ;;
ERR_AUTH_KEYS_SYMLINK) echo "❌ Refuse: authorized_keys is symlink" ;;
ERR_HOME_NOT_DIR) echo "❌ Refuse: user home is not a directory" ;;
ERR_SSH_DIR_NOT_DIR) echo "❌ Refuse: .ssh exists but is not a directory" ;;
ERR_AUTH_KEYS_NOT_FILE) echo "❌ Refuse: authorized_keys exists but is not a regular file" ;;
DELAY_RESTART_MSG) echo "⚠️ Delay restart mode: config written, please manually restart sshd and test" ;;
*) echo "$key" ;;
esac
fi
}
# ---------------- Logging & Audit ----------------
init_log_files() {
for logfile in "$LOG_FILE" "$AUDIT_FILE"; do
# [v4.7.4 FIX-LOW] Reject pre-planted symlinks at log paths. A symlink