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
35 changes: 35 additions & 0 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: PR review tests

on:
pull_request:
branches:
- main

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
python-version: "3.11"
- name: Sync dependencies
run: uv sync --all-extras
- name: Ruff lint
run: uv run ruff check .
- name: Ruff format check
run: uv run ruff format --check .

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
python-version: "3.13"
- name: Sync dependencies
run: uv sync --all-extras
- name: Run tests
run: uv run pytest
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
.*

# Git
!.*github

# Python files to ignore
__pycache__
.venv
Expand All @@ -14,9 +19,6 @@ data/
*.key
*.pem

# Temp launch tests
.temp*

# Build artifacts
*.tar.gz
*.tgz
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@ A simple Flask API and dashboard mode is also available for issuing and download
| Scheduling / renewal | `cron` entry + headless CLI mode |
| Config persistence | `json` (stdlib) |
| Subprocess management | `subprocess` (stdlib) |
| Linting | `ruff` |


- Python's `ssl` module exposes verification and context objects but delegates key/cert generation to OpenSSL. The application shells out to `openssl` for all key and certificate operations — this is the standard pattern when avoiding a third-party cryptography library.

- Use strict type annotations.

- Ensure `ruff` passes with no errors or warnings.

- The token generated means everything, it can be used to download certs and keys, so it should be stored securely.

## Cipher Policy
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ at different directories or by using **p Root dir** in the TUI to switch at runt

The interactive TUI is the default mode (`uv run ssltui`). Press **i** to
initialise the CA and **n** to issue a certificate. For the full keyboard
reference and the issue-form walkthrough, see [TUI.md](TUI.md).
reference and the issue-form walkthrough, see [TUI.md](docs/TUI.md).

![ssltui TUI showing the certificate list](docs/artifacts/tui.png)

Expand Down Expand Up @@ -327,7 +327,7 @@ request to any endpoint without leaving the browser:
The generated command is masked by default and copies with the real token. The
designer only *builds* commands — it never sends mutating requests from the
browser, keeping the dashboard read-only. For the full endpoint reference, see
[API.md](API.md).
[API.md](docs/API.md).

## Cipher policy

Expand All @@ -337,3 +337,29 @@ available. Certificates are signed with SHA-384. The leaf cert validity cap is

Forbidden: RC4, 3DES, MD5, SHA-1 signatures, RSA key exchange, export ciphers,
NULL ciphers.

## Development

Install the dev tooling (Ruff + pytest) into the venv:

```bash
uv sync --extra dev
```

