Skip to content

Resolve API hostnames before dialing - #553

Open
Ticed wants to merge 1 commit into
infernode-os:masterfrom
Ticed:feat/llmclient-resolve-hostnames
Open

Resolve API hostnames before dialing#553
Ticed wants to merge 1 commit into
infernode-os:masterfrom
Ticed:feat/llmclient-resolve-hostnames

Conversation

@Ticed

@Ticed Ticed commented Aug 25, 2026

Copy link
Copy Markdown

What this changes

llmclient built every dial string as "tcp!" + host + "!" + port and handed
it to sys->dial. devip only accepts a literal address, and this build ships
no connection server, so any endpoint configured by name fails before a
connection is attempted. Upstream's own tcp_test shows it:

=== RUN   TcpDialHostname
--- SKIP: TcpDialHostname (0.00s)
    network unavailable or DNS failed: invalid IP address (connect google.com!80 )

That is not an offline machine — TcpDialIp passes in the same run. It is
dial refusing a name.

The six dial sites now go through dialaddr(), which resolves the name with
Srv->iph2a first. $Srv is a hosted builtin and absent on native builds, so
the module is loaded optionally and dialaddr falls back to the literal host
when it is nil — a native build behaves exactly as before. IPv4 is preferred
because the dial string is !-separated and an IPv6 literal is ambiguous in
that form. Same approach tlsperf already takes.

Evidence

sys->dial rejects every name, not just an unreachable one. Dialing four
addresses from inside the emulator on this machine:

tcp!127.0.0.1!80    -> FAIL: Connection refused      (dial worked; nothing listening)
tcp!localhost!80    -> FAIL: invalid IP address
tcp!google.com!80   -> FAIL: invalid IP address
tcp!example.com!80  -> FAIL: invalid IP address

127.0.0.1 gets a real TCP answer, so the stack is up. localhost fails
identically to the others, so this is not DNS and not reachability — dial
will not take a name.

The resolver path works, and shows why IPv4 is preferred:

localhost      -> ::1               dial ::1!80  -> Connection refused (reached)
google.com     -> 142.250.191.14    dial         -> ok
example.com    -> 104.20.23.154     dial         -> ok

iph2a returns the IPv6 address first for localhost, and an IPv6 literal is
ambiguous inside a !-separated dial string.

Test

No new test. Resolution needs a live resolver, so a hermetic one would have to
stub Srv, and dialaddr is internal. What is checked:

  • the three suites that exercise this module are green against rebuilt
    bytecode — llmclient_reqshape 4, llmclient_think_gating 5, llmsrv 62
  • the fallback path is the pre-change behaviour, byte for byte

Worth a separate look

TcpDialHostname reports this failure as a skip, under "network unavailable
or DNS failed". A build that cannot dial a name at all is indistinguishable
from an offline test machine, so the limitation stays invisible. Splitting
those two cases would make it visible, but that is a change to his test rather
than to the client, so it is not in this PR.

@pdfinn

pdfinn commented Aug 31, 2026

Copy link
Copy Markdown
Member

Heads-up on merging this one, and it is my doing rather than yours.

This branch predates #560, which stopped tracking compiled bytecode. It has a
.dis file in its diff, so merging hits:

CONFLICT (modify/delete): <path>.dis deleted in HEAD and modified in <branch>.
Version <branch> of <path>.dis left in tree.

Git leaves the file sitting in the working tree, so resolving with git add .
would quietly re-commit bytecode that master deliberately removed. The
resolution is to delete it:

git rm <path>.dis

Or just rebase onto current master, where the file no longer exists and the
conflict does not arise.

Nothing else in the PR is affected — dis/ is a build product now, rebuilt
with:

for d in appl appl/mpeg appl/veltro tests; do (cd $d && mk install); done

hooks/post-merge does that automatically after a pull if you have run
./hooks/install.sh.

Sorry for the friction — five open PRs are in this position because of the
timing.

@pdfinn pdfinn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnosis confirmed. I dialled both forms from a throwaway module on master
rather than trusting the test's skip message:

tcp!1.1.1.1!80           ok=0
tcp!one.one.one.one!80   ok=-1  invalid IP address (connect one.one.one.one!80 )

So it is exactly as you describe — dial refuses the name, and the machine is
online. TcpDialHostname skipping was hiding a real defect behind an
"offline?" message.

The approach is right: optional Srv load with a literal fallback means native
builds behave as before, and dialaddr degrading to the old string when
iph2a returns nothing is the correct failure mode. Preferring IPv4 because
the dial string is !-separated is a good reason, well stated.

One thing to confirm before this lands

llmclient.b does not consult publicnet. appl/lib/webclient.b and
appl/veltro/tools/webfetch.b do — that is where the SSRF blocklist lives.

Today that gap is narrow, because dial only accepts a literal address, so the
reachable set is whatever an operator typed as an IP. This change widens it to
anything DNS resolves, which is the point of the PR but also a real change in
reach for a module that has no blocklist in front of it.

