mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 09:06:06 +00:00
restrict XC access per user by configurable IP allowlist
This commit is contained in:
parent
2650bee7e9
commit
8894d51877
8 changed files with 91 additions and 3 deletions
16
apps/accounts/migrations/0007_user_xc_allowed_ips.py
Normal file
16
apps/accounts/migrations/0007_user_xc_allowed_ips.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0006_user_stream_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='xc_allowed_ips',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
]
|
||||
|
|
@ -31,6 +31,7 @@ class User(AbstractUser):
|
|||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
|
||||
stream_limit = models.IntegerField(default=0)
|
||||
xc_allowed_ips = models.TextField(blank=True, default='')
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import ipaddress
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
|
|
@ -66,6 +67,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"custom_properties",
|
||||
"avatar_config",
|
||||
"stream_limit",
|
||||
"xc_allowed_ips",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"last_login",
|
||||
|
|
@ -74,6 +76,19 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"last_name",
|
||||
]
|
||||
|
||||
def validate_xc_allowed_ips(self, value):
|
||||
if not value or not value.strip():
|
||||
return value
|
||||
for cidr in value.split(','):
|
||||
cidr = cidr.strip()
|
||||
if not cidr:
|
||||
continue
|
||||
try:
|
||||
ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError(f"'{cidr}' is not a valid CIDR range")
|
||||
return value
|
||||
|
||||
def validate_custom_properties(self, value):
|
||||
"""Validate custom_properties structure and size."""
|
||||
if value is None:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from django.views.decorators.http import require_http_methods
|
||||
from apps.epg.models import ProgramData
|
||||
from apps.accounts.models import User
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from datetime import datetime, timedelta
|
||||
|
|
@ -1926,6 +1926,9 @@ def xc_get_user(request):
|
|||
if custom_properties["xc_password"] != password:
|
||||
return None
|
||||
|
||||
if not user_xc_ip_allowed(request, user):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ from .url_utils import (
|
|||
from .utils import get_logger
|
||||
from uuid import UUID
|
||||
import gevent
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
|
||||
from apps.proxy.utils import check_user_stream_limits
|
||||
|
||||
logger = get_logger()
|
||||
|
|
@ -572,6 +572,9 @@ def stream_xc(request, username, password, channel_id):
|
|||
if custom_properties["xc_password"] != password:
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
if not user_xc_ip_allowed(request, user):
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
if user.user_level < 10:
|
||||
user_profile_count = user.channel_profiles.count()
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from rest_framework.permissions import AllowAny
|
|||
from apps.accounts.models import User
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from apps.proxy.utils import check_user_stream_limits
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -1031,6 +1031,9 @@ def stream_xc_movie(request, username, password, stream_id, extension):
|
|||
if custom_properties["xc_password"] != password:
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
if not user_xc_ip_allowed(request, user):
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
# All authenticated users get access to VOD from all active M3U accounts
|
||||
filters = {"movie_id": stream_id, "m3u_account__is_active": True}
|
||||
|
||||
|
|
@ -1065,6 +1068,9 @@ def stream_xc_episode(request, username, password, stream_id, extension):
|
|||
if custom_properties["xc_password"] != password:
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
if not user_xc_ip_allowed(request, user):
|
||||
return Response({"error": "Invalid credentials"}, status=401)
|
||||
|
||||
# All authenticated users get access to series/episodes from all active M3U accounts
|
||||
filters = {"episode_id": stream_id, "m3u_account__is_active": True}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,26 @@ def get_client_ip(request):
|
|||
return ip
|
||||
|
||||
|
||||
def user_xc_ip_allowed(request, user):
|
||||
"""Check request IP against per-user XC allowed ranges. Empty = allow all (0.0.0.0/0)."""
|
||||
allowed_ips = getattr(user, 'xc_allowed_ips', '') or ''
|
||||
if not allowed_ips.strip():
|
||||
return True
|
||||
|
||||
cidrs = [c.strip() for c in allowed_ips.split(',') if c.strip()]
|
||||
if not cidrs:
|
||||
return True
|
||||
|
||||
client_ip = ipaddress.ip_address(get_client_ip(request))
|
||||
for cidr in cidrs:
|
||||
try:
|
||||
if client_ip in ipaddress.ip_network(cidr, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def network_access_allowed(request, settings_key):
|
||||
try:
|
||||
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Switch,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
TagsInput,
|
||||
Text,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
|
|
@ -23,6 +24,7 @@ import useChannelsStore from '../../store/channels';
|
|||
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { copyToClipboard } from '../../utils';
|
||||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
|
||||
|
||||
const User = ({ user = null, isOpen, onClose }) => {
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
|
|
@ -48,6 +50,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
xc_allowed_ips: [],
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
|
|
@ -69,6 +72,11 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
|
||||
? 'XC password must be alphanumeric'
|
||||
: null,
|
||||
xc_allowed_ips: values.xc_allowed_ips.some(
|
||||
(cidr) => !cidr.match(IPV4_CIDR_REGEX) && !cidr.match(IPV6_CIDR_REGEX)
|
||||
)
|
||||
? 'Each entry must be a valid CIDR range (e.g. 192.168.1.0/24)'
|
||||
: null,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -94,6 +102,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
customProps.xc_password = values.xc_password || '';
|
||||
delete values.xc_password;
|
||||
|
||||
// Serialize xc_allowed_ips array to comma-separated string for the backend
|
||||
values.xc_allowed_ips = (values.xc_allowed_ips || []).join(',');
|
||||
|
||||
// Save hide_adult_content in custom_properties
|
||||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
|
@ -155,6 +166,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
xc_allowed_ips: user.xc_allowed_ips
|
||||
? user.xc_allowed_ips.split(',').filter(Boolean)
|
||||
: [],
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
|
|
@ -384,6 +398,16 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<TagsInput
|
||||
label="XC Allowed IP Ranges"
|
||||
description="Restrict XC access to these CIDR ranges. Leave empty to allow all (0.0.0.0/0)."
|
||||
placeholder="e.g. 192.168.1.0/24"
|
||||
splitChars={[',', ' ']}
|
||||
{...form.getInputProps('xc_allowed_ips')}
|
||||
key={form.key('xc_allowed_ips')}
|
||||
/>
|
||||
)}
|
||||
{canGenerateKey && (
|
||||
<Stack gap="xs">
|
||||
{userAPIKey && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue