@@ -46,6 +46,39 @@ fn default_api() -> u16 { 8553 }
4646fn default_inter_node ( ) -> u16 { 8554 }
4747fn 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+ }
0 commit comments