diff --git a/docs/modules/tls.md b/docs/modules/tls.md index f7ff16f..178df76 100644 --- a/docs/modules/tls.md +++ b/docs/modules/tls.md @@ -2,11 +2,61 @@ Using the `tls` module, you can generate TLS certificates and private keys. -The module has two subcommands: +The module has the following subcommands: * `rootCA` - generates a CA (certification authority) self signed certificate and private key pair. These are to be used by a TLS server. * `userCERT` - generates a certificate signed by a given CA, a private key and a CA list (chain of trust) file. These are to be used by TLS clients (users). +* `db_add` - adds a new TLS domain to the `tls_mgm` table. +* `db_update` - changes the columns of an existing TLS domain. +* `db_list` - lists the TLS domains provisioned in the `tls_mgm` table. +* `db_show` - prints the columns of a TLS domain. +* `db_delete` - removes a TLS domain from the `tls_mgm` table. + +The `db_*` subcommands provision the `tls_mgm` module over the database, where +the certificate, private key and CA list are stored as BLOB values rather than +as paths to files. A TLS domain is identified by its name and its type +(`server` or `client`), both passed as arguments: +``` +opensips-cli -x tls db_delete a.example.org server +``` +The domain and its type may also be given by name, in which case they can +appear anywhere among the other columns: +``` +opensips-cli -x tls db_delete domain=a.example.org type=server +``` +Giving one of them both ways at once is an error. The commands addressing a +single domain ask for whatever is left out; `db_add` and `db_show` default the +type to `server`, while `db_update` and `db_delete` have no default and keep +asking until one is given, so that they cannot change a different domain than +the intended one. + +`db_add` and `db_update` take the remaining `tls_mgm` columns as `column=value` +arguments, in any order and after the domain and the type: +``` +opensips-cli -x tls db_add a.example.org server method=TLSv1_2 verify_cert=1 +``` +The settable columns are `match_ip_address`, `match_sip_domain`, `method`, +`verify_cert`, `require_cert`, `certificate`, `private_key`, `crl_check_all`, +`crl_dir`, `ca_list`, `ca_dir`, `cipher_list`, `dh_params` and `ec_curve`. A +column that is not given is left to its default in the database schema; +`db_update` only changes the columns it is given. + +The `certificate`, `private_key`, `ca_list` and `dh_params` columns hold PEM +content, so their value is the path of the file holding it, and that file is +read and stored in the table: +``` +opensips-cli -x tls db_add a.example.org server \ + certificate=/etc/opensips/tls/user/user-cert.pem \ + private_key=/etc/opensips/tls/user/user-privkey.pem +``` +Every other column is stored as the value it is given, paths included: for +example, `ca_list` reads the file it points to, while `ca_dir` and `crl_dir` +keep the directory as such, which is what `tls_mgm` expects of them. + +After every change, the `tls_reload` MI command is issued so that a running +OpenSIPS picks up the new domains. If OpenSIPS cannot be reached, a warning is +logged and the domains are loaded at the next restart. ## Configuration @@ -47,6 +97,12 @@ List of `opensips-cli.cfg` settings for configuring user certificates: * tls_user_key_size - the size of the RSA key, in bits (e.g. 4096) * tls_user_md - the digest algorithm to use for signing (e.g. SHA256) +List of `opensips-cli.cfg` settings for the `db_*` subcommands: + +* database_tls_url - URL of the database holding the `tls_mgm` table; falls +back to `database_url` +* database_tls_name - name of the database; falls back to `database_name` + ## Examples @@ -97,3 +153,38 @@ tls_user_notafter: 315360000 tls_user_key_size: 4096 tls_user_md: SHA256 ``` + +To provision the certificate generated above as a TLS domain in the database: +``` +opensips-cli -x tls db_add a.example.org server \ + certificate=/etc/opensips/tls/user/user-cert.pem \ + private_key=/etc/opensips/tls/user/user-privkey.pem \ + ca_list=/etc/opensips/tls/user/user-calist.pem +``` +Certificates issued by a public CA are provisioned the same way: +``` +opensips-cli -x tls db_add a.example.org server \ + certificate=/etc/letsencrypt/live/a.example.org/fullchain.pem \ + private_key=/etc/letsencrypt/live/a.example.org/privkey.pem +``` +Configuration file example for the `db_*` subcommands: +``` +[default] +database_url: mysql://opensips:opensipsrw@localhost +database_name: opensips +``` + +To renew the certificate of a domain, or to change any of its other columns: +``` +opensips-cli -x tls db_update a.example.org server \ + certificate=/etc/letsencrypt/live/a.example.org/fullchain.pem \ + private_key=/etc/letsencrypt/live/a.example.org/privkey.pem +opensips-cli -x tls db_update a.example.org server cipher_list=HIGH +``` + +To inspect and remove the provisioned domains: +``` +opensips-cli -x tls db_list +opensips-cli -x tls db_show a.example.org server +opensips-cli -x tls db_delete a.example.org server +``` diff --git a/opensipscli/modules/tls.py b/opensipscli/modules/tls.py index d9cfbec..717aed5 100644 --- a/opensipscli/modules/tls.py +++ b/opensipscli/modules/tls.py @@ -25,8 +25,38 @@ from os.path import exists, join, dirname from os import makedirs from opensipscli.config import cfg, OpenSIPSCLIConfig +from opensipscli.db import osdb, osdbError +from opensipscli import comm from random import randrange +DEFAULT_DB_NAME = "opensips" +TLS_MGM_TABLE = "tls_mgm" +TLS_DOMAIN_COL = "domain" +TLS_TYPE_COL = "type" +TLS_CERT_COL = "certificate" +TLS_PK_COL = "private_key" +TLS_CALIST_COL = "ca_list" +TLS_DH_COL = "dh_params" + +# the tls_mgm columns that can be provisioned +TLS_MGM_COLUMNS = ["match_ip_address", "match_sip_domain", "method", + "verify_cert", "require_cert", TLS_CERT_COL, TLS_PK_COL, + "crl_check_all", "crl_dir", TLS_CALIST_COL, "ca_dir", "cipher_list", + TLS_DH_COL, "ec_curve"] + +# columns holding PEM content, which is read from the file they point to +TLS_PEM_COLUMNS = [TLS_CERT_COL, TLS_PK_COL, TLS_CALIST_COL, TLS_DH_COL] + +# columns identifying a row, in the order they are accepted as arguments +TLS_KEY_COLUMNS = [TLS_DOMAIN_COL, TLS_TYPE_COL] + +# generated by the database, never provisioned +TLS_ID_COL = "id" + +# as defined by CLIENT_DOMAIN_TYPE/SERVER_DOMAIN_TYPE in tls_mgm/tls_domain.h +TLS_DOMAIN_TYPES = {"client": 1, "server": 2} +TLS_TYPE_NAMES = {v: k for k, v in TLS_DOMAIN_TYPES.items()} + openssl_version = None try: @@ -207,6 +237,7 @@ def load(self, key): password=None) class tls(Module): + def do_rootCA(self, params, modifiers=None): global cfg logger.info("Preparing to generate CA cert + key...") @@ -349,6 +380,311 @@ def do_userCERT(self, params, modifiers=None): logger.info("user private key created in " + k_f) logger.info("user CA list (chain of trust) created in " + ca_f) + def tls_db_connect(self): + """ + connects to the database holding the tls_mgm table + """ + if not osdb.has_sqlalchemy(): + logger.error("SQLAlchemy not available: cannot access the database") + return None + + engine = osdb.get_db_engine() + + db_url = cfg.read_param(["database_tls_url", "database_url"], + "Please provide us the URL of the database") + if db_url is None: + print() + logger.error("no URL specified: aborting!") + return None + + db_url = osdb.set_url_driver(db_url, engine) + db_name = cfg.read_param(["database_tls_name", "database_name"], + "Please provide the database storing the TLS domains", + DEFAULT_DB_NAME) + + try: + db = osdb(db_url, db_name) + except osdbError: + logger.error("failed to connect to database %s", db_name) + return None + + if not db.connect(): + return None + + return db + + def tls_db_reload(self): + """ + makes a running OpenSIPS pick up the tls_mgm changes + """ + if comm.execute('tls_reload') is None: + logger.warning("could not reload the TLS domains; " + "OpenSIPS will load them at the next restart") + + def tls_db_params(self, params, require_type=False): + """ + resolves the (domain, type) pair identifying a tls_mgm row, along with + the columns to provision. Everything is given as 'column=value', with + the domain and its type also accepted as the first two arguments; what + is left out is asked for. The value of a PEM column is the path of the + file holding it + """ + cols = {} + for param in [p for p in params if '=' in p]: + col, val = param.split('=', 1) + if col == TLS_ID_COL: + logger.error("column '%s' is generated by the database", + TLS_ID_COL) + return None, None, None + + if col not in TLS_MGM_COLUMNS and col not in TLS_KEY_COLUMNS: + logger.error("unknown %s column '%s'", TLS_MGM_TABLE, col) + return None, None, None + + if col in TLS_PEM_COLUMNS: + path = val + try: + with open(path, "rt") as f: + val = f.read() + except Exception as e: + logger.exception(e) + logger.error("Failed to read %s", path) + return None, None, None + + if "-----BEGIN" not in val: + logger.error("%s is not in PEM format", path) + return None, None, None + + cols[col] = val + + # the domain and its type identify the row, they are not provisioned + args = [p for p in params if '=' not in p] + if len(args) > len(TLS_KEY_COLUMNS): + logger.error("too many arguments: expected at most a domain and " + "its type") + return None, None, None + + key = {} + for i, col in enumerate(TLS_KEY_COLUMNS): + if i < len(args): + if col in cols: + logger.error("'%s' given both as an argument and as " + "'%s='", col, col) + return None, None, None + key[col] = args[i] + else: + key[col] = cols.pop(col, None) + + domain = key[TLS_DOMAIN_COL] + if not domain: + domain = cfg.read_param(None, + "Please provide the name of the TLS domain") + if not domain: + logger.error("no TLS domain specified!") + return None, None, None + + dtype = key[TLS_TYPE_COL] + if not dtype: + # commands changing an existing row get no default, so that they + # cannot pick a different row than the intended one + dtype = cfg.read_param(None, "TLS domain type (server/client)", + None if require_type else "server") + if not dtype: + logger.error("no TLS domain type specified!") + return None, None, None + + if dtype.lower() not in TLS_DOMAIN_TYPES: + logger.error("invalid TLS domain type '%s': " + "expected 'server' or 'client'", dtype) + return None, None, None + + return domain, TLS_DOMAIN_TYPES[dtype.lower()], cols + + def do_db_add(self, params=None, modifiers=None): + """ + provisions a new TLS domain in the database + """ + domain, dtype, cols = self.tls_db_params(params or []) + if not domain: + return -1 + + db = self.tls_db_connect() + if not db: + return -1 + + row = {TLS_DOMAIN_COL: domain, TLS_TYPE_COL: dtype} + if db.entry_exists(TLS_MGM_TABLE, row): + logger.error("TLS %s domain '%s' already exists", + TLS_TYPE_NAMES[dtype], domain) + db.destroy() + return -1 + + row.update(cols) + if db.insert(TLS_MGM_TABLE, row) is False: + db.destroy() + return -1 + + db.destroy() + logger.info("Successfully added TLS %s domain '%s'", + TLS_TYPE_NAMES[dtype], domain) + self.tls_db_reload() + return True + + def do_db_update(self, params=None, modifiers=None): + """ + changes the given columns of an existing TLS domain + """ + domain, dtype, cols = self.tls_db_params(params or [], True) + if not domain: + return -1 + + if not cols: + logger.error("no column to update: expected 'column=value'") + return -1 + + db = self.tls_db_connect() + if not db: + return -1 + + row = {TLS_DOMAIN_COL: domain, TLS_TYPE_COL: dtype} + if not db.entry_exists(TLS_MGM_TABLE, row): + logger.error("TLS %s domain '%s' does not exist", + TLS_TYPE_NAMES[dtype], domain) + db.destroy() + return -1 + + if db.update(TLS_MGM_TABLE, cols, row) is False: + db.destroy() + return -1 + + db.destroy() + logger.info("Successfully updated TLS %s domain '%s'", + TLS_TYPE_NAMES[dtype], domain) + self.tls_db_reload() + return True + + def do_db_list(self, params=None, modifiers=None): + """ + lists the TLS domains provisioned in the database + """ + db = self.tls_db_connect() + if not db: + return -1 + + res = db.find(TLS_MGM_TABLE, + ["id", TLS_DOMAIN_COL, TLS_TYPE_COL, "method", + "verify_cert", "require_cert"], None) + if res is None: + db.destroy() + return -1 + + rows = res.fetchall() + db.destroy() + + if not rows: + logger.info("no TLS domain provisioned in %s", TLS_MGM_TABLE) + return True + + print("{:<5} {:<32} {:<8} {:<8} {:<8} {:<8}".format( + "id", "domain", "type", "method", "verify", "require")) + for r in rows: + print("{:<5} {:<32} {:<8} {:<8} {:<8} {:<8}".format( + r[0], r[1], TLS_TYPE_NAMES.get(r[2], r[2]), + str(r[3]), str(r[4]), str(r[5]))) + return True + + def do_db_show(self, params=None, modifiers=None): + """ + prints the columns of a TLS domain + """ + domain, dtype, cols = self.tls_db_params(params or []) + if domain and cols: + logger.error("db_show takes no column: '%s'", list(cols)[0]) + return -1 + if not domain: + return -1 + + db = self.tls_db_connect() + if not db: + return -1 + + res = db.find(TLS_MGM_TABLE, TLS_MGM_COLUMNS, + {TLS_DOMAIN_COL: domain, TLS_TYPE_COL: dtype}) + row = res.first() if res is not None else None + db.destroy() + + if not row: + logger.error("TLS %s domain '%s' does not exist", + TLS_TYPE_NAMES[dtype], domain) + return -1 + + def decode(val): + return val.decode('utf-8') if isinstance(val, bytes) else val + + values = dict(zip(TLS_MGM_COLUMNS, row)) + + print("{} domain: {}".format(TLS_TYPE_NAMES[dtype], domain)) + for col in TLS_MGM_COLUMNS: + if col in TLS_PEM_COLUMNS: + continue + print("{}: {}".format(col, + "" if values[col] is None else values[col])) + + # the private key is never printed back + print("{}: {}".format(TLS_PK_COL, + "" if values[TLS_PK_COL] else "")) + + for col in TLS_PEM_COLUMNS: + if col == TLS_PK_COL: + continue + print("\n{}:\n{}".format(col, + decode(values[col]) if values[col] else "")) + return True + + def do_db_delete(self, params=None, modifiers=None): + """ + removes a TLS domain from the database + """ + domain, dtype, cols = self.tls_db_params(params or [], True) + if domain and cols: + logger.error("db_delete takes no column: '%s'", list(cols)[0]) + return -1 + if not domain: + return -1 + + db = self.tls_db_connect() + if not db: + return -1 + + row = {TLS_DOMAIN_COL: domain, TLS_TYPE_COL: dtype} + if not db.entry_exists(TLS_MGM_TABLE, row): + logger.error("TLS %s domain '%s' does not exist", + TLS_TYPE_NAMES[dtype], domain) + db.destroy() + return -1 + + if db.delete(TLS_MGM_TABLE, row) is False: + db.destroy() + return -1 + + db.destroy() + logger.info("Successfully deleted TLS %s domain '%s'", + TLS_TYPE_NAMES[dtype], domain) + self.tls_db_reload() + return True + + def __complete__(self, command, text, line, begidx, endidx): + """ + helper for autocompletion in interactive mode + """ + if command not in ('db_add', 'db_update'): + return [''] + + cols = [c + '=' for c in TLS_MGM_COLUMNS] + if not text: + return cols + + return [c for c in cols if c.startswith(text)] or [''] def __exclude__(self): return (not openssl_version, None)