Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 ]
Expand Down
26 changes: 25 additions & 1 deletion bin/setpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <mendez.foto@gmail.com> 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
Expand Down
3 changes: 2 additions & 1 deletion firstboot.d/30rootpass
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 76 additions & 2 deletions tests/test_setpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Loading