1
0
mirror of https://github.com/laurivosandi/certidude synced 2025-10-31 01:19:11 +00:00

Move leases and tagging backend to filesystem extended attributes

This commit is contained in:
2017-03-26 00:10:09 +00:00
parent 79aa1e18c0
commit 1813056fc7
25 changed files with 279 additions and 497 deletions

View File

@@ -6,6 +6,7 @@ import logging
import os
import click
import hashlib
import xattr
from datetime import datetime
from time import sleep
from certidude import authority, mailer
@@ -58,6 +59,15 @@ class SessionResource(object):
def serialize_certificates(g):
for common_name, path, buf, obj, server in g():
try:
last_seen = datetime.strptime(xattr.getxattr(path, "user.last_seen"), "%Y-%m-%dT%H:%M:%S.%fZ")
lease = dict(
address = xattr.getxattr(path, "user.address"),
last_seen = last_seen,
age = datetime.utcnow() - last_seen
)
except IOError: # No such attribute(s)
lease = None
yield dict(
serial_number = "%x" % obj.serial_number,
common_name = common_name,
@@ -65,7 +75,12 @@ class SessionResource(object):
# TODO: key type, key length, key exponent, key modulo
signed = obj.not_valid_before,
expires = obj.not_valid_after,
sha256sum = hashlib.sha256(buf).hexdigest()
sha256sum = hashlib.sha256(buf).hexdigest(),
lease = lease,
tags = dict([
(j[9:], xattr.getxattr(path, j).decode("utf-8"))
for j in xattr.listxattr(path)
if j.startswith("user.tag.")])
)
if req.context.get("user").is_admin():
@@ -81,6 +96,10 @@ class SessionResource(object):
),
request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED,
authority = dict(
lease = dict(
offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option
dead = 604800 # Seconds from last activity to consider lease dead, X509 chain broken or machine discarded
),
common_name = authority.ca_cert.subject.get_attributes_for_oid(
NameOID.COMMON_NAME)[0].value,
outbox = dict(
@@ -108,8 +127,8 @@ class SessionResource(object):
)
) if req.context.get("user").is_admin() else None,
features=dict(
tagging=config.TAGGING_BACKEND,
leases=config.LEASES_BACKEND,
tagging=True,
leases=True,
logging=config.LOGGING_BACKEND))
@@ -155,12 +174,13 @@ def certidude_app():
from .revoked import RevocationListResource
from .signed import SignedCertificateDetailResource
from .request import RequestListResource, RequestDetailResource
from .lease import LeaseResource, StatusFileLeaseResource
from .whois import WhoisResource
from .tag import TagResource, TagDetailResource
from .lease import LeaseResource, LeaseDetailResource
from .cfg import ConfigResource, ScriptResource
from .tag import TagResource, TagDetailResource
from .attrib import AttributeResource
app = falcon.API(middleware=NormalizeMiddleware())
app.req_options.auto_parse_form_urlencoded = True
# Certificate authority API calls
app.add_route("/api/ocsp/", CertificateStatusResource())
@@ -171,25 +191,21 @@ def certidude_app():
app.add_route("/api/request/", RequestListResource())
app.add_route("/api/", SessionResource())
# Gateway API calls, should this be moved to separate project?
if config.LEASES_BACKEND == "openvpn-status":
app.add_route("/api/lease/", StatusFileLeaseResource(config.OPENVPN_STATUS_URI))
elif config.LEASES_BACKEND == "sql":
app.add_route("/api/lease/", LeaseResource())
app.add_route("/api/whois/", WhoisResource())
# Extended attributes for scripting etc.
app.add_route("/api/signed/{cn}/attr/", AttributeResource())
# API calls used by pushed events on the JS end
app.add_route("/api/signed/{cn}/tag/", TagResource())
app.add_route("/api/signed/{cn}/lease/", LeaseDetailResource())
# API call used to delete existing tags
app.add_route("/api/signed/{cn}/tag/{key}/", TagDetailResource())
# Gateways can submit leases via this API call
app.add_route("/api/lease/", LeaseResource())
# Optional user enrollment API call
if config.USER_ENROLLMENT_ALLOWED:
app.add_route("/api/bundle/", BundleResource())
if config.TAGGING_BACKEND == "sql":
uri = config.cp.get("tagging", "database")
app.add_route("/api/tag/", TagResource(uri))
app.add_route("/api/tag/{identifier}/", TagDetailResource(uri))
app.add_route("/api/config/", ConfigResource(uri))
app.add_route("/api/script/", ScriptResource(uri))
elif config.TAGGING_BACKEND:
raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND)
return app

46
certidude/api/attrib.py Normal file
View File

