Skip to content

Commit 1f302f2

Browse files
author
Paul C
committed
Merge beta: v25.12.12 — a node with a real TLS certificate can call its own API again
2 parents d2bd132 + b1ff8ee commit 1f302f2

7 files changed

Lines changed: 167 additions & 49 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "25.12.11"
3+
version = "25.12.12"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/api/mod.rs

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10832,25 +10832,33 @@ pub async fn control_panel_inventory_node(
1083210832
}
1083310833

1083410834
async fn fetch_local_json(state: &web::Data<AppState>, path: &str) -> Option<serde_json::Value> {
10835-
// Local fan-out hits the node over HTTP on whichever plain port is
10836-
// actually listening — inter_node when TLS is on (the main port is
10837-
// HTTPS then), or api when TLS is off (the api port IS the plain
10838-
// HTTP listener). Using the wrong one silently fails because no
10839-
// listener is bound.
10840-
let ports = crate::ports::PortConfig::load();
10841-
let port = if state.tls_enabled { ports.inter_node } else { ports.api };
10835+
// Try the loopback candidates in order rather than picking one port up
10836+
// front. The previous version assumed that "TLS is on" implies the plain
10837+
// inter-node listener exists, but a node with a CA-signed certificate
10838+
// never binds it (`cert_is_self_signed` in main.rs), so on those nodes
10839+
// every self-fetch failed — the node silently contributed nothing to its
10840+
// own inventory. See `ports::self_api_urls`.
1084210841
let client = &*API_HTTP_CLIENT;
10843-
let url = format!("http://127.0.0.1:{}{}", port, path);
10844-
let resp = client.get(&url)
10845-
.timeout(std::time::Duration::from_secs(5))
10846-
.header("X-WolfStack-Secret", state.cluster_secret.clone())
10847-
.send().await.ok()?;
10848-
if !resp.status().is_success() {
10849-
// Drain error body → socket returns to keep-alive pool.
10842+
for url in crate::ports::self_api_urls(path) {
10843+
let Ok(resp) = client.get(&url)
10844+
.timeout(std::time::Duration::from_secs(5))
10845+
.header("X-WolfStack-Secret", state.cluster_secret.clone())
10846+
.send().await
10847+
else {
10848+
continue; // wrong scheme or nothing bound here — try the next
10849+
};
10850+
if resp.status().is_success() {
10851+
if let Ok(j) = resp.json::<serde_json::Value>().await { return Some(j); }
10852+
// json() failing still consumed the body.
10853+
return None;
10854+
}
10855+
// A bound listener that gave a definitive non-2xx answer is the right
10856+
// listener — a different port won't answer differently. Drain the body
10857+
// so the socket returns to the keep-alive pool, then stop.
1085010858
let _ = resp.bytes().await;
1085110859
return None;
1085210860
}
10853-
resp.json::<serde_json::Value>().await.ok()
10861+
None
1085410862
}
1085510863

1085610864
async fn fetch_remote_json(

src/ports.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,39 @@ fn default_api() -> u16 { 8553 }
4646
fn default_inter_node() -> u16 { 8554 }
4747
fn default_status() -> u16 { 8550 }
4848

49+
/// Loopback URLs for calling THIS node's own API, best candidate first.
50+
///
51+
/// The self equivalent of `api::build_node_urls`, and it exists for the same
52+
/// reason that function documents: **a node with a CA-signed certificate does
53+
/// not bind the plain-HTTP inter-node listener at all** (see the
54+
/// `cert_is_self_signed` guard in `main.rs`). Any self-call that goes straight
55+
/// to `http://127.0.0.1:{inter_node}` therefore hits a closed port on exactly
56+
/// the installs that are configured most correctly — it fails silently and
57+
/// forever, not intermittently, so it reads as "this feature is broken on this
58+
/// node" rather than as a connection bug.
59+
///
60+
/// Order:
61+
/// 1. HTTPS on the api port — the main listener whenever TLS is on, and the
62+
/// ONLY listener on a CA-signed install. Callers must use a client with
63+
/// `danger_accept_invalid_certs`, since a self-signed install serves its own
64+
/// cert here; the peer is 127.0.0.1, so validation buys nothing.
65+
/// 2. HTTP on the api port — `--no-tls` installs, where the api port IS the
66+
/// plain listener.
67+
/// 3. HTTP on the inter-node port — self-signed installs, which still bind it.
68+
///
69+
/// `path` is expected to start with `/`.
70+
pub fn self_api_urls(path: &str) -> Vec<String> {
71+
let cfg = PortConfig::load();
72+
let mut urls = Vec::with_capacity(3);
73+
urls.push(format!("https://127.0.0.1:{}{}", cfg.api, path));
74+
urls.push(format!("http://127.0.0.1:{}{}", cfg.api, path));
75+
// Only worth trying when it is a genuinely different port.
76+
if cfg.inter_node != cfg.api {
77+
urls.push(format!("http://127.0.0.1:{}{}", cfg.inter_node, path));
78+
}
79+
urls
80+
}
81+
4982
/// Reconcile a systemd-unit-baked `--port N` into a loaded [`PortConfig`].
5083
///
5184
/// Background: `setup.sh` historically wrote `--port $WS_PORT` into the
@@ -431,3 +464,40 @@ mod tests {
431464
assert_eq!(persisted.inter_node, 9001);
432465
}
433466
}
467+
468+
#[cfg(test)]
469+
mod self_api_url_tests {
470+
use super::*;
471+
472+
/// HTTPS on the api port MUST come first. A CA-signed install binds only
473+
/// that listener, so any ordering that reaches the inter-node port first
474+
/// re-creates the wolfstack-1 failure: self-calls to a closed port,
475+
/// reported as "unreachable", forever.
476+
#[test]
477+
fn https_on_the_api_port_is_tried_first() {
478+
let urls = self_api_urls("/api/containers/lxc");
479+
assert!(urls[0].starts_with("https://127.0.0.1:"), "got {:?}", urls);
480+
assert!(urls[0].ends_with("/api/containers/lxc"), "got {:?}", urls);
481+
}
482+
483+
/// The plain-HTTP fallbacks must still be present for --no-tls installs
484+
/// and for self-signed installs that do bind the inter-node listener.
485+
#[test]
486+
fn plain_http_fallbacks_follow() {
487+
let urls = self_api_urls("/x");
488+
assert!(urls.iter().any(|u| u.starts_with("http://")), "got {:?}", urls);
489+
// Every candidate targets loopback — never a routable address.
490+
assert!(urls.iter().all(|u| u.contains("127.0.0.1")), "got {:?}", urls);
491+
}
492+
493+
/// No duplicate candidate when a config collapses the two ports onto one
494+
/// value — retrying an identical URL just doubles the connect timeout.
495+
#[test]
496+
fn no_duplicate_candidate_when_ports_coincide() {
497+
let urls = self_api_urls("/x");
498+
let mut seen = urls.clone();
499+
seen.sort();
500+
seen.dedup();
501+
assert_eq!(seen.len(), urls.len(), "duplicate candidates in {:?}", urls);
502+
}
503+
}

src/wolfagents/dispatch.rs

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,27 +1465,39 @@ async fn tool_wolfstack_api(
14651465
// session). This means user-scoped endpoints that inspect the
14661466
// session username will see "cluster-node" instead, which is
14671467
// fine for observation and admin ops.
1468-
let port = crate::ports::PortConfig::load().api;
1469-
let url = format!("http://127.0.0.1:{}{}", port, path);
1468+
// Walk the loopback candidates rather than assuming plain HTTP on the api
1469+
// port: when TLS is on, that port serves HTTPS and a plain-HTTP request to
1470+
// it never connects. See `ports::self_api_urls`.
14701471
let client = &*DISPATCH_CLIENT;
1471-
let req_builder = match method.as_str() {
1472-
"GET" => client.get(&url),
1473-
"POST" => client.post(&url),
1474-
"PUT" => client.put(&url),
1475-
"PATCH" => client.patch(&url),
1476-
"DELETE" => client.delete(&url),
1477-
_ => unreachable!(),
1478-
};
1479-
let req_builder = req_builder
1480-
.timeout(Duration::from_secs(30))
1481-
.header("X-WolfStack-Secret", &state.cluster_secret);
1482-
let req_builder = if let Some(b) = body {
1483-
req_builder.header("Content-Type", "application/json").json(b)
1484-
} else {
1485-
req_builder
1486-
};
1472+
let mut last_err: Option<String> = None;
1473+
let mut response = None;
1474+
for url in crate::ports::self_api_urls(path) {
1475+
let req_builder = match method.as_str() {
1476+
"GET" => client.get(&url),
1477+
"POST" => client.post(&url),
1478+
"PUT" => client.put(&url),
1479+
"PATCH" => client.patch(&url),
1480+
"DELETE" => client.delete(&url),
1481+
_ => unreachable!(),
1482+
};
1483+
let req_builder = req_builder
1484+
.timeout(Duration::from_secs(30))
1485+
.header("X-WolfStack-Secret", &state.cluster_secret);
1486+
let req_builder = if let Some(b) = body {
1487+
req_builder.header("Content-Type", "application/json").json(b)
1488+
} else {
1489+
req_builder
1490+
};
1491+
match req_builder.send().await {
1492+
// Any answer from a bound listener is THE answer — every listener
1493+
// serves the same routes, so a different port won't reply
1494+
// differently. Only a transport failure is worth retrying.
1495+
Ok(resp) => { response = Some(resp); break; }
1496+
Err(e) => { last_err = Some(e.to_string()); }
1497+
}
1498+
}
14871499

1488-
match req_builder.send().await {
1500+
match response.ok_or_else(|| last_err.unwrap_or_else(|| "no loopback listener answered".to_string())) {
14891501
Ok(resp) => {
14901502
let status = resp.status().as_u16();
14911503
let text = resp.text().await.unwrap_or_default();

src/wolfhost/api/servers.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ pub fn wolfstack_client() -> reqwest::Client {
4141
.unwrap_or_default()
4242
}
4343

44-
/// Try HTTPS first (port 8553), then HTTP on same port, then HTTP on 8554
45-
/// Try HTTPS on 8553 first, then HTTP on 8554 (port+1)
44+
/// Loopback URLs for this node's own WolfStack API, best candidate first.
45+
///
46+
/// Delegates to `ports::self_api_urls` so a node with non-default ports works:
47+
/// this used to hardcode 8553/8554, which silently broke on any install whose
48+
/// `ports.json` moved them (the go2rtc/Frigate clashes that made the Node Ports
49+
/// panel necessary in the first place).
4650
pub fn wolfstack_urls(path: &str) -> Vec<String> {
47-
vec![
48-
format!("https://127.0.0.1:8553{}", path),
49-
format!("http://127.0.0.1:8554{}", path),
50-
]
51+
crate::ports::self_api_urls(path)
5152
}
5253

5354
pub async fn wolfstack_api(path: &str) -> Result<serde_json::Value, String> {

web/js/app.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80221,8 +80221,12 @@ async function galeraRemoveProxy(cid, container) {
8022180221
// ── Adopt from a container picker ────────────────────────────────────
8022280222

8022380223
let _galeraAdoptInv = [];
80224+
// Hosts whose inventory fetch failed, so the picker can say so instead of
80225+
// silently listing fewer hosts than the cluster actually has.
80226+
let _galeraAdoptFailures = [];
8022480227
function galeraAdoptOpen() {
8022580228
_galeraAdoptInv = [];
80229+
_galeraAdoptFailures = [];
8022680230
const html = `
8022780231
<div style="font-size:13px;display:grid;gap:10px;">
8022880232
<label>Cluster name (wsrep_cluster_name)
@@ -80261,14 +80265,27 @@ async function galeraAdoptLoadInventory() {
8026180265
try {
8026280266
const results = await Promise.all(hosts.map(h =>
8026380267
fetch('/api/control-panel/inventory/node/' + encodeURIComponent(h.id))
80264-
.then(r => (r.ok ? r.json() : { items: [] }))
80265-
.catch(() => ({ items: [] }))
80268+
.then(r => (r.ok ? r.json() : { items: [], errors: [{ kind: 'all', error: 'HTTP ' + r.status }] }))
80269+
.catch(e => ({ items: [], errors: [{ kind: 'all', error: (e && e.message) || String(e) }] }))
8026680270
));
8026780271
const inv = [];
80268-
results.forEach(d => (d.items || []).forEach(it => {
80269-
if (it.kind === 'lxc' || it.kind === 'docker') inv.push(it);
80270-
}));
80272+
// A host whose inventory failed used to contribute nothing and say
80273+
// nothing, so the picker just showed fewer hosts than the cluster has
80274+
// and the operator had no way to tell a broken host from an empty one.
80275+
const failures = [];
80276+
results.forEach((d, i) => {
80277+
(d.items || []).forEach(it => {
80278+
if (it.kind === 'lxc' || it.kind === 'docker') inv.push(it);
80279+
});
80280+
const errs = (d.errors || []).filter(e => e.kind !== 'share');
80281+
if (errs.length) {
80282+
const host = hosts[i];
80283+
failures.push(((host && (host.hostname || host.id)) || 'unknown host')
80284+
+ ': ' + errs.map(e => (e.kind || '?') + ' — ' + (e.error || 'failed')).join('; '));
80285+
}
80286+
});
8027180287
_galeraAdoptInv = inv;
80288+
_galeraAdoptFailures = failures;
8027280289
galeraAdoptRenderPicker();
8027380290
} catch (e) {
8027480291
if (picker) picker.innerHTML = `<div role="alert" style="color:var(--danger);font-size:12px;padding:10px;">Couldn't load containers: ${escapeHtml((e && e.message) || String(e))}</div>`;
@@ -80278,13 +80295,23 @@ async function galeraAdoptLoadInventory() {
8027880295
function galeraAdoptRenderPicker() {
8027980296
const picker = document.getElementById('ga-picker');
8028080297
if (!picker) return;
80298+
// Failed hosts are announced whether or not any containers were found —
80299+
// "2 of your 3 hosts answered" is the single most useful thing to know
80300+
// here, and its absence is what made a missing host look like a host with
80301+
// nothing on it.
80302+
const warn = _galeraAdoptFailures.length
80303+
? `<div role="alert" style="color:var(--warning,#f59e0b);font-size:11px;padding:8px;margin-bottom:6px;border:1px solid var(--warning,#f59e0b);border-radius:6px;line-height:1.5;">
80304+
⚠ Couldn't list containers on ${_galeraAdoptFailures.length} host(s) — their containers are missing from this list:<br>
80305+
${_galeraAdoptFailures.map(f => escapeHtml(f)).join('<br>')}
80306+
</div>`
80307+
: '';
8028180308
if (!_galeraAdoptInv.length) {
80282-
picker.innerHTML = `<div style="color:var(--text-muted);font-size:12px;padding:10px;">No LXC or Docker containers found in this cluster.</div>`;
80309+
picker.innerHTML = warn + `<div style="color:var(--text-muted);font-size:12px;padding:10px;">No LXC or Docker containers found in this cluster.</div>`;
8028380310
return;
8028480311
}
8028580312
const byHost = {};
8028680313
_galeraAdoptInv.forEach((it, i) => { (byHost[it.node_id] = byHost[it.node_id] || []).push({ it, i }); });
80287-
let html = '';
80314+
let html = warn;
8028880315
Object.keys(byHost).forEach(hostId => {
8028980316
html += `<div style="font-size:11px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px;margin:6px 0 4px;">${escapeHtml(galeraHostName(hostId))}</div>`;
8029080317
byHost[hostId].forEach(({ it, i }) => {

0 commit comments

Comments
 (0)