mirror of
https://github.com/laurivosandi/certidude
synced 2026-01-12 17:06:59 +00:00
Move leases and tagging backend to filesystem extended attributes
This commit is contained in:
@@ -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
46
certidude/api/attrib.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user