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
33 changes: 33 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ 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 feat/first-boot-fqdn: shell 99.62, Python 99 (2026-10-02)

The first boot asks the fully qualified domain name (31fqdn). Shell:
`lib/hostname.sh` 9/9 (the rename 09hostname has always done, as a
function that replaces the name as a whole name or a first label, through
perl), `firstboot.d/09hostname` 8/8, measured for the first time, and
`firstboot.d/31fqdn` 21/21, 100 percent each, from
`tests/test-hostname.bats` (13 tests) and `tests/test-fqdn.bats` (16
tests): the rename over scratch copies of the files, the name inside other
words left alone, a colon and a dot matched literally, the same name again
touching nothing, bash's own HOSTNAME, the screen asked with the name the
machine has, the description recorded before the rename and the hosts
entry after it, a hostname without a domain, an empty answer, FQDN
preseeded and SKIP, a failing screen, record or hosts entry, an answer the
screen did not shape, and the real `bin/fqdn.py` with FQDN preseeded
writing a new instance.yaml, one that is there, leaving one that already
declares the name alone, and refusing a name that is not a domain. 301
bats in all. The file list of the rename is a `readarray` here document
because kcov marks the lines of a multi-line array assignment as not run.

Python: `libinithooks/fqdn.py` (152 statements, 50 branches) 100 percent
and `bin/fqdn.py` (97 statements, 34 branches) 100 percent, from
`tests/test_fqdn.py` (66 tests: the checks on a typed name, the split, the
declared names, the hostname that goes with a name, the prefill, the
/etc/hosts entry, the updated description and the one left equal, the
path, the writer with keel stubbed on PATH accepting and refusing, without
keel, and keeping the file's mode) and `tests/test_fqdn_cli.py` (32 tests
on the fake dialog: the screen, the notice without a domain, Back, ESC, a
preseeded name, --record, --hosts and the usage errors).
`test_dialog_brand.py` checks `bin/fqdn.py` as well; the reader accepts
`tls.acme.agree_tos` (two tests in `test_declarative_validate.py`). 515
passed (3 skipped without `KEEL_SRC`); total 99.

## Branch fix/password-once-and-updates-record: shell 99.58, Python 99 (2026-10-02)

`firstboot.d/95secupdates` is measured for the first time: 49 of 50
Expand Down
35 changes: 33 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -525,8 +525,10 @@ Notes:
variables read by 01ipconfig; static IPv6 addresses cannot be written
to /etc/network/interfaces yet and are refused.

- tls.acme is accepted and validated, but no certificate is requested
yet; use confconsole for that.
- tls.acme is accepted and validated (enabled, challenge, domains,
agree_tos, as the instance tooling reads them), but no certificate is
requested at first boot; keel spec apply --system and confconsole's
Let's Encrypt screen request it.

The description can be validated before it is used::

Expand Down Expand Up @@ -576,6 +578,7 @@ Common to all appliances::
15regen-sslcert DH_BITS [ 1024 | 2048 | 4096 ]
29preseed INITFENCE [ SKIP ]
30rootpass* ROOT_PASS
31fqdn FQDN [ SKIP | the name ]
75keel-role database.server.role of the instance description
80keel-cloud HUB_APIKEY [ SKIP | the key ]
85secalerts SEC_ALERTS [ SKIP ]
Expand All @@ -585,6 +588,34 @@ Common to all appliances::

Notes on the Keel hooks:

- 31fqdn asks the machine's fully qualified domain name,
blog.example.org, prefilled with the name the machine has (what
"pct create --hostname" set; a dotted name is offered as it is).
The instance description (/etc/keel/instance.yaml, or the file
00declarative read) records instance.hostname and instance.fqdn,
plus tls.acme.domains: [the name] when it declares no domain yet,
so confconsole's Let's Encrypt screen offers it; tls.acme.enabled
is never touched, a hostname declared beside the unchanged name is
kept, and a description the answer does not change is not
rewritten. Then the first label becomes the hostname, set the way
09hostname sets it (lib/hostname.sh: the old name replaced as a
whole name or a first label, never inside another word), and
/etc/hosts gets the entry that makes "hostname -f" answer the name,
rewriting the line a container manager wrote for the host where it
stands. The description comes first so that a step that fails
leaves a description saying what the machine should be. A name
without a domain is kept as the hostname alone, after a notice that
no certificate can be requested without a domain; an empty answer
keeps what the machine has. Lower case labels of letters, digits
and dashes, as a domain name is written; anything else is refused
and asked again. FQDN preseeds the answer (instance.fqdn of the
description renders it, so a described machine is not asked);
FQDN=SKIP asks nothing and changes nothing. keel-init asks again,
prefilled with the fqdn the description declares. The description
is written as confconsole writes it: a copy beside the file,
checked by "keel spec validate --no-secret-files" when keel is
installed, then moved into place; keel has no writer of its own.

