mirror of
https://github.com/apidoorman/doorman.git
synced 2026-04-26 02:28:54 -05:00
Gateway and API IP lists
This commit is contained in:
@@ -455,9 +455,64 @@ class Settings(BaseSettings):
|
||||
jwt_access_token_expires: timedelta = timedelta(minutes=int(os.getenv('ACCESS_TOKEN_EXPIRES_MINUTES', 15)))
|
||||
jwt_refresh_token_expires: timedelta = timedelta(days=int(os.getenv('REFRESH_TOKEN_EXPIRES_DAYS', 30)))
|
||||
|
||||
@doorman.middleware('http')
|
||||
async def ip_filter_middleware(request: Request, call_next):
|
||||
try:
|
||||
settings = get_cached_settings()
|
||||
wl = settings.get('ip_whitelist') or []
|
||||
bl = settings.get('ip_blacklist') or []
|
||||
trust_xff = bool(settings.get('trust_x_forwarded_for'))
|
||||
client_ip = None
|
||||
if trust_xff:
|
||||
xff = request.headers.get('x-forwarded-for') or request.headers.get('X-Forwarded-For')
|
||||
if xff:
|
||||
client_ip = xff.split(',')[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
def _ip_in_list(ip: str, patterns: list) -> bool:
|
||||
try:
|
||||
import ipaddress
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
for pat in patterns:
|
||||
p = (pat or '').strip()
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
if '/' in p:
|
||||
net = ipaddress.ip_network(p, strict=False)
|
||||
if ip_obj in net:
|
||||
return True
|
||||
else:
|
||||
if ip_obj == ipaddress.ip_address(p):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if client_ip:
|
||||
if wl and not _ip_in_list(client_ip, wl):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=403, content={'status_code': 403, 'error_code': 'SEC010', 'error_message': 'IP not allowed'})
|
||||
if bl and _ip_in_list(client_ip, bl):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(status_code=403, content={'status_code': 403, 'error_code': 'SEC011', 'error_message': 'IP blocked'})
|
||||
except Exception:
|
||||
pass
|
||||
return await call_next(request)
|
||||
|
||||
@doorman.middleware('http')
|
||||
async def metrics_middleware(request: Request, call_next):
|
||||
start = asyncio.get_event_loop().time()
|
||||
# Capture request bytes in via Content-Length header if available
|
||||
def _parse_len(val: str | None) -> int:
|
||||
try:
|
||||
return int(val) if val is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
bytes_in = _parse_len(request.headers.get('content-length'))
|
||||
response = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
@@ -494,7 +549,27 @@ async def metrics_middleware(request: Request, call_next):
|
||||
elif p.startswith('/api/soap/'):
|
||||
seg = p.rsplit('/', 1)[-1] or 'unknown'
|
||||
api_key = f'soap:{seg}'
|
||||
metrics_store.record(status=status, duration_ms=duration_ms, username=username, api_key=api_key)
|
||||
# Try to compute response bytes out
|
||||
clen = 0
|
||||
try:
|
||||
clen = _parse_len(getattr(response, 'headers', {}).get('content-length'))
|
||||
if clen == 0:
|
||||
body = getattr(response, 'body', None)
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
clen = len(body)
|
||||
except Exception:
|
||||
clen = 0
|
||||
|
||||
metrics_store.record(status=status, duration_ms=duration_ms, username=username, api_key=api_key, bytes_in=bytes_in, bytes_out=clen)
|
||||
try:
|
||||
if username:
|
||||
from utils.bandwidth_util import add_usage, _get_user
|
||||
# Only track if user has a limit configured to avoid extra writes
|
||||
u = _get_user(username)
|
||||
if u and u.get('bandwidth_limit_bytes'):
|
||||
add_usage(username, int(bytes_in) + int(clen), u.get('bandwidth_limit_window') or 'day')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
@@ -38,5 +38,11 @@ class CreateApiModel(BaseModel):
|
||||
api_id: Optional[str] = Field(None, description='Unique identifier for the API, auto-generated', example=None)
|
||||
api_path: Optional[str] = Field(None, description='Unique path for the API, auto-generated', example=None)
|
||||
|
||||
# Per-API IP policy
|
||||
api_ip_mode: Optional[str] = Field('allow_all', description="IP policy mode: 'allow_all' or 'whitelist'")
|
||||
api_ip_whitelist: Optional[List[str]] = Field(None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist')
|
||||
api_ip_blacklist: Optional[List[str]] = Field(None, description='IPs/CIDRs denied regardless of mode')
|
||||
api_trust_x_forwarded_for: Optional[bool] = Field(None, description='Override: trust X-Forwarded-For for this API')
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -24,6 +24,8 @@ class CreateUserModel(BaseModel):
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field('day', min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
active: Optional[bool] = Field(True, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(False, description='UI access for the user', example=False)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# External imports
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from typing import Optional, List
|
||||
|
||||
class SecuritySettingsModel(BaseModel):
|
||||
enable_auto_save: Optional[bool] = Field(default=None)
|
||||
auto_save_frequency_seconds: Optional[int] = Field(default=None, ge=60, description='How often to auto-save memory dump (seconds)')
|
||||
dump_path: Optional[str] = Field(default=None, description='Path to write encrypted memory dumps')
|
||||
|
||||
ip_whitelist: Optional[List[str]] = Field(default=None, description='List of allowed IPs/CIDRs. If non-empty, only these are allowed.')
|
||||
ip_blacklist: Optional[List[str]] = Field(default=None, description='List of blocked IPs/CIDRs')
|
||||
trust_x_forwarded_for: Optional[bool] = Field(default=None, description='If true, use X-Forwarded-For header for client IP')
|
||||
|
||||
@@ -36,5 +36,11 @@ class UpdateApiModel(BaseModel):
|
||||
|
||||
api_auth_required: Optional[bool] = Field(None, description='If true (default), JWT auth is required for this API when not public. If false, requests may be unauthenticated but must meet other checks as configured.')
|
||||
|
||||
# Per-API IP policy
|
||||
api_ip_mode: Optional[str] = Field(None, description="IP policy mode: 'allow_all' or 'whitelist'")
|
||||
api_ip_whitelist: Optional[List[str]] = Field(None, description='Allowed IPs/CIDRs when api_ip_mode=whitelist')
|
||||
api_ip_blacklist: Optional[List[str]] = Field(None, description='IPs/CIDRs denied regardless of mode')
|
||||
api_trust_x_forwarded_for: Optional[bool] = Field(None, description='Override: trust X-Forwarded-For for this API')
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -23,6 +23,8 @@ class UpdateUserModel(BaseModel):
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field(None, min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
active: Optional[bool] = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(None, description='UI access for the user', example=False)
|
||||
class Config:
|
||||
|
||||
@@ -23,8 +23,12 @@ class UserModelResponse(BaseModel):
|
||||
throttle_wait_duration_type: Optional[str] = Field(None, min_length=1, max_length=7, description='Wait duration for the throttle limit', example='seconds')
|
||||
throttle_queue_limit: Optional[int] = Field(None, ge=0, description='Throttle queue limit for the user', example=10)
|
||||
custom_attributes: Optional[dict] = Field(None, description='Custom attributes for the user', example={'custom_key': 'custom_value'})
|
||||
bandwidth_limit_bytes: Optional[int] = Field(None, ge=0, description='Maximum bandwidth allowed within the window (bytes)', example=1073741824)
|
||||
bandwidth_limit_window: Optional[str] = Field(None, min_length=1, max_length=10, description='Bandwidth window unit (second/minute/hour/day/month)', example='day')
|
||||
bandwidth_usage_bytes: Optional[int] = Field(None, ge=0, description='Current bandwidth usage in the active window (bytes)', example=123456)
|
||||
bandwidth_resets_at: Optional[int] = Field(None, description='UTC epoch seconds when the current bandwidth window resets', example=1727481600)
|
||||
active: Optional[bool] = Field(None, description='Active status of the user', example=True)
|
||||
ui_access: Optional[bool] = Field(None, description='UI access for the user', example=False)
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@@ -18,6 +18,7 @@ from models.response_model import ResponseModel
|
||||
from utils import api_util
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.limit_throttle_util import limit_and_throttle
|
||||
from utils.bandwidth_util import enforce_pre_request_limit
|
||||
from utils.auth_util import auth_required
|
||||
from utils.group_util import group_required
|
||||
from utils.response_util import process_response
|
||||
@@ -27,6 +28,7 @@ from utils.health_check_util import check_mongodb, check_redis, get_memory_usage
|
||||
from services.gateway_service import GatewayService
|
||||
from utils.validation_util import validation_util
|
||||
from utils.audit_util import audit
|
||||
from utils.ip_policy_util import enforce_api_ip_policy
|
||||
|
||||
gateway_router = APIRouter()
|
||||
|
||||
@@ -176,6 +178,11 @@ async def gateway(request: Request, path: str):
|
||||
api = await api_util.get_api(api_key, f'/{parts[0]}/{parts[1]}')
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
if api:
|
||||
try:
|
||||
enforce_api_ip_policy(request, api)
|
||||
except HTTPException as e:
|
||||
return process_response(ResponseModel(status_code=e.status_code, error_code=e.detail, error_message='IP restricted').dict(), 'rest')
|
||||
username = None
|
||||
if not api_public:
|
||||
if api_auth_required:
|
||||
@@ -184,6 +191,7 @@ async def gateway(request: Request, path: str):
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
await enforce_pre_request_limit(request, username)
|
||||
else:
|
||||
|
||||
pass
|
||||
@@ -299,6 +307,11 @@ async def soap_gateway(request: Request, path: str):
|
||||
api = await api_util.get_api(api_key, f'/{parts[0]}/{parts[1]}')
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
if api:
|
||||
try:
|
||||
enforce_api_ip_policy(request, api)
|
||||
except HTTPException as e:
|
||||
return process_response(ResponseModel(status_code=e.status_code, error_code=e.detail, error_message='IP restricted').dict(), 'soap')
|
||||
username = None
|
||||
if not api_public:
|
||||
if api_auth_required:
|
||||
@@ -307,6 +320,7 @@ async def soap_gateway(request: Request, path: str):
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
await enforce_pre_request_limit(request, username)
|
||||
else:
|
||||
pass
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
@@ -418,6 +432,11 @@ async def graphql_gateway(request: Request, path: str):
|
||||
api_name = re.sub(r'^.*/', '',request.url.path)
|
||||
api_key = doorman_cache.get_cache('api_id_cache', api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
api = await api_util.get_api(api_key, api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
if api:
|
||||
try:
|
||||
enforce_api_ip_policy(request, api)
|
||||
except HTTPException as e:
|
||||
return process_response(ResponseModel(status_code=e.status_code, error_code=e.detail, error_message='IP restricted').dict(), 'graphql')
|
||||
api_public = bool(api.get('api_public')) if api else False
|
||||
api_auth_required = bool(api.get('api_auth_required')) if api and api.get('api_auth_required') is not None else True
|
||||
username = None
|
||||
@@ -428,6 +447,7 @@ async def graphql_gateway(request: Request, path: str):
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
await enforce_pre_request_limit(request, username)
|
||||
else:
|
||||
pass
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
@@ -555,6 +575,14 @@ async def grpc_gateway(request: Request, path: str):
|
||||
await limit_and_throttle(request)
|
||||
payload = await auth_required(request)
|
||||
username = payload.get('sub')
|
||||
try:
|
||||
api_name = re.sub(r'^.*/', '',request.url.path)
|
||||
api_key = doorman_cache.get_cache('api_id_cache', api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
api = await api_util.get_api(api_key, api_name + '/' + request.headers.get('X-API-Version', 'v0'))
|
||||
if api:
|
||||
enforce_api_ip_policy(request, api)
|
||||
except HTTPException as e:
|
||||
return process_response(ResponseModel(status_code=e.status_code, error_code=e.detail, error_message='IP restricted').dict(), 'grpc')
|
||||
logger.info(f"{request_id} | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S:%f')[:-3]}ms")
|
||||
logger.info(f'{request_id} | Username: {username} | From: {request.client.host}:{request.client.port}')
|
||||
logger.info(f'{request_id} | Endpoint: {request.method} {str(request.url.path)}')
|
||||
|
||||
@@ -40,7 +40,7 @@ Response:
|
||||
response_model=ResponseModel,
|
||||
)
|
||||
|
||||
async def get_metrics(request: Request, range: str = '24h'):
|
||||
async def get_metrics(request: Request, range: str = '24h', group: str = 'minute', sort: str = 'asc'):
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time() * 1000
|
||||
try:
|
||||
@@ -55,7 +55,13 @@ async def get_metrics(request: Request, range: str = '24h'):
|
||||
error_code='MON001',
|
||||
error_message='You do not have permission to view monitor metrics'
|
||||
).dict(), 'rest')
|
||||
snap = metrics_store.snapshot(range)
|
||||
grp = (group or 'minute').lower()
|
||||
if grp not in ('minute', 'day'):
|
||||
grp = 'minute'
|
||||
srt = (sort or 'asc').lower()
|
||||
if srt not in ('asc', 'desc'):
|
||||
srt = 'asc'
|
||||
snap = metrics_store.snapshot(range, group=grp, sort=srt)
|
||||
return process_response(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
@@ -255,6 +261,19 @@ async def generate_report(request: Request, start: str, end: str):
|
||||
api_errors[k] = api_errors.get(k, 0) + v
|
||||
for k, v in (b.user_counts or {}).items():
|
||||
user_totals[k] = user_totals.get(k, 0) + v
|
||||
# Bandwidth aggregation from metrics buckets (available window)
|
||||
buckets = list(metrics_store._buckets)
|
||||
sel = [b for b in buckets if b.start_ts >= start_ts and b.start_ts <= end_ts]
|
||||
total_bytes_in = sum(getattr(b, 'bytes_in', 0) for b in sel)
|
||||
total_bytes_out = sum(getattr(b, 'bytes_out', 0) for b in sel)
|
||||
# Daily breakdown (UTC)
|
||||
from collections import defaultdict
|
||||
daily_bw = defaultdict(lambda: {'in': 0, 'out': 0})
|
||||
for b in sel:
|
||||
day_ts = int((b.start_ts // 86400) * 86400)
|
||||
daily_bw[day_ts]['in'] += getattr(b, 'bytes_in', 0)
|
||||
daily_bw[day_ts]['out'] += getattr(b, 'bytes_out', 0)
|
||||
|
||||
avg_ms = (total_ms / total) if total else 0.0
|
||||
|
||||
buf = io.StringIO()
|
||||
@@ -268,6 +287,11 @@ async def generate_report(request: Request, start: str, end: str):
|
||||
w.writerow(['success_rate', f'{(0 if total == 0 else (100.0 * (total - errors) / total)):.2f}%'])
|
||||
w.writerow(['avg_response_ms', f'{avg_ms:.2f}'])
|
||||
w.writerow([])
|
||||
w.writerow(['Bandwidth Overview'])
|
||||
w.writerow(['total_bytes_in', int(total_bytes_in)])
|
||||
w.writerow(['total_bytes_out', int(total_bytes_out)])
|
||||
w.writerow(['total_bytes', int(total_bytes_in + total_bytes_out)])
|
||||
w.writerow([])
|
||||
|
||||
w.writerow(['Status Codes'])
|
||||
w.writerow(['status', 'count'])
|
||||
@@ -289,6 +313,16 @@ async def generate_report(request: Request, start: str, end: str):
|
||||
for uname, cnt in sorted(user_totals.items(), key=lambda kv: kv[1], reverse=True):
|
||||
w.writerow([uname, cnt])
|
||||
|
||||
w.writerow([])
|
||||
w.writerow(['Bandwidth (per day, UTC)'])
|
||||
w.writerow(['date', 'bytes_in', 'bytes_out', 'total'])
|
||||
for day_ts in sorted(daily_bw.keys()):
|
||||
import datetime as _dt
|
||||
date_str = _dt.datetime.utcfromtimestamp(day_ts).strftime('%Y-%m-%d')
|
||||
bi = int(daily_bw[day_ts]['in'])
|
||||
bo = int(daily_bw[day_ts]['out'])
|
||||
w.writerow([date_str, bi, bo, bi + bo])
|
||||
|
||||
csv_bytes = buf.getvalue().encode('utf-8')
|
||||
filename = f'doorman_report_{start}_to_{end}.csv'
|
||||
return FastAPIResponse(content=csv_bytes, media_type='text/csv', headers={
|
||||
|
||||
@@ -54,6 +54,14 @@ async def get_security_settings(request: Request):
|
||||
error_message='You do not have permission to view security settings'
|
||||
).dict(), 'rest')
|
||||
settings = await load_settings()
|
||||
# Augment with client IP info for UI safety checks
|
||||
try:
|
||||
client_ip = request.client.host if request.client else None
|
||||
xff = request.headers.get('x-forwarded-for') or request.headers.get('X-Forwarded-For')
|
||||
client_ip_xff = xff.split(',')[0].strip() if xff else None
|
||||
except Exception:
|
||||
client_ip = None
|
||||
client_ip_xff = None
|
||||
|
||||
settings_with_mode = dict(settings)
|
||||
try:
|
||||
@@ -61,6 +69,8 @@ async def get_security_settings(request: Request):
|
||||
settings_with_mode['memory_only'] = bool(database.memory_only)
|
||||
except Exception:
|
||||
settings_with_mode['memory_only'] = False
|
||||
settings_with_mode['client_ip'] = client_ip
|
||||
settings_with_mode['client_ip_xff'] = client_ip_xff
|
||||
return process_response(ResponseModel(
|
||||
status_code=200,
|
||||
response_headers={'request_id': request_id},
|
||||
|
||||
@@ -16,6 +16,8 @@ from utils.database import user_collection, subscriptions_collection, api_collec
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from models.create_user_model import CreateUserModel
|
||||
from utils.role_util import platform_role_required_bool
|
||||
from utils.bandwidth_util import get_current_usage
|
||||
import time
|
||||
|
||||
logger = logging.getLogger('doorman.gateway')
|
||||
|
||||
@@ -81,6 +83,29 @@ class UserService:
|
||||
error_code='USR002',
|
||||
error_message='User not found'
|
||||
).dict()
|
||||
# Augment with bandwidth usage info
|
||||
try:
|
||||
limit = user.get('bandwidth_limit_bytes')
|
||||
if limit and int(limit) > 0:
|
||||
window = user.get('bandwidth_limit_window') or 'day'
|
||||
used = int(get_current_usage(username, window))
|
||||
# compute reset epoch (UTC)
|
||||
mapping = {
|
||||
'second': 1,
|
||||
'minute': 60,
|
||||
'hour': 3600,
|
||||
'day': 86400,
|
||||
'week': 604800,
|
||||
'month': 2592000,
|
||||
}
|
||||
sec = mapping.get(str(window).lower().rstrip('s'), 86400)
|
||||
now = int(time.time())
|
||||
bucket_start = (now // sec) * sec
|
||||
resets_at = bucket_start + sec
|
||||
user['bandwidth_usage_bytes'] = used
|
||||
user['bandwidth_resets_at'] = resets_at
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f'{request_id} | User retrieval successful')
|
||||
return ResponseModel(
|
||||
status_code=200,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from utils.doorman_cache_util import doorman_cache
|
||||
from utils.database import user_collection
|
||||
|
||||
def _window_to_seconds(win: Optional[str]) -> int:
|
||||
mapping = {
|
||||
'second': 1,
|
||||
'minute': 60,
|
||||
'hour': 3600,
|
||||
'day': 86400,
|
||||
'week': 604800,
|
||||
'month': 2592000,
|
||||
}
|
||||
if not win:
|
||||
return 86400
|
||||
w = win.lower().rstrip('s')
|
||||
return mapping.get(w, 86400)
|
||||
|
||||
def _bucket_key(username: str, window: str, now: Optional[int] = None) -> tuple[str, int]:
|
||||
sec = _window_to_seconds(window)
|
||||
now = now or int(time.time())
|
||||
bucket = (now // sec) * sec
|
||||
key = f'bandwidth_usage:{username}:{sec}:{bucket}'
|
||||
return key, sec
|
||||
|
||||
def _get_user(username: str) -> Optional[dict]:
|
||||
user = doorman_cache.get_cache('user_cache', username)
|
||||
if not user:
|
||||
user = user_collection.find_one({'username': username})
|
||||
if user and user.get('_id'):
|
||||
del user['_id']
|
||||
return user
|
||||
|
||||
def _get_client():
|
||||
return doorman_cache.cache if getattr(doorman_cache, 'is_redis', False) else None
|
||||
|
||||
def get_current_usage(username: str, window: Optional[str]) -> int:
|
||||
win = window or 'day'
|
||||
key, ttl = _bucket_key(username, win)
|
||||
client = _get_client()
|
||||
if client is not None:
|
||||
val = client.get(key)
|
||||
try:
|
||||
return int(val) if val is not None else 0
|
||||
except Exception:
|
||||
return 0
|
||||
# Memory cache path (raw access)
|
||||
val = doorman_cache.cache.get(key)
|
||||
try:
|
||||
return int(val) if isinstance(val, int) else int(val or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def add_usage(username: str, delta_bytes: int, window: Optional[str]) -> None:
|
||||
if not delta_bytes:
|
||||
return
|
||||
win = window or 'day'
|
||||
key, ttl = _bucket_key(username, win)
|
||||
client = _get_client()
|
||||
if client is not None:
|
||||
try:
|
||||
# atomic increment
|
||||
new_val = client.incrby(key, int(delta_bytes))
|
||||
# ensure TTL at least for the window
|
||||
client.expire(key, ttl)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Memory fallback: not strictly atomic across processes
|
||||
cur = get_current_usage(username, win)
|
||||
new_val = cur + int(delta_bytes)
|
||||
try:
|
||||
# Directly use underlying MemoryCache for TTL control
|
||||
doorman_cache.cache.setex(key, ttl, str(new_val))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def enforce_pre_request_limit(request: Request, username: Optional[str]) -> None:
|
||||
if not username:
|
||||
return
|
||||
user = _get_user(username)
|
||||
if not user:
|
||||
return
|
||||
limit = user.get('bandwidth_limit_bytes')
|
||||
if not limit or int(limit) <= 0:
|
||||
return
|
||||
window = user.get('bandwidth_limit_window') or 'day'
|
||||
used = get_current_usage(username, window)
|
||||
# Check incoming content-length
|
||||
clen = 0
|
||||
try:
|
||||
clen = int(request.headers.get('content-length') or 0)
|
||||
except Exception:
|
||||
clen = 0
|
||||
if used >= int(limit) or used + clen > int(limit):
|
||||
raise HTTPException(status_code=429, detail='Bandwidth limit exceeded')
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from typing import Optional, List
|
||||
|
||||
from utils.security_settings_util import get_cached_settings
|
||||
|
||||
def _get_client_ip(request: Request, trust_xff: bool) -> Optional[str]:
|
||||
if trust_xff:
|
||||
xff = request.headers.get('x-forwarded-for') or request.headers.get('X-Forwarded-For')
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
return request.client.host if request.client else None
|
||||
|
||||
def _ip_in_list(ip: str, patterns: List[str]) -> bool:
|
||||
try:
|
||||
import ipaddress
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
for pat in (patterns or []):
|
||||
p = (pat or '').strip()
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
if '/' in p:
|
||||
net = ipaddress.ip_network(p, strict=False)
|
||||
if ip_obj in net:
|
||||
return True
|
||||
else:
|
||||
if ip_obj == ipaddress.ip_address(p):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def enforce_api_ip_policy(request: Request, api: dict):
|
||||
"""
|
||||
Enforce per-API IP policy.
|
||||
- api_ip_mode: 'allow_all' (default) or 'whitelist'
|
||||
- api_ip_whitelist: applied when mode=='whitelist'
|
||||
- api_ip_blacklist: always applied (deny)
|
||||
- api_trust_x_forwarded_for: override; otherwise use platform trust_x_forwarded_for
|
||||
"""
|
||||
try:
|
||||
settings = get_cached_settings()
|
||||
trust_xff = bool(api.get('api_trust_x_forwarded_for')) if api.get('api_trust_x_forwarded_for') is not None else bool(settings.get('trust_x_forwarded_for'))
|
||||
client_ip = _get_client_ip(request, trust_xff)
|
||||
if not client_ip:
|
||||
return
|
||||
mode = (api.get('api_ip_mode') or 'allow_all').strip().lower()
|
||||
wl = api.get('api_ip_whitelist') or []
|
||||
bl = api.get('api_ip_blacklist') or []
|
||||
# Blacklist always applies
|
||||
if bl and _ip_in_list(client_ip, bl):
|
||||
raise HTTPException(status_code=403, detail='API011')
|
||||
if mode == 'whitelist':
|
||||
if not wl or not _ip_in_list(client_ip, wl):
|
||||
raise HTTPException(status_code=403, detail='API010')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# Fail open on errors
|
||||
return
|
||||
|
||||
@@ -16,17 +16,24 @@ class MinuteBucket:
|
||||
count: int = 0
|
||||
error_count: int = 0
|
||||
total_ms: float = 0.0
|
||||
bytes_in: int = 0
|
||||
bytes_out: int = 0
|
||||
|
||||
status_counts: Dict[int, int] = field(default_factory=dict)
|
||||
api_counts: Dict[str, int] = field(default_factory=dict)
|
||||
api_error_counts: Dict[str, int] = field(default_factory=dict)
|
||||
user_counts: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def add(self, ms: float, status: int, username: Optional[str], api_key: Optional[str]) -> None:
|
||||
def add(self, ms: float, status: int, username: Optional[str], api_key: Optional[str], bytes_in: int = 0, bytes_out: int = 0) -> None:
|
||||
self.count += 1
|
||||
if status >= 400:
|
||||
self.error_count += 1
|
||||
self.total_ms += ms
|
||||
try:
|
||||
self.bytes_in += int(bytes_in or 0)
|
||||
self.bytes_out += int(bytes_out or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.status_counts[status] = self.status_counts.get(status, 0) + 1
|
||||
@@ -51,6 +58,8 @@ class MetricsStore:
|
||||
def __init__(self, max_minutes: int = 60 * 24 * 30):
|
||||
self.total_requests: int = 0
|
||||
self.total_ms: float = 0.0
|
||||
self.total_bytes_in: int = 0
|
||||
self.total_bytes_out: int = 0
|
||||
self.status_counts: Dict[int, int] = defaultdict(int)
|
||||
self.username_counts: Dict[str, int] = defaultdict(int)
|
||||
self.api_counts: Dict[str, int] = defaultdict(int)
|
||||
@@ -72,20 +81,25 @@ class MetricsStore:
|
||||
self._buckets.popleft()
|
||||
return mb
|
||||
|
||||
def record(self, status: int, duration_ms: float, username: Optional[str] = None, api_key: Optional[str] = None) -> None:
|
||||
def record(self, status: int, duration_ms: float, username: Optional[str] = None, api_key: Optional[str] = None, bytes_in: int = 0, bytes_out: int = 0) -> None:
|
||||
now = time.time()
|
||||
minute_start = self._minute_floor(now)
|
||||
bucket = self._ensure_bucket(minute_start)
|
||||
bucket.add(duration_ms, status, username, api_key)
|
||||
bucket.add(duration_ms, status, username, api_key, bytes_in=bytes_in, bytes_out=bytes_out)
|
||||
self.total_requests += 1
|
||||
self.total_ms += duration_ms
|
||||
try:
|
||||
self.total_bytes_in += int(bytes_in or 0)
|
||||
self.total_bytes_out += int(bytes_out or 0)
|
||||
except Exception:
|
||||
pass
|
||||
self.status_counts[status] += 1
|
||||
if username:
|
||||
self.username_counts[username] += 1
|
||||
if api_key:
|
||||
self.api_counts[api_key] += 1
|
||||
|
||||
def snapshot(self, range_key: str) -> Dict:
|
||||
def snapshot(self, range_key: str, group: str = 'minute', sort: str = 'asc') -> Dict:
|
||||
|
||||
range_to_minutes = {
|
||||
'1h': 60,
|
||||
@@ -96,20 +110,64 @@ class MetricsStore:
|
||||
minutes = range_to_minutes.get(range_key, 60 * 24)
|
||||
buckets: List[MinuteBucket] = list(self._buckets)[-minutes:]
|
||||
series = []
|
||||
for b in buckets:
|
||||
avg_ms = (b.total_ms / b.count) if b.count else 0.0
|
||||
series.append({
|
||||
'timestamp': b.start_ts,
|
||||
'count': b.count,
|
||||
'error_count': b.error_count,
|
||||
'avg_ms': avg_ms,
|
||||
|
||||
if group == 'day':
|
||||
# Aggregate per UTC day
|
||||
from collections import defaultdict
|
||||
day_map: Dict[int, Dict[str, float]] = defaultdict(lambda: {
|
||||
'count': 0,
|
||||
'error_count': 0,
|
||||
'total_ms': 0.0,
|
||||
'bytes_in': 0,
|
||||
'bytes_out': 0,
|
||||
})
|
||||
for b in buckets:
|
||||
day_ts = int((b.start_ts // 86400) * 86400)
|
||||
d = day_map[day_ts]
|
||||
d['count'] += b.count
|
||||
d['error_count'] += b.error_count
|
||||
d['total_ms'] += b.total_ms
|
||||
d['bytes_in'] += b.bytes_in
|
||||
d['bytes_out'] += b.bytes_out
|
||||
# Build series
|
||||
for day_ts, d in day_map.items():
|
||||
avg_ms = (d['total_ms'] / d['count']) if d['count'] else 0.0
|
||||
series.append({
|
||||
'timestamp': day_ts,
|
||||
'count': int(d['count']),
|
||||
'error_count': int(d['error_count']),
|
||||
'avg_ms': avg_ms,
|
||||
'bytes_in': int(d['bytes_in']),
|
||||
'bytes_out': int(d['bytes_out']),
|
||||
})
|
||||
else:
|
||||
# Default per-minute series
|
||||
for b in buckets:
|
||||
avg_ms = (b.total_ms / b.count) if b.count else 0.0
|
||||
series.append({
|
||||
'timestamp': b.start_ts,
|
||||
'count': b.count,
|
||||
'error_count': b.error_count,
|
||||
'avg_ms': avg_ms,
|
||||
'bytes_in': b.bytes_in,
|
||||
'bytes_out': b.bytes_out,
|
||||
})
|
||||
|
||||
# Sort series by timestamp
|
||||
reverse = (str(sort).lower() == 'desc')
|
||||
try:
|
||||
series.sort(key=lambda x: x.get('timestamp', 0), reverse=reverse)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total = self.total_requests
|
||||
avg_total_ms = (self.total_ms / total) if total else 0.0
|
||||
status = {str(k): v for k, v in self.status_counts.items()}
|
||||
return {
|
||||
'total_requests': total,
|
||||
'avg_response_ms': avg_total_ms,
|
||||
'total_bytes_in': self.total_bytes_in,
|
||||
'total_bytes_out': self.total_bytes_out,
|
||||
'status_counts': status,
|
||||
'series': series,
|
||||
'top_users': sorted(self.username_counts.items(), key=lambda kv: kv[1], reverse=True)[:10],
|
||||
|
||||
@@ -23,6 +23,9 @@ DEFAULTS = {
|
||||
'enable_auto_save': False,
|
||||
'auto_save_frequency_seconds': 900,
|
||||
'dump_path': os.getenv('MEM_DUMP_PATH', 'generated/memory_dump.bin'),
|
||||
'ip_whitelist': [],
|
||||
'ip_blacklist': [],
|
||||
'trust_x_forwarded_for': False,
|
||||
}
|
||||
|
||||
# Persist settings to a small JSON file so memory-only mode
|
||||
|
||||
@@ -24,6 +24,10 @@ interface API {
|
||||
api_allowed_retry_count: number
|
||||
api_authorization_field_swap?: string
|
||||
api_allowed_headers?: string[]
|
||||
api_ip_mode?: 'allow_all' | 'whitelist'
|
||||
api_ip_whitelist?: string[]
|
||||
api_ip_blacklist?: string[]
|
||||
api_trust_x_forwarded_for?: boolean
|
||||
api_credits_enabled: boolean
|
||||
api_credit_group?: string
|
||||
api_path?: string
|
||||
@@ -50,6 +54,10 @@ interface UpdateApiData {
|
||||
api_allowed_retry_count?: number
|
||||
api_authorization_field_swap?: string
|
||||
api_allowed_headers?: string[]
|
||||
api_ip_mode?: 'allow_all' | 'whitelist'
|
||||
api_ip_whitelist?: string[]
|
||||
api_ip_blacklist?: string[]
|
||||
api_trust_x_forwarded_for?: boolean
|
||||
api_credits_enabled?: boolean
|
||||
api_credit_group?: string
|
||||
api_public?: boolean
|
||||
@@ -72,6 +80,10 @@ const ApiDetailPage = () => {
|
||||
const [newGroup, setNewGroup] = useState('')
|
||||
const [newServer, setNewServer] = useState('')
|
||||
const [newHeader, setNewHeader] = useState('')
|
||||
const [ipWhitelistText, setIpWhitelistText] = useState('')
|
||||
const [ipBlacklistText, setIpBlacklistText] = useState('')
|
||||
const [clientIp, setClientIp] = useState('')
|
||||
const [clientIpXff, setClientIpXff] = useState('')
|
||||
const [endpoints, setEndpoints] = useState<EndpointItem[]>([])
|
||||
const [epNewServer, setEpNewServer] = useState<Record<string, string>>({})
|
||||
const [epSaving, setEpSaving] = useState<Record<string, boolean>>({})
|
||||
@@ -173,6 +185,10 @@ const ApiDetailPage = () => {
|
||||
api_allowed_retry_count: parsedApi.api_allowed_retry_count,
|
||||
api_authorization_field_swap: parsedApi.api_authorization_field_swap,
|
||||
api_allowed_headers: [...(parsedApi.api_allowed_headers || [])],
|
||||
api_ip_mode: (parsedApi as any).api_ip_mode || 'allow_all',
|
||||
api_ip_whitelist: [...((parsedApi as any).api_ip_whitelist || [])],
|
||||
api_ip_blacklist: [...((parsedApi as any).api_ip_blacklist || [])],
|
||||
api_trust_x_forwarded_for: !!(parsedApi as any).api_trust_x_forwarded_for,
|
||||
api_credits_enabled: parsedApi.api_credits_enabled,
|
||||
api_credit_group: parsedApi.api_credit_group,
|
||||
api_public: (parsedApi as any).api_public,
|
||||
@@ -180,6 +196,8 @@ const ApiDetailPage = () => {
|
||||
active: (parsedApi as any).active
|
||||
})
|
||||
setLoading(false)
|
||||
setIpWhitelistText(((parsedApi as any).api_ip_whitelist || []).join('\n'))
|
||||
setIpBlacklistText(((parsedApi as any).api_ip_blacklist || []).join('\n'))
|
||||
} catch (err) {
|
||||
setError('Failed to load API data')
|
||||
setLoading(false)
|
||||
@@ -222,11 +240,38 @@ const ApiDetailPage = () => {
|
||||
setError('Failed to load API data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
try {
|
||||
const found = JSON.parse(sessionStorage.getItem('selectedApi') || 'null') || (api as any)
|
||||
if (found) {
|
||||
setIpWhitelistText(((found as any).api_ip_whitelist || []).join('\n'))
|
||||
setIpBlacklistText(((found as any).api_ip_blacklist || []).join('\n'))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
}, [apiId])
|
||||
|
||||
// Fetch client IP info for warning checks
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await fetchJson(`${SERVER_URL}/platform/security/settings`)
|
||||
setClientIp(String((data as any).client_ip || ''))
|
||||
setClientIpXff(String((data as any).client_ip_xff || ''))
|
||||
} catch {}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const addMyIpToWhitelist = () => {
|
||||
const trust = isEditing ? !!editData.api_trust_x_forwarded_for : !!(api as any)?.api_trust_x_forwarded_for
|
||||
const effectiveIp = (trust && clientIpXff) ? clientIpXff : clientIp
|
||||
if (!effectiveIp) return
|
||||
const list = ipWhitelistText.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean)
|
||||
if (list.includes(effectiveIp)) return
|
||||
setIpWhitelistText(prev => (prev && prev.trim().length > 0) ? `${prev.trim()}\n${effectiveIp}` : effectiveIp)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadEndpoints = async () => {
|
||||
if (!api) return
|
||||
@@ -303,7 +348,10 @@ const ApiDetailPage = () => {
|
||||
|
||||
const targetName = (api?.['api_name'] as string) || ''
|
||||
const targetVersion = (api?.['api_version'] as string) || ''
|
||||
await putJson(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, editData)
|
||||
const payload: any = { ...editData }
|
||||
if (typeof ipWhitelistText === 'string') payload.api_ip_whitelist = ipWhitelistText.split(/\r?\n|,/).map(s=>s.trim()).filter(Boolean)
|
||||
if (typeof ipBlacklistText === 'string') payload.api_ip_blacklist = ipBlacklistText.split(/\r?\n|,/).map(s=>s.trim()).filter(Boolean)
|
||||
await putJson(`${SERVER_URL}/platform/api/${encodeURIComponent(targetName)}/${encodeURIComponent(targetVersion)}`, payload)
|
||||
|
||||
// Refresh from server to get the latest canonical data
|
||||
try {
|
||||
@@ -931,6 +979,94 @@ const ApiDetailPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">IP Access Control</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">Manage IP policy for this API</div>
|
||||
<button type="button" className="btn btn-ghost btn-xs" onClick={addMyIpToWhitelist}>Add My IP</button>
|
||||
</div>
|
||||
{(() => {
|
||||
const effectiveIp = (((isEditing ? editData.api_trust_x_forwarded_for : (api as any).api_trust_x_forwarded_for) && clientIpXff) ? clientIpXff : clientIp)
|
||||
const listFromText = (t: string) => t.split(/\r?\n|,/).map(s=>s.trim()).filter(Boolean)
|
||||
const wl = isEditing ? listFromText(ipWhitelistText) : (((api as any).api_ip_whitelist || []) as string[])
|
||||
const bl = isEditing ? listFromText(ipBlacklistText) : (((api as any).api_ip_blacklist || []) as string[])
|
||||
const toLong = (s: string) => { const parts = s.split('.'); if (parts.length !== 4) return NaN; return parts.reduce((a,p)=> (a<<8)+(parseInt(p,10)&255),0) }
|
||||
const matches = (ip: string, patterns: string[]) => {
|
||||
if (!ip) return false
|
||||
const ipL = toLong(ip)
|
||||
return (patterns || []).some(raw => {
|
||||
const p = (raw || '').trim(); if (!p) return false
|
||||
if (p.includes('/')) {
|
||||
const [net, maskStr] = p.split('/'); const mask = parseInt(maskStr,10)
|
||||
const netL = toLong(net); if (isNaN(ipL) || isNaN(netL)) return false
|
||||
const maskBits = mask <= 0 ? 0 : (0xFFFFFFFF << (32 - Math.min(mask, 32))) >>> 0
|
||||
return (((ipL>>>0) & maskBits) === (netL & maskBits))
|
||||
}
|
||||
return p === ip
|
||||
})
|
||||
}
|
||||
const mode = (isEditing ? editData.api_ip_mode : (api as any).api_ip_mode) || 'allow_all'
|
||||
const warnWL = (mode === 'whitelist') && (wl.length > 0) && !matches(effectiveIp, wl)
|
||||
const warnBL = matches(effectiveIp, bl)
|
||||
if (!(warnWL || warnBL)) return null
|
||||
return (
|
||||
<div className="rounded-md bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
{warnBL ? 'Warning: Your current IP appears in the blacklist and you may lose access after saving.' : 'Warning: Your current IP is not in the whitelist and you may lose access after saving.'}
|
||||
<div className="text-xs mt-1">Your IP: {effectiveIp || 'unknown'} {((isEditing ? editData.api_trust_x_forwarded_for : (api as any).api_trust_x_forwarded_for) ? '(using X-Forwarded-For)' : '')}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Policy</label>
|
||||
{isEditing ? (
|
||||
<select className="input" value={(editData.api_ip_mode as any) || 'allow_all'} onChange={(e)=>handleInputChange('api_ip_mode', e.target.value as any)}>
|
||||
<option value="allow_all">Allow All</option>
|
||||
<option value="whitelist">Whitelist</option>
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{(api as any).api_ip_mode || 'allow_all'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input type="checkbox" className="h-4 w-4" checked={!!editData.api_trust_x_forwarded_for} onChange={(e)=>handleInputChange('api_trust_x_forwarded_for', e.target.checked)} />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Trust X-Forwarded-For (behind proxy)</label>
|
||||
<InfoTooltip text="Effective IP for this API: if enabled and X-Forwarded-For is present, use its first IP; otherwise use the direct client IP." />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-900 dark:text-white">Trust XFF: {((api as any).api_trust_x_forwarded_for ? 'Yes' : 'No')}</p>
|
||||
<InfoTooltip text="Effective IP for this API: if X-Forwarded-For is trusted and present, the first IP is used; otherwise the direct client IP is used." />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Whitelist</label>
|
||||
{isEditing ? (
|
||||
<textarea className="input min-h-[120px]" value={ipWhitelistText} onChange={(e)=>setIpWhitelistText(e.target.value)} />
|
||||
) : (
|
||||
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{((api as any).api_ip_whitelist || []).join('\n') || '—'}</pre>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Blacklist</label>
|
||||
{isEditing ? (
|
||||
<textarea className="input min-h-[120px]" value={ipBlacklistText} onChange={(e)=>setIpBlacklistText(e.target.value)} />
|
||||
) : (
|
||||
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{((api as any).api_ip_blacklist || []).join('\n') || '—'}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{useProtobuf ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
|
||||
@@ -9,6 +9,7 @@ import FormHelp from '@/components/FormHelp'
|
||||
import { SERVER_URL } from '@/utils/config'
|
||||
import { postJson } from '@/utils/api'
|
||||
import ConfirmModal from '@/components/ConfirmModal'
|
||||
import { getJson } from '@/utils/api'
|
||||
|
||||
const AddApiPage = () => {
|
||||
const router = useRouter()
|
||||
@@ -29,6 +30,8 @@ const AddApiPage = () => {
|
||||
api_credit_group: '',
|
||||
active: true,
|
||||
api_auth_required: true,
|
||||
api_ip_mode: 'allow_all' as 'allow_all' | 'whitelist',
|
||||
api_trust_x_forwarded_for: false,
|
||||
// Frontend-only preference; stored in localStorage per API
|
||||
use_protobuf: false,
|
||||
// kept for future use; backend ignores unknown fields
|
||||
@@ -42,6 +45,28 @@ const AddApiPage = () => {
|
||||
const [newRole, setNewRole] = useState('')
|
||||
const [newGroup, setNewGroup] = useState('')
|
||||
const [newHeader, setNewHeader] = useState('')
|
||||
const [ipWhitelistText, setIpWhitelistText] = useState('')
|
||||
const [ipBlacklistText, setIpBlacklistText] = useState('')
|
||||
const [clientIp, setClientIp] = useState('')
|
||||
const [clientIpXff, setClientIpXff] = useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const data = await getJson<any>(`${SERVER_URL}/platform/security/settings`)
|
||||
setClientIp(String(data.client_ip || ''))
|
||||
setClientIpXff(String(data.client_ip_xff || ''))
|
||||
} catch {}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const addMyIpToWhitelist = () => {
|
||||
const effectiveIp = (((formData as any).api_trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp)
|
||||
if (!effectiveIp) return
|
||||
const list = ipWhitelistText.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean)
|
||||
if (list.includes(effectiveIp)) return
|
||||
setIpWhitelistText(prev => (prev && prev.trim().length > 0) ? `${prev.trim()}\n${effectiveIp}` : effectiveIp)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -51,6 +76,8 @@ const AddApiPage = () => {
|
||||
try {
|
||||
// Trim empty optional fields to keep payload clean
|
||||
const payload: any = { ...formData }
|
||||
payload.api_ip_whitelist = ipWhitelistText.split(/\r?\n|,/).map((s:string) => s.trim()).filter(Boolean)
|
||||
payload.api_ip_blacklist = ipBlacklistText.split(/\r?\n|,/).map((s:string) => s.trim()).filter(Boolean)
|
||||
if (!payload.api_authorization_field_swap) delete payload.api_authorization_field_swap
|
||||
if (!payload.api_credit_group) delete payload.api_credit_group
|
||||
if (!Array.isArray(payload.api_allowed_headers) || payload.api_allowed_headers.length === 0) delete payload.api_allowed_headers
|
||||
@@ -313,6 +340,74 @@ const AddApiPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">IP Access Control</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#api-ip-policy">Control IP access per API.</FormHelp>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{(() => {
|
||||
const effectiveIp = (((formData as any).api_trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp)
|
||||
const listFromText = (t: string) => t.split(/\r?\n|,/).map(s=>s.trim()).filter(Boolean)
|
||||
const wl = listFromText(ipWhitelistText)
|
||||
const bl = listFromText(ipBlacklistText)
|
||||
const toLong = (s: string) => { const parts = s.split('.'); if (parts.length !== 4) return NaN; return parts.reduce((a,p)=> (a<<8)+(parseInt(p,10)&255),0) }
|
||||
const matches = (ip: string, patterns: string[]) => {
|
||||
if (!ip) return false
|
||||
const ipL = toLong(ip)
|
||||
return patterns.some(raw => {
|
||||
const p = raw.trim(); if (!p) return false
|
||||
if (p.includes('/')) {
|
||||
const [net, maskStr] = p.split('/'); const mask = parseInt(maskStr,10)
|
||||
const netL = toLong(net); if (isNaN(ipL) || isNaN(netL)) return false
|
||||
const maskBits = mask <= 0 ? 0 : (0xFFFFFFFF << (32 - Math.min(mask, 32))) >>> 0
|
||||
return (((ipL>>>0) & maskBits) === (netL & maskBits))
|
||||
}
|
||||
return p === ip
|
||||
})
|
||||
}
|
||||
const warnWL = ((formData as any).api_ip_mode === 'whitelist') && wl.length > 0 && !matches(effectiveIp, wl)
|
||||
const warnBL = matches(effectiveIp, bl)
|
||||
if (!(warnWL || warnBL)) return null
|
||||
return (
|
||||
<div className="rounded-md bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||
{warnBL ? 'Warning: Your current IP appears in the blacklist and you may lose access after saving.' : 'Warning: Your current IP is not in the whitelist and you may lose access after saving.'}
|
||||
<div className="text-xs mt-1">Your IP: {effectiveIp || 'unknown'} {((formData as any).api_trust_x_forwarded_for ? '(using X-Forwarded-For)' : '')}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Policy</label>
|
||||
<select name="api_ip_mode" className="input" value={formData.api_ip_mode}
|
||||
onChange={(e)=>setFormData(p=>({...p, api_ip_mode: e.target.value as any}))}>
|
||||
<option value="allow_all">Allow All</option>
|
||||
<option value="whitelist">Whitelist</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">When Whitelist is selected, only listed IPs/CIDRs can call this API. Blacklist applies always.</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex items-center gap-2">
|
||||
<input id="api_trust_x_forwarded_for" type="checkbox" className="h-4 w-4" checked={!!(formData as any).api_trust_x_forwarded_for} onChange={(e)=>setFormData(p=>({...p, api_trust_x_forwarded_for: e.target.checked}))} />
|
||||
<label htmlFor="api_trust_x_forwarded_for" className="text-sm text-gray-700 dark:text-gray-300">Trust X-Forwarded-For (behind proxy)</label>
|
||||
<InfoTooltip text="Effective IP for this API: if enabled and X-Forwarded-For is present, use its first IP; otherwise use the direct client IP." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Whitelist (one per line or comma-separated)</label>
|
||||
<button type="button" className="btn btn-ghost btn-xs" onClick={addMyIpToWhitelist}>Add My IP</button>
|
||||
</div>
|
||||
<textarea className="input min-h-[120px]" value={ipWhitelistText} onChange={(e)=>setIpWhitelistText(e.target.value)} placeholder="192.168.1.10\n10.0.0.0/8" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Blacklist (one per line or comma-separated)</label>
|
||||
<textarea className="input min-h-[120px]" value={ipBlacklistText} onChange={(e)=>setIpBlacklistText(e.target.value)} placeholder="203.0.113.0/24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Configuration</h3>
|
||||
|
||||
@@ -269,6 +269,12 @@ const APIsPage = () => {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{api.api_name}</p>
|
||||
{(((api as any).api_ip_mode || 'allow_all') === 'whitelist') && (
|
||||
<span className="badge badge-secondary">IP Whitelist</span>
|
||||
)}
|
||||
{Array.isArray((api as any).api_ip_blacklist) && (api as any).api_ip_blacklist.length > 0 && (
|
||||
<span className="badge badge-error">Blacklist</span>
|
||||
)}
|
||||
{((api as any).api_public ?? false) && (
|
||||
<span className="badge badge-warning flex items-center gap-1" title="This API is public">
|
||||
Public
|
||||
|
||||
@@ -29,20 +29,30 @@ const MonitorPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [metrics, setMetrics] = useState<any | null>(null)
|
||||
const [timeRange, setTimeRange] = useState('24h')
|
||||
const [groupBy, setGroupBy] = useState<'minute' | 'day'>('minute')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [liveness, setLiveness] = useState<string | null>(null)
|
||||
const [readiness, setReadiness] = useState<{ status: string; mongodb?: boolean; redis?: boolean; mode?: string; cache_backend?: string } | null>(null)
|
||||
const fmtBytes = (n: number | undefined | null) => {
|
||||
const v = typeof n === 'number' ? n : 0
|
||||
const units = ['B','KB','MB','GB','TB']
|
||||
let u = 0; let val = v
|
||||
while (val >= 1024 && u < units.length - 1) { val /= 1024; u++ }
|
||||
return `${val.toFixed(val >= 10 || u === 0 ? 0 : 1)} ${units[u]}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
fetchProbes()
|
||||
}, [timeRange])
|
||||
}, [timeRange, groupBy, sortOrder])
|
||||
|
||||
const fetchMetrics = async (rangeOverride?: string) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const range = rangeOverride ?? timeRange
|
||||
const payload = await getJson<any>(`${SERVER_URL}/platform/monitor/metrics?range=${encodeURIComponent(range)}`)
|
||||
const url = `${SERVER_URL}/platform/monitor/metrics?range=${encodeURIComponent(range)}&group=${encodeURIComponent(groupBy)}&sort=${encodeURIComponent(sortOrder)}`
|
||||
const payload = await getJson<any>(url)
|
||||
setMetrics(payload)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
@@ -110,6 +120,22 @@ const MonitorPage: React.FC = () => {
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 30 Days</option>
|
||||
</select>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy((e.target.value as 'minute' | 'day'))}
|
||||
className="input"
|
||||
>
|
||||
<option value="minute">Group: Minute</option>
|
||||
<option value="day">Group: Day</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder((e.target.value as 'asc' | 'desc'))}
|
||||
className="input"
|
||||
>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<button onClick={() => { void fetchMetrics(); }} className="btn btn-secondary">
|
||||
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
@@ -186,6 +212,51 @@ const MonitorPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Data In</p>
|
||||
<p className="stats-value">{fmtBytes(metrics?.total_bytes_in)}</p>
|
||||
<p className="stats-change"> </p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-indigo-100 dark:bg-indigo-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Data Out</p>
|
||||
<p className="stats-value">{fmtBytes(metrics?.total_bytes_out)}</p>
|
||||
<p className="stats-change"> </p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-amber-100 dark:bg-amber-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2m13 4l-5-5-5 5m5-5v12" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="stats-label">Total Data</p>
|
||||
<p className="stats-value">{fmtBytes(((metrics?.total_bytes_in ?? 0) + (metrics?.total_bytes_out ?? 0)))}</p>
|
||||
<p className="stats-change"> </p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-lg bg-cyan-100 dark:bg-cyan-900/20 flex items-center justify-center">
|
||||
<svg className="h-6 w-6 text-cyan-600 dark:text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h10a4 4 0 100-8h-1M7 15a4 4 0 010-8h8a4 4 0 010 8" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -256,21 +327,18 @@ const MonitorPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header"><h3 className="card-title">Request Volume (per minute)</h3></div>
|
||||
<div className="card-header"><h3 className="card-title">Request Volume ({groupBy === 'day' ? 'per day' : 'per minute'})</h3></div>
|
||||
<div className="p-6">
|
||||
<div className="h-48 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{((metrics?.series || []) as any[])
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((pt: any, idx: number) => (
|
||||
<li key={`${pt.timestamp}-${idx}`} className="flex justify-between">
|
||||
<span>{new Date(pt.timestamp * 1000).toLocaleTimeString()}</span>
|
||||
<span>
|
||||
{pt.count} req • avg {Math.round(pt.avg_ms)}ms • {pt.error_count} errs
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{((metrics?.series || []) as any[]).map((pt: any, idx: number) => (
|
||||
<li key={`${pt.timestamp}-${idx}`} className="flex justify-between">
|
||||
<span>{groupBy === 'day' ? new Date(pt.timestamp * 1000).toLocaleDateString() : new Date(pt.timestamp * 1000).toLocaleTimeString()}</span>
|
||||
<span>
|
||||
{pt.count} req • avg {Math.round(pt.avg_ms)}ms • {pt.error_count} errs
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{(!metrics || !metrics.series || metrics.series.length === 0) && (
|
||||
<p className="text-gray-500 dark:text-gray-400">No data</p>
|
||||
@@ -280,6 +348,29 @@ const MonitorPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Bandwidth ({groupBy === 'day' ? 'per day' : 'per minute'})</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="h-48 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{((metrics?.series || []) as any[]).map((pt: any, idx: number) => (
|
||||
<li key={`bw-${pt.timestamp}-${idx}`} className="flex justify-between">
|
||||
<span>{groupBy === 'day' ? new Date(pt.timestamp * 1000).toLocaleDateString() : new Date(pt.timestamp * 1000).toLocaleTimeString()}</span>
|
||||
<span>
|
||||
In {fmtBytes(pt.bytes_in)} • Out {fmtBytes(pt.bytes_out)} • Total {fmtBytes((pt.bytes_in || 0) + (pt.bytes_out || 0))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{(!metrics || !metrics.series || metrics.series.length === 0) && (
|
||||
<p className="text-gray-500 dark:text-gray-400">No data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">System Status</h3>
|
||||
|
||||
@@ -77,11 +77,11 @@ export default function ReportsPage() {
|
||||
<button className="btn btn-secondary" onClick={() => preset(24*7)}>Last 7d</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn btn-primary" onClick={generate} disabled={downloading}>{downloading ? 'Generating…' : 'Download CSV'}</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The CSV includes overview, status code distribution, per-API usage (success/failure), and per-user request counts for the selected range.
|
||||
The CSV includes overview, status code distribution, per-API usage (success/failure), per-user request counts, and bandwidth totals plus a per-day bandwidth breakdown for the selected range.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,4 +90,3 @@ export default function ReportsPage() {
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ interface SecuritySettings {
|
||||
enable_auto_save: boolean
|
||||
auto_save_frequency_seconds: number
|
||||
dump_path: string
|
||||
ip_whitelist?: string[]
|
||||
ip_blacklist?: string[]
|
||||
trust_x_forwarded_for?: boolean
|
||||
}
|
||||
|
||||
const SecurityPage = () => {
|
||||
@@ -64,9 +67,16 @@ const SecurityPage = () => {
|
||||
const [settings, setSettings] = useState<SecuritySettings>({
|
||||
enable_auto_save: false,
|
||||
auto_save_frequency_seconds: 900,
|
||||
dump_path: 'generated/memory_dump.bin'
|
||||
dump_path: 'generated/memory_dump.bin',
|
||||
ip_whitelist: [],
|
||||
ip_blacklist: [],
|
||||
trust_x_forwarded_for: false,
|
||||
})
|
||||
const [ipWhitelistText, setIpWhitelistText] = useState('')
|
||||
const [ipBlacklistText, setIpBlacklistText] = useState('')
|
||||
const [memoryOnly, setMemoryOnly] = useState(false)
|
||||
const [clientIp, setClientIp] = useState('')
|
||||
const [clientIpXff, setClientIpXff] = useState('')
|
||||
const [restorePath, setRestorePath] = useState('')
|
||||
const { permissions } = useAuth()
|
||||
|
||||
@@ -115,9 +125,16 @@ const SecurityPage = () => {
|
||||
setSettings({
|
||||
enable_auto_save: !!data.enable_auto_save,
|
||||
auto_save_frequency_seconds: Number(data.auto_save_frequency_seconds || 900),
|
||||
dump_path: data.dump_path || 'generated/memory_dump.bin'
|
||||
dump_path: data.dump_path || 'generated/memory_dump.bin',
|
||||
ip_whitelist: Array.isArray(data.ip_whitelist) ? data.ip_whitelist : [],
|
||||
ip_blacklist: Array.isArray(data.ip_blacklist) ? data.ip_blacklist : [],
|
||||
trust_x_forwarded_for: !!data.trust_x_forwarded_for,
|
||||
})
|
||||
setMemoryOnly(!!data.memory_only)
|
||||
setClientIp(String(data.client_ip || ''))
|
||||
setClientIpXff(String(data.client_ip_xff || ''))
|
||||
setIpWhitelistText((Array.isArray(data.ip_whitelist) ? data.ip_whitelist : []).join('\n'))
|
||||
setIpBlacklistText((Array.isArray(data.ip_blacklist) ? data.ip_blacklist : []).join('\n'))
|
||||
} catch (err) {
|
||||
setError('Failed to load security settings. Please try again later.')
|
||||
} finally {
|
||||
@@ -129,7 +146,12 @@ const SecurityPage = () => {
|
||||
try {
|
||||
setSettingsSaving(true)
|
||||
setError(null)
|
||||
await putJson(`${SERVER_URL}/platform/security/settings`, settings)
|
||||
const payload = {
|
||||
...settings,
|
||||
ip_whitelist: ipWhitelistText.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean),
|
||||
ip_blacklist: ipBlacklistText.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean),
|
||||
}
|
||||
await putJson(`${SERVER_URL}/platform/security/settings`, payload)
|
||||
setSuccess('Security settings saved')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
@@ -233,6 +255,14 @@ const SecurityPage = () => {
|
||||
|
||||
// No tabs for this page; show all sections inline
|
||||
|
||||
const addMyIpToWhitelist = () => {
|
||||
const effectiveIp = (settings.trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp
|
||||
if (!effectiveIp) return
|
||||
const list = ipWhitelistText.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean)
|
||||
if (list.includes(effectiveIp)) return
|
||||
setIpWhitelistText(prev => (prev && prev.trim().length > 0) ? `${prev.trim()}\n${effectiveIp}` : effectiveIp)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredPermission="manage_security">
|
||||
<Layout>
|
||||
@@ -364,6 +394,61 @@ const SecurityPage = () => {
|
||||
<button onClick={handleDumpNow} className="btn btn-secondary">Dump Now</button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<h4 className="text-md font-medium text-gray-900 dark:text-white mb-2">IP Access Control <InfoTooltip text="Effective IP: if 'Trust X-Forwarded-For' is enabled and the request includes X-Forwarded-For, the first IP in that header is used. Otherwise the direct client IP is used. Warnings and enforcement follow this rule." /></h4>
|
||||
{(() => {
|
||||
// compute effective IP and risk inline to avoid stale values
|
||||
const effectiveIp = (settings.trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp
|
||||
const listFromText = (t: string) => t.split(/\r?\n|,/).map(s=>s.trim()).filter(Boolean)
|
||||
const wl = listFromText(ipWhitelistText)
|
||||
const bl = listFromText(ipBlacklistText)
|
||||
const toLong = (s: string) => { const parts = s.split('.'); if (parts.length !== 4) return NaN; return parts.reduce((a,p)=> (a<<8)+(parseInt(p,10)&255),0) }
|
||||
const matches = (ip: string, patterns: string[]) => {
|
||||
if (!ip) return false
|
||||
const ipL = toLong(ip)
|
||||
return patterns.some(raw => {
|
||||
const p = raw.trim(); if (!p) return false
|
||||
if (p.includes('/')) {
|
||||
const [net, maskStr] = p.split('/'); const mask = parseInt(maskStr,10)
|
||||
const netL = toLong(net); if (isNaN(ipL) || isNaN(netL)) return false
|
||||
const maskBits = mask <= 0 ? 0 : (0xFFFFFFFF << (32 - Math.min(mask, 32))) >>> 0
|
||||
return (((ipL>>>0) & maskBits) === (netL & maskBits))
|
||||
}
|
||||
return p === ip
|
||||
})
|
||||
}
|
||||
const warnWL = wl.length > 0 && !matches(effectiveIp, wl)
|
||||
const warnBL = matches(effectiveIp, bl)
|
||||
if (!(warnWL || warnBL)) return null
|
||||
return (
|
||||
<div className="rounded-md bg-warning-50 border border-warning-200 p-3 text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200 mb-2">
|
||||
{warnBL ? 'Warning: Your current IP appears in the blacklist and you may lose access after saving.' : 'Warning: Your current IP is not in the whitelist and you may lose access after saving.'}
|
||||
<div className="text-xs mt-1">Your IP: {effectiveIp || 'unknown'} {settings.trust_x_forwarded_for ? '(using X-Forwarded-For)' : ''}</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Whitelist (one per line or comma-separated)</label>
|
||||
<button type="button" className="btn btn-ghost btn-xs" onClick={addMyIpToWhitelist}>Add My IP</button>
|
||||
</div>
|
||||
<textarea className="input min-h-[120px]" value={ipWhitelistText} onChange={(e)=>setIpWhitelistText(e.target.value)} placeholder="192.168.1.10\n10.0.0.0/8" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">If non-empty, only these IPs/CIDRs can access the platform and gateway.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Blacklist (one per line or comma-separated)</label>
|
||||
<textarea className="input min-h-[120px]" value={ipBlacklistText} onChange={(e)=>setIpBlacklistText(e.target.value)} placeholder="203.0.113.0/24" />
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Denied IPs/CIDRs. Checked after whitelist.</p>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex items-center gap-2">
|
||||
<input type="checkbox" className="h-4 w-4" checked={!!settings.trust_x_forwarded_for} onChange={(e)=>setSettings(s => ({...s, trust_x_forwarded_for: e.target.checked}))} />
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">Trust X-Forwarded-For (when behind a proxy)</label>
|
||||
<InfoTooltip text="When enabled, the first IP in X-Forwarded-For is treated as the client IP. Otherwise, the direct source IP is used." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Restore From Path
|
||||
|
||||
@@ -23,6 +23,8 @@ interface User {
|
||||
throttle_wait_duration_type: string
|
||||
throttle_queue_limit: number | null
|
||||
custom_attributes: Record<string, string>
|
||||
bandwidth_limit_bytes?: number
|
||||
bandwidth_limit_window?: string
|
||||
active: boolean
|
||||
ui_access?: boolean
|
||||
}
|
||||
@@ -41,6 +43,8 @@ interface UpdateUserData {
|
||||
throttle_wait_duration_type?: string
|
||||
throttle_queue_limit?: number | null
|
||||
custom_attributes?: Record<string, string>
|
||||
bandwidth_limit_bytes?: number
|
||||
bandwidth_limit_window?: string
|
||||
active?: boolean
|
||||
ui_access?: boolean
|
||||
}
|
||||
@@ -55,6 +59,7 @@ const UserDetailPage = () => {
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [refreshingUsage, setRefreshingUsage] = useState(false)
|
||||
const [editData, setEditData] = useState<UpdateUserData>({})
|
||||
const [newCustomAttribute, setNewCustomAttribute] = useState({ key: '', value: '' })
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
@@ -83,10 +88,25 @@ const UserDetailPage = () => {
|
||||
throttle_wait_duration_type: parsedUser.throttle_wait_duration_type,
|
||||
throttle_queue_limit: parsedUser.throttle_queue_limit,
|
||||
custom_attributes: { ...parsedUser.custom_attributes },
|
||||
bandwidth_limit_bytes: parsedUser.bandwidth_limit_bytes,
|
||||
bandwidth_limit_window: parsedUser.bandwidth_limit_window,
|
||||
active: parsedUser.active,
|
||||
ui_access: parsedUser.ui_access
|
||||
})
|
||||
setLoading(false)
|
||||
// Background refresh from server for live fields (e.g., bandwidth usage)
|
||||
;(async () => {
|
||||
try {
|
||||
const refreshed = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(parsedUser.username)}`)
|
||||
setUser(refreshed)
|
||||
sessionStorage.setItem('selectedUser', JSON.stringify(refreshed))
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
bandwidth_limit_bytes: refreshed.bandwidth_limit_bytes,
|
||||
bandwidth_limit_window: refreshed.bandwidth_limit_window,
|
||||
}))
|
||||
} catch {}
|
||||
})()
|
||||
} catch (err) {
|
||||
setError('Failed to load user data')
|
||||
setLoading(false)
|
||||
@@ -125,6 +145,8 @@ const UserDetailPage = () => {
|
||||
throttle_wait_duration_type: user.throttle_wait_duration_type,
|
||||
throttle_queue_limit: user.throttle_queue_limit,
|
||||
custom_attributes: { ...user.custom_attributes },
|
||||
bandwidth_limit_bytes: user.bandwidth_limit_bytes,
|
||||
bandwidth_limit_window: user.bandwidth_limit_window,
|
||||
active: user.active,
|
||||
ui_access: user.ui_access
|
||||
})
|
||||
@@ -258,6 +280,20 @@ const UserDetailPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUsage = async () => {
|
||||
try {
|
||||
setRefreshingUsage(true)
|
||||
const refreshedUser = await fetchJson(`${SERVER_URL}/platform/user/${encodeURIComponent(username)}`)
|
||||
setUser(refreshedUser)
|
||||
sessionStorage.setItem('selectedUser', JSON.stringify(refreshedUser))
|
||||
} catch (err) {
|
||||
if (err instanceof Error) setError(err.message)
|
||||
else setError('Failed to refresh usage')
|
||||
} finally {
|
||||
setRefreshingUsage(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
@@ -506,6 +542,58 @@ const UserDetailPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Bandwidth Limit</h3>
|
||||
{!isEditing && (
|
||||
<button onClick={refreshUsage} className="btn btn-outline btn-sm" disabled={refreshingUsage}>
|
||||
{refreshingUsage ? (
|
||||
<span className="flex items-center"><span className="spinner mr-2"></span>Refreshing</span>
|
||||
) : (
|
||||
'Refresh Usage'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bytes (limit)</label>
|
||||
{isEditing ? (
|
||||
<input type="number" className="input" min={0}
|
||||
value={editData.bandwidth_limit_bytes ?? 0}
|
||||
onChange={(e) => handleInputChange('bandwidth_limit_bytes', e.target.value ? parseInt(e.target.value) : undefined)} />
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{user.bandwidth_limit_bytes ?? '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Window</label>
|
||||
{isEditing ? (
|
||||
<select className="input" value={editData.bandwidth_limit_window || 'day'}
|
||||
onChange={(e) => handleInputChange('bandwidth_limit_window', e.target.value)}>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-gray-900 dark:text-white">{user.bandwidth_limit_window || 'day'}</p>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Usage</label>
|
||||
<p className="text-gray-900 dark:text-white">
|
||||
{typeof (user as any).bandwidth_usage_bytes === 'number' ? (user as any).bandwidth_usage_bytes : 0} / {user.bandwidth_limit_bytes ?? '—'} bytes
|
||||
{(user as any).bandwidth_resets_at ? ` • resets ${new Date(((user as any).bandwidth_resets_at) * 1000).toLocaleString()}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="card-title">Groups</h3>
|
||||
|
||||
@@ -23,6 +23,8 @@ interface CreateUserData {
|
||||
throttle_wait_duration_type?: string
|
||||
throttle_queue_limit?: number | null
|
||||
custom_attributes: Record<string, string>
|
||||
bandwidth_limit_bytes?: number
|
||||
bandwidth_limit_window?: string
|
||||
active: boolean
|
||||
ui_access: boolean
|
||||
}
|
||||
@@ -36,6 +38,8 @@ const AddUserPage = () => {
|
||||
role: '',
|
||||
groups: [],
|
||||
custom_attributes: {},
|
||||
bandwidth_limit_bytes: undefined,
|
||||
bandwidth_limit_window: 'day',
|
||||
active: true,
|
||||
ui_access: false
|
||||
})
|
||||
@@ -461,6 +465,34 @@ const AddUserPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Bandwidth Limit</h3>
|
||||
<FormHelp docHref="/docs/using-fields.html#bandwidth">Limit total bytes per user over a time window.</FormHelp>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bytes (limit)</label>
|
||||
<input type="number" className="input" min={0}
|
||||
value={formData.bandwidth_limit_bytes ?? ''}
|
||||
onChange={(e) => handleInputChange('bandwidth_limit_bytes', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="e.g., 1073741824 for 1 GB" disabled={loading} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Window</label>
|
||||
<select className="input" value={formData.bandwidth_limit_window || 'day'}
|
||||
onChange={(e) => handleInputChange('bandwidth_limit_window', e.target.value)} disabled={loading}>
|
||||
<option value="second">Second</option>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Custom Attributes</h3>
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user