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
22 changes: 22 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ previous tip a2d9f70 (2025-10-02) was 50 commits behind and lacked the
IPv6-aware rewrite of `ifutil.py` (upstream pull request #109), which is
the file the static IPv6 work starts from.

## Branch feat/lets-encrypt-from-the-spec: 100 percent kept (2026-10-02)

The Let's Encrypt screen reads and writes the instance description.
`keelcli.py` gains `acme_domains`, `bare_domains`, `recordable_domains`,
`with_acme` and `save_acme` (414 statements, 84 branches, 100 percent) and
stays in the measured set. `tests/test_lets_encrypt.py` (35 tests) loads
`plugins.d/Lets_Encrypt/get_certificate.py` the way confconsole does, with
the fake console, requests, dehydrated-wrapper and keel replaced: the
domains a description offers, the writer accepting and refusing (keel
refusing, keel absent, a file that cannot be staged or replaced), the
prefill (declared domains, the fqdn, more than five, the file and its
alias, a fresh machine, a description that does not read), the screen
recording an issued certificate and writing nothing on a failed request, a
refusal shown with keel's words, a wildcard left out of the description
and said so (alone, recording nothing), a cancelled form, and the form
text held to an 80x24 console with the five boxes under it. 864 passed, 7
skipped,
total 100. Measured on its own (`--source=plugins.d/Lets_Encrypt`), the
screen is at 61 percent: every line this branch adds runs, what is left
is the DNS-01 flow and the alias handling the tests do not reach, so the
file does not join the gate yet.

## Baseline: 0 percent measured

There is no `tests/` directory, no pytest or unittest suite and no
Expand Down
20 changes: 20 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
confconsole (2.2.3+keel14) trixie; urgency=low

* The Let's Encrypt screen is driven by the instance description. Its
domain boxes are prefilled from tls.acme.domains, else instance.fqdn
(the name the first boot now records, inithooks 31fqdn), else from
dehydrated's domains file as before, example.com on a fresh machine.
Once the operator confirms the domains and the certificate is issued,
/etc/keel/instance.yaml gets tls.acme.domains and tls.acme.enabled:
true, written as every Instance screen writes it (keelcli: staged,
keel spec validate, moved into place; a refusal is shown and the file
left as it was). A request that fails writes nothing to it. dehydrated
keeps reading its own domains file, written as before; HTTP-01 and
DNS-01 are unchanged. A wildcard is left out of the description and
said so, since keel's domain validation has no field it fits yet.
* The screen's text fits an 80x24 console with the five boxes under it:
it was eleven rows, and a test now holds it to the room a 24 row
console leaves.

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

confconsole (2.2.3+keel13) trixie; urgency=low

* The first boot role screen does not run keel inspect on a machine
Expand Down
25 changes: 25 additions & 0 deletions docs/Lets_encrypt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ to do so will cause the Let's Encrypt challenges to fail (so you won't get a
certificate). Repeated failures may cause your server to be blocked (for up to
a week - perhaps longer) from further attempts.

The instance description
------------------------

The domain boxes are prefilled from the instance description,
``/etc/keel/instance.yaml`` (or ``$KEEL_SPEC``): ``tls.acme.domains``
when it declares any, else ``instance.fqdn``, the name the first boot
recorded (inithooks ``31fqdn``), else the first line of
``/etc/dehydrated/confconsole.domains.txt`` as before, ``example.com`` on
a fresh machine. A description that does not read counts as none.

Once you have confirmed the domains and the certificate was issued, the
description gets ``tls.acme.domains`` (the domains as confirmed, without
blanks and without an alias) and ``tls.acme.enabled: true``, so ``keel
spec apply --system`` and ``keel diff`` know the certificate this machine
asked for; ``challenge`` and ``agree_tos`` are left as they were. It is
written as every Instance screen writes it (``keelcli``): a copy beside
the file, ``keel spec validate --no-secret-files`` on it, then moved into
place, and a description keel refuses, or a machine without keel, is said
on the screen and the file is left as it was. A request that fails writes
nothing to the description. A wildcard (DNS-01) is left out of the
description and said so on the screen: keel's domain validation has no
field a wildcard fits yet, and with nothing but wildcards confirmed the
description is not written. dehydrated keeps reading
``confconsole.domains.txt``, which is written as before.

Getting a certificate - Behind the scenes
-----------------------------------------

Expand Down
92 changes: 92 additions & 0 deletions keelcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,98 @@ def promote_text(result: Result) -> str:
)


