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

View File

@ -15,6 +15,7 @@ from cryptography.hazmat.primitives import hashes, serialization
from certidude import config, push, mailer, const
from certidude import errors
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]))?$"
@ -299,6 +300,9 @@ def _sign(csr, buf, overwrite=False):
cert_path = os.path.join(config.SIGNED_DIR, common_name.value + ".pem")
renew = False
signed_path = os.path.join(config.SIGNED_DIR, "%s.pem" % common_name.value)
revoked_path = None
# Move existing certificate if necessary
if os.path.exists(cert_path):
with open(cert_path) as fh:
@ -310,7 +314,6 @@ def _sign(csr, buf, overwrite=False):
if overwrite:
if renew:
# 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)
os.rename(signed_path, revoked_path)
else:
@ -325,6 +328,13 @@ def _sign(csr, buf, overwrite=False):
fh.write(cert_buf)
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
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_SUBSCRIBE = cp.get("push", "long poll subscribe")
TAGGING_BACKEND = cp.get("tagging", "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:
USERS_WHITELIST = set([j for j in cp.get("authorization", "users whitelist").split(" ") if j])
@ -93,4 +88,6 @@ elif "ldap" == AUTHORIZATION_BACKEND:
else:
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

View File

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

View File

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

View File

@ -11,6 +11,8 @@ def publish(event_type, event_data):
"""
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:
# Push server disabled
return

View File

@ -24,7 +24,7 @@ class RelationalMixin(object):
if self.uri.scheme == "sqlite":
cur.executescript(fh.read())
else:
cur.execute(fh.read())
cur.execute(fh.read(), multi=True)
conn.commit()
cur.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-signed" data-section="signed" style="display:none;">Signed</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>
</ul>
</nav>

View File

@ -1,45 +1,54 @@
jQuery.timeago.settings.allowFuture = true;
function onTagClicked() {
var value = $(this).html();
function normalizeCommonName(j) {
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);
$(event.target).addClass("busy");
if (updated == "") {
$(this).addClass("busy");
$.ajax({
method: "DELETE",
url: "/api/tag/" + $(this).attr("data-id")
url: "/api/signed/" + cn + "/tag/" + key + "/"
});
} else if (updated && updated != value) {
$.ajax({
method: "PUT",
url: "/api/tag/" + $(this).attr("data-id"),
dataType: "json",
data: {
key: $(this).attr("data-key"),
value: updated
}
});
setTag(cn, key, updated, menu);
}
}
function onNewTagClicked() {
var cn = $(event.target).attr("data-cn");
var key = $(event.target).val();
$(event.target).val("");
function onNewTagClicked(event) {
var menu = event.target;
var cn = $(menu).attr("data-cn");
var key = $(menu).val();
$(menu).val("");
var value = prompt("Enter new " + key + " tag for " + cn);
if (!value) return;
if (value.length == 0) return;
$.ajax({
method: "POST",
url: "/api/tag/",
dataType: "json",
data: {
cn: cn,
value: value,
key: key
}
});
$(menu).addClass("busy");
setTag(cn, key, value, event.target);
}
function onTagFilterChanged() {
@ -68,47 +77,48 @@ function onRequestSubmitted(e) {
url: "/api/request/" + e.data + "/",
dataType: "json",
success: function(request, status, xhr) {
console.info("Going to prepend:", request);
onRequestDeleted(e); // Delete any existing ones just in case
$("#pending_requests").prepend(
nunjucks.render('views/request.html', { request: request }));
$("#pending_requests time").timeago();
},
error: function(response) {
console.info("Failed to retrieve certificate:", response);
}
});
}
function onRequestDeleted(e) {
console.log("Removing deleted request", e.data);
$("#request-" + e.data.replace("@", "--").replace(".", "-")).remove();
$("#request-" + normalizeCommonName(e.data)).remove();
}
function onClientUp(e) {
console.log("Adding security association:", e.data);
var lease = JSON.parse(e.data);
var $status = $("#signed_certificates [data-dn='" + lease.identity + "'] .status");
$status.html(nunjucks.render('views/status.html', {
lease: {
address: lease.address,
identity: lease.identity,
acquired: new Date(),
released: null
}}));
}
function onLeaseUpdate(e) {
console.log("Lease updated:", e.data);
$.ajax({
method: "GET",
url: "/api/signed/" + e.data + "/lease/",
dataType: "json",
success: function(lease, status, xhr) {
console.info("Retrieved lease update details:", lease);
lease.age = (new Date() - new Date(lease.last_seen)) / 1000.0
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);
var lease = JSON.parse(e.data);
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()
}}));
},
error: function(response) {
console.info("Failed to retrieve certificate:", response);
}
});
}
function onRequestSigned(e) {
console.log("Request signed:", e.data);
var slug = e.data.replace("@", "--").replace(".", "-");
var slug = normalizeCommonName(e.data);
console.log("Removing:", slug);
$("#request-" + slug).slideUp("normal", function() { $(this).remove(); });
@ -122,6 +132,7 @@ function onRequestSigned(e) {
console.info("Retrieved certificate:", certificate);
$("#signed_certificates").prepend(
nunjucks.render('views/signed.html', { certificate: certificate }));
$("#signed_certificates time").timeago(); // TODO: optimize?
},
error: function(response) {
console.info("Failed to retrieve certificate:", response);
@ -129,42 +140,25 @@ function onRequestSigned(e) {
});
}
function onCertificateRevoked(e) {
console.log("Removing revoked certificate", e.data);
$("#certificate-" + e.data.replace("@", "--").replace(".", "-")).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();
$("#certificate-" + normalizeCommonName(e.data)).slideUp("normal", function() { $(this).remove(); });
}
function onTagUpdated(e) {
console.log("Tag updated", e.data);
var cn = e.data;
console.log("Tag updated", cn);
$.ajax({
method: "GET",
url: "/api/tag/" + e.data + "/",
url: "/api/signed/" + cn + "/tag/",
dataType: "json",
success:function(tag, status, xhr) {
console.info("Updated tag", tag);
$("#tag_" + tag.id).html(tag.value);
success:function(tags, status, xhr) {
console.info("Updated", cn, "tags", tags);
$(".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("up-client", onClientUp);
source.addEventListener("down-client", onClientDown);
source.addEventListener("lease-update", onLeaseUpdate);
source.addEventListener("request-deleted", onRequestDeleted);
source.addEventListener("request-submitted", onRequestSubmitted);
source.addEventListener("request-signed", onRequestSigned);
source.addEventListener("certificate-revoked", onCertificateRevoked);
source.addEventListener("tag-added", onTagAdded);
source.addEventListener("tag-removed", onTagRemoved);
source.addEventListener("tag-updated", onTagUpdated);
source.addEventListener("tag-update", onTagUpdated);
console.info("Swtiching to requests section");
$("section").hide();
@ -258,41 +249,6 @@ $(document).ready(function() {
});
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) {
$("#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
*/

View File

@ -46,12 +46,17 @@
{% if session.features.tagging %}
<div class="tags">
<select class="icon tag" data-cn="{{ certificate.common_name }}" onChange="onNewTagClicked();">
<option value="">Add tag...</option>
{% include 'views/tagtypes.html' %}
<span data-cn="{{ certificate.common_name }}">
{% include 'views/tags.html' %}
</span>
<select class="icon tag" data-cn="{{ certificate.common_name }}" onChange="onNewTagClicked(event);">
<option value="">Add tag...</option>
{% include 'views/tagtypes.html' %}
</select>
</div>
{% endif %}
<div class="status"></div>
<div class="status">
{% include 'views/status.html' %}
</div>
</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>
{% if lease %}
{% if lease.released %}
Last seen {{ lease.released }} at {{ lease.address }}
{% else %}
Online since {{ lease.acquired }} at <a target="{{ lease.address }}" href="http://{{ lease.address }}">{{ lease.address }}</a>
{% endif %}
{% else %}
Not seen
{% endif %}
{% if certificate.lease %}
<svg height="32" width="32">
<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 %}"/>
</svg>
{% if certificate.lease.age > session.authority.lease.offline %}
Last seen <time class="timeago" datetime="{{ certificate.lease.last_seen }}">{{ certificate.lease.last_seen }}</time>
at {{ certificate.lease.address }}
{% else %}
Online since <time class="timeago" datetime="{{ certificate.lease.last_seen }}">{{ certificate.lease.last_seen }}</time> at
<a target="{{ certificate.lease.address }}" href="http://{{ certificate.lease.address }}">{{ certificate.lease.address }}</a>
{% endif %}
{% endif %}
</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()"
title="{{ tag.key }}={{ tag.value }}" class="{{ tag.key | replace('.', ' ') }}"
data-id="{{ tag.id }}" data-key="{{ tag.key }}">{{ tag.value }}</span>
{% for key, value in certificate.tags %}
<span onclick="onTagClicked(event);"
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
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]
# Server certificate is granted to certificate with
# common name that includes period which translates to FQDN of the machine.