Move leases and tagging backend to filesystem extended attributes

This commit is contained in:
Lauri Võsandi 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 os
import click import click
import hashlib import hashlib
import xattr
from datetime import datetime from datetime import datetime
from time import sleep from time import sleep
from certidude import authority, mailer from certidude import authority, mailer
@ -58,6 +59,15 @@ class SessionResource(object):
def serialize_certificates(g): def serialize_certificates(g):
for common_name, path, buf, obj, server in 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( yield dict(
serial_number = "%x" % obj.serial_number, serial_number = "%x" % obj.serial_number,
common_name = common_name, common_name = common_name,
@ -65,7 +75,12 @@ class SessionResource(object):
# TODO: key type, key length, key exponent, key modulo # TODO: key type, key length, key exponent, key modulo
signed = obj.not_valid_before, signed = obj.not_valid_before,
expires = obj.not_valid_after, 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(): if req.context.get("user").is_admin():
@ -81,6 +96,10 @@ class SessionResource(object):
), ),
request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED, request_submission_allowed = config.REQUEST_SUBMISSION_ALLOWED,
authority = dict( 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( common_name = authority.ca_cert.subject.get_attributes_for_oid(
NameOID.COMMON_NAME)[0].value, NameOID.COMMON_NAME)[0].value,
outbox = dict( outbox = dict(
@ -108,8 +127,8 @@ class SessionResource(object):
) )
) if req.context.get("user").is_admin() else None, ) if req.context.get("user").is_admin() else None,
features=dict( features=dict(
tagging=config.TAGGING_BACKEND, tagging=True,
leases=config.LEASES_BACKEND, leases=True,
logging=config.LOGGING_BACKEND)) logging=config.LOGGING_BACKEND))
@ -155,12 +174,13 @@ def certidude_app():
from .revoked import RevocationListResource from .revoked import RevocationListResource
from .signed import SignedCertificateDetailResource from .signed import SignedCertificateDetailResource
from .request import RequestListResource, RequestDetailResource from .request import RequestListResource, RequestDetailResource
from .lease import LeaseResource, StatusFileLeaseResource from .lease import LeaseResource, LeaseDetailResource
from .whois import WhoisResource
from .tag import TagResource, TagDetailResource
from .cfg import ConfigResource, ScriptResource from .cfg import ConfigResource, ScriptResource
from .tag import TagResource, TagDetailResource
from .attrib import AttributeResource
app = falcon.API(middleware=NormalizeMiddleware()) app = falcon.API(middleware=NormalizeMiddleware())
app.req_options.auto_parse_form_urlencoded = True
# Certificate authority API calls # Certificate authority API calls
app.add_route("/api/ocsp/", CertificateStatusResource()) app.add_route("/api/ocsp/", CertificateStatusResource())
@ -171,25 +191,21 @@ def certidude_app():
app.add_route("/api/request/", RequestListResource()) app.add_route("/api/request/", RequestListResource())
app.add_route("/api/", SessionResource()) app.add_route("/api/", SessionResource())
# Gateway API calls, should this be moved to separate project? # Extended attributes for scripting etc.
if config.LEASES_BACKEND == "openvpn-status": app.add_route("/api/signed/{cn}/attr/", AttributeResource())
app.add_route("/api/lease/", StatusFileLeaseResource(config.OPENVPN_STATUS_URI))
elif config.LEASES_BACKEND == "sql": # API calls used by pushed events on the JS end
app.add_route("/api/lease/", LeaseResource()) app.add_route("/api/signed/{cn}/tag/", TagResource())
app.add_route("/api/whois/", WhoisResource()) 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 # Optional user enrollment API call
if config.USER_ENROLLMENT_ALLOWED: if config.USER_ENROLLMENT_ALLOWED:
app.add_route("/api/bundle/", BundleResource()) 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 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 datetime import datetime
from dateutil import tz
from pyasn1.codec.der import decoder 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.auth import login_required, authorize_admin
from certidude.decorators import serialize from certidude.decorators import serialize
localtime = tz.tzlocal() # TODO: lease namespacing (?)
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
class LeaseDetailResource(object):
@serialize @serialize
@login_required @login_required
@authorize_admin @authorize_admin
def on_get(self, req, resp): def on_get(self, req, resp, cn):
from openvpn_status import parse_status path, buf, cert = authority.get_signed(cn)
from urllib import urlopen return dict(
fh = urlopen(self.uri) last_seen = xattr.getxattr(path, "user.last_seen"),
# openvpn-status.log has no information about timezone address = xattr.getxattr(path, "user.address").decode("ascii")
# 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
}
class LeaseResource(object): class LeaseResource(object):
@serialize def on_post(self, req, resp):
@login_required # TODO: verify signature
@authorize_admin common_name = req.get_param("client", required=True)
def on_get(self, req, resp): path, buf, cert = authority.get_signed(common_name) # TODO: catch exceptions
from ipaddress import ip_address 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 # client-disconnect is pretty much unusable:
SQL_LEASES = """ # - Android Connect Client results "IP packet with unknown IP version=2" on gateway
select # - NetworkManager just kills OpenVPN client, disconnect is never reported
acquired, # - Disconnect is also not reported when uplink connection dies or laptop goes to sleep
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()

View File

@ -3,6 +3,7 @@ import click
import falcon import falcon
import logging import logging
import ipaddress import ipaddress
import json
import os import os
import hashlib import hashlib
from base64 import b64decode from base64 import b64decode
@ -154,10 +155,33 @@ class RequestDetailResource(object):
Fetch certificate signing request as PEM Fetch certificate signing request as PEM
""" """
resp.set_header("Content-Type", "application/pkcs10") 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", logger.debug(u"Signing request %s was downloaded by %s",
cn, req.context.get("remote_addr")) 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 @csrf_protection
@login_required @login_required
@authorize_admin @authorize_admin

View File

@ -1,76 +1,42 @@
import falcon import falcon
import logging import logging
from certidude.relational import RelationalMixin import xattr
from certidude import authority
from certidude.auth import login_required, authorize_admin from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize, csrf_protection from certidude.decorators import serialize, csrf_protection
logger = logging.getLogger("api") logger = logging.getLogger("api")
class TagResource(RelationalMixin): class TagResource(object):
SQL_CREATE_TABLES = "tag_tables.sql"
@serialize @serialize
@login_required @login_required
@authorize_admin @authorize_admin
def on_get(self, req, resp): def on_get(self, req, resp, cn):
return self.iterfetch("select * from tag") 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 @csrf_protection
@serialize
@login_required @login_required
@authorize_admin @authorize_admin
def on_post(self, req, resp): def on_post(self, req, resp, cn):
from certidude import push from certidude import push
args = req.get_param("cn"), req.get_param("key"), req.get_param("value") path, buf, cert = authority.get_signed(cn)
rowid = self.sql_execute("tag_insert.sql", *args) key, value = req.get_param("key", required=True), req.get_param("value", required=True)
push.publish("tag-added", str(rowid)) xattr.setxattr(path, "user.tag.%s" % key, value.encode("utf-8"))
logger.debug(u"Tag cn=%s, key=%s, value=%s added" % args) logger.debug(u"Tag %s=%s set for %s" % (key, value, cn))
push.publish("tag-update", cn)
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()
class TagDetailResource(object):
@csrf_protection @csrf_protection
@serialize
@login_required @login_required
@authorize_admin @authorize_admin
def on_put(self, req, resp, identifier): def on_delete(self, req, resp, cn, key):
from certidude import push from certidude import push
args = req.get_param("value"), identifier path, buf, cert = authority.get_signed(cn)
self.sql_execute("tag_update.sql", *args) xattr.removexattr(path, "user.tag.%s" % key)
logger.debug(u"Tag %s updated, value set to %s", logger.debug(u"Tag %s removed for %s" % (key, cn))
identifier, req.get_param("value")) push.publish("tag-update", cn)
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)

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")

View File

@ -15,6 +15,7 @@ from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const from certidude import config, push, mailer, const
from certidude import errors from certidude import errors
from jinja2 import Template from jinja2 import Template
from xattr import getxattr, listxattr, setxattr
RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$" RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])(@(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]))?$"
@ -299,6 +300,9 @@ def _sign(csr, buf, overwrite=False):
cert_path = os.path.join(config.SIGNED_DIR, common_name.value + ".pem") cert_path = os.path.join(config.SIGNED_DIR, common_name.value + ".pem")
renew = False renew = False
signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value)
revoked_path = None
# Move existing certificate if necessary # Move existing certificate if necessary
if os.path.exists(cert_path): if os.path.exists(cert_path):
with open(cert_path) as fh: with open(cert_path) as fh:
@ -310,7 +314,6 @@ def _sign(csr, buf, overwrite=False):
if overwrite: if overwrite:
if renew: if renew:
# TODO: is this the best approach? # TODO: is this the best approach?
signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value)
revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % prev.serial_number) revoked_path = os.path.join(config.REVOKED_DIR, "%x.pem" % prev.serial_number)
os.rename(signed_path, revoked_path) os.rename(signed_path, revoked_path)
else: else:
@ -325,6 +328,13 @@ def _sign(csr, buf, overwrite=False):
fh.write(cert_buf) fh.write(cert_buf)
os.rename(cert_path + ".part", cert_path) os.rename(cert_path + ".part", cert_path)
# Copy filesystem attributes to newly signed certificate
if revoked_path:
for key in listxattr(revoked_path):
if not key.startswith("user."):
continue
setxattr(signed_path, key, getxattr(revoked_path, key))
# Send mail # Send mail
recipient = None recipient = None

View File

@ -72,12 +72,7 @@ EVENT_SOURCE_SUBSCRIBE = cp.get("push", "event source subscribe")
LONG_POLL_PUBLISH = cp.get("push", "long poll publish") LONG_POLL_PUBLISH = cp.get("push", "long poll publish")
LONG_POLL_SUBSCRIBE = cp.get("push", "long poll subscribe") LONG_POLL_SUBSCRIBE = cp.get("push", "long poll subscribe")
TAGGING_BACKEND = cp.get("tagging", "backend")
LOGGING_BACKEND = cp.get("logging", "backend") LOGGING_BACKEND = cp.get("logging", "backend")
LEASES_BACKEND = cp.get("leases", "backend")
OPENVPN_STATUS_URI = cp.get("leases", "openvpn status uri")
if "whitelist" == AUTHORIZATION_BACKEND: if "whitelist" == AUTHORIZATION_BACKEND:
USERS_WHITELIST = set([j for j in cp.get("authorization", "users whitelist").split(" ") if j]) USERS_WHITELIST = set([j for j in cp.get("authorization", "users whitelist").split(" ") if j])
@ -93,4 +88,6 @@ elif "ldap" == AUTHORIZATION_BACKEND:
else: else:
raise NotImplementedError("Unknown authorization backend '%s'" % AUTHORIZATION_BACKEND) raise NotImplementedError("Unknown authorization backend '%s'" % AUTHORIZATION_BACKEND)
TAG_TYPES = [j.split("/", 1) + [cp.get("tag types", j)] for j in cp.options("tag types")]
# TODO: Check if we don't have base or servers # TODO: Check if we don't have base or servers

View File

@ -3,7 +3,7 @@ import ipaddress
import json import json
import logging import logging
import types import types
from datetime import date, time, datetime from datetime import date, time, datetime, timedelta
from certidude.auth import User from certidude.auth import User
from urlparse import urlparse from urlparse import urlparse
@ -58,6 +58,8 @@ class MyEncoder(json.JSONEncoder):
return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
if isinstance(obj, date): if isinstance(obj, date):
return obj.strftime("%Y-%m-%d") return obj.strftime("%Y-%m-%d")
if isinstance(obj, timedelta):
return obj.total_seconds()
if isinstance(obj, types.GeneratorType): if isinstance(obj, types.GeneratorType):
return tuple(obj) return tuple(obj)
if isinstance(obj, User): if isinstance(obj, User):

View File

@ -2,7 +2,7 @@
import logging import logging
import time import time
from datetime import datetime from datetime import datetime
from certidude.api.tag import RelationalMixin from certidude.relational import RelationalMixin
class LogHandler(logging.Handler, RelationalMixin): class LogHandler(logging.Handler, RelationalMixin):
SQL_CREATE_TABLES = "log_tables.sql" SQL_CREATE_TABLES = "log_tables.sql"

View File

@ -11,6 +11,8 @@ def publish(event_type, event_data):
""" """
Publish event on nchan EventSource publisher Publish event on nchan EventSource publisher
""" """
assert event_type, "No event type specified"
assert event_data, "No event data specified"
if not config.EVENT_SOURCE_PUBLISH: if not config.EVENT_SOURCE_PUBLISH:
# Push server disabled # Push server disabled
return return

View File

@ -24,7 +24,7 @@ class RelationalMixin(object):
if self.uri.scheme == "sqlite": if self.uri.scheme == "sqlite":
cur.executescript(fh.read()) cur.executescript(fh.read())
else: else:
cur.execute(fh.read()) cur.execute(fh.read(), multi=True)
conn.commit() conn.commit()
cur.close() cur.close()
conn.close() conn.close()

View File

@ -1,9 +0,0 @@
insert into tag (
`cn`,
`key`,
`value`
) values (
%s,
%s,
%s
)

View File

@ -1,3 +0,0 @@
delete from tag
where id = ?
limit 1

View File

@ -1,9 +0,0 @@
insert into tag (
`cn`,
`key`,
`value`
) values (
?,
?,
?
);

View File

@ -1,16 +0,0 @@
select
device_tag.id as `id`,
tag.key as `key`,
tag.value as `value`,
device.cn as `cn`
from
device_tag
join
tag
on
device_tag.tag_id = tag.id
join
device
on
device_tag.device_id = device.id

View File

@ -1,36 +0,0 @@
create table if not exists `tag` (
`id` integer primary key,
`cn` varchar(255) not null,
`key` varchar(255) not null,
`value` varchar(255) not null
);
create table if not exists `tag_properties` (
`id` integer primary key,
`tag_key` varchar(255) not null,
`tag_value` varchar(255) not null,
`property_key` varchar(255) not null,
`property_value` varchar(255) not null
);
/*
create table if not exists `device_tag` (
`id` int(11) not null,
`device_id` varchar(45) not null,
`tag_id` varchar(45) not null,
`attached` timestamp null default current_timestamp,
primary key (`id`)
);
create table if not exists `device` (
`id` int(11) not null,
`created` timestamp not null default current_timestamp,
`cn` varchar(255) not null,
`product_model` varchar(50) not null,
`product_serial` varchar(50) default null,
`hardware_address` varchar(17) unique not null,
primary key (`id`)
);
*/

View File

@ -1,4 +0,0 @@
update `tag`
set `value` = ?
where `id` = ?
limit 1

View File

@ -19,7 +19,6 @@
<li id="section-requests" data-section="requests" style="display:none;">Requests</li> <li id="section-requests" data-section="requests" style="display:none;">Requests</li>
<li id="section-signed" data-section="signed" style="display:none;">Signed</li> <li id="section-signed" data-section="signed" style="display:none;">Signed</li>
<li id="section-revoked" data-section="revoked" style="display:none;">Revoked</li> <li id="section-revoked" data-section="revoked" style="display:none;">Revoked</li>
<li id="section-config" data-section="config" style="display:none;">Configuration</li>
<li id="section-log" data-section="log" style="display:none;">Log</li> <li id="section-log" data-section="log" style="display:none;">Log</li>
</ul> </ul>
</nav> </nav>

View File

@ -1,45 +1,54 @@
jQuery.timeago.settings.allowFuture = true; jQuery.timeago.settings.allowFuture = true;
function onTagClicked() { function normalizeCommonName(j) {
var value = $(this).html(); return j.replace("@", "--").split(".").join("-"); // dafuq ?!
}
function setTag(cn, key, value, indicator) {
$.ajax({
method: "POST",
url: "/api/signed/" + cn + "/tag/",
data: { value: value, key: key },
dataType: "text",
complete: function(xhr, status) {
console.info("Tag added successfully", xhr.status, status);
},
success: function() {
$(indicator).removeClass("busy");
},
error: function(xhr, status, e) {
console.info("Submitting request failed with:", status, e);
alert(e);
}
});
}
function onTagClicked(event) {
var cn = $(event.target).attr("data-cn");
var key = $(event.target).attr("data-key");
var value = $(event.target).html();
var updated = prompt("Enter new tag or clear to remove the tag", value); var updated = prompt("Enter new tag or clear to remove the tag", value);
$(event.target).addClass("busy");
if (updated == "") { if (updated == "") {
$(this).addClass("busy");
$.ajax({ $.ajax({
method: "DELETE", method: "DELETE",
url: "/api/tag/" + $(this).attr("data-id") url: "/api/signed/" + cn + "/tag/" + key + "/"
}); });
} else if (updated && updated != value) { } else if (updated && updated != value) {
$.ajax({ setTag(cn, key, updated, menu);
method: "PUT",
url: "/api/tag/" + $(this).attr("data-id"),
dataType: "json",
data: {
key: $(this).attr("data-key"),
value: updated
}
});
} }
} }
function onNewTagClicked() { function onNewTagClicked(event) {
var cn = $(event.target).attr("data-cn"); var menu = event.target;
var key = $(event.target).val(); var cn = $(menu).attr("data-cn");
$(event.target).val(""); var key = $(menu).val();
$(menu).val("");
var value = prompt("Enter new " + key + " tag for " + cn); var value = prompt("Enter new " + key + " tag for " + cn);
if (!value) return; if (!value) return;
if (value.length == 0) return; if (value.length == 0) return;
$.ajax({ $(menu).addClass("busy");
method: "POST", setTag(cn, key, value, event.target);
url: "/api/tag/",
dataType: "json",
data: {
cn: cn,
value: value,
key: key
}
});
} }
function onTagFilterChanged() { function onTagFilterChanged() {
@ -68,47 +77,48 @@ function onRequestSubmitted(e) {
url: "/api/request/" + e.data + "/", url: "/api/request/" + e.data + "/",
dataType: "json", dataType: "json",
success: function(request, status, xhr) { success: function(request, status, xhr) {
console.info("Going to prepend:", request);
onRequestDeleted(e); // Delete any existing ones just in case onRequestDeleted(e); // Delete any existing ones just in case
$("#pending_requests").prepend( $("#pending_requests").prepend(
nunjucks.render('views/request.html', { request: request })); nunjucks.render('views/request.html', { request: request }));
$("#pending_requests time").timeago();
},
error: function(response) {
console.info("Failed to retrieve certificate:", response);
} }
}); });
} }
function onRequestDeleted(e) { function onRequestDeleted(e) {
console.log("Removing deleted request", e.data); console.log("Removing deleted request", e.data);
$("#request-" + e.data.replace("@", "--").replace(".", "-")).remove(); $("#request-" + normalizeCommonName(e.data)).remove();
} }
function onClientUp(e) { function onLeaseUpdate(e) {
console.log("Adding security association:", e.data); console.log("Lease updated:", e.data);
var lease = JSON.parse(e.data); $.ajax({
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); method: "GET",
$status.html(nunjucks.render('views/status.html', { url: "/api/signed/" + e.data + "/lease/",
lease: { dataType: "json",
address: lease.address, success: function(lease, status, xhr) {
identity: lease.identity, console.info("Retrieved lease update details:", lease);
acquired: new Date(), lease.age = (new Date() - new Date(lease.last_seen)) / 1000.0
released: null var $status = $("#signed_certificates [data-cn='" + e.data + "'] .status");
}})); $status.html(nunjucks.render('views/status.html', {
} certificate: {
lease: lease }}));
$("time", $status).timeago();
function onClientDown(e) { },
console.log("Removing security association:", e.data); error: function(response) {
var lease = JSON.parse(e.data); console.info("Failed to retrieve certificate:", response);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status"); }
$status.html(nunjucks.render('views/status.html', { });
lease: {
address: lease.address,
identity: lease.identity,
acquired: null,
released: new Date()
}}));
} }
function onRequestSigned(e) { function onRequestSigned(e) {
console.log("Request signed:", e.data); console.log("Request signed:", e.data);
var slug = e.data.replace("@", "--").replace(".", "-"); var slug = normalizeCommonName(e.data);
console.log("Removing:", slug); console.log("Removing:", slug);
$("#request-" + slug).slideUp("normal", function() { $(this).remove(); }); $("#request-" + slug).slideUp("normal", function() { $(this).remove(); });
@ -122,6 +132,7 @@ function onRequestSigned(e) {
console.info("Retrieved certificate:", certificate); console.info("Retrieved certificate:", certificate);
$("#signed_certificates").prepend( $("#signed_certificates").prepend(
nunjucks.render('views/signed.html', { certificate: certificate })); nunjucks.render('views/signed.html', { certificate: certificate }));
$("#signed_certificates time").timeago(); // TODO: optimize?
}, },
error: function(response) { error: function(response) {
console.info("Failed to retrieve certificate:", response); console.info("Failed to retrieve certificate:", response);
@ -129,42 +140,25 @@ function onRequestSigned(e) {
}); });
} }
function onCertificateRevoked(e) { function onCertificateRevoked(e) {
console.log("Removing revoked certificate", e.data); console.log("Removing revoked certificate", e.data);
$("#certificate-" + e.data.replace("@", "--").replace(".", "-")).slideUp("normal", function() { $(this).remove(); }); $("#certificate-" + normalizeCommonName(e.data)).slideUp("normal", function() { $(this).remove(); });
}
function onTagAdded(e) {
console.log("Tag added", e.data);
$.ajax({
method: "GET",
url: "/api/tag/" + e.data + "/",
dataType: "json",
success: function(tag, status, xhr) {
// TODO: Deduplicate
$tag = $("<span id=\"tag_" + tag.id + "\" title=\"" + tag.key + "=" + tag.value + "\" class=\"" + tag.key.replace(/\./g, " ") + " icon tag\" data-id=\""+tag.id+"\" data-key=\"" + tag.key + "\">" + tag.value + "</span>");
$tags = $("#signed_certificates [data-cn='" + tag.cn + "'] .tags").prepend(" ");
$tags = $("#signed_certificates [data-cn='" + tag.cn + "'] .tags").prepend($tag);
$tag.click(onTagClicked);
}
})
}
function onTagRemoved(e) {
console.log("Tag removed", e.data);
$("#tag_" + e.data).remove();
} }
function onTagUpdated(e) { function onTagUpdated(e) {
console.log("Tag updated", e.data); var cn = e.data;
console.log("Tag updated", cn);
$.ajax({ $.ajax({
method: "GET", method: "GET",
url: "/api/tag/" + e.data + "/", url: "/api/signed/" + cn + "/tag/",
dataType: "json", dataType: "json",
success:function(tag, status, xhr) { success:function(tags, status, xhr) {
console.info("Updated tag", tag); console.info("Updated", cn, "tags", tags);
$("#tag_" + tag.id).html(tag.value); $(".tags span[data-cn='" + cn + "']").html(
nunjucks.render('views/tags.html', {
certificate: {
common_name: cn,
tags:tags }}));
} }
}) })
} }
@ -210,15 +204,12 @@ $(document).ready(function() {
} }
source.addEventListener("log-entry", onLogEntry); source.addEventListener("log-entry", onLogEntry);
source.addEventListener("up-client", onClientUp); source.addEventListener("lease-update", onLeaseUpdate);
source.addEventListener("down-client", onClientDown);
source.addEventListener("request-deleted", onRequestDeleted); source.addEventListener("request-deleted", onRequestDeleted);
source.addEventListener("request-submitted", onRequestSubmitted); source.addEventListener("request-submitted", onRequestSubmitted);
source.addEventListener("request-signed", onRequestSigned); source.addEventListener("request-signed", onRequestSigned);
source.addEventListener("certificate-revoked", onCertificateRevoked); source.addEventListener("certificate-revoked", onCertificateRevoked);
source.addEventListener("tag-added", onTagAdded); source.addEventListener("tag-update", onTagUpdated);
source.addEventListener("tag-removed", onTagRemoved);
source.addEventListener("tag-updated", onTagUpdated);
console.info("Swtiching to requests section"); console.info("Swtiching to requests section");
$("section").hide(); $("section").hide();
@ -258,41 +249,6 @@ $(document).ready(function() {
}); });
console.log("Features enabled:", session.features); console.log("Features enabled:", session.features);
if (session.features.tagging) {
console.info("Tagging enabled");
$("#section-config").show();
$.ajax({
method: "GET",
url: "/api/config/",
dataType: "json",
success: function(configuration, status, xhr) {
console.info("Appending", configuration.length, "configuration items");
$("#config").html(nunjucks.render('views/configuration.html', { configuration:configuration}));
/**
* Fetch tags for certificates
*/
$.ajax({
method: "GET",
url: "/api/tag/",
dataType: "json",
success:function(tags, status, xhr) {
console.info("Got", tags.length, "tags");
$("#config").html(nunjucks.render('views/configuration.html', { configuration:configuration}));
for (var j = 0; j < tags.length; j++) {
// TODO: Deduplicate
$tag = $("<span id=\"tag_" + tags[j].id + "\" title=\"" + tags[j].key + "=" + tags[j].value + "\" class=\"" + tags[j].key.replace(/\./g, " ") + " icon tag\" data-id=\""+tags[j].id+"\" data-key=\"" + tags[j].key + "\">" + tags[j].value + "</span>");
console.info("Inserting tag", tags[j], $tag);
$tags = $("#signed_certificates [data-cn='" + tags[j].cn + "'] .tags").prepend(" ");
$tags = $("#signed_certificates [data-cn='" + tags[j].cn + "'] .tags").prepend($tag);
$tag.click(onTagClicked);
$("#tags_autocomplete").prepend("<option value=\"" + tags[j].id + "\">" + tags[j].key + "='" + tags[j].value + "'</option>");
}
}
});
}
});
}
if (session.request_submission_allowed) { if (session.request_submission_allowed) {
$("#request_submit").click(function() { $("#request_submit").click(function() {
@ -320,36 +276,6 @@ $(document).ready(function() {
}); });
} }
/**
* Fetch leases associated with certificates
*/
if (session.features.leases) {
$.ajax({
method: "GET",
url: "/api/lease/",
dataType: "json",
success: function(leases, status, xhr) {
console.info("Got leases:", leases);
for (var j = 0; j < leases.length; j++) {
var $status = $("#signed_certificates [data-dn='" + leases[j].identity + "'] .status");
if (!$status.length) {
console.info("Detected rogue client:", leases[j]);
continue;
}
$status.html(nunjucks.render('views/status.html', {
lease: {
address: leases[j].address,
age: (new Date() - new Date(leases[j].released)) / 1000,
identity: leases[j].identity,
acquired: new Date(leases[j].acquired).toLocaleString(),
released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null
}}));
}
}
});
}
/** /**
* Fetch log entries * Fetch log entries
*/ */

View File

@ -46,12 +46,17 @@
{% if session.features.tagging %} {% if session.features.tagging %}
<div class="tags"> <div class="tags">
<select class="icon tag" data-cn="{{ certificate.common_name }}" onChange="onNewTagClicked();"> <span data-cn="{{ certificate.common_name }}">
<option value="">Add tag...</option> {% include 'views/tags.html' %}
{% include 'views/tagtypes.html' %} </span>
<select class="icon tag" data-cn="{{ certificate.common_name }}" onChange="onNewTagClicked(event);">
<option value="">Add tag...</option>
{% include 'views/tagtypes.html' %}
</select> </select>
</div> </div>
{% endif %} {% endif %}
<div class="status"></div> <div class="status">
{% include 'views/status.html' %}
</div>
</li> </li>

View File

@ -1,16 +1,14 @@
<svg height="32" width="32">
<circle cx="16" cy="16" r="13" stroke="black" stroke-width="3" fill="{% if lease %}{% if lease.released %}{% if lease.age > 1209600 %}#D6083B{% else %}#0072CF{% endif %}{%else %}#55A51C{% endif %}{% else %}#F3BD48{% endif %}" />
</svg>
<span> <span>
{% if certificate.lease %}
{% if lease %} <svg height="32" width="32">
{% if lease.released %} <circle cx="16" cy="16" r="13" stroke="black" stroke-width="3" fill="{% if certificate.lease %}{% if certificate.lease.age > session.authority.lease.offline %}#0072CF{% elif certificate.lease.age > session.authority.lease.dead %}#D6083B{%else %}#55A51C{% endif %}{% endif %}"/>
Last seen {{ lease.released }} at {{ lease.address }} </svg>
{% else %} {% if certificate.lease.age > session.authority.lease.offline %}
Online since {{ lease.acquired }} at <a target="{{ lease.address }}" href="http://{{ lease.address }}">{{ lease.address }}</a> Last seen <time class="timeago" datetime="{{ certificate.lease.last_seen }}">{{ certificate.lease.last_seen }}</time>
{% endif %} at {{ certificate.lease.address }}
{% else %} {% else %}
Not seen Online since <time class="timeago" datetime="{{ certificate.lease.last_seen }}">{{ certificate.lease.last_seen }}</time> at
{% endif %} <a target="{{ certificate.lease.address }}" href="http://{{ certificate.lease.address }}">{{ certificate.lease.address }}</a>
{% endif %}
{% endif %}
</span> </span>

Before

Width:  |  Height:  |  Size: 561 B

After

Width:  |  Height:  |  Size: 913 B

View File

@ -1,3 +1,6 @@
<span id="tag_{{ tag.id }}" onclick="onTagClicked()" {% for key, value in certificate.tags %}
title="{{ tag.key }}={{ tag.value }}" class="{{ tag.key | replace('.', ' ') }}" <span onclick="onTagClicked(event);"
data-id="{{ tag.id }}" data-key="{{ tag.key }}">{{ tag.value }}</span> title="{{ key }}={{ value }}" class="tag icon {{ key | replace('.', ' ') }}"
data-cn="{{ certificate.common_name }}"
data-key="{{ key }}">{{ value }}</span>
{% endfor %}

View File

@ -57,26 +57,6 @@ backend =
;backend = sql ;backend = sql
database = sqlite://{{ directory }}/db.sqlite database = sqlite://{{ directory }}/db.sqlite
[tagging]
backend =
;backend = sql
database = sqlite://{{ directory }}/db.sqlite
[leases]
backend =
;backend = sql
schema = strongswan
database = sqlite://{{ directory }}/db.sqlite
# Following was used on an OpenWrt router
# uci set openvpn.s2c.status=/www/status.log
# uci commit; touch /www/status.log; chmod 755 /www/status.log
;backend = openvpn-status
;openvpn status uri = /var/log/openvpn-status.log
openvpn status uri = http://router.example.com/status.log
[signature] [signature]
# Server certificate is granted to certificate with # Server certificate is granted to certificate with
# common name that includes period which translates to FQDN of the machine. # common name that includes period which translates to FQDN of the machine.