gns3-server 3.0.0rc2__py3-none-any.whl → 3.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of gns3-server might be problematic. Click here for more details.

Files changed (49) hide show
  1. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/METADATA +19 -18
  2. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/RECORD +48 -42
  3. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/WHEEL +1 -1
  4. gns3server/api/routes/controller/images.py +58 -21
  5. gns3server/api/routes/controller/projects.py +20 -4
  6. gns3server/api/routes/controller/templates.py +23 -2
  7. gns3server/api/routes/index.py +3 -3
  8. gns3server/api/server.py +38 -3
  9. gns3server/appliances/arista-veos.gns3a +20 -514
  10. gns3server/appliances/cisco-7200.gns3a +26 -0
  11. gns3server/appliances/cisco-asav.gns3a +14 -1
  12. gns3server/appliances/cisco-iou-l2.gns3a +16 -4
  13. gns3server/appliances/cisco-iou-l3.gns3a +16 -4
  14. gns3server/appliances/innovaphone-app.gns3a +50 -0
  15. gns3server/appliances/innovaphone-ipva.gns3a +78 -0
  16. gns3server/appliances/pfsense.gns3a +14 -0
  17. gns3server/compute/docker/__init__.py +1 -1
  18. gns3server/compute/iou/iou_vm.py +22 -12
  19. gns3server/controller/__init__.py +48 -38
  20. gns3server/controller/appliance_manager.py +2 -2
  21. gns3server/controller/compute.py +8 -3
  22. gns3server/controller/node.py +2 -6
  23. gns3server/controller/project.py +1 -2
  24. gns3server/crash_report.py +1 -1
  25. gns3server/db/repositories/images.py +22 -3
  26. gns3server/db/repositories/pools.py +1 -1
  27. gns3server/db/repositories/templates.py +11 -0
  28. gns3server/db/tasks.py +120 -79
  29. gns3server/disks/empty100G.qcow2 +0 -0
  30. gns3server/disks/empty200G.qcow2 +0 -0
  31. gns3server/disks/empty30G.qcow2 +0 -0
  32. gns3server/disks/empty8G.qcow2 +0 -0
  33. gns3server/schemas/config.py +1 -1
  34. gns3server/server.py +3 -3
  35. gns3server/services/authentication.py +9 -6
  36. gns3server/static/favicon.ico +0 -0
  37. gns3server/static/redoc.standalone.js +1782 -0
  38. gns3server/static/swagger-ui-bundle.js +2 -0
  39. gns3server/static/swagger-ui.css +3 -0
  40. gns3server/static/web-ui/index.html +1 -1
  41. gns3server/static/web-ui/main.62c99707e4709a56.js +1 -0
  42. gns3server/utils/asyncio/__init__.py +4 -12
  43. gns3server/utils/asyncio/pool.py +1 -4
  44. gns3server/utils/images.py +62 -39
  45. gns3server/version.py +2 -2
  46. gns3server/static/web-ui/main.ed82697b58d803e7.js +0 -1
  47. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/LICENSE +0 -0
  48. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/entry_points.txt +0 -0
  49. {gns3_server-3.0.0rc2.dist-info → gns3_server-3.0.2.dist-info}/top_level.txt +0 -0
@@ -18,14 +18,19 @@
18
18
  import ipaddress
19
19
  import aiohttp
20
20
  import asyncio
21
- import async_timeout
22
21
  import socket
23
22
  import json
24
23
  import sys
25
24
  import io
25
+
26
26
  from fastapi import HTTPException
27
27
  from aiohttp import web
28
28
 
29
+ if sys.version_info >= (3, 11):
30
+ from asyncio import timeout as asynctimeout
31
+ else:
32
+ from async_timeout import timeout as asynctimeout
33
+
29
34
  from ..utils import parse_version
30
35
  from ..utils.asyncio import locking
