django-ip-path-restrict 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ip-path-restrict
3
+ Version: 1.0.0
4
+ Summary: Restrict Django paths to allowed IP addresses, with optional Cloudflare support.
5
+ Author: Peter Stavrou
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Peter
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/peterstavrou/django-ip-path-restrict
29
+ Project-URL: Repository, https://github.com/peterstavrou/django-ip-path-restrict
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Framework :: Django
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.11
35
+ Classifier: Programming Language :: Python :: 3.12
36
+ Classifier: Programming Language :: Python :: 3.13
37
+ Requires-Python: >=3.11
38
+ Description-Content-Type: text/markdown
39
+ License-File: LICENSE
40
+ Requires-Dist: Django>=5.2
41
+ Dynamic: license-file
42
+
43
+ # django-ip-path-restrict
44
+
45
+ Restrict access to specific URLs so that only users with whitelisted IP addresses can access them.
46
+
47
+ Supports IPv4 and IPv6 addresses, IP ranges, and optional Cloudflare support.
48
+
49
+ ## Requirements
50
+
51
+ - Python 3.11+
52
+ - Django 5.2+
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install django-ip-path-restrict
58
+ ```
59
+
60
+ In the `settings.py` file, add the middleware near the top of `MIDDLEWARE`, preferably immediately after `SecurityMiddleware`:
61
+
62
+ ```python
63
+ MIDDLEWARE = [
64
+ 'django.middleware.security.SecurityMiddleware',
65
+ 'django_ip_path_restrict.middleware.RestrictPathsByIPMiddleware',
66
+ # Other middleware...
67
+ ]
68
+ ```
69
+
70
+ Placing the middleware near the top allows unauthorised requests to be blocked early, before other middleware processes them.
71
+
72
+ ## Configuration
73
+
74
+ Add `PROTECTED_PATHS` to `settings.py` to specify which URLs to restrict based on IP addresses.
75
+
76
+ ```python
77
+ PROTECTED_PATHS = [
78
+ '/admin/*', # * matches /admin/ and all paths under it
79
+ '/login/', # Only matches /login/
80
+ ]
81
+ ```
82
+
83
+ Add `ALLOWED_IPS` to specify which IP addresses are allowed to access the protected URLs.
84
+
85
+ IPv4, IPv6 and CIDR IP ranges are supported:
86
+
87
+ ```python
88
+ ALLOWED_IPS = [
89
+ '203.0.113.10', # IPv4 address
90
+ '2001:db8::10', # IPv6 address
91
+ '198.51.100.0/24', # IPv4 address range
92
+ ]
93
+ ```
94
+
95
+ Replace these example IP addresses with the addresses you want to allow.
96
+
97
+ Users who try to access protected URLs from an IP address that is not in `ALLOWED_IPS` will receive a 404 Not Found response.
98
+
99
+ All other URLs remain accessible as normal.
100
+
101
+ If your project already uses django-environ or another settings library, you can load `ALLOWED_IPS` from an environment variable instead.
102
+
103
+ ### Local development
104
+
105
+ IP restrictions apply regardless of `DEBUG`. To access protected URLs locally, add the localhost IP addresses to `ALLOWED_IPS` in your development settings:
106
+
107
+ ```python
108
+ ALLOWED_IPS = [
109
+ '127.0.0.1', # IPv4 localhost
110
+ '::1', # IPv6 localhost
111
+ ]
112
+ ```
113
+
114
+ Keep these addresses in your development settings rather than adding them to your production allowlist.
115
+
116
+ ## Path matching
117
+
118
+
119
+ | Pattern | Paths matched |
120
+ | --- | --- |
121
+ | `/admin` | `/admin` only |
122
+ | `/admin/` | `/admin/` only |
123
+ | `/admin/*` | `/admin/` and all paths under it, but not `/admin` |
124
+ | `/admin*` | All paths starting with `/admin`, including `/administration` |
125
+
126
+
127
+ For a standard Django admin installation, use `/admin/*`.
128
+
129
+ The only supported wildcard is `*`, which must appear at the end of a path.
130
+
131
+ ## Use with a reverse proxy
132
+
133
+ When Nginx, Apache, LiteSpeed, or another reverse proxy sits in front of Django, `REMOTE_ADDR` may contain the proxy's IP address instead of the visitor's IP address.
134
+
135
+ Add the IP addresses of trusted reverse proxies to `IP_RESTRICT_TRUSTED_PROXY_IPS` in `settings.py`:
136
+
137
+ ```python
138
+ IP_RESTRICT_TRUSTED_PROXY_IPS = [
139
+ '127.0.0.1', # IPv4 localhost
140
+ '::1', # IPv6 localhost
141
+ ]
142
+ ```
143
+
144
+ These addresses are for reverse proxies running on the same server as Django. If your proxy runs on another server, use its IP address instead.
145
+
146
+ The middleware only trusts `X-Real-IP` when `REMOTE_ADDR` belongs to a trusted proxy. Otherwise, the header is ignored. If a trusted proxy does not provide a valid `X-Real-IP`, access to protected URLs is denied.
147
+
148
+ For Nginx, add the following line to the location block that forwards requests to Django:
149
+
150
+ ```nginx
151
+ proxy_set_header X-Real-IP $remote_addr;
152
+ ```
153
+
154
+ Do not add `0.0.0.0/0` or `::/0` to `IP_RESTRICT_TRUSTED_PROXY_IPS`. Ensure the Django application server is not directly accessible from untrusted networks.
155
+
156
+ This feature supports TCP-connected proxies, not Unix sockets.
157
+
158
+ ## Use with Cloudflare
159
+
160
+ Without Cloudflare or another reverse proxy, Django normally receives the visitor's IP address directly.
161
+
162
+ When your domain's DNS record is set to **Proxied** (orange cloud) in Cloudflare, your server receives a Cloudflare IP address instead of the visitor's IP address.
163
+
164
+ Cloudflare includes the visitor's original IP address in the `CF-Connecting-IP` request header.
165
+
166
+ Without the Cloudflare configuration, `ALLOWED_IPS` cannot match the real visitor because Django only sees Cloudflare's IP address.
167
+
168
+ The middleware uses Cloudflare's IP ranges to verify that the connecting IP address belongs to Cloudflare before trusting `CF-Connecting-IP`. It never trusts `X-Forwarded-For` and ignores spoofed `CF-Connecting-IP` headers from other sources.
169
+
170
+ If Cloudflare sits in front of Nginx or another reverse proxy, configure `IP_RESTRICT_TRUSTED_PROXY_IPS` first. The middleware verifies the Cloudflare IP received through that trusted proxy before reading `CF-Connecting-IP`.
171
+
172
+ To use the Cloudflare IP update command, add `django_ip_path_restrict` to `INSTALLED_APPS` in `settings.py`:
173
+
174
+ ```python
175
+ INSTALLED_APPS = [
176
+ # Existing apps...
177
+ 'django_ip_path_restrict',
178
+ ]
179
+ ```
180
+
181
+ Adding the package to `INSTALLED_APPS` is not required if you only use the middleware.
182
+
183
+ ### Configure the cache file
184
+
185
+ Set a location where the package can save Cloudflare's IP ranges. This should be outside publicly accessible directories such as `static` and `media`.
186
+
187
+ If the directory doesn't exist, the package will create it automatically.
188
+
189
+ ```python
190
+ CLOUDFLARE_IPS_FILE = BASE_DIR / 'private' / 'cloudflare_ips.json'
191
+ ```
192
+
193
+ ### Download Cloudflare IP ranges
194
+
195
+ The package does not include Cloudflare's IP ranges. Run the following command after installation to download them:
196
+
197
+ ```bash
198
+ python manage.py update_cloudflare_ips
199
+ ```
200
+
201
+ ### Schedule daily updates
202
+
203
+ You can use a cron job to update Cloudflare's IP ranges automatically once per day.
204
+
205
+ **Linux (crontab)**
206
+
207
+ ```cron
208
+ 0 4 * * * cd /home/USER/project && /home/USER/project/.venv/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
209
+ ```
210
+
211
+ **cPanel**
212
+
213
+ In cPanel, go to **Advanced > Cron Jobs** and set the schedule to run daily at 4:00 AM.
214
+
215
+ Enter the following in the Command field:
216
+
217
+ ```bash
218
+ cd /home/USER/project && /home/USER/virtualenv/project/3.12/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
219
+ ```
220
+
221
+ Replace the example paths with your actual project and virtual environment paths.
222
+
223
+ ### Automatic updates (optional)
224
+
225
+ If you cannot schedule updates using a cron job, you can enable automatic updates when a protected URL is accessed:
226
+
227
+ ```python
228
+ CLOUDFLARE_IPS_AUTO_UPDATE = True
229
+ ```
230
+
231
+ With this setting enabled, the next request to a protected URL attempts to download Cloudflare's IP ranges if the cache file is missing, invalid, or more than 24 hours old. The request may take longer due to the download, which has a five-second timeout.
232
+
233
+ If the download fails and no valid cache file is available, requests relying on Cloudflare IP detection will be blocked.
234
+
235
+ ### Share Cloudflare IP ranges between sites on the same server
236
+
237
+ Set each site's `CLOUDFLARE_IPS_FILE` to the same shared cache file, outside directories publicly accessible by your websites. Each site reads the file directly and automatically detects when it has been updated.
238
+
239
+ ```python
240
+ CLOUDFLARE_IPS_FILE = '/home/USER/private/cloudflare_ips.json'
241
+ ```
242
+
243
+ Run `update_cloudflare_ips` once per day from one site's virtual environment. It safely updates the shared file, preventing other sites from reading incomplete data.
244
+
245
+ ## Author
246
+
247
+ Created by [Peter Stavrou](https://peterstavrou.com/).
@@ -0,0 +1,205 @@
1
+ # django-ip-path-restrict
2
+
3
+ Restrict access to specific URLs so that only users with whitelisted IP addresses can access them.
4
+
5
+ Supports IPv4 and IPv6 addresses, IP ranges, and optional Cloudflare support.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11+
10
+ - Django 5.2+
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install django-ip-path-restrict
16
+ ```
17
+
18
+ In the `settings.py` file, add the middleware near the top of `MIDDLEWARE`, preferably immediately after `SecurityMiddleware`:
19
+
20
+ ```python
21
+ MIDDLEWARE = [
22
+ 'django.middleware.security.SecurityMiddleware',
23
+ 'django_ip_path_restrict.middleware.RestrictPathsByIPMiddleware',
24
+ # Other middleware...
25
+ ]
26
+ ```
27
+
28
+ Placing the middleware near the top allows unauthorised requests to be blocked early, before other middleware processes them.
29
+
30
+ ## Configuration
31
+
32
+ Add `PROTECTED_PATHS` to `settings.py` to specify which URLs to restrict based on IP addresses.
33
+
34
+ ```python
35
+ PROTECTED_PATHS = [
36
+ '/admin/*', # * matches /admin/ and all paths under it
37
+ '/login/', # Only matches /login/
38
+ ]
39
+ ```
40
+
41
+ Add `ALLOWED_IPS` to specify which IP addresses are allowed to access the protected URLs.
42
+
43
+ IPv4, IPv6 and CIDR IP ranges are supported:
44
+
45
+ ```python
46
+ ALLOWED_IPS = [
47
+ '203.0.113.10', # IPv4 address
48
+ '2001:db8::10', # IPv6 address
49
+ '198.51.100.0/24', # IPv4 address range
50
+ ]
51
+ ```
52
+
53
+ Replace these example IP addresses with the addresses you want to allow.
54
+
55
+ Users who try to access protected URLs from an IP address that is not in `ALLOWED_IPS` will receive a 404 Not Found response.
56
+
57
+ All other URLs remain accessible as normal.
58
+
59
+ If your project already uses django-environ or another settings library, you can load `ALLOWED_IPS` from an environment variable instead.
60
+
61
+ ### Local development
62
+
63
+ IP restrictions apply regardless of `DEBUG`. To access protected URLs locally, add the localhost IP addresses to `ALLOWED_IPS` in your development settings:
64
+
65
+ ```python
66
+ ALLOWED_IPS = [
67
+ '127.0.0.1', # IPv4 localhost
68
+ '::1', # IPv6 localhost
69
+ ]
70
+ ```
71
+
72
+ Keep these addresses in your development settings rather than adding them to your production allowlist.
73
+
74
+ ## Path matching
75
+
76
+
77
+ | Pattern | Paths matched |
78
+ | --- | --- |
79
+ | `/admin` | `/admin` only |
80
+ | `/admin/` | `/admin/` only |
81
+ | `/admin/*` | `/admin/` and all paths under it, but not `/admin` |
82
+ | `/admin*` | All paths starting with `/admin`, including `/administration` |
83
+
84
+
85
+ For a standard Django admin installation, use `/admin/*`.
86
+
87
+ The only supported wildcard is `*`, which must appear at the end of a path.
88
+
89
+ ## Use with a reverse proxy
90
+
91
+ When Nginx, Apache, LiteSpeed, or another reverse proxy sits in front of Django, `REMOTE_ADDR` may contain the proxy's IP address instead of the visitor's IP address.
92
+
93
+ Add the IP addresses of trusted reverse proxies to `IP_RESTRICT_TRUSTED_PROXY_IPS` in `settings.py`:
94
+
95
+ ```python
96
+ IP_RESTRICT_TRUSTED_PROXY_IPS = [
97
+ '127.0.0.1', # IPv4 localhost
98
+ '::1', # IPv6 localhost
99
+ ]
100
+ ```
101
+
102
+ These addresses are for reverse proxies running on the same server as Django. If your proxy runs on another server, use its IP address instead.
103
+
104
+ The middleware only trusts `X-Real-IP` when `REMOTE_ADDR` belongs to a trusted proxy. Otherwise, the header is ignored. If a trusted proxy does not provide a valid `X-Real-IP`, access to protected URLs is denied.
105
+
106
+ For Nginx, add the following line to the location block that forwards requests to Django:
107
+
108
+ ```nginx
109
+ proxy_set_header X-Real-IP $remote_addr;
110
+ ```
111
+
112
+ Do not add `0.0.0.0/0` or `::/0` to `IP_RESTRICT_TRUSTED_PROXY_IPS`. Ensure the Django application server is not directly accessible from untrusted networks.
113
+
114
+ This feature supports TCP-connected proxies, not Unix sockets.
115
+
116
+ ## Use with Cloudflare
117
+
118
+ Without Cloudflare or another reverse proxy, Django normally receives the visitor's IP address directly.
119
+
120
+ When your domain's DNS record is set to **Proxied** (orange cloud) in Cloudflare, your server receives a Cloudflare IP address instead of the visitor's IP address.
121
+
122
+ Cloudflare includes the visitor's original IP address in the `CF-Connecting-IP` request header.
123
+
124
+ Without the Cloudflare configuration, `ALLOWED_IPS` cannot match the real visitor because Django only sees Cloudflare's IP address.
125
+
126
+ The middleware uses Cloudflare's IP ranges to verify that the connecting IP address belongs to Cloudflare before trusting `CF-Connecting-IP`. It never trusts `X-Forwarded-For` and ignores spoofed `CF-Connecting-IP` headers from other sources.
127
+
128
+ If Cloudflare sits in front of Nginx or another reverse proxy, configure `IP_RESTRICT_TRUSTED_PROXY_IPS` first. The middleware verifies the Cloudflare IP received through that trusted proxy before reading `CF-Connecting-IP`.
129
+
130
+ To use the Cloudflare IP update command, add `django_ip_path_restrict` to `INSTALLED_APPS` in `settings.py`:
131
+
132
+ ```python
133
+ INSTALLED_APPS = [
134
+ # Existing apps...
135
+ 'django_ip_path_restrict',
136
+ ]
137
+ ```
138
+
139
+ Adding the package to `INSTALLED_APPS` is not required if you only use the middleware.
140
+
141
+ ### Configure the cache file
142
+
143
+ Set a location where the package can save Cloudflare's IP ranges. This should be outside publicly accessible directories such as `static` and `media`.
144
+
145
+ If the directory doesn't exist, the package will create it automatically.
146
+
147
+ ```python
148
+ CLOUDFLARE_IPS_FILE = BASE_DIR / 'private' / 'cloudflare_ips.json'
149
+ ```
150
+
151
+ ### Download Cloudflare IP ranges
152
+
153
+ The package does not include Cloudflare's IP ranges. Run the following command after installation to download them:
154
+
155
+ ```bash
156
+ python manage.py update_cloudflare_ips
157
+ ```
158
+
159
+ ### Schedule daily updates
160
+
161
+ You can use a cron job to update Cloudflare's IP ranges automatically once per day.
162
+
163
+ **Linux (crontab)**
164
+
165
+ ```cron
166
+ 0 4 * * * cd /home/USER/project && /home/USER/project/.venv/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
167
+ ```
168
+
169
+ **cPanel**
170
+
171
+ In cPanel, go to **Advanced > Cron Jobs** and set the schedule to run daily at 4:00 AM.
172
+
173
+ Enter the following in the Command field:
174
+
175
+ ```bash
176
+ cd /home/USER/project && /home/USER/virtualenv/project/3.12/bin/python manage.py update_cloudflare_ips >> /home/USER/cloudflare_update.log 2>&1
177
+ ```
178
+
179
+ Replace the example paths with your actual project and virtual environment paths.
180
+
181
+ ### Automatic updates (optional)
182
+
183
+ If you cannot schedule updates using a cron job, you can enable automatic updates when a protected URL is accessed:
184
+
185
+ ```python
186
+ CLOUDFLARE_IPS_AUTO_UPDATE = True
187
+ ```
188
+
189
+ With this setting enabled, the next request to a protected URL attempts to download Cloudflare's IP ranges if the cache file is missing, invalid, or more than 24 hours old. The request may take longer due to the download, which has a five-second timeout.
190
+
191
+ If the download fails and no valid cache file is available, requests relying on Cloudflare IP detection will be blocked.
192
+
193
+ ### Share Cloudflare IP ranges between sites on the same server
194
+
195
+ Set each site's `CLOUDFLARE_IPS_FILE` to the same shared cache file, outside directories publicly accessible by your websites. Each site reads the file directly and automatically detects when it has been updated.
196
+
197
+ ```python
198
+ CLOUDFLARE_IPS_FILE = '/home/USER/private/cloudflare_ips.json'
199
+ ```
200
+
201
+ Run `update_cloudflare_ips` once per day from one site's virtual environment. It safely updates the shared file, preventing other sites from reading incomplete data.
202
+
203
+ ## Author
204
+
205
+ Created by [Peter Stavrou](https://peterstavrou.com/).
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoIPPathRestrictConfig(AppConfig):
5
+ name = 'django_ip_path_restrict'
6
+ verbose_name = 'Django IP Path Restrict'
@@ -0,0 +1,163 @@
1
+ """Cloudflare IP range caching and on-request updates."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from ipaddress import ip_network
7
+ from pathlib import Path
8
+ from tempfile import NamedTemporaryFile
9
+ from urllib.request import Request, urlopen
10
+
11
+ API_URL = 'https://api.cloudflare.com/client/v4/ips'
12
+ UPDATE_INTERVAL_SECONDS = 86400 # Default: 24 hours
13
+ IP_VERSIONS = {
14
+ 'ipv4_cidrs': 4,
15
+ 'ipv6_cidrs': 6,
16
+ }
17
+
18
+
19
+ def validate_ip_ranges(ip_range_data):
20
+ """Validate IPv4 and IPv6 ranges and normalize them for storage."""
21
+ if not isinstance(ip_range_data, dict):
22
+ raise ValueError('Expected a dictionary containing Cloudflare IP ranges')
23
+
24
+ validated_ip_ranges = {}
25
+
26
+ for ip_range_key, expected_ip_version in IP_VERSIONS.items():
27
+ ip_ranges = ip_range_data.get(ip_range_key)
28
+
29
+ if not isinstance(ip_ranges, list) or not ip_ranges:
30
+ raise ValueError(
31
+ f'Cloudflare response has no valid {ip_range_key}'
32
+ )
33
+
34
+ ip_networks = [
35
+ ip_network(ip_range, strict=True)
36
+ for ip_range in ip_ranges
37
+ ]
38
+
39
+ if any(
40
+ network.version != expected_ip_version
41
+ for network in ip_networks
42
+ ):
43
+ raise ValueError(f'Invalid address family in {ip_range_key}')
44
+
45
+ validated_ip_ranges[ip_range_key] = [str(network) for network in ip_networks]
46
+
47
+ return validated_ip_ranges
48
+
49
+
50
+ def fetch_cloudflare_ranges():
51
+ """Fetch IP ranges from Cloudflare's public API."""
52
+ request_headers = {'User-Agent': 'django-ip-path-restrict/1.0.0'}
53
+
54
+ with urlopen(
55
+ Request(API_URL, headers=request_headers), timeout=5
56
+ ) as api_response:
57
+ api_response_data = json.load(api_response)
58
+
59
+ if not api_response_data.get('success'):
60
+ raise ValueError('Cloudflare API returned an unsuccessful response')
61
+
62
+ return validate_ip_ranges(api_response_data.get('result'))
63
+
64
+
65
+ def save_cloudflare_ranges(cache_file_path, ip_ranges):
66
+ """Save validated IP ranges without leaving an incomplete cache file."""
67
+ validated_ip_ranges = validate_ip_ranges(ip_ranges)
68
+ cache_file_path = Path(cache_file_path)
69
+ cache_file_path.parent.mkdir(parents=True, exist_ok=True)
70
+ temporary_file_path = None
71
+
72
+ try:
73
+ with NamedTemporaryFile(
74
+ mode='w',
75
+ encoding='utf-8',
76
+ dir=cache_file_path.parent,
77
+ prefix='.cloudflare_ips_',
78
+ suffix='.tmp',
79
+ delete=False,
80
+ ) as temporary_file:
81
+ temporary_file_path = Path(temporary_file.name)
82
+ json.dump(validated_ip_ranges, temporary_file, indent=2)
83
+ temporary_file.write('\n')
84
+
85
+ os.replace(temporary_file_path, cache_file_path)
86
+ finally:
87
+ if temporary_file_path is not None:
88
+ temporary_file_path.unlink(missing_ok=True)
89
+
90
+
91
+ def download_and_save(cache_file_path):
92
+ """Download Cloudflare ranges and safely replace the cache file."""
93
+ cloudflare_ip_ranges = fetch_cloudflare_ranges()
94
+ save_cloudflare_ranges(cache_file_path, cloudflare_ip_ranges)
95
+ return (
96
+ len(cloudflare_ip_ranges['ipv4_cidrs'])
97
+ + len(cloudflare_ip_ranges['ipv6_cidrs'])
98
+ )
99
+
100
+
101
+ class CloudflareIPStore:
102
+ """Load cached Cloudflare ranges and refresh them once every 24 hours."""
103
+
104
+ def __init__(self, cache_file_path):
105
+ self.cache_file_path = Path(cache_file_path)
106
+ self._cached_networks = ()
107
+ self._cache_file_modified_time_nanoseconds = None
108
+ self._last_update_attempt = 0
109
+
110
+ @staticmethod
111
+ def _load_file(cache_file_path):
112
+ with Path(cache_file_path).open(encoding='utf-8') as cache_file:
113
+ return validate_ip_ranges(json.load(cache_file))
114
+
115
+ @staticmethod
116
+ def _to_networks(ip_ranges):
117
+ ip_range_strings = ip_ranges['ipv4_cidrs'] + ip_ranges['ipv6_cidrs']
118
+ return tuple(ip_network(ip_range) for ip_range in ip_range_strings)
119
+
120
+ def get_networks(self):
121
+ """Reload a changed cache file and fail closed when it is unavailable."""
122
+ try:
123
+ cache_file_modified_time_nanoseconds = self.cache_file_path.stat().st_mtime_ns
124
+ except OSError:
125
+ self._cached_networks = ()
126
+ self._cache_file_modified_time_nanoseconds = None
127
+ return self._cached_networks
128
+
129
+ if cache_file_modified_time_nanoseconds != self._cache_file_modified_time_nanoseconds:
130
+ try:
131
+ cached_ip_ranges = self._load_file(self.cache_file_path)
132
+ except (OSError, TypeError, ValueError):
133
+ self._cached_networks = ()
134
+ else:
135
+ self._cached_networks = self._to_networks(cached_ip_ranges)
136
+
137
+ self._cache_file_modified_time_nanoseconds = cache_file_modified_time_nanoseconds
138
+
139
+ return self._cached_networks
140
+
141
+ def refresh_if_stale(self):
142
+ """Refresh a missing, invalid, or 24-hour-old cache on the next request."""
143
+ cached_networks = self.get_networks()
144
+ try:
145
+ cache_age = time.time() - self.cache_file_path.stat().st_mtime
146
+ except OSError:
147
+ cache_age = UPDATE_INTERVAL_SECONDS
148
+
149
+ if cached_networks and cache_age < UPDATE_INTERVAL_SECONDS:
150
+ return
151
+
152
+ current_time = time.time()
153
+ if current_time - self._last_update_attempt < UPDATE_INTERVAL_SECONDS:
154
+ return
155
+
156
+ self._last_update_attempt = current_time
157
+
158
+ try:
159
+ download_and_save(self.cache_file_path)
160
+ except Exception:
161
+ return
162
+
163
+ self.get_networks()
@@ -0,0 +1,29 @@
1
+ """Manually refresh the Cloudflare IP range cache."""
2
+
3
+ from django.conf import settings
4
+ from django.core.management.base import BaseCommand, CommandError
5
+
6
+ from ...cloudflare import download_and_save
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = 'Download Cloudflare IP ranges to CLOUDFLARE_IPS_FILE.'
11
+
12
+ def handle(self, *args, **options):
13
+ cache_file_path = getattr(settings, 'CLOUDFLARE_IPS_FILE', None)
14
+ if not cache_file_path:
15
+ raise CommandError('Set CLOUDFLARE_IPS_FILE in Django settings first.')
16
+
17
+ try:
18
+ total_range_count = download_and_save(cache_file_path)
19
+ except Exception as update_error:
20
+ raise CommandError(
21
+ f'Cloudflare IP update failed: {update_error}'
22
+ ) from update_error
23
+
24
+ self.stdout.write(
25
+ self.style.SUCCESS(
26
+ f'Updated {total_range_count} Cloudflare IP ranges in '
27
+ f'{cache_file_path}'
28
+ )
29
+ )