Stream pcap from compute to controller to client

This commit is contained in:
Julien Duponchelle 2016-04-22 16:22:03 +02:00
parent 48e71617d6
commit 1ce576c020
No known key found for this signature in database
GPG Key ID: CE8B29639E07F5E8
15 changed files with 172 additions and 124 deletions

View File

@ -284,7 +284,7 @@ class Project:
""" """
A temporary directory. Will be clean at project open and close A temporary directory. Will be clean at project open and close
""" """
return os.path.join(self._path, "project-files", "tmp") return os.path.join(self._path, "tmp")
def capture_working_directory(self): def capture_working_directory(self):
""" """
@ -293,7 +293,7 @@ class Project:
:returns: path to the directory :returns: path to the directory
""" """
workdir = os.path.join(self._path, "project-files", "tmp", "captures") workdir = os.path.join(self._path, "tmp", "captures")
try: try:
os.makedirs(workdir, exist_ok=True) os.makedirs(workdir, exist_ok=True)
except OSError as e: except OSError as e:

View File

@ -125,6 +125,22 @@ class Compute:
"connected": self._connected "connected": self._connected
} }
@asyncio.coroutine
def streamFile(self, project, path):
"""
Read file of a project and stream it
:param project: A project object
:param path: The path of the file in the project
:returns: A file stream
"""
url = self._getUrl("/projects/{}/stream/{}".format(project.id, path))
response = yield from self._session.request("GET", url, auth=self._auth)
if response.status == 404:
raise aiohttp.web.HTTPNotFound(text="{} not found on compute".format(path))
return response.content
@asyncio.coroutine @asyncio.coroutine
def httpQuery(self, method, path, data=None): def httpQuery(self, method, path, data=None):
if not self._connected: if not self._connected:

View File

@ -70,6 +70,13 @@ class Link:
""" """
raise NotImplementedError raise NotImplementedError
@asyncio.coroutine
def read_pcap(self):
"""
Return a FileStream of the Pcap from the compute node
"""
raise NotImplementedError
def capture_file_name(self): def capture_file_name(self):
""" """
:returns: File name for a capture on this link :returns: File name for a capture on this link

View File

@ -122,3 +122,12 @@ class UDPLink(Link):
return vm return vm
raise aiohttp.web.HTTPConflict(text="Capture is not supported for this link") raise aiohttp.web.HTTPConflict(text="Capture is not supported for this link")
@asyncio.coroutine
def read_pcap(self):
"""
Return a FileStream of the Pcap from the compute node
"""
if self._capture_vm:
compute = self._capture_vm["vm"].compute
return compute.streamFile(self._project, "tmp/captures/" + self.capture_file_name())

View File

@ -27,7 +27,6 @@ from .virtualbox_handler import VirtualBoxHandler
from .vpcs_handler import VPCSHandler from .vpcs_handler import VPCSHandler
from .vmware_handler import VMwareHandler from .vmware_handler import VMwareHandler
from .config_handler import ConfigHandler from .config_handler import ConfigHandler
from .file_handler import FileHandler
from .version_handler import VersionHandler from .version_handler import VersionHandler
from .notification_handler import NotificationHandler from .notification_handler import NotificationHandler

View File

@ -1,59 +0,0 @@
#
# Copyright (C) 2015 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import aiohttp
from ....web.route import Route
from ....schemas.file import FILE_STREAM_SCHEMA
class FileHandler:
@classmethod
@Route.get(
r"/files/stream",
description="Stream a file from the server",
status_codes={
200: "File retrieved",
404: "File doesn't exist",
409: "Can't access to file"
},
input=FILE_STREAM_SCHEMA
)
def read(request, response):
response.enable_chunked_encoding()
try:
with open(request.json.get("location"), "rb") as f:
loop = asyncio.get_event_loop()
response.content_type = "application/octet-stream"
response.set_status(200)
# Very important: do not send a content lenght otherwise QT close the connection but curl can consume the Feed
response.content_length = None
response.start(request)
while True:
data = yield from loop.run_in_executor(None, f.read, 16)
if len(data) == 0:
yield from asyncio.sleep(0.1)
else:
response.write(data)
except FileNotFoundError:
raise aiohttp.web.HTTPNotFound()
except OSError as e:
raise aiohttp.web.HTTPConflict(text=str(e))

