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
6 changes: 4 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ jobs:
with:
threshold: 98
# tests/test-packaging.bats runs debhelper over debian/ and reads the
# maintainer scripts it generates; in CI a missing debhelper fails it
apt-packages: debhelper
# maintainer scripts it generates, and tests/test-firstboot-pty.bats
# runs the first boot screens with the real dialog; in CI a missing
# package fails them
apt-packages: debhelper dialog python3-dialog
python:
uses: keel-linux/.github/.github/workflows/test-python.yml@main
with:
Expand Down
115 changes: 115 additions & 0 deletions bin/setpass.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,49 @@

Options:
-p --pass= if not provided, will ask interactively

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
LXC writing /etc/shadow in the root file system before the first boot) is
offered Keep, first: the password stays as it is. Only one the image did
not ship: `passwd -S` says it is usable, the image carries its build date
(/etc/keel/build-date, written by common's seal-root, which fails a build
whose root is not locked) and the password last changed on or after it,
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.
"""

import datetime
import os
import sys
import getopt
import subprocess
import signal
from typing import NoReturn

# `passwd -S` status of an account that can log in with a password: L is
# locked (a hash starting with ! or *, which is how images ship root), NP
# has none.
USABLE = "P"
PASSWD_TIMEOUT = 10
# systemd writes it in a container, whatever the container manager
CONTAINER_MARKER = "/run/systemd/container"
# Where the account database and the image's build date are read; the
# variables point a test at scratch files.
SHADOW = "/etc/shadow"
BUILD_DATE = "/etc/keel/build-date"
SHADOW_VAR = "INITHOOKS_SHADOW"
BUILD_DATE_VAR = "INITHOOKS_BUILD_DATE"
EPOCH = datetime.date(1970, 1, 1)
# Password fields images shipped: the crypt() of the empty string, in five
# older WordPress images.
PLACEHOLDERS = frozenset({"U6aMy0wojraho"})
EXPLICIT_RUN = "_TURNKEY_INIT"
# 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)"


def fatal(
msg: str | subprocess.TimeoutExpired | subprocess.CalledProcessError,
Expand All @@ -31,6 +66,78 @@ def usage(msg: str | getopt.GetoptError = "") -> NoReturn:
sys.exit(1)


def password_usable(username: str) -> bool:
"""Whether USERNAME can log in with a password now, by `passwd -S`

Anything but a clear yes (passwd missing, failing, slow, or another
status) is no, and the screen is the one without Keep.
"""
try:
out = subprocess.run(
["passwd", "-S", username],
capture_output=True,
text=True,
check=False,
timeout=PASSWD_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired):
return False
fields = out.stdout.split()
return out.returncode == 0 and len(fields) > 1 and fields[1] == USABLE


def build_day(path: str) -> int | None:
"""The day the image was built, in days since 1970-01-01 as shadow
counts them, from PATH (YYYY-MM-DD); None when it cannot be read"""
try:
with open(path) as fob:
built = datetime.date.fromisoformat(fob.read().strip())
except (OSError, ValueError):
return None
return (built - EPOCH).days


def set_after_build(username: str) -> bool:
"""Whether USERNAME's password was set on this machine, not shipped