@@ -0,0 +1,46 @@
import falcon
import logging
import ipaddress
from xattr import getxattr, listxattr
from datetime import datetime
from certidude import config, authority
from certidude.decorators import serialize
logger = logging.getLogger("api")
class AttributeResource(object):
@serialize
def on_get(self, req, resp, cn):
"""
Return extended attributes stored on the server.
This not only contains tags and lease information,
but might also contain some other sensitive information.
"""
path, buf, cert = authority.get_signed(cn)
attribs = dict()
for key in listxattr(path):
if not key.startswith("user."):
continue
value = getxattr(path, key)
current = attribs
if "." in key:
namespace, key = key.rsplit(".", 1)
for component in namespace.split("."):
if component not in current:
current[component] = dict()
current = current[component]
current[key] = value
whitelist = attribs.get("user").get("address")
if req.context.get("remote_addr") != whitelist:
logger.info("Attribute access denied from %s, expected %s for %s",
req.context.get("remote_addr"),
whitelist,
cn)
raise falcon.HTTPForbidden("Forbidden",
"Attributes only accessible to the machine")
return attribs

View File

@@ -1,96 +1,39 @@
import click
import xattr
from datetime import datetime
from dateutil import tz
from pyasn1.codec.der import decoder
from certidude import config
from certidude import config, authority, push
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize
localtime = tz.tzlocal()
OIDS = {
(2, 5, 4, 3) : 'CN', # common name
(2, 5, 4, 6) : 'C', # country
(2, 5, 4, 7) : 'L', # locality
(2, 5, 4, 8) : 'ST', # stateOrProvince
(2, 5, 4, 10) : 'O', # organization
(2, 5, 4, 11) : 'OU', # organizationalUnit
}
def parse_dn(data):
chunks, remainder = decoder.decode(data)
dn = ""
if remainder:
raise ValueError()
# TODO: Check for duplicate entries?
def generate():
for chunk in chunks:
for chunkette in chunk:
key, value = chunkette
yield str(OIDS[key] + "=" + value)
return ", ".join(generate())
class StatusFileLeaseResource(object):
def __init__(self, uri):
self.uri = uri
# TODO: lease namespacing (?)
class LeaseDetailResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
from openvpn_status import parse_status
from urllib import urlopen
fh = urlopen(self.uri)
# openvpn-status.log has no information about timezone
# and dates marked there use local time instead of UTC
status = parse_status(fh.read())
for cn, e in status.routing_table.items():
yield {
"acquired": status.client_list[cn].connected_since.replace(tzinfo=localtime),
"released": None,
"address": e.virtual_address,
"identity": "CN=%s" % cn, # BUGBUG
}
def on_get(self, req, resp, cn):
path, buf, cert = authority.get_signed(cn)
return dict(
last_seen = xattr.getxattr(path, "user.last_seen"),
address = xattr.getxattr(path, "user.address").decode("ascii")
)
class LeaseResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
from ipaddress import ip_address
def on_post(self, req, resp):
# TODO: verify signature
common_name = req.get_param("client", required=True)
path, buf, cert = authority.get_signed(common_name) # TODO: catch exceptions
if cert.serial != req.get_param_as_int("serial", required=True): # Badum we have OCSP!
raise # TODO proper exception
if req.get_param("action") == "client-connect":
xattr.setxattr(path, "user.address", req.get_param("address", required=True).encode("ascii"))
xattr.setxattr(path, "user.last_seen", datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z")
push.publish("lease-update", common_name)
# BUGBUG
SQL_LEASES = """
select
acquired,
released,
address,
identities.data as identity
from
addresses
right join
identities
on
identities.id = addresses.identity
where
addresses.released <> 1
order by
addresses.id
desc
"""
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor()
cursor.execute(SQL_LEASES)
for acquired, released, address, identity in cursor:
yield {
"acquired": datetime.utcfromtimestamp(acquired),
"released": datetime.utcfromtimestamp(released) if released else None,
"address": ip_address(bytes(address)),
"identity": parse_dn(bytes(identity))
}
cursor.close()
conn.close()
# client-disconnect is pretty much unusable:
# - Android Connect Client results "IP packet with unknown IP version=2" on gateway
# - NetworkManager just kills OpenVPN client, disconnect is never reported
# - Disconnect is also not reported when uplink connection dies or laptop goes to sleep

View File

@@ -3,6 +3,7 @@ import click
import falcon
import logging
import ipaddress
import json
import os
import hashlib
from base64 import b64decode
@@ -154,10 +155,33 @@ class RequestDetailResource(object):
Fetch certificate signing request as PEM
"""
resp.set_header("Content-Type", "application/pkcs10")
_, resp.body, _ = authority.get_request(cn)
_, buf, _ = authority.get_request(cn)
logger.debug(u"Signing request %s was downloaded by %s",
cn, req.context.get("remote_addr"))
preferred_type = req.client_prefers(("application/json", "application/x-pem-file"))
if preferred_type == "application/x-pem-file":
# For certidude client, curl scripts etc
resp.set_header("Content-Type", "application/x-pem-file")
resp.set_header("Content-Disposition", ("attachment; filename=%s.pem" % cn))
resp.body = buf
elif preferred_type == "application/json":
# For web interface events
resp.set_header("Content-Type", "application/json")
resp.set_header("Content-Disposition", ("attachment; filename=%s.json" % cn))
resp.body = json.dumps(dict(
common_name = cn,
server = authority.server_flags(cn),
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()))
else:
raise falcon.HTTPUnsupportedMediaType(
"Client did not accept application/json or application/x-pem-file")
@csrf_protection
@login_required
@authorize_admin

View File

@@ -1,76 +1,42 @@
import falcon
import logging
from certidude.relational import RelationalMixin
import xattr
from certidude import authority
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize, csrf_protection
logger = logging.getLogger("api")
class TagResource(RelationalMixin):
SQL_CREATE_TABLES = "tag_tables.sql"
class TagResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return self.iterfetch("select * from tag")
def on_get(self, req, resp, cn):
path, buf, cert = authority.get_signed(cn)
return dict([
(k[9:], xattr.getxattr(path, k))
for k in xattr.listxattr(path)
if k.startswith("user.tag.")])
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_post(self, req, resp):
def on_post(self, req, resp, cn):
from certidude import push
args = req.get_param("cn"), req.get_param("key"), req.get_param("value")
rowid = self.sql_execute("tag_insert.sql", *args)
push.publish("tag-added", str(rowid))
logger.debug(u"Tag cn=%s, key=%s, value=%s added" % args)
class TagDetailResource(RelationalMixin):
SQL_CREATE_TABLES = "tag_tables.sql"
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp, identifier):
conn = self.sql_connect()
cursor = conn.cursor()
if self.uri.scheme == "mysql":
cursor.execute("select `cn`, `key`, `value` from tag where id = %s", (identifier,))
else:
cursor.execute("select `cn`, `key`, `value` from tag where id = ?", (identifier,))
cols = [j[0] for j in cursor.description]
for row in cursor:
cursor.close()
conn.close()
return dict(zip(cols, row))
cursor.close()
conn.close()
raise falcon.HTTPNotFound()
path, buf, cert = authority.get_signed(cn)
key, value = req.get_param("key", required=True), req.get_param("value", required=True)
xattr.setxattr(path, "user.tag.%s" % key, value.encode("utf-8"))
logger.debug(u"Tag %s=%s set for %s" % (key, value, cn))
push.publish("tag-update", cn)
class TagDetailResource(object):
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_put(self, req, resp, identifier):
def on_delete(self, req, resp, cn, key):
from certidude import push
args = req.get_param("value"), identifier
self.sql_execute("tag_update.sql", *args)
logger.debug(u"Tag %s updated, value set to %s",
identifier, req.get_param("value"))
push.publish("tag-updated", identifier)
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_delete(self, req, resp, identifier):
from certidude import push
self.sql_execute("tag_delete.sql", identifier)
push.publish("tag-removed", identifier)
logger.debug(u"Tag %s removed" % identifier)
path, buf, cert = authority.get_signed(cn)
xattr.removexattr(path, "user.tag.%s" % key)
logger.debug(u"Tag %s removed for %s" % (key, cn))
push.publish("tag-update", cn)

View File

@@ -1,58 +0,0 @@
import falcon
import ipaddress
from datetime import datetime
from certidude import config
from certidude.decorators import serialize
from certidude.api.lease import parse_dn
def address_to_identity(conn, addr):
"""
Translate currently online client's IP-address to distinguished name
"""
SQL_LEASES = """
select
acquired,
released,
identities.data as identity
from
addresses
right join
identities
on
identities.id = addresses.identity
where
address = %s and
released is not null
"""
cursor = conn.cursor()
import struct
cursor.execute(SQL_LEASES, (struct.pack("!L", int(addr)),))
for acquired, released, identity in cursor:
cursor.close()
return addr, datetime.utcfromtimestamp(acquired), parse_dn(bytes(identity))
cursor.close()
return None
class WhoisResource(object):
@serialize
def on_get(self, req, resp):
conn = config.DATABASE_POOL.get_connection()
identity = address_to_identity(
conn,
req.context.get("remote_addr")
)
conn.close()
if identity:
return dict(address=identity[0], acquired=identity[1], identity=identity[2])
else:
resp.status = falcon.HTTP_403
resp.body = "Failed to look up node %s" % req.context.get("remote_addr")