View File

@ -305,6 +305,50 @@ class ProjectHandler:
except PermissionError: except PermissionError:
raise aiohttp.web.HTTPForbidden() raise aiohttp.web.HTTPForbidden()
@classmethod
@Route.get(
r"/projects/{project_id}/stream/{path:.+}",
description="Stream a file from a project",
parameters={
"project_id": "The UUID of the project",
},
status_codes={
200: "Return the file",
403: "Permission denied",
404: "The file doesn't exist"
})
def stream_file(request, response):
pm = ProjectManager.instance()
project = pm.get_project(request.match_info["project_id"])
path = request.match_info["path"]
path = os.path.normpath(path)
# Raise error if user try to escape
if path[0] == ".":
raise aiohttp.web.HTTPForbidden
path = os.path.join(project.path, path)
response.content_type = "application/octet-stream"
response.set_status(200)
response.enable_chunked_encoding()
# Very important: do not send a content length otherwise QT close the connection but curl can consume the Feed
response.content_length = None
try:
with open(path, "rb") as f:
response.start(request)
while True:
data = f.read(4096)
if not data:
yield from asyncio.sleep(0.1)
yield from response.write(data)
except FileNotFoundError:
raise aiohttp.web.HTTPNotFound()
except PermissionError:
raise aiohttp.web.HTTPForbidden()
@classmethod @classmethod
@Route.post( @Route.post(
r"/projects/{project_id}/files/{path:.+}", r"/projects/{project_id}/files/{path:.+}",

View File

@ -15,6 +15,9 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import aiohttp
import asyncio
from ....web.route import Route from ....web.route import Route
from ....schemas.link import LINK_OBJECT_SCHEMA, LINK_CAPTURE_SCHEMA from ....schemas.link import LINK_OBJECT_SCHEMA, LINK_CAPTURE_SCHEMA
from ....controller.project import Project from ....controller.project import Project
@ -113,3 +116,40 @@ class LinkHandler:
yield from link.delete() yield from link.delete()
response.set_status(204) response.set_status(204)
response.json(link) response.json(link)
@classmethod
@Route.get(
r"/projects/{project_id}/links/{link_id}/pcap",
parameters={
"project_id": "UUID for the project",
"link_id": "UUID of the link"
},
description="Get the pcap from the capture",
status_codes={
200: "Return the file",
403: "Permission denied",
404: "The file doesn't exist"
})
def pcap(request, response):
controller = Controller.instance()
project = controller.getProject(request.match_info["project_id"])
link = project.getLink(request.match_info["link_id"])
content = yield from link.read_pcap()
if content is None:
raise aiohttp.web.HTTPNotFound(text="pcap file not found")
response.content_type = "application/vnd.tcpdump.pcap"
response.set_status(200)
response.enable_chunked_encoding()
# Very important: do not send a content length otherwise QT close the connection but curl can consume the Feed
response.content_length = None
response.start(request)
while True:
chunk = yield from content.read(4096)
if not chunk:
yield from asyncio.sleep(0.1)
yield from response.write(chunk)

View File

@ -45,11 +45,13 @@ in futur GNS3 versions.
<tr> <tr>
<th>ID</td> <th>ID</td>
<th>Capture</td> <th>Capture</td>
<th>PCAP</td>
</tr> </tr>
{% for link in project.links.values() %} {% for link in project.links.values() %}
<tr> <tr>
<td>{{link.id}}</td> <td>{{link.id}}</td>
<td>{{link.capturing}}</td> <td>{{link.capturing}}</td>
<td><a href="/v2/projects/{{project.id}}/links/{{link.id}}/pcap">Download</a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>

View File

@ -179,3 +179,11 @@ def test_json(compute):
"user": "test", "user": "test",
"connected": True "connected": True
} }
def test_streamFile(project, async_run, compute):
response = MagicMock()
response.status = 200
with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock:
async_run(compute.streamFile(project, "test/titi"))
mock.assert_called_with("GET", "https://example.com:84/v2/compute/projects/{}/stream/test/titi".format(project.id), auth=None)

View File

@ -18,6 +18,7 @@
import pytest import pytest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from gns3server.controller.link import Link from gns3server.controller.link import Link
from gns3server.controller.vm import VM from gns3server.controller.vm import VM
from gns3server.controller.compute import Compute from gns3server.controller.compute import Compute

