Skip to content

Commit

Permalink
Implement node self-provisioning.
Browse files Browse the repository at this point in the history
  • Loading branch information
lhartung committed Dec 20, 2018
1 parent 54a5765 commit 22bc55b
Show file tree
Hide file tree
Showing 17 changed files with 192 additions and 75 deletions.
6 changes: 2 additions & 4 deletions paradrop/daemon/paradrop/backend/chute_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
import tarfile
import tempfile

import yaml

from autobahn.twisted.resource import WebSocketResource
from klein import Klein

Expand All @@ -21,6 +19,7 @@
from paradrop.core.chute.chute_storage import ChuteStorage
from paradrop.core.config import resource
from paradrop.core.container.chutecontainer import ChuteContainer
from paradrop.lib.utils import pdosq

from . import cors
from . import hostapd_control
Expand Down Expand Up @@ -88,8 +87,7 @@ def extract_tarred_chute(data):
if not os.path.isfile(configfile):
raise Exception("No paradrop.yaml file found in chute source")

with open(configfile, "r") as source:
paradrop_yaml = yaml.safe_load(source)
paradrop_yaml = pdosq.read_yaml_file(configfile, default={})

return (tempdir, paradrop_yaml)

Expand Down
6 changes: 4 additions & 2 deletions paradrop/daemon/paradrop/backend/config_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from paradrop.base.pdutils import timeint, str2json
from paradrop.core.config import hostconfig
from paradrop.core.agent.http import PDServerRequest
from paradrop.core.agent.provisioning import read_provisioning_result
from paradrop.core.agent.reporting import sendNodeIdentity, sendStateReport
from paradrop.core.agent.wamp_session import WampSession
from paradrop.confd import client as pdconf_client
Expand Down Expand Up @@ -264,7 +265,9 @@ def get_provision(self, request):
Get the provision status of the device.
"""
cors.config_cors(request)
result = dict()
request.setHeader('Content-Type', 'application/json')

result = read_provisioning_result()
result['routerId'] = nexus.core.info.pdid
result['pdserver'] = nexus.core.info.pdserver
result['wampRouter'] = nexus.core.info.wampRouter
Expand All @@ -273,7 +276,6 @@ def get_provision(self, request):
apitoken is not None)
result['httpConnected'] = nexus.core.jwt_valid
result['wampConnected'] = nexus.core.wamp_connected
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)

@routes.route('/settings', methods=['GET'])
Expand Down
16 changes: 4 additions & 12 deletions paradrop/daemon/paradrop/base/nexus.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from twisted.internet.defer import inlineCallbacks, returnValue

from . import output, settings
from paradrop.lib.utils import pdosq

# Global access. Assign this wherever you instantiate the Nexus object:
# nexus.core = MyNexusSubclass()
Expand Down Expand Up @@ -219,14 +220,14 @@ def resolveInfo(nexus, path):
if not os.path.isfile(path):
createDefaultInfo(path)

contents = loadYaml(path)
contents = pdosq.read_yaml_file(path)

# Sanity check contents of info and throw it out if bad
if not validateInfo(contents):
output.out.err('Saved configuration data invalid, destroying it.')
os.remove(path)
createDefaultInfo(path)
contents = loadYaml(path)
contents = pdosq.read_yaml_file(path, default={})
writeYaml(contents, path)

nexus.info.pdid = contents['pdid']
Expand Down Expand Up @@ -275,13 +276,4 @@ def writeYaml(contents, path):
# print 'Writing ' + str(contents) + ' to path ' + str(path)

with open(path, 'w') as f:
f.write(yaml.dump(contents, default_flow_style=False))


def loadYaml(path):
''' Return dict from YAML found at path '''
with open(path, 'r') as f:
contents = yaml.load(f.read())

# print 'Loaded ' + str(contents) + ' from path ' + str(path)
return contents
f.write(yaml.safe_dump(contents, default_flow_style=False))
94 changes: 94 additions & 0 deletions paradrop/daemon/paradrop/core/agent/provisioning.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Implement self-provisioning. This runs when the node starts up and detects
that it is not provisioned but is configured with a provision key.
It will attempt to contact the controller using the provision key. If
successful, the node will download some configuration and the assigned
node key.
"""
import os

import yaml

from paradrop.base import nexus, settings
from paradrop.core.agent.http import PDServerRequest
from paradrop.core.config import devices, zerotier
from paradrop.lib.utils import pdosq


