fix(api): bind the address from config, not a clap default
Some checks failed
deploy / deploy (push) Failing after 5m45s
Some checks failed
deploy / deploy (push) Failing after 5m45s
Run 6 started every unit cleanly and still failed, at the health probe. The journal says why: "tireless-api listening", addr: "127.0.0.1:23296" while the config it had just loaded says `bind = "0.0.0.0:23296"`. The `--bind` flag carried `default_value_t`, so it was always Some, so it always won — the config field I added was never read by anything. The failure mode is the interesting part: from bob the service looks perfect. It starts, logs "listening", and answers a local curl. Only a request from the proxy that actually fronts it fails, which is why the probe was moved off loopback in the first place. Make --bind an Option with no default and fall back to config.api.bind, and add a test asserting the shipped template does not bind loopback — with the reason, so that if ingress ever moves onto bob the test explains that the bind, the firewalld service and the vhost move together. Verified end to end on the real hosts: the API binds 0.0.0.0, the proxy reaches /v1/ready across the mesh, https://tireless.internal serves both the dashboard and the API, and the served certificate matches the one on disk by serial. Refs #9 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
@@ -10,17 +10,25 @@ use axum::{Json, Router, extract::State, http::StatusCode, routing::get};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Registered in `architecture/port-allocations.md`.
|
||||
const DEFAULT_PORT: u16 = 23296;
|
||||
// The bind address, port 23296 included, lives in `Config`'s `[api]` section
|
||||
// and its Default impl — one place, so a binary cannot disagree with the config
|
||||
// file it was handed. The port is registered in
|
||||
// `architecture/port-allocations.md`.
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "tireless-api", version)]
|
||||
struct Args {
|
||||
#[arg(long, default_value = "/etc/tireless/config.toml")]
|
||||
config: String,
|
||||
/// Bind address. Loopback by default — nginx on the same host fronts it.
|
||||
#[arg(long, env = "TIRELESS_API_BIND", default_value_t = format!("127.0.0.1:{DEFAULT_PORT}"))]
|
||||
bind: String,
|
||||
/// Bind address, overriding `[api] bind` in the config file.
|
||||
///
|
||||
/// Deliberately an `Option` with no default. A clap default would *always*
|
||||
/// be present and would therefore always win over the config file, which is
|
||||
/// how this silently bound loopback while `config.toml` asked for
|
||||
/// `0.0.0.0` — the API came up healthy on bob and was unreachable from the
|
||||
/// proxy that fronts it.
|
||||
#[arg(long, env = "TIRELESS_API_BIND")]
|
||||
bind: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -40,7 +48,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
database_configured: !config.database.host.is_empty(),
|
||||
});
|
||||
|
||||
let addr: SocketAddr = args.bind.parse().context("invalid bind address")?;
|
||||
// Config is the source of truth; the flag is an override for local runs.
|
||||
let bind = args.bind.clone().unwrap_or_else(|| config.api.bind.clone());
|
||||
let addr: SocketAddr = bind
|
||||
.parse()
|
||||
.with_context(|| format!("invalid bind address {bind:?}"))?;
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind {addr}"))?;
|
||||
|
||||
@@ -461,6 +461,23 @@ mod tests {
|
||||
assert_eq!(cc.window, expected.window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shipped_template_binds_an_address_the_proxy_can_reach() {
|
||||
// Ingress runs on the hanzalova proxy, not on bob (design.md §6.2), so a
|
||||
// loopback bind makes the API unreachable from the thing that fronts it.
|
||||
// That failure is invisible from the host: the service starts, logs
|
||||
// "listening", and answers a local curl perfectly well.
|
||||
//
|
||||
// If ingress is ever moved onto bob, this test is the reminder that the
|
||||
// bind, the firewalld service and the vhost move together.
|
||||
let c = Config::from_toml_str(&shipped_template()).expect("load");
|
||||
assert!(
|
||||
!c.api.bind.starts_with("127.") && !c.api.bind.starts_with("localhost"),
|
||||
"api.bind is {:?}, which the proxy on another host cannot reach",
|
||||
c.api.bind
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_pointing_opencode_at_anthropic_is_refused() {
|
||||
// Invariant 4. The whole point of putting this in validate() is that no
|
||||
|
||||
Reference in New Issue
Block a user