31
36
  from ..controller.controller_error import (
@@ -502,8 +507,8 @@ class Compute:
502
507
  """ Returns URL for specific path at Compute"""
503
508
  return self._getUrl(path)
504
509
 
505
- async def _run_http_query(self, method, path, data=None, timeout=20, raw=False):
506
- async with async_timeout.timeout(delay=timeout):
510
+ async def _run_http_query(self, method, path, data=None, timeout=120, raw=False):
511
+ async with asynctimeout(delay=timeout):
507
512
  url = self._getUrl(path)
508
513
  headers = {"content-type": "application/json"}
509
514
  chunked = None
@@ -572,15 +572,11 @@ class Node:
572
572
  Start a node
573
573
  """
574
574
  try:
575
- # For IOU we need to send the licence everytime
575
+ # For IOU: we need to send the licence everytime we start a node
576
576
  if self.node_type == "iou":
577
577
  license_check = self._project.controller.iou_license.get("license_check", True)
578
578
  iourc_content = self._project.controller.iou_license.get("iourc_content", None)
579
- # if license_check and not iourc_content:
580
- # raise aiohttp.web.HTTPConflict(text="IOU licence is not configured")
581
- await self.post(
582
- "/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
583
- )
579
+ await self.post("/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content})
584
580
  else:
585
581
  await self.post("/start", data=data, timeout=240)
586
582
  except asyncio.TimeoutError:
@@ -595,7 +595,7 @@ class Project:
595
595
 
596
596
  if node_type == "iou":
597
597
  async with self._iou_id_lock:
598
- # wait for a IOU node to be completely created before adding a new one
598
+ # wait for an IOU node to be completely created before adding a new one
599
599
  # this is important otherwise we allocate the same application ID (used
600
600
  # to generate MAC addresses) when creating multiple IOU node at the same time
601
601
  if "properties" in kwargs.keys():
@@ -1339,7 +1339,6 @@ class Project:
1339
1339
  Copy the project files directly rather than in an import-export fashion.
1340
1340
 
1341
1341
  :param name: Name of the new project. A new one will be generated in case of conflicts
1342
- :param location: Parent directory of the new project
1343
1342
  :param reset_mac_addresses: Reset MAC addresses for the new project
1344
1343
  """
1345
1344
 
@@ -58,7 +58,7 @@ class CrashReport:
58
58
  Report crash to a third party service
59
59
  """
60
60
 
61
- DSN = "https://29d15f2b7fde7fbd860843b7ee24dc7f@o19455.ingest.us.sentry.io/38482"
61
+ DSN = "https://9cf53e6b9adfe49b867f1847b7cc4d72@o19455.ingest.us.sentry.io/38482"
62
62
  _instance = None
63
63
 
64
64
  def __init__(self):
@@ -18,7 +18,7 @@
18
18
  import os
19
19
 
20
20
  from typing import Optional, List
21
- from sqlalchemy import select, delete
21
+ from sqlalchemy import select, delete, update
22
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
23
 
24
24
  from .base import BaseRepository
@@ -103,6 +103,22 @@ class ImagesRepository(BaseRepository):
103
103
  await self._db_session.refresh(db_image)
104
104
  return db_image
105
105
 
106
+ async def update_image(self, image_path: str, checksum: str, checksum_algorithm: str) -> models.Image:
107
+ """
108
+ Update an image.
109
+ """
110
+
111
+ query = update(models.Image).\
112
+ where(models.Image.path == image_path).\
113
+ values(checksum=checksum, checksum_algorithm=checksum_algorithm)
114
+
115
+ await self._db_session.execute(query)
116
+ await self._db_session.commit()
117
+ image_db = await self.get_image_by_checksum(checksum)
118
+ if image_db:
119
+ await self._db_session.refresh(image_db) # force refresh of updated_at value
120
+ return image_db
121
+
106
122
  async def delete_image(self, image_path: str) -> bool:
107
123
  """
108
124
  Delete an image.
@@ -119,7 +135,7 @@ class ImagesRepository(BaseRepository):
119
135
  await self._db_session.commit()
120
136
  return result.rowcount > 0
121
137
 
122
- async def prune_images(self) -> int:
138
+ async def prune_images(self, skip_images: list[str] = None) -> int:
123
139
  """
124
140
  Prune images not attached to any template.
125
141
  """
@@ -130,12 +146,15 @@ class ImagesRepository(BaseRepository):
130
146
  images = result.scalars().all()
131
147
  images_deleted = 0
132
148
  for image in images:
149
+ if skip_images and image.filename in skip_images:
150
+ log.debug(f"Skipping image '{image.path}' for pruning")
151
+ continue
133
152
  try:
134
153
  log.debug(f"Deleting image '{image.path}'")
135
154
  os.remove(image.path)
136
155
  except OSError:
137
156
  log.warning(f"Could not delete image file {image.path}")
138
- if await self.delete_image(image.filename):
157
+ if await self.delete_image(image.path):
139
158
  images_deleted += 1
140
159
  log.info(f"{images_deleted} image(s) have been deleted")
141
160
  return images_deleted
@@ -80,7 +80,7 @@ class ResourcePoolsRepository(BaseRepository):
80
80
  await self._db_session.commit()
81
81
  return result.rowcount > 0
82
82
 
83
- async def get_resource_memberships(self, resource_id: UUID) -> List[models.UserGroup]:
83
+ async def get_resource_memberships(self, resource_id: UUID) -> List[models.ResourcePool]:
84
84
  """
85
85
  Get all resource memberships in resource pools.
86
86
  """
@@ -170,3 +170,14 @@ class TemplatesRepository(BaseRepository):
170
170
  await self._db_session.commit()
171
171
  await self._db_session.refresh(template_in_db)
172
172
  return template_in_db
173
+
174
+ async def get_template_images(self, template_id: UUID) -> List[models.Image]:
175
+ """
176
+ Return all images attached to a template.
177
+ """
178
+
179
+ query = select(models.Image).\
180
+ join(models.Image.templates).\
181
+ filter(models.Template.template_id == template_id)
182
+ result = await self._db_session.execute(query)
183
+ return result.scalars().all()
gns3server/db/tasks.py CHANGED
@@ -16,13 +16,11 @@
16
16
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
17
 
18
18
  import asyncio
19
- import signal
19
+ import time
20
20
  import os
21
21
 
22
22
  from fastapi import FastAPI
23
23
  from pydantic import ValidationError
24
- from watchfiles import awatch, Change
25
-
26
24
  from typing import List
27
25
  from sqlalchemy import event
28
26
  from sqlalchemy.engine import Engine
@@ -32,10 +30,13 @@ from alembic import command, config
32
30
  from alembic.script import ScriptDirectory
33
31
  from alembic.runtime.migration import MigrationContext
34
32
  from alembic.util.exc import CommandError
33
+ from watchdog.observers import Observer
34
+ from watchdog.events import FileSystemEvent, PatternMatchingEventHandler
35
35
 
36
36
  from gns3server.db.repositories.computes import ComputesRepository
37
37
  from gns3server.db.repositories.images import ImagesRepository
38
- from gns3server.utils.images import discover_images, check_valid_image_header, read_image_info, default_images_directory, InvalidImageError
38
+ from gns3server.utils.images import md5sum, discover_images, read_image_info, InvalidImageError
39
+ from gns3server.utils.asyncio import wait_run_in_executor
39
40
  from gns3server import schemas
40
41
 
41
42
  from .models import Base
@@ -130,81 +131,7 @@ async def get_computes(app: FastAPI) -> List[dict]:
130
131
  return computes
131
132
 
132
133
 
133
- def image_filter(change: Change, path: str) -> bool:
134
-
135
- if change == Change.added and os.path.isfile(path):
136
- if path.endswith(".tmp") or path.endswith(".md5sum") or path.startswith("."):
137
- return False
138
- if "/lib/" in path or "/lib64/" in path:
139
- # ignore custom IOU libraries
140
- return False
141
- header_magic_len = 7
142
- with open(path, "rb") as f:
143
- image_header = f.read(header_magic_len) # read the first 7 bytes of the file
144
- if len(image_header) >= header_magic_len:
145
- try:
146
- check_valid_image_header(image_header)
147
- except InvalidImageError as e:
148
- log.debug(f"New image '{path}': {e}")
149
- return False
150
- else:
151
- log.debug(f"New image '{path}': size is too small to be valid")
152
- return False
153
- return True
154
- # FIXME: should we support image deletion?
155
- # elif change == Change.deleted:
156
- # return True
157
- return False
158
-
159
-
160
- async def monitor_images_on_filesystem(app: FastAPI):
161
-
162
- directories_to_monitor = []
163
- for image_type in ("qemu", "ios", "iou"):
164
- image_dir = default_images_directory(image_type)
165
- if os.path.isdir(image_dir):
166
- log.debug(f"Monitoring for new images in '{image_dir}'")
167
- directories_to_monitor.append(image_dir)
168
-
169
- try:
170
- async for changes in awatch(
171
- *directories_to_monitor,
172
- watch_filter=image_filter,
173
- raise_interrupt=True
174
- ):
175
- async with AsyncSession(app.state._db_engine) as db_session:
176
- images_repository = ImagesRepository(db_session)
177
- for change in changes:
178
- change_type, image_path = change
179
- if change_type == Change.added:
180
- try:
181
- image = await read_image_info(image_path)
182
- except InvalidImageError as e:
183
- log.warning(str(e))
184
- continue
185
- try:
186
- if await images_repository.get_image(image_path):
187
- continue
188
- await images_repository.add_image(**image)
189
- log.info(f"Discovered image '{image_path}' has been added to the database")
190
- except SQLAlchemyError as e:
191
- log.warning(f"Error while adding image '{image_path}' to the database: {e}")
192
- # if change_type == Change.deleted:
193
- # try:
194
- # if await images_repository.get_image(image_path):
195
- # success = await images_repository.delete_image(image_path)
196
- # if not success:
197
- # log.warning(f"Could not delete image '{image_path}' from the database")
198
- # else:
199
- # log.info(f"Image '{image_path}' has been deleted from the database")
200
- # except SQLAlchemyError as e:
201
- # log.warning(f"Error while deleting image '{image_path}' from the database: {e}")
202
- except KeyboardInterrupt:
203
- # send SIGTERM to the server PID so uvicorn can shutdown the process
204
- os.kill(os.getpid(), signal.SIGTERM)
205
-
206
-
207
- async def discover_images_on_filesystem(app: FastAPI):
134
+ async def discover_images_on_filesystem(app: FastAPI) -> None:
208
135
 
209
136
  async with AsyncSession(app.state._db_engine) as db_session:
210
137
  images_repository = ImagesRepository(db_session)
@@ -228,3 +155,117 @@ async def discover_images_on_filesystem(app: FastAPI):
228
155
 
229
156
  # monitor if images have been manually added
230
157
  asyncio.create_task(monitor_images_on_filesystem(app))
158
+
159
+
160
+ async def update_disk_checksums(updated_disks: List[str]) -> None:
161
+ """
162
+ Update the checksum of a list of disks in the database.
163
+
164
+ :param updated_disks: list of updated disks
165
+ """
166
+
167
+ from gns3server.api.server import app
168
+ async with AsyncSession(app.state._db_engine) as db_session:
169
+ images_repository = ImagesRepository(db_session)
170
+ for path in updated_disks:
171
+ image = await images_repository.get_image(path)
172
+ if image:
173
+ log.info(f"Updating image '{path}' in the database")
174
+ checksum = await wait_run_in_executor(md5sum, path, cache_to_md5file=False)
175
+ if image.checksum != checksum:
176
+ await images_repository.update_image(path, checksum, "md5")
177
+
178
+ class EventHandler(PatternMatchingEventHandler):
179
+ """
180
+ Watchdog event handler.
181
+ """
182
+
183
+ def __init__(self, queue: asyncio.Queue, loop: asyncio.BaseEventLoop, **kwargs):
184
+
185
+ self._loop = loop
186
+ self._queue = queue
187
+
188
+ # ignore temporary files, md5sum files, hidden files and directories
189
+ super().__init__(ignore_patterns=["*.tmp", "*.md5sum", ".*"], ignore_directories = True, **kwargs)
190
+
191
+ def on_closed(self, event: FileSystemEvent) -> None:
192
+ # monitor for closed files (e.g. when a file has finished to be copied)
193
+ if "/lib/" in event.src_path or "/lib64/" in event.src_path:
194
+ return # ignore custom IOU libraries
195
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
196
+
197
+ class EventIterator(object):
198
+ """
199
+ Watchdog Event iterator.
200
+ """
201
+
202
+ def __init__(self, queue: asyncio.Queue):
203
+ self.queue = queue
204
+
205
+ def __aiter__(self):
206
+ return self
207
+
208
+ async def __anext__(self):
209
+
210
+ item = await self.queue.get()
211
+ if item is None:
212
+ raise StopAsyncIteration
213
+ return item
214
+
215
+ async def monitor_images_on_filesystem(app: FastAPI):
216
+
217
+ def watchdog(
218
+ path: str,
219
+ queue: asyncio.Queue,
220
+ loop: asyncio.BaseEventLoop,
221
+ app: FastAPI, recursive: bool = False
222
+ ) -> None:
223
+ """
224
+ Thread to monitor a directory for new images.
225
+ """
226
+
227
+ handler = EventHandler(queue, loop)
228
+ observer = Observer()
229
+ observer.schedule(handler, str(path), recursive=recursive)
230
+ observer.start()
231
+ log.info(f"Monitoring for new images in '{path}'")
232
+ while True:
233
+ time.sleep(1)
234
+ # stop when the app is exiting
235
+ if app.state.exiting:
236
+ observer.stop()
237
+ observer.join(10)
238
+ log.info(f"Stopping monitoring for new images in '{path}'")
239
+ loop.call_soon_threadsafe(queue.put_nowait, None)
240
+ break
241
+
242
+ queue = asyncio.Queue()
243
+ loop = asyncio.get_event_loop()
244
+ server_config = Config.instance().settings.Server
245
+ image_dir = os.path.expanduser(server_config.images_path)
246
+ asyncio.get_event_loop().run_in_executor(None, watchdog,image_dir, queue, loop, app, True)
247
+
248
+ async for filesystem_event in EventIterator(queue):
249
+ # read the file system event from the queue
250
+ image_path = filesystem_event.src_path
251
+ expected_image_type = None
252
+ if "IOU" in image_path:
253
+ expected_image_type = "iou"
254
+ elif "QEMU" in image_path:
255
+ expected_image_type = "qemu"
256
+ elif "IOS" in image_path:
257
+ expected_image_type = "ios"
258
+ async with AsyncSession(app.state._db_engine) as db_session:
259
+ images_repository = ImagesRepository(db_session)
260
+ try:
261
+ image = await read_image_info(image_path, expected_image_type)
262
+ except InvalidImageError as e:
263
+ log.warning(str(e))
264
+ continue
265
+ try:
266
+ if await images_repository.get_image(image_path):
267
+ continue
268
+ await images_repository.add_image(**image)
269
+ log.info(f"Discovered image '{image_path}' has been added to the database")
270
+ except SQLAlchemyError as e:
271
+ log.warning(f"Error while adding image '{image_path}' to the database: {e}")
Binary file
Binary file
Binary file
Binary file
@@ -147,7 +147,7 @@ class ServerSettings(BaseModel):
147
147
  allow_remote_console: bool = False
148
148
  enable_builtin_templates: bool = True
149
149
  install_builtin_appliances: bool = True
150
- model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True, use_enum_values=True)
150
+ model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True)
151
151
 
