From 3889f5e3e7de6e937ffa35810d07021eda11612f Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 14:19:35 +0300 Subject: [PATCH 1/5] fix(tests): isolate tests and guard against non-SQLite engines The suite left tables in place between tests. `app` is a module-level singleton, so the in-memory SQLite engine is reused for the whole session and rows created by one test's fixtures collided with the next test's inserts, failing with "UNIQUE constraint failed: #control.setup". Restore the missing db.drop_all() teardown, and make ldap_test_app depend on test_app so the tables exist before it runs. Also assert on db.engine.url, not just the configured URI. The config value is what we asked for; the engine is what create_all/drop_all actually operate on. #control and #task are mapped models, so a drop_all() against the production server would drop the live experiment tables. The existing check would pass if an engine had already been built from another URI. 13 passed, previously 7 passed with 6 errors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MvZQkduzotzQy2F5LaaBnE --- tests/conftest.py | 17 +++++++++++++++++ tests/test_api.py | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e5c9a2e..e136396 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,9 +37,26 @@ def test_app(): f"Tests must use in-memory SQLite, got: {db_uri}" ) + # Assert on the ENGINE, not just the config above. The config value is + # what we ASKED for; db.engine.url is what create_all/drop_all actually + # operate on. If an engine had already been built from another URI, the + # config check would pass while the engine still pointed at a real + # database. #control and #task are mapped models, so drop_all() on the + # production server would drop the live experiment tables. + engine_url = str(db.engine.url) + assert engine_url == "sqlite:///:memory:", ( + f"REFUSING TO RUN: tests are bound to {engine_url}, not in-memory " + "SQLite. db.drop_all() below would DROP the #control and #task tables." + ) + db.create_all() yield app db.session.remove() + # Drop tables between tests. `app` is a module-level singleton, so the + # in-memory SQLite engine is reused across the whole session; without + # this, rows created by one test's fixtures collide with the next + # test's inserts (UNIQUE constraint failed: #control.setup). + db.drop_all() @pytest.fixture diff --git a/tests/test_api.py b/tests/test_api.py index b0d091a..225a4ab 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,8 +6,12 @@ @pytest.fixture -def ldap_test_app(): - """Test application fixture specifically for LDAP testing.""" +def ldap_test_app(test_app): + """Test application fixture specifically for LDAP testing. + + Depends on test_app so the in-memory tables exist: a successful LDAP login + redirects to the index view, which queries ControlTable. + """ # Override config for LDAP tests app.config["USE_LDAP_AUTH"] = True app.config["USE_LOCAL_AUTH"] = False From f5868ee1f2de59a85957ab0e8787ffd21bee6840 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 14:19:46 +0300 Subject: [PATCH 2/5] feat(security): rate-limit login and harden session cookies The login form was an unthrottled gateway to the LDAP directory. Since directories lock accounts after a few failures, anyone could lock out every account in the lab. Add two independent limits on POST /login: per IP generous, because lab members may share one NAT'd address per username the important one. Accounts lock individually, so an attacker rotating addresses could still lock one person out. Keying on the submitted username holds regardless of source. Only failed attempts count, so normal users never consume their own quota. No default limits are applied: the control table and activity monitor poll their endpoints every few seconds and would otherwise get 429s. Counters live in per-worker memory, which needs no extra service but means the effective limit is roughly 4x the configured value with 4 workers. Set the limits so value x workers stays below the directory's lockout threshold. RATELIMIT_STORAGE_URI can point at a shared store to make them exact, falling back to memory if it is unreachable. Also harden the session cookie: HttpOnly, SameSite=Lax, configurable lifetime, and an opt-in Secure flag. ProxyFix is applied only when TRUST_PROXY_HEADERS is set, since trusting X-Forwarded-For while directly reachable would let clients forge their address and skip the limit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MvZQkduzotzQy2F5LaaBnE --- app.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 8 +++++ utils/config.py | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/app.py b/app.py index 9cf6ae8..afaf2b1 100644 --- a/app.py +++ b/app.py @@ -14,8 +14,11 @@ url_for, ) from flask_ldap3_login import AuthenticationResponseStatus, LDAP3LoginManager +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import DeclarativeBase +from werkzeug.middleware.proxy_fix import ProxyFix # Import configuration from utils.config import get_config @@ -40,6 +43,71 @@ class Base(DeclarativeBase): # Initialize extensions db.init_app(app) +# Trust X-Forwarded-* only when explicitly told we sit behind a reverse proxy. +# Without this, every request behind a proxy appears to come from the proxy's +# own IP, so the login rate limit below would apply to all users collectively +# instead of per client. Enabling it while directly exposed would instead let +# clients spoof the header and bypass the limit, which is why it is opt-in. +if app.config.get("TRUST_PROXY_HEADERS"): + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + logger.info("Trusting X-Forwarded-* headers from a single upstream proxy") + +# Rate-limit counter storage. The default is per-worker memory, which needs no +# extra service but means each gunicorn worker counts independently: with 4 +# workers the effective limit is roughly 4x the configured one. Tune the limits +# in .env against your directory's lockout threshold to compensate. +# +# To make the limits exact, point RATELIMIT_STORAGE_URI at a shared store +# (redis://...) and install the extra: pip install "flask-limiter[redis]". +# If that store is unreachable - or the extra is missing - we degrade to memory +# rather than refusing to start. A weaker limit beats no application at all. +_storage_uri = app.config.get("RATELIMIT_STORAGE_URI", "memory://") +if _storage_uri.startswith("redis"): + try: + import redis as _redis + + _redis.from_url(_storage_uri, socket_connect_timeout=2).ping() + logger.info("Rate limiting backed by %s (shared across workers)", _storage_uri) + except Exception as exc: # noqa: BLE001 - any failure means "fall back" + logger.warning( + "Rate-limit store %s unreachable (%s); falling back to per-worker " + "memory storage. Limits will be looser than configured.", + _storage_uri, + exc, + ) + _storage_uri = "memory://" + + +def _login_username_key(): + """Rate-limit key based on the account being attempted, not the caller. + + LDAP locks accounts individually, so an attacker rotating source addresses + could still lock one person out. Keying on the submitted username stops + that regardless of where the requests originate. + """ + return (request.form.get("username") or "").strip().lower() or "anonymous" + + +def _login_failed(response): + """Only count FAILED logins against the limit. + + A failed login re-renders the form (200); a successful one redirects (302). + Counting only failures means normal users never consume their own quota. + """ + return response.status_code == 200 + + +# Deliberately NO default limits: the control table and activity monitor poll +# their API endpoints every few seconds, and a global limit would return 429 to +# the normal UI. Only /login is limited, because only /login reaches LDAP. +limiter = Limiter( + get_remote_address, + app=app, + default_limits=[], + storage_uri=_storage_uri, + strategy="fixed-window", +) + # Initialize LDAP if enabled ldap_manager = None @@ -59,6 +127,19 @@ def decorated_function(*args, **kwargs): @app.route("/login", methods=["GET", "POST"]) +@limiter.limit( + lambda: app.config["LOGIN_RATE_LIMIT_IP"], + methods=["POST"], + deduct_when=_login_failed, + error_message="Too many login attempts from this address. Please wait and try again.", +) +@limiter.limit( + lambda: app.config["LOGIN_RATE_LIMIT_USER"], + key_func=_login_username_key, + methods=["POST"], + deduct_when=_login_failed, + error_message="Too many failed attempts for this account. Please wait and try again.", +) def login(): error = None @@ -88,6 +169,10 @@ def login(): user = User.query.filter_by(username=username).first() if user and user.check_password(password): logger.info(f"Local auth: User {username} logged in successfully") + # permanent=True is what makes PERMANENT_SESSION_LIFETIME + # apply; without it the cookie simply lasts until the + # browser closes and never expires on its own. + session.permanent = True session["username"] = username session["is_admin"] = user.is_admin @@ -105,6 +190,7 @@ def login(): if response.status == AuthenticationResponseStatus.success: logger.info(f"LDAP auth: User {username} logged in successfully") + session.permanent = True session["username"] = username # Redirect to the next parameter or index diff --git a/pyproject.toml b/pyproject.toml index 8f5c64a..b592b34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,14 @@ dependencies = [ "flask-sqlalchemy>=3.1.1", "flask-ldap3-login>=0.9.16", "werkzeug>=3.0.0", + + # Brute-force protection on /login. Without it, the login form is an + # unthrottled gateway to the LDAP directory, which has an account lockout + # policy - so anyone could lock out every lab account. + # + # No [redis] extra: counters live in worker memory. Install + # flask-limiter[redis] only if you add a shared store - see app.py. + "flask-limiter>=3.5.0", # Database "sqlalchemy>=2.0.38", diff --git a/utils/config.py b/utils/config.py index e94b7b9..bf80f3b 100644 --- a/utils/config.py +++ b/utils/config.py @@ -1,5 +1,7 @@ import os +from datetime import timedelta from pathlib import Path + from dotenv import load_dotenv # Load environment variables from .env file if it exists @@ -40,6 +42,66 @@ class Config: # Flask application settings PORT = int(os.environ.get("PORT", "8000")) + # --- Session cookie hardening --------------------------------------- + # HTTPONLY: JavaScript cannot read the session cookie, limiting the damage + # of any cross-site scripting bug. + # SAMESITE "Lax": the cookie is not sent on cross-site POSTs, which blunts + # cross-site request forgery even though CSRF tokens are not yet in place. + # SECURE: when true the browser only sends the cookie over HTTPS. It MUST + # stay false while the app is served over plain HTTP, or nobody can log + # in at all. Set SESSION_COOKIE_SECURE=true as soon as TLS is terminated + # in front of the app. + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Lax" + SESSION_COOKIE_SECURE = ( + os.environ.get("SESSION_COOKIE_SECURE", "false").lower() == "true" + ) + PERMANENT_SESSION_LIFETIME = timedelta( + hours=int(os.environ.get("SESSION_LIFETIME_HOURS", "12")) + ) + + # --- Brute-force protection ----------------------------------------- + # Applied to POST /login only, and only FAILED attempts are counted, so a + # normal user logging in and out never consumes quota. + # + # Two independent limits, because they stop different attacks: + # + # PER IP - stops one host hammering the form. Kept generous because + # several lab members may share one NAT'd public address; a + # tight per-IP limit would throttle the whole lab at once. + # + # PER USER - the important one. LDAP locks accounts individually, so an + # attacker rotating IPs could still lock out one person. This + # is keyed on the submitted username instead, so it holds no + # matter where the attempts come from. + # + # NOTE: both are counted PER WORKER. With no shared store and 4 gunicorn + # workers, the effective allowance is about 4x the value set here. Choose + # numbers so that (value x workers) stays BELOW the directory's lockout + # threshold, or the app will not stop an account being locked. + LOGIN_RATE_LIMIT_IP = os.environ.get( + "LOGIN_RATE_LIMIT_IP", "10 per minute; 60 per hour" + ) + LOGIN_RATE_LIMIT_USER = os.environ.get( + "LOGIN_RATE_LIMIT_USER", "4 per 15 minutes; 10 per hour" + ) + + # Where the rate-limit counters live. The default "memory://" needs no + # extra service but keeps counts per worker process - see the note above. + # Pointing this at a shared store (redis://...) makes both limits exact; + # that also needs `pip install "flask-limiter[redis]"`. Falls back to + # memory automatically if the store is unreachable or the extra is absent. + RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI", "memory://") + + # Only enable behind a reverse proxy you control (nginx, Caddy, Cloudflare + # Tunnel, or an appliance). It makes Flask trust X-Forwarded-For so limiting + # sees the real client IP. If enabled while the app is directly reachable, + # clients can spoof that header and bypass the rate limit entirely - hence + # the default of false. See DEPLOY.md section 6. + TRUST_PROXY_HEADERS = ( + os.environ.get("TRUST_PROXY_HEADERS", "false").lower() == "true" + ) + # Database URI SQLALCHEMY_DATABASE_URI = ( f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" From f8decb6d9f9185380be175e004b452bee088b36f Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 14:19:58 +0300 Subject: [PATCH 3/5] feat(docker): add container build and compose deployment Replaces a hand-built venv and gunicorn invocation with `docker compose up -d`, so the app can be moved to another machine without knowing Python, and comes back on its own after a crash or a reboot. Dockerfile: single-stage python:3.12-slim. No compiler is needed since every dependency is pure Python or ships manylinux wheels. Dependencies are installed before the code is copied, so a code-only rebuild reuses the layer cache and takes about two seconds. Runs as a non-root user. The health check uses urllib rather than curl, which slim does not ship, and targets /login because / only redirects. Entry point is `main:app`, which works because main.py imports app at module scope. Importing it skips main()'s database pre-flight check, which is wanted here: a transient database blip should serve errors rather than crash-loop the container. .dockerignore keeps the live .env out of the image and excludes the recursive build/lib tree, cutting the build context from 314 MB to under 2 KB. Tests are deliberately kept in the image so the deployed artefact can check itself. Compose serves plain HTTP on a configurable HOST_PORT, published on the local network. restart: unless-stopped covers crashes and reboots. FLASK_CONFIG is pinned to production so the Werkzeug debugger cannot be switched on by a stale .env, and FLASK_ENV is deliberately never set because it is an authentication bypass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MvZQkduzotzQy2F5LaaBnE --- .dockerignore | 34 ++++++++++ .env.example | 160 +++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 105 +++++++++++++++++++++++++++++ docker-compose.yml | 122 ++++++++++++++++++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4591cf4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# --- Secrets: MUST stay out of the image --------------------------------- +# The real .env holds live DB/LDAP credentials. It is injected at RUNTIME +# by docker-compose (env_file:), never baked into the image. +.env +*.env + +# --- Build context bloat -------------------------------------------------- +# build/ contains a pathological build/lib/build/lib/... nest ~6 levels deep. +# Uploading it to the Docker daemon slows every single build. +build/ +dist/ +*.egg-info/ +.venv/ +venv/ +ENV/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# --- Not needed at runtime ------------------------------------------------ +# NOTE: tests/ is deliberately NOT excluded. It is the built-in self-check: +# docker compose run --rm web python -m pytest -q +# It uses in-memory SQLite, so it proves the image works with no database. +.git/ +.github/ +docs/ +site/ +mkdocs.yml +*.log +.DS_Store +.vscode/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecbf8f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,160 @@ +# ============================================================================= +# ethopy_control - environment template +# +# Copy this file to .env and fill in the real values: +# cp .env.example .env +# +# .env is gitignored and MUST NOT be committed. The real values are handed over +# separately (password manager / lab admin) - see DEPLOY.md. +# ============================================================================= + +# --- Flask ------------------------------------------------------------------- +# Signs session cookies. Generate a fresh one per deployment with: +# python3 -c "import secrets; print(secrets.token_urlsafe(48))" +# If this changes, everyone is simply logged out. Use 32+ characters. +SECRET_KEY=change-me-generate-a-long-random-string + +# production | development | testing. +# docker-compose.yml forces "production"; this value only matters if you run +# the app outside Docker. +FLASK_CONFIG=production + +# --- Ports --------------------------------------------------------------------- +# There are TWO ports, and only one of them can ever clash with other software. +# +# HOST_PORT is the one you connect to, and the ONE TO CHANGE if 8000 is already +# taken on this machine (another app, an old gunicorn, a second copy of this +# one). Nothing else needs touching. +HOST_PORT=8000 +# +# PORT is the port INSIDE the container. Leave it alone. Containers each get +# their own private network, so 8000 in there can never collide with anything, +# and it is hardcoded in the Dockerfile's gunicorn command and health check. +# docker-compose.yml pins it to 8000 and ignores whatever is set here. +# It is only read when running the app OUTSIDE Docker, via `python main.py`. +PORT=8000 + +# --- How it is served --------------------------------------------------------- +# Out of the box the app serves PLAIN HTTP, reachable from other machines on the +# local network at http://: +# +# There is no HTTPS and no certificate to manage. That keeps the setup small, +# but it means passwords cross the network in the clear, so this is only +# appropriate on a trusted local network. +# +# To publish it beyond the lab, put a reverse proxy in front of it or reach it +# over a VPN, and then set the two variables below. See DEPLOY.md section 6. + +# --- Security ----------------------------------------------------------------- +# Login throttling. Only FAILED attempts count, so normal users never consume +# their own quota. +# +# IMPORTANT: these numbers are PER GUNICORN WORKER. +# +# There is no shared counter store, so each of the 4 workers counts on its own +# and the effective allowance is roughly: +# +# configured value x number of workers = what actually gets through +# 4 per 15 minutes x 4 = about 16 per 15 minutes +# +# Set them against your directory's own lockout threshold. If it locks an +# account after very few failures, either lower these numbers or reduce +# --workers in the Dockerfile (fewer workers = smaller multiplier). +# +# PER IP: generous, because several lab members may share one NAT'd address. +LOGIN_RATE_LIMIT_IP=10 per minute; 60 per hour +# +# PER USERNAME: the important one. LDAP locks accounts individually, so an +# attacker rotating IP addresses could still lock one person out. This limit is +# keyed on the account being attempted, so it holds regardless of source. +LOGIN_RATE_LIMIT_USER=4 per 15 minutes; 10 per hour +# +# Optional: point this at a shared store to make the limits exact instead of +# approximate. Needs a Redis service and `pip install "flask-limiter[redis]"`. +# Left unset, counters stay in worker memory as described above. +# RATELIMIT_STORAGE_URI=redis://redis:6379/0 + +# Set to true ONLY once the app is served over HTTPS. When true the browser +# refuses to send the session cookie over plain HTTP, so turning it on too +# early makes login silently impossible. +# +# Leave false for the default local-network setup. Turn it on together with +# TRUST_PROXY_HEADERS when a reverse proxy terminates HTTPS in front. +SESSION_COOKIE_SECURE=false + +# How long a login lasts before the user must sign in again. +SESSION_LIFETIME_HOURS=12 + +# Set to true ONLY when a reverse proxy (nginx, Caddy, a Cloudflare Tunnel, or +# an appliance that already does this) sits in front of the app. It makes rate limiting see the real client +# IP instead of the proxy's. +# +# Enabling it while the app is directly reachable lets any client forge that IP +# and skip the login rate limit entirely, so also firewall port 8000 to the +# proxy's address. See DEPLOY.md section 6. +TRUST_PROXY_HEADERS=false + +# --- Database (REQUIRED) ------------------------------------------------------ +# The existing lab MySQL server. This app does NOT create or host a database - +# it connects to the shared one the Raspberry Pi setups also write to. +# +# The DB user needs read/write on THREE schemas: +# - lab_experiments (this is DB_NAME below) +# - lab_behavior (hardcoded in real_time_plot/get_activity.py) +# - lab_interface (hardcoded in real_time_plot/get_activity.py) +# The tables `#control` and `#task` must already exist there. +# +# Get the real hostname from the lab database admin, or copy it from the .env +# on a machine that is already running the app. +DB_HOST=db.example.org +DB_PORT=3306 +DB_NAME=lab_experiments +DB_USER=change-me +DB_PASSWORD=change-me + +# --- SSH (REQUIRED, even if unused) ------------------------------------------- +# Used ONLY by the "reboot" button, which SSHes into a setup's IP (from the +# `ip` column of the #control table) and runs `sudo reboot`. +# +# These two variables must be present or the app will not start at all - +# utils/config.py validates them at import time. If you do not use the reboot +# feature, leave the placeholders; the button will return a clear +# "SSH credentials not configured" error instead of crashing the app. +# +# For Raspberry Pi setups this is typically the `pi` user. The Pi must allow +# `sudo reboot` without a password prompt. +SSH_USERNAME=change-me +SSH_PASSWORD=change-me + +# --- Authentication ----------------------------------------------------------- +# The lab authenticates against an LDAP directory. Local auth is the fallback +# if LDAP is ever unavailable (it needs a `users` table and an admin account). +USE_LOCAL_AUTH=false +USE_LDAP_AUTH=true + +# --- LDAP (required only when USE_LDAP_AUTH=true) ----------------------------- +# Placeholders. The lab's real directory host and DN layout come from the +# handed-over .env or from the lab admin - they are deliberately not committed +# to this repository. +LDAP_HOST=ldap.example.org +LDAP_PORT=389 +LDAP_USE_SSL=false +LDAP_BASE_DN=dc=example,dc=org +LDAP_USER_DN=ou=users +LDAP_GROUP_DN=ou=groups +# Leave both blank for anonymous bind (this is what the lab currently uses). +LDAP_BIND_USER_DN= +LDAP_BIND_USER_PASSWORD= +# NOTE: no spaces around "=" - Docker's env-file parser is stricter than +# python-dotenv and would read the key as "LDAP_SEARCH_FOR_GROUPS ". +LDAP_SEARCH_FOR_GROUPS=true + +# --- Admin (only used when USE_LOCAL_AUTH=true) ------------------------------- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me + +# --- DO NOT SET --------------------------------------------------------------- +# FLASK_ENV=development +# This is an authentication BYPASS (app.py:65-75) - any username and password +# combination is accepted. It is a different variable from FLASK_CONFIG and is +# easy to set out of habit. Never set it on a machine reachable by others. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d311d1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,105 @@ +# ============================================================================= +# ethopy_control - production image +# +# Single-stage on purpose. A multi-stage build would buy nothing here: every +# dependency is either pure Python (pymysql, ldap3) or ships prebuilt manylinux +# wheels (cryptography, paramiko). No compiler, no build-essential, no gcc, +# and no mysql-client are needed. +# +# Build: docker compose build +# Run: docker compose up -d +# ============================================================================= + +# Repo declares requires-python >=3.9; docs say 3.11; the dev .venv is 3.13. +# 3.12 is the safe middle ground (3.9 is end-of-life). +FROM python:3.12-slim + +# PYTHONUNBUFFERED: send logs straight to stdout so `docker logs` shows them +# immediately instead of holding them in a buffer. +# PYTHONDONTWRITEBYTECODE: no .pyc clutter in the container filesystem. +# PIP_NO_CACHE_DIR: don't keep pip's download cache in the image layer. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# --- Dependency layer ------------------------------------------------------- +# Copy ONLY the dependency manifest first. Docker caches each instruction, so +# as long as pyproject.toml is unchanged, the slow pip install below is reused +# from cache and a code change rebuilds in seconds instead of minutes. +# +# README.md is required because pyproject.toml declares `readme = "README.md"`; +# without it the build fails on metadata generation. +# +# At this point no Python packages exist in the context, so `pip install .` +# resolves and installs the DEPENDENCIES ONLY - which is exactly what we want +# in this cached layer. The application code arrives in the next step. +# +# .[test] also pulls in pytest so the image carries its own self-check +# (`python -m pytest -q`). It costs a few MB and gives whoever inherits this a +# way to verify a new machine without needing a database. +COPY pyproject.toml README.md ./ +# The rm cleans up the empty build/ and *.egg-info/ directories setuptools +# leaves behind in the working directory while building the wheel. +RUN pip install --no-cache-dir ".[test]" \ + && rm -rf /app/build /app/*.egg-info + +# --- Application layer ------------------------------------------------------ +# Copy the source. .dockerignore keeps .env, .venv, build/, dist/ and the logs +# out of this. +# +# IMPORTANT: the app runs from /app, not from site-packages. `pip install .` +# installs the utils/ and real_time_plot/ PACKAGES, but app.py, main.py and +# models.py are top-level MODULES and are not installed. Running with +# WORKDIR=/app is what makes `import app` resolve. +COPY . . + +# --- Security --------------------------------------------------------------- +# Run as a non-root user. If the app is ever compromised, the attacker lands as +# an unprivileged user rather than as root inside the container. +RUN useradd --create-home --shell /bin/bash appuser \ + && chown -R appuser:appuser /app +USER appuser + +# Matches PORT's default in utils/config.py. Documentation only - the actual +# published port is set by docker-compose.yml. +EXPOSE 8000 + +# --- Health check ----------------------------------------------------------- +# Uses Python's urllib, NOT curl: python:3.12-slim does not ship curl, which is +# the bug in the example Dockerfile that used to live in docs/setup.md. +# +# Targets /login, not /. `/` is behind @login_required and only 302-redirects +# here, so /login returning 200 is the honest "the app is really serving" test. +# +# NOTE: this marks the container unhealthy in `docker ps`, but Docker's restart +# policy does NOT act on healthcheck failures. See DEPLOY.md for what actually +# recovers what. +HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/login', timeout=5).status == 200 else 1)" + +# --- Start ------------------------------------------------------------------ +# gunicorn, not `python main.py`: main.py starts Flask's development server, +# which is single-threaded and explicitly not for production use. +# +# `main:app` works because main.py does `from app import app` at module scope. +# Importing main does NOT execute main(), which deliberately skips its database +# pre-flight check - in a container we would rather start and serve errors than +# refuse to boot and crash-loop during a brief database blip. +# +# -w 4 : 4 sync worker processes. Correct here - the app has no +# WebSockets or SSE, only AJAX polling, so no async worker +# class is needed. +# --timeout 60 : a worker stuck longer than this is killed and respawned by +# the gunicorn master. This is what recovers a hung request, +# and it needs to exceed the 5s paramiko SSH reboot call. +# --access-logfile - : access logs to stdout, so `docker logs` has everything. +CMD ["gunicorn", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "4", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "main:app"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3835b58 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,122 @@ +# ============================================================================= +# ethopy_control - deployment +# +# Start: docker compose up -d +# Stop: docker compose down +# Logs: docker compose logs -f +# Rebuild: docker compose up -d --build +# +# See DEPLOY.md for full instructions. +# +# ----------------------------------------------------------------------------- +# THIS STACK SERVES PLAIN HTTP ON PORT 8000. THERE IS NO HTTPS HERE. +# +# That is deliberate. It makes the app work out of the box on any lab network +# with no domain name, no certificates and no extra services to maintain. +# +# It also means passwords and session cookies cross the network unencrypted, so +# this configuration is only appropriate on a TRUSTED LOCAL NETWORK. +# +# Do not port-forward this to the internet as-is. To publish it safely, put a +# reverse proxy in front of it or reach it over a VPN. Both routes, and the two +# environment variables below that must change when you do, are in DEPLOY.md +# section 6. +# ----------------------------------------------------------------------------- +# ============================================================================= + +services: + web: + # Built from the Dockerfile in this directory. The repository is the single + # source of truth - there is no registry to publish to or pull from, and + # nothing external that can go stale. + # + # first run / after a code change: docker compose up -d --build + # normal restart: docker compose up -d + build: . + image: ethopy_control:local + + container_name: ethopy_control + + # --- Recovery ----------------------------------------------------------- + # unless-stopped: restart the container if it crashes, AND start it again + # automatically when the machine boots. It does NOT restart if someone + # deliberately ran `docker compose stop` - a manual stop stays stopped. + # + # Requires the Docker daemon to start at boot: sudo systemctl enable docker + restart: unless-stopped + + # --- Credentials -------------------------------------------------------- + # Reads the real secrets from .env on the HOST at startup and injects them + # as environment variables. The file is never copied into the image + # (.dockerignore excludes it), so the built image contains no secrets. + # + # Copy .env.example to .env and fill it in. See DEPLOY.md. + env_file: + - .env + + # --- Overrides ---------------------------------------------------------- + # Values here beat anything in .env, so production settings cannot be + # undone by a stale .env left over from a developer machine. + environment: + # Turns OFF Flask debug mode. Without this, utils/config.py defaults to + # "development" and exposes the interactive Werkzeug debugger. + FLASK_CONFIG: production + # Pinned, and deliberately overriding whatever .env says. This is the port + # INSIDE the container, which must match the Dockerfile's gunicorn --bind + # and its HEALTHCHECK. To change the port you connect to, set HOST_PORT in + # .env instead - see the ports block below. + PORT: "8000" + # RATELIMIT_STORAGE_URI is deliberately NOT set, so login rate-limit + # counters live in each gunicorn worker's own memory. + # + # The consequence, stated plainly: with 4 workers the effective limit is + # about 4x the configured one, because attempts spread across processes + # that cannot see each other's counts. A limit of "4 per 15 minutes" lets + # through roughly 16. + # + # That is an accepted trade for keeping this to a single container. If + # your directory locks accounts after a small number of failures, either + # lower the limits in .env to compensate or reduce the worker count. + # FLASK_ENV is deliberately NOT set. Setting it to "development" makes + # app.py:65-75 accept ANY username and password. See DEPLOY.md. + # + # SESSION_COOKIE_SECURE and TRUST_PROXY_HEADERS are NOT set here either. + # They both default to false, which is correct for plain HTTP: + # + # SESSION_COOKIE_SECURE=true stops the browser sending the session + # cookie over anything but HTTPS. Turned on here, nobody could log in. + # + # TRUST_PROXY_HEADERS=true makes the app believe the client IP claimed + # in X-Forwarded-For. With no proxy in front, any client can forge + # that header and walk straight past the login rate limit. + # + # Turn BOTH on at the same time, and only once a reverse proxy is + # terminating HTTPS in front of this container. See DEPLOY.md section 6. + + # Read as HOST_PORT : CONTAINER_PORT. Only the left side can ever clash + # with something else on this machine, so only the left side is adjustable. + # + # HOST_PORT set it in .env if 8000 is taken. Defaults to 8000. + # container port fixed at 8000 forever. Every container gets its own + # private network, so 8000 inside can never collide with + # anything - not even another copy of this app. It is also + # hardcoded in the Dockerfile's gunicorn command, EXPOSE + # and HEALTHCHECK, which is why it must not change here. + # + # Published on all interfaces, so the app is reachable from other machines + # on the local network at http://:. That is what + # makes it usable by the lab with no further setup, and it is also what a + # reverse proxy on another machine connects to. + # + # If you later put a proxy in front, restrict who may reach this port + # directly - otherwise the proxy can be bypassed along with its HTTPS and + # the real-client-IP rate limiting. DEPLOY.md section 6 has the rule. + ports: + - "${HOST_PORT:-8000}:8000" + + # Keep container logs from filling the disk over months of running. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" From 99c88752732511adae3565e9d20d7230a5aef876 Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 14:20:09 +0300 Subject: [PATCH 4/5] docs: add deployment runbook and Docker explainer DEPLOY.md is the operator runbook: deploying on a new machine, everyday commands, what recovers what and what does not, updating after a code change, migrating an existing gunicorn install, and what every .env value is and who to ask for it. It records two traps that are easy to misdiagnose. Docker never restarts a container a human stopped, and it counts `docker kill` as manual, so the obvious way to test the restart policy appears to prove it broken. And `ufw` does not filter Docker-published ports, because Docker's rules are evaluated first, so a rule that looks active does nothing. docs/docker.md explains how the setup works and why, with diagrams: what is in the image, why dependencies are copied before the code, how credentials get in without being baked in, how a request reaches the app, and which failures recover themselves. docs/setup.md previously carried an aspirational Docker section describing files that never existed, with a wrong port and unnecessary packages. Two conflicting guides is the worst outcome for whoever inherits this, so it now points at the real one. mkdocs.yml gains the markdown extensions the new pages need, including mermaid rendering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MvZQkduzotzQy2F5LaaBnE --- DEPLOY.md | 369 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 15 +- docs/docker.md | 339 +++++++++++++++++++++++++++++++++++++++++++++ docs/setup.md | 221 ++--------------------------- mkdocs.yml | 17 +++ 5 files changed, 754 insertions(+), 207 deletions(-) create mode 100644 DEPLOY.md create mode 100644 docs/docker.md diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..5bd7060 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,369 @@ +# Deploying and Running ethopy_control + +This is the operations guide for **running** ethopy_control, not for developing it. It assumes no Python knowledge. Everything runs in Docker. + +**What this app is:** a web page for controlling the lab's Raspberry Pi experiment setups. It does not talk to the Pis directly — it reads and writes rows in the shared lab MySQL database, and the Pis poll that database. The one exception is the "reboot" button, which SSHes into a setup and reboots it. + +**What it needs to work:** network access to the lab MySQL server (the `DB_HOST` in your `.env`) and to the LDAP directory server. It does **not** host its own database. + +**How it is served:** plain HTTP on port 8000, reachable from other machines on the same local network. There is no HTTPS, deliberately — see [section 6](#6-https-and-who-can-reach-it) before exposing it beyond the lab. + +--- + +## 1. Deploy on a new computer + +You need three things: Docker, a `docker-compose.yml`, and a `.env`. + +### Step 1 — Install Docker + +On Ubuntu/Debian: + +```bash +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER # so you don't need sudo for docker +newgrp docker # or just log out and back in +``` + +**Then make sure Docker starts when the machine boots:** + +```bash +sudo systemctl enable docker +``` + +> This line is easy to skip and it is the one that matters. Without it, the app +> will **not** come back after a power cut or a reboot. + +### Step 2 — Get the code + +```bash +git clone https://github.com/ef-lab/ethopy_control +cd ethopy_control +cp .env.example .env +``` + +(If you already have a working `.env` on another machine, copy that one across instead of editing the template — it saves filling everything in again. Never send it by email; use a password manager.) + +### Step 3 — Fill in `.env` + +Open `.env` and replace every `change-me`. Each value is explained in the file itself. See [section 7](#7-what-the-env-values-are) for where to get them. + +### Step 4 — Start it + +```bash +docker compose up -d --build +``` + +`-d` means "detached" — it runs in the background and keeps running after you close the terminal. `--build` compiles the image from the `Dockerfile`; it takes about a minute the first time and is only needed after the code changes. + +Open `http://:8000` and log in with your lab LDAP account. + +That's it. There is no Python to install, no virtualenv, and no gunicorn to configure — all of that is inside the image. + +--- + +## 2. Everyday commands + +Run these from the directory containing `docker-compose.yml`. + +### What is running + +One container, `web`: the Flask app under gunicorn, with four worker processes. It publishes port 8000 on the local network. + +```bash +docker compose logs -f web # everything the app is doing +``` + +### Commands + +| What you want | Command | +| --- | --- | +| Is it running? | `docker compose ps` | +| Watch the app logs | `docker compose logs -f web` | +| Last 100 lines of everything | `docker compose logs --tail=100` | +| Restart everything | `docker compose restart` | +| Restart just the app | `docker compose restart web` | +| Stop it | `docker compose down` | +| Start it again | `docker compose up -d` | +| Update to the newest version | `git pull && docker compose up -d --build` | +| Check the image is healthy | `docker compose run --rm --no-deps web python -m pytest -q` | + +`docker compose ps` shows a `STATUS` column. You want `Up ... (healthy)`. +`(unhealthy)` means the container is alive but the web page is not responding — +go read the logs. + +--- + +## 3. When the page stops working + +**First, always:** + +```bash +docker compose ps # is it even running? +docker compose logs --tail=50 +``` + +### It restarts itself in these cases + +| What went wrong | What happens | +| --- | --- | +| The app crashed | Docker restarts it automatically, within seconds | +| The computer rebooted | Docker starts at boot and brings the app back | +| A single request got stuck | gunicorn kills that worker after 60s and starts a fresh one | + +You do not need to do anything for those. + +### It does *not* fix itself in these cases + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `(unhealthy)` but still `Up` | App is wedged | `docker compose restart` | +| Logs show database connection errors | The lab MySQL server is down or unreachable, or the DB password changed | Check the network and the DB credentials — this is not a Docker problem | +| Login fails for everyone | LDAP server unreachable | Check the `LDAP_HOST` in your `.env`. As a temporary workaround see [section 8](#8-if-ldap-is-down) | +| `Cannot connect to the Docker daemon` | Docker isn't running | `sudo systemctl start docker` | +| `port is already allocated` | Something else is on port 8000 (very likely an old gunicorn — see section 5) | Stop the other thing, or pick a free port: set `HOST_PORT=8080` in `.env`, then `docker compose up -d` | + +### Testing that recovery actually works + +If you want to prove to yourself that it restarts, **do not use `docker kill`**. +Docker deliberately does *not* restart a container that a human stopped, and it +counts `docker kill` and `docker stop` as human decisions. The container will +just sit there `Exited`, and it looks like recovery is broken when it isn't. + +To simulate a real crash, kill a worker process *inside* the container: + +```bash +docker compose exec -u root web sh -c 'kill -9 7' # 7 = a worker PID +docker compose logs --tail=5 # master reports it and starts a new one +``` + +You should see `Worker (pid:7) was sent SIGKILL!` immediately followed by `Booting worker with pid: ...`, and the site never goes down. + +To test the reboot case, genuinely reboot the machine and check the page is back before you log in: + +```bash +sudo reboot +# then, from another computer: +curl -I http://:8000/login # expect HTTP/1.1 200 +``` + +### The blunt instrument + +```bash +docker compose down && docker compose up -d +``` + +This is safe. The app stores nothing on disk — all the data lives in the lab database — so you cannot lose data by restarting or even deleting the container. + +--- + +## 4. Updating after a code change + +The image is built on the machine that runs it, from this repository. There is no registry involved, so updating is just pulling the code and rebuilding: + +```bash +git pull +docker compose up -d --build +``` + +Docker reuses cached layers, so a code-only change rebuilds in a couple of seconds — only a change to `pyproject.toml` triggers a full dependency reinstall. + +### Going back to a previous version + +Because the repository is the source of truth, rolling back is a git operation: + +```bash +git log --oneline # find the commit you want +git checkout +docker compose up -d --build +``` + +Return to the latest with `git checkout main && docker compose up -d --build`. + +### Why there is no image registry + +An earlier version of this setup published the image to GitHub Container Registry so machines could pull it without building. That was removed deliberately: anyone deploying needs this repository anyway (for `docker-compose.yml` and `.env.example`), so "no source required" bought very little, while adding a CI pipeline and a package-visibility setting that could quietly break long after anyone remembered they existed. Building locally keeps one source of truth and one thing to understand. + +--- + +## 5. Migrating the existing lab computer + +That machine currently runs gunicorn by hand. Before starting Docker there, the old process must be stopped or it will hold port 8000. + +```bash +# Find it +ps aux | grep -i gunicorn +sudo systemctl list-units | grep -i ethopy # in case it's a systemd service +``` + +Then, depending on what you find: + +```bash +# If it's a systemd service (replace the name): +sudo systemctl stop ethopy_control +sudo systemctl disable ethopy_control # so it doesn't come back at boot + +# If it was started by hand (nohup / screen / tmux): +pkill -f gunicorn +``` + +Confirm port 8000 is free, then start Docker: + +```bash +sudo ss -lntp | grep 8000 # should print nothing +docker compose up -d +``` + +Keep the old virtualenv around for a week or two in case you need to fall back. + +--- + +## 6. HTTPS and who can reach it + +**This stack serves plain HTTP on port 8000. There is no HTTPS.** + +That is deliberate: it works on any network with no domain name and nothing to +renew after the person who set it up has moved on. + +> ⚠️ **Over plain HTTP, passwords and session cookies cross the network +> readable by anyone in the path. Do not forward port 8000 to the internet.** + +Fine on a trusted local network. To reach it from outside, there are two routes. + +**A VPN is the simpler and safer one.** Remote users join the network and open +`http://:8000` as if they were sitting there. Nothing is published, +so the login page cannot be reached by strangers at all, and there are no +certificates to renew. Leave both settings below at `false` for this route. + +**A reverse proxy** is the alternative when outsiders also need access. Something +in front (nginx, Caddy, a Cloudflare Tunnel, or an appliance that already does +this for other services) terminates HTTPS and forwards to port 8000. Give the app +its own hostname rather than a path, since Flask generates links from `/`. + +That route needs two changes in `.env`, and both matter: + +```bash +SESSION_COOKIE_SECURE=true # without working HTTPS, login silently fails +TRUST_PROXY_HEADERS=true # without the firewall below, this bypasses the rate limit +``` + +Then restrict port 8000 to the proxy only. Note that `ufw` does **not** work +here: Docker inserts its own rules, which are evaluated first. Use the +`DOCKER-USER` chain, which Docker leaves alone. + +```bash +sudo iptables -I DOCKER-USER -p tcp --dport 8000 -s -j ACCEPT +sudo iptables -A DOCKER-USER -p tcp --dport 8000 -j DROP +sudo apt install iptables-persistent && sudo netfilter-persistent save +``` + +Check from a third machine, which should now time out: + +```bash +curl --max-time 5 -I http://:8000/login +``` + +--- + +## 7. What the `.env` values are + +| Variable | What it is | Where to get it | +| --- | --- | --- | +| `SECRET_KEY` | Signs login cookies | Generate: `python3 -c "import secrets; print(secrets.token_urlsafe(48))"`. Changing it just logs everyone out. | +| `HOST_PORT` | The port you connect to, e.g. `http://server:8000` | Defaults to `8000`. Change it only if that port is already taken on the machine. | +| `PORT` | The port *inside* the container | Leave at `8000`. It is pinned by `docker-compose.yml` and only read when running outside Docker. | +| `DB_HOST` / `DB_PORT` / `DB_NAME` | The lab MySQL server | From the lab database admin, or copy from a machine already running the app. Port is normally `3306`, database `lab_experiments`. | +| `DB_USER` / `DB_PASSWORD` | Lab database account | From the lab database admin. Needs access to `lab_experiments`, `lab_behavior` and `lab_interface`. | +| `SSH_USERNAME` / `SSH_PASSWORD` | Login for the Raspberry Pis, used only by the reboot button | The Pi account (often `pi`). **Must be set to something even if unused** — the app refuses to start otherwise. | +| `LDAP_*` | Lab directory login | From the lab admin or the handed-over `.env`. The lab uses an anonymous bind, so the two `BIND` values stay empty. | +| `USE_LOCAL_AUTH` / `USE_LDAP_AUTH` | Which login method | `false` / `true` for normal lab use | + +### Credentials that must be handed over + +Before the current maintainer leaves, someone else needs: + +- [ ] The lab **database** username and password (`DB_USER` / `DB_PASSWORD`) +- [ ] The **Raspberry Pi** SSH username and password (`SSH_USERNAME` / `SSH_PASSWORD`) +- [ ] **GitHub** access to `ef-lab/ethopy_control` (to change code and to make the package public) +- [ ] A copy of the working **`.env`** file — via a password manager, never email or Git + +### Never set `FLASK_ENV=development` + +It is an **authentication bypass**: with it set, any username and password is accepted (`app.py:65-75`). It is a different variable from `FLASK_CONFIG`, which makes it easy to set by accident while debugging. `docker-compose.yml` deliberately does not set it. + +--- + +## 8. If LDAP is down + +Everyone is locked out, because LDAP is the only login method enabled. To switch to local accounts temporarily, set in `.env`: + +``` +USE_LOCAL_AUTH=true +USE_LDAP_AUTH=false +``` + +This requires a `users` table with an admin account in the database. If none exists, create it once: + +```bash +docker compose run --rm -e ADMIN_USERNAME=admin -e ADMIN_PASSWORD='pick-a-strong-one' \ + web python -c "from utils.init_db import initialize_database; initialize_database()" +``` + +Then `docker compose up -d` and log in with that account. Switch back to LDAP when the directory is available again. + +--- + +## 9. Fallback: running without Docker + +If Docker cannot be used, the app runs directly under gunicorn. This is the +setup Docker replaced; it needs Python 3.11+ on the machine. + +```bash +git clone https://github.com/ef-lab/ethopy_control +cd ethopy_control +python3 -m venv .venv && source .venv/bin/activate +pip install . +cp .env.example .env # then fill it in +gunicorn --bind 0.0.0.0:8000 --workers 4 --timeout 60 main:app +``` + +To make that survive reboots, create `/etc/systemd/system/ethopy_control.service`: + +```ini +[Unit] +Description=ethopy_control +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=YOUR_USER +WorkingDirectory=/home/YOUR_USER/ethopy_control +EnvironmentFile=/home/YOUR_USER/ethopy_control/.env +Environment=FLASK_CONFIG=production +ExecStart=/home/YOUR_USER/ethopy_control/.venv/bin/gunicorn \ + --bind 0.0.0.0:8000 --workers 4 --timeout 60 \ + --access-logfile - --error-logfile - main:app +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ethopy_control +sudo journalctl -u ethopy_control -f # logs +``` + +`Restart=always` plus `enable` gives the same crash-and-reboot recovery that Docker's `restart: unless-stopped` provides. + +--- + +## 10. Known issues + +- **The reboot button does not currently work.** `SSH_USERNAME` and `SSH_PASSWORD` are still placeholders, so it returns *"SSH credentials not configured"*. Set real Pi credentials in `.env` to enable it. The Pi also has to allow `sudo reboot` without a password prompt. +- **The UI loads jQuery, Plotly, FontAwesome and Toastify from public CDNs.** On a fully air-gapped network the page will load but look broken. +- `real_time_plot/real_time_events.py` is a separate Dash app on port 8050 that is **not** part of the deployment and is not started by the container. +- **No HTTPS by default.** Passwords cross the network in the clear, so the default setup belongs on a trusted local network only. See section 6. +- **No CSRF tokens** on state-changing routes. `SameSite=Lax` cookies mitigate this substantially but not completely. Worth fixing before any long-term internet exposure. diff --git a/README.md b/README.md index c6d2747..7c79973 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,20 @@ A Flask-based application for managing laboratory experiments and device control 👉 [Documentation](https://ef-lab.github.io/ethopy_control/) -## Quick Start +## Deploying / running it + +To **run** the app (rather than develop it), use Docker — no Python setup needed +on the target machine: + +```bash +docker compose up -d # then open http://localhost:8000 +``` + +👉 **[DEPLOY.md](DEPLOY.md)** — deploying on a new computer, automatic restart +after crashes and reboots, reading logs, updating, credential handover, and a +non-Docker fallback. + +## Quick Start (development) 1. **Clone the repository:** ```bash diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..c8ed72b --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,339 @@ +# How the Docker Deployment Works + +This page explains **how** ethopy_control runs in Docker and why it is built the way it is. For **what to type** — deploying on a new machine, restarting, reading logs — see [DEPLOY.md](https://github.com/ef-lab/ethopy_control/blob/main/DEPLOY.md). + +Every figure and measurement below was taken from a real running container, not from generic documentation. + +--- + +## The one idea + +Almost every Docker confusion comes from blurring two different things. + +An **image** is a sealed, read-only snapshot of a filesystem — Python 3.12, the dependencies, and the application code, stacked and frozen. It does nothing. It just sits on disk. + +A **container** is an ordinary Linux process running on the machine, which has been told "treat that snapshot as your entire filesystem, and don't look outside it." + +That is genuinely most of it. A container is **not** a virtual machine — there is no second operating system booting, which is why ethopy_control goes from launched to serving traffic in about eight seconds. It is gunicorn, running as a normal process, with a restricted view of the world. + +```mermaid +flowchart LR + subgraph disk["ON DISK — built once"] + IMG["IMAGE
read-only · 342 MB
python 3.12 · deps · code"] + end + subgraph mem["IN MEMORY — running now"] + C1["CONTAINER
ethopy_control
+ writable scratch layer"] + C2["CONTAINER
a second one, if wanted
+ its own scratch layer"] + end + IMG -->|docker run| C1 + IMG -->|docker run| C2 + C1 -->|removed| X["scratch layer
discarded"] + X -.->|image untouched| IMG +``` + +One image can start many containers, and each gets its own writable scratch layer on top. Anything a container writes there vanishes when it is removed. + +!!! note "Why restarting is safe" + `docker compose down` cannot lose data. The app stores nothing locally — all real data lives in the lab MySQL server. Only the disposable scratch layer is discarded. + +--- + +## What is inside the image + +The image is built in layers, one per instruction in the `Dockerfile`, each +storing only what changed. The real breakdown of the 342 MB: + +| Layer | Contents | Size | +| --- | --- | ---: | +| `python:3.12-slim` | Debian + Python interpreter | 138 MB | +| `pip install ".[test]"` | flask · sqlalchemy · paramiko · ldap3 · dash · gunicorn | 197 MB | +| `COPY . .` | app.py · main.py · templates · static · utils | **0.33 MB** | +| `useradd appuser` | the non-root user | 0.34 MB | + +The application code is roughly **0.1%** of the image. Everything else is the +runtime it needs. That lopsidedness drives the next section. + +--- + +## Why dependencies are copied before the code + +Docker caches every layer. On rebuild it walks the instructions from the top and reuses everything up to the first one whose inputs changed — then rebuilds that step **and everything after it**. + +This is why the `Dockerfile` does something that looks redundant: it copies `pyproject.toml` on its own, installs dependencies, and only *then* copies the source. + +```dockerfile +# 1. just the dependency list +COPY pyproject.toml README.md ./ +RUN pip install --no-cache-dir ".[test]" # the slow 197 MB step + +# 2. then the code, which changes constantly +COPY . . +``` + +Because the code arrives *after* the install, editing `app.py` cannot invalidate the dependency layer. Docker reuses it and only redoes the 0.33 MB copy. + +```mermaid +flowchart LR + subgraph a["You edit app.py"] + direction LR + A1["COPY pyproject
CACHED"] --> A2["pip install
CACHED"] --> A3["COPY . .
rebuilt"] --> A4(["2.3 seconds"]) + end + subgraph b["You edit pyproject.toml"] + direction LR + B1["COPY pyproject
rebuilt"] --> B2["pip install
rebuilt"] --> B3["COPY . .
rebuilt"] --> B4(["~36 seconds"]) + end +``` + +Measured on a real machine: + +| Situation | Time | +| --- | ---: | +| First build, nothing cached | 36 s | +| After editing application code | **2.3 s** | +| After changing a dependency | ~36 s | + +The cache breaks at the first changed instruction and stays broken for +everything below it — so the fast-changing things go last. + +!!! tip "`README.md` is not decorative" + It is copied alongside `pyproject.toml` because `pyproject.toml` declares `readme = "README.md"`. Without it, the build fails during metadata generation. + +--- + +## How credentials get in without being in the image + +This is the part most worth understanding, because it is what makes the image safe to publish on GitHub. + +The image contains **no credentials**, and no lab hostnames either. `/app/.env` does not exist inside the running container. Yet the app knows the database host and password. Two mechanisms do that: + +- **`.dockerignore`** lists `.env`, so the build never copies it *in*. +- **`docker-compose.yml`** has `env_file: .env`, which reads the file *on the host at startup* and injects the values as environment variables. + +```mermaid +flowchart LR + ENV[".env
on the host
real passwords"] + IMG["IMAGE
code + dependencies
no secrets inside
safe to publish"] + CON["CONTAINER
DB_PASSWORD=•••"] + DB[("lab MySQL server")] + + ENV -.->|"BLOCKED by .dockerignore"| IMG + IMG -->|starts| CON + ENV -->|"env_file: injected at startup"| CON + CON -->|authenticates| DB +``` + +There is a neat consequence. Since `.env` is absent inside the container, this line in `utils/config.py` simply finds nothing: + +```python +env_path = Path(".env") +if env_path.exists(): # False inside the container + load_dotenv(dotenv_path=env_path) +``` + +So the injected environment variables are the single source of truth, with no second copy of the config to drift out of sync. The same image runs on a laptop and in the lab, only the injected values differ. + +### Config is validated at import time + +`utils/config.py` evaluates its required variables in the **class body**, so a missing value raises `ValueError` the moment the module is imported — the container dies on startup, not on the first request. + +These five must always be present, even when unused: + +`SECRET_KEY` · `DB_USER` · `DB_PASSWORD` · `SSH_USERNAME` · `SSH_PASSWORD` + +This is why running the test suite in a container still needs dummy values passed with `-e`. + +!!! danger "Never set `FLASK_ENV=development`" + It is an authentication **bypass**, not a debug flag — `app.py:65-75` accepts *any* username and password when it is set. It is a different variable from `FLASK_CONFIG`, which makes it dangerously easy to set out of habit. `docker-compose.yml` deliberately leaves it unset. + +--- + +## How a request reaches the app + +The container has its own private IP on a Docker-managed network. Nothing outside the machine can reach that address directly. The `ports: "8000:8000"` line bridges the gap: Docker listens on the host's port 8000 and forwards to the container's port 8000. + +Read the mapping as **host : container**. To serve on the normal web port instead, change only the left side to `"80:8000"` — the app inside still listens on 8000 and needs no reconfiguring. + +```mermaid +flowchart LR + B["browser
http://host:8000"] -->|"local network"| M["gunicorn master
pid 1"] + subgraph con["app container"] + M --> W1["w1"] + M --> W2["w2"] + M --> W3["w3"] + M --> W4["w4"] + end +``` + +**This is plain HTTP, with no HTTPS.** The stack ships that way on purpose: it +works on any lab network with no domain name and no certificates to renew. The +cost is that passwords cross the network readable, so it belongs on a trusted +local network only. + +To publish it beyond the lab you put a reverse proxy in front, or reach it over +a VPN. Neither changes anything in this repository except two settings in +`.env`. See DEPLOY.md section 6. + +Inside, gunicorn runs as a **master process supervising four workers**. The master does not handle requests; it supervises them. That distinction matters for recovery. + +### What the container reaches out to + +| Destination | Purpose | +| --- | --- | +| Lab MySQL server, port 3306 | Reads and writes `#control`, `#task` and activity tables | +| LDAP directory, port 389 | LDAP login | +| Raspberry Pi IPs, port 22 | SSH — the reboot button only | + +Only port 8000 is published inbound. Everything else is **outbound**, which is why plain bridge networking works with no special configuration, and why the container needs no privileged access to control the rigs. + +### The app does not drive the Pis + +Worth stating explicitly, because it is the reason this containerizes so cleanly: the web app never controls the Raspberry Pis directly. The rigs poll the shared `#control` table and write their own state back. The web page just edits rows. + +```mermaid +flowchart LR + WEB["ethopy_control
web UI"] -->|"writes rows"| DB[("#control table")] + RIG["Raspberry Pi rigs"] -->|"poll for changes"| DB + RIG -->|"write status back"| DB + WEB -.->|"SSH sudo reboot
(the one exception)"| RIG +``` + +There are no serial ports, no GPIO, no `/dev` access, no `subprocess` calls and no host mounts anywhere in the codebase. The only direct device operation is the SSH reboot in `app.py:373-435`. + +### Three database schemas + +`real_time_plot/get_activity.py` opens engines at import time for three schemas on the same server: + +- `lab_experiments` — configurable via `DB_NAME` +- `lab_behavior` — **hardcoded** +- `lab_interface` — **hardcoded** + +The database user needs access to all three. The `#control` and `#task` tables must already exist; the app only ever creates the `users` table. + +--- + +## What recovers what + +"It restarts automatically" is really four different mechanisms handling four different failures. Knowing which is which saves debugging the wrong layer. + +| Failure | Handled by | What you see | | +| --- | --- | --- | --- | +| A request hangs a worker | `gunicorn --timeout 60`
*(the master, not Docker)* | Worker killed and replaced; site never drops | automatic | +| The app crashes outright | `restart: unless-stopped` | Container restarts within seconds | automatic | +| The machine reboots | Docker daemon at boot, then the restart policy | App is back before anyone logs in | automatic | +| Alive but wedged | `HEALTHCHECK` marks it unhealthy | `docker compose ps` shows `(unhealthy)` | **manual** | +| Lab database unreachable | Nothing, correctly | Errors in the logs; restarting will not help | **not Docker** | + +!!! warning "Docker's restart policy does not react to health checks" + A container can sit marked `unhealthy` indefinitely. In practice `--timeout` covers the realistic hang, because the failure mode is a stuck worker rather than a wedged master. The health check is a signal for a human, not an automatic fix. + +`unless-stopped` is chosen over `always` deliberately: it still survives reboots, but respects a deliberate `docker compose stop` instead of fighting the +operator. + +!!! danger "Do not test recovery with `docker kill`" + Docker never restarts a container that a human stopped, and it counts `docker kill` and `docker stop` as human decisions. The container will sit there `Exited` and it looks like recovery is broken — it is not. + + To simulate a real crash, kill a worker **inside** the container: + + ```bash + docker compose exec -u root web sh -c 'kill -9 7' + docker compose logs --tail=5 + ``` + + You should see `Worker (pid:7) was sent SIGKILL!` immediately followed by `Booting worker with pid: ...`, with no interruption to the site. + +### Machine reboot needs one manual step + +```bash +sudo systemctl enable docker +``` + +Without this the Docker daemon does not start at boot, and the restart policy never gets a chance to run. This is the single easiest thing to forget. + +--- + +## Reading the commands + +With the mental model in place, the commands stop being incantations. + +| Command | What it really does | +| --- | --- | +| `docker compose up -d` | Start a container from the image; `-d` detaches it so it outlives your terminal | +| `docker compose down` | Stop and delete the container. The image stays. No data is lost. | +| `docker compose ps` | Is the process alive, and does the health check pass? | +| `docker compose logs -f` | Everything gunicorn wrote to stdout, streamed | +| `docker compose up -d --build` | Rebuild the image from the `Dockerfile`, then restart the container with it | +| `docker compose exec web sh` | Open a shell *inside* the running container | +| `docker compose build` | Turn the `Dockerfile` into a new image, using the layer cache | + +Updating is `pull` then `up -d` precisely because they are separate steps: the first fetches the new image, the second notices the container is running an old one and replaces it. + +### Looking inside a running container + +This is the single most useful debugging habit. The container is just a process, so you can walk into it: + +```bash +# what config did it actually get? +docker compose exec web sh -c 'echo $DB_HOST' + +# can it reach the lab database from in there? +docker compose exec web python -c \ + "import socket,os; socket.create_connection((os.environ['DB_HOST'],3306),timeout=5); print('reachable')" + +# run the built-in self-check +docker compose run --rm web python -m pytest -q +``` + +That last one is worth remembering. The tests use in-memory SQLite, so they prove the image is sound **without touching a database** — useful on a brand-new machine before trusting it with anything. Note that the required environment variables must still be present, because of the import-time validation described above. + +--- + +## The files, and what each one is for + +| File | Role | +| --- | --- | +| `Dockerfile` | How to build the image: base, dependencies, code, user, port, start command | +| `.dockerignore` | What must **not** enter the image — `.env` above all | +| `docker-compose.yml` | How to run it: image, credentials, port mapping, restart policy | +| `.env.example` | Template for the credentials; the real `.env` is gitignored | +| `DEPLOY.md` | The operator runbook | + +### Why there is no image registry + +Building the image leaves it in the Docker daemon's storage on **that machine only** — it is not a file in the project folder. So for a second computer to run it, one of three things has to happen: + +1. **Rebuild from source there** (needs the repository) — what this project does +2. Export with `docker save`, move the tar, `docker load` (works offline, goes stale immediately) +3. Push to a registry and `docker pull` (the usual choice at scale) + +An earlier version of this setup used option 3, publishing to GitHub Container Registry via a CI workflow. It was removed deliberately. + +The selling point of a registry is that a new machine needs no source code. In practice that bought very little here: whoever deploys needs this repository anyway for `docker-compose.yml` and `.env.example`, and anyone changing the code needs it regardless. Meanwhile it added a CI pipeline and a package-visibility setting that would fail with an unhelpful *denied* — both things that can quietly break long after the person who set them up has gone. + +For a one- or two-machine lab deployment, `git pull && docker compose up -d --build` is one mental model instead of two, and the layer cache makes a code-only rebuild take about two seconds. + +--- + +## Design decisions worth knowing + +**Single-stage build.** A multi-stage build would buy nothing here: every dependency is either pure Python (`pymysql`, `ldap3`) or ships prebuilt manylinux wheels (`cryptography`, `paramiko`). No compiler, no `build-essential`, no `gcc`, and no `mysql-client` are needed. + +**Python 3.12.** The project declares `>=3.9`, older docs said 3.11, and the development virtualenv is 3.13. 3.12 is the safe middle ground; 3.9 is end-of-life. + +**gunicorn, not `python main.py`.** `main.py` starts Flask's development server, which is single-threaded and not for production. Four **sync** workers are correct here because the app has no WebSockets or SSE — the live views are plain AJAX polling. + +**`main:app` as the entry point.** This works because `main.py` does `from app import app` at module scope. Importing `main` does **not** execute `main()`, which deliberately skips its database pre-flight check — in a container it is better to start and serve errors than to refuse to boot and crash-loop during a brief database blip. + +**Health check uses `urllib`, not `curl`.** `python:3.12-slim` does not ship `curl`. It targets `/login` rather than `/`, because `/` is behind `@login_required` and only issues a 302 redirect. + +**Non-root user.** If the app is ever compromised, the attacker lands as an unprivileged user inside the container rather than as root. + +**Plain HTTP, and no HTTPS service in this stack.** An earlier version bundled a Caddy container that obtained certificates automatically. It was removed deliberately. Bundling HTTPS forces every deployment to have a public domain name and a certificate to renew, which is the wrong default for a tool most labs will run on their own network. It also duplicated work that an existing reverse proxy on the network may already do for every other service. Serving HTTP and stopping there keeps this repository to one job, and leaves the choice of how to publish it to whoever deploys it. See DEPLOY.md section 6. + +--- + +## Known issues + +- **The reboot button does not currently work.** `SSH_USERNAME` and `SSH_PASSWORD` are placeholders, so it returns *"SSH credentials not configured"* (`app.py:392`). Set real Raspberry Pi credentials in `.env` to enable it; the Pi must also allow `sudo reboot` without a password prompt. +- **The UI loads jQuery, Plotly, FontAwesome and Toastify from public CDNs.** On a fully air-gapped network the page loads but looks broken. +- **`real_time_plot/real_time_events.py`** is a separate Dash app on port 8050. It is never imported by `app.py` or `main.py` and is not part of the deployment. +- **`app_setup.py` cannot run in a container** — it is interactive. Inject environment variables instead. diff --git a/docs/setup.md b/docs/setup.md index bce90c5..43d33a1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -60,7 +60,7 @@ This interactive script will: python3 main.py ``` -The application will be available at `http://localhost:5000`. +The application will be available at `http://localhost:8000` (the default `PORT`). ## Project Structure ``` @@ -175,7 +175,7 @@ python real_time_events.py Access the real-time plots at `http://localhost:8050`. -## Production Deployment +## "Production" Deployment ### Using Gunicorn @@ -184,220 +184,29 @@ Access the real-time plots at `http://localhost:8050`. pip install . # Run with multiple workers -gunicorn -w 4 -b 0.0.0.0:5000 main:app +gunicorn -w 4 -b 0.0.0.0:8000 --timeout 60 main:app ``` -### Docker Deployment (Optional) +### Docker Deployment (Recommended) -
-Click to expand Docker deployment instructions - -#### Multi-stage Production Dockerfile - -Create a `Dockerfile`: -```dockerfile -# Multi-stage build for optimal image size -FROM python:3.11-slim as builder - -# Install build dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Copy only requirements first to leverage Docker layer caching -COPY pyproject.toml ./ -RUN pip install --no-cache-dir --user . - -# Production stage -FROM python:3.11-slim - -# Install runtime dependencies -RUN apt-get update && apt-get install -y \ - mysql-client \ - && rm -rf /var/lib/apt/lists/* - -# Create non-root user for security -RUN useradd --create-home --shell /bin/bash appuser - -# Set working directory -WORKDIR /app - -# Copy Python packages from builder stage -COPY --from=builder /root/.local /home/appuser/.local - -# Copy application code -COPY . . - -# Change ownership to non-root user -RUN chown -R appuser:appuser /app - -# Switch to non-root user -USER appuser - -# Add local bin to PATH -ENV PATH=/home/appuser/.local/bin:$PATH - -# Expose port -EXPOSE 5000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5000/ || exit 1 - -# Run the application -CMD ["python3", "main.py"] -``` - -#### Development Dockerfile - -For development purposes, create a `Dockerfile.dev`: -```dockerfile -FROM python:3.11-slim - -# Install development dependencies -RUN apt-get update && apt-get install -y \ - build-essential \ - gcc \ - mysql-client \ - curl \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Copy project files -COPY pyproject.toml ./ -RUN pip install --no-cache-dir -e .[dev] - -# Copy application code -COPY . . - -# Expose port -EXPOSE 5000 - -# Run in development mode -CMD ["python3", "main.py"] -``` - -#### Docker Compose Setup - -Create a `docker-compose.yml` for complete development environment: -```yaml -version: '3.8' - -services: - app: - build: - context: . - dockerfile: Dockerfile.dev - ports: - - "5000:5000" - environment: - - FLASK_CONFIG=development - - DB_HOST=mysql - - DB_PORT=3306 - - DB_NAME=lab_experiments - - DB_USER=sqlcontrol - - DB_PASSWORD=password - - SECRET_KEY=dev-secret-key-change-in-production - - SSH_USERNAME=admin - - SSH_PASSWORD=admin - - ADMIN_USERNAME=admin - - ADMIN_PASSWORD=admin - - USE_LOCAL_AUTH=true - depends_on: - - mysql - volumes: - - .:/app - - /app/.venv # Exclude venv from volume mount - command: python3 main.py - - mysql: - image: mysql:8.0 - environment: - - MYSQL_ROOT_PASSWORD=rootpassword - - MYSQL_DATABASE=lab_experiments - - MYSQL_USER=sqlcontrol - - MYSQL_PASSWORD=password - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - command: --default-authentication-plugin=mysql_native_password - -volumes: - mysql_data: -``` - -#### Building and Running +Docker is the supported way to deploy ethopy_control. It removes the need to +install Python, create a virtualenv, or configure gunicorn on the target +machine: ```bash -# Build production image -docker build -t ethopy-control . - -# Run production container -docker run -d \ - --name ethopy-control \ - -p 5000:5000 \ - -e SECRET_KEY="your-production-secret" \ - -e DB_HOST="your-db-host" \ - -e DB_USER="your-db-user" \ - -e DB_PASSWORD="your-db-password" \ - -e SSH_USERNAME="your-ssh-user" \ - -e SSH_PASSWORD="your-ssh-password" \ - -e ADMIN_USERNAME="admin" \ - -e ADMIN_PASSWORD="your-admin-password" \ - -e FLASK_CONFIG="production" \ - ethopy-control - -# Run with Docker Compose (development) -docker-compose up -d - -# View logs -docker-compose logs -f app - -# Stop services -docker-compose down +docker compose up -d --build ``` -#### Environment Variables for Docker +The image is built on the machine that runs it, straight from the `Dockerfile` +in this repository — there is no registry to configure or keep in sync. -Create a `.env.docker` file for container environment variables: -```bash -# Database Configuration -DB_HOST=mysql -DB_PORT=3306 -DB_NAME=lab_experiments -DB_USER=ethopycontrol -DB_PASSWORD=secure_password - -# Application Configuration -SECRET_KEY=your-super-secret-key-for-production -FLASK_CONFIG=production -USE_LOCAL_AUTH=true - -# SSH Configuration -SSH_USERNAME=your-ssh-username -SSH_PASSWORD=your-ssh-password - -# Admin Configuration -ADMIN_USERNAME=admin -ADMIN_PASSWORD=secure-admin-password -``` +**See [DEPLOY.md](https://github.com/ef-lab/ethopy_control/blob/main/DEPLOY.md) for the full guide**, covering deployment on a +new computer, automatic restart after crashes and reboots, log access, +credential handover, and a non-Docker fallback. -**Security Notes for Docker:** -- Never use default passwords in production -- Use Docker secrets for sensitive data -- Run containers as non-root user -- Regularly update base images -- Use multi-stage builds to reduce image size -- Implement proper logging and monitoring +The relevant files live at the repository root: `Dockerfile`, +`docker-compose.yml`, `.dockerignore` and `.env.example`. -
### Security Considerations diff --git a/mkdocs.yml b/mkdocs.yml index 1b60fc6..ec50f50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,6 +30,7 @@ nav: - Setup Guide: setup.md - User Management: user_management.md - Monitoring and Control: monitoring.md + - How Docker Works: docker.md - Real-Time Plotting: - Activity Monitor: activity_monitor.md - Custom Event Types: custom_event_types.md @@ -40,3 +41,19 @@ nav: - Project Structure: development/project_structure.md - Testing: development/testing.md - Contributing: development/contributing.md + +markdown_extensions: + # Admonitions: the !!! note / warning / danger callouts in docker.md + - admonition + - pymdownx.details + - attr_list + - md_in_html + - tables + - pymdownx.tasklist: + custom_checkbox: true + # Renders ```mermaid fences as diagrams instead of code blocks + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format From 3281e619b8ae8751f5a7709808a1368c66912a7a Mon Sep 17 00:00:00 2001 From: Alexandros Evangelou Date: Wed, 2 Sep 2026 14:20:16 +0300 Subject: [PATCH 5/5] chore: gitignore private lab notes This repository is public and its docs publish to GitHub Pages. Notes that name real hosts or describe how a particular site's network is arranged should not be committed. private/ is handed over directly, the same way .env is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MvZQkduzotzQy2F5LaaBnE --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb175a9..a8c407e 100644 --- a/.gitignore +++ b/.gitignore @@ -121,4 +121,6 @@ ENV/ .mypy_cache/ # IDE settings -.vscode/ \ No newline at end of file +.vscode/ +# Lab-specific notes: handed over directly, never committed (public repo) +private/