# --- the Let's Encrypt screen ----------------------------------------------
#
# The screen is driven by the description: its boxes are prefilled with
# tls.acme.domains, else instance.fqdn (what the first boot recorded), and
# the domains the operator confirmed are written back, with acme turned on,
# once the certificate was issued. Nothing here opens a dialog or touches
# dehydrated's files.

ACME_UNCHANGED = (
"The certificate was issued, but {path} was NOT changed: the next"
" keel spec apply --system will not know about it.\n\n{problem}"
)
# keel's domain validation takes labels of letters, digits and dashes
# (keel.spec.fields.LABEL_RE), so a wildcard has no field it fits yet
ACME_WILDCARDS = (
"The certificate was issued. {wildcards}: a wildcard is not recorded"
" in {path}, which has no field it fits yet, so keel spec apply"
" --system will not know about it. Recorded: {recorded}."
)
ACME_NOTHING_RECORDED = (
"The certificate was issued, but nothing was recorded in {path}:"
" {wildcards} is a wildcard, and the description has no field it fits"
" yet, so keel spec apply --system will not know about it."
)


def acme_domains(document: dict) -> list[str]:
"""The domains the description offers: tls.acme.domains when it
declares any, else instance.fqdn, else none"""
tls = document.get("tls")
acme = tls.get("acme") if isinstance(tls, dict) else None
domains = acme.get("domains") if isinstance(acme, dict) else None
if isinstance(domains, list) and domains:
return [str(domain) for domain in domains]
instance = document.get("instance")
fqdn = instance.get("fqdn") if isinstance(instance, dict) else None
return [str(fqdn)] if fqdn else []


def bare_domains(values: list[str]) -> list[str]:
"""The domains of the boxes, without blanks and without the alias
dehydrated's file carries after `>`"""
found = [value.split(">", 1)[0].strip() for value in values]
return [domain for domain in found if domain]


def recordable_domains(values: list[str]) -> tuple[list[str], list[str]]:
"""The domains of the boxes the description can hold, and the
wildcards it cannot: (recorded, wildcards)"""
domains = bare_domains(values)
return ([one for one in domains if "*" not in one],
[one for one in domains if "*" in one])


def with_acme(document: dict, domains: list[str]) -> dict:
"""A new description declaring `domains` with acme enabled; the
rest of tls.acme (challenge, agree_tos) is kept. A copy, never an
edit in place, as with_server."""
tls = document.get("tls")
tls = dict(tls) if isinstance(tls, dict) else {}
acme = tls.get("acme")
acme = dict(acme) if isinstance(acme, dict) else {}
acme["domains"] = list(domains)
acme["enabled"] = True
return {**document, "tls": {**tls, "acme": acme}}


def save_acme(path: str, document: dict, domains: list[str]) -> str:
"""Write tls.acme.domains and tls.acme.enabled: true; the problem or ""

Staged and validated before anything replaces the description, as
every Instance screen does it.
"""
staged, problem = stage_spec(with_acme(document, domains), path)
if problem:
return problem
try:
result = call(["spec", "validate", "--no-secret-files", "--spec",
staged])
except KeelNotInstalled as error:
discard_spec(staged)
return str(error)
if result.code != OK:
discard_spec(staged)
return invalid_text(path, result)
problem = commit_spec(staged, path)
if problem:
discard_spec(staged)
return f"{path} was NOT changed.\n\n{problem}"
return ""


# --- what a replica needs, and the credential both ends hold -------------
#
# The primary's screen hands the operator what each replica's screen will
Expand Down
85 changes: 68 additions & 17 deletions plugins.d/Lets_Encrypt/get_certificate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
"""Get Let's Encrypt SSl cert"""
"""Get Let's Encrypt SSl cert

The domain boxes are prefilled from the instance description
(/etc/keel/instance.yaml): tls.acme.domains when it declares any, else
instance.fqdn, the name the first boot recorded; else from dehydrated's
domains file as before, example.com on a fresh machine. Once the operator
has confirmed the domains and the certificate was issued, the description
gets tls.acme.domains and tls.acme.enabled: true, through keelcli as every
Instance screen writes it; a request that fails writes nothing to it.
dehydrated keeps reading its own domains file, which is written as before.
"""

import requests
import subprocess
Expand All @@ -9,20 +19,21 @@
from json import JSONDecodeError
from glob import glob

import keelcli

LE_INFO_URL = "https://acme-v02.api.letsencrypt.org/directory"