The question is whether baseurl is ever agent-influenced. It is a parameter
defaulting to http://localhost:11434/v1, and nsconstruct.b:311 is explicit
that /mnt/llm is granted by capability rather than by existence — so on the
face of it this is operator configuration and there is no issue. But I could
not rule out a write path through llmsrv's ctl surface, and if one exists (or
is added later), a prompt-injected agent pointing the client at an internal
address is precisely what publicnet was written to stop.

If it is operator-only, say so in the PR and I am happy. If it is not, this
should route through the same check webfetch uses.

Smaller notes

dialaddr resolves unconditionally, including when host is already a literal
IP — iph2a("127.0.0.1") presumably returns it unchanged, but that is a DNS
round trip on the common Ollama-at-localhost path. A cheap "does it contain
only digits and dots, or a colon" check up front would skip it. Not
correctness, just latency on the hot path.

The IPv4 scan sets isv4 = 1 then clears it on a colon, which reads slightly
backwards — contains(a, ':') would say it more directly if agentlib is
already imported here. Cosmetic.

Also needs the dis/lib/llmclient.dis conflict resolved by deletion rather
than by keeping the branch's copy — see my other comment.

pdfinn
pdfinn previously approved these changes Aug 31, 2026

@pdfinn pdfinn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Closing my own question — I could determine this from the tree and should not
have put it to you.

apiurl has exactly two assignments in appl/cmd/llmsrv.b:

245:	apiurl = "";
257:		'u' => apiurl = arg->earg();

Init, and the -u command-line flag. There is no runtime write path. The
Qctl write handler accepts reset, close and autocompact <n> and
rejects everything else with "unknown command"; Qmodel sets sess.model
through resolvemodel(), not a URL.

So the endpoint is operator configuration fixed at process start, and this
change does not widen anything an agent can reach. It widens what an
operator's own -u can express, which is the entire point. publicnet does
not belong in this path — it guards agent-supplied URLs in webclient and
webfetch, which is a different trust context.

The engineering justification stands on its own: hostname dial is broken, I
reproduced it, and every hosted LLM API is addressed by name.

Approving. The two smaller notes — skipping resolution when host is already
a literal, and the inverted isv4 scan — are both optional.

Still needs the dis/lib/llmclient.dis conflict resolved by deleting the file
rather than keeping the branch's copy.

@Ticed
Ticed force-pushed the feat/llmclient-resolve-hostnames branch from 4811968 to 6129379 Compare August 31, 2026 11:47
@Ticed

Ticed commented Aug 31, 2026

Copy link
Copy Markdown
Author

Rebased onto current master; dis/lib/llmclient.dis resolved by deletion, not
by keeping the branch's copy.

I had this reviewed here before pushing, including re-deriving your own
apiurl conclusion rather than taking it. It holds: two assignments in
appl/cmd/llmsrv.b, init and the -u flag, and Qctl rejects everything
outside reset, close and autocompact. No runtime write path, so nothing
agent-influenced reaches the dial and publicnet does not belong here.

The reviewer also confirmed the change does what it claims, with a probe rather
than by reading: pre-fix, a raw dial of a hostname fails with invalid IP address; dialaddr resolves localhost to [::1, 127.0.0.1] and picks the
v4 address.

One finding worth having, since it is a gap rather than a defect: no test in the
tree can go red against this change. Every llmclient test dials a literal
address, and TcpDialHostname bypasses llmclient entirely. A hostname-based
mockserver test would fix that and I would rather offer it separately than
enlarge this PR.

Your two smaller notes I would leave. iph2a on a literal returns in-process
without a DNS round trip, so the early-out saves nothing measurable; and the
isv4 scan, odd as it reads, provably selects the right address.

@Ticed

Ticed commented Aug 31, 2026

Copy link
Copy Markdown
Author

The force-push dismissed your approval, sorry. The delta from the commit you approved (4811968c) to the tip (61293796) is:

appl/lib/llmclient.b    unchanged, byte-identical
dis/lib/llmclient.dis   deleted

Nothing but the .dis deletion you required. No source changed.

@Ticed
Ticed force-pushed the feat/llmclient-resolve-hostnames branch from 6129379 to c476515 Compare September 2, 2026 03:14
@Ticed

Ticed commented Sep 2, 2026

Copy link
Copy Markdown
Author

Rebased onto current master (df34b02). Force-pushed 6129379 -> c476515.

git range-diff reports every commit unchanged — same content, same messages,
only the base moved. The five commits that landed underneath are #583, #582,
#566, #578 and #580; none of them touches this branch's files.

Re-verified on macOS after the rebase.

llmclient_reqshape and llmclient_think_gating pass. llmclient_sse_fallback
still fails three of its cases, which is the known pre-existing failure on
master and is what #552 fixes — its NonStreamingBaseline case, the one that
actually dials through the new dialaddr() path, passes here.

This branch adds no test of its own: dialaddr() is private and the commit does
not introduce one. Saying so rather than implying coverage that is not there.

#578 fixed the ClusterFuzzLite link break, so Fuzz should now be green here
rather than red for a reason that was never this branch's.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants