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:
root 2026-03-25 03:32:55 -05:00
parent 189e8fb99b
commit 211e11b718

View File

@ -19,15 +19,33 @@ from functools import wraps
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET", secrets.token_hex(32))
# ─── AUTH ─────────────────────────────────────────────────────
# ─── AUTH + DEMO MODE ─────────────────────────────────────────
_rate_limit = {} # ip -> (count, window_start)
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX = 60 # requests per window
LOGIN_RATE_MAX = 5 # login attempts per window
RATE_LIMIT_WINDOW = 60
RATE_LIMIT_MAX = 60
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):
if is_allowlisted(ip):
return False
now = time.time()
if ip not in _rate_limit or now - _rate_limit[ip][1] > RATE_LIMIT_WINDOW:
_rate_limit[ip] = (1, now)
@ -39,10 +57,21 @@ def rate_limited(ip, max_req=RATE_LIMIT_MAX):
return False
def is_admin():
return session.get("role") == "admin"
def is_demo():
return _demo_mode["active"]
def login_required(f):
@wraps(f)
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/"):
return jsonify({"error": "unauthorized"}), 401
return redirect("/login")
@ -53,7 +82,15 @@ def login_required(f):
def admin_required(f):
@wraps(f)
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 request.path.startswith("/api/"):
return jsonify({"error": "unauthorized"}), 401
return redirect("/login")
if session.get("role") != "admin":
return "Forbidden", 403
@ -64,14 +101,19 @@ def admin_required(f):
@app.before_request
def security_checks():
ip = request.remote_addr
# Rate limit
# Rate limit (allowlisted IPs skip)
if rate_limited(ip):
return jsonify({"error": "rate limited"}), 429
# Allow login/static without auth
if request.path in ("/login", "/api/auth/login", "/api/auth/setup"):
# Always allow these
if request.path in ("/login", "/api/auth/login", "/api/auth/setup", "/api/demo/status"):
return
if request.path.startswith("/static"):
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
@ -238,6 +280,44 @@ def logout_page():
session.clear()
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"
DEFAULT_CONFIG = {
"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>
<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>
<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>
</div>
</header>
@ -1050,7 +1131,46 @@ async function deleteRun(id) {
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();
checkDemo();
</script>
<div id="repipe-overlay" class="repipe-overlay" onclick="if(event.target===this)closeRepipe()">
<div class="repipe-modal">
@ -1161,6 +1281,7 @@ ADMIN_HTML = r"""
<div class="tab" onclick="switchTab('models')">Models</div>
<div class="tab" onclick="switchTab('openrouter')">OpenRouter</div>
<div class="tab" onclick="switchTab('timeouts')">Timeouts</div>
<div class="tab" onclick="switchTab('security')">Security</div>
</div>
<!-- PROVIDERS TAB -->
@ -1243,6 +1364,22 @@ ADMIN_HTML = r"""
<div id="timeout-list"><div class="empty">Loading models...</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>
<script>
@ -1456,6 +1593,53 @@ function switchTab(name) {
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-'+name));
if (name === 'timeouts') renderTimeouts();
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) {