TITLE = "Certificate Creation Wizard"
SPEC_TITLE = "Instance description"

DESC = """Please enter domain(s) to generate certificate for.

To generate a single certificate for up to five domains (including subdomains),
enter each domain into a box, one domain per box. Empty boxes will be ignored.
# Fits an 80x24 console with the five boxes under it (tests/test_lets_encrypt)
DESC = """Enter the domain(s) the certificate is for, one per box; empty boxes
are ignored. One certificate covers up to five domains.

Wildcard domains are supported, but only when using DNS-01 challenge. Alias
will be auto generated, so should not be entered here.

For wildcards and for multiple certificates, please consult the docs:
https://github.com/Keel-Linux/confconsole/blob/master/docs/Lets_encrypt.rst
The boxes come from /etc/keel/instance.yaml (tls.acme.domains, else
instance.fqdn); what you confirm is written back there once the
certificate is issued. A wildcard needs the DNS-01 challenge, and its
alias is generated: do not type it. More in docs/Lets_encrypt.rst.
"""

dehydrated_conf = "/etc/dehydrated"
Expand Down Expand Up @@ -106,13 +117,24 @@ def gen_alias(line: str) -> str:
return line.split(" ")[0].replace("*", "star").replace(".", "_")


def spec_domains() -> list[str]:
"""The domains the instance description offers, none when it has
none or does not read (the file then decides, as before)"""
document, problem = keelcli.load_description(keelcli.spec_path())
if problem:
return []
return keelcli.acme_domains(document)


def load_domains() -> tuple[list[str], str | None]:
"""Loads domain conf, writes default config if non-existent. Expects
"/etc/dehydrated" to exist
returns a tuple of list(domains) and alias"""
returns a tuple of list(domains) and alias. The domains are the
instance description's when it declares any (spec_domains), else the
file's; dehydrated's file is made or backed up either way."""
if not isfile(domain_path):
copyfile(d_dom_example, domain_path)
return [example_domain, "", "", "", ""], None
domains, alias = [example_domain], None
else:
backup_domain_path = ".".join([domain_path, "bak"])
copyfile(domain_path, backup_domain_path)
Expand All @@ -131,11 +153,37 @@ def load_domains() -> tuple[list[str], str | None]:
break
if alias:
alias = alias.strip()
while len(domains) > 5:
domains.pop()
while len(domains) < 5:
domains.append("")
return domains, alias
domains = spec_domains() or domains
while len(domains) > 5:
domains.pop()
while len(domains) < 5:
domains.append("")
return domains, alias


def record_domains(values: list[str]) -> str:
"""Write the confirmed domains into the description, acme enabled;
what to tell the operator, or "" when there is nothing to say

A wildcard (DNS-01) is left out, since keel's validation has no field
it fits yet, and said so; with nothing else confirmed the description
is not written at all.
"""
path = keelcli.spec_path()
domains, wildcards = keelcli.recordable_domains(values)
if not domains:
return keelcli.ACME_NOTHING_RECORDED.format(
path=path, wildcards=", ".join(wildcards))
document, problem = keelcli.load_description(path)
if not problem:
problem = keelcli.save_acme(path, document, domains)
if problem:
return keelcli.ACME_UNCHANGED.format(path=path, problem=problem)
if wildcards:
return keelcli.ACME_WILDCARDS.format(
path=path, wildcards=", ".join(wildcards),
recorded=", ".join(domains))
return ""


def save_domains(domains: list[str], alias: str | None = None) -> None:
Expand Down Expand Up @@ -440,6 +488,9 @@ def run() -> None:
text=True,
)
if proc.returncode == 0:
problem = record_domains(values)
if problem:
console.msgbox(SPEC_TITLE, problem, autosize=True)
break
else:
console.msgbox("Error!", proc.stderr)
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,26 @@ def __init__(self, *args, **kwargs):
sys.modules["systemd.journal"] = journal


def _install_requests_stub():
"""The Let's Encrypt screen imports requests (python3-requests) to
read the terms of service; the tests replace its `get`, so a host
without the package gets a module with that one name, which refuses
to be called for real."""
try:
importlib.import_module("requests")
except ModuleNotFoundError:
stub = types.ModuleType("requests")

def get(url, **kwargs):
raise AssertionError(f"requests.get({url!r}) must be replaced")

stub.get = get
sys.modules["requests"] = stub


_install_netinfo_stub()
_install_dialog_stubs()
_install_requests_stub()


@pytest.fixture
Expand Down
Loading
Loading