pinecrypt-gateway-backend/pinecrypt/server/cli.py

585 lines
24 KiB
Python

# coding: utf-8
try:
import coverage
except ImportError:
pass
else:
if coverage.process_startup():
print("Enabled code coverage tracking")
import falcon
import click
import logging
import os
import pymongo
import signal
import socket
import sys
import pytz
from asn1crypto import pem, x509
from certbuilder import CertificateBuilder, pem_armor_certificate
from datetime import datetime, timedelta
from jinja2 import Environment, PackageLoader
from oscrypto import asymmetric
from math import log, ceil
from pinecrypt.server import const, mongolog, mailer, db
from pinecrypt.server.middleware import NormalizeMiddleware, PrometheusEndpoint
from pinecrypt.server.common import cn_to_dn, generate_serial
from time import sleep
from wsgiref.simple_server import make_server
logger = logging.getLogger(__name__)
mongolog.register()
def graceful_exit(signal_number, stack_frame):
print("Received signal %d, exiting now" % signal_number)
sys.exit(0)
def fqdn_required(func):
def wrapped(**args):
common_name = args.get("common_name")
if "." in common_name:
logger.info("Using fully qualified hostname %s" % common_name)
else:
raise ValueError("Fully qualified hostname not specified as common name, make sure hostname -f works")
return func(**args)
return wrapped
def waitfile(path):
def wrapper(func):
def wrapped(**args):
while not os.path.exists(path):
sleep(1)
return func(**args)
return wrapped
return wrapper
@click.command("log", help="Dump logs")
def pinecone_log():
for record in mongolog.collection.find():
print(record["created"].strftime("%Y-%m-%d %H:%M:%S"),
record["severity"],
record["message"])
@click.command("users", help="List users")
def pinecone_users():
from pinecrypt.server.user import User
admins = set(User.objects.filter_admins())
for user in User.objects.all():
click.echo("%s;%s;%s;%s;%s" % (
"admin" if user in admins else "user",
user.name, user.given_name, user.surname, user.mail))
@click.command("list", help="List certificates")
@click.option("--verbose", "-v", default=False, is_flag=True, help="Verbose output")
@click.option("--show-key-type", "-k", default=False, is_flag=True, help="Show key type and length")
@click.option("--show-path", "-p", default=False, is_flag=True, help="Show filesystem paths")
@click.option("--show-extensions", "-e", default=False, is_flag=True, help="Show X.509 Certificate Extensions")
@click.option("--hide-requests", "-h", default=False, is_flag=True, help="Hide signing requests")
@click.option("--show-signed", "-s", default=False, is_flag=True, help="Show signed certificates")
@click.option("--show-revoked", "-r", default=False, is_flag=True, help="Show revoked certificates")
def pinecone_list(verbose, show_key_type, show_extensions, show_path, show_signed, show_revoked, hide_requests):
from pinecrypt.server import db
for o in db.certificates.find():
print(o["common_name"], o["status"], o.get("instance"), o.get("remote"), o.get("last_seen"))
@click.command("list", help="List sessions")
def pinecone_session_list():
from pinecrypt.server import db
for o in db.sessions.find():
print(o["user"], o["started"], o.get("expires"), o.get("last_seen"))
@click.command("sign", help="Sign certificate")
@click.argument("common_name")
@click.option("--profile", "-p", default="Roadwarrior", help="Profile")
@click.option("--overwrite", "-o", default=False, is_flag=True, help="Revoke valid certificate with same CN")
def pinecone_sign(common_name, overwrite, profile):
from pinecrypt.server import authority
authority.sign(common_name, overwrite=overwrite, profile=profile)
@click.command("revoke", help="Revoke certificate")
@click.option("--reason", "-r", default="key_compromise",
help="Revocation reason, one of: key_compromise affiliation_changed superseded cessation_of_operation privilege_withdrawn")
@click.argument("common_name")
def pinecone_revoke(common_name, reason):
from pinecrypt.server import authority
authority.revoke(common_name, reason)
@click.command("kinit", help="Initialize Kerberos credential cache for LDAP")
def pinecone_housekeeping_kinit():
# Update LDAP service ticket if Certidude is joined to domain
if not os.path.exists("/etc/krb5.keytab"):
raise click.ClickException("No Kerberos keytab configured")
_, kdc = const.LDAP_ACCOUNTS_URI.rsplit("/", 1)
cmd = "KRB5CCNAME=%s.part kinit -k %s$ -S ldap/%s@%s -t /etc/krb5.keytab" % (
const.LDAP_GSSAPI_CRED_CACHE,
const.HOSTNAME.upper(), kdc, const.KERBEROS_REALM
)
click.echo("Executing: %s" % cmd)
if os.system(cmd):
raise click.ClickException("Failed to initialize Kerberos credential cache!")
os.system("chown certidude:certidude %s.part" % const.LDAP_GSSAPI_CRED_CACHE)
os.rename("%s.part" % const.LDAP_GSSAPI_CRED_CACHE, const.LDAP_GSSAPI_CRED_CACHE)
@click.command("daily", help="Send notifications about expired certificates")
def pinecone_housekeeping_expiration():
from pinecrypt.server import authority
threshold_move = datetime.utcnow().replace(tzinfo=pytz.UTC) - const.CLOCK_SKEW_TOLERANCE
threshold_notify = datetime.utcnow().replace(tzinfo=pytz.UTC) + timedelta(hours=48)
expired = []
about_to_expire = []
# Collect certificates which have expired and are about to expire
for common_name, path, buf, cert, signed, expires in authority.list_signed():
if expires.replace(tzinfo=pytz.UTC) < threshold_move:
expired.append((common_name, path, cert))
elif expires.replace(tzinfo=pytz.UTC) < threshold_notify:
about_to_expire.append((common_name, path, cert))
# Send e-mail notifications
if expired or about_to_expire:
mailer.send("expiration-notification.md", **locals())
# Move valid, but now expired certificates
for common_name, path, cert in expired:
expired_path = os.path.join(const.EXPIRED_DIR, "%040x.pem" % cert.serial_number)
click.echo("Moving %s to %s" % (path, expired_path))
os.rename(path, expired_path)
os.remove(os.path.join(const.SIGNED_BY_SERIAL_DIR, "%040x.pem" % cert.serial_number))
# Move revoked certificate which have expired
for common_name, path, buf, cert, signed, expires, revoked, reason in authority.list_revoked():
if expires.replace(tzinfo=pytz.UTC) < threshold_move:
expired_path = os.path.join(const.EXPIRED_DIR, "%040x.pem" % cert.serial_number)
click.echo("Moving %s to %s" % (path, expired_path))
os.rename(path, expired_path)
# TODO: Send separate e-mails to subjects
@click.command("ocsp-responder")
@waitfile(const.AUTHORITY_CERTIFICATE_PATH)
def pinecone_serve_ocsp_responder():
from pinecrypt.server.api.ocsp import app
app.run(port=5001, debug=const.DEBUG)
@click.command("events")
def pinecone_serve_events():
from pinecrypt.server.api.events import app
app.run(port=8001, debug=const.DEBUG)
@click.command("builder")
def pinecone_serve_builder():
from pinecrypt.server.api.builder import app
app.run(port=7001, debug=const.DEBUG)
@click.command("provision", help="Provision keys")
def pinecone_provision():
default_policy = "REJECT" if const.DEBUG else "DROP"
click.echo("Setting up firewall rules")
if const.REPLICAS:
# TODO: atomic update with `ipset restore`
for replica in const.REPLICAS:
for fam, _, _, _, addrs in socket.getaddrinfo(replica, None):
if fam == 10:
os.system("ipset add ipset6-mongo-replicas %s" % addrs[0])
elif fam == 2:
os.system("ipset add ipset4-mongo-replicas %s" % addrs[0])
os.system("ipset create -exist -quiet ipset4-client-ingress hash:ip timeout 3600 counters")
os.system("ipset create -exist -quiet ipset6-client-ingress hash:ip family inet6 timeout 3600 counters")
os.system("ipset create -exist -quiet ipset4-client-egress hash:ip timeout 3600 counters")
os.system("ipset create -exist -quiet ipset6-client-egress hash:ip family inet6 timeout 3600 counters")
os.system("ipset create -exist -quiet ipset4-mongo-replicas hash:ip")
os.system("ipset create -exist -quiet ipset6-mongo-replicas hash:ip family inet6")
os.system("ipset create -exist -quiet ipset4-prometheus-subnets hash:net")
os.system("ipset create -exist -quiet ipset6-prometheus-subnets hash:net family inet6")
for subnet in const.PROMETHEUS_SUBNETS:
os.system("ipset add -exist -quiet ipset%d-prometheus-subnets %s" % (subnet.version, subnet))
def g():
yield "*filter"
yield ":INBOUND_BLOCKED - [0:0]"
yield "-A INBOUND_BLOCKED -j %s -m comment --comment \"Default policy\"" % default_policy
yield ":OUTBOUND_CLIENT - [0:0]"
yield "-A OUTBOUND_CLIENT -m set ! --match-set ipset4-client-ingress dst -j SET --add-set ipset4-client-ingress dst"
yield "-A OUTBOUND_CLIENT -j ACCEPT"
yield ":INBOUND_CLIENT - [0:0]"
yield "-A INBOUND_CLIENT -m set ! --match-set ipset4-client-ingress src -j SET --add-set ipset4-client-ingress src"
yield "-A INBOUND_CLIENT -j ACCEPT"
yield ":INPUT DROP [0:0]"
yield "-A INPUT -i lo -j ACCEPT -m comment --comment \"Allow loopback\""
yield "-A INPUT -p icmp -j ACCEPT -m comment --comment \"Allow ping\""
yield "-A INPUT -p esp -j ACCEPT -m comment --comment \"Allow ESP traffic\""
yield "-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"Allow returning packets\""
yield "-A INPUT -p tcp --dport 22 -j ACCEPT -m comment --comment \"Allow SSH\""
yield "-A INPUT -p udp --dport 53 -j ACCEPT -m comment --comment \"Allow GoreDNS over UDP\""
yield "-A INPUT -p tcp --dport 53 -j ACCEPT -m comment --comment \"Allow GoreDNS over TCP\""
yield "-A INPUT -p tcp --dport 80 -j ACCEPT -m comment --comment \"Allow insecure HTTP\""
yield "-A INPUT -p tcp --dport 443 -j ACCEPT -m comment --comment \"Allow HTTPS / OpenVPN TCP\""
yield "-A INPUT -p tcp --dport 8443 -j ACCEPT -m comment --comment \"Allow mutually authenticated HTTPS\""
yield "-A INPUT -p udp --dport 1194 -j ACCEPT -m comment --comment \"Allow OpenVPN UDP\""
yield "-A INPUT -p udp --dport 500 -j ACCEPT -m comment --comment \"Allow IPsec IKE\""
yield "-A INPUT -p udp --dport 4500 -j ACCEPT -m comment --comment \"Allow IPsec NAT traversal\""
if const.REPLICAS:
yield "-A INPUT -p tcp --dport 27017 -j ACCEPT -m set --match-set ipset4-mongo-replicas src -m comment --comment \"Allow MongoDB internode\""
yield "-A INPUT -p tcp --dport 9090 -j ACCEPT -m set --match-set ipset4-prometheus-subnets src -m comment --comment \"Allow Prometheus\""
yield "-A INPUT -j INBOUND_BLOCKED"
yield ":FORWARD DROP [0:0]"
yield "-A FORWARD -i tunudp0 -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from OpenVPN UDP clients\""
yield "-A FORWARD -i tuntcp0 -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from OpenVPN TCP clients\""
yield "-A FORWARD -m policy --dir in --pol ipsec -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from IPSec clients\""
yield "-A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j OUTBOUND_CLIENT -m comment --comment \"Outbound traffic to clients\""
yield "-A FORWARD -j %s -m comment --comment \"Default policy\"" % default_policy
yield ":OUTPUT DROP [0:0]"
yield "-A OUTPUT -j ACCEPT"
yield "COMMIT"
yield "*nat"
yield ":PREROUTING ACCEPT [0:0]"
yield ":INPUT ACCEPT [0:0]"
yield ":OUTPUT ACCEPT [0:0]"
yield ":POSTROUTING ACCEPT [0:0]"
yield "-A POSTROUTING -j MASQUERADE"
yield "COMMIT"
with open("/tmp/rules4", "w") as fh:
for line in g():
fh.write(line)
fh.write("\n")
if not const.DISABLE_FIREWALL:
os.system("iptables-restore < /tmp/rules4")
os.system("sed -e 's/ipset4/ipset6/g' -e 's/p icmp/p ipv6-icmp/g' /tmp/rules4 > /tmp/rules6")
os.system("ip6tables-restore < /tmp/rules6")
os.system("sysctl -w net.ipv6.conf.all.forwarding=1")
os.system("sysctl -w net.ipv6.conf.default.forwarding=1")
os.system("sysctl -w net.ipv4.ip_forward=1")
if const.REPLICAS:
click.echo("Provisioning MongoDB replicaset")
# WTF https://github.com/docker-library/mongo/issues/339
c = pymongo.MongoClient("localhost", 27017)
config = {"_id": "rs0", "members": [
{"_id": index, "host": "%s:27017" % hostname} for index, hostname in enumerate(const.REPLICAS)]}
print("Provisioning MongoDB replicaset: %s" % repr(config))
try:
c.admin.command("replSetInitiate", config)
except pymongo.errors.OperationFailure:
print("Looks like it's already initialized")
pass
# Expand variables
distinguished_name = cn_to_dn(const.AUTHORITY_COMMON_NAME)
# Generate and sign CA key
if os.path.exists(const.AUTHORITY_CERTIFICATE_PATH) and os.path.exists(const.AUTHORITY_PRIVATE_KEY_PATH):
click.echo("Authority keypair already exists")
else:
if const.AUTHORITY_KEYTYPE == "ec":
click.echo("Generating %s EC key for CA ..." % const.CURVE_NAME)
public_key, private_key = asymmetric.generate_pair("ec", curve=const.CURVE_NAME)
else:
click.echo("Generating %d-bit RSA key for CA ..." % const.KEY_SIZE)
public_key, private_key = asymmetric.generate_pair("rsa", bit_size=const.KEY_SIZE)
# https://technet.microsoft.com/en-us/library/aa998840(v=exchg.141).aspx
builder = CertificateBuilder(distinguished_name, public_key)
builder.self_signed = True
builder.ca = True
builder.serial_number = generate_serial()
now = datetime.utcnow().replace(tzinfo=pytz.UTC)
builder.begin_date = now - const.CLOCK_SKEW_TOLERANCE
builder.end_date = now + timedelta(days=const.AUTHORITY_LIFETIME_DAYS)
certificate = builder.build(private_key)
header, _, der_bytes = pem.unarmor(pem_armor_certificate(certificate))
obj = {
"name": "root",
"key": asymmetric.dump_private_key(private_key, None),
"cert": pem_armor_certificate(certificate)
}
if const.SECRET_STORAGE == "db":
db.secrets.create_index("name", unique=True)
try:
db.secrets.insert_one(obj)
except pymongo.errors.DuplicateKeyError:
obj = db.secrets.find_one({"name": "root"})
# Set permission bits to 600
os.umask(0o177)
with open(const.AUTHORITY_PRIVATE_KEY_PATH + ".part", "wb") as f:
f.write(obj["key"])
# Set permission bits to 644
os.umask(0o133)
with open(const.AUTHORITY_CERTIFICATE_PATH + ".part", "wb") as f:
f.write(obj["cert"])
os.rename(const.AUTHORITY_PRIVATE_KEY_PATH + ".part",
const.AUTHORITY_PRIVATE_KEY_PATH)
os.rename(const.AUTHORITY_CERTIFICATE_PATH + ".part",
const.AUTHORITY_CERTIFICATE_PATH)
click.echo("Authority certificate written to: %s" % const.AUTHORITY_CERTIFICATE_PATH)
click.echo("Attempting self-enroll")
from pinecrypt.server import authority
authority.self_enroll(skip_notify=True)
# Insert/update DNS records for the replica itself
click.echo("Advertising via DNS: %s -> %s" % (const.FQDN, repr(const.ADVERTISE_ADDRESS)))
db.certificates.update_one({
"common_name": const.FQDN,
"status": "signed",
}, {
"$set": {
"dns": {
"fqdn": const.FQDN,
"san": const.AUTHORITY_NAMESPACE,
},
"ip": list(const.ADVERTISE_ADDRESS),
}
})
# Separate pushed subnets by address family
push4 = set()
push6 = set()
for subnet in const.PUSH_SUBNETS:
if subnet.version == 4:
push4.add(subnet)
elif subnet.version == 6:
if not const.CLIENT_SUBNET6:
raise ValueError("Can't push IPv6 routes if no IPv6 client subnet is configured")
push6.add(subnet)
else:
raise NotImplementedError()
# Generate OpenVPN configurations
click.echo("Generating OpenVPN configuration files...")
from pinecrypt.server import config
ctx = {
"authority_namespace": const.AUTHORITY_NAMESPACE,
"push4": push4,
"push6": push6,
"openvpn_tls_version_min": config.get("Globals", "OPENVPN_TLS_VERSION_MIN")["value"],
"openvpn_tls_ciphersuites": config.get("Globals", "OPENVPN_TLS_CIPHERSUITES")["value"],
"openvpn_tls_cipher": config.get("Globals", "OPENVPN_TLS_CIPHER")["value"],
"openvpn_cipher": config.get("Globals", "OPENVPN_CIPHER")["value"],
"openvpn_auth": config.get("Globals", "OPENVPN_AUTH")["value"],
"strongswan_dhgroup": config.get("Globals", "STRONGSWAN_DHGROUP")["value"],
"strongswan_ike": config.get("Globals", "STRONGSWAN_IKE")["value"],
"strongswan_esp": config.get("Globals", "STRONGSWAN_ESP")["value"],
}
env = Environment(loader=PackageLoader("pinecrypt.server", "templates"))
os.umask(0o133)
d = ceil(log(const.CLIENT_SUBNET_SLOT_COUNT) / log(2))
for slot, proto in enumerate(["udp", "tcp"]):
ctx["proto"] = proto
ctx["slot4"] = list(const.CLIENT_SUBNET4.subnets(d))[slot]
ctx["slot6"] = list(const.CLIENT_SUBNET6.subnets(d))[slot] if const.CLIENT_SUBNET6 else None
with open("/server-secrets/openvpn-%s.conf" % proto, "w") as fh:
fh.write(env.get_template("openvpn.conf").render(ctx))
# Merged variants for StrongSwan
ctx["push"] = ctx["push4"].union(ctx["push6"])
# Generate StrongSwan config
click.echo("Generating StrongSwan configuration files...")
slot += 1
ctx["slot4"] = list(const.CLIENT_SUBNET4.subnets(d))[slot]
ctx["slot6"] = list(const.CLIENT_SUBNET6.subnets(d))[slot] if const.CLIENT_SUBNET6 else []
with open("/server-secrets/ipsec.conf", "w") as fh:
fh.write(env.get_template("ipsec.conf").render(ctx))
# Why do you do this StrongSwan?! You will parse the cert anyway,
# why do I need to distinguish ECDSA vs RSA in config?!
with open(const.SELF_CERT_PATH, "rb") as fh:
certificate_buf = fh.read()
header, _, certificate_der_bytes = pem.unarmor(certificate_buf)
certificate = x509.Certificate.load(certificate_der_bytes)
public_key = asymmetric.load_public_key(certificate["tbs_certificate"]["subject_public_key_info"])
with open("/server-secrets/ipsec.secrets", "w") as fh:
fh.write(": %s %s\n" % (
"ECDSA" if public_key.algorithm == "ec" else "RSA",
const.SELF_KEY_PATH
))
# TODO: use this task to send notification emails maybe?
click.echo("Finished starting up")
sleep(86400)
@click.command("backend", help="Serve main backend")
@waitfile(const.SELF_CERT_PATH)
def pinecone_serve_backend():
from pinecrypt.server.tokens import TokenManager
from pinecrypt.server.api.signed import SignedCertificateDetailResource
from pinecrypt.server.api.request import RequestListResource, RequestDetailResource
from pinecrypt.server.api.script import ScriptResource
from pinecrypt.server.api.tag import TagResource, TagDetailResource
from pinecrypt.server.api.bootstrap import BootstrapResource
from pinecrypt.server.api.token import TokenResource
from pinecrypt.server.api.session import SessionResource, CertificateAuthorityResource
from pinecrypt.server.api.revoked import RevokedCertificateDetailResource
from pinecrypt.server.api.log import LogResource
from pinecrypt.server.api.revoked import RevocationListResource
app = falcon.App(middleware=NormalizeMiddleware())
app.req_options.strip_url_path_trailing_slash = True
app.req_options.auto_parse_form_urlencoded = True
app.add_route("/metrics", PrometheusEndpoint())
# CN to Id api call
app.add_route("/api/signed/{cn}", SignedCertificateDetailResource(), suffix="cn")
app.add_route("/api/signed/{cn}/tag", TagResource(), suffix="cn")
# Certificate authority API calls
app.add_route("/api/certificate", CertificateAuthorityResource())
app.add_route("/api/signed/id/{id}", SignedCertificateDetailResource())
app.add_route("/api/request/id/{id}", RequestDetailResource())
app.add_route("/api/request", RequestListResource())
app.add_route("/api/revoked/{serial_number}", RevokedCertificateDetailResource())
app.add_route("/api/log", LogResource())
app.add_route("/api/revoked", RevocationListResource())
token_resource = None
token_manager = None
if const.USER_ENROLLMENT_ALLOWED: # TODO: add token enable/disable flag for config
token_manager = TokenManager()
token_resource = TokenResource(token_manager)
app.add_route("/api/token", token_resource)
app.add_route("/api/session", SessionResource(token_manager))
# Extended attributes for scripting etc.
app.add_route("/api/signed/id/{id}/script", ScriptResource())
# API calls used by pushed events on the JS end
app.add_route("/api/signed/id/{id}/tag", TagResource())
# API call used to delete existing tags
app.add_route("/api/signed/id/{id}/tag/{tag}", TagDetailResource())
# Bootstrap resource
app.add_route("/api/bootstrap", BootstrapResource())
signal.signal(signal.SIGTERM, graceful_exit)
with make_server("127.0.0.1", 4001, app) as httpd:
httpd.serve_forever()
@click.command("test", help="Test mailer")
@click.argument("recipient")
def pinecone_test(recipient):
from pinecrypt.server import mailer
mailer.send("test.md", to=recipient)
@click.command("list", help="List tokens")
def pinecone_token_list():
from pinecrypt.server.tokens import TokenManager
token_manager = TokenManager(const.TOKEN_DATABASE)
cols = "uuid", "expires", "subject", "state"
now = datetime.utcnow().replace(tzinfo=pytz.UTC)
for token in token_manager.list(expired=True, used=True):
token["state"] = "used" if token.get("used") else ("valid" if token.get("expires") > now else "expired")
print(";".join([str(token.get(col)) for col in cols]))
@click.command("purge", help="Purge tokens")
@click.option("-a", "--all", default=False, is_flag=True, help="Purge all not only expired tokens")
def pinecone_token_purge(all):
from pinecrypt.server.tokens import TokenManager
token_manager = TokenManager(const.TOKEN_DATABASE)
print(token_manager.purge(all))
@click.command("issue", help="Issue token")
@click.option("-m", "--subject-mail", default=None, help="Subject e-mail override")
@click.argument("subject")
def pinecone_token_issue(subject, subject_mail):
from pinecrypt.server.tokens import TokenManager
from pinecrypt.server.user import User
token_manager = TokenManager(const.TOKEN_DATABASE)
token_manager.issue(None, User.objects.get(subject), subject_mail)
@click.group("housekeeping", help="Housekeeping tasks")
def pinecone_housekeeping(): pass
@click.group("token", help="Token management")
def pinecone_token(): pass
@click.group("serve", help="Entrypoints")
def pinecone_serve(): pass
@click.group("session", help="Session management")
def pinecone_session(): pass
@click.group()
def entry_point(): pass
pinecone_serve.add_command(pinecone_serve_backend)
pinecone_serve.add_command(pinecone_serve_ocsp_responder)
pinecone_serve.add_command(pinecone_serve_events)
pinecone_serve.add_command(pinecone_serve_builder)
pinecone_session.add_command(pinecone_session_list)
pinecone_token.add_command(pinecone_token_list)
pinecone_token.add_command(pinecone_token_purge)
pinecone_token.add_command(pinecone_token_issue)
pinecone_housekeeping.add_command(pinecone_housekeeping_kinit)
pinecone_housekeeping.add_command(pinecone_housekeeping_expiration)
entry_point.add_command(pinecone_token)
entry_point.add_command(pinecone_serve)
entry_point.add_command(pinecone_sign)
entry_point.add_command(pinecone_revoke)
entry_point.add_command(pinecone_list)
entry_point.add_command(pinecone_housekeeping)
entry_point.add_command(pinecone_users)
entry_point.add_command(pinecone_test)
entry_point.add_command(pinecone_log)
entry_point.add_command(pinecone_provision)
entry_point.add_command(pinecone_session)
if __name__ == "__main__":
entry_point()