diff --git a/backend/tests/test_env_var_doc_guard.py b/backend/tests/test_env_var_doc_guard.py new file mode 100644 index 00000000..ab0e8e0d --- /dev/null +++ b/backend/tests/test_env_var_doc_guard.py @@ -0,0 +1,90 @@ +"""Every environment variable the backend reads is listed in docs/ENVIRONMENT.md. + +About 30 of the ~70 variables were documented nowhere, so operators found +settings like the encryption-key file or the extension signing keys only by +reading the source. +""" +import ast +import os +import re + +HERE = os.path.dirname(os.path.abspath(__file__)) +BACKEND = os.path.dirname(HERE) +DOC = os.path.join(os.path.dirname(BACKEND), 'docs', 'ENVIRONMENT.md') + +# Read from the environment but not panel settings. +NOT_SETTINGS = { + 'PATH', # passed through to child processes + 'APPDATA', # Windows dev fallback for the uptime history file +} + +ENV_HELPER = re.compile(r'^_?env_[a-z]+$') + + +def _sources(): + yield os.path.join(BACKEND, 'config.py') + yield os.path.join(BACKEND, 'run.py') + for root, dirs, files in os.walk(os.path.join(BACKEND, 'app')): + dirs[:] = [d for d in dirs if d not in ('plugins', '__pycache__')] + for name in files: + if name.endswith('.py'): + yield os.path.join(root, name) + + +def _string_constants(tree): + out = {} + for node in tree.body: + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) \ + and isinstance(node.value.value, str): + for target in node.targets: + if isinstance(target, ast.Name): + out[target.id] = node.value.value + return out + + +def _read_names(path): + tree = ast.parse(open(path, encoding='utf-8').read()) + consts = _string_constants(tree) + + def literal(node): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name): + return consts.get(node.id) + return None + + for node in ast.walk(tree): + key = None + if isinstance(node, ast.Call) and node.args: + func = ast.unparse(node.func) + short = func.rsplit('.', 1)[-1] + if func in ('os.environ.get', 'os.getenv', 'os.environ.setdefault') \ + or ENV_HELPER.match(short) or short == 'env_bool': + key = literal(node.args[0]) + elif isinstance(node, ast.Subscript) and ast.unparse(node.value) == 'os.environ' \ + and isinstance(node.ctx, ast.Load): + key = literal(node.slice) + if key and re.fullmatch(r'[A-Z][A-Z0-9_]+', key): + yield key + + +def test_every_env_var_read_is_documented(): + documented = set(re.findall(r'^\| `([A-Z][A-Z0-9_]+)`', open(DOC, encoding='utf-8').read(), re.M)) + read = {} + for path in _sources(): + for key in _read_names(path): + read.setdefault(key, os.path.relpath(path, BACKEND)) + missing = sorted(k for k in read if k not in documented and k not in NOT_SETTINGS) + assert not missing, ( + 'Add these to docs/ENVIRONMENT.md: ' + + ', '.join(f'{k} ({read[k]})' for k in missing)) + + +def test_the_scan_sees_reads_through_helpers_and_constants(): + """The guard is only as good as its scan: config.py reads through + _env_bool/_env_int and safe_http names its variable in a constant.""" + names = set() + for path in _sources(): + names.update(_read_names(path)) + assert {'TRUST_PROXY_HEADERS', 'AUTH_IP_MAX_ATTEMPTS', 'SERVERKIT_ALLOW_PRIVATE_WEBHOOKS', + 'SECRET_KEY'} <= names diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md new file mode 100644 index 00000000..5d6df369 --- /dev/null +++ b/docs/ENVIRONMENT.md @@ -0,0 +1,119 @@ +# Environment variables + +Every environment variable the panel backend reads at runtime. Installer +options (`SERVERKIT_PROFILE`, `PANEL_DOMAIN`, `SERVERKIT_SKIP_SSL`, ...) are +covered in [INSTALLATION.md](INSTALLATION.md#install-options); this page is +about the running panel. + +On a host install the values live in `/opt/serverkit/.env`, loaded by the +`serverkit-backend` systemd unit. With Docker they come from the compose file +or `docker run -e`. Restart the panel after changing one. + +`backend/tests/test_environment_doc.py` fails when the backend reads a +variable that is not listed here. + +## Core + +| Variable | Default | Purpose | +|---|---|---| +| `FLASK_ENV` | `development` | `production` selects the production config, refuses the default secret keys or an empty `CORS_ORIGINS`, and auto-generates `SERVERKIT_ENCRYPTION_KEY` when it is missing. The systemd unit sets it. | +| `SECRET_KEY` | insecure dev value | Flask session signing. Must be set in production. | +| `JWT_SECRET_KEY` | insecure dev value | Signs access and refresh tokens. Changing it signs everyone out. | +| `DATABASE_URL` | `sqlite:////app/instance/serverkit.db` | SQLAlchemy URL (`sqlite:///...` or `postgresql://...`). In Docker see the compose note in INSTALLATION.md. | +| `LOG_LEVEL` | `INFO` (`DEBUG` in development) | Python logging level name (`DEBUG`, `INFO`, `WARNING`, ...). | +| `PORT` | `5000` | Port for `python run.py`. Gunicorn deployments bind with `-b` instead. | +| `SERVERKIT_PROFILE` | `standard` | Install profile (`minimal`, `standard`, `full`) when the panel setting has not overridden it. Written by the installer. | + +## Network and proxy + +| Variable | Default | Purpose | +|---|---|---| +| `CORS_ORIGINS` | local dev origins | Comma-separated allowed browser origins; required in production. `SERVERKIT_PUBLIC_URL` is added automatically. | +| `SERVERKIT_PUBLIC_URL` | unset | The URL browsers and agents use to reach the panel. Needed behind your own proxy or tunnel, or websocket handshakes are rejected on origin. | +| `TRUST_PROXY_HEADERS` | `false` | Trust `X-Forwarded-*` from the reverse proxy to find the real client IP. `true` behind the bundled nginx. See SECURITY.md. | +| `TRUSTED_PROXY_HOPS` | `1` | How many proxies sit in front of the panel. | +| `SERVERKIT_SSL_MODE` | read from `/etc/serverkit/ssl-mode`, else `insecure` | `secure` turns on HSTS. Only set it when the panel really serves end-to-end HTTPS. | +| `SITES_BASE_DOMAIN` | empty (`lvh.me` in development) | Managed sites are published at `.`. Empty falls back to `localhost:` URLs. The panel setting overrides it. | +| `SERVER_PUBLIC_IP` | empty | IP that auto-created DNS records point at. | +| `SERVERKIT_ALLOW_PRIVATE_WEBHOOKS` | off | Let outgoing webhooks and event subscriptions reach private/internal addresses (the SSRF guard blocks them by default). | + +## Security and secrets + +| Variable | Default | Purpose | +|---|---|---| +| `SERVERKIT_ENCRYPTION_KEY` | generated on first production boot | Key for secrets stored in the database. Back it up: losing it makes stored credentials unreadable. | +| `SERVERKIT_ENV_FILE` | auto-detected | The `.env` file the generated encryption key is written to. | +| `AUTH_IP_MAX_ATTEMPTS` | `10` | Failed logins from one IP before it is blocked. | +| `AUTH_IP_WINDOW_MINUTES` | `15` | Window those attempts are counted in. | +| `AUTH_IP_BLOCK_MINUTES` | `15` | How long the IP stays blocked. | +| `RATELIMIT_STORAGE_URI` | in-memory | Rate-limit storage (e.g. `redis://...`). In-memory is correct for the single-worker panel. | +| `SERVERKIT_PASSKEY_RP_ID` | host of `BASE_URL`, else `localhost` | WebAuthn relying-party ID: the panel's domain. | +| `SERVERKIT_PASSKEY_ORIGIN` | `BASE_URL`, else `http://localhost` | Origin passkey ceremonies must come from. | +| `SERVERKIT_PASSKEY_RP_NAME` | `ServerKit` | Name shown in the authenticator prompt. | +| `BASE_URL` | unset | Fallback for the two passkey values above. | +| `PROMETHEUS_TOKEN` | unset | Extra bearer token accepted by the Prometheus scrape endpoint, besides the one the panel generates. | +| `SERVERKIT_SETUP_CODE` | generated | Pins the one-time code that the first admin registration requires, for automation. At least 12 characters. | +| `SERVERKIT_DEMO_MODE` | off | Reject every mutating API request, for a public read-only demo. Wins over the panel setting. | + +## Paths + +| Variable | Default | Purpose | +|---|---|---| +| `SERVERKIT_INSTALL_DIR` | `/opt/serverkit` | Where the panel is installed. The systemd unit sets it. | +| `SERVERKIT_DIR` | `/var/serverkit` | Data root (apps, deployments, imports). Also a lower-priority hint when locating `.env`. | +| `SERVERKIT_CONFIG_DIR` | `/etc/serverkit` | Panel configuration files. | +| `SERVERKIT_LOG_DIR` | `/var/log/serverkit` | Panel log directory. | +| `SERVERKIT_LOG_FILE` | unset | A panel log file to include in support bundles (the systemd unit logs to the journal). | +| `SERVERKIT_BACKUP_DIR` | `/var/backups/serverkit` | Update snapshots and backups. Must match `BACKUP_DIR` in `scripts/update.sh`. | +| `SERVERKIT_CACHE_DIR` | `/var/cache/serverkit` | Caches, including build-pack workspaces. | +| `BUILDPACK_WORKSPACE_DIR` | `$SERVERKIT_CACHE_DIR/buildpacks` | Where build packs stage sources. | +| `SERVERKIT_IMPORTS_DIR` | `$SERVERKIT_DIR/imports` | Uploaded site-migration archives. | +| `SERVERKIT_QUARANTINE_DIR` | `/var/quarantine` | Files the malware scanner quarantines. | +| `SERVERKIT_TMP_DIR` | `/tmp` | Temp directory disk reclaim cleans. | +| `SERVERKIT_LOCK_FILE` | `/var/lock/serverkit-update.lock` | Updater lock; slot retention waits while it is held. | +| `SERVERKIT_SCRIPTS_DIR` | `/scripts` | Where the agent installers are served from, for layouts that split the backend from the tree. | +| `VMAIL_DIR` | `/var/vmail` | Mailbox storage. | +| `NGINX_CONF_DIR` | `/etc/nginx` | nginx configuration root. | +| `SERVERKIT_WAF_DIR` | `/etc/nginx/serverkit-conf.d/waf` | Generated WAF rule files. | +| `SERVERKIT_OWASP_CRS_PATH` | `/usr/share/modsecurity-crs/owasp-crs.load` | OWASP CRS entrypoint the WAF rules include. | +| `SERVERKIT_MODSEC_AUDIT_LOG` | `/var/log/modsec_audit.log` | ModSecurity JSON audit log read for WAF events. | + +## Binaries + +| Variable | Default | Purpose | +|---|---|---| +| `NGINX_BIN` | `/usr/sbin/nginx` | nginx executable. | +| `CERTBOT_BIN` | found on `PATH` | certbot executable. | +| `NIXPACKS_BIN` | `nixpacks` | Optional nixpacks executable for opaque builds. | + +## Extensions + +| Variable | Default | Purpose | +|---|---|---| +| `SERVERKIT_BACKEND_PLUGINS_DIR` | `backend/app/plugins` | Where extension backends are installed. | +| `SERVERKIT_FRONTEND_PLUGINS_DIR` | `frontend/src/plugins` | Where extension frontends are installed. Docker panels must set it and mount the folder to install extensions that ship a UI. | +| `SERVERKIT_BUILTIN_EXTENSIONS_DIR` | `/builtin-extensions` | Bundled extensions offered for one-click install. | +| `SERVERKIT_GITHUB_TOKEN` | unset | GitHub token for extension downloads: lifts the anonymous rate limit and allows private repositories. Sent only to GitHub hosts over HTTPS. | +| `SERVERKIT_ALLOW_PRIVATE_DOWNLOADS` | off | Let extension downloads reach private addresses, for a private-network registry. | +| `SERVERKIT_ALLOW_PLUGIN_PIP` | off | Install an extension's `requirements.txt`. Off by default because pip runs with the panel's privileges and a `setup.py` is arbitrary code. | +| `SERVERKIT_TRUSTED_EXTENSION_KEYS` | unset | Path to an extra keys file trusted for extension signatures, beside the pinned first-party keys. | + +## Integrations + +| Variable | Default | Purpose | +|---|---|---| +| `REDIS_URL` | `redis://localhost:6379/0` | Optional Redis for the cache; the panel works without it. | +| `SERVERKIT_AGENT_GITHUB_REPO` | the official agent repository | Repository agent releases and installers are fetched from. | +| `SERVERKIT_CLOUD_URL` | the hosted service URL | Endpoint `serverkit connect` pairs with. A `--cloud` flag wins. | +| `LINKED_PANEL_ALLOWED_WRITE_ROOTS` | `/var/serverkit/apps,/etc/serverkit` | Comma-separated directories a master panel may write to when this panel runs as its linked worker. | +| `ANTHROPIC_BASE_URL` | unset | Not a panel setting: when set in the panel's process environment the native Claude driver would inherit it, so Claude connections refuse to run. Remove it, or configure a compatible-gateway connection instead. | + +## Development and testing + +| Variable | Default | Purpose | +|---|---|---| +| `SERVERKIT_SKIP_BACKGROUND` | off | Do not start background threads (metrics, queues, jobs). The updater sets it while running migrations. | +| `SERVERKIT_DEMO_DEPLOYS` | off | Enable scripted demo deployments that exercise the deploy console without Docker. | +| `SERVERKIT_PROFILE_REQUESTS` | off | Measure each API request's time and SQL query count, for local or staging profiling. No SQL text or parameters are recorded. | +| `SERVERKIT_STAGING` | off | Mark a staging instance; `/system/health` reports it. | +| `TEST_DATABASE_URL` | a per-process temp SQLite file | Database for the test suite (set by `tests/conftest.py` when unset). | diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index bc667223..d531dadd 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -64,6 +64,9 @@ On a `minimal` install: | `SERVERKIT_OFFLINE_TARBALL=/path/to/...tar.gz` | Use a local tarball instead of downloading | | `SERVERKIT_MIRROR_URL=https://mirror.example.com/releases` | Fetch releases/checksums from a private mirror | +These options apply to the installer. Settings the running panel reads from +its `.env` are listed in [ENVIRONMENT.md](ENVIRONMENT.md). + Example with a domain: ```bash