Add demo mode + IP allowlist + admin security tab
- Demo mode toggle: admin can enable public access without login - Demo users can view/run everything but cannot modify admin settings - Admin write routes (config saves, API keys) blocked for non-admins in demo - IP allowlist: LAN (192.168.1.*) and localhost never rate-limited - Admin panel gets Security tab: demo toggle, allowlist management - Main UI shows "Demo ON" button (green) + top banner when active - Demo mode state is in-memory, resets on restart (safe default) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
189e8fb99b
commit
211e11b718
200
llm_team_ui.py
200
llm_team_ui.py
@ -19,15 +19,33 @@ from functools import wraps
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
|
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
|
||||||
|
|
||||||
# ─── AUTH ─────────────────────────────────────────────────────
|
# ─── AUTH + DEMO MODE ─────────────────────────────────────────
|
||||||
|
|
||||||
_rate_limit = {} # ip -> (count, window_start)
|
_rate_limit = {} # ip -> (count, window_start)
|
||||||
RATE_LIMIT_WINDOW = 60 # seconds
|
RATE_LIMIT_WINDOW = 60
|
||||||
RATE_LIMIT_MAX = 60 # requests per window
|
RATE_LIMIT_MAX = 60
|
||||||
LOGIN_RATE_MAX = 5 # login attempts per window
|
LOGIN_RATE_MAX = 5
|
||||||
|
|
||||||
|
# IPs that never get rate-limited (your LAN, localhost)
|
||||||
|
ALLOWLIST_IPS = {"127.0.0.1", "::1", "192.168.1.1"}
|
||||||
|
# Demo mode state — toggled by admin at runtime
|
||||||
|
_demo_mode = {"active": False, "started_by": None}
|
||||||
|
|
||||||
|
# Admin-only write routes — blocked in demo for non-admin users
|
||||||
|
ADMIN_WRITE_ROUTES = {
|
||||||
|
"/api/admin/config": ["POST"],
|
||||||
|
"/api/admin/test-provider": ["POST"],
|
||||||
|
"/api/auth/login": ["POST"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowlisted(ip):
|
||||||
|
return ip in ALLOWLIST_IPS or ip.startswith("192.168.1.")
|
||||||
|
|
||||||
|
|
||||||
def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
||||||
|
if is_allowlisted(ip):
|
||||||
|
return False
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW:
|
if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW:
|
||||||
_rate_limit[ip] = (1, now)
|
_rate_limit[ip] = (1, now)
|
||||||
@ -39,10 +57,21 @@ def rate_limited(ip, max_req=RATE_LIMIT_MAX):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin():
|
||||||
|
return session.get("role") == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def is_demo():
|
||||||
|
return _demo_mode["active"]
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
if not session.get("user_id"):
|
# Demo mode: everyone gets in
|
||||||
|
if is_demo() and not session.get("user_id"):
|
||||||
|
session["demo_user"] = True
|
||||||
|
if not session.get("user_id") and not is_demo():
|
||||||
if request.path.startswith("/api/"):
|
if request.path.startswith("/api/"):
|
||||||
return jsonify({"error": "unauthorized"}), 401
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
@ -53,7 +82,15 @@ def login_required(f):
|
|||||||
def admin_required(f):
|
def admin_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
# Demo mode: allow read access (GET), block writes unless admin
|
||||||
|
if is_demo():
|
||||||
|
if request.method == "GET":
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
if not is_admin():
|
||||||
|
return jsonify({"error": "demo mode: read-only", "demo": True}), 403
|
||||||
if not session.get("user_id"):
|
if not session.get("user_id"):
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
if session.get("role") != "admin":
|
if session.get("role") != "admin":
|
||||||
return "Forbidden", 403
|
return "Forbidden", 403
|
||||||
@ -64,14 +101,19 @@ def admin_required(f):
|
|||||||
@app.before_request
|
@app.before_request
|
||||||
def security_checks():
|
def security_checks():
|
||||||
ip = request.remote_addr
|
ip = request.remote_addr
|
||||||
# Rate limit
|
# Rate limit (allowlisted IPs skip)
|
||||||
if rate_limited(ip):
|
if rate_limited(ip):
|
||||||
return jsonify({"error": "rate limited"}), 429
|
return jsonify({"error": "rate limited"}), 429
|
||||||
# Allow login/static without auth
|
# Always allow these
|
||||||
if request.path in ("/login", "/api/auth/login", "/api/auth/setup"):
|
if request.path in ("/login", "/api/auth/login", "/api/auth/setup", "/api/demo/status"):
|
||||||
return
|
return
|
||||||
if request.path.startswith("/static"):
|
if request.path.startswith("/static"):
|
||||||
return
|
return
|
||||||
|
# In demo mode, block admin write routes for non-admins
|
||||||
|
if is_demo() and not is_admin():
|
||||||
|
for route, methods in ADMIN_WRITE_ROUTES.items():
|
||||||
|
if request.path == route and request.method in methods:
|
||||||
|
return jsonify({"error": "demo mode: admin settings are read-only", "demo": True}), 403
|
||||||
|
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
@ -238,6 +280,44 @@ def logout_page():
|
|||||||
session.clear()
|
session.clear()
|
||||||
return redirect("/login")
|
return redirect("/login")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/demo/status")
|
||||||
|
def demo_status():
|
||||||
|
return jsonify({"active": is_demo(), "started_by": _demo_mode.get("started_by")})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/demo/toggle", methods=["POST"])
|
||||||
|
def demo_toggle():
|
||||||
|
if not is_admin():
|
||||||
|
return jsonify({"error": "admin only"}), 403
|
||||||
|
_demo_mode["active"] = not _demo_mode["active"]
|
||||||
|
_demo_mode["started_by"] = session.get("username") if _demo_mode["active"] else None
|
||||||
|
return jsonify({"active": _demo_mode["active"]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/demo/allowlist", methods=["GET"])
|
||||||
|
def demo_get_allowlist():
|
||||||
|
if not is_admin():
|
||||||
|
return jsonify({"error": "admin only"}), 403
|
||||||
|
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/demo/allowlist", methods=["POST"])
|
||||||
|
def demo_set_allowlist():
|
||||||
|
if not is_admin():
|
||||||
|
return jsonify({"error": "admin only"}), 403
|
||||||
|
data = request.json or {}
|
||||||
|
ip = data.get("ip", "").strip()
|
||||||
|
action = data.get("action", "add")
|
||||||
|
if not ip:
|
||||||
|
return jsonify({"error": "ip required"}), 400
|
||||||
|
if action == "add":
|
||||||
|
ALLOWLIST_IPS.add(ip)
|
||||||
|
elif action == "remove" and ip in ALLOWLIST_IPS:
|
||||||
|
ALLOWLIST_IPS.discard(ip)
|
||||||
|
return jsonify({"ips": sorted(ALLOWLIST_IPS)})
|
||||||
|
|
||||||
|
|
||||||
CONFIG_PATH = "/root/llm_team_config.json"
|
CONFIG_PATH = "/root/llm_team_config.json"
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
"providers": {
|
"providers": {
|
||||||
@ -461,6 +541,7 @@ HTML = r"""
|
|||||||
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;cursor:pointer;">History</button>
|
<button onclick="toggleHistory()" style="color:var(--text2);background:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;cursor:pointer;">History</button>
|
||||||
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(34,197,94,0.3);border-radius:6px;">Lab</a>
|
<a href="/lab" style="color:var(--green);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(34,197,94,0.3);border-radius:6px;">Lab</a>
|
||||||
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;">Admin</a>
|
<a href="/admin" style="color:var(--text2);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid var(--border);border-radius:6px;">Admin</a>
|
||||||
|
<button id="demo-toggle" onclick="toggleDemo()" style="display:none;color:var(--orange);background:none;font-size:11px;padding:4px 8px;border:1px solid rgba(245,158,11,0.3);border-radius:6px;cursor:pointer">Demo</button>
|
||||||
<a href="/logout" style="color:#ef4444;text-decoration:none;font-size:11px;padding:4px 8px;border:1px solid rgba(239,68,68,0.2);border-radius:6px;opacity:0.7">Logout</a>
|
<a href="/logout" style="color:#ef4444;text-decoration:none;font-size:11px;padding:4px 8px;border:1px solid rgba(239,68,68,0.2);border-radius:6px;opacity:0.7">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -1050,7 +1131,46 @@ async function deleteRun(id) {
|
|||||||
renderHistoryList();
|
renderHistoryList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DEMO MODE ───────────────────────────────
|
||||||
|
async function checkDemo() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/demo/status');
|
||||||
|
const d = await r.json();
|
||||||
|
updateDemoUI(d.active);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDemoUI(active) {
|
||||||
|
const btn = document.getElementById('demo-toggle');
|
||||||
|
const banner = document.getElementById('demo-banner');
|
||||||
|
if (btn) {
|
||||||
|
btn.style.display = '';
|
||||||
|
btn.textContent = active ? 'Demo ON' : 'Demo';
|
||||||
|
btn.style.color = active ? '#22c55e' : 'var(--orange)';
|
||||||
|
btn.style.borderColor = active ? 'rgba(34,197,94,0.4)' : 'rgba(245,158,11,0.3)';
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
if (!banner) {
|
||||||
|
const b = document.createElement('div');
|
||||||
|
b.id = 'demo-banner';
|
||||||
|
b.style.cssText = 'position:fixed;top:0;left:0;right:0;background:rgba(34,197,94,0.1);border-bottom:1px solid rgba(34,197,94,0.3);color:#22c55e;text-align:center;font-size:11px;padding:4px;z-index:50;font-weight:500';
|
||||||
|
b.textContent = 'DEMO MODE — public access enabled';
|
||||||
|
document.body.prepend(b);
|
||||||
|
}
|
||||||
|
} else if (banner) {
|
||||||
|
banner.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDemo() {
|
||||||
|
const r = await fetch('/api/demo/toggle', {method:'POST'});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) return;
|
||||||
|
updateDemoUI(d.active);
|
||||||
|
}
|
||||||
|
|
||||||
loadModels();
|
loadModels();
|
||||||
|
checkDemo();
|
||||||
</script>
|
</script>
|
||||||
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
|
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
|
||||||
<div class="repipe-modal">
|
<div class="repipe-modal">
|
||||||
@ -1161,6 +1281,7 @@ ADMIN_HTML = r"""
|
|||||||
<div class="tab" onclick="switchTab('models')">Models</div>
|
<div class="tab" onclick="switchTab('models')">Models</div>
|
||||||
<div class="tab" onclick="switchTab('openrouter')">OpenRouter</div>
|
<div class="tab" onclick="switchTab('openrouter')">OpenRouter</div>
|
||||||
<div class="tab" onclick="switchTab('timeouts')">Timeouts</div>
|
<div class="tab" onclick="switchTab('timeouts')">Timeouts</div>
|
||||||
|
<div class="tab" onclick="switchTab('security')">Security</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PROVIDERS TAB -->
|
<!-- PROVIDERS TAB -->
|
||||||
@ -1243,6 +1364,22 @@ ADMIN_HTML = r"""
|
|||||||
<div id="timeout-list"><div class="empty">Loading models...</div></div>
|
<div id="timeout-list"><div class="empty">Loading models...</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- DEMO & SECURITY TAB -->
|
||||||
|
<div id="tab-security" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<h3>Demo Mode
|
||||||
|
<button class="btn" id="admin-demo-btn" style="margin-left:auto" onclick="adminToggleDemo()">Enable Demo</button>
|
||||||
|
</h3>
|
||||||
|
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">When active, the public can view and use the Team UI, Lab, and all modes without logging in. Admin settings (API keys, config saves) are read-only for non-admins.</p>
|
||||||
|
<div id="demo-status-admin" style="font-size:13px">Status: <strong style="color:var(--text2)">Off</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>IP Allowlist <button class="btn" style="margin-left:auto" onclick="addAllowlistIP()">+ Add IP</button></h3>
|
||||||
|
<p style="font-size:12px;color:var(--text2);margin-bottom:10px">These IPs are never rate-limited. Your local network (192.168.1.*) is always allowed.</p>
|
||||||
|
<div id="allowlist"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -1456,6 +1593,53 @@ function switchTab(name) {
|
|||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-'+name));
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-'+name));
|
||||||
if (name === 'timeouts') renderTimeouts();
|
if (name === 'timeouts') renderTimeouts();
|
||||||
if (name === 'models') { loadOllamaModels(); renderCloudModels(); }
|
if (name === 'models') { loadOllamaModels(); renderCloudModels(); }
|
||||||
|
if (name === 'security') { loadDemoStatus(); loadAllowlist(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDemoStatus() {
|
||||||
|
const r = await fetch('/api/demo/status');
|
||||||
|
const d = await r.json();
|
||||||
|
const btn = document.getElementById('admin-demo-btn');
|
||||||
|
const st = document.getElementById('demo-status-admin');
|
||||||
|
if (d.active) {
|
||||||
|
btn.textContent = 'Disable Demo';
|
||||||
|
btn.className = 'btn btn-r';
|
||||||
|
st.innerHTML = 'Status: <strong style="color:var(--green)">ON</strong>' + (d.started_by ? ' (by ' + d.started_by + ')' : '');
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Enable Demo';
|
||||||
|
btn.className = 'btn btn-g';
|
||||||
|
st.innerHTML = 'Status: <strong style="color:var(--text2)">Off</strong>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminToggleDemo() {
|
||||||
|
await fetch('/api/demo/toggle', {method:'POST'});
|
||||||
|
loadDemoStatus();
|
||||||
|
toast('Demo mode toggled');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllowlist() {
|
||||||
|
const r = await fetch('/api/demo/allowlist');
|
||||||
|
const d = await r.json();
|
||||||
|
const el = document.getElementById('allowlist');
|
||||||
|
if (!d.ips || !d.ips.length) { el.innerHTML = '<div class="empty">No IPs in allowlist.</div>'; return; }
|
||||||
|
el.innerHTML = d.ips.map(ip =>
|
||||||
|
`<div class="model-row"><span class="name">${ip}</span>${ip.startsWith('192.168.1.') ? '<span class="meta">LAN</span>' : ''}<button class="btn btn-sm btn-r" onclick="removeAllowIP('${ip}')">Remove</button></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAllowlistIP() {
|
||||||
|
const ip = prompt('Enter IP address to allowlist:');
|
||||||
|
if (!ip) return;
|
||||||
|
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'add'})});
|
||||||
|
loadAllowlist();
|
||||||
|
toast('Added ' + ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAllowIP(ip) {
|
||||||
|
await fetch('/api/demo/allowlist', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ip, action:'remove'})});
|
||||||
|
loadAllowlist();
|
||||||
|
toast('Removed ' + ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toast(msg, ok=true) {
|
function toast(msg, ok=true) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user