- 75keel-role and 80keel-cloud (handbook decision 0020) run
confconsole's first boot screens, /usr/lib/confconsole/keelfirstboot.py,
and do nothing without confconsole. 75keel-role asks this node's
Expand Down
172 changes: 172 additions & 0 deletions bin/fqdn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!/usr/bin/python3
# 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.

Options:
--fqdn= the name; if not provided, will ask interactively,
prefilled with --current (or the fqdn the instance
description declares)
--current= the name the machine has, as `hostname` answers
--record write the name given with --hostname and --fqdn into
the instance description, and ask nothing; a
description the name does not change is left alone
--hosts write the name given with --hostname and --fqdn into
the hosts file, and ask nothing
--hostname= with --record or --hosts: the hostname

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
hostname is the first label of the name, or the one the description
declares beside that very name. Nothing is printed when the machine
keeps its name.

Environment:
INITHOOKS_DECL the instance description (default: the one
00declarative reads, else /etc/keel/instance.yaml)
INITHOOKS_HOSTS the hosts file (default: /etc/hosts)
"""

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

from libinithooks import declarative, fqdn

HOSTS = "/etc/hosts"
HOSTS_VAR = "INITHOOKS_HOSTS"

TITLE = "Domain name"
TEXT = (
"The name this machine is reached by, with its domain, for example"
" blog.example.org. It becomes the hostname, the name /etc/hosts"
" answers for this machine, and the domain a TLS certificate is"
" requested for in confconsole.\n\n"
"Leave the field empty to keep the name the machine has.\n\n"
"Fully qualified domain name:"
)
NO_DOMAIN = (
"{name} has no domain, so it is kept as the hostname only. No"
" certificate can be requested without a domain.\n\n"
"Continue with the hostname alone, or go back and add the domain?"
)


def fatal(msg: object) -> NoReturn:
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)


def usage(msg: str | getopt.GetoptError = "") -> NoReturn:
if msg:
print(f"Error: {msg}", file=sys.stderr)
print(f"Syntax: {sys.argv[0]} [options]", file=sys.stderr)
print(__doc__, file=sys.stderr)
sys.exit(1)


def hosts_path() -> str:
return os.environ.get(HOSTS_VAR, HOSTS)


def load_description() -> tuple[str, dict]:
path = fqdn.spec_path()
try:
return path, fqdn.load(path)
except declarative.DeclarativeError as e:
fatal(e)


def ask(current: str, document: dict) -> str:
"""The name the operator confirmed, or "" to keep the machine's"""
from libinithooks.dialog_wrapper import Dialog

d = Dialog("Keel Linux - First boot configuration")
init = fqdn.prefill(current, document)
while True:
_, typed = d.inputbox(TITLE, TEXT, init, "Apply", "")
name, problem = fqdn.normalize(typed)
if problem:
d.error(problem)
init = typed
continue
if not name or "." in name:
return name
if d.yesno(TITLE, NO_DOMAIN.format(name=name), "Continue", "Back"):
return name
init = name


def record(hostname: str, name: str) -> None:
"""The description with the name in it, unless it holds it already"""
path, document = load_description()
after = fqdn.updated(document, hostname, name)
if after == document:
print(f"fqdn: {path} already declares the name, not written",
file=sys.stderr)
return
try:
fqdn.write_spec(path, after)
except fqdn.FqdnError as e:
fatal(e)


def hosts(hostname: str, name: str) -> None:
try:
fqdn.write_hosts(hosts_path(), hostname, name)
except fqdn.FqdnError as e:
fatal(e)


def main():
signal.signal(signal.SIGINT, signal.SIG_IGN)
try:
l_opts = ["help", "fqdn=", "current=", "record", "hosts",
"hostname="]
opts, args = getopt.gnu_getopt(sys.argv[1:], "h", l_opts)
except getopt.GetoptError as e:
usage(e)

if args:
usage()

preseeded = current = hostname = ""
action = ""
for opt, val in opts:
if opt in ("-h", "--help"):
usage()
elif opt == "--fqdn":
preseeded = val
elif opt == "--current":
current = val
elif opt == "--hostname":
hostname = val
else: # --record or --hosts, the writes
action = opt

if action:
if not hostname:
usage(f"{action} needs --hostname")
(record if action == "--record" else hosts)(hostname, preseeded)
return

_, document = load_description()
if preseeded:
name, problem = fqdn.normalize(preseeded)
if problem:
fatal(problem)
else:
name = ask(current, document)
if not name:
return
print(f"HOSTNAME={fqdn.hostname_for(name, document)}")
print(f"FQDN={fqdn.split(name)[1]}")


if __name__ == "__main__":
main()
38 changes: 38 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
inithooks (2.3.6+keel19) trixie; urgency=medium

* The first boot asks the machine's fully qualified domain name, in a
new hook, 31fqdn, after the root password and before the application
hooks. The box is prefilled with the name the machine has (what pct
create --hostname set; a dotted name as it is). The instance
description records instance.hostname and instance.fqdn, plus
tls.acme.domains with the name when it declares no domain yet, so
confconsole's Let's Encrypt screen is driven by the description;
tls.acme.enabled is never touched, a hostname declared beside the
unchanged name is kept, and a description the answer does not change
is not rewritten. Then the first label becomes the hostname, set the
way 09hostname sets it (the rename is now lib/hostname.sh, which
both hooks source), and /etc/hosts gets the entry that makes
hostname -f answer the name, rewriting the line pct wrote for the
host where it stands. The description first, so that a step that
fails leaves one saying what the machine should be. A name without a
domain is kept as the hostname alone, after a notice that no
certificate can be requested without a domain; an empty answer keeps
what the machine has. Lower case labels of letters, digits and
dashes; anything else is refused and asked again. FQDN preseeds it
(instance.fqdn renders it, so a described machine is not asked),
FQDN=SKIP asks nothing.
* The description is written as confconsole writes it (keelcli.py):
PyYAML, a copy beside the file, keel spec validate --no-secret-files
when keel is installed, then moved into place, with the mode the
file had. keel has no writer of its own.
* The rename replaces the old name only as a whole name or as the
first label of a dotted name, matched literally. 09hostname's sed
took it as a pattern: a machine called web was rewritten inside
every SSH key comment, postfix setting and word of /etc/hosts that
contained it, and a colon in the name broke the command.
* The reader accepts tls.acme.agree_tos, which the instance tooling
accepts, and refuses enabled or agree_tos that are not booleans.
* 09hostname reads INITHOOKS_DEFAULT like the other Keel hooks.

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

inithooks (2.3.6+keel18) trixie; urgency=medium

* 95secupdates reads its updates from
Expand Down
38 changes: 14 additions & 24 deletions firstboot.d/09hostname
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
#!/bin/bash -e
# set hostname
# HOSTNAME: the name (if none specified, nothing is done)
#
# The rename itself is lib/hostname.sh, shared with 31fqdn.

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

[ -e $INITHOOKS_CONF ] && . $INITHOOKS_CONF
if [[ -e "$INITHOOKS_CONF" ]]; then
# shellcheck source=/dev/null
source "$INITHOOKS_CONF"
fi

[ -z "$HOSTNAME" ] && exit 0
[[ -z "$HOSTNAME" ]] && exit 0

old=$(hostname)

for file in \
/etc/exim4/update-exim4.conf.conf \
/etc/printcap \
/etc/hostname \
/etc/hosts \
/etc/network/interfaces \
/etc/ssh/ssh_host_rsa_key.pub \
/etc/ssh/ssh_host_dsa_key.pub \
/etc/ssh/ssh_host_ecdsa_key.pub \
/etc/ssh/ssh_host_ed25519_key.pub \
/etc/mailname \
/etc/postfix/main.cf \
/etc/motd \
/etc/ssmtp/ssmtp.conf
do
[ -f $file ] && sed -i -e "s:$old:$HOSTNAME:g" $file
done

hostname $HOSTNAME
hostname_set "$HOSTNAME"

exit 0
Loading
Loading