initial commit
This commit is contained in:
commit
6150895b86
9 changed files with 945 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
database.ini
|
49
CertInfo.py
Normal file
49
CertInfo.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
see: https://www.postgresqltutorial.com/postgresql-python/connect/
|
||||||
|
|
||||||
|
Dhya Sat, 05 Mar 2022 01:00:10 -0700
|
||||||
|
"""
|
||||||
|
|
||||||
|
class CertInfo:
|
||||||
|
"""Class to contain certificate info"""
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def set_x509_cert(self, x509_cert):
|
||||||
|
self.x509_cert = x509_cert
|
||||||
|
|
||||||
|
def get_x509_cert(self):
|
||||||
|
return self.x509_cert
|
||||||
|
|
||||||
|
def set_cn(self, common_name):
|
||||||
|
self.common_name = common_name
|
||||||
|
|
||||||
|
def get_cn(self):
|
||||||
|
return self.common_name
|
||||||
|
|
||||||
|
def set_issuer(self, issuer):
|
||||||
|
self.issuer = issuer
|
||||||
|
|
||||||
|
def get_issuer(self):
|
||||||
|
return self.issuer
|
||||||
|
|
||||||
|
def set_not_valid_before(self, not_valid_before):
|
||||||
|
self.not_valid_before = not_valid_before
|
||||||
|
|
||||||
|
def get_not_valid_before(self):
|
||||||
|
return self.not_valid_before
|
||||||
|
|
||||||
|
def set_not_valid_after(self, not_valid_after):
|
||||||
|
self.not_valid_after = not_valid_after
|
||||||
|
|
||||||
|
def get_not_valid_after(self):
|
||||||
|
return self.not_valid_after
|
||||||
|
|
||||||
|
def set_last_checked(self, last_checked):
|
||||||
|
self.last_checked = last_checked
|
||||||
|
|
||||||
|
def get_last_checked(self):
|
||||||
|
return self.last_checked
|
||||||
|
|
93
README.md
Normal file
93
README.md
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
# Cert Manager
|
||||||
|
|
||||||
|
Cert Manager is a program to manage SSL certificates for multiple domains. It stores information in a PostgreSQL database. It retreives live certificates from the web and updates the database records accordingly. It reports on expiring or expired certificates, and it renews certifcates.
|
||||||
|
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
cryptography
|
||||||
|
psycopg v.3
|
||||||
|
```bash
|
||||||
|
# Install with the virtualenv
|
||||||
|
# remove existing venv:
|
||||||
|
rm -rf venv
|
||||||
|
# setup venv
|
||||||
|
virtualenv venv && source ./venv/bin/activate && pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# Install without virtualenv (not recommended):
|
||||||
|
pip install cryptography psycopg[binary]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- Settings stored in `database.ini`
|
||||||
|
|
||||||
|
|
||||||
|
## cert_manager database
|
||||||
|
|
||||||
|
- Contains master list of all domains
|
||||||
|
- "isActive" to indicate whether a domain is currently active
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
```bash
|
||||||
|
--help # show help
|
||||||
|
--check-live # check info for live certficate
|
||||||
|
--checkall # check SSL certficates for all managed domains
|
||||||
|
--list-active # List all active domains
|
||||||
|
--critical # List all expired or soon-to-expire domains
|
||||||
|
--info # Show info about a domain
|
||||||
|
--refresh # Refresh cert info in database for domain
|
||||||
|
--refresh-all # Refresh cert info in database for all domains
|
||||||
|
--renew # Renew certificate for domain
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create database and user
|
||||||
|
|
||||||
|
Add this line to the bottom of /etc/postgresql/15/main/pg_hba.conf:
|
||||||
|
```conf
|
||||||
|
local cert_manager cert_manager peer
|
||||||
|
```
|
||||||
|
Make sure port in /etc/postgresql/17/main/postgresql.conf agrees with port in cert-manager-db-setup
|
||||||
|
|
||||||
|
sudo grep port /etc/postgresql/17/main/postgresql.conf
|
||||||
|
```bash
|
||||||
|
# reload postgresql:
|
||||||
|
sudo systemctl reload postgresql
|
||||||
|
# setup db:
|
||||||
|
/usr/bin/cp -f cert-manager.sql cert-manager-db-setup /tmp/
|
||||||
|
cd /tmp
|
||||||
|
./cert-manager-db-setup
|
||||||
|
# test db access
|
||||||
|
psql --username=cert_manager --host=127.0.0.1 --port=5433 --dbname=cert_manager --command="SELECT * FROM main LIMIT 3"
|
||||||
|
```
|
||||||
|
## Create database (Windows)
|
||||||
|
|
||||||
|
- assumes Windows version of PostgreSQL installed
|
||||||
|
```ps1
|
||||||
|
dropdb -U postgres --echo --if-exists cert_manager
|
||||||
|
dropuser -U postgres --echo --if-exists cert_manager
|
||||||
|
createuser -U postgres --echo --pwprompt cert_manager
|
||||||
|
createdb -U postgres --echo --owner=cert_manager cert_manager
|
||||||
|
psql -U postgres --username=cert_manager --dbname=cert_manager --echo-all --file=cert-manager.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## certbot output
|
||||||
|
|
||||||
|
- output from subprocess.run() needs to be captured and processed
|
||||||
|
```bash
|
||||||
|
certbot certonly --dns-rfc2136 --dns-rfc2136-credentials /etc/letsencrypt/rfc2136.ini -d 'example.org'
|
||||||
|
```
|
||||||
|
```text
|
||||||
|
Saving debug log to /var/log/letsencrypt/letsencrypt.log
|
||||||
|
Requesting a certificate for example.org
|
||||||
|
Waiting 60 seconds for DNS changes to propagate
|
||||||
|
|
||||||
|
Successfully received certificate.
|
||||||
|
Certificate is saved at: /etc/letsencrypt/live/example.org/fullchain.pem
|
||||||
|
Key is saved at: /etc/letsencrypt/live/example.org/privkey.pem
|
||||||
|
This certificate expires on 2023-01-29.
|
||||||
|
These files will be updated when the certificate renews.
|
||||||
|
Certbot has set up a scheduled task to automatically renew this certificate in the background.
|
||||||
|
```
|
BIN
__pycache__/config.cpython-311.pyc
Normal file
BIN
__pycache__/config.cpython-311.pyc
Normal file
Binary file not shown.
742
certmanager.py
Normal file
742
certmanager.py
Normal file
|
@ -0,0 +1,742 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
see: https://www.postgresqltutorial.com/postgresql-python/connect/
|
||||||
|
|
||||||
|
Options:
|
||||||
|
add <host> # add <host> to database
|
||||||
|
remove <host> # remove <host> from database
|
||||||
|
active <host> # set <host> status to active
|
||||||
|
inactive <host> # set <host> status to inactive
|
||||||
|
info <host> # get host info from database
|
||||||
|
check <host> # check live cert for <host> and update db entry if in db
|
||||||
|
checkall # check live cert for all active hosts
|
||||||
|
critical # list all certs with status 'critical'
|
||||||
|
renew # renew a host
|
||||||
|
|
||||||
|
Dhya Thu, 03 Oct 2024 14:32:32 -0700
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
from socket import socket, AF_INET, SOCK_STREAM
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import ExtensionOID
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from psycopg import sql
|
||||||
|
|
||||||
|
# import os
|
||||||
|
# os.chdir("/usr/local/lib/certmanager")
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
DT_FMT: Final[str] = '%F %T %Z'
|
||||||
|
|
||||||
|
|
||||||
|
def fqdn_type(host_name):
|
||||||
|
"""
|
||||||
|
Used by argparse to validate fqdn input
|
||||||
|
Makes sure supplied host name is a valid fqdn
|
||||||
|
"""
|
||||||
|
if not 1 < len(host_name) < 253:
|
||||||
|
return False
|
||||||
|
parts = host_name.split('.')
|
||||||
|
fqdn = re. \
|
||||||
|
compile(r'^[a-z0-9]([a-z-0-9-]{0,61}[a-z0-9])?$',
|
||||||
|
re.IGNORECASE)
|
||||||
|
if all(fqdn.match(part) for part in parts):
|
||||||
|
return host_name
|
||||||
|
print("Error: \"", host_name, "\" is not a valid hostname.", sep='')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Host:
|
||||||
|
"""
|
||||||
|
Class to encapsulate a host
|
||||||
|
All fields are optional except {host_name}, e.g.:
|
||||||
|
h1 = Host("example.com")
|
||||||
|
"""
|
||||||
|
host_name: str # non-default argument must come first
|
||||||
|
host_id: int = None # primary key, autoincremented
|
||||||
|
common_name: str = None
|
||||||
|
issuer: str = None
|
||||||
|
not_valid_before: datetime = None
|
||||||
|
not_valid_after: datetime = None
|
||||||
|
active: bool = False # a host can have active or inactive status
|
||||||
|
checked: datetime = None # date of most recent cert check
|
||||||
|
check_status: str = None # status of most recent cert check
|
||||||
|
check_err: str = None # error (if any) of most recent cert check
|
||||||
|
renewed: datetime = None # datetime of most recent cert renewal
|
||||||
|
renew_status: str = None # status of most recent cert renewal
|
||||||
|
renew_out: str = None # output of most recent cert renewal
|
||||||
|
renew_err: str = None # error output of most recent cert renewal
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(
|
||||||
|
cls, *, host_id, host_name, common_name, issuer,
|
||||||
|
not_valid_before, not_valid_after, active,
|
||||||
|
checked, check_status, check_err, renewed, renew_status,
|
||||||
|
renew_out, renew_err
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Row factory to process psycopg SQL values into Host class
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
host_id=host_id,
|
||||||
|
host_name=host_name,
|
||||||
|
common_name=common_name,
|
||||||
|
issuer=issuer,
|
||||||
|
not_valid_before=not_valid_before,
|
||||||
|
not_valid_after=not_valid_after,
|
||||||
|
active=active,
|
||||||
|
checked=checked,
|
||||||
|
check_status=check_status,
|
||||||
|
check_err=check_err,
|
||||||
|
renewed=renewed,
|
||||||
|
renew_status=renew_status,
|
||||||
|
renew_out=renew_out,
|
||||||
|
renew_err=renew_err
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def row_factory(cls, cursor):
|
||||||
|
"""use cursor"""
|
||||||
|
columns = [column.name for column in cursor.description]
|
||||||
|
|
||||||
|
def make_row(values):
|
||||||
|
row = dict(zip(columns, values))
|
||||||
|
return cls.from_row(**row)
|
||||||
|
|
||||||
|
return make_row
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class San:
|
||||||
|
"""
|
||||||
|
Class to encapsulate Subject Alternative Names data
|
||||||
|
"""
|
||||||
|
name: str # non-default argument must come first
|
||||||
|
host_id: int = None # primary key, autoincremented
|
||||||
|
status: str = None # status of most recent cert check
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(
|
||||||
|
cls, *, host_id, name, status
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Row factory to process psycopg SQL values into SAN class
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
host_id=host_id,
|
||||||
|
name=name,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def row_factory(cls, cursor):
|
||||||
|
"""use cursor"""
|
||||||
|
columns = [column.name for column in cursor.description]
|
||||||
|
|
||||||
|
def make_row(values):
|
||||||
|
row = dict(zip(columns, values))
|
||||||
|
return cls.from_row(**row)
|
||||||
|
|
||||||
|
return make_row
|
||||||
|
|
||||||
|
|
||||||
|
def db_retrieve(query):
|
||||||
|
"""
|
||||||
|
Retrieves SSL certificate data
|
||||||
|
"""
|
||||||
|
params = config()
|
||||||
|
with psycopg.connect(**params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# next two lines are for debuging db queries
|
||||||
|
# print(query.as_string(conn))
|
||||||
|
# return
|
||||||
|
cur.execute(query)
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def db_execute(query):
|
||||||
|
"""
|
||||||
|
Execute SQL statement
|
||||||
|
"""
|
||||||
|
params = config()
|
||||||
|
with psycopg.connect(**params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# next lines are for debuging db queries
|
||||||
|
# print(query.as_string(conn))
|
||||||
|
# sys.exit(0)
|
||||||
|
# return
|
||||||
|
cur.execute(query)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def is_managed(host_name):
|
||||||
|
"""
|
||||||
|
Validate that a host is managed by this application, returns boolean
|
||||||
|
"""
|
||||||
|
# TODO: why is this called multiple times?
|
||||||
|
params = config()
|
||||||
|
with psycopg.connect(**params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = ("SELECT EXISTS(SELECT 1 FROM main "
|
||||||
|
f"WHERE host_name='{host_name}')")
|
||||||
|
cur.execute(query)
|
||||||
|
if result := cur.fetchone()[0]:
|
||||||
|
return result
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cert_error_db_update(host, err):
|
||||||
|
"""
|
||||||
|
Update database with cert check error data
|
||||||
|
"""
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
UPDATE {table} SET
|
||||||
|
checked = {f1},
|
||||||
|
check_status = {f2},
|
||||||
|
check_err = {f3}
|
||||||
|
WHERE host_name = {f4}
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
# .Identifier = PGSQL identifier, e.g. table & column names, uses
|
||||||
|
# double quotes
|
||||||
|
# .Literal = PGSQL literal, e.g. field values, uses single quotes
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
f1=sql.Literal(datetime.now(timezone.utc)),
|
||||||
|
f2=sql.Literal('failure'),
|
||||||
|
f3=sql.Literal(str(err)),
|
||||||
|
f4=sql.Literal(host)
|
||||||
|
)
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sans(host):
|
||||||
|
"""
|
||||||
|
Retreive host's SANs from database
|
||||||
|
"""
|
||||||
|
# print(f"get sans: {host}")
|
||||||
|
# SELECT 'name' FROM 'san' WHERE 'host_id' = (select host_id from main where host_name = {host})
|
||||||
|
|
||||||
|
params = config()
|
||||||
|
with psycopg.connect(**params) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
query = ("SELECT * FROM san WHERE host_id = "
|
||||||
|
f"(SELECT host_id FROM main WHERE host_name = '{host}')"
|
||||||
|
)
|
||||||
|
# next two lines are for debuging db queries
|
||||||
|
# print(f"get_sans query: {query}")
|
||||||
|
# return
|
||||||
|
cur.execute(query)
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def cert_db_update(host, cert):
|
||||||
|
"""
|
||||||
|
Update database host record with cert info
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: process SANs from cert
|
||||||
|
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
UPDATE {table} SET
|
||||||
|
not_valid_before = {f1},
|
||||||
|
not_valid_after = {f2},
|
||||||
|
common_name = {f3},
|
||||||
|
issuer = {f4},
|
||||||
|
checked = {f5},
|
||||||
|
check_status = {f6}
|
||||||
|
WHERE host_name = {f7}
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
# .Identifier = PGSQL identifier, e.g. table & column names, uses
|
||||||
|
# double quotes
|
||||||
|
# .Literal = PGSQL literal, e.g. field values, uses single quotes
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
f1=sql.Literal(
|
||||||
|
cert.not_valid_before_utc.replace(tzinfo=timezone.utc)),
|
||||||
|
f2=sql.Literal(
|
||||||
|
cert.not_valid_after_utc.replace(tzinfo=timezone.utc)),
|
||||||
|
f3=sql.Literal(
|
||||||
|
cert.subject.get_attributes_for_oid(
|
||||||
|
NameOID.COMMON_NAME)[0].value),
|
||||||
|
f4=sql.Literal(
|
||||||
|
cert.issuer.get_attributes_for_oid(
|
||||||
|
NameOID.ORGANIZATION_NAME)[0].value),
|
||||||
|
f5=sql.Literal(datetime.now(timezone.utc)),
|
||||||
|
f6=sql.Literal('success'),
|
||||||
|
f7=sql.Literal(host)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
managed_sans = get_sans(host)
|
||||||
|
|
||||||
|
san = cert. \
|
||||||
|
extensions. \
|
||||||
|
get_extension_for_oid(ExtensionOID.
|
||||||
|
SUBJECT_ALTERNATIVE_NAME)
|
||||||
|
|
||||||
|
cert_sans = san.value.get_values_for_type(x509.DNSName)
|
||||||
|
|
||||||
|
if managed_sans is not None:
|
||||||
|
for _ in managed_sans:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# This will require a separete UPDATE/INSERT for dns_names table
|
||||||
|
|
||||||
|
# print("DNS Names:", dnames)
|
||||||
|
# INSERT INTO san (host_id, name)
|
||||||
|
# VALUES ((select host_id from main where host_name = 'workin.com'), '*.workin.com');
|
||||||
|
|
||||||
|
|
||||||
|
class GetCertException(Exception):
|
||||||
|
"""
|
||||||
|
Custom exception thrown by get_cert()
|
||||||
|
Raise with: raise get_cert_exception("Exception text")
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_cert(host_to_check):
|
||||||
|
"""
|
||||||
|
Check live certificate for host
|
||||||
|
"""
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
try:
|
||||||
|
sock = socket(AF_INET, SOCK_STREAM, 0)
|
||||||
|
sock.settimeout(5.0) # 5 second timeout
|
||||||
|
ssock = context.wrap_socket(sock, server_hostname=host_to_check)
|
||||||
|
ssock.connect((host_to_check, 443))
|
||||||
|
pem_data = ssl.DER_cert_to_PEM_cert(ssock.getpeercert(True))
|
||||||
|
cert = x509.load_pem_x509_certificate(str.encode(pem_data))
|
||||||
|
|
||||||
|
# EOF occurred in violation of protocol (_ssl.c:1123)
|
||||||
|
# [Errno -2] Name or service not known
|
||||||
|
# [Errno -3] Temporary failure in name resolution
|
||||||
|
# [Errno -5] No address associated with hostname
|
||||||
|
# [Errno 110] Connection timed out
|
||||||
|
# [Errno 111] Connection refused
|
||||||
|
# [Errno 113] No route to host
|
||||||
|
except Exception as err:
|
||||||
|
if is_managed(host_to_check):
|
||||||
|
cert_error_db_update(host_to_check, err)
|
||||||
|
raise GetCertException(err)
|
||||||
|
|
||||||
|
# Handle self-signed certificates which lack common name/org. name
|
||||||
|
if not cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME):
|
||||||
|
err_string = "Certificate has no Common Name"
|
||||||
|
if not cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME):
|
||||||
|
err_string = "Certificate has no Common Name and no Organization Name"
|
||||||
|
if is_managed(host_to_check):
|
||||||
|
cert_error_db_update(host_to_check, err_string)
|
||||||
|
raise GetCertException(err_string)
|
||||||
|
|
||||||
|
return cert, ssock
|
||||||
|
|
||||||
|
|
||||||
|
def handle_add(args):
|
||||||
|
"""
|
||||||
|
Add a host
|
||||||
|
"""
|
||||||
|
if is_managed(args.host):
|
||||||
|
print("Host \'", args.host, "\' is already managed by this application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = sql.SQL(
|
||||||
|
"INSERT INTO {table} (host_name) VALUES ({f1})"
|
||||||
|
).format(
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
f1=sql.Literal(args.host)
|
||||||
|
)
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_remove(args):
|
||||||
|
"""
|
||||||
|
Remove a host
|
||||||
|
"""
|
||||||
|
if not is_managed(args.host):
|
||||||
|
print("Host \'", args.host, "\' is not managed by this application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = sql.SQL(
|
||||||
|
"DELETE FROM {table} WHERE {host_name} = {f1}"
|
||||||
|
).format(
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
host_name=sql.Identifier('host_name'),
|
||||||
|
f1=sql.Literal(args.host)
|
||||||
|
)
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_list_all(args):
|
||||||
|
"""
|
||||||
|
Handle list-all
|
||||||
|
|
||||||
|
List all hosts and their check status
|
||||||
|
"""
|
||||||
|
query = sql.SQL(
|
||||||
|
"SELECT {host_name}, {check_status} FROM {table}"
|
||||||
|
).format(
|
||||||
|
host_name=sql.Identifier('host_name'),
|
||||||
|
check_status=sql.Identifier('check_status'),
|
||||||
|
table=sql.Identifier('main')
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(db_retrieve(query)) == 0:
|
||||||
|
print("No hosts found in the database")
|
||||||
|
else:
|
||||||
|
result = db_retrieve(query)
|
||||||
|
rlist = list(result)
|
||||||
|
rlist.sort()
|
||||||
|
for i in rlist:
|
||||||
|
print("{:<36} Check status:".format(i[0].decode("utf-8")), i[1].decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_list_active(args):
|
||||||
|
"""
|
||||||
|
Handle list-active
|
||||||
|
|
||||||
|
List hosts marked as active
|
||||||
|
"""
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
SELECT {host_name} FROM {table}
|
||||||
|
WHERE {active} IS TRUE
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
host_name=sql.Identifier('host_name'),
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
active=sql.Identifier('active')
|
||||||
|
)
|
||||||
|
actives = list(zip(*db_retrieve(query)))[0]
|
||||||
|
alist = list(actives)
|
||||||
|
alist.sort()
|
||||||
|
for a in alist:
|
||||||
|
print(a.decode())
|
||||||
|
|
||||||
|
|
||||||
|
def handle_active(args):
|
||||||
|
"""
|
||||||
|
Set host status to active
|
||||||
|
"""
|
||||||
|
if not is_managed(args.host):
|
||||||
|
print(f"Host \'{args.host}\' is not managed by this application.",
|
||||||
|
"Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
UPDATE {table} SET
|
||||||
|
active = {f1}
|
||||||
|
WHERE host_name = {f2}
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
f1=sql.Literal('true'),
|
||||||
|
f2=sql.Literal(args.host)
|
||||||
|
)
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_inactive(args):
|
||||||
|
"""
|
||||||
|
Set host status to inactive
|
||||||
|
"""
|
||||||
|
if not is_managed(args.host):
|
||||||
|
print(f"Host \'{args.host}\' is not managed by this application.",
|
||||||
|
"Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
UPDATE {table} SET
|
||||||
|
active = {f1}
|
||||||
|
WHERE host_name = {f2}
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
f1=sql.Literal('false'),
|
||||||
|
f2=sql.Literal(args.host)
|
||||||
|
)
|
||||||
|
db_execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_info(args):
|
||||||
|
"""
|
||||||
|
Return database info on host
|
||||||
|
"""
|
||||||
|
# print("info", args.host)
|
||||||
|
if not is_managed(args.host):
|
||||||
|
print("Host \'", args.host, "\' is not managed by this application.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
params = config()
|
||||||
|
with psycopg.connect(**params) as conn:
|
||||||
|
with conn.cursor(row_factory=Host.row_factory) as cur:
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
SELECT * FROM main WHERE host_name = %s
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(query, (args.host,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
#sans = get_sans(args.host)
|
||||||
|
|
||||||
|
# stringer = dt.strftime(DT_FMT) if dt else lambda _: None;
|
||||||
|
print(
|
||||||
|
f"Host name: {row.host_name} \n",
|
||||||
|
f"Cert Common Name: {row.common_name} \n",
|
||||||
|
f"Cert Issuer: {row.issuer} \n",
|
||||||
|
"Cert Not Valid Before: ",
|
||||||
|
row.not_valid_before.astimezone(timezone.utc).strftime(DT_FMT)
|
||||||
|
if row.not_valid_before is not None else '<none>',
|
||||||
|
" (",
|
||||||
|
row.not_valid_before.strftime(DT_FMT)
|
||||||
|
if row.not_valid_before is not None else '<none>', ")\n",
|
||||||
|
"Cert Not Valid After: ",
|
||||||
|
row.not_valid_after.astimezone(timezone.utc).strftime(DT_FMT)
|
||||||
|
if row.not_valid_after is not None else '<none>',
|
||||||
|
" (",
|
||||||
|
row.not_valid_after.strftime(DT_FMT)
|
||||||
|
if row.not_valid_after is not None else '<none>',
|
||||||
|
")\n",
|
||||||
|
"Active: ", row.active, "\n",
|
||||||
|
"Checked date: ",
|
||||||
|
row.checked.strftime(
|
||||||
|
DT_FMT) if row.checked is not None else '<none>',
|
||||||
|
"\n",
|
||||||
|
"Check status: ", row.check_status, "\n",
|
||||||
|
"Check error msg: ", row.check_err, "\n",
|
||||||
|
"Renewed date: ",
|
||||||
|
row.renewed.strftime(
|
||||||
|
DT_FMT) if row.renewed is not None else '<none>',
|
||||||
|
"\n",
|
||||||
|
sep=''
|
||||||
|
)
|
||||||
|
# if sans is not None:
|
||||||
|
# print("SANs: ", end="")
|
||||||
|
# for n in sans:
|
||||||
|
# print(f"{n} ", end="")
|
||||||
|
# return
|
||||||
|
# print(f"No SANs information for {args.host}")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_check(args):
|
||||||
|
"""
|
||||||
|
Check live certificate for host and refresh database entry
|
||||||
|
"""
|
||||||
|
# TODO: don't call is_managed() repeatedly here
|
||||||
|
|
||||||
|
print("Getting live cert info for:", args.host)
|
||||||
|
|
||||||
|
print(f"in handle_check(), is_managed={is_managed(args.host)}")
|
||||||
|
|
||||||
|
if not is_managed(args.host):
|
||||||
|
print("NOTICE: Host \'", args.host,
|
||||||
|
"\' is not managed by this application.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cert, ssock = get_cert(args.host)
|
||||||
|
except GetCertException as err:
|
||||||
|
print("Error getting certificate:", err)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# if not is_managed(args.host):
|
||||||
|
print("SSL protocol version:", ssock.version())
|
||||||
|
print("Issuer:",
|
||||||
|
cert.issuer.get_attributes_for_oid(NameOID.ORGANIZATION_NAME)[0].value)
|
||||||
|
print("Subject CN:",
|
||||||
|
cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value)
|
||||||
|
print("Not valid before:", cert.not_valid_before_utc)
|
||||||
|
print("Not valid after:", cert.not_valid_after_utc)
|
||||||
|
san = cert.extensions.get_extension_for_oid(
|
||||||
|
ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
|
||||||
|
dnames = san.value.get_values_for_type(x509.DNSName)
|
||||||
|
print("DNS Names:")
|
||||||
|
for name in dnames:
|
||||||
|
print(" ", name)
|
||||||
|
# return
|
||||||
|
|
||||||
|
if is_managed(args.host):
|
||||||
|
cert_db_update(args.host, cert)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_checkall(args):
|
||||||
|
"""
|
||||||
|
Handle --check-all
|
||||||
|
|
||||||
|
Refreshes info for all certificates
|
||||||
|
"""
|
||||||
|
active_hosts_query = sql.SQL(
|
||||||
|
"""
|
||||||
|
SELECT {host_name} FROM {table}
|
||||||
|
WHERE {active} IS TRUE
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
host_name=sql.Identifier('host_name'),
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
active=sql.Identifier('active')
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts = []
|
||||||
|
for host in db_retrieve(active_hosts_query):
|
||||||
|
hosts.append(host[0])
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as executor:
|
||||||
|
future_to_host = {executor.submit(get_cert, h): h for h in hosts}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_host):
|
||||||
|
h = future_to_host[future]
|
||||||
|
try:
|
||||||
|
print("Getting live cert info for:", h)
|
||||||
|
# get_cert() returns tuple[Certificate, SSLSocket]
|
||||||
|
cert = future.result()[0]
|
||||||
|
cert_db_update(h, cert)
|
||||||
|
except GetCertException as err:
|
||||||
|
print("Error getting certificate for: ", h, ": ", err, sep="")
|
||||||
|
except Exception as exc:
|
||||||
|
print('%r generated an exception: %s' % (h, exc))
|
||||||
|
# else:
|
||||||
|
# print()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_critical(args):
|
||||||
|
"""
|
||||||
|
Handle --critical
|
||||||
|
|
||||||
|
Return list of expiring and recently expired active hosts
|
||||||
|
"""
|
||||||
|
# print("critical", args)
|
||||||
|
# return
|
||||||
|
CRITICAL_INTERVAL: Final[str] = '14 DAY'
|
||||||
|
query = sql.SQL(
|
||||||
|
"""
|
||||||
|
SELECT {host_name}, {nva} FROM {table}
|
||||||
|
WHERE {nva} < now() + interval {interval}
|
||||||
|
"""
|
||||||
|
).format(
|
||||||
|
host_name=sql.Identifier('host_name'),
|
||||||
|
table=sql.Identifier('main'),
|
||||||
|
nva=sql.Identifier('not_valid_after'),
|
||||||
|
interval=sql.Literal(CRITICAL_INTERVAL)
|
||||||
|
)
|
||||||
|
if len(db_retrieve(query)) == 0:
|
||||||
|
print("No hosts are listed as critical at this time")
|
||||||
|
else:
|
||||||
|
for i in db_retrieve(query):
|
||||||
|
print("{:<30} Expires:".format(i[0].decode("utf-8"), i[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_renew(args):
|
||||||
|
"""
|
||||||
|
Handle --renew <host_name>
|
||||||
|
|
||||||
|
Renews a certificate
|
||||||
|
"""
|
||||||
|
if certbot_cmd := shutil.which('certbot'):
|
||||||
|
renew_cmd = [
|
||||||
|
f'{certbot_cmd}',
|
||||||
|
'--cert-name', f'{args.host}',
|
||||||
|
'--dns-rfc2136',
|
||||||
|
'--dns-rfc2136-credentials',
|
||||||
|
'/etc/letsencrypt/rfc2136.ini',
|
||||||
|
'-d', f'{args.host}'
|
||||||
|
]
|
||||||
|
# subprocess.run(certbotcmd, check=False)
|
||||||
|
print("Not implemented yet")
|
||||||
|
print("Renew command:", *renew_cmd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
sys.stderr.write("\nThe certbot binary was not found in $PATH\n"
|
||||||
|
"Please make sure certbot is installed.\n"
|
||||||
|
"This program will now exit.\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
args = ""
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
# this is to deal with "AttributeError: 'Namespace' object has no attribute 'func'" issue
|
||||||
|
parser.set_defaults(func=lambda _: parser.print_help())
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(help='Functions')
|
||||||
|
|
||||||
|
# "add" parser
|
||||||
|
add_parser = subparsers.add_parser('add', help='add host')
|
||||||
|
add_parser.add_argument('-active', required=False,
|
||||||
|
action="store_true", help='set host status to active')
|
||||||
|
add_parser.add_argument('-inactive', required=False,
|
||||||
|
action="store_true", help='set host status to inactive')
|
||||||
|
add_parser.add_argument('host', type=str, help='host name')
|
||||||
|
add_parser.set_defaults(func=handle_add)
|
||||||
|
|
||||||
|
# "remove" parser
|
||||||
|
remove_parser = subparsers.add_parser('remove', help='remove host')
|
||||||
|
remove_parser.add_argument('host', type=str, help='host name')
|
||||||
|
remove_parser.set_defaults(func=handle_remove)
|
||||||
|
|
||||||
|
# "list-all" parser
|
||||||
|
listall_parser = subparsers.add_parser(
|
||||||
|
'list-all', help='list all hosts and their check status')
|
||||||
|
listall_parser.set_defaults(func=handle_list_all)
|
||||||
|
|
||||||
|
# "list-active" parser
|
||||||
|
listactive_parser = subparsers.add_parser(
|
||||||
|
'list-active', help='list hosts with active status')
|
||||||
|
listactive_parser.set_defaults(func=handle_list_active)
|
||||||
|
|
||||||
|
# "active" parser
|
||||||
|
active_parser = subparsers.add_parser('active', help='set host to active')
|
||||||
|
active_parser.add_argument('host', type=str, help='host name')
|
||||||
|
active_parser.set_defaults(func=handle_active)
|
||||||
|
|
||||||
|
# "inactive" parser
|
||||||
|
inactive_parser = subparsers.add_parser(
|
||||||
|
'inactive', help='set host to inactive')
|
||||||
|
inactive_parser.add_argument('host', type=str, help='host name')
|
||||||
|
inactive_parser.set_defaults(func=handle_inactive)
|
||||||
|
|
||||||
|
# "info" parser
|
||||||
|
info_parser = subparsers.add_parser('info', help='display info about host')
|
||||||
|
info_parser.add_argument('host', type=str, help='host name')
|
||||||
|
info_parser.set_defaults(func=handle_info)
|
||||||
|
|
||||||
|
# "check" parser
|
||||||
|
check_parser = subparsers.add_parser(
|
||||||
|
'check', help='check live SSL cert for host')
|
||||||
|
check_parser.add_argument('host', type=str, help='host name')
|
||||||
|
check_parser.set_defaults(func=handle_check)
|
||||||
|
|
||||||
|
# "checkall" parser
|
||||||
|
checkall_parser = subparsers.add_parser(
|
||||||
|
'check-all', help='check live SSL certs for all active hosts')
|
||||||
|
checkall_parser.set_defaults(func=handle_checkall)
|
||||||
|
|
||||||
|
# "critical" parser
|
||||||
|
critical_parser = subparsers.add_parser(
|
||||||
|
'critical', help='list active hosts that are critical')
|
||||||
|
critical_parser.set_defaults(func=handle_critical)
|
||||||
|
|
||||||
|
# "renew" parser
|
||||||
|
renew_parser = subparsers.add_parser('renew', help='renew SSL cert for host')
|
||||||
|
renew_parser.add_argument('host', type=str, help='host name')
|
||||||
|
renew_parser.set_defaults(func=handle_renew)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.func:
|
||||||
|
args.func(args)
|
21
config.py
Normal file
21
config.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# https://www.postgresqltutorial.com/postgresql-python/connect/
|
||||||
|
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
|
def config(cf='database.ini', section='postgresql'):
|
||||||
|
parser = ConfigParser()
|
||||||
|
parser.read(cf)
|
||||||
|
|
||||||
|
# get section, default to postgresql
|
||||||
|
db = {}
|
||||||
|
if parser.has_section(section):
|
||||||
|
params = parser.items(section)
|
||||||
|
for param in params:
|
||||||
|
db[param[0]] = param[1]
|
||||||
|
else:
|
||||||
|
raise Exception('Section {0} not found in the {1} file'.format(section, filename))
|
||||||
|
|
||||||
|
return db
|
||||||
|
|
6
database.ini.EXAMPLE
Normal file
6
database.ini.EXAMPLE
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[postgresql]
|
||||||
|
host=localhost
|
||||||
|
port=5433
|
||||||
|
dbname=cert_manager
|
||||||
|
user=cert_manager
|
||||||
|
password=1234567890abcde
|
6
requirements.txt
Normal file
6
requirements.txt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
cffi==1.17.1
|
||||||
|
cryptography==43.0.1
|
||||||
|
psycopg==3.2.3
|
||||||
|
psycopg-binary==3.2.3
|
||||||
|
pycparser==2.22
|
||||||
|
typing_extensions==4.12.2
|
27
testconn.py
Normal file
27
testconn.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# https://www.postgresqltutorial.com/postgresql-python/connect/
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = config()
|
||||||
|
conn = psycopg.connect(**params)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * from main LIMIT 10"
|
||||||
|
)
|
||||||
|
# cur.fetchone()
|
||||||
|
for record in cur:
|
||||||
|
print(record)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except (Exception, psycopg.DatabaseError) as error:
|
||||||
|
print(error)
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue