Skip to content

Commit 32d1b0d

Browse files
committed
feat(centrifugo): add channel name validation utilities
chore: bump version to 1.5.117
1 parent f97fbae commit 32d1b0d

File tree

6 files changed

+291
-3
lines changed

6 files changed

+291
-3
lines changed

packages/django_cfg/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class MyConfig(DjangoConfig):
3232
default_app_config = "django_cfg.apps.DjangoCfgConfig"
3333

3434
# Version information
35-
__version__ = "1.5.115"
35+
__version__ = "1.5.117"
3636
__license__ = "MIT"
3737

3838
# Setup warnings debug early (checks env var only at this point)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
Centrifugo channel name validation utilities.
3+
4+
Helps detect common mistakes with channel naming that can lead to permission errors.
5+
"""
6+
7+
import logging
8+
import re
9+
import warnings
10+
from dataclasses import dataclass
11+
from typing import Optional
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
@dataclass
17+
class ChannelValidationResult:
18+
"""Result of channel name validation."""
19+
20+
valid: bool
21+
warning: Optional[str] = None
22+
suggestion: Optional[str] = None
23+
24+
25+
def validate_channel_name(channel: str) -> ChannelValidationResult:
26+
"""
27+
Validate Centrifugo channel name and detect potential issues.
28+
29+
Common issues:
30+
- Using `#` for namespace separator (should use `:`)
31+
- User-limited channels without proper JWT token setup
32+
33+
Args:
34+
channel: Channel name to validate
35+
36+
Returns:
37+
Validation result with warnings and suggestions
38+
39+
Examples:
40+
>>> # ❌ Bad: might be interpreted as user-limited channel
41+
>>> result = validate_channel_name('terminal#session#abc123')
42+
>>> print(result.warning)
43+
Channel "terminal#session#abc123" uses '#' separator...
44+
45+
>>> # ✅ Good: proper namespace separator
46+
>>> result = validate_channel_name('terminal:session:abc123')
47+
>>> print(result.valid)
48+
True
49+
"""
50+
# Count # symbols
51+
hash_count = channel.count("#")
52+
53+
if hash_count >= 2:
54+
# Pattern: namespace#something#something
55+
# This might be interpreted as user-limited channel: namespace#user_id#channel
56+
parts = channel.split("#")
57+
namespace, possible_user_id, *rest = parts
58+
59+
# Check if second part looks like a user ID (numeric)
60+
is_numeric_user_id = possible_user_id.isdigit()
61+
62+
if not is_numeric_user_id and possible_user_id:
63+
# Non-numeric second part after # - likely a mistake
64+
suggestion = channel.replace("#", ":")
65+
66+
return ChannelValidationResult(
67+
valid=False,
68+
warning=(
69+
f'Channel "{channel}" uses \'#\' separator which Centrifugo interprets as '
70+
f'user-limited channel boundary. The part "{possible_user_id}" will be treated '
71+
f"as user_id, which may cause permission errors if not in JWT token."
72+
),
73+
suggestion=f"Use ':' for namespace separation: \"{suggestion}\"",
74+
)
75+
76+
if is_numeric_user_id:
77+
return ChannelValidationResult(
78+
valid=True,
79+
warning=(
80+
f'Channel "{channel}" appears to be a user-limited channel '
81+
f'(user_id: {possible_user_id}). Make sure your JWT token\'s "sub" '
82+
f'field matches "{possible_user_id}".'
83+
),
84+
)
85+
86+
# Single # is okay for user-limited channels like "user#123"
87+
if hash_count == 1:
88+
namespace, user_id_part = channel.split("#")
89+
if user_id_part and not user_id_part.isdigit() and user_id_part != "*":
90+
# Non-numeric user_id (not a wildcard) - might be a mistake
91+
suggestion = channel.replace("#", ":")
92+
return ChannelValidationResult(
93+
valid=False,
94+
warning=(
95+
f'Channel "{channel}" uses \'#\' but "{user_id_part}" doesn\'t look '
96+
f"like a user_id. This might cause permission issues."
97+
),
98+
suggestion=f"Consider using ':' instead: \"{suggestion}\"",
99+
)
100+
101+
return ChannelValidationResult(valid=True)
102+
103+
104+
def log_channel_warnings(channel: str, *, raise_warning: bool = False) -> None:
105+
"""
106+
Log channel validation warnings (development only).
107+
108+
Args:
109+
channel: Channel name to validate
110+
raise_warning: If True, raises Python warning instead of logging
111+
112+
Examples:
113+
>>> # Log to logger
114+
>>> log_channel_warnings('terminal#session#abc123')
115+
116+
>>> # Raise Python warning (will be visible in tests)
117+
>>> log_channel_warnings('terminal#session#abc123', raise_warning=True)
118+
"""
119+
# Skip in production (if DEBUG=False)
120+
try:
121+
from django.conf import settings
122+
123+
if not settings.DEBUG:
124+
return
125+
except (ImportError, AttributeError):
126+
# Django not configured or DEBUG not set - continue anyway
127+
pass
128+
129+
result = validate_channel_name(channel)
130+
131+
if not result.valid and result.warning:
132+
message = f"[Centrifugo Channel Warning] {result.warning}"
133+
if result.suggestion:
134+
message += f"\n💡 Suggestion: {result.suggestion}"
135+
136+
if raise_warning:
137+
warnings.warn(message, UserWarning, stacklevel=2)
138+
else:
139+
logger.warning(message)
140+
141+
elif result.warning:
142+
# Valid but has informational warning
143+
if raise_warning:
144+
warnings.warn(f"[Centrifugo] {result.warning}", UserWarning, stacklevel=2)
145+
else:
146+
logger.info(f"[Centrifugo] {result.warning}")
147+
148+
149+
__all__ = [
150+
"ChannelValidationResult",
151+
"validate_channel_name",
152+
"log_channel_warnings",
153+
]

packages/django_cfg/apps/integrations/centrifugo/services/client/direct_client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ async def publish(
165165
... data={"status": "running", "timestamp": "2025-11-05T09:00:00Z"}
166166
... )
167167
"""
168+
# Validate channel name and log warnings (development only)
169+
from ..channel_validator import log_channel_warnings
170+
log_channel_warnings(channel)
171+
168172
message_id = str(uuid4())
169173
start_time = time.time()
170174

packages/django_cfg/core/builders/security_builder.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,11 @@ def _get_prod_allowed_hosts(self, domain_hosts: List[str]) -> List[str]:
219219
220220
In production we're strict, but need to allow:
221221
- Public domains (security_domains)
222+
- Docker internal service names (auto-detected)
222223
- Docker health checks (internal IPs, if needed)
223224
224225
Problem: If we allow all IPs - insecure!
225-
Solution: Allow only private IP ranges (RFC 1918).
226+
Solution: Allow only private IP ranges (RFC 1918) + internal service names.
226227
227228
Args:
228229
domain_hosts: List of normalized domain hostnames
@@ -234,6 +235,10 @@ def _get_prod_allowed_hosts(self, domain_hosts: List[str]) -> List[str]:
234235

235236
# Check if running in Docker
236237
if self._is_running_in_docker():
238+
# Auto-detect internal service names from configuration
239+
internal_services = self._get_internal_service_names()
240+
allowed_hosts.extend(internal_services)
241+
237242
# Allow Docker/Kubernetes health checks
238243
# Use regex for private IPs (RFC 1918)
239244
allowed_hosts.extend([
@@ -250,6 +255,127 @@ def _get_prod_allowed_hosts(self, domain_hosts: List[str]) -> List[str]:
250255

251256
return allowed_hosts
252257

258+
def _get_internal_service_names(self) -> List[str]:
259+
"""
260+
Auto-detect Docker internal service names from configuration.
261+
262+
Extracts service hostnames from internal URLs:
263+
- Centrifugo API URL (centrifugo_api_url)
264+
- gRPC internal URL (grpc.internal_url if available)
265+
- Any other internal service URLs
266+
267+
Example:
268+
centrifugo_api_url = "http://djangocfg-centrifugo:8000/api"
269+
→ Extracts: "djangocfg-centrifugo"
270+
271+
Returns:
272+
List of internal service hostnames (without port)
273+
"""
274+
service_names = []
275+
276+
# Extract from Centrifugo config
277+
if hasattr(self.config, 'centrifugo') and self.config.centrifugo:
278+
centrifugo_cfg = self.config.centrifugo
279+
280+
# Extract from centrifugo_api_url (for Django → Centrifugo publishing)
281+
if hasattr(centrifugo_cfg, 'centrifugo_api_url'):
282+
api_url = centrifugo_cfg.centrifugo_api_url
283+
hostname = self._extract_hostname_from_url(api_url)
284+
if hostname and self._is_internal_service_name(hostname):
285+
service_names.append(hostname)
286+
287+
# Extract from gRPC config
288+
if hasattr(self.config, 'grpc') and self.config.grpc:
289+
grpc_cfg = self.config.grpc
290+
291+
# Extract from internal_url (for container-to-container gRPC)
292+
if hasattr(grpc_cfg, 'internal_url'):
293+
internal_url = grpc_cfg.internal_url
294+
# Handle formats: "djangocfg-grpc:50051" or "http://djangocfg-grpc:50051"
295+
hostname = self._extract_hostname_from_url(internal_url, allow_no_protocol=True)
296+
if hostname and self._is_internal_service_name(hostname):
297+
service_names.append(hostname)
298+
299+
# Deduplicate while preserving order
300+
return list(dict.fromkeys(service_names))
301+
302+
def _extract_hostname_from_url(self, url: str, allow_no_protocol: bool = False) -> str:
303+
"""
304+
Extract hostname from URL.
305+
306+
Args:
307+
url: URL string (e.g., "http://djangocfg-api:8000/path" or "djangocfg-grpc:50051")
308+
allow_no_protocol: Allow URLs without protocol (for gRPC addresses)
309+
310+
Returns:
311+
Hostname without port (e.g., "djangocfg-api")
312+
"""
313+
if not url:
314+
return ""
315+
316+
try:
317+
# Handle URLs without protocol (e.g., "djangocfg-grpc:50051")
318+
if allow_no_protocol and not url.startswith(("http://", "https://")):
319+
# Split by : to get hostname:port
320+
hostname_port = url.split('/')[0] # Remove path if any
321+
hostname = hostname_port.split(':')[0] # Remove port
322+
return hostname.strip()
323+
324+
# Standard URL parsing
325+
parsed = urlparse(url if url.startswith(("http://", "https://")) else f"http://{url}")
326+
hostname = parsed.hostname or parsed.netloc.split(':')[0]
327+
return hostname.strip() if hostname else ""
328+
except Exception:
329+
return ""
330+
331+
def _is_internal_service_name(self, hostname: str) -> bool:
332+
"""
333+
Check if hostname is an internal Docker/Kubernetes service name.
334+
335+
Internal service names typically:
336+
- Don't contain dots (unlike domains: example.com)
337+
- Are not IPs (172.x.x.x, 192.168.x.x)
338+
- Are not localhost/127.0.0.1
339+
340+
Examples:
341+
"djangocfg-api" → True (internal service)
342+
"djangocfg-grpc" → True (internal service)
343+
"api.example.com" → False (external domain)
344+
"192.168.1.10" → False (IP address)
345+
"localhost" → False (localhost)
346+
347+
Args:
348+
hostname: Hostname to check
349+
350+
Returns:
351+
True if internal service name, False otherwise
352+
"""
353+
if not hostname:
354+
return False
355+
356+
hostname = hostname.lower().strip()
357+
358+
# Exclude localhost
359+
if hostname in ('localhost', '127.0.0.1'):
360+
return False
361+
362+
# Exclude IP addresses (simple check)
363+
if hostname.replace('.', '').isdigit():
364+
return False
365+
366+
# Exclude domains with dots (external domains like api.example.com)
367+
# Keep Kubernetes service names like service.namespace.svc.cluster.local
368+
if '.' in hostname:
369+
# Allow .cluster.local and .svc (Kubernetes)
370+
if hostname.endswith(('.cluster.local', '.svc')):
371+
return True
372+
# Otherwise it's an external domain
373+
return False
374+
375+
# If no dots and not excluded - it's an internal service name
376+
# Examples: djangocfg-api, djangocfg-grpc, postgres, redis
377+
return True
378+
253379
def _is_running_in_docker(self) -> bool:
254380
"""
255381
Detect if application is running in Docker.

packages/django_cfg/models/api/grpc/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,11 @@ class GRPCConfig(BaseConfig):
376376
description="Public URL for clients (e.g., 'grpc.djangocfg.com:443'). If None, auto-generated from api_url",
377377
)
378378

379+
internal_url: Optional[str] = Field(
380+
default=None,
381+
description="Internal Docker/Kubernetes URL for container-to-container communication (e.g., 'djangocfg-grpc:50051' or 'localhost:50051')",
382+
)
383+
379384
enable_reflection: Optional[bool] = Field(
380385
default=None,
381386
description="Enable server reflection for grpcurl/tools. If None, uses server.enable_reflection (True by default)",

packages/django_cfg/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "django-cfg"
7-
version = "1.5.115"
7+
version = "1.5.117"
88
description = "Modern Django framework with type-safe Pydantic v2 configuration, Next.js admin integration, real-time WebSockets, and 8 enterprise apps. Replace settings.py with validated models, 90% less code. Production-ready with AI agents, auto-generated TypeScript clients, and zero-config features."
99
readme = "README.md"
1010
keywords = [ "django", "configuration", "pydantic", "settings", "type-safety", "pydantic-settings", "django-environ", "startup-validation", "ide-autocomplete", "nextjs-admin", "react-admin", "websocket", "centrifugo", "real-time", "typescript-generation", "ai-agents", "enterprise-django", "django-settings", "type-safe-config", "modern-django",]

0 commit comments

Comments
 (0)