152
152
  @field_validator("additional_images_paths", mode="before")
153
153
  @classmethod
gns3server/server.py CHANGED
@@ -267,9 +267,9 @@ class Server:
267
267
  else:
268
268
  log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'")
269
269
 
270
- # we only support Python 3 version >= 3.8
271
- if sys.version_info < (3, 8, 0):
272
- raise SystemExit("Python 3.8 or higher is required")
270
+ # we only support Python 3 version >= 3.9
271
+ if sys.version_info < (3, 9, 0):
272
+ raise SystemExit("Python 3.9 or higher is required")
273
273
 
274
274
  log.info(
275
275
  "Running with Python {major}.{minor}.{micro} and has PID {pid}".format(
@@ -14,8 +14,9 @@
14
14
  # You should have received a copy of the GNU General Public License
15
15
  # along with this program. If not, see <http://www.gnu.org/licenses/>.
16
16
 
17
-
18
- from jose import JWTError, jwt
17
+ from joserfc import jwt
18
+ from joserfc.jwk import OctKey
19
+ from joserfc.errors import JoseError
19
20
  from datetime import datetime, timedelta, timezone
20
21
  import bcrypt
21
22
 
@@ -56,7 +57,8 @@ class AuthService:
56
57
  secret_key = DEFAULT_JWT_SECRET_KEY
57
58
  log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
58
59
  algorithm = Config.instance().settings.Controller.jwt_algorithm
59
- encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=algorithm)
60
+ key = OctKey.import_key(secret_key)
61
+ encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key)
60
62
  return encoded_jwt
61
63
 
62
64
  def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
@@ -73,11 +75,12 @@ class AuthService:
73
75
  secret_key = DEFAULT_JWT_SECRET_KEY
74
76
  log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
75
77
  algorithm = Config.instance().settings.Controller.jwt_algorithm
76
- payload = jwt.decode(token, secret_key, algorithms=[algorithm])
77
- username: str = payload.get("sub")
78
+ key = OctKey.import_key(secret_key)
79
+ payload = jwt.decode(token, key, algorithms=[algorithm])
80
+ username: str = payload.claims.get("sub")
78
81
  if username is None:
79
82
  raise credentials_exception
80
83
  token_data = TokenData(username=username)
81
- except (JWTError, ValidationError):
84
+ except (JoseError, ValidationError, ValueError):
82
85
  raise credentials_exception
83
86
  return token_data.username
Binary file