From fe436c6453d0a15be13500dbb551644eba47305d Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 25 May 2026 10:54:08 +0200 Subject: [PATCH] feat: 12-factor env vars FLAPI_PORT + FLAPI_HOST (#63) - main.cpp: FLAPI_PORT / FLAPI_HOST fallback for --port / --host with CLI > env > config > default precedence; invalid FLAPI_PORT (non-int / out of range) exits 1 with single-line stderr, mirroring the FLAPI_LOG_LEVEL pattern from #47 - main.cpp: new --host CLI flag, paired with FLAPI_HOST - config_manager: http-host YAML key (default 0.0.0.0) + get/setHttpHost accessors - api_server: pass bindaddr to crow so the configured host is honoured - test_env_overrides.py: 7 new pytest cases covering invalid FLAPI_PORT (abc / 0 / 99999 / -1), env-only port bind, CLI-wins-over-env precedence, and FLAPI_HOST bind - CLI_REFERENCE / CONFIG_REFERENCE / AGENTS: docs reflect the new env vars and the http-host config field --- AGENTS.md | 2 + docs/CLI_REFERENCE.md | 59 +++++++++- docs/CONFIG_REFERENCE.md | 7 +- src/api_server.cpp | 11 +- src/config_manager.cpp | 4 + src/include/config_manager.hpp | 3 + src/main.cpp | 40 ++++++- test/integration/test_env_overrides.py | 149 +++++++++++++++++++++++-- 8 files changed, 259 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6dcce56..460b77f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -771,6 +771,8 @@ rate_limit: # Configuration FLAPI_CONFIG=path/to/flapi.yaml # Config file path FLAPI_LOG_LEVEL=debug|info|warn|error +FLAPI_PORT=8080 # HTTP port (fallback for --port) +FLAPI_HOST=0.0.0.0 # Bind address (fallback for --host) # Authentication JWT_SECRET=your-secret-key # JWT signing key diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md index c2b0d1a..1aa6bd0 100644 --- a/docs/CLI_REFERENCE.md +++ b/docs/CLI_REFERENCE.md @@ -15,6 +15,7 @@ This document provides a complete reference for the `flapi` server executable's 2. [Command-Line Options](#2-command-line-options) - [Configuration File](#configuration-file---c---config) - [Server Port](#server-port---p---port) + - [Bind Host](#bind-host---host) - [Log Level](#log-level---log-level) - [Validate Configuration](#validate-configuration---validate-config) - [Configuration Service](#configuration-service---config-service) @@ -134,11 +135,22 @@ Overrides the HTTP server port defined in the configuration file. | Type | integer | | Default | From config file (typically `8080`) | | Required | No | +| Environment variable | `FLAPI_PORT` | **Description:** When specified, this option overrides the `http-port` value in the configuration file. Useful for running multiple instances or when port configuration needs to be dynamic. +**Precedence (highest wins):** +1. `-p` / `--port` CLI flag +2. `FLAPI_PORT` environment variable +3. `http-port` from `flapi.yaml` +4. Built-in default (`8080`) + +Invalid `FLAPI_PORT` values (non-integer, `<1`, `>65535`) cause flapi to +exit with a single-line error -- a typo like `FLAPI_PORT=abc` surfaces +immediately rather than silently falling through to the config-file value. + **Example:** ```bash @@ -147,9 +159,52 @@ When specified, this option overrides the `http-port` value in the configuration # Override config file port ./flapi -c production.yaml --port 80 + +# 12-factor: pick up the port from the environment +export FLAPI_PORT=9000 +./flapi +``` + +> **Implementation:** `src/main.cpp`, `src/api_server.cpp` | **Tests:** `test/integration/test_env_overrides.py`, `test/integration/conftest.py` + +--- + +### Bind Host (`--host`) + +Overrides the bind address (`http-host`) defined in the configuration file. + +| Property | Value | +|----------|-------| +| Long form | `--host` | +| Type | string | +| Default | From config file (`0.0.0.0` if unset) | +| Required | No | +| Environment variable | `FLAPI_HOST` | + +**Description:** + +Controls which network interface the HTTP server binds on. Use +`127.0.0.1` to restrict access to the loopback interface only, or +`0.0.0.0` to accept connections on all interfaces. + +**Precedence (highest wins):** +1. `--host` CLI flag +2. `FLAPI_HOST` environment variable +3. `http-host` from `flapi.yaml` +4. Built-in default (`0.0.0.0`) + +**Example:** + +```bash +# Loopback only (useful behind a reverse proxy) +./flapi --host 127.0.0.1 + +# 12-factor: pick up the host from the environment +export FLAPI_HOST=127.0.0.1 +./flapi ``` -> **Implementation:** `src/main.cpp`, `src/api_server.cpp` | **Tests:** `test/integration/conftest.py` +> **Implementation:** `src/main.cpp`, `src/api_server.cpp`, `src/config_manager.cpp` | **Tests:** `test/integration/test_env_overrides.py` --- @@ -520,6 +575,8 @@ notarisation. | Variable | Description | Used By | |----------|-------------|---------| | `FLAPI_CONFIG` | Path to `flapi.yaml` (fallback for `-c`) | `--config` fallback | +| `FLAPI_PORT` | HTTP server port (fallback for `-p` / `--port`); invalid values exit 1 | `--port` fallback | +| `FLAPI_HOST` | Bind address (fallback for `--host`) | `--host` fallback | | `FLAPI_LOG_LEVEL` | Log verbosity (fallback for `--log-level`); invalid values exit 1 | `--log-level` fallback | | `FLAPI_CONFIG_SERVICE_TOKEN` | Authentication token for configuration service API | `--config-service-token` fallback | | `FLAPI_NO_TELEMETRY` | Disable telemetry when set to `1`, `true`, or `yes` | `--no-telemetry` fallback | diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index 55e3b50..fd8c3b1 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -151,6 +151,8 @@ should not bake in. | Env var | Read at | Effect | Precedence | |---------|---------|--------|------------| | `FLAPI_CONFIG` | startup | Path to `flapi.yaml` (fallback for `-c`) | CLI > env > `flapi.yaml` default | +| `FLAPI_PORT` | startup | HTTP server port (fallback for `-p` / `--port`) | CLI > env > `http-port` config > `8080`; invalid values exit non-zero | +| `FLAPI_HOST` | startup | Bind address (fallback for `--host`) | CLI > env > `http-host` config > `0.0.0.0` | | `FLAPI_LOG_LEVEL` | startup | Log verbosity (fallback for `--log-level`) | CLI > env > `info` default; invalid values exit non-zero | | `FLAPI_CONFIG_SERVICE_TOKEN` | startup | Bearer token for the management API (fallback for `--config-service-token`) | CLI > env > auto-generate | | `FLAPI_NO_TELEMETRY` | startup | Disable PostHog telemetry (fallback for `--no-telemetry`) | CLI > env > config-file > enabled | @@ -178,7 +180,8 @@ The main configuration file defines global settings, connections, and server beh | `project-name` | string | - | Human-readable project name | | `project-description` | string | - | Project description | | `server-name` | string | `"localhost"` | Server hostname for generated URLs | -| `http-port` | integer | `8080` | HTTP server port | +| `http-port` | integer | `8080` | HTTP server port (overridable via `--port` / `FLAPI_PORT`) | +| `http-host` | string | `"0.0.0.0"` | Bind address (overridable via `--host` / `FLAPI_HOST`); use `127.0.0.1` to restrict to loopback | **Example:** @@ -186,6 +189,7 @@ The main configuration file defines global settings, connections, and server beh project-name: Customer API project-description: REST API for customer data access server-name: api.example.com +http-host: 0.0.0.0 http-port: 8080 ``` @@ -1808,6 +1812,7 @@ flAPI supports both hyphenated and camelCase naming for backward compatibility: | Configuration | Default Value | |---------------|---------------| | `http-port` | `8080` | +| `http-host` | `"0.0.0.0"` | | `server-name` | `"localhost"` | | `duckdb.db_path` | `:memory:` | | `duckdb.access_mode` | `READ_WRITE` | diff --git a/src/api_server.cpp b/src/api_server.cpp index e79d7b7..c420dac 100644 --- a/src/api_server.cpp +++ b/src/api_server.cpp @@ -283,19 +283,22 @@ void APIServer::run(int port) { } const auto& https = configManager->getHttpsConfig(); + const std::string bind_host = configManager->getHttpHost(); if (https.enabled) { - CROW_LOG_INFO << "HTTPS enabled: serving TLS on port " << configManager->getHttpPort(); + CROW_LOG_INFO << "HTTPS enabled: serving TLS on " << bind_host << ":" << configManager->getHttpPort(); CROW_LOG_DEBUG << " cert: " << https.ssl_cert_file; CROW_LOG_DEBUG << " key: " << https.ssl_key_file; - app.port(configManager->getHttpPort()) + app.bindaddr(bind_host) + .port(configManager->getHttpPort()) .server_name("flAPI") .multithreaded() .use_compression(crow::compression::GZIP) .ssl_file(https.ssl_cert_file, https.ssl_key_file) .run(); } else { - CROW_LOG_INFO << "Server starting on port " << configManager->getHttpPort() << "..."; - app.port(configManager->getHttpPort()) + CROW_LOG_INFO << "Server starting on " << bind_host << ":" << configManager->getHttpPort() << "..."; + app.bindaddr(bind_host) + .port(configManager->getHttpPort()) .server_name("flAPI") .multithreaded() .use_compression(crow::compression::GZIP) diff --git a/src/config_manager.cpp b/src/config_manager.cpp index 20b9b20..a64f55d 100644 --- a/src/config_manager.cpp +++ b/src/config_manager.cpp @@ -109,9 +109,11 @@ void ConfigManager::parseMainConfig() { project_description = safeGet(config, "project-description", "project-description"); server_name = safeGet(config, "server-name", "server-name", "localhost"); http_port = safeGet(config, "http-port", "http-port", 8080); + http_host = safeGet(config, "http-host", "http-host", "0.0.0.0"); CROW_LOG_DEBUG << "Project Name: " << project_name; CROW_LOG_DEBUG << "Server Name: " << server_name; + CROW_LOG_DEBUG << "HTTP Host: " << http_host; CROW_LOG_DEBUG << "HTTP Port: " << http_port; parseHttpsConfig(); @@ -1184,6 +1186,8 @@ std::string ConfigManager::getProjectDescription() const { return project_descri std::string ConfigManager::getServerName() const { return server_name; } int ConfigManager::getHttpPort() const { return http_port; } void ConfigManager::setHttpPort(int port) { http_port = port; } +std::string ConfigManager::getHttpHost() const { return http_host; } +void ConfigManager::setHttpHost(const std::string& host) { http_host = host; } std::string ConfigManager::getTemplatePath() const { return template_config.path; } std::filesystem::path ConfigManager::getFullTemplatePath() const { return std::filesystem::path(base_path) / template_config.path; } std::shared_ptr ConfigManager::getFileProvider() const { diff --git a/src/include/config_manager.hpp b/src/include/config_manager.hpp index fdb091d..f484dff 100644 --- a/src/include/config_manager.hpp +++ b/src/include/config_manager.hpp @@ -526,6 +526,8 @@ class ConfigManager { std::string getServerName() const; int getHttpPort() const; void setHttpPort(int port); + std::string getHttpHost() const; + void setHttpHost(const std::string& host); virtual std::string getTemplatePath() const; std::string getCacheSchema() const; const std::unordered_map& getConnections() const; @@ -606,6 +608,7 @@ class ConfigManager { std::string cache_schema = "flapi"; std::string server_name; int http_port = 8080; + std::string http_host = "0.0.0.0"; std::unordered_map connections; RateLimitConfig rate_limit_config; bool auth_enabled; diff --git a/src/main.cpp b/src/main.cpp index bba6c5b..5009b51 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -322,6 +323,10 @@ int main(int argc, char* argv[]) .default_value(-1) .scan<'i', int>(); + program.add_argument("--host") + .help("Bind address for the web server (e.g. 0.0.0.0, 127.0.0.1)") + .default_value(std::string("")); + program.add_argument("--log-level") .help("Set the log level (debug, info, warning, error)") .default_value(std::string("info")); @@ -439,13 +444,16 @@ int main(int argc, char* argv[]) std::string config_file = program.get("--config"); int cmd_port = program.get("--port"); + std::string cmd_host = program.get("--host"); std::string log_level = program.get("--log-level"); bool validate_config = program.get("--validate-config"); - // 12-factor env-var fallback (#47). Precedence: - // CLI flag > env var > built-in default. + // 12-factor env-var fallback (#47, #63). Precedence: + // CLI flag > env var > config file > built-in default. // CLI wins because we only consult the env when the user didn't - // pass the flag. + // pass the flag; config-file values are applied later in + // initializeConfig() and only kick in when neither CLI nor env + // provided a value. if (!program.is_used("--config")) { if (const char* env = std::getenv("FLAPI_CONFIG"); env != nullptr && *env != '\0') { config_file = env; @@ -456,6 +464,29 @@ int main(int argc, char* argv[]) log_level = env; } } + if (!program.is_used("--port")) { + if (const char* env = std::getenv("FLAPI_PORT"); env != nullptr && *env != '\0') { + // Reject non-int / out-of-range early so a typo doesn't + // silently fall through to the config-file value. + try { + size_t consumed = 0; + const int parsed = std::stoi(env, &consumed); + if (consumed != std::strlen(env) || parsed < 1 || parsed > 65535) { + throw std::invalid_argument("out of range"); + } + cmd_port = parsed; + } catch (const std::exception&) { + std::cerr << "flapi: invalid FLAPI_PORT '" << env + << "'; must be an integer in 1..65535\n"; + return 1; + } + } + } + if (!program.is_used("--host")) { + if (const char* env = std::getenv("FLAPI_HOST"); env != nullptr && *env != '\0') { + cmd_host = env; + } + } // Validate log_level. Invalid values are an error, not a silent // fallback -- typos like FLAPI_LOG_LEVEL=DEBUG should surface // immediately, not run the server at the wrong verbosity. @@ -519,6 +550,9 @@ int main(int argc, char* argv[]) if (cmd_port != -1) { config_manager->setHttpPort(cmd_port); } + if (!cmd_host.empty()) { + config_manager->setHttpHost(cmd_host); + } initializeDatabase(config_manager); diff --git a/test/integration/test_env_overrides.py b/test/integration/test_env_overrides.py index 388f8fe..682d51b 100644 --- a/test/integration/test_env_overrides.py +++ b/test/integration/test_env_overrides.py @@ -1,20 +1,25 @@ -"""12-factor env-var precedence tests (issue #47). +"""12-factor env-var precedence tests (issues #47, #63). -Verifies that `FLAPI_CONFIG` and `FLAPI_LOG_LEVEL` work as documented: - CLI flag > env var > built-in default. -Plus: invalid `FLAPI_LOG_LEVEL` values are rejected with a clear -single-line error, not silently coerced. +Verifies that `FLAPI_CONFIG`, `FLAPI_LOG_LEVEL`, `FLAPI_PORT` and +`FLAPI_HOST` all work as documented: + CLI flag > env var > config file > built-in default. +Plus: invalid `FLAPI_LOG_LEVEL` and `FLAPI_PORT` values are rejected +with a clear single-line error, not silently coerced. -These tests build a tiny fixture config and invoke `flapi --validate-config` -as a subprocess -- no HTTP server lifecycle needed. +Most tests build a tiny fixture config and invoke `flapi --validate-config` +as a subprocess -- no HTTP server lifecycle needed. The FLAPI_PORT / +FLAPI_HOST bind tests actually start the server and observe the listening +socket, since `--validate-config` exits before binding. """ from __future__ import annotations import os import pathlib +import socket import subprocess import sys +import time import pytest @@ -147,3 +152,133 @@ def test_default_config_path_unchanged_when_no_env(tmp_path: pathlib.Path, monke f"default flapi.yaml lookup failed: " f"stdout={res.stdout} stderr={res.stderr}" ) + + +# --- FLAPI_PORT / FLAPI_HOST (issue #63) -------------------------------------- + +def _pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_for_bind(host: str, port: int, timeout_s: float = 10.0) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + try: + s.connect((host, port)) + return True + except OSError: + time.sleep(0.1) + return False + + +def _start_server(config: pathlib.Path, env_overrides: dict, extra_args=None): + env = {k: v for k, v in os.environ.items() if not k.startswith("FLAPI_")} + env.update(env_overrides) + args = [str(_flapi()), "-c", str(config)] + (extra_args or []) + return subprocess.Popen( + args, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + +@pytest.mark.parametrize("bogus", ["abc", "0", "99999", "-1"]) +def test_invalid_FLAPI_PORT_is_rejected(tmp_path: pathlib.Path, bogus: str): + config = _write_minimal_config(tmp_path) + res = _run( + [str(_flapi()), "--validate-config", "-c", str(config)], + env_overrides={"FLAPI_PORT": bogus}, + ) + assert res.returncode == 1 + combined = (res.stderr + res.stdout).lower() + assert "invalid flapi_port" in combined + assert bogus.lower() in combined + + +def test_FLAPI_PORT_used_when_no_port_flag(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + port = _pick_free_port() + proc = _start_server(config, {"FLAPI_PORT": str(port)}) + try: + assert _wait_for_bind("127.0.0.1", port), ( + f"FLAPI_PORT={port} did not result in a listening socket; " + f"server output: {(proc.stdout.read() if proc.stdout else '')[:500]}" + ) + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def test_CLI_port_wins_over_FLAPI_PORT(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + cli_port = _pick_free_port() + env_port = _pick_free_port() + while env_port == cli_port: + env_port = _pick_free_port() + proc = _start_server( + config, + {"FLAPI_PORT": str(env_port)}, + extra_args=["--port", str(cli_port)], + ) + try: + assert _wait_for_bind("127.0.0.1", cli_port), ( + f"--port {cli_port} did not bind despite being given on the CLI" + ) + # And the env-supplied port must NOT be listening. + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + assert s.connect_ex(("127.0.0.1", env_port)) != 0, ( + f"FLAPI_PORT={env_port} bound despite CLI override; " + f"CLI precedence broken" + ) + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def test_FLAPI_HOST_used_when_no_host_flag(tmp_path: pathlib.Path): + config = _write_minimal_config(tmp_path) + port = _pick_free_port() + proc = _start_server( + config, + {"FLAPI_PORT": str(port), "FLAPI_HOST": "127.0.0.1"}, + ) + try: + assert _wait_for_bind("127.0.0.1", port), ( + "server did not bind on 127.0.0.1 as FLAPI_HOST requested" + ) + # External-iface check: binding 127.0.0.1 should NOT make the + # port reachable from a non-loopback address. Skip if no + # non-loopback iface available (e.g. CI sandboxes). + try: + hostname_ip = socket.gethostbyname(socket.gethostname()) + except OSError: + hostname_ip = "127.0.0.1" + if hostname_ip != "127.0.0.1": + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + assert s.connect_ex((hostname_ip, port)) != 0, ( + f"FLAPI_HOST=127.0.0.1 leaked onto {hostname_ip}; " + f"bindaddr not respected" + ) + finally: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5)