Add Days field to the TUI cert form

This commit is contained in:
Vlad Doloman
2026-08-15 05:30:31 +03:00
parent fad3e5187e
commit 96664f954c
2 changed files with 96 additions and 17 deletions

View File

@@ -791,15 +791,17 @@ class CertFormResult:
email: str email: str
password: str password: str
confirmed: bool confirmed: bool
days: str = ""
_FORM_FIELDS = ("cn", "email", "password") _FORM_FIELDS = ("cn", "email", "days", "password")
_FORM_LABELS = { _FORM_LABELS = {
"cn": "CN", "cn": "CN",
"email": "Email", "email": "Email",
"days": "Days (blank = EasyRSA default)",
"password": "Password", "password": "Password",
} }
_FORM_FIELD_Y = {"cn": 3, "email": 5, "password": 7} _FORM_FIELD_Y = {"cn": 3, "email": 5, "days": 7, "password": 9}
def show_cert_form( def show_cert_form(
@@ -807,6 +809,7 @@ def show_cert_form(
cn: str = "", cn: str = "",
email: str = "", email: str = "",
password: str = "", password: str = "",
days: str = "",
cn_readonly: bool = False, cn_readonly: bool = False,
) -> CertFormResult: ) -> CertFormResult:
"""Modal cert-detail form. """Modal cert-detail form.
@@ -816,7 +819,7 @@ def show_cert_form(
# Clamp to the screen so curses.newwin() can't raise "curses function # Clamp to the screen so curses.newwin() can't raise "curses function
# returned NULL" outright on a terminal narrower/shorter than the usual # returned NULL" outright on a terminal narrower/shorter than the usual
# 80x24 (e.g. a serial console with an unusually small viewport). # 80x24 (e.g. a serial console with an unusually small viewport).
h, w = min(13, sh), min(62, sw) h, w = min(15, sh), min(62, sw)
try: try:
win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2))
except curses.error: except curses.error:
@@ -826,7 +829,7 @@ def show_cert_form(
_BTN_CONTINUE = "_continue" _BTN_CONTINUE = "_continue"
_BTN_CANCEL = "_cancel" _BTN_CANCEL = "_cancel"
_BTN_Y = 9 _BTN_Y = 11
_btn_x_cont = (w - 26) // 2 # "[ Continue ]"=12 + gap=4 + "[ Cancel ]"=10 = 26 _btn_x_cont = (w - 26) // 2 # "[ Continue ]"=12 + gap=4 + "[ Cancel ]"=10 = 26
_btn_x_canc = _btn_x_cont + 16 _btn_x_canc = _btn_x_cont + 16
@@ -835,18 +838,32 @@ def show_cert_form(
fields: Dict[str, InputField] = { fields: Dict[str, InputField] = {
"cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn), "cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn),
"email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email), "email": InputField(win, _FORM_FIELD_Y["email"], 3, fw, initial=email),
"days": InputField(win, _FORM_FIELD_Y["days"], 3, fw, initial=days,
allowed=string.digits),
"password": InputField(win, _FORM_FIELD_Y["password"], 3, fw, "password": InputField(win, _FORM_FIELD_Y["password"], 3, fw,
initial=password if password else generate_password(), initial=password if password else generate_password(),
mask=True), mask=True),
} }
focus = 0 focus = 0
error = ""
def _submit() -> CertFormResult: def _submit() -> Optional[CertFormResult]:
nonlocal error
try:
# Validate with the same resolver the CLI and .conf use, so what
# counts as valid never diverges between entry points.
days_value = resolve_cert_days(fields["days"].value, "Days")
except ConfigError:
# The resolver's full sentence does not fit a 62-column window.
error = "Days must be a positive number, or blank to inherit"
return None
error = ""
final_cn = cn if cn_readonly else fields["cn"].value final_cn = cn if cn_readonly else fields["cn"].value
return CertFormResult( return CertFormResult(
cn=final_cn, cn=final_cn,
email=fields["email"].value, email=fields["email"].value,
password=fields["password"].value, password=fields["password"].value,
days=days_value,
confirmed=True, confirmed=True,
) )
@@ -887,8 +904,11 @@ def show_cert_form(
pass pass
hint = "Tab=next F5=regen pwd Ctrl-G=confirm Esc=cancel" hint = "Tab=next F5=regen pwd Ctrl-G=confirm Esc=cancel"
hint_attr = curses.color_pair(COLOR_DISABLED)
if error:
hint, hint_attr = error, curses.color_pair(COLOR_ERROR)
try: try:
win.addstr(h - 2, 2, hint[:w - 4], curses.color_pair(COLOR_DISABLED)) win.addstr(h - 2, 2, hint[:w - 4], hint_attr)
except curses.error: except curses.error:
pass pass
@@ -913,8 +933,11 @@ def show_cert_form(
curses.curs_set(0) curses.curs_set(0)
return CertFormResult(cn=cn, email="", password="", confirmed=False) return CertFormResult(cn=cn, email="", password="", confirmed=False)
if key == 0x07: # Ctrl-G: immediate submit if key == 0x07: # Ctrl-G: immediate submit
result = _submit()
if result is not None:
curses.curs_set(0) curses.curs_set(0)
return _submit() return result
continue
if key in (9, curses.KEY_DOWN): # Tab / Down: next if key in (9, curses.KEY_DOWN): # Tab / Down: next
focus = (focus + 1) % len(active) focus = (focus + 1) % len(active)
continue continue
@@ -934,9 +957,13 @@ def show_cert_form(
) )
continue continue
if key in (10, 13, curses.KEY_ENTER, ord(" ")): if key in (10, 13, curses.KEY_ENTER, ord(" ")):
curses.curs_set(0)
if current == _BTN_CONTINUE: if current == _BTN_CONTINUE:
return _submit() result = _submit()
if result is not None:
curses.curs_set(0)
return result
continue
curses.curs_set(0)
return CertFormResult(cn=cn, email="", password="", confirmed=False) return CertFormResult(cn=cn, email="", password="", confirmed=False)
else: else:
if key in (10, 13, curses.KEY_ENTER): if key in (10, 13, curses.KEY_ENTER):

View File

@@ -115,11 +115,11 @@ def test_show_cert_form_uses_erase_not_clear():
def test_show_cert_form_confirm(): def test_show_cert_form_confirm():
"""Enter advances through the 3 fields to the Continue button; Enter on """Enter advances through the 4 fields to the Continue button; Enter on
Continue confirms. That's 4 Enter keypresses total.""" Continue confirms. That's 5 Enter keypresses total."""
stdscr = _make_stdscr() stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70) win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10] win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win): with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="bob@example.com") result = show_cert_form(stdscr, cn="bob", email="bob@example.com")
assert isinstance(result, CertFormResult) assert isinstance(result, CertFormResult)
@@ -130,11 +130,11 @@ def test_show_cert_form_confirm():
def test_show_cert_form_cancel_button(): def test_show_cert_form_cancel_button():
"""Tab to the Cancel button (4 Tabs from start) and press Enter cancels.""" """Tab to the Cancel button (5 Tabs from start) and press Enter cancels."""
stdscr = _make_stdscr() stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70) win = _make_win(rows=20, cols=70)
# Tab×4: cn→email→password→Continue→Cancel, then Enter # Tab×5: cn→email→days→password→Continue→Cancel, then Enter
win.getch.side_effect = [9, 9, 9, 9, 10] win.getch.side_effect = [9, 9, 9, 9, 9, 10]
with patch("curses.newwin", return_value=win): with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="alice", email="alice@example.com") result = show_cert_form(stdscr, cn="alice", email="alice@example.com")
assert isinstance(result, CertFormResult) assert isinstance(result, CertFormResult)
@@ -142,7 +142,7 @@ def test_show_cert_form_cancel_button():
def test_show_cert_form_clamps_to_narrow_screen(): def test_show_cert_form_clamps_to_narrow_screen():
"""The form's fixed 13x62 size must not exceed a smaller-than-usual """The form's fixed 15x62 size must not exceed a smaller-than-usual
screen, which would make curses.newwin() raise.""" screen, which would make curses.newwin() raise."""
stdscr = _make_stdscr(rows=10, cols=40) stdscr = _make_stdscr(rows=10, cols=40)
win = _make_win() win = _make_win()
@@ -156,6 +156,58 @@ def test_show_cert_form_clamps_to_narrow_screen():
assert x >= 0 assert x >= 0
def test_show_cert_form_returns_days():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="90")
assert result.confirmed is True
assert result.days == "90"
def test_show_cert_form_blank_days_means_inherit():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="")
assert result.confirmed is True
assert result.days == ""
def test_show_cert_form_days_field_is_digits_only():
# cn is read-only here, so focus starts on email: Tab once to reach days.
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [9] + [ord(c) for c in "9a0!"] + [10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="",
cn_readonly=True)
assert result.confirmed is True
assert result.days == "90"
def test_show_cert_form_refuses_zero_days():
# Submitting "0" must keep the form open rather than closing and failing
# later inside EasyRSA. Enter×5 tries to submit, Esc then cancels.
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10, 27]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="0")
assert result.confirmed is False
def test_show_cert_form_normalises_leading_zero_days():
stdscr = _make_stdscr()
win = _make_win(rows=20, cols=70)
win.getch.side_effect = [10, 10, 10, 10, 10]
with patch("curses.newwin", return_value=win):
result = show_cert_form(stdscr, cn="bob", email="b@c.d", days="090")
assert result.days == "90"
def test_show_cert_form_newwin_failure_returns_cancelled(): def test_show_cert_form_newwin_failure_returns_cancelled():
"""If curses.newwin() still fails despite clamping, show_cert_form must """If curses.newwin() still fails despite clamping, show_cert_form must
degrade to an unconfirmed result rather than crash the whole app.""" degrade to an unconfirmed result rather than crash the whole app."""