chore: prod env checker, secret guidance, internet-exposure runbook
This commit is contained in:
parent
c3c4986979
commit
25cf645295
5 changed files with 219 additions and 4 deletions
|
|
@ -1,4 +1,6 @@
|
|||
# Copy to `.env` and fill in values. `.env` is gitignored.
|
||||
# Generate secrets with: openssl rand -hex 32
|
||||
# Validate before deploying: uv run python scripts/check_prod.py --env ../.env
|
||||
# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas).
|
||||
# Prod overrides via this .env file (e.g. APP_ENV=prod).
|
||||
# Paths resolve from the repo root in code.
|
||||
|
|
@ -12,6 +14,8 @@ FORGEJO_TOKEN=
|
|||
LANGFLOW_VERSION=1.12.0
|
||||
LANGFLOW_URL=http://langflow:7860
|
||||
LANGFLOW_SUPERUSER=langflow
|
||||
# Long random values (check_prod.py enforces length). Never leave the
|
||||
# placeholders: the authoring UI is internet-facing.
|
||||
LANGFLOW_SUPERUSER_PASSWORD=
|
||||
LANGFLOW_SECRET_KEY=
|
||||
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
### Security
|
||||
- Production ops: `scripts/check_prod.py` validates `.env` secrets and
|
||||
modes before deploy (with tests); README production checklist
|
||||
(proxy gates for Langflow, secret rotation, upgrade runbook).
|
||||
### Security
|
||||
- API authorization: flow management is admin-only, runs and run/approval
|
||||
reads require login with strict session-tenant scoping, approval
|
||||
decisions record the session user, and flow callbacks authenticate with
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -126,15 +126,32 @@ Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
|
|||
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
|
||||
Deleting a file removes the flow including its runs (git keeps the JSON).
|
||||
|
||||
## Production (self-hosted, internal use — no TLS/reverse proxy included)
|
||||
## Production (self-hosted — TLS/reverse proxy required, not included)
|
||||
|
||||
1. Check out the repo on the host, create `.env` from `.env.example` with
|
||||
real secrets and `FRONTEND_MODE=preview`.
|
||||
2. `docker compose up -d --build`.
|
||||
3. Code updates via `git pull` + `docker compose restart api frontend`
|
||||
real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`.
|
||||
2. Validate it: `uv run python scripts/check_prod.py --env ../.env`
|
||||
(run from `backend/`; blocks on weak secrets or dev modes).
|
||||
3. `docker compose up -d --build`.
|
||||
4. Code updates via `git pull` + `docker compose restart api frontend`
|
||||
(the checkout is bind-mounted); image rebuilds only for dependency
|
||||
changes (`docker compose up -d --build`).
|
||||
|
||||
Internet exposure checklist (both forwarded ports sit behind your HTTPS
|
||||
proxy — nothing here substitutes for it):
|
||||
|
||||
- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or
|
||||
OIDC) in front of Langflow `:7860`. Langflow has a history of
|
||||
critical unauthenticated RCEs — treat its UI as privileged tooling,
|
||||
never share flows publicly ("Shareable Playground" = RCE risk).
|
||||
- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate
|
||||
after any incident or staff change; re-run `check_prod.py`.
|
||||
- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION`
|
||||
(`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then
|
||||
`lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on
|
||||
`backend/flows/*.json`. Check Langflow security advisories monthly.
|
||||
- Backups: `backend/data/` (SQLite) and the `langflow-data` volume.
|
||||
|
||||
Container updates: registry images (`langflowai/langflow:latest`)
|
||||
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
|
||||
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.
|
||||
|
|
|
|||
111
backend/scripts/check_prod.py
Normal file
111
backend/scripts/check_prod.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Validate a prod `.env` file before (re)deploying.
|
||||
|
||||
Usage (from backend/ or repo root):
|
||||
uv run python scripts/check_prod.py --env ../.env
|
||||
uv run python scripts/check_prod.py # defaults to repo-root .env
|
||||
|
||||
Fails (exit 1) on missing/weak secrets and insecure modes; warns on
|
||||
soft misconfigurations. Safe to run anywhere: reads one file, no imports
|
||||
from app (stdlib only), prints no secret values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BACKEND.parent
|
||||
|
||||
MIN_TOKEN = 32
|
||||
MIN_PASSWORD = 16
|
||||
MIN_API_KEY = 16
|
||||
|
||||
|
||||
def parse_env(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
if key.startswith("export "):
|
||||
key = key[len("export ") :].strip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""(failures, warnings) for a parsed env file."""
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
def secret(name: str, minimum: int, what: str) -> None:
|
||||
value = values.get(name, "")
|
||||
if not value:
|
||||
failures.append(f"{name} is empty — generate one: openssl rand -hex 32")
|
||||
elif len(value) < minimum:
|
||||
failures.append(f"{name} too short ({len(value)} < {minimum} chars)")
|
||||
|
||||
def placeholder(name: str, prefixes: tuple[str, ...]) -> None:
|
||||
value = values.get(name, "")
|
||||
if value and any(value == p or value.startswith(p) for p in prefixes):
|
||||
failures.append(f"{name} still holds an example placeholder")
|
||||
|
||||
# Shared + service API keys.
|
||||
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
|
||||
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
|
||||
placeholder("LANGFLOW_API_KEY", ("sk-",))
|
||||
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
|
||||
placeholder("LFX_API_KEY", ("lfx-",))
|
||||
# Langflow's own auth (internet-facing authoring UI).
|
||||
secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password")
|
||||
secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key")
|
||||
|
||||
# Insecure modes.
|
||||
if values.get("FRONTEND_MODE", "preview") == "dev":
|
||||
failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod")
|
||||
if values.get("APP_ENV", "prod") == "dev":
|
||||
failures.append("APP_ENV=dev enables autoreload — unset it in prod")
|
||||
if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true":
|
||||
failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access")
|
||||
|
||||
# Soft misconfigurations.
|
||||
if not values.get("ADMIN_EMAILS", ""):
|
||||
warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account")
|
||||
if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""):
|
||||
warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail")
|
||||
if not values.get("CONTACT_TO", ""):
|
||||
warnings.append("CONTACT_TO empty — access-request notifications go nowhere")
|
||||
frontend_url = values.get("FRONTEND_URL", "")
|
||||
if frontend_url and not frontend_url.startswith("https://"):
|
||||
warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext")
|
||||
|
||||
return failures, warnings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
|
||||
args = parser.parse_args()
|
||||
path = Path(args.env)
|
||||
if not path.is_file():
|
||||
print(f"no env file at {path}")
|
||||
return 1
|
||||
failures, warnings = check(parse_env(path))
|
||||
for message in warnings:
|
||||
print(f"WARN: {message}")
|
||||
for message in failures:
|
||||
print(f"FAIL: {message}")
|
||||
if failures:
|
||||
print(f"{len(failures)} blocking issue(s) — fix before deploying")
|
||||
return 1
|
||||
print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
79
backend/tests/test_check_prod.py
Normal file
79
backend/tests/test_check_prod.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Prod env checker: failures block deploys, warnings don't."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
|
||||
|
||||
from check_prod import check
|
||||
|
||||
|
||||
def _good() -> dict:
|
||||
return {
|
||||
"CALLBACK_TOKEN": "t" * 40,
|
||||
"LANGFLOW_API_KEY": "k" * 24,
|
||||
"LFX_API_KEY": "x" * 24,
|
||||
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
|
||||
"LANGFLOW_SECRET_KEY": "s" * 40,
|
||||
"FRONTEND_MODE": "preview",
|
||||
"ADMIN_EMAILS": "admin@example.com",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PASSWORD": "secret",
|
||||
"CONTACT_TO": "team@example.com",
|
||||
"FRONTEND_URL": "https://example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_good_env_passes_without_warnings():
|
||||
failures, warnings = check(_good())
|
||||
assert failures == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_empty_and_short_secrets_fail():
|
||||
values = _good()
|
||||
values["CALLBACK_TOKEN"] = ""
|
||||
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
|
||||
failures, _ = check(values)
|
||||
assert any("CALLBACK_TOKEN" in f for f in failures)
|
||||
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
|
||||
|
||||
|
||||
def test_placeholders_fail():
|
||||
values = _good()
|
||||
values["LANGFLOW_API_KEY"] = "sk-"
|
||||
values["LFX_API_KEY"] = "lfx-abc"
|
||||
failures, _ = check(values)
|
||||
assert any("LANGFLOW_API_KEY" in f for f in failures)
|
||||
assert any("LFX_API_KEY" in f for f in failures)
|
||||
|
||||
|
||||
def test_insecure_modes_fail():
|
||||
values = _good()
|
||||
values["FRONTEND_MODE"] = "dev"
|
||||
values["APP_ENV"] = "dev"
|
||||
values["LANGFLOW_AUTO_LOGIN"] = "true"
|
||||
failures, _ = check(values)
|
||||
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
|
||||
assert len([f for f in failures if "APP_ENV" in f]) == 1
|
||||
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
|
||||
|
||||
|
||||
def test_soft_misconfigurations_warn_only():
|
||||
values = _good()
|
||||
values["ADMIN_EMAILS"] = ""
|
||||
values["SMTP_PASSWORD"] = ""
|
||||
values["CONTACT_TO"] = ""
|
||||
values["FRONTEND_URL"] = "http://example.com"
|
||||
failures, warnings = check(values)
|
||||
assert failures == []
|
||||
assert len(warnings) == 4
|
||||
|
||||
|
||||
def test_example_file_would_not_pass():
|
||||
from check_prod import parse_env
|
||||
|
||||
values = parse_env(REPO_ROOT / ".env.example")
|
||||
failures, _ = check(values)
|
||||
assert failures, "placeholders must be detectable"
|
||||
Loading…
Add table
Add a link
Reference in a new issue