feat: RBAC multi-user role-based access control #5
@@ -31,6 +31,7 @@ def create_refresh_token(data: dict):
|
|||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
|
from rbac import User, get_pages
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
@@ -41,13 +42,16 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
if username is None or username != config.ADMIN_USER:
|
role: str = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return username
|
pages = get_pages(username, role)
|
||||||
|
return User(username=username, role=role, pages=pages)
|
||||||
|
|
||||||
async def get_current_user_ws(token: str):
|
async def get_current_user_ws(token: str):
|
||||||
|
from rbac import User, get_pages
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
@@ -57,11 +61,13 @@ async def get_current_user_ws(token: str):
|
|||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
if username is None or username != config.ADMIN_USER:
|
role: str = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
return username
|
pages = get_pages(username, role)
|
||||||
|
return User(username=username, role=role, pages=pages)
|
||||||
|
|
||||||
# TOTP Persistence
|
# TOTP Persistence
|
||||||
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from slowapi.util import get_remote_address
|
|||||||
from slowapi.errors import RateLimitExceeded
|
from slowapi.errors import RateLimitExceeded
|
||||||
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security
|
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security
|
||||||
import auth as auth_module
|
import auth as auth_module
|
||||||
|
from rbac import require_page, require_write
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
@@ -139,16 +140,27 @@ def _router_reachable():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Dependency to store user in request.state for rbac dependencies
|
||||||
|
async def _inject_user(request: Request, user = Depends(auth_module.get_current_user)):
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
# Public auth endpoints
|
# Public auth endpoints
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
# Protected endpoints
|
# Protected endpoints with page-level RBAC
|
||||||
app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)])
|
app.include_router(docker_router.router, prefix="/api/docker",
|
||||||
app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)])
|
dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
|
||||||
app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)])
|
app.include_router(gitea.router, prefix="/api/gitea",
|
||||||
app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)])
|
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
|
||||||
app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(auth_module.get_current_user)])
|
app.include_router(files.router, prefix="/api/files",
|
||||||
app.include_router(security.router, prefix="/api/security", dependencies=[Depends(auth_module.get_current_user)])
|
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
|
||||||
|
app.include_router(system.router, prefix="/api/system",
|
||||||
|
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
||||||
|
app.include_router(chat_summary.router, prefix="/api/chat-summary",
|
||||||
|
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
|
||||||
|
app.include_router(security.router, prefix="/api/security",
|
||||||
|
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
||||||
|
|
||||||
# WebSocket endpoint (auth handled inside handler via Query param)
|
# WebSocket endpoint (auth handled inside handler via Query param)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
|
||||||
|
|
||||||
|
ROLE_GROUP_MAP = {
|
||||||
|
"dashboard_admin": "admin",
|
||||||
|
"dashboard_member": "member",
|
||||||
|
"dashboard_viewer": "viewer",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_RBAC = {
|
||||||
|
"role_defaults": {
|
||||||
|
"admin": {"pages": "*"},
|
||||||
|
"member": {"pages": ["dashboard", "docker", "files", "openclaw", "chat-digest"]},
|
||||||
|
"viewer": {"pages": ["dashboard"]},
|
||||||
|
},
|
||||||
|
"user_overrides": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
pages: list | str # "*" or list of page ids
|
||||||
|
|
||||||
|
|
||||||
|
def load_rbac() -> dict:
|
||||||
|
try:
|
||||||
|
if os.path.exists(RBAC_FILE):
|
||||||
|
with open(RBAC_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return DEFAULT_RBAC.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_rbac(data: dict):
|
||||||
|
os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True)
|
||||||
|
with open(RBAC_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_role(groups: list[str]) -> Optional[str]:
|
||||||
|
for group in groups:
|
||||||
|
if group in ROLE_GROUP_MAP:
|
||||||
|
return ROLE_GROUP_MAP[group]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pages(username: str, role: str) -> list | str:
|
||||||
|
rbac = load_rbac()
|
||||||
|
overrides = rbac.get("user_overrides", {})
|
||||||
|
if username in overrides:
|
||||||
|
return overrides[username].get("pages", rbac["role_defaults"].get(role, {}).get("pages", []))
|
||||||
|
return rbac.get("role_defaults", {}).get(role, {}).get("pages", [])
|
||||||
|
|
||||||
|
|
||||||
|
def has_page_access(user: User, page: str) -> bool:
|
||||||
|
if user.pages == "*":
|
||||||
|
return True
|
||||||
|
return page in user.pages
|
||||||
|
|
||||||
|
|
||||||
|
def require_page(page: str):
|
||||||
|
"""FastAPI dependency factory — 403 if user lacks page access."""
|
||||||
|
async def checker(request: Request):
|
||||||
|
user: User = request.state.user
|
||||||
|
if not has_page_access(user, page):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin():
|
||||||
|
"""FastAPI dependency — 403 if user is not admin."""
|
||||||
|
async def checker(request: Request):
|
||||||
|
user: User = request.state.user
|
||||||
|
if user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
return checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_write():
|
||||||
|
"""FastAPI dependency — 403 if viewer tries non-GET method."""
|
||||||
|
async def checker(request: Request):
|
||||||
|
user: User = request.state.user
|
||||||
|
if user.role == "viewer" and request.method != "GET":
|
||||||
|
raise HTTPException(status_code=403, detail="Read-only access")
|
||||||
|
return checker
|
||||||
@@ -32,6 +32,7 @@ class Token(BaseModel):
|
|||||||
token_type: str
|
token_type: str
|
||||||
user: str
|
user: str
|
||||||
has_2fa: bool
|
has_2fa: bool
|
||||||
|
role: str = "admin"
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
@@ -96,25 +97,27 @@ async def login(creds: LoginRequest, request: Request):
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Direct login is admin-only fallback
|
||||||
access_token = auth.create_access_token(
|
access_token = auth.create_access_token(
|
||||||
data={"sub": creds.username},
|
data={"sub": creds.username, "role": "admin"},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": creds.username})
|
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"user": creds.username,
|
"user": creds.username,
|
||||||
"has_2fa": bool(totp_secret)
|
"has_2fa": bool(totp_secret),
|
||||||
|
"role": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/proxy")
|
@router.get("/proxy")
|
||||||
async def proxy_auth(request: Request):
|
async def proxy_auth(request: Request):
|
||||||
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||||
Caddy sets Remote-User header after successful Authelia 2FA verification."""
|
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||||
# Prevent IP spoofing: ensure proxy traffic is from trusted proxy network
|
|
||||||
import config
|
import config
|
||||||
|
from rbac import resolve_role
|
||||||
if not config.is_trusted_proxy(request.client.host):
|
if not config.is_trusted_proxy(request.client.host):
|
||||||
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
|
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
|
||||||
raise HTTPException(status_code=403, detail="Unauthorized proxy source")
|
raise HTTPException(status_code=403, detail="Unauthorized proxy source")
|
||||||
@@ -122,27 +125,39 @@ async def proxy_auth(request: Request):
|
|||||||
remote_user = request.headers.get("Remote-User")
|
remote_user = request.headers.get("Remote-User")
|
||||||
if not remote_user:
|
if not remote_user:
|
||||||
raise HTTPException(status_code=401, detail="No proxy auth header")
|
raise HTTPException(status_code=401, detail="No proxy auth header")
|
||||||
# Map the LDAP user to the dashboard admin user
|
|
||||||
# Only allow known users (the admin)
|
# Parse groups from Remote-Groups header (comma-separated)
|
||||||
if remote_user != config.ADMIN_USER:
|
remote_groups_raw = request.headers.get("Remote-Groups", "")
|
||||||
|
groups = [g.strip() for g in remote_groups_raw.split(",") if g.strip()]
|
||||||
|
|
||||||
|
role = resolve_role(groups)
|
||||||
|
if role is None:
|
||||||
|
logger.warning(f"User {remote_user} has no dashboard role (groups: {groups})")
|
||||||
raise HTTPException(status_code=403, detail="User not authorized for dashboard")
|
raise HTTPException(status_code=403, detail="User not authorized for dashboard")
|
||||||
|
|
||||||
access_token = auth.create_access_token(
|
access_token = auth.create_access_token(
|
||||||
data={"sub": remote_user},
|
data={"sub": remote_user, "role": role},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": remote_user})
|
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"user": remote_user,
|
"user": remote_user,
|
||||||
"has_2fa": True,
|
"has_2fa": True,
|
||||||
|
"role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
async def read_users_me(current_user: str = Depends(auth.get_current_user)):
|
async def read_users_me(current_user = Depends(auth.get_current_user)):
|
||||||
totp_secret = auth.load_totp_secret()
|
totp_secret = auth.load_totp_secret()
|
||||||
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
return {
|
||||||
|
"username": current_user.username,
|
||||||
|
"role": current_user.role,
|
||||||
|
"pages": current_user.pages,
|
||||||
|
"has_2fa": bool(totp_secret),
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
@@ -152,7 +167,8 @@ async def refresh_token(req: RefreshRequest, request: Request):
|
|||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if username != config.ADMIN_USER:
|
role = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
raise HTTPException(status_code=401, detail="Invalid user")
|
raise HTTPException(status_code=401, detail="Invalid user")
|
||||||
if not auth.verify_token_version(payload.get("tv", -1)):
|
if not auth.verify_token_version(payload.get("tv", -1)):
|
||||||
raise HTTPException(status_code=401, detail="Token revoked")
|
raise HTTPException(status_code=401, detail="Token revoked")
|
||||||
@@ -160,20 +176,20 @@ async def refresh_token(req: RefreshRequest, request: Request):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
auth.increment_token_version()
|
auth.increment_token_version()
|
||||||
access_token = auth.create_access_token(
|
access_token = auth.create_access_token(
|
||||||
data={"sub": username},
|
data={"sub": username, "role": role},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
|
||||||
return {"access_token": access_token, "refresh_token": refresh_token}
|
return {"access_token": access_token, "refresh_token": refresh_token}
|
||||||
|
|
||||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
||||||
async def generate_2fa(current_user: str = Depends(auth.get_current_user)):
|
async def generate_2fa(current_user = Depends(auth.get_current_user)):
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user, issuer_name="NAS Dashboard")
|
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard")
|
||||||
return {"secret": secret, "uri": uri}
|
return {"secret": secret, "uri": uri}
|
||||||
|
|
||||||
@router.post("/setup-2fa/verify")
|
@router.post("/setup-2fa/verify")
|
||||||
async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depends(auth.get_current_user)):
|
async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)):
|
||||||
totp = pyotp.TOTP(request.secret)
|
totp = pyotp.TOTP(request.secret)
|
||||||
if not totp.verify(request.code):
|
if not totp.verify(request.code):
|
||||||
raise HTTPException(status_code=400, detail="Invalid code")
|
raise HTTPException(status_code=400, detail="Invalid code")
|
||||||
@@ -183,7 +199,7 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depend
|
|||||||
return {"message": "2FA enabled successfully"}
|
return {"message": "2FA enabled successfully"}
|
||||||
|
|
||||||
@router.post("/setup-2fa/disable")
|
@router.post("/setup-2fa/disable")
|
||||||
async def disable_2fa(current_user: str = Depends(auth.get_current_user)):
|
async def disable_2fa(current_user = Depends(auth.get_current_user)):
|
||||||
auth.save_totp_secret("")
|
auth.save_totp_secret("")
|
||||||
return {"message": "2FA disabled"}
|
return {"message": "2FA disabled"}
|
||||||
|
|
||||||
@@ -193,7 +209,7 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def change_password(req: ChangePasswordRequest, request: Request, current_user: str = Depends(auth.get_current_user)):
|
async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)):
|
||||||
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
if not auth.verify_password(req.current_password, auth.load_password_hash()):
|
||||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
if len(req.new_password) < 8:
|
if len(req.new_password) < 8:
|
||||||
@@ -226,22 +242,22 @@ def _get_challenge(client_data_b64: str) -> bytes:
|
|||||||
return chal_bytes
|
return chal_bytes
|
||||||
|
|
||||||
@router.post("/passkey/register/options")
|
@router.post("/passkey/register/options")
|
||||||
async def passkey_register_options(current_user: str = Depends(auth.get_current_user)):
|
async def passkey_register_options(current_user = Depends(auth.get_current_user)):
|
||||||
existing = auth.load_passkey_credentials()
|
existing = auth.load_passkey_credentials()
|
||||||
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||||
options = generate_registration_options(
|
options = generate_registration_options(
|
||||||
rp_id=config.WEBAUTHN_RP_ID,
|
rp_id=config.WEBAUTHN_RP_ID,
|
||||||
rp_name="NAS Dashboard",
|
rp_name="NAS Dashboard",
|
||||||
user_id=config.ADMIN_USER.encode(),
|
user_id=current_user.username.encode(),
|
||||||
user_name=config.ADMIN_USER,
|
user_name=current_user.username,
|
||||||
user_display_name=config.ADMIN_USER,
|
user_display_name=current_user.username,
|
||||||
exclude_credentials=exclude,
|
exclude_credentials=exclude,
|
||||||
)
|
)
|
||||||
_store_challenge(options.challenge)
|
_store_challenge(options.challenge)
|
||||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||||
|
|
||||||
@router.post("/passkey/register/verify")
|
@router.post("/passkey/register/verify")
|
||||||
async def passkey_register_verify(request: Request, current_user: str = Depends(auth.get_current_user)):
|
async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
||||||
try:
|
try:
|
||||||
@@ -304,19 +320,19 @@ async def passkey_login_verify(request: Request):
|
|||||||
auth._save_auth_data(data)
|
auth._save_auth_data(data)
|
||||||
username = config.ADMIN_USER
|
username = config.ADMIN_USER
|
||||||
access_token = auth.create_access_token(
|
access_token = auth.create_access_token(
|
||||||
data={"sub": username},
|
data={"sub": username, "role": "admin"},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
)
|
)
|
||||||
refresh_token = auth.create_refresh_token(data={"sub": username})
|
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
|
||||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username}
|
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user": username, "role": "admin"}
|
||||||
|
|
||||||
@router.get("/passkey/list")
|
@router.get("/passkey/list")
|
||||||
async def passkey_list(current_user: str = Depends(auth.get_current_user)):
|
async def passkey_list(current_user = Depends(auth.get_current_user)):
|
||||||
creds = auth.load_passkey_credentials()
|
creds = auth.load_passkey_credentials()
|
||||||
return {"passkeys": [{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")} for c in creds]}
|
return {"passkeys": [{"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")} for c in creds]}
|
||||||
|
|
||||||
@router.post("/passkey/delete")
|
@router.post("/passkey/delete")
|
||||||
async def passkey_delete(request: Request, current_user: str = Depends(auth.get_current_user)):
|
async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
cred_id = body.get("id")
|
cred_id = body.get("id")
|
||||||
data = auth._load_auth_data()
|
data = auth._load_auth_data()
|
||||||
@@ -326,6 +342,38 @@ async def passkey_delete(request: Request, current_user: str = Depends(auth.get_
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/passkey/clear")
|
@router.post("/passkey/clear")
|
||||||
async def passkey_clear(current_user: str = Depends(auth.get_current_user)):
|
async def passkey_clear(current_user = Depends(auth.get_current_user)):
|
||||||
auth.clear_passkey_credentials()
|
auth.clear_passkey_credentials()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# RBAC admin endpoints
|
||||||
|
@router.get("/rbac/config")
|
||||||
|
async def rbac_config(current_user = Depends(auth.get_current_user)):
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
from rbac import load_rbac
|
||||||
|
return load_rbac()
|
||||||
|
|
||||||
|
@router.put("/rbac/overrides/{username}")
|
||||||
|
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
from rbac import load_rbac, save_rbac
|
||||||
|
body = await request.json()
|
||||||
|
pages = body.get("pages")
|
||||||
|
if pages is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing pages field")
|
||||||
|
rbac = load_rbac()
|
||||||
|
rbac.setdefault("user_overrides", {})[username] = {"pages": pages}
|
||||||
|
save_rbac(rbac)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/rbac/overrides/{username}")
|
||||||
|
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
|
||||||
|
if current_user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
from rbac import load_rbac, save_rbac
|
||||||
|
rbac = load_rbac()
|
||||||
|
rbac.get("user_overrides", {}).pop(username, None)
|
||||||
|
save_rbac(rbac)
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -38,9 +38,20 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
|||||||
try:
|
try:
|
||||||
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
|
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
|
||||||
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
if payload.get("type") != "access" or payload.get("sub") != config.ADMIN_USER:
|
if payload.get("type") != "access":
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
username = payload.get("sub")
|
||||||
|
role = payload.get("role", "admin")
|
||||||
|
if username is None:
|
||||||
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
|
return
|
||||||
|
# RBAC: check terminal page access
|
||||||
|
from rbac import get_pages
|
||||||
|
pages = get_pages(username, role)
|
||||||
|
if pages != "*" and "terminal" not in pages:
|
||||||
|
await websocket.close(code=1008, reason="Access denied")
|
||||||
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ services:
|
|||||||
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
|
- /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro
|
||||||
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
||||||
- /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro
|
- /volume1/docker/chat-summarizer/data:/app/data/chat-summarizer:ro
|
||||||
|
- /volume1/docker/nas-dashboard/rbac.json:/volume1/docker/nas-dashboard/rbac.json
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -11,13 +11,31 @@
|
|||||||
import Security from "./routes/Security.svelte";
|
import Security from "./routes/Security.svelte";
|
||||||
import Login from "./routes/Login.svelte";
|
import Login from "./routes/Login.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { getToken, checkAuth, setToken, setRefreshToken } from "./lib/api.js";
|
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser } from "./lib/api.js";
|
||||||
|
|
||||||
let page = $state("dashboard");
|
let page = $state("dashboard");
|
||||||
let dark = $state(false);
|
let dark = $state(false);
|
||||||
let sidebarOpen = $state(false);
|
let sidebarOpen = $state(false);
|
||||||
let authorized = $state(false);
|
let authorized = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let userRole = $state("");
|
||||||
|
let allowedPages = $state([]);
|
||||||
|
|
||||||
|
async function fetchUserInfo() {
|
||||||
|
try {
|
||||||
|
const data = await checkAuth();
|
||||||
|
setCurrentUser(data);
|
||||||
|
userRole = data.role || "admin";
|
||||||
|
allowedPages = data.pages || "*";
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch user info:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPageAccess(pageId) {
|
||||||
|
if (allowedPages === "*") return true;
|
||||||
|
return Array.isArray(allowedPages) && allowedPages.includes(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Theme init
|
// Theme init
|
||||||
@@ -37,6 +55,7 @@
|
|||||||
setToken(data.access_token);
|
setToken(data.access_token);
|
||||||
setRefreshToken(data.refresh_token);
|
setRefreshToken(data.refresh_token);
|
||||||
authorized = true;
|
authorized = true;
|
||||||
|
await fetchUserInfo();
|
||||||
loading = false;
|
loading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await checkAuth();
|
await fetchUserInfo();
|
||||||
authorized = true;
|
authorized = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Auth check failed:", e);
|
console.error("Auth check failed:", e);
|
||||||
@@ -90,31 +109,33 @@
|
|||||||
<Login />
|
<Login />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
|
<div class="flex h-screen overflow-hidden bg-surface-50 {dark ? 'dark bg-surface-900' : ''}">
|
||||||
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen />
|
<Sidebar bind:page {dark} {toggleTheme} {logout} bind:sidebarOpen {allowedPages} {userRole} />
|
||||||
<main class="flex-1 overflow-auto">
|
<main class="flex-1 overflow-auto">
|
||||||
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
<div class="max-w-[1400px] mx-auto p-6 lg:p-8">
|
||||||
<!-- Mobile hamburger -->
|
<!-- Mobile hamburger -->
|
||||||
<button class="md:hidden mb-4 p-2 rounded-lg hover:bg-surface-200 {dark ? 'hover:bg-surface-700 text-surface-300' : 'text-surface-600'}" onclick={() => sidebarOpen = true} aria-label="Open menu">
|
<button class="md:hidden mb-4 p-2 rounded-lg hover:bg-surface-200 {dark ? 'hover:bg-surface-700 text-surface-300' : 'text-surface-600'}" onclick={() => sidebarOpen = true} aria-label="Open menu">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if page === "dashboard"}
|
{#if page === "dashboard" && hasPageAccess("dashboard")}
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
{:else if page === "docker"}
|
{:else if page === "docker" && hasPageAccess("docker")}
|
||||||
<Docker />
|
<Docker />
|
||||||
{:else if page === "gitea"}
|
{:else if page === "gitea" && hasPageAccess("gitea")}
|
||||||
<Gitea />
|
<Gitea />
|
||||||
{:else if page === "files"}
|
{:else if page === "files" && hasPageAccess("files")}
|
||||||
<Files />
|
<Files />
|
||||||
{:else if page === "terminal"}
|
{:else if page === "terminal" && hasPageAccess("terminal")}
|
||||||
<Terminal />
|
<Terminal />
|
||||||
{:else if page === "openclaw"}
|
{:else if page === "openclaw" && hasPageAccess("openclaw")}
|
||||||
<OpenClaw />
|
<OpenClaw />
|
||||||
{:else if page === "chat-digest"}
|
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
||||||
<ChatSummary />
|
<ChatSummary />
|
||||||
{:else if page === "settings"}
|
{:else if page === "settings" && userRole === "admin"}
|
||||||
<Settings />
|
<Settings />
|
||||||
{:else if page === "security"}
|
{:else if page === "security" && hasPageAccess("security")}
|
||||||
<Security />
|
<Security />
|
||||||
|
{:else}
|
||||||
|
<Dashboard />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false) } = $props();
|
let { page = $bindable("dashboard"), dark = false, toggleTheme = () => {}, logout = () => {}, sidebarOpen = $bindable(false), allowedPages = "*", userRole = "admin" } = $props();
|
||||||
|
|
||||||
|
function canAccess(id) {
|
||||||
|
if (!id) return true; // external links always shown
|
||||||
|
if (allowedPages === "*") return true;
|
||||||
|
return Array.isArray(allowedPages) && allowedPages.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||||
@@ -75,6 +81,7 @@
|
|||||||
<nav class="flex-1 px-3 mt-2 overflow-y-auto">
|
<nav class="flex-1 px-3 mt-2 overflow-y-auto">
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p>
|
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Main</p>
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
|
{#if canAccess(link.id)}
|
||||||
<button
|
<button
|
||||||
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150
|
class="w-full text-left px-3 py-2.5 rounded-lg text-[13px] font-medium flex items-center gap-2.5 mb-0.5 transition-all duration-150
|
||||||
{page === link.id
|
{page === link.id
|
||||||
@@ -98,6 +105,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{link.label}
|
{link.label}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
||||||
@@ -128,6 +136,7 @@
|
|||||||
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
<div class="my-4 border-t border-surface-200 {dark ? 'border-surface-700' : ''}"></div>
|
||||||
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p>
|
<p class="text-[10px] font-semibold uppercase tracking-wider text-surface-400 px-3 mb-2">Tools</p>
|
||||||
{#each tools as item}
|
{#each tools as item}
|
||||||
|
{#if canAccess(item.id)}
|
||||||
{#if item.external || item.port}
|
{#if item.external || item.port}
|
||||||
<a
|
<a
|
||||||
href={extHref(item)}
|
href={extHref(item)}
|
||||||
@@ -178,11 +187,13 @@
|
|||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Bottom actions -->
|
<!-- Bottom actions -->
|
||||||
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}">
|
<div class="px-4 pb-4 pt-2 border-t border-surface-200 {dark ? 'border-surface-700' : ''}">
|
||||||
|
{#if userRole === "admin"}
|
||||||
<button
|
<button
|
||||||
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''} {page === 'settings' ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') : ''}"
|
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''} {page === 'settings' ? 'bg-primary-50 text-primary-700 shadow-sm ' + (dark ? 'bg-primary-900/30 text-primary-300' : '') : ''}"
|
||||||
onclick={() => navigate('settings')}
|
onclick={() => navigate('settings')}
|
||||||
@@ -190,6 +201,7 @@
|
|||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={toggleTheme}
|
onclick={toggleTheme}
|
||||||
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
|
class="w-full px-3 py-2 rounded-lg text-[13px] font-medium flex items-center gap-2.5 text-surface-500 hover:bg-surface-100 transition-all duration-150 mb-1 {dark ? 'hover:bg-surface-700 text-surface-400' : ''}"
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
const BASE = "/api";
|
const BASE = "/api";
|
||||||
let token = localStorage.getItem("token") || "";
|
let token = localStorage.getItem("token") || "";
|
||||||
|
|
||||||
|
// Current user info populated after auth
|
||||||
|
export let currentUser = { username: "", role: "", pages: [] };
|
||||||
|
|
||||||
|
export function setCurrentUser(u) {
|
||||||
|
currentUser = u;
|
||||||
|
}
|
||||||
|
|
||||||
export function setToken(t) {
|
export function setToken(t) {
|
||||||
token = t;
|
token = t;
|
||||||
if (t) localStorage.setItem("token", t);
|
if (t) localStorage.setItem("token", t);
|
||||||
|
|||||||
@@ -73,12 +73,77 @@ dev.nas.jimmygan.com {
|
|||||||
}
|
}
|
||||||
|
|
||||||
git.jimmygan.com {
|
git.jimmygan.com {
|
||||||
tls {
|
tls {
|
||||||
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
}
|
}
|
||||||
forward_auth 127.0.0.1:9092 {
|
forward_auth 127.0.0.1:9092 {
|
||||||
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
}
|
}
|
||||||
reverse_proxy 127.0.0.1:3300
|
reverse_proxy 127.0.0.1:3300
|
||||||
|
}
|
||||||
|
|
||||||
|
photos.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
forward_auth 127.0.0.1:9092 {
|
||||||
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:2283 {
|
||||||
|
header_up X-Forwarded-Proto https
|
||||||
|
header_up X-Forwarded-Host {host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
media.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
forward_auth 127.0.0.1:9092 {
|
||||||
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8096
|
||||||
|
}
|
||||||
|
|
||||||
|
books.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
forward_auth 127.0.0.1:9092 {
|
||||||
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:13378
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
forward_auth 127.0.0.1:9092 {
|
||||||
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8030
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
forward_auth 127.0.0.1:9092 {
|
||||||
|
uri /api/verify?rd=https://auth.jimmygan.com:8443
|
||||||
|
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8222
|
||||||
|
}
|
||||||
|
|
||||||
|
speed.jimmygan.com {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
||||||
|
}
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user