def can_provision():
"""
Check if self-provisioning is possible.
We need to be able to read in the batch ID and key in order to provision
the node.
"""
conf = read_provisioning_conf()
for field in ["batch_id", "batch_key"]:
if conf.get(field, None) is None:
return False

return True


def provision_self(update_manager):
"""
Provision the node.
Returns a deferred.
"""
name = "node-{:x}".format(devices.get_hardware_serial())

conf = read_provisioning_conf()
batch_id = conf.get("batch_id", None)
batch_key = conf.get("batch_key", None)

def cbresponse(response):
router = response.data['router']
nexus.core.provision(router['_id'])
nexus.core.saveKey(router['password'], 'apitoken')
nexus.core.jwt_valid = True

batch = response.data['batch']
hostconfig_patch = batch.get("hostconfig_patch", [])
zerotier_networks = batch.get("zerotier_networks", [])
update_manager.add_provision_update(hostconfig_patch, zerotier_networks)

write_provisioning_result(response.data)

data = {
"name": name,
"key": batch_key,
"zerotier_address": zerotier.getAddress()
}

request = PDServerRequest('/api/batches/{}/provision'.format(batch_id))
d = request.post(**data)
d.addCallback(cbresponse)
return d


def read_provisioning_conf():
"""
Read provisioning information from the configuration file.
This file will exist on startup if the node is to be self-provisioned.
"""
path = os.path.join(settings.CONFIG_HOME_DIR, "provision.yaml")
return pdosq.read_yaml_file(path, default={})


def read_provisioning_result():
"""
Read provisioning result from the filesystem.
"""
path = os.path.join(settings.CONFIG_HOME_DIR, "provisioned.yaml")
return pdosq.read_yaml_file(path, default={})


def write_provisioning_result(result):
"""
Write provisioning result to a persistent file.
"""
path = os.path.join(settings.CONFIG_HOME_DIR, "provisioned.yaml")
with open(path, "w") as output:
output.write(yaml.safe_dump(result, default_flow_style=False))
11 changes: 8 additions & 3 deletions paradrop/daemon/paradrop/core/agent/wamp_session.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
'''
The WAMP session of the paradrop daemon
'''
import six

from twisted.internet.defer import inlineCallbacks
from autobahn.wamp import auth

Expand All @@ -12,6 +10,13 @@
from paradrop.base.cxbr import BaseSession


def ensure_unicode(s):
if hasattr(s, 'decode'):
return s.decode('ascii')
else:
return s


class WampSession(BaseSession):
# Make this a class variable because a new WampSession object is created
# whenever the WAMP connection resets, but we only call set_update_fetcher
Expand All @@ -28,7 +33,7 @@ def onConnect(self):
out.info('Starting WAMP-CRA authentication on realm "{}" as user "{}"...'\
.format(self.config.realm, nexus.core.info.pdid))
self.join(self.config.realm, [u'wampcra'],
six.u(nexus.core.info.pdid))
ensure_unicode(nexus.core.info.pdid))

def onChallenge(self, challenge):
if challenge.method == u"wampcra":
Expand Down
6 changes: 2 additions & 4 deletions paradrop/daemon/paradrop/core/config/files.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import os
import shutil

import yaml

from paradrop.base import settings
from paradrop.core.chute.builder import build_chute
from paradrop.core.container.downloader import downloader
from paradrop.lib.utils import pdosq


def download_chute_files(update):
Expand All @@ -31,8 +30,7 @@ def load_chute_configuration(update):
workdir = getattr(update, "workdir", None)
if workdir is not None:
conf_path = os.path.join(workdir, settings.CHUTE_CONFIG_FILE)
with open(conf_path, "r") as source:
config = yaml.safe_load(source)
config = pdosq.read_yaml_file(conf_path, default={})

# Look for additional build information from the update object
# and merge with the configuration file.
Expand Down
11 changes: 2 additions & 9 deletions paradrop/daemon/paradrop/core/config/hostconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import yaml

from paradrop.base import settings
from paradrop.lib.utils import datastruct
from paradrop.lib.utils import datastruct, pdosq

from . import devices as config_devices

Expand Down Expand Up @@ -93,14 +93,7 @@ def load(path=None):
if path is None:
path = settings.HOST_CONFIG_FILE

try:
with open(path, 'r') as source:
data = yaml.safe_load(source.read())
return data
except IOError:
pass

return None
return pdosq.read_yaml_file(path, default=None)


def generateHostConfig(devices):
Expand Down
13 changes: 13 additions & 0 deletions paradrop/daemon/paradrop/core/update/update_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ def add_update(self, **update):

return d

