Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Verify TLS connection to EOS devices #149

Merged
merged 18 commits into from
Jan 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
934118c
Use enforce_verification=True but still set up unverified context, so…
indy-independence Dec 21, 2020
8eeac94
New nornir task: arista_copy_cert which copies ssl .crt and .key file…
indy-independence Dec 21, 2020
8345914
Add function that generates a key+certificate for a device and signin…
indy-independence Dec 21, 2020
15ab239
API call to renew/install certificate on specified hostname or group
indy-independence Dec 22, 2020
f3516da
Add docs for device_cert renew and config options for certs
indy-independence Dec 22, 2020
6aa2af9
Start work on device cert install during ztp
indy-independence Dec 22, 2020
d18b0d6
Change verify_tls_device to separate config option. Update docker con…
indy-independence Dec 23, 2020
e923ba5
Fix connection options for insecure device states
indy-independence Dec 23, 2020
31124c2
Try different way of handling exception/fail from ztp_device_cert
indy-independence Dec 23, 2020
617c47a
Test workaround for 'no route to host', this helped when testing on c…
indy-independence Dec 23, 2020
aa7d524
Make init_device add timeout option to current connection options ins…
indy-independence Dec 23, 2020
597b410
Try moving ztp_device_cert to subtask within push_base_management
indy-independence Dec 23, 2020
b92372a
Bugfix hostname var
indy-independence Dec 23, 2020
cf75cdf
Fix some unit tests for cert
indy-independence Jan 4, 2021
7f5b704
export credentials in nosetest script as well
indy-independence Jan 4, 2021
a589ac3
Use separate docker volume for cacert
indy-independence Jan 12, 2021
e1ddfb7
Make sure ipv4 address has right type when sending to generate_device…
indy-independence Jan 12, 2021
e48e1f1
Remove device cert at factory_default
indy-independence Jan 13, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docker/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN /opt/cnaas/cnaas-setup.sh $BUILDBRANCH
# Prepare for supervisord, uwsgi, ngninx
COPY nosetests.sh /opt/cnaas/
COPY exec-pre-app.sh /opt/cnaas/
COPY createca.sh /opt/cnaas/
COPY --chown=root:www-data config/uwsgi.ini /opt/cnaas/venv/cnaas-nms/
COPY config/supervisord_app.conf /etc/supervisor/supervisord.conf
COPY config/nginx_app.conf /etc/nginx/sites-available/
Expand All @@ -40,6 +41,10 @@ RUN chown -R root:www-data /opt/cnaas/ && \
chmod -R u=rwX,g=rX,o= /opt/cnaas/
RUN chown -R www-data:www-data /opt/cnaas/templates/
RUN chown -R www-data:www-data /opt/cnaas/settings/
# Give permission for devicecert store
RUN mkdir /tmp/devicecerts
RUN chown -R www-data:www-data /tmp/devicecerts && \
chmod -R u=rwX,g=,o= /tmp/devicecerts
# Give permission for unittests
RUN chown root:www-data /opt/cnaas/*.sh && \
chmod g+rx /opt/cnaas/*.sh
Expand Down
4 changes: 4 additions & 0 deletions docker/api/config/api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ host: 0.0.0.0
httpd_url: "https://cnaas_httpd:1443/api/v1.0/firmware"
verify_tls: False
jwtcert: /opt/cnaas/jwtcert/public.pem
verify_tls_device: False
cafile: /opt/cnaas/cacert/rootCA.crt
cakeyfile: /opt/cnaas/cacert/rootCA.key
certpath: /tmp/devicecerts/
14 changes: 14 additions & 0 deletions docker/api/createca.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/bin/bash

if [ -f /opt/cnaas/cacert/rootCA.key ] || [ -f /opt/cnaas/cacert/rootCA.crt ]
then
exit 0
fi

cd /opt/cnaas/cacert
umask 077
openssl genrsa -out rootCA.key 4096
openssl req -subj /C=/ST=/L=/O=/CN=cnaasNMSrootCA -x509 -new -nodes -key rootCA.key -sha256 -out rootCA.crt -days 7300
chown root:www-data rootCA.*
chmod 640 rootCA.*

9 changes: 9 additions & 0 deletions docker/api/nosetests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ source venv/bin/activate

cd venv/cnaas-nms/src

export USERNAME_DHCP_BOOT="admin"
export PASSWORD_DHCP_BOOT="abc123abc123"
export USERNAME_DISCOVERED="admin"
export PASSWORD_DISCOVERED="abc123abc123"
export USERNAME_INIT="admin"
export PASSWORD_INIT="abc123abc123"
export USERNAME_MANAGED="admin"
export PASSWORD_MANAGED="abc123abc123"

nosetests --collect-only --with-id -v
nosetests --with-coverage --cover-package=cnaas_nms -v
cp .coverage /coverage/.coverage-nosetests
Expand Down
5 changes: 5 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ services:
- type: volume
source: cnaas-jwtcert
target: /opt/cnaas/jwtcert
- type: volume
source: cnaas-cacert
target: /opt/cnaas/cacert

cnaas_httpd:
image:
Expand Down Expand Up @@ -118,3 +121,5 @@ volumes:
external: true
cnaas-jwtcert:
external: true
cnaas-cacert:
external: true
19 changes: 18 additions & 1 deletion docs/apiref/devices.rst
Original file line number Diff line number Diff line change
Expand Up @@ -320,4 +320,21 @@ use this API call:
This will schedule a job to log in to the device, get the facts and update the
database. You can perform this action on both MANAGED and UNMANAGED devices.
UNMANAGED devices might not be reachable so this could be a good test-call
before moving the device back to the MANAGED state.
before moving the device back to the MANAGED state.

Renew certificates
------------------

To manually request installation/renewal of a new device certificate use
the device_cert API:

::

curl https://localhost/api/v1.0/device_cert -d '{"hostname": "eosdist1", "action": "RENEW"}' -X POST -H "Content-Type: application/json"

This will schedule a job to generate a new key and certificate for the specified
device(s) and copy them to the device(s). The certificate will be signed by the
NMS CA (specified in api.yml).

Either one of "hostname" or "group" arguments must be specified. The "action"
argument must be specified and the only valid action for now is "RENEW".
5 changes: 5 additions & 0 deletions docs/configuration/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ Defines parameters for the API:
- jwtcert: Defines the path to the public JWT certificate used to verify JWT tokens
- httpd_url: URL to the httpd container containing firmware images
- verify_tls: Verify certificate for connections to httpd/firmware server
- verify_tls_device: Verify TLS connections to devices, defaults to True
- cafile: Path to CA certificate used to verify device certificates.
If no path is specified then the system default CAs will be used.
- cakeyfile: Path to CA key, used to sign device certificates after generation.
- certpath: Path to store generated device certificates in.
- allow_apply_config_liverun: Allow liverun on apply_config API call. Defaults to False.

/etc/cnaas-nms/repository.yml
Expand Down
4 changes: 3 additions & 1 deletion src/cnaas_nms/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
from cnaas_nms.tools.log import get_logger

from cnaas_nms.api.device import device_api, devices_api, device_init_api, \
device_initcheck_api, device_syncto_api, device_discover_api, device_update_facts_api
device_initcheck_api, device_syncto_api, device_discover_api, \
device_update_facts_api, device_cert_api
from cnaas_nms.api.linknet import api as links_api
from cnaas_nms.api.firmware import api as firmware_api
from cnaas_nms.api.interface import api as interfaces_api
Expand Down Expand Up @@ -101,6 +102,7 @@ def handle_error(self, e):
api.add_namespace(device_syncto_api)
api.add_namespace(device_discover_api)
api.add_namespace(device_update_facts_api)
api.add_namespace(device_cert_api)
api.add_namespace(links_api)
api.add_namespace(firmware_api)
api.add_namespace(interfaces_api)
Expand Down
89 changes: 89 additions & 0 deletions src/cnaas_nms/api/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@
device_update_facts_api = Namespace('device_update_facts',
description='API to update facts about devices',
prefix='/api/{}'.format(__api_version__))
device_cert_api = Namespace('device_cert', description='API to handle device certificates',
prefix='/api/{}'.format(__api_version__))


device_model = device_api.model('device', {
Expand Down Expand Up @@ -99,6 +101,18 @@
'full_config': fields.String(required=True),
})

device_cert_model = device_syncto_api.model('device_cert', {
'hostname': fields.String(required=False,
description="Device hostname",
example="myhostname"),
'group': fields.String(required=False,
description="Device group",
example="mygroup"),
'action': fields.String(required=True,
description="Action to execute, one of: RENEW",
example="RENEW")
})


class DeviceByIdApi(Resource):
@jwt_required
Expand Down Expand Up @@ -749,6 +763,80 @@ def post(self, hostname: str):
return res, 200


class DeviceCertApi(Resource):
@jwt_required
@device_api.expect(device_cert_model)
def post(self):
"""Execute certificate related actions on device"""
json_data = request.get_json()
# default args
kwargs: dict = {}

if 'action' in json_data and isinstance(json_data['action'], str):
action = json_data['action'].upper()
else:
return empty_result(
status='error',
data=f"Required field 'action' was not specified"
), 400

if 'comment' in json_data and isinstance(json_data['comment'], str):
kwargs['job_comment'] = json_data['comment']
if 'ticket_ref' in json_data and isinstance(json_data['ticket_ref'], str):
kwargs['job_ticket_ref'] = json_data['ticket_ref']

total_count: Optional[int] = None
nr = cnaas_init()

if 'hostname' in json_data:
hostname = str(json_data['hostname'])
if not Device.valid_hostname(hostname):
return empty_result(
status='error',
data=f"Hostname '{hostname}' is not a valid hostname"
), 400
_, total_count, _ = inventory_selector(nr, hostname=hostname)
if total_count != 1:
return empty_result(
status='error',
data=f"Hostname '{hostname}' not found or is not a managed device"
), 400
kwargs['hostname'] = hostname
elif 'group' in json_data:
group_name = str(json_data['group'])
if group_name not in get_groups():
return empty_result(status='error', data='Could not find a group with name {}'.format(group_name))
kwargs['group'] = group_name
_, total_count, _ = inventory_selector(nr, group=group_name)
else:
return empty_result(
status='error',
data=f"No devices were specified"
), 400

if action == 'RENEW':
scheduler = Scheduler()
job_id = scheduler.add_onetime_job(
'cnaas_nms.confpush.cert:renew_cert',
when=1,
scheduled_by=get_jwt_identity(),
kwargs=kwargs)

res = empty_result(data=f"Scheduled job to renew certificates")
res['job_id'] = job_id

resp = make_response(json.dumps(res), 200)
if total_count:
resp.headers['X-Total-Count'] = total_count
resp.headers['Content-Type'] = "application/json"
return resp
else:
return empty_result(
status='error',
data=f"Unknown action specified: {action}"
), 400


# Devices
device_api.add_resource(DeviceByIdApi, '/<int:device_id>')
device_api.add_resource(DeviceByHostnameApi, '/<string:hostname>')
Expand All @@ -762,4 +850,5 @@ def post(self, hostname: str):
device_discover_api.add_resource(DeviceDiscoverApi, '')
device_syncto_api.add_resource(DeviceSyncApi, '')
device_update_facts_api.add_resource(DeviceUpdateFactsApi, '')
device_cert_api.add_resource(DeviceCertApi, '')
# device/<string:hostname>/current_config
3 changes: 2 additions & 1 deletion src/cnaas_nms/api/tests/data/testdata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ untagged_vlan: 'STUDENT'
tagged_vlan_list: ['STUDENT', 'STUDENT2']
jwt_auth_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpYXQiOjE1NzEwNTk2MTgsIm5iZiI6MTU3MTA1OTYxOCwianRpIjoiNTQ2MDk2YTUtZTNmOS00NzFlLWE2NTctZWFlYTZkNzA4NmVhIiwic3ViIjoiYWRtaW4iLCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.Sfffg9oZg_Kmoq7Oe8IoTcbuagpP6nuUXOQzqJpgDfqDq_GM_4zGzt7XxByD4G0q8g4gZGHQnV14TpDer2hJXw"
initcheck_device_id: 3
groupname: 'ALL'
groupname: 'ALL'
managed_dist: 'eosdist1'
14 changes: 13 additions & 1 deletion src/cnaas_nms/api/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def setUp(self):
self.client = self.app.test_client()

def test_get_single_device(self):
hostname = "eosdist1"
hostname = self.testdata['managed_dist']
result = self.client.get(
f'/api/v1.0/devices',
params={"filter[hostname]": hostname}
Expand Down Expand Up @@ -341,6 +341,18 @@ def test_get_groups_osversion(self):
self.assertGreaterEqual(len(result.json['data']['groups'][groupname]), 1,
f"No devices found in group '{groupname}' os_versions")

def test_renew_cert_errors(self):
# Test invalid hostname
data = {"hostname": "...", "action": "RENEW"}
result = self.client.post('/api/v1.0/device_cert', json=data)
self.assertEqual(result.status_code, 400)

# Test invalid action
data = {"hostname": self.testdata['managed_dist']}
result = self.client.post('/api/v1.0/device_cert', json=data)
self.assertEqual(result.status_code, 400)
self.assertTrue("action" in result.json['message'], msg="Unexpected error message")


if __name__ == '__main__':
unittest.main()
Loading