diff --git a/README.rst b/README.rst index 04b90c7..dfd20d3 100644 --- a/README.rst +++ b/README.rst @@ -389,6 +389,16 @@ becomes the hostname, the /etc/hosts entry and the name of the self-signed certificate, and is recorded in the instance description. FQDN=SKIP keeps the name the machine has. +ROOT_PASS=KEEP keeps the root (or admin) password set before the first +boot, by pct create --password or LXC in the root file system, so an +unattended first boot need not repeat it. It is accepted exactly when the +first boot screen would offer Keep: the image carries its build date +(/etc/keel/build-date), the password is usable and was last changed on or +after it, and it is not the placeholder older images shipped. Otherwise +30rootpass fails with an error in /var/log/inithooks.log, as an invalid +preseed does, and the password is left as it was; KEEP (in any case) is +never set as the password. + This preseeding mechanism makes it relatively easy to integrate TurnKey with custom control panels, virtualization solutions, etc. @@ -609,7 +619,7 @@ Common to all appliances:: IP6_SLAAC [ yes | no ] 15regen-sslcert DH_BITS [ 1024 | 2048 | 4096 ] 29preseed INITFENCE [ SKIP ] - 30rootpass* ROOT_PASS + 30rootpass* ROOT_PASS [ KEEP | the password ] 31fqdn FQDN [ SKIP | the name ] 75keel-role database.server.role of the instance description 80keel-cloud HUB_APIKEY [ SKIP | the key ] diff --git a/bin/setpass.py b/bin/setpass.py index 138bba8..9c40243 100755 --- a/bin/setpass.py +++ b/bin/setpass.py @@ -6,7 +6,8 @@ username username of account to set password for Options: - -p --pass= if not provided, will ask interactively + -p --pass= if not provided, will ask interactively; KEEP (any case) + keeps the password as Keep does, without a screen Asked interactively at first boot, an account that can already log in with a password set when the container was created (`pct create --password`, or @@ -18,6 +19,11 @@ and the shadow field is neither empty nor a placeholder older images shipped. The field is compared and never printed or logged. keel-init (_TURNKEY_INIT) asks as before. + +--pass=KEEP (ROOT_PASS=KEEP in inithooks.conf) is the unattended Keep: +accepted exactly when the screen would offer Keep, by the same check. +Otherwise it is an error on stderr and exit 1, as other invalid preseeds +are; KEEP is never set as the password and nothing is asked. """ import datetime @@ -46,6 +52,8 @@ # older WordPress images. PLACEHOLDERS = frozenset({"U6aMy0wojraho"}) EXPLICIT_RUN = "_TURNKEY_INIT" +# The preseed value that asks for Keep, compared whatever its case +PRESEED_KEEP = "KEEP" # Each fits beside the Generate tag in the widest menu dialog_wrapper draws KEEP_CONTAINER = "Password set when the container was created (recommended)" KEEP_MACHINE = "Password already set on this machine (recommended)" @@ -156,6 +164,22 @@ def main(): elif opt in ("-p", "--pass"): password = val + if password.upper() == PRESEED_KEEP: + if not keep_offer(username): + fatal( + f"ROOT_PASS={PRESEED_KEEP}: the {username} password cannot" + " be kept; Keep needs one set on this machine on or after" + " the image build date (pct create --password), not locked," + " empty or the placeholder, and is not offered under" + " keel-init" + ) + print( + f"setpass: the {username} password set before the first boot" + f" was kept (ROOT_PASS={PRESEED_KEEP})", + file=sys.stderr, + ) + return + if not password: from libinithooks.dialog_wrapper import Dialog diff --git a/debian/changelog b/debian/changelog index 242f8c7..a6d42f5 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,18 @@ +inithooks (2.3.6+keel24) trixie; urgency=medium + + * ROOT_PASS=KEEP keeps the root (or admin) password set before the first + boot, so an unattended first boot after pct create --password no + longer needs the password repeated in ROOT_PASS. setpass.py accepts + --pass=KEEP, in any case, exactly when the screen would offer Keep, + by the same keep_offer() check: the build date is there, the password + is usable and was last changed on or after it, it is not the + placeholder, and this is not keel-init. Otherwise it writes an error + naming ROOT_PASS=KEEP to stderr and exits 1, as an invalid FQDN or + SEC_ALERTS preseed does; KEEP is never set as the password and + nothing is asked. The README lists the value. + + -- Marcos Mendez Sat, 03 Oct 2026 20:42:55 +0000 + inithooks (2.3.6+keel23) trixie; urgency=medium * The first boot's security updates never hold it. 95secupdates ran diff --git a/firstboot.d/30rootpass b/firstboot.d/30rootpass index cf44561..7491b4f 100755 --- a/firstboot.d/30rootpass +++ b/firstboot.d/30rootpass @@ -2,7 +2,8 @@ # set root password # # ROOT_PASS preseeded (or rendered from secrets.root_password by -# 00declarative) sets it without a screen. Otherwise setpass.py asks, and +# 00declarative) sets it without a screen; ROOT_PASS=KEEP keeps the one +# set before the first boot, or fails when Keep would not be offered. Otherwise setpass.py asks, and # offers to keep a password set before the first boot (pct create # --password, or LXC in the root file system) when there is one. When # nobody can answer the console (lib/console.sh), the password stays as diff --git a/tests/test_setpass.py b/tests/test_setpass.py index 6b91cf1..520ece0 100644 --- a/tests/test_setpass.py +++ b/tests/test_setpass.py @@ -117,7 +117,8 @@ def run_setpass(self, *answers, status=LOCKED, argv=("root",), environ=None): """Run setpass.py ARGV with the dialogs answering ANSWERS and passwd -S printing STATUS; return what chpasswd read ("" when it - was not run), the console and the passwd -S stand-in""" + was not run), the console and the passwd -S stand-in. The exit + status, None when main() returned, is self.status.""" setpass = load_setpass() console = FakeConsole(*answers) chpasswd = mock.MagicMock() @@ -139,7 +140,11 @@ def run_setpass(self, *answers, status=LOCKED, argv=("root",), redirect_stdout(self.printed), capture_logs() as self.logged, ): - setpass.main() + self.status = None + try: + setpass.main() + except SystemExit as stopped: + self.status = stopped.code # whatever happened, the shadow field went nowhere for said in (self.printed.getvalue(), *self.logged, console.shown()): self.assertNotIn(HASH, said) @@ -336,5 +341,74 @@ def test_the_admin_account_is_asked_about_itself(self): self.assertEqual(passwd.call_args.args[0], ["passwd", "-S", "admin"]) +class TestPreseededKeep(SetpassCase): + """ROOT_PASS=KEEP: keep the password the hypervisor set, without a + screen, under exactly the conditions the screen offers Keep. Anything + else is an error, never a password "KEEP" and never a screen.""" + + def run_keep(self, value="KEEP", status=USABLE, environ=None): + return self.run_setpass(status=status, environ=environ, + argv=("root", f"--pass={value}")) + + def assert_kept(self, value="KEEP"): + given, console, _ = self.run_keep(value) + self.assertIsNone(self.status) + self.assertEqual(given, "") + self.assertEqual(console.calls, []) + self.assertIn("kept", self.printed.getvalue()) + + def assert_refused(self, status=USABLE, environ=None): + given, console, _ = self.run_keep(status=status, environ=environ) + self.assertEqual(self.status, 1) + self.assertEqual(given, "") + self.assertEqual(console.calls, []) + said = self.printed.getvalue() + self.assertIn("Error:", said) + self.assertIn("ROOT_PASS=KEEP", said) + + def test_keep_leaves_a_password_set_after_the_build_alone(self): + self.assert_kept() + + def test_keep_on_the_build_day_is_accepted(self): + self.write_shadow(changed=BUILD_DAY) + self.assert_kept() + + def test_keep_is_read_whatever_its_case(self): + # "keep" must not become the password either + for value in ("keep", "Keep"): + with self.subTest(value=value): + self.assert_kept(value) + + def test_keep_outside_a_container_is_accepted(self): + self.in_container(False) + self.assert_kept() + + def test_keep_of_a_locked_password_fails(self): + self.assert_refused(status=LOCKED) + + def test_keep_of_an_empty_password_fails(self): + self.assert_refused(status=EMPTY) + + def test_keep_of_a_password_older_than_the_image_fails(self): + self.write_shadow(changed=BUILD_DAY - 1) + self.assert_refused() + + def test_keep_on_an_image_without_a_build_date_fails(self): + os.remove(self.build_date) + self.assert_refused() + + def test_keep_of_the_placeholder_fails(self): + self.write_shadow(field=PLACEHOLDER) + self.assert_refused() + + def test_keep_under_keel_init_fails(self): + # keel-init offers no Keep, so no KEEP either + self.assert_refused(environ={"_TURNKEY_INIT": "y"}) + + def test_a_password_containing_keep_is_set(self): + given, _, _ = self.run_keep("KEEPme-123") + self.assertEqual(given, "root:KEEPme-123") + + if __name__ == "__main__": unittest.main()