Files
netbird/management/internals/modules/agentnetwork/modeldiscovery/failure.go
T
mlsmaycon 5720c33be9 [management] Fix what a second review found in the credential check
Four defects and two pieces of wording, from cubic's pass over the branch.

A provider that asks the proxy to skip TLS verification was checked with a
client that verifies it, so a self-hosted endpoint behind a self-signed
certificate was refused for the one reason its operator had already declared
they accept. Those records now save unchecked, alongside the other cases this
cannot speak for. Sending the credential over a connection management declines
to verify was the other way out, and a worse one.

A key pasted with surrounding whitespace passed its check and then failed every
request: the vendor call trims before building the auth header, the synthesiser
substitutes the stored value verbatim. The key is now stored in the form it
will be sent, so what was checked is what runs.

The proxy-guard test resolved api.openai.com for real before reaching its own
transport, and on a runner with no egress that lookup failed as an
UnreachableError too — so it passed while never exercising the socket guard it
is named for.

A DNS timeout said only that the lookup failed. It now says so as a timeout,
without borrowing the wording of a connection that was never attempted.

The create description promised more than the check delivers: for Bedrock the
runtime host is resolved but never contacted, so a public host that does not
answer is still stored. It says that now, and names the TLS exemption above.
The invalid-upstream message no longer quotes the URL back, which was the one
path in this feature that echoed what the operator typed.

The live suite's single-vendor scope and its dependence on vendor availability
are now stated where the tests are, rather than left to be rediscovered.
2026-08-27 08:15:06 +00:00

115 lines
4.0 KiB
Go

package modeldiscovery
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"os"
"syscall"
)
// Fetch serves two callers with different needs: the model picker, which only
// needs to know it failed, and the provider credential check, which has to
// tell an operator whether the URL or the key is at fault. Each failure
// carries a type so the second does not have to branch on a message.
// VendorStatusError reports a listing answered with something other than 200.
// Only the vendor's own code separates a refused credential (401, 403) from a
// URL that does not serve this API (404, 405) from an unwell vendor (5xx).
type VendorStatusError struct {
Provider string
Status int
}
func (e *VendorStatusError) Error() string {
return fmt.Sprintf("%s returned %d for its model listing", e.Provider, e.Status)
}
// UnreachableError reports that the request never reached the vendor: the
// name did not resolve, the connection was refused, TLS failed, or it timed
// out. Nothing was authenticated, so only the URL is implicated.
type UnreachableError struct {
Provider string
Err error
}
func (e *UnreachableError) Error() string {
return fmt.Sprintf("reach %s: %v", e.Provider, e.Err)
}
func (e *UnreachableError) Unwrap() error { return e.Err }
// Reason names the transport failure in words an operator can act on: a wrong
// port and a wrong hostname fail differently and are worth telling apart.
// Empty means unrecognised, and the caller should say only that the host could
// not be reached rather than paste a Go error into the UI.
func (e *UnreachableError) Reason() string {
err := e.Err
var dns *net.DNSError
if errors.As(err, &dns) {
if dns.IsNotFound {
return "no such host"
}
// Named apart from the dial timeout below. A resolver that never
// answered and an upstream that never answered send an operator to
// different places, and the generic "connection timed out" would
// describe a connection that was never attempted.
if dns.IsTimeout {
return "dns lookup timed out"
}
return "dns lookup failed"
}
// Timeouts are checked before the syscall cases: a dial that times out is
// reported as a net.OpError wrapping a timeout, and the operator needs to
// hear "timed out" rather than the syscall underneath it.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) {
return "connection timed out"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "connection timed out"
}
if errors.Is(err, syscall.ECONNREFUSED) {
return "connection refused"
}
if errors.Is(err, syscall.EHOSTUNREACH) || errors.Is(err, syscall.ENETUNREACH) {
return "host unreachable"
}
var certErr *tls.CertificateVerificationError
if errors.As(err, &certErr) {
return "tls certificate not trusted"
}
var recordErr tls.RecordHeaderError
if errors.As(err, &recordErr) {
return "not a tls endpoint"
}
return ""
}
// ErrUnparseableListing marks a 200 whose body is not a listing in the shape
// the catalog declared. Distinct from a status refusal: the host answered and
// authenticated fine, it is just not the API — a login page, say.
var ErrUnparseableListing = errors.New("response is not a model listing")
// ErrNoDiscoveryHost marks a provider whose listing host cannot be derived
// from the record: Bedrock's control-plane host comes from the region in the
// upstream, so a proxied endpoint leaves nowhere to send it, and inventing one
// would spend the credential somewhere never configured.
//
// Wraps ErrInvalidRequest so the discovery endpoint still answers 400, while a
// credential check can read it as "cannot be checked" rather than "broken".
var ErrNoDiscoveryHost = errors.New("provider has no derivable discovery host")
// ErrPrivateHost marks an upstream resolving somewhere management will not
// dial. A self-hosted endpoint on a private network is a legitimate provider
// the proxy reaches through the tunnel, so this means the check cannot run,
// not that the record is wrong.
var ErrPrivateHost = errors.New("discovery host is not publicly routable")