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
19 changes: 19 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ Measured on 2026-09-24 against upstream master (33c43b8), following the
project decision 0003 (90 percent floor per repository, 95 percent for every
file our changes touch).

## Branch fix/headless-first-boot: shell 99.69, Python 99 (2026-10-03)

A first boot nobody can answer, the hosts entry and the certificate's
name. `lib/console.sh` is new, 22/22, and `lib/sslcert.sh`, 51/51, takes
the body of `firstboot.d/15regen-sslcert` (9/9 now). The new
`tests/test-console.bats` (23 tests) runs the rule on ptys whose master
is never read, unsized and sized, on a read pty under `script`, on the
controlling terminal and with none, and every hook that draws a screen
on both unread ptys within a deadline, its screen a stand-in that never
returns (31fqdn's the real `bin/fqdn.py`). The python pty harnesses of
this file and `test-run.bats` keep kcov's trace descriptor open
(`close_fds=False`): with it closed, what ran under them was not
measured. `firstboot.d/31fqdn` 43/43, with the hosts entry after SKIP, an
empty answer and nobody to answer, and the certificate made again after
a rename (real openssl), kept when it is for the name, signed by an
authority, or unreadable. `bin/fqdn.py` and `libinithooks/fqdn.py` stay
at 100 percent with `--machine`, `in_hosts` and `machine`. 367 bats, 530
pytest; shell total 99.69, Python 99.

## Branch fix/first-boot-without-journal-or-console: shell 99.64 (2026-10-03)

Two first boot stalls of the published core booted headless.
Expand Down
26 changes: 26 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,40 @@ configuration variables into it before the first system boot. For example::
export DB_PASS=supersecretmysqlpass
export APP_EMAIL=admin@example.com
export APP_PASS=webappadminpassword
export FQDN=blog.example.org
export SEC_ALERTS=admin@example.com
export SEC_UPDATES=FORCE
export HUB_APIKEY=SKIP
EOF

FQDN is the machine's fully qualified domain name (firstboot.d/31fqdn): it
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.

This preseeding mechanism makes it relatively easy to integrate TurnKey
with custom control panels, virtualization solutions, etc.

A first boot nobody can answer skips the questions. When the console has
no size (the tty of an LXC container nobody is attached to with pct
console or lxc-console), does not take a write (one nobody reads), or
there is no terminal at all, every hook that would draw a screen asks
nothing and does what it does without an answer, saying so in one line
of /var/log/inithooks.log and the journal (lib/console.sh):

- 30rootpass keeps the password the machine has (pct create --password),
or none;
- 31fqdn keeps the name the machine has and records it in the instance
description when it has a domain;
- 75keel-role and 80keel-cloud leave the node standalone, without a Keel
Cloud key;
- 85secalerts sets no alert email;
- 95secupdates installs the security updates, and 99reboot reboots for a
new kernel, as SEC_UPDATES=FORCE does.

A preseeded value is always used. Run keel-init afterwards to answer what
was skipped.

Don't worry about leaving sensitive passwords in there: after the first boot,
inithooks blanks /etc/inithooks.conf out so important passwords aren't
accidentally left in the clear. Although obviously if the conf file is
Expand Down
29 changes: 24 additions & 5 deletions bin/fqdn.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# Copyright (c) 2026 Keel Linux maintainers
"""Ask the machine's fully qualified domain name, and record it

Run by firstboot.d/31fqdn, three times: to ask, to record the answer in
the instance description, and, once the machine is renamed, to write the
/etc/hosts entry.
Run by firstboot.d/31fqdn: to ask, to record the answer in the instance
description, and, once the machine is renamed, to write the /etc/hosts
entry; and, when the machine keeps its name, to say what that name is.

Options:
--fqdn= the name; if not provided, will ask interactively,
Expand All @@ -17,6 +17,9 @@
--hosts write the name given with --hostname and --fqdn into
the hosts file, and ask nothing
--hostname= with --record or --hosts: the hostname
--machine print the name the machine has, given with --current,
the way an answer is printed, and ask nothing: its
domain is the one its line in the hosts file gives it

Asked or preseeded, the answer is printed as two lines for the hook:
HOSTNAME=<the hostname> and FQDN=<the name, empty without a domain>. The
Expand Down Expand Up @@ -116,6 +119,17 @@ def record(hostname: str, name: str) -> None:
fatal(e)


def machine(current: str) -> None:
"""Print the name the machine has, as an answer is printed"""
try:
text = fqdn.read_hosts(hosts_path())
except fqdn.FqdnError as e:
fatal(e)
hostname, name = fqdn.machine(current, text)
print(f"HOSTNAME={hostname}")
print(f"FQDN={name}")


def hosts(hostname: str, name: str) -> None:
try:
fqdn.write_hosts(hosts_path(), hostname, name)
Expand All @@ -127,7 +141,7 @@ def main():
signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
l_opts = ["help", "fqdn=", "current=", "record", "hosts",
"hostname="]
"hostname=", "machine"]
opts, args = getopt.gnu_getopt(sys.argv[1:], "h", l_opts)
except getopt.GetoptError as e:
usage(e)
Expand All @@ -146,9 +160,14 @@ def main():
current = val
elif opt == "--hostname":
hostname = val
else: # --record or --hosts, the writes
else: # --record, --hosts or --machine, which ask nothing
action = opt

if action == "--machine":
if not current:
usage("--machine needs --current")
machine(current)
return
if action:
if not hostname:
usage(f"{action} needs --hostname")
Expand Down
35 changes: 35 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
inithooks (2.3.6+keel22) trixie; urgency=medium

* A first boot nobody can answer asks nothing. The Web 19.0-3 booted
headless in an LXC container (2026-10-03) stopped for good at 31fqdn,
whose screen waited on a tty1 nobody was attached to. lib/console.sh
is now the one rule, for run's notices and for every hook that draws
a screen: nobody can answer a console with no size (stty 0 0), one
that does not take a probe larger than a pty's buffer within 2 s, or
no terminal at all. run asks once and hands the answer to the hooks
(INITHOOKS_UNATTENDED), whose output then goes to the inithooks log.
Each hook does what it does without an answer and says so in one
line: 30rootpass keeps the password, 31fqdn keeps the name and
records it as instance.hostname and instance.fqdn when it has a
domain, 75keel-role and 80keel-cloud run confconsole's screens
without a terminal (the node stays standalone, a preseeded key is
still stored), 85secalerts sets no email, 95secupdates installs and
99reboot reboots as SEC_UPDATES=FORCE does. The README's preseed
example lists FQDN and says so.
* The /etc/hosts entry for the machine's name is written on every first
boot. The image ships none (common's seal-hostname), and 31fqdn wrote
it only for an answer, so after FQDN=SKIP or an empty answer hostname
-f could fail. 31fqdn now always ends with `127.0.1.1 <fqdn>
<hostname>`, or `127.0.1.1 <hostname>` without a domain, the form keel
spec apply --system writes; a kept name keeps the domain its hosts
line already gives it (fqdn.py --machine).
* The self-signed certificate is for the machine's name. Given no
names, turnkey-make-ssl-cert takes them from `hostname -A`, a reverse
lookup of the machine's addresses, and a Web container named web
served CN=core. 15regen-sslcert now gives it the fqdn and the
hostname (lib/sslcert.sh) and --ip for the addresses, and 31fqdn
makes the certificate again when the name it settles is not the CN of
a self-signed one; one an authority signed is kept.

-- Marcos Mendez <mendez.foto@gmail.com> Sat, 03 Oct 2026 18:00:00 +0000

inithooks (2.3.6+keel21) trixie; urgency=medium

* A first boot without a journal finishes. 15regen-sslcert ran under
Expand Down
74 changes: 12 additions & 62 deletions firstboot.d/15regen-sslcert
Original file line number Diff line number Diff line change
@@ -1,71 +1,21 @@
#!/bin/bash -e
# Regenerate self-signed TLS/SSL cert & key
# Regenerate self-signed TLS/SSL cert & key, for the name the machine has
# (lib/sslcert.sh); 31fqdn makes it again when it settles another name.

[[ -n "$_TURNKEY_INIT" ]] && exit 0

[[ -e $INITHOOKS_CONF ]] && . "$INITHOOKS_CONF"

_hook=$(basename "$0")


# The journal is a side effect of a hook whose job is the certificate:
# under -e a logger that fails (journald down, "socket /dev/log: Connection
# refused", the published core 19.0-6 booted headless on 2026-10-03) killed
# the hook at its first line, so it may not fail the hook.
log() {
local level=$1
shift
logger -t inithooks -p "$level" "[$_hook] $*" 2>/dev/null || true
}

fatal() { log 3 "$*"; echo "FATAL: [$_hook] $*" 1>&2 ; exit 1 ; }
info() { log 5 "$*"; echo "INFO: [$_hook] $*" ; }

INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}"
# shellcheck source=default/inithooks
source "$INITHOOKS_DEFAULT"
# shellcheck source=lib/sslcert.sh
source "$INITHOOKS_PATH/lib/sslcert.sh"

# Check for 'turnkey-make-ssl-cert' - should be provided by
# turnkey-ssl package.
turnkey_make_ssl_cert=$(which turnkey-make-ssl-cert) \
|| fatal "turnkey-make-ssl-cert executable not found."

# We use predefined 4096 bits default dhparams file for TLS1.2 (not needed for
# TLS1.3)
# See https://github.com/turnkeylinux/tracker/issues/1653 for more info.
info "Generating SSL/TLS cert & key."
$turnkey_make_ssl_cert --default --force

# Restart relevant services
SERVICES=(nginx apache2 lighttpd tomcat10 tomcat11 webmin)

# make sure that generated keys are ready to use - avoids occasional race
# condition where cert & key don't (yet) match. a single sleep should be plenty
# but let's be sure
# modulus_md5 KIND FILE: the md5 of the modulus of the x509 or rsa FILE
modulus_md5() { openssl "$1" -noout -modulus -in "$2" | openssl md5; }

for _wait in {1..5}; do
cert_md5=$(modulus_md5 x509 /etc/ssl/private/cert.pem)
key_md5=$(modulus_md5 rsa /etc/ssl/private/cert.key)
if [[ "$cert_md5" == "$key_md5" ]]; then
info "SSL cert and key have been written - ready to restart services"
break
elif [[ $_wait -eq 1 ]]; then
info "Waiting for updated ssl cert & key to be written to disk"
else
echo "..."
fi
sleep 1
done
[[ -e $INITHOOKS_CONF ]] && . "$INITHOOKS_CONF"

info "Restarting relevant services."
for service in "${SERVICES[@]}"; do
# only restart services that are running
if systemctl is-active --quiet "${service}.service"; then
info "$service running; restarting..."
systemctl restart --quiet "$service"
fi
done
HOOK=$(basename "$0")

# final tidy up
update-ca-certificates
names=$(sslcert_names)
# shellcheck disable=SC2086 # the names are words
sslcert_make $names

exit 0
12 changes: 11 additions & 1 deletion firstboot.d/30rootpass
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
# 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.
# --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
# the machine has it, set before the first boot or locked, as Keep does.

USERNAME=root

Expand All @@ -13,6 +15,14 @@ INITHOOKS_DEFAULT="${INITHOOKS_DEFAULT:-/etc/default/inithooks}"
. "$INITHOOKS_DEFAULT"
[ "$(echo "$SUDOADMIN" | tr '[:upper:]' '[:lower:]')" = "true" ] && USERNAME="admin"

# shellcheck source=lib/console.sh
. "$INITHOOKS_PATH/lib/console.sh"

# shellcheck disable=SC1090
[ -e "$INITHOOKS_CONF" ] && . "$INITHOOKS_CONF"

if [ -z "$ROOT_PASS" ] && console_unattended; then
console_skipped 30rootpass "the $USERNAME password stays as the machine has it (pct create --password, or locked); keel-init asks it"
exit 0
fi
"$INITHOOKS_PATH/bin/setpass.py" "$USERNAME" --pass="$ROOT_PASS"
Loading
Loading