A small provisioning system for client-side SSH access. It has two parts:
provision_api/— a Django + Django REST Framework service that stores hosts, users, SSH public keys, host fingerprints, access grants, and an audit trail in SQLite, exposing a JSON API.ssh_cli.py— a command-line tool that reads host/key data, records grants and audit entries through the API, and writes the local SSH files (config,known_hosts,authorized_keys) with the correct permissions.
The CLI is idempotent (re-running provision never duplicates entries) and
revoke removes only the targeted host entries.
uv venv --python 3.12 .venv
uv pip install --python .venv -e . # or: uv pip install -r requirements.txtDependencies: Django 5.0.x, djangorestframework 3.15.x, requests 2.32.x.
Nothing is hard-coded; everything has a sensible default and can be overridden by a CLI flag or an environment variable.
| Setting | CLI flag | Environment variable | Default |
|---|---|---|---|
| API base URL | --api-base |
PROVISION_API_BASE |
http://localhost:8000/api/v1 |
| Database path | --db-path |
PROVISION_DB_PATH |
./db.sqlite3 (next to manage.py) |
| SSH target dir | --target-dir |
PROVISION_TARGET_DIR |
the current user's home directory |
SSH User |
--login-user |
— | $USER |
The Django service reads PROVISION_DB_PATH (and optionally
PROVISION_SECRET_KEY / PROVISION_DEBUG) so the API and the CLI can share the
same database.
# 1. Prepare and seed the database
python manage.py migrate
python seed_data.py
# 2. Run the API
python manage.py runserver 0.0.0.0:8000
# 3. Provision access (writes files under <target-dir>/.ssh)
python ssh_cli.py --action provision --user alice \
--hosts web-server-01,db-server-01 --target-dir /tmp/alice-home
# 4. Inspect status, then revoke one host
python ssh_cli.py --action status --user alice --target-dir /tmp/alice-home
python ssh_cli.py --action revoke --user alice --hosts web-server-01 \
--target-dir /tmp/alice-homeAfter installation the CLI is also available as the ssh-provision console
script.
| Method & path | Purpose | Success status |
|---|---|---|
POST /api/v1/grants |
Create grant(s) for a user/hosts | 201 |
GET /api/v1/grants/<user> |
List a user's active grants | 200 |
DELETE /api/v1/grants/<id> |
Revoke a grant | 204 |
POST /api/v1/audit |
Record an audit entry | 201 |
Endpoints accept both trailing-slash and non-trailing-slash forms.
config— oneHostblock per host (HostName,Port,User); mode600.known_hosts— host fingerprint lines; mode644.authorized_keys— the user's active public keys; mode600.
.venv/bin/python -m pytestThe suite starts a live Django server against a temporary, seeded database, drives the CLI for provision/revoke/status, and asserts file contents, permissions, idempotency, revoke behaviour, and audit logging. Unit tests cover the file-handling helpers in isolation.
seed_data.py loads four hosts (web-server-01, db-server-01, bastion-01,
cache-server-01), three users (alice, bob, carol) with SSH keys, and the
matching host fingerprints.
The service is built to run as a container under gunicorn, with the operational concerns a real deployment needs.
| Concern | How |
|---|---|
| Auth | X-API-Key header, validated (constant-time) against PROVISION_API_KEYS. Unset ⇒ open (dev). The CLI sends PROVISION_API_KEY automatically. |
| Rate limiting | DRF throttle, opt-in via PROVISION_RATE_LIMIT (e.g. 120/min). Counter is per-process unless PROVISION_CACHE_URL points at Redis. |
| Health probes | GET /api/v1/health (liveness), GET /api/v1/readiness (DB check). Never gated by auth/throttle. |
| Metrics | Prometheus at GET /metrics (django-prometheus). |
| Logging | JSON lines to stdout (PROVISION_LOG_LEVEL). |
| Database | SQLite by default; set PROVISION_DATABASE_URL for Postgres. |
| Static files | Served by WhiteNoise (collected at image build). |
| Env var | Default | Meaning |
|---|---|---|
PROVISION_DEBUG |
1 |
0 for production |
PROVISION_ALLOWED_HOSTS |
localhost,127.0.0.1,0.0.0.0 |
comma-separated |
PROVISION_SECRET_KEY |
dev key | set in production |
PROVISION_API_KEYS |
— | comma-separated keys; unset ⇒ auth off |
PROVISION_RATE_LIMIT |
— | e.g. 120/min; unset ⇒ no throttle |
PROVISION_DATABASE_URL |
— | Postgres URL; unset ⇒ SQLite |
PROVISION_CACHE_URL |
— | Redis URL for a global throttle |
PROVISION_DB_PATH |
db.sqlite3 |
SQLite path |
WEB_CONCURRENCY |
2 |
gunicorn workers |
docker build -t ssh-provision .
docker run -p 8000:8000 -e PROVISION_SEED=1 -v ssh_data:/data ssh-provisionThe entrypoint runs migrations (and seeds when PROVISION_SEED=1), then serves
with gunicorn. SQLite is persisted on the /data volume; a healthcheck hits
/api/v1/health.
Compose, app only:
docker compose up -d --buildWith a Postgres + Redis overlay (shared DB and global throttle):
docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d --buildA Prometheus + Grafana stack ships under a Compose profile:
docker compose --profile monitoring up -d --build- Metrics: http://localhost:8000/metrics
- Prometheus: http://localhost:9090 (scrape + alert rules in
monitoring/) - Grafana: http://localhost:3000 (admin/admin) — the SSH Provision Service dashboard (request rate, responses by status, p50/p95/p99 latency) and the Prometheus datasource are auto-provisioned.
- CI (
.github/workflows/ci.yml) — Django check + ruff + pytest on Python 3.11/3.12, then a Docker build with a/healthsmoke test. - CD (
.github/workflows/cd.yml) — builds and pushes an image to GHCR onv*tags / releases.
A Makefile wraps the common tasks (make to list them).