From 96664f954c35579489b6e4e0aa209d3e81e54613 Mon Sep 17 00:00:00 2001 From: Vlad Doloman Date: Sat, 15 Aug 2026 05:30:31 +0300 Subject: [PATCH] Add Days field to the TUI cert form --- openvpncertupdate.py | 47 +++++++++++++++++++++++------- tests/test_dialogs.py | 66 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/openvpncertupdate.py b/openvpncertupdate.py index 5541c4b..6804c1c 100644 --- a/openvpncertupdate.py +++ b/openvpncertupdate.py @@ -791,15 +791,17 @@ class CertFormResult: email: str password: str confirmed: bool + days: str = "" -_FORM_FIELDS = ("cn", "email", "password") +_FORM_FIELDS = ("cn", "email", "days", "password") _FORM_LABELS = { "cn": "CN", "email": "Email", + "days": "Days (blank = EasyRSA default)", "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( @@ -807,6 +809,7 @@ def show_cert_form( cn: str = "", email: str = "", password: str = "", + days: str = "", cn_readonly: bool = False, ) -> CertFormResult: """Modal cert-detail form. @@ -816,7 +819,7 @@ def show_cert_form( # Clamp to the screen so curses.newwin() can't raise "curses function # returned NULL" outright on a terminal narrower/shorter than the usual # 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: win = curses.newwin(h, w, max(0, (sh - h) // 2), max(0, (sw - w) // 2)) except curses.error: @@ -826,7 +829,7 @@ def show_cert_form( _BTN_CONTINUE = "_continue" _BTN_CANCEL = "_cancel" - _BTN_Y = 9 + _BTN_Y = 11 _btn_x_cont = (w - 26) // 2 # "[ Continue ]"=12 + gap=4 + "[ Cancel ]"=10 = 26 _btn_x_canc = _btn_x_cont + 16 @@ -835,18 +838,32 @@ def show_cert_form( fields: Dict[str, InputField] = { "cn": InputField(win, _FORM_FIELD_Y["cn"], 3, fw, initial=cn), "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, initial=password if password else generate_password(), mask=True), } 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 return CertFormResult( cn=final_cn, email=fields["email"].value, password=fields["password"].value, + days=days_value, confirmed=True, ) @@ -887,8 +904,11 @@ def show_cert_form( pass 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: - 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: pass @@ -913,8 +933,11 @@ def show_cert_form( curses.curs_set(0) return CertFormResult(cn=cn, email="", password="", confirmed=False) if key == 0x07: # Ctrl-G: immediate submit - curses.curs_set(0) - return _submit() + result = _submit() + if result is not None: + curses.curs_set(0) + return result + continue if key in (9, curses.KEY_DOWN): # Tab / Down: next focus = (focus + 1) % len(active) continue @@ -934,9 +957,13 @@ def show_cert_form( ) continue if key in (10, 13, curses.KEY_ENTER, ord(" ")): - curses.curs_set(0) 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) else: if key in (10, 13, curses.KEY_ENTER): diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 5d041b8..ffd67d6 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -115,11 +115,11 @@ def test_show_cert_form_uses_erase_not_clear(): def test_show_cert_form_confirm(): - """Enter advances through the 3 fields to the Continue button; Enter on - Continue confirms. That's 4 Enter keypresses total.""" + """Enter advances through the 4 fields to the Continue button; Enter on + Continue confirms. That's 5 Enter keypresses total.""" stdscr = _make_stdscr() 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): result = show_cert_form(stdscr, cn="bob", email="bob@example.com") assert isinstance(result, CertFormResult) @@ -130,11 +130,11 @@ def test_show_cert_form_confirm(): 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() win = _make_win(rows=20, cols=70) - # Tab×4: cn→email→password→Continue→Cancel, then Enter - win.getch.side_effect = [9, 9, 9, 9, 10] + # Tab×5: cn→email→days→password→Continue→Cancel, then Enter + win.getch.side_effect = [9, 9, 9, 9, 9, 10] with patch("curses.newwin", return_value=win): result = show_cert_form(stdscr, cn="alice", email="alice@example.com") assert isinstance(result, CertFormResult) @@ -142,7 +142,7 @@ def test_show_cert_form_cancel_button(): 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.""" stdscr = _make_stdscr(rows=10, cols=40) win = _make_win() @@ -156,6 +156,58 @@ def test_show_cert_form_clamps_to_narrow_screen(): 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(): """If curses.newwin() still fails despite clamping, show_cert_form must degrade to an unconfirmed result rather than crash the whole app."""