View File

@ -19,6 +19,7 @@ import pytest
import asyncio import asyncio
import aiohttp import aiohttp
from unittest.mock import MagicMock from unittest.mock import MagicMock
from tests.utils import asyncio_patch
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.controller.compute import Compute from gns3server.controller.compute import Compute
@ -160,3 +161,17 @@ def test_capture(async_run, project):
assert link.capturing is False assert link.capturing is False
compute1.post.assert_any_call("/projects/{}/iou/vms/{}/adapters/3/ports/1/stop_capture".format(project.id, vm_iou.id)) compute1.post.assert_any_call("/projects/{}/iou/vms/{}/adapters/3/ports/1/stop_capture".format(project.id, vm_iou.id))
def test_read_pcap(project, async_run):
compute1 = MagicMock()
link = UDPLink(project)
async_run(link.addVM(compute1, 0, 4))
async_run(link.addVM(compute1, 3, 1))
capture = async_run(link.start_capture())
assert link._capture_vm is not None
async_run(link.read_pcap())
link._capture_vm["vm"].compute.streamFile.assert_called_with(project, "tmp/captures/" + link.capture_file_name())

View File

@ -1,62 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2015 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
This test suite check /files endpoint
"""
import json
import asyncio
import aiohttp
from gns3server.version import __version__
def test_stream(http_compute, tmpdir, loop):
with open(str(tmpdir / "test"), 'w+') as f:
f.write("hello")
def go(future):
query = json.dumps({"location": str(tmpdir / "test")})
headers = {'content-type': 'application/json'}
response = yield from aiohttp.request("GET", http_compute.get_url("/files/stream"), data=query, headers=headers)
response.body = yield from response.content.read(5)
with open(str(tmpdir / "test"), 'a') as f:
f.write("world")
response.body += yield from response.content.read(5)
response.close()
future.set_result(response)
future = asyncio.Future()
asyncio.async(go(future))
response = loop.run_until_complete(future)
assert response.status == 200
assert response.body == b'helloworld'
def test_stream_file_not_found(http_compute, tmpdir, loop):
def go(future):
query = json.dumps({"location": str(tmpdir / "test")})
headers = {'content-type': 'application/json'}
response = yield from aiohttp.request("GET", http_compute.get_url("/files/stream"), data=query, headers=headers)
response.close()
future.set_result(response)
future = asyncio.Future()
asyncio.async(go(future))
response = loop.run_until_complete(future)
assert response.status == 404

View File

@ -218,6 +218,25 @@ def test_get_file(http_compute, tmpdir):
assert response.status == 403 assert response.status == 403
def test_stream_file(http_compute, tmpdir):
with patch("gns3server.config.Config.get_section_config", return_value={"project_directory": str(tmpdir)}):
project = ProjectManager.instance().create_project(project_id="01010203-0405-0607-0809-0a0b0c0d0e0b")
with open(os.path.join(project.path, "hello"), "w+") as f:
f.write("world")
response = http_compute.get("/projects/{project_id}/files/hello".format(project_id=project.id), raw=True)
assert response.status == 200
assert response.body == b"world"
response = http_compute.get("/projects/{project_id}/files/false".format(project_id=project.id), raw=True)
assert response.status == 404
response = http_compute.get("/projects/{project_id}/files/../hello".format(project_id=project.id), raw=True)
assert response.status == 403
def test_export(http_compute, tmpdir, loop, project): def test_export(http_compute, tmpdir, loop, project):
os.makedirs(project.path, exist_ok=True) os.makedirs(project.path, exist_ok=True)

View File

@ -95,6 +95,15 @@ def test_stop_capture(http_controller, tmpdir, project, compute, async_run):
assert response.status == 204 assert response.status == 204
def test_pcap(http_controller, tmpdir, project, compute, async_run):
link = Link(project)
link
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.link.Link.read_pcap", return_value=None) as mock:
response = http_controller.get("/projects/{}/links/{}/pcap".format(project.id, link.id), example=True)
assert mock.called
def test_delete_link(http_controller, tmpdir, project, compute, async_run): def test_delete_link(http_controller, tmpdir, project, compute, async_run):
link = Link(project) link = Link(project)