Lint, autofix, and format with [Ruff](https://docs.astral.sh/ruff/):

```bash
uv run ruff check . # lint
uv run ruff check --fix . # lint and apply safe autofixes
uv run ruff format . # format in place
uv run ruff format --check . # verify formatting (what CI runs)
```

Run the tests:

```bash
uv run pytest
```

CI runs the same `ruff check`, `ruff format --check`, and `pytest` on every push
and pull request (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
File renamed without changes.
8 changes: 8 additions & 0 deletions TODO.md → docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ Goal: `ssltui` behaves like a first-class system tool, not just a Python entry p
- [ ] Provide a man page (`man ssltui`) and shell completions (bash/zsh/fish).
- [ ] Optional `systemd` user units for the API/dashboard serve mode and a
timer-based alternative to the cron renewal entry.

## Test coverage

## Improve test harness

- [ ] Add a test harness for the CLI
- [ ] Add a test harness for the TUI
- [ ] Add a web test harness for the API
File renamed without changes.
19 changes: 18 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ dependencies = [

[project.optional-dependencies]
api = ["flask>=3.0"]
dev = ["pytest>=8.0"]
dev = ["pytest>=8.0", "ruff>=0.6"]

[project.urls]
Homepage = "https://github.com/hampuslinden/ssltui"
Expand All @@ -49,7 +49,24 @@ packages = ["ssltui"]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]

[tool.hatch.version]
path = "ssltui/__init__.py"

[tool.ruff]
target-version = "py311"
line-length = 88
src = ["ssltui", "tests"]
extend-exclude = [".venv", "dist", "build"]

[tool.ruff.lint]
# pycodestyle errors/warnings, pyflakes, import sorting, pyupgrade, bugbear.
select = ["E", "F", "W", "I", "UP", "B"]
# Line length is owned by the formatter; E501 only nags on long strings/comments
# the formatter cannot split, so disable it (Ruff's recommended pattern).
ignore = ["E501"]

[tool.ruff.lint.isort]
known-first-party = ["ssltui"]
101 changes: 73 additions & 28 deletions ssltui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,62 @@ def _build_parser() -> argparse.ArgumentParser:
# --renew
renew = sub.add_parser("renew", help="Renew expiring certs (cron-safe)")
renew.add_argument("--cert", metavar="CN", help="Renew a specific cert by CN")
renew.add_argument("--threshold", type=int, default=30,
help="Renew certs expiring within N days (default 30)")
renew.add_argument(
"--threshold",
type=int,
default=30,
help="Renew certs expiring within N days (default 30)",
)

# --status
sub.add_parser("status", help="Print expiry table and exit")

# --issue
issue = sub.add_parser("issue", help="Issue a cert non-interactively")
issue.add_argument("--cn", required=True, help="Common name")
issue.add_argument("--san", action="append", default=[], metavar="SAN",
help="Subject Alternative Name (repeatable)")
issue.add_argument(
"--san",
action="append",
default=[],
metavar="SAN",
help="Subject Alternative Name (repeatable)",
)
issue.add_argument("--key-type", choices=["ec", "rsa"], default="ec")
issue.add_argument("--days", type=int, default=180)

# serve
serve = sub.add_parser("serve", help="Start the REST API server (requires Flask)")
serve.add_argument("--host", default="127.0.0.1",
help="Bind address; use 0.0.0.0 for all interfaces (default 127.0.0.1)")
serve.add_argument("--port", type=int, default=8080, help="HTTP port (default 8080)")
serve.add_argument("--https-port", dest="https_port", type=int, default=8443,
help="HTTPS port (default 8443); used when a server cert is configured")
serve.add_argument("--debug", action="store_true",
help="Enable Flask debug mode. Do not use in production")
serve.add_argument("--no-threaded", dest="threaded", action="store_false",
help="Handle one request at a time instead of threading (threaded is the default)")
serve.add_argument(
"--host",
default="127.0.0.1",
help="Bind address; use 0.0.0.0 for all interfaces (default 127.0.0.1)",
)
serve.add_argument(
"--port", type=int, default=8080, help="HTTP port (default 8080)"
)
serve.add_argument(
"--https-port",
dest="https_port",
type=int,
default=8443,
help="HTTPS port (default 8443); used when a server cert is configured",
)
serve.add_argument(
"--debug",
action="store_true",
help="Enable Flask debug mode. Do not use in production",
)
serve.add_argument(
"--no-threaded",
dest="threaded",
action="store_false",
help="Handle one request at a time instead of threading (threaded is the default)",
)

# get
get = sub.add_parser("get", help="Print or save a cert, key, chain, or full PEM bundle")
get = sub.add_parser(
"get", help="Print or save a cert, key, chain, or full PEM bundle"
)
get.add_argument("--cn", default=None, help="Common name of the certificate")
get.add_argument(
"--what",
Expand All @@ -58,7 +86,8 @@ def _build_parser() -> argparse.ArgumentParser:
),
)
get.add_argument(
"--out", metavar="FILE",
"--out",
metavar="FILE",
help="Write to FILE instead of stdout (recommended for keys)",
)

Expand Down Expand Up @@ -97,13 +126,14 @@ def main(argv: list[str] | None = None) -> None:
else:
# Default: interactive TUI
from ssltui.tui import run_tui

run_tui()


def _cmd_renew(args) -> None:
from ssltui import config
from ssltui.ca import renew_cert, CAError
from ssltui.renewal import renew_all, refresh_crl
from ssltui.ca import CAError, renew_cert
from ssltui.renewal import refresh_crl, renew_all

root = config.data_dir()
exit_code = 0
Expand Down Expand Up @@ -163,12 +193,17 @@ def _cmd_status() -> None:

def _cmd_issue(args) -> None:
from ssltui import config
from ssltui.ca import issue_cert, CAError
from ssltui.ca import CAError, issue_cert

root = config.data_dir()
try:
meta = issue_cert(root, cn=args.cn, sans=args.san,
key_type=args.key_type, validity_days=args.days)
meta = issue_cert(
root,
cn=args.cn,
sans=args.san,
key_type=args.key_type,
validity_days=args.days,
)
print(f"OK issued {meta['cn']}")
print(f" cert: {meta['cert']}")
print(f" key: {meta['key']}")
Expand All @@ -180,12 +215,18 @@ def _cmd_issue(args) -> None:

def _cmd_serve(args) -> None:
import sys

from ssltui import config
from ssltui.api import run_server

if not sys.stdout.isatty():
run_server(host=args.host, port=args.port, https_port=args.https_port,
debug=args.debug, threaded=args.threaded)
run_server(
host=args.host,
port=args.port,
https_port=args.https_port,
debug=args.debug,
threaded=args.threaded,
)
return

from ssltui.api import APIServer, _resolve_token, server_ssl_context
Expand All @@ -198,15 +239,21 @@ def _cmd_serve(args) -> None:
token = _resolve_token(root)
ctx, _fqdn = server_ssl_context(root)
try:
server = APIServer(host=args.host, port=args.port, token=token, root=root,
threaded=args.threaded,
https_port=args.https_port if ctx is not None else None,
ssl_context=ctx)
server = APIServer(
host=args.host,
port=args.port,
token=token,
root=root,
threaded=args.threaded,
https_port=args.https_port if ctx is not None else None,
ssl_context=ctx,
)
except OSError as exc:
print(f"Error starting API server: {exc}", file=sys.stderr)
sys.exit(1)

from ssltui.tui import ServeApp

ServeApp(server, token).run()


Expand Down Expand Up @@ -287,8 +334,6 @@ def _cmd_get(args) -> None:
print(f"ERROR: no cert found for CN={cn!r}", file=sys.stderr)
sys.exit(1)

safe_cn = cn.replace("*", "wildcard").replace("/", "_")

if what == "cert":
data = Path(entry["cert"]).read_bytes()
elif what == "key":
Expand Down
Loading
Loading