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:
		| @@ -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