"""
Install and manage chutes on the host.
Endpoints for these functions can be found under /api/v1/chutes.
"""
import json
import os
import re
import shutil
import subprocess
import tarfile
import tempfile
import yaml
from autobahn.twisted.resource import WebSocketResource
from klein import Klein
from twisted.internet import reactor
from twisted.internet.defer import inlineCallbacks, returnValue
from paradrop.base import pdutils, settings
from paradrop.base.output import out
from paradrop.core.chute.chute_storage import ChuteStorage
from paradrop.core.config import resource
from paradrop.core.container.chutecontainer import ChuteContainer
from . import cors
from . import hostapd_control
[docs]class ChuteCacheEncoder(json.JSONEncoder):
"""
JSON encoder for chute cache dictionary.
The chute cache can contain arbitrary objects, some of which may not be
JSON-serializable. This encoder returns handles unserializable objects by
returning the `repr` string.
"""
[docs] def default(self, o):
try:
return json.JSONEncoder.default(self, o)
except TypeError as error:
return repr(o)
[docs]class UpdateEncoder(json.JSONEncoder):
[docs] def default(self, o):
result = {
'created': o.createdTime,
'responses': o.responses,
'failure': o.failure
}
return result
[docs]def tarfile_is_safe(tar):
"""
Check the names of files in the archive for safety.
Returns True if all paths are relative and safe or False if
any of the paths are absolute (leading slash) or try to access
parent directories (leading ..).
"""
for member in tar:
# normpath is useful here because it correctly normalizes "a/../../c"
# to "../c".
path = os.path.normpath(member.name)
if os.path.isabs(path) or path.startswith(".."):
return False
return True
[docs]class ChuteApi(object):
routes = Klein()
def __init__(self, update_manager):
self.update_manager = update_manager
@routes.route('/', methods=['GET'])
[docs] def get_chutes(self, request):
"""
List installed chutes.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"environment": {},
"name": "hello-world",
"allocation": {
"cpu_shares": 1024,
"prioritize_traffic": false
},
"state": "running",
"version": "x1511808778",
"resources": null
}
]
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
chuteStorage = ChuteStorage()
chutes = chuteStorage.getChuteList()
allocation = resource.computeResourceAllocation(chutes)
result = []
for chute in chutes:
container = ChuteContainer(chute.name)
result.append({
'name': chute.name,
'state': container.getStatus(),
'version': getattr(chute, 'version', None),
'allocation': allocation.get(chute.name, None),
'environment': getattr(chute, 'environment', None),
'resources': getattr(chute, 'resources', None)
})
return json.dumps(result)
@routes.route('/', methods=['POST'])
[docs] def create_chute(self, request):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='create',
tok=pdutils.timeint())
ctype = request.requestHeaders.getRawHeaders('Content-Type',
default=[None])[0]
if ctype == "application/x-tar":
workdir, paradrop_yaml = extract_tarred_chute(request.content)
config = paradrop_yaml.get("config", {})
# Try to read chute name from top level (preferred) or from config
# object (deprecated).
if 'name' in paradrop_yaml:
update['name'] = paradrop_yaml['name']
elif 'name' in config:
out.warn("Deprecated: move chute name to top level of config file.")
update['name'] = config['name']
else:
raise Exception("Chute name not found in configuration file.")
update['workdir'] = workdir
chute_version = paradrop_yaml.get("version", None)
update['version'] = "x{}".format(update['tok'])
update.update(config)
else:
# TODO: this case is not tested
body = json.loads(request.content.read())
config = body['config']
update.update(config)
# Set a time-based version number for side-loaded chutes because we do
# not expect they to receive it from the config file.
update['version'] = "x{}".format(update['tok'])
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>', methods=['GET'])
[docs] def get_chute(self, request, chute):
"""
Get information about an installed chute.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/hello-world
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"environment": {},
"name": "hello-world",
"allocation": {
"cpu_shares": 1024,
"prioritize_traffic": false
},
"state": "running",
"version": "x1511808778",
"resources": null
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
container = ChuteContainer(chute)
except KeyError as error:
request.setResponseCode(404)
return "{}"
chuteStorage = ChuteStorage()
allocation = resource.computeResourceAllocation(
chuteStorage.getChuteList())
result = {
'name': chute,
'state': container.getStatus(),
'version': getattr(chute_obj, 'version', None),
'allocation': allocation.get(chute, None),
'environment': getattr(chute_obj, 'environment', None),
'resources': getattr(chute_obj, 'resources', None)
}
return json.dumps(result)
@routes.route('/<chute>', methods=['PUT'])
[docs] def update_chute(self, request, chute):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='update',
tok=pdutils.timeint(),
name=chute)
ctype = request.requestHeaders.getRawHeaders('Content-Type',
default=[None])[0]
if ctype == "application/x-tar":
workdir, paradrop_yaml = extract_tarred_chute(request.content)
config = paradrop_yaml.get("config", {})
# Try to read chute name from top level (preferred) or from config
# object (deprecated).
if 'name' in paradrop_yaml:
update['name'] = paradrop_yaml['name']
elif 'name' in config:
out.warn("Deprecated: move chute name to top level of config file.")
update['name'] = config['name']
else:
raise Exception("Chute name not found in configuration file.")
update['workdir'] = workdir
chute_version = paradrop_yaml.get("version", None)
update['version'] = "x{}".format(update['tok'])
update.update(config)
else:
body = json.loads(request.content.read())
config = body['config']
update.update(config)
# Set a time-based version number for side-loaded chutes because we do
# not expect the to receive it from the config file.
update['version'] = "x{}".format(update['tok'])
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>', methods=['DELETE'])
[docs] def delete_chute(self, request, chute):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='delete',
tok=pdutils.timeint(),
name=chute)
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>/stop', methods=['POST'])
[docs] def stop_chute(self, request, chute):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='stop',
tok=pdutils.timeint(),
name=chute)
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>/start', methods=['POST'])
[docs] def start_chute(self, request, chute):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='start',
tok=pdutils.timeint(),
name=chute)
try:
body = json.loads(request.content.read())
# Chute environment variables can be replaced during the operation.
update['environment'] = body['environment']
except Exception as error:
pass
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>/restart', methods=['POST'])
[docs] def restart_chute(self, request, chute):
cors.config_cors(request)
update = dict(updateClass='CHUTE',
updateType='restart',
tok=pdutils.timeint(),
name=chute)
try:
body = json.loads(request.content.read())
# Chute environment variables can be replaced during the operation.
update['environment'] = body['environment']
except Exception as error:
pass
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
request.setHeader('Content-Type', 'application/json')
return json.dumps(result)
@routes.route('/<chute>/cache', methods=['GET'])
[docs] def get_chute_cache(self, request, chute):
"""
Get chute cache contents.
The chute cache is a key-value store used during chute installation.
It can be useful for debugging the Paradrop platform.
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
result = chute_obj.getCacheContents()
return json.dumps(result, cls=ChuteCacheEncoder)
except KeyError as error:
request.setResponseCode(404)
return "{}"
@routes.route('/<chute>/config', methods=['GET'])
[docs] def get_chute_config(self, request, chute):
"""
Get current chute configuration.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/config
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"net": {
"wifi": {
"dhcp": {
"lease": "1h",
"limit": 250,
"start": 3
},
"intfName": "wlan0",
"options": {
"isolate": True
},
"ssid": "Free WiFi",
"type": "wifi"
}
}
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
config = chute_obj.getConfiguration()
return json.dumps(config)
except KeyError as error:
request.setResponseCode(404)
return "{}"
@routes.route('/<chute>/config', methods=['PUT'])
[docs] def set_chute_config(self, request, chute):
"""
Update the chute configuration and restart to apply changes.
**Example request**:
.. sourcecode:: http
PUT /api/v1/chutes/captive-portal/config
Content-Type: application/json
{
"net": {
"wifi": {
"dhcp": {
"lease": "1h",
"limit": 250,
"start": 3
},
"intfName": "wlan0",
"options": {
"isolate": True
},
"ssid": "Better Free WiFi",
"type": "wifi"
}
}
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"change_id": 1
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
update = dict(updateClass='CHUTE',
updateType='restart',
tok=pdutils.timeint(),
name=chute)
try:
body = json.loads(request.content.read())
update.update(body)
except Exception as error:
pass
# We will return the change ID to the caller for tracking and log
# retrieval.
update['change_id'] = self.update_manager.assign_change_id()
d = self.update_manager.add_update(**update)
result = {
'change_id': update['change_id']
}
return json.dumps(result)
@routes.route('/<chute>/networks', methods=['GET'])
[docs] def get_networks(self, request, chute):
"""
Get list of networks configured for the chute.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"interface": "wlan0",
"type": "wifi",
"name": "wifi"
}
]
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "[]"
result = []
for iface in networkInterfaces:
data = {
'name': iface['name'],
'type': iface['netType'],
'interface': iface['internalIntf']
}
result.append(data)
return json.dumps(result)
@routes.route('/<chute>/networks/<network>', methods=['GET'])
[docs] def get_network(self, request, chute, network):
"""
Get information about a network configured for the chute.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"interface": "wlan0",
"type": "wifi",
"name": "wifi"
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
data = {}
for iface in networkInterfaces:
if iface['name'] != network:
continue
data = {
'name': iface['name'],
'type': iface['netType'],
'interface': iface['internalIntf']
}
return json.dumps(data)
@routes.route('/<chute>/networks/<network>/leases', methods=['GET'])
[docs] def get_leases(self, request, chute, network):
"""
Get current list of DHCP leases for chute network.
Returns a list of DHCP lease records with the following fields:
expires
lease expiration time (seconds since Unix epoch)
mac_addr
device MAC address
ip_addr
device IP address
hostname
name that the device reported
client_id
optional identifier supplied by device
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi/leases
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"client_id": "01:5c:59:48:7d:b9:e6",
"expires": "1511816276",
"ip_addr": "192.168.128.64",
"mac_addr": "5c:59:48:7d:b9:e6",
"hostname": "paradrops-iPod"
}
]
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
externalSystemDir = chute_obj.getCache('externalSystemDir')
except KeyError as error:
request.setResponseCode(404)
return "[]"
leasefile = 'dnsmasq-{}.leases'.format(network)
path = os.path.join(externalSystemDir, leasefile)
# The format of the dnsmasq leases file is one entry per line with
# space-separated fields.
keys = ['expires', 'mac_addr', 'ip_addr', 'hostname', 'client_id']
try:
leases = []
with open(path, 'r') as source:
for line in source:
parts = line.strip().split()
leases.append(dict(zip(keys, parts)))
return json.dumps(leases)
except IOError as error:
# During chute uninstallation, there is a small window where the
# chute still exists but the leases file has been removed.
request.setResponseCode(404)
return "[]"
@routes.route('/<chute>/networks/<network>/ssid', methods=['GET'])
[docs] def get_ssid(self, request, chute, network):
"""
Get currently configured SSID for the chute network.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi/ssid
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"ssid": "Free WiFi",
"bssid": "02:00:08:24:03:dd"
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
address = os.path.join(settings.PDCONFD_WRITE_DIR, "hostapd", ifname)
return hostapd_control.execute(address, command="GET_CONFIG")
@routes.route('/<chute>/networks/<network>/ssid', methods=['PUT'])
[docs] def set_ssid(self, request, chute, network):
"""
Change the configured SSID for the chute network.
The change will not persist after a reboot. If a persistent change is
desired, you should update the chute configuration instead.
**Example request**:
.. sourcecode:: http
PUT /api/v1/chutes/captive-portal/networks/wifi/ssid
Content-Type: application/json
{
"ssid": "Best Free WiFi"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"message": "OK"
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
body = json.loads(request.content.read())
if "ssid" not in body:
raise Exception("ssid required")
command = "SET ssid {}".format(body['ssid'])
address = os.path.join(settings.PDCONFD_WRITE_DIR, "hostapd", ifname)
return hostapd_control.execute(address, command=command)
@routes.route('/<chute>/networks/<network>/hostapd_status', methods=['GET'])
[docs] def get_hostapd_status(self, request, chute, network):
"""
Get low-level status information from the access point.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi/hostapd_status
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"olbc_ht": "0",
"cac_time_left_seconds": "N/A",
"num_sta_no_short_slot_time": "0",
"olbc": "1",
"num_sta_non_erp": "0",
"ht_op_mode": "0x4",
"state": "ENABLED",
"num_sta_ht40_intolerant": "0",
"channel": "11",
"bssid[0]": "02:00:08:24:03:dd",
"ieee80211n": "1",
"cac_time_seconds": "0",
"num_sta[0]": "1",
"ieee80211ac": "0",
"phy": "phy0",
"num_sta_ht_no_gf": "1",
"freq": "2462",
"num_sta_ht_20_mhz": "1",
"num_sta_no_short_preamble": "0",
"secondary_channel": "0",
"ssid[0]": "Free WiFi",
"num_sta_no_ht": "0",
"bss[0]": "vwlan7e1b"
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
address = os.path.join(settings.PDCONFD_WRITE_DIR, "hostapd", ifname)
return hostapd_control.execute(address, command="STATUS")
@routes.route('/<chute>/networks/<network>/stations', methods=['GET'])
[docs] def get_stations(self, request, chute, network):
"""
Get detailed information about connected wireless stations.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi/stations
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
[
{
"rx_packets": "230",
"tdls_peer": "no",
"authenticated": "yes",
"rx_bytes": "12511",
"tx_bitrate": "1.0 MBit/s",
"tx_retries": "0",
"signal": "-45 [-49, -48] dBm",
"authorized": "yes",
"rx_bitrate": "65.0 MBit/s MCS 7",
"mfp": "no",
"tx_failed": "0",
"inactive_time": "4688 ms",
"mac_addr": "5c:59:48:7d:b9:e6",
"tx_bytes": "34176",
"wmm_wme": "yes",
"preamble": "short",
"tx_packets": "88",
"signal_avg": "-44 [-48, -47] dBm"
}
]
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "[]"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
cmd = ['iw', 'dev', ifname, 'station', 'dump']
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
stations = []
current = {}
for line in proc.stdout:
line = line.strip()
match = re.match("Station\s+(\S+)\s+.*", line)
if match is not None:
current = {
'mac_addr': match.group(1)
}
stations.append(current)
continue
match = re.match("(.*):\s+(.*)", line)
if match is not None:
key = match.group(1).lower().replace(' ', '_').replace('/', '_')
current[key] = match.group(2)
return json.dumps(stations)
@routes.route('/<chute>/networks/<network>/stations/<mac>', methods=['GET'])
[docs] def get_station(self, request, chute, network, mac):
"""
Get detailed information about a connected station.
**Example request**:
.. sourcecode:: http
GET /api/v1/chutes/captive-portal/networks/wifi/stations/5c:59:48:7d:b9:e6
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"rx_packets": "230",
"tdls_peer": "no",
"authenticated": "yes",
"rx_bytes": "12511",
"tx_bitrate": "1.0 MBit/s",
"tx_retries": "0",
"signal": "-45 [-49, -48] dBm",
"authorized": "yes",
"rx_bitrate": "65.0 MBit/s MCS 7",
"mfp": "no",
"tx_failed": "0",
"inactive_time": "4688 ms",
"mac_addr": "5c:59:48:7d:b9:e6",
"tx_bytes": "34176",
"wmm_wme": "yes",
"preamble": "short",
"tx_packets": "88",
"signal_avg": "-44 [-48, -47] dBm"
}
"""
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
cmd = ['iw', 'dev', ifname, 'station', 'get', mac]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
station = {}
for line in proc.stdout:
line = line.strip()
match = re.match("Station\s+(\S+)\s+.*", line)
if match is not None:
station['mac_addr'] = match.group(1)
continue
match = re.match("(.*):\s+(.*)", line)
if match is not None:
key = match.group(1).lower().replace(' ', '_').replace('/', '_')
station[key] = match.group(2)
return json.dumps(station)
@routes.route('/<chute>/networks/<network>/stations/<mac>', methods=['DELETE'])
[docs] def delete_station(self, request, chute, network, mac):
cors.config_cors(request)
request.setHeader('Content-Type', 'application/json')
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return "{}"
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
cmd = ['iw', 'dev', ifname, 'station', 'del', mac]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
messages = []
for line in proc.stdout:
line = line.strip()
messages.append(line)
return json.dumps(messages)
@routes.route('/<chute>/networks/<network>/hostapd_control/ws', branch=True, methods=['GET'])
[docs] def hostapd_control(self, request, chute, network):
try:
chute_obj = ChuteStorage.chuteList[chute]
networkInterfaces = chute_obj.getCache('networkInterfaces')
except KeyError as error:
request.setResponseCode(404)
return ""
ifname = None
for iface in networkInterfaces:
if iface['name'] == network:
ifname = iface['externalIntf']
break
ctrl_iface = os.path.join(settings.PDCONFD_WRITE_DIR, "hostapd", ifname)
factory = hostapd_control.HostapdControlWSFactory(ctrl_iface)
factory.setProtocolOptions(autoPingInterval=10, autoPingTimeout=5)
return WebSocketResource(factory)