Gateway and API IP lists

This commit is contained in:
seniorswe
2025-10-02 22:30:44 -04:00
parent 8803a33bc0
commit d78d087153
23 changed files with 991 additions and 38 deletions
+76 -1
View File
@@ -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
+28
View File
@@ -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)}')
+36 -2
View File
@@ -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},
+25
View File
@@ -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,
+101
View File
@@ -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')
+65
View File
@@ -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
+69 -11
View File
@@ -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
+137 -1
View File
@@ -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">
+95
View File
@@ -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>
+6
View File
@@ -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
+105 -14
View File
@@ -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">&nbsp;</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">&nbsp;</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">&nbsp;</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>
+2 -3
View File
@@ -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>
)
}
+88 -3
View File
@@ -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>
+32
View File
@@ -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">