578 lines
24 KiB
Python
578 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("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 tun0 -j INBOUND_CLIENT -m comment --comment \"Inbound traffic from OpenVPN UDP clients\""
|
|
yield "-A FORWARD -i tun1 -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]"
|
|
if not const.DISABLE_MASQUERADE:
|
|
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_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()
|