Its shadow entry must hold a field that is neither empty nor one of
PLACEHOLDERS, last changed on or after the build day. The same day
counts: shadow keeps days, a container is often created the day its
image was built, and the image left the build with root locked.
"""
built = build_day(os.environ.get(BUILD_DATE_VAR, BUILD_DATE))
if built is None:
return False
try:
with open(os.environ.get(SHADOW_VAR, SHADOW)) as fob:
entry = next(
(line.rstrip("\n").split(":") for line in fob
if line.split(":", 1)[0] == username),
None,
)
except OSError:
return False
if entry is None or len(entry) < 3:
return False
if not entry[1] or entry[1] in PLACEHOLDERS:
return False
try:
return int(entry[2]) >= built > 0
except ValueError:
return False


def keep_offer(username: str) -> str:
"""The description of Keep for USERNAME, or "" for no Keep"""
if os.environ.get(EXPLICIT_RUN) or not password_usable(username):
return ""
if not set_after_build(username):
return ""
if os.path.exists(CONTAINER_MARKER):
return KEEP_CONTAINER
return KEEP_MACHINE


def main():
signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
Expand All @@ -56,7 +163,15 @@ def main():
password = d.get_password(
f"{username.capitalize()} Password",
f"Please enter new password for the {username} account.",
keep=keep_offer(username),
)
if password is None:
print(
f"setpass: the {username} password set before the first"
" boot was kept",
file=sys.stderr,
)
return

assert password
command = ["chpasswd"]
Expand Down
52 changes: 52 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
inithooks (2.3.6+keel17) trixie; urgency=medium

* The console no longer looks frozen between first boot screens. On a
Proxmox VE container the maintainer pressed <Saved> on "Did you save
the password?" and the screen stayed on it until the Keel Cloud
screen came. Nothing hung: what ran in between drew nothing. Measured
on an LXC container from the step8 Web image, the gap is 2 s, and
15 s with a quarter of a CPU: the boot wait ran before every hook from
30 on, asking systemctl eleven times each and sleeping up to 10 s per
hook while the system was still starting, then 75keel-role ran keel
inspect. run now waits for a starting system once per run, stops
waiting as soon as it is up, and says so on the screen. Each first
boot hook is named on the screen while it runs ("Configuring
keel-role... please wait"), in a box with the first boot backtitle,
so a step that works without a screen of its own never leaves the
last one up. Nothing is drawn when the output is not a terminal or
goes to the log (REDIRECT_OUTPUT), and a notice that cannot be drawn
is not an error.
* A root password set when the container was created is kept if the
operator wants. pct create --password (or LXC writing /etc/shadow
before the first boot) gave root a password, and the first boot made
the operator replace it. The images ship root locked; when root can
already log in with a password at first boot, the password screen
offers Keep first, as the default and the recommendation, with
Generate and Manual below it. Only a password the image did not ship:
passwd -S must say it is usable, the image must carry its build date
(/etc/keel/build-date, from common's seal-root, which fails a build
whose root is not locked) and the password must have changed on or
after it, and the shadow field must be neither empty nor
U6aMy0wojraho, which older WordPress images shipped; a build with
ROOT_PASS, or an image without the date, gets no Keep. The field is
compared, never printed or logged. INITHOOKS_SHADOW and
INITHOOKS_BUILD_DATE point the tests at scratch files.
Without such a password the screen is as before, a preseeded
ROOT_PASS or a declared secrets.root_password still draws no screen,
and keel-init still asks for a new one. Dialog.get_password() takes
keep, the description of the Keep entry, and returns None when it is
chosen; a menu is drawn wide enough for its longest entry, up to 76
columns. 30rootpass reads INITHOOKS_DEFAULT like the other hooks.
* tests/test-firstboot-pty.bats runs run, the real 30rootpass,
setpass.py and dialog on a pty under script, typing each key when its
screen has reached the pty, with a timeout on the whole run: Generate
then Saved, New, Manual, Keep, Generate below Keep and a preseeded
password each go on to the next hook's screen. A run that stops
answering fails it, and so does a screen drawn into a pipe, which
never reaches the pty. CI installs dialog and python3-dialog for it.
DIALOG_LOG names the file dialog_wrapper logs to (default
/var/log/dialog.log), so the test runs as a user who cannot write
/var/log.

-- Marcos Mendez <mendez.foto@gmail.com> Fri, 02 Oct 2026 16:00:00 +0000

inithooks (2.3.6+keel16) trixie; urgency=medium

* A generated password is shown on one screen only. The screen that shows
Expand Down
17 changes: 12 additions & 5 deletions firstboot.d/30rootpass
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
#!/bin/bash -e
# set root password
#
# ROOT_PASS preseeded (or rendered from secrets.root_password by
# 00declarative) sets it without a screen. 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.

USERNAME=root

. /etc/default/inithooks
[ "$(echo $SUDOADMIN | tr [A-Z] [a-z] )" = "true" ] && USERNAME=admin

[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF
$INITHOOKS_PATH/bin/setpass.py $USERNAME --pass="$ROOT_PASS"
INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}"
# shellcheck source=default/inithooks
. "$INITHOOKS_DEFAULT"
[ "$(echo "$SUDOADMIN" | tr '[:upper:]' '[:lower:]')" = "true" ] && USERNAME="admin"

# shellcheck disable=SC1090
[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF"
"$INITHOOKS_PATH/bin/setpass.py" "$USERNAME" --pass="$ROOT_PASS"
59 changes: 46 additions & 13 deletions libinithooks/dialog_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@
if "DIALOG_DEBUG" in environ.keys():
LOG_LEVEL = logging.DEBUG

# DIALOG_LOG names another file, for tests that run a hook as a user who
# cannot write /var/log
logging.basicConfig(
filename="/var/log/dialog.log", encoding="utf-8", level=LOG_LEVEL
filename=environ.get("DIALOG_LOG", "/var/log/dialog.log"),
encoding="utf-8",
level=LOG_LEVEL,
)


Expand Down Expand Up @@ -61,6 +65,14 @@ def password_complexity(password: str) -> int:
PASSWORD_UPPER + PASSWORD_LOWER + PASSWORD_DIGITS + PASSWORD_SYMBOLS
)
GENERATED_LENGTH = 20
# The menu tag of get_password(keep=...): the account keeps its password.
KEEP = "Keep"
# Columns a menu box takes besides its longest tag and description, and the
# widest box drawn, which an 80 column console shows whole (measured with
# dialog 1.3 on tty1 of an LXC container: a box 76 wide shows 58 columns of
# description beside an 8 column tag).
MENU_MARGIN = 10
MENU_MAX_WIDTH = 76
GENERATED_MIN_LENGTH = 12
# Generated candidates tried against the caller's rules before the operator
# is asked to type a password instead (a pass_req regex can refuse them all).
Expand Down Expand Up @@ -386,12 +398,17 @@ def menu(
"""Titled message with single choice of options & 'ok' button.
choices is a list of options, each a tuple of the option tag and
its short description: [(opt1, opt1_info), (opt2, opt2_info)]
The box is self.width wide, wider when an option needs it, up to
MENU_MAX_WIDTH, so that no description is cut off.
Returns the selected option tag - e.g. 'opt1'"""
needed = MENU_MARGIN + max(len(tag) for tag, _ in choices) + max(
len(info) for _, info in choices
)
_, choice = self.wrapper( # return_code, choice
"menu",
text,
self.height,
self.width,
max(self.width, min(needed, MENU_MAX_WIDTH)),
menu_height=len(choices) + 1,
title=title,
choices=choices,
Expand All @@ -408,16 +425,21 @@ def get_password(
blacklist: list[str] | None = None,
offer_generate: bool = True,
gen_length: int = GENERATED_LENGTH,
keep: str = "",
) -> str | None:
"""Validated password, generated or typed.
"""Validated password, generated or typed; None when kept.

When offer_generate is True (the default), a menu comes first:
- Generate (recommended): a random password (generate_password),
shown to the operator, who must confirm it was saved; 'New'
discards it and shows another.
- Keep, only when KEEP is given: the password the account has
already, which KEEP describes. It is first, the default and
the recommendation, and choosing it returns None.
- Generate (recommended without Keep): a random password
(generate_password), shown to the operator, who must confirm
it was saved; 'New' discards it and shows another.
- Manual: the password box below.
Existing callers get the menu without any change. Pass
offer_generate=False for the password box alone, as before.
offer_generate=False for the password box alone, as before; Keep
needs the menu, and asking for both raises ValueError.

The generated password satisfies the same rules as a typed one
(pass_req, min_complexity, blacklist): it is gen_length characters
Expand All @@ -428,7 +450,9 @@ def get_password(

ESC never skips the password: every dialog of it is shown again.

Returns password"""
Returns password, or None when the operator chose Keep"""
if keep and not offer_generate:
raise ValueError("get_password(): keep needs the menu")
required = self._value_required
self._value_required = True
try:
Expand All @@ -440,6 +464,7 @@ def get_password(
list(blacklist or []),
offer_generate,
gen_length,
keep,
)
finally:
self._value_required = required
Expand All @@ -453,16 +478,24 @@ def _get_password(
blacklist: list[str],
offer_generate: bool,
gen_length: int,
) -> str:
keep: str = "",
) -> str | None:
if offer_generate:
generate = "A strong random password"
choices = [(KEEP, keep)] if keep else []
if not keep:
generate += " (recommended)"
choices += [
("Generate", generate),
("Manual", "Type my own password"),
]
choice = self.menu(
title,
f"{text}\n\nChoose how to set this password:",
[
("Generate", "A strong random password (recommended)"),
("Manual", "Type my own password"),
],
choices,
)
if choice == KEEP:
return None
if choice == "Generate":
password = self._generate_password_flow(
title, pass_req, min_complexity, blacklist, gen_length
Expand Down
Loading
Loading