def add_provision_update(self, hostconfig_patch, zerotier_networks):
update = self._make_router_update("patchhostconfig")
update.patch = list(hostconfig_patch)

for network in zerotier_networks:
update.patch.append({
"op": "add",
"path": "/zerotier/networks/-",
"value": network
})

self.updateQueue.append(update)

def assign_change_id(self):
"""
Get a unique change ID for an update.
Expand Down
17 changes: 17 additions & 0 deletions paradrop/daemon/paradrop/lib/utils/pdosq.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import errno
import os

from paradrop.lib.utils.yaml import yaml


def makedirs(p):
"""
Expand All @@ -30,6 +32,21 @@ def makedirs(p):
return False


def read_yaml_file(path, default=None):
"""
Read the contents of a file and interpret as YAML.
default: return value if the file cannot be read.
"""
try:
with open(path, "r") as source:
return yaml.load(source)
except IOError:
return default
except OSError:
return default


def safe_remove(path):
"""
Remove a file or silently pass if the file does not exist.
Expand Down
38 changes: 20 additions & 18 deletions paradrop/daemon/paradrop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from paradrop.base.output import out
from paradrop.base import nexus, settings
from paradrop.lib.misc.procmon import ProcessMonitor
from paradrop.core.agent import provisioning
from paradrop.core.agent.reporting import sendNodeIdentity, sendStateReport
from paradrop.core.agent.wamp_session import WampSession
from paradrop.core.update.update_fetcher import UpdateFetcher
Expand All @@ -22,8 +23,9 @@


class Nexus(nexus.NexusBase):
def __init__(self, update_fetcher):
def __init__(self, update_fetcher, update_manager):
self.update_fetcher = update_fetcher
self.update_manager = update_manager
# Want to change logging functionality? See optional args on the base class and pass them here
super(Nexus, self).__init__(stealStdio=True, printToConsole=True)

Expand All @@ -33,23 +35,23 @@ def onStart(self):
super(Nexus, self).onStart()
# onStart is called when the reactor starts, not when the connection is made.
# Check for provisioning keys and attempt to connect
if not self.provisioned():
out.warn('Router has no keys or identity. Waiting to connect to to server.')
while not self.provisioned() and provisioning.can_provision():
yield provisioning.provision_self(self.update_manager)

try:
# Set up communication with pdserver.
# 1. Create a report of the current system state and send that.
# 2. Send the node public key.
# 3. Poll for a list of updates that should be applied.
# 4. Open WAMP session.
yield sendStateReport()
yield sendNodeIdentity()
yield self.update_fetcher.start_polling()
yield self.connect(WampSession)
except Exception:
out.warn('The router ID or password is invalid!')
else:
try:
# Set up communication with pdserver.
# 1. Create a report of the current system state and send that.
# 2. Send the node public key.
# 3. Poll for a list of updates that should be applied.
# 4. Open WAMP session.
yield sendStateReport()
yield sendNodeIdentity()
yield self.update_fetcher.start_polling()
yield self.connect(WampSession)
except Exception:
out.warn('The router ID or password is invalid!')
else:
out.info('WAMP session is ready!')
out.info('WAMP session is ready!')

def onStop(self):
if self.session is not None:
Expand Down Expand Up @@ -85,7 +87,7 @@ def main():
airshark_manager = AirsharkManager()

# Globally assign the nexus object so anyone else can access it.
nexus.core = Nexus(update_fetcher)
nexus.core = Nexus(update_fetcher, update_manager)
http_server = HttpServer(update_manager, update_fetcher, airshark_manager, args.portal)
setup_http_server(http_server, '0.0.0.0', settings.PORTAL_SERVER_PORT)
reactor.listenMulticast(1900, SsdpResponder(), listenMultiple=True)
Expand Down
6 changes: 0 additions & 6 deletions paradrop/localweb/www/app-0a0b70a09a06d51e9635.js

This file was deleted.

7 changes: 7 additions & 0 deletions paradrop/localweb/www/app-aa24dc18488c6d2b3c88.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion paradrop/localweb/www/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@

<body ng-app="paradropPortal" ng-strict-di>
<app></app>
<script type="text/javascript" src="vendor-0a0b70a09a06d51e9635.js"></script><script type="text/javascript" src="app-0a0b70a09a06d51e9635.js"></script></body>
<script type="text/javascript" src="vendor-aa24dc18488c6d2b3c88.js"></script><script type="text/javascript" src="app-aa24dc18488c6d2b3c88.js"></script></body>
</html>

0 comments on commit 22bc55b

Please sign in to comment.