Skip to content

Commit 4e65117

Browse files
Closes #18627: Proxy routing (#18681)
* Introduce proxy routing * Misc cleanup * Document PROXY_ROUTERS parameter
1 parent 7c52698 commit 4e65117

File tree

9 files changed

+108
-23
lines changed

9 files changed

+108
-23
lines changed

docs/configuration/system.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
6464

6565
## HTTP_PROXIES
6666

67-
Default: None
67+
Default: Empty
6868

6969
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
7070

@@ -75,6 +75,8 @@ HTTP_PROXIES = {
7575
}
7676
```
7777

78+
If more flexibility is needed in determining which proxy to use for a given request, consider implementing one or more custom proxy routers via the [`PROXY_ROUTERS`](#proxy_routers) parameter.
79+
7880
---
7981

8082
## INTERNAL_IPS
@@ -160,6 +162,16 @@ The file path to the location where media files (such as image attachments) are
160162

161163
---
162164

165+
## PROXY_ROUTERS
166+
167+
Default: `["utilities.proxy.DefaultProxyRouter"]`
168+
169+
A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class.
170+
171+
The `route()` method on each class must return a dictionary of candidate proxies arranged by protocol (e.g. `http` and/or `https`), or None if no viable proxy can be determined. The default class, `DefaultProxyRouter`, simply returns the content of [`HTTP_PROXIES`](#http_proxies).
172+
173+
---
174+
163175
## REPORTS_ROOT
164176

165177
Default: `$INSTALL_ROOT/netbox/reports/`

netbox/core/data_backends.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77
from urllib.parse import urlparse
88

99
from django import forms
10-
from django.conf import settings
1110
from django.core.exceptions import ImproperlyConfigured
1211
from django.utils.translation import gettext as _
1312

1413
from netbox.data_backends import DataBackend
1514
from netbox.utils import register_data_backend
1615
from utilities.constants import HTTP_PROXY_SUPPORTED_SCHEMAS, HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS
16+
from utilities.proxy import resolve_proxies
1717
from utilities.socks import ProxyPoolManager
1818
from .exceptions import SyncError
1919

@@ -70,18 +70,18 @@ def init_config(self):
7070

7171
# Initialize backend config
7272
config = ConfigDict()
73-
self.use_socks = False
73+
self.socks_proxy = None
7474

7575
# Apply HTTP proxy (if configured)
76-
if settings.HTTP_PROXIES:
77-
if proxy := settings.HTTP_PROXIES.get(self.url_scheme, None):
78-
if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
79-
raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
76+
proxies = resolve_proxies(url=self.url, context={'client': self}) or {}
77+
if proxy := proxies.get(self.url_scheme):
78+
if urlparse(proxy).scheme not in HTTP_PROXY_SUPPORTED_SCHEMAS:
79+
raise ImproperlyConfigured(f"Unsupported Git DataSource proxy scheme: {urlparse(proxy).scheme}")
8080

81-
if self.url_scheme in ('http', 'https'):
82-
config.set("http", "proxy", proxy)
83-
if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
84-
self.use_socks = True
81+
if self.url_scheme in ('http', 'https'):
82+
config.set("http", "proxy", proxy)
83+
if urlparse(proxy).scheme in HTTP_PROXY_SUPPORTED_SOCK_SCHEMAS:
84+
self.socks_proxy = proxy
8585

8686
return config
8787

@@ -98,8 +98,8 @@ def fetch(self):
9898
}
9999

100100
# check if using socks for proxy - if so need to use custom pool_manager
101-
if self.use_socks:
102-
clone_args['pool_manager'] = ProxyPoolManager(settings.HTTP_PROXIES.get(self.url_scheme))
101+
if self.socks_proxy:
102+
clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
103103

104104
if self.url_scheme in ('http', 'https'):
105105
if self.params.get('username'):
@@ -147,7 +147,7 @@ def init_config(self):
147147

148148
# Initialize backend config
149149
return Boto3Config(
150-
proxies=settings.HTTP_PROXIES,
150+
proxies=resolve_proxies(url=self.url, context={'client': self}),
151151
)
152152

153153
@contextmanager

netbox/core/jobs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.conf import settings
66
from netbox.jobs import JobRunner, system_job
77
from netbox.search.backends import search_backend
8+
from utilities.proxy import resolve_proxies
89
from .choices import DataSourceStatusChoices, JobIntervalChoices
910
from .exceptions import SyncError
1011
from .models import DataSource
@@ -71,7 +72,7 @@ def send_census_report():
7172
url=settings.CENSUS_URL,
7273
params=census_data,
7374
timeout=3,
74-
proxies=settings.HTTP_PROXIES
75+
proxies=resolve_proxies(url=settings.CENSUS_URL)
7576
)
7677
except requests.exceptions.RequestException:
7778
pass

netbox/core/plugins.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from netbox.plugins import PluginConfig
1212
from netbox.registry import registry
1313
from utilities.datetime import datetime_from_timestamp
14+
from utilities.proxy import resolve_proxies
1415

1516
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
1617
CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed'
@@ -120,10 +121,11 @@ def get_catalog_plugins():
120121
def get_pages():
121122
# TODO: pagination is currently broken in API
122123
payload = {'page': '1', 'per_page': '50'}
124+
proxies = resolve_proxies(url=settings.PLUGIN_CATALOG_URL)
123125
first_page = session.get(
124126
settings.PLUGIN_CATALOG_URL,
125127
headers={'User-Agent': USER_AGENT_STRING},
126-
proxies=settings.HTTP_PROXIES,
128+
proxies=proxies,
127129
timeout=3,
128130
params=payload
129131
).json()
@@ -135,7 +137,7 @@ def get_pages():
135137
next_page = session.get(
136138
settings.PLUGIN_CATALOG_URL,
137139
headers={'User-Agent': USER_AGENT_STRING},
138-
proxies=settings.HTTP_PROXIES,
140+
proxies=proxies,
139141
timeout=3,
140142
params=payload
141143
).json()

netbox/extras/dashboard/widgets.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from extras.choices import BookmarkOrderingChoices
1818
from utilities.object_types import object_type_identifier, object_type_name
1919
from utilities.permissions import get_permission_for_model
20+
from utilities.proxy import resolve_proxies
2021
from utilities.querydict import dict_to_querydict
2122
from utilities.templatetags.builtins.filters import render_markdown
2223
from utilities.views import get_viewname
@@ -330,7 +331,7 @@ def get_feed(self):
330331
response = requests.get(
331332
url=self.config['feed_url'],
332333
headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'},
333-
proxies=settings.HTTP_PROXIES,
334+
proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}),
334335
timeout=3
335336
)
336337
response.raise_for_status()

netbox/extras/management/commands/housekeeping.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from core.models import Job, ObjectChange
1313
from netbox.config import Config
14+
from utilities.proxy import resolve_proxies
1415

1516

1617
class Command(BaseCommand):
@@ -107,7 +108,7 @@ def handle(self, *args, **options):
107108
response = requests.get(
108109
url=settings.RELEASE_CHECK_URL,
109110
headers=headers,
110-
proxies=settings.HTTP_PROXIES
111+
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
111112
)
112113
response.raise_for_status()
113114

netbox/extras/webhooks.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import logging
44

55
import requests
6-
from django.conf import settings
76
from django_rq import job
87
from jinja2.exceptions import TemplateError
98

9+
from utilities.proxy import resolve_proxies
1010
from .constants import WEBHOOK_EVENT_TYPES
1111

1212
logger = logging.getLogger('netbox.webhooks')
@@ -63,9 +63,10 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
6363
raise e
6464

6565
# Prepare the HTTP request
66+
url = webhook.render_payload_url(context)
6667
params = {
6768
'method': webhook.http_method,
68-
'url': webhook.render_payload_url(context),
69+
'url': url,
6970
'headers': headers,
7071
'data': body.encode('utf8'),
7172
}
@@ -88,7 +89,8 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username,
8889
session.verify = webhook.ssl_verification
8990
if webhook.ca_file_path:
9091
session.verify = webhook.ca_file_path
91-
response = session.send(prepared_request, proxies=settings.HTTP_PROXIES)
92+
proxies = resolve_proxies(url=url, context={'client': webhook})
93+
response = session.send(prepared_request, proxies=proxies)
9294

9395
if 200 <= response.status_code <= 299:
9496
logger.info(f"Request succeeded; response status {response.status_code}")

netbox/netbox/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.contrib.messages import constants as messages
1010
from django.core.exceptions import ImproperlyConfigured, ValidationError
1111
from django.core.validators import URLValidator
12+
from django.utils.module_loading import import_string
1213
from django.utils.translation import gettext_lazy as _
1314

1415
from netbox.config import PARAMS as CONFIG_PARAMS
@@ -116,7 +117,7 @@
116117
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
117118
FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
118119
GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
119-
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
120+
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
120121
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
121122
ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False)
122123
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
@@ -131,6 +132,7 @@
131132
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False)
132133
PLUGINS = getattr(configuration, 'PLUGINS', [])
133134
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
135+
PROXY_ROUTERS = getattr(configuration, 'PROXY_ROUTERS', ['utilities.proxy.DefaultProxyRouter'])
134136
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
135137
REDIS = getattr(configuration, 'REDIS') # Required
136138
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
@@ -201,6 +203,14 @@
201203
"RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox"
202204
)
203205

206+
# Validate configured proxy routers
207+
for path in PROXY_ROUTERS:
208+
if type(path) is str:
209+
try:
210+
import_string(path)
211+
except ImportError:
212+
raise ImproperlyConfigured(f"Invalid path in PROXY_ROUTERS: {path}")
213+
204214

205215
#
206216
# Database
@@ -577,6 +587,7 @@ def _setting(name, default=None):
577587
sample_rate=SENTRY_SAMPLE_RATE,
578588
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
579589
send_default_pii=SENTRY_SEND_DEFAULT_PII,
590+
# TODO: Support proxy routing
580591
http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None,
581592
https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None
582593
)

netbox/utilities/proxy.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from django.conf import settings
2+
from django.utils.module_loading import import_string
3+
from urllib.parse import urlparse
4+
5+
__all__ = (
6+
'DefaultProxyRouter',
7+
'resolve_proxies',
8+
)
9+
10+
11+
class DefaultProxyRouter:
12+
"""
13+
Base class for a proxy router.
14+
"""
15+
@staticmethod
16+
def _get_protocol_from_url(url):
17+
"""
18+
Determine the applicable protocol (e.g. HTTP or HTTPS) from the given URL.
19+
"""
20+
return urlparse(url).scheme
21+
22+
def route(self, url=None, protocol=None, context=None):
23+
"""
24+
Returns the appropriate proxy given a URL or protocol. Arbitrary context data may also be passed where
25+
available.
26+
27+
Args:
28+
url: The specific request URL for which the proxy will be used (if known)
29+
protocol: The protocol in use (e.g. http or https) (if known)
30+
context: Additional context to aid in proxy selection. May include e.g. the requesting client.
31+
"""
32+
if url and protocol is None:
33+
protocol = self._get_protocol_from_url(url)
34+
if protocol and protocol in settings.HTTP_PROXIES:
35+
return {
36+
protocol: settings.HTTP_PROXIES[protocol]
37+
}
38+
return settings.HTTP_PROXIES
39+
40+
41+
def resolve_proxies(url=None, protocol=None, context=None):
42+
"""
43+
Return a dictionary of candidate proxies (compatible with the requests module), or None.
44+
45+
Args:
46+
url: The specific request URL for which the proxy will be used (optional)
47+
protocol: The protocol in use (e.g. http or https) (optional)
48+
context: Arbitrary additional context to aid in proxy selection (optional)
49+
"""
50+
context = context or {}
51+
52+
for item in settings.PROXY_ROUTERS:
53+
router = import_string(item) if type(item) is str else item
54+
if proxies := router().route(url=url, protocol=protocol, context=context):
55+
return proxies

0 commit comments

Comments
 (0)