Coverage for core/data_backends.py: 37%
122 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
2import re
3import tempfile
4from contextlib import contextmanager
5from pathlib import Path, PurePosixPath
6from urllib.parse import urlparse
8from django import forms
9from django.core.exceptions import ImproperlyConfigured
10from django.utils.translation import gettext as _
12from netbox.data_backends import DataBackend
13from netbox.utils import register_data_backend
14from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
15from utilities.proxy import resolve_proxies
16from utilities.socks import ProxyPoolManager
18from .exceptions import SyncError
20__all__ = (
21 'GitBackend',
22 'LocalBackend',
23 'S3Backend',
24 'url_has_embedded_credentials',
25)
27logger = logging.getLogger('netbox.data_backends')
30def url_has_embedded_credentials(url):
31 """
32 Check if a URL contains embedded credentials (username in the URL).
34 URLs like 'https://user@bitbucket.org/...' have embedded credentials.
35 This is used to avoid passing explicit credentials to dulwich when the
36 URL already contains them, which would cause authentication conflicts.
37 """
38 parsed = urlparse(url)
39 return bool(parsed.username)
42@register_data_backend()
43class LocalBackend(DataBackend):
44 name = 'local'
45 label = _('Local')
46 is_local = True
48 @contextmanager
49 def fetch(self):
50 logger.debug("Data source type is local; skipping fetch")
51 local_path = urlparse(self.url).path # Strip file:// scheme
53 yield local_path
56@register_data_backend()
57class GitBackend(DataBackend):
58 name = 'git'
59 label = 'Git'
60 parameters = {
61 'username': forms.CharField(
62 required=False,
63 label=_('Username'),
64 widget=forms.TextInput(attrs={'class': 'form-control'}),
65 help_text=_("Only used for cloning with HTTP(S)"),
66 ),
67 'password': forms.CharField(
68 required=False,
69 label=_('Password'),
70 widget=forms.TextInput(attrs={'class': 'form-control'}),
71 help_text=_("Only used for cloning with HTTP(S)"),
72 ),
73 'branch': forms.CharField(
74 required=False,
75 label=_('Branch'),
76 widget=forms.TextInput(attrs={'class': 'form-control'})
77 )
78 }
79 sensitive_parameters = ['password']
81 def init_config(self):
82 from dulwich.config import ConfigDict
84 # Initialize backend config
85 config = ConfigDict()
86 self.socks_proxy = None
88 # Apply HTTP proxy (if configured)
89 proxies = resolve_proxies(url=self.url, context={'client': self}) or {}
90 if proxy := proxies.get(self.url_scheme):
91 if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
92 raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
94 if self.url_scheme in ('http', 'https'):
95 config.set("http", "proxy", proxy)
96 if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
97 self.socks_proxy = proxy
99 return config
101 @contextmanager
102 def fetch(self):
103 from dulwich import porcelain
105 local_path = tempfile.TemporaryDirectory()
107 clone_args = {
108 "branch": self.params.get('branch'),
109 "config": self.config,
110 "errstream": porcelain.NoneStream(),
111 }
113 # check if using socks for proxy - if so need to use custom pool_manager
114 if self.socks_proxy:
115 clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
117 if self.url_scheme in ('http', 'https'):
118 # Only pass explicit credentials if URL doesn't already contain embedded username
119 # to avoid credential conflicts (see #20902)
120 if not url_has_embedded_credentials(self.url) and self.params.get('username'):
121 clone_args.update(
122 {
123 "username": self.params.get('username'),
124 "password": self.params.get('password'),
125 }
126 )
127 if self.url_scheme:
128 clone_args["quiet"] = True
129 clone_args["depth"] = 1
131 logger.debug(f"Cloning git repo: {self.url}")
132 try:
133 porcelain.clone(self.url, local_path.name, **clone_args)
134 except BaseException as e:
135 raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
137 yield local_path.name
139 local_path.cleanup()
142@register_data_backend()
143class S3Backend(DataBackend):
144 name = 'amazon-s3'
145 label = 'Amazon S3'
146 parameters = {
147 'aws_access_key_id': forms.CharField(
148 label=_('AWS access key ID'),
149 widget=forms.TextInput(attrs={'class': 'form-control'})
150 ),
151 'aws_secret_access_key': forms.CharField(
152 label=_('AWS secret access key'),
153 widget=forms.TextInput(attrs={'class': 'form-control'})
154 ),
155 }
156 sensitive_parameters = ['aws_secret_access_key']
158 REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'
160 def init_config(self):
161 from botocore.config import Config as Boto3Config
163 # Initialize backend config
164 return Boto3Config(
165 proxies=resolve_proxies(url=self.url, context={'client': self}),
166 )
168 @contextmanager
169 def fetch(self):
170 import boto3
172 local_path = tempfile.TemporaryDirectory()
173 local_root = Path(local_path.name).resolve()
175 # Initialize the S3 resource and bucket
176 aws_access_key_id = self.params.get('aws_access_key_id')
177 aws_secret_access_key = self.params.get('aws_secret_access_key')
178 s3 = boto3.resource(
179 's3',
180 region_name=self._region_name,
181 aws_access_key_id=aws_access_key_id,
182 aws_secret_access_key=aws_secret_access_key,
183 config=self.config,
184 endpoint_url=self._endpoint_url
185 )
186 bucket = s3.Bucket(self._bucket_name)
188 try:
189 # Download all files within the specified path
190 for obj in bucket.objects.filter(Prefix=self._remote_path):
191 local_filename = self._resolve_local_path(local_root, obj.key)
192 # Build local path
193 local_filename.parent.mkdir(parents=True, exist_ok=True)
194 bucket.download_file(obj.key, str(local_filename))
196 yield local_path.name
197 finally:
198 local_path.cleanup()
200 @staticmethod
201 def _resolve_local_path(local_root, key):
202 # S3 object keys are POSIX-style paths. Strip any leading separator so the key
203 # joins onto the temp directory rather than replacing it, then ensure the
204 # resolved destination remains within the temp directory to prevent path
205 # traversal via crafted object keys.
206 key_parts = PurePosixPath(key.lstrip('/')).parts
207 local_filename = local_root.joinpath(*key_parts).resolve()
208 if not local_filename.is_relative_to(local_root) or local_filename == local_root:
209 raise SyncError(
210 _("Invalid S3 object key '{key}': resolves outside of the local data directory").format(key=key)
211 )
212 return local_filename
214 @property
215 def _region_name(self):
216 domain = urlparse(self.url).netloc
217 if m := re.match(self.REGION_REGEX, domain):
218 return m.group(1)
219 return None
221 @property
222 def _bucket_name(self):
223 url_path = urlparse(self.url).path.lstrip('/')
224 return url_path.split('/')[0]
226 @property
227 def _endpoint_url(self):
228 url_path = urlparse(self.url)
229 return url_path._replace(params="", fragment="", query="", path="").geturl()
231 @property
232 def _remote_path(self):
233 url_path = urlparse(self.url).path.lstrip('/')
234 if '/' in url_path:
235 return url_path.split('/', 1)[1]
236 return ''