fix(api): bind the address from config, not a clap default
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:
rob thijssen
2026-08-07 16:52:09 +03:00
parent a5efa1e096
commit 42582cb922
2 changed files with 35 additions and 6 deletions

View File

@@ -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}"))?;

View File

@@ -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