Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions cmd/firn/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/spf13/cobra"

"github.com/frostyard/firn/internal/bootcimg"
"github.com/frostyard/firn/internal/pipeline"
"github.com/frostyard/firn/internal/progress"
"github.com/frostyard/firn/internal/recipe"
Expand Down Expand Up @@ -64,9 +65,10 @@ func newInstallCmd() *cobra.Command {
}

env := &pipeline.Env{
Machine: recipe.Env{ZoneinfoDir: "/usr/share/zoneinfo"},
Runner: runner.New(),
Version: Version,
Machine: recipe.Env{ZoneinfoDir: "/usr/share/zoneinfo"},
Runner: runner.New(),
Version: Version,
RegistryProbe: bootcimg.HTTPRegistryProbe,
}
var err error
if env.Machine.SecureBoot, env.Machine.TPM, env.UEFI, err = probes.resolve(); err != nil {
Expand Down
20 changes: 20 additions & 0 deletions cmd/firn/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ func TestInstallV1WarnsBeforeFirstStep(t *testing.T) {
}
}

func TestInstallWiresRegistryProbe(t *testing.T) {
old := assembleInstall
t.Cleanup(func() { assembleInstall = old })
var wired bool
assembleInstall = func(*recipe.Loaded) *pipeline.Pipeline {
return &pipeline.Pipeline{Steps: []pipeline.Step{{Name: "fake", Preflight: true, Run: func(_ context.Context, env *pipeline.Env) error {
wired = env.RegistryProbe != nil
return nil
}}}}
}
cmd := newInstallCmd()
cmd.SetArgs([]string{"--secure-boot=off", "--tpm=off", "--uefi=on", "--dry-run", commandRecipeFile(t, "bootc")})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if !wired {
t.Fatal("install env has no registry probe wired")
}
}

func mustLoadRecipe(t *testing.T, path string) *recipe.Loaded {
t.Helper()
l, err := recipe.Load(path)
Expand Down
8 changes: 5 additions & 3 deletions cmd/firn/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"path/filepath"
"syscall"

"github.com/frostyard/firn/internal/bootcimg"
"github.com/frostyard/firn/internal/pipeline"
"github.com/frostyard/firn/internal/platform"
"github.com/frostyard/firn/internal/progress"
Expand Down Expand Up @@ -92,9 +93,10 @@ func runTUIWithRuntime(parent context.Context, o tuiOptions, rt tuiRuntime) (ret
defer stop()

env := &pipeline.Env{
Machine: recipe.Env{ZoneinfoDir: "/usr/share/zoneinfo"},
Runner: runner.New(),
Version: Version,
Machine: recipe.Env{ZoneinfoDir: "/usr/share/zoneinfo"},
Runner: runner.New(),
Version: Version,
RegistryProbe: bootcimg.HTTPRegistryProbe,
Comment thread
bketelsen marked this conversation as resolved.
}
var err error
if env.Machine.SecureBoot, err = tristate(o.secureBoot, platform.SecureBoot); err != nil {
Expand Down
3 changes: 3 additions & 0 deletions cmd/firn/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ func TestRunTUIPropagatesInstallResults(t *testing.T) {
return filepath.Join(dir, "recipe.toml"), loaded, nil
},
runInstall: func(_ context.Context, env *pipeline.Env, got *recipe.Loaded) (tui.InstallResult, error) {
if env.RegistryProbe == nil {
t.Fatal("TUI install env has no registry probe wired")
}
if got != loaded || env.Recipe != &loaded.Recipe {
t.Fatalf("engine bridge received env=%+v recipe=%p", env, got)
}
Expand Down
17 changes: 17 additions & 0 deletions docs/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ For bootc recipes that set `image.cosign_pub_key`, preflight selects the
same embedded-or-remote source the install will consume, resolves it to an
immutable digest, verifies that digest with cosign, and carries the pinned
reference into both native-bootc and podman installation paths.
When the image cannot be resolved (or, for a signed recipe, its signature cannot
be verified) with a failure whose text positively shows connectivity (DNS, dial,
timeout), preflight probes the registry with an HTTPS request to its `/v2/`
endpoint through the environment's proxy settings. A probe that finds no route
to a server (DNS or dial failure, timeout, or a proxy answering 502/503/504)
turns the error into `registry_unreachable`, naming the registry and the cause
and saying the network or the registry may be responsible. If that attempt
failed on a machine with no routable address (no non-loopback interface up with
a global unicast address) it is `network_unreachable` instead; the interface
check only words a failure, it never runs ahead of the attempt. Both keep the
original failure attached. Ambiguous outcomes keep the original error and code:
a TLS trust, alert or protocol failure, a proxy demanding credentials, a
registry-level skopeo response, a cosign verdict, or an error with no
connectivity marker. The probe is supplemental evidence, never a gate or a
pre-check: skopeo's outcome stays authoritative, a cached image still installs
offline, and caller cancellation is never reported as a network failure. It is
injected through `pipeline.Env.RegistryProbe`; nil disables it.

### The bootc path

Expand Down
2 changes: 2 additions & 0 deletions docs/specs/progress-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ version-1 wire contract.
| `group_missing` | `warning`, `summary` | A requested supplementary user group was absent from the installed image. |
| `image_verification_failed` | `error` | Bootc image digest resolution or cosign verification failed in `preflight-image`; no destructive step has run. |
| `image_verification_retried` | `warning` | A cosign verification attempt in `preflight-image` failed (transient registry responses can do this) and a bounded retry follows; verification is not weakened, and the run still fails closed with `image_verification_failed` if the final attempt fails. |
| `network_unreachable` | `error` | `preflight-image` could not resolve or verify the image, the diagnostic probe of its registry failed, and the machine has no routable network address (no non-loopback interface is up with a global unicast address). Takes precedence over `registry_unreachable`. No destructive step has run. |
| `no_core_set` | `warning`, `summary` | Core Flatpaks were requested but the selected image publishes no core set (no label, or an empty one); emitted in `preflight-image` and the install continues. |
| `no_tpm` | `warning` | No TPM was detected; only choices that do not require it remain valid. |
| `recipe_v1_deprecated` | `warning` | Accepted bootc version-1 recipe; message is the `deprecated-version` text in the [recipe schema](recipe-schema.md#bootc-version-1-compatibility). Emitted by install and the TUI before the first `step_start`. `firn validate` writes the deprecation to stderr instead of emitting progress and exits 0. |
| `registry_unreachable` | `error` | `preflight-image` could not resolve or verify the image with a failure that positively looks like connectivity (DNS, dial, timeout), and a diagnostic probe of its registry (HTTPS `/v2/`, through the environment's proxy settings) also found no route to a server: a DNS or dial failure, a timeout, or a proxy answering 502/503/504. The message names the registry and the probe's cause, says the network or the registry may be responsible, and carries the original failure. Ambiguous failures keep their original code: TLS trust, alert and protocol failures and proxy 407/403 (a server or proxy answered), registry-level responses (`manifest unknown`, `unauthorized`, `denied`, rate limits), cosign verdicts (`no matching signatures`, ...), and anything without a connectivity marker (`step_failed`, or `image_verification_failed` for a signed recipe). `registries.conf` mirrors are not consulted. No destructive step has run. |
| `step_failed` | `error` | A pipeline step failed without a more specific stable code. |
| `store_cleanup_failed` | `warning` | Cleanup of the redirected bootc container-image store failed. |
| `store_umount_failed` | `warning` | Unmounting the redirected bootc container-image store failed. |
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/recipe-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Top level:
| `family` | string | yes | MUST be `"bootc"`. Never inferred. |
| `ref` | string | yes | OCI image reference. |
| `target_ref` | string | no | Post-install upgrade ref; defaults to `ref`. |
| `cosign_pub_key` | string (path) | no | Enables independent cosign verification of a registry `ref`. Before any destructive step, Firn selects the source it would install (preferring a valid embedded containers-storage image), resolves it to an immutable `sha256` digest, runs `cosign verify --key` against that digest, and installs that same digest. Verification failure emits `image_verification_failed`. |
| `cosign_pub_key` | string (path) | no | Enables independent cosign verification of a registry `ref`. Before any destructive step, Firn selects the source it would install (preferring a valid embedded containers-storage image), resolves it to an immutable `sha256` digest, runs `cosign verify --key` against that digest, and installs that same digest. Verification failure emits `image_verification_failed`; when the registry itself cannot be reached it emits `registry_unreachable` (or `network_unreachable` with no network configured) instead, see the [progress protocol](progress-protocol.md#stable-codes). |

### `[target]`

Expand Down
19 changes: 17 additions & 2 deletions internal/bootcimg/bootcimg.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func bareImageRef(image string) string {
return image[idx+3:]
}
if idx := strings.Index(image, ":"); idx > 0 {
if transport := image[:idx]; !strings.ContainsAny(transport, "/.") {
if transport := image[:idx]; !strings.ContainsAny(transport, "/.") && !isPortThenPath(image[idx+1:]) {
return image[idx+1:]
}
}
Expand Down Expand Up @@ -273,6 +273,21 @@ func Install(ctx context.Context, r *runner.Runner, o Options) error {
// implicitly, so that result is reduced to reachable-or-not.
// TODO: port NeedsPull/LayerCount if firn grows explicit pull progress.
func CheckImage(ctx context.Context, r *runner.Runner, image string) error {
_, err := CheckAndPinImage(ctx, r, image, "", nil)
_, err := CheckAndPinImage(ctx, r, image, "", nil, nil)
return err
}

// isPortThenPath reports a "5000/repo" remainder: the prefix before it was a
// host (localhost:5000/repo), not an OCI transport.
func isPortThenPath(rest string) bool {
port, _, ok := strings.Cut(rest, "/")
if !ok || port == "" {
return false
}
for _, c := range port {
if c < '0' || c > '9' {
return false
}
}
return true
}
228 changes: 228 additions & 0 deletions internal/bootcimg/probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package bootcimg

import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
)

// probeTimeout bounds one whole registry probe: DNS, connect, TLS and the
// request share this single deadline.
const probeTimeout = 5 * time.Second

// ErrNoNetwork marks a failed probe on a machine with no routable address: no
// non-loopback interface is up with a global unicast address. A failed attempt
// plus that fact is what justifies telling the user the network is missing.
var ErrNoNetwork = errors.New("no routable network address is configured")

// Prober reports whether the registry serving ref answers at all, returning
// nil when it does. It is supplemental evidence for a failure skopeo or
// cosign already reported, never a gate: a nil Prober disables probing, so
// tests and callers that do not wire one never touch the network.
type Prober func(ctx context.Context, ref string) error

// RegistryUnreachableError reports that an image operation failed and the
// registry could not be reached either. It wraps the original failure, which
// stays authoritative; the probe only adds the diagnosis.
type RegistryUnreachableError struct {
Host string
// NoNetwork is set when the machine has no network configured, as
// opposed to one registry failing to answer.
NoNetwork bool
Cause error // the probe failure
Err error // the original skopeo/cosign failure
}

// Error claims the network is missing only when the local check shows that.
// One endpoint failing (a route, proxy, address family or the registry
// itself) cannot establish it, so otherwise the message names the endpoint
// and the probe's cause and only says the network may be involved.
func (e *RegistryUnreachableError) Error() string {
if e.NoNetwork {
return fmt.Sprintf("no routable network address is configured on this machine, so registry %s cannot be reached; installing from a network image requires a network connection: %v", e.Host, e.Err)
}
return fmt.Sprintf("cannot reach registry %s (%v); this could be caused by the network connection or by the registry, and installing from a network image requires access to it: %v",
e.Host, e.Cause, e.Err)
}

func (e *RegistryUnreachableError) Unwrap() error { return e.Err }

// Markers in a failure's text showing a registry (or cosign) answered, so a
// failed probe must not relabel it. A mirror or plain-HTTP registry can fail
// the probe while the tool that really talks to the registry succeeded in
// reaching it.
var (
registryResponseMarkers = []string{
"manifest unknown", "name unknown", "unauthorized", "denied",
"authentication required", "forbidden", "toomanyrequests", "too many requests",
}
// transportMarkers positively identify a connectivity failure in tool
// output. Without one the failure is ambiguous and keeps its own code.
transportMarkers = []string{
"dial tcp", "no such host", "i/o timeout", "connection refused",
"network is unreachable", "no route to host", "tls handshake timeout",
"context deadline exceeded", "lookup ",
}
cosignVerdictMarkers = []string{
"no matching signatures", "signature verification failed",
"no signatures found", "invalid signature", "no valid signatures",
"no matching attestations",
}
)

func hasMarker(err error, markers []string) bool {
msg := strings.ToLower(err.Error())
for _, m := range markers {
if strings.Contains(msg, m) {
return true
}
}
return false
}

// diagnose upgrades orig to a RegistryUnreachableError when probe shows the
// registry for ref cannot be reached. It returns orig unchanged when probing
// is off, ref is not a registry reference, the registry answers, or the
// caller's context ended (cancellation is never a network verdict).
func diagnose(ctx context.Context, probe Prober, ref string, orig error) error {
if probe == nil || ctx.Err() != nil || !IsRegistryRef(ref) {
return orig
}
pctx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
cause := probe(pctx, ref)
if cause == nil || ctx.Err() != nil {
return orig
}
if errors.Is(cause, context.DeadlineExceeded) {
cause = fmt.Errorf("timed out after %s", probeTimeout)
}
return &RegistryUnreachableError{
Host: registryHost(ref),
NoNetwork: errors.Is(cause, ErrNoNetwork),
Cause: cause,
Err: orig,
}
}

// registryHost returns the host[:port] skopeo would contact for ref: the
// first path component when it names a registry, else Docker Hub, whose
// docker.io aliases are served from registry-1.docker.io.
func registryHost(ref string) string {
const hub = "registry-1.docker.io"
first, _, hasPath := strings.Cut(bareImageRef(ref), "/")
if !hasPath || (!strings.ContainsAny(first, ".:") && first != "localhost") {
return hub
}
switch first {
case "docker.io", "index.docker.io":
return hub
}
return first
}

// HTTPRegistryProbe is the production Prober: an HTTPS request to the
// registry's /v2/ endpoint through the environment's proxy settings. Any HTTP
// response, including 401, means the registry is reachable. If the attempt
// fails on a machine with no routable address, the failure wraps ErrNoNetwork.
//
// Only positively identified connectivity failures are reported: DNS and dial
// failures, timeouts, and a proxy that could not reach the registry (502, 503,
// 504). A TLS trust, alert or protocol failure proves a server answered, and
// anything else (a proxy demanding credentials with 407, unknown errors) is
// ambiguous; all of those give no verdict, so the original skopeo/cosign
// failure keeps its own code. registries.conf mirrors are not consulted.
func HTTPRegistryProbe(ctx context.Context, ref string) error {
tr := &http.Transport{Proxy: http.ProxyFromEnvironment}
defer tr.CloseIdleConnections()
return probeWith(ctx, &http.Client{Transport: tr}, ref, func() bool {
return networkConfigured(net.Interfaces, (*net.Interface).Addrs)
})
}

// probeWith tries the registry first and consults configured only to word a
// failure, so the interface check can never block a route that works.
func probeWith(ctx context.Context, client *http.Client, ref string, configured func() bool) error {
err := probeRegistry(ctx, client, ref)
if err != nil && !configured() {
return fmt.Errorf("%w: %w", ErrNoNetwork, err)
}
return err
}

// networkConfigured reports whether any non-loopback interface is up with a
// global unicast address. Failing to enumerate counts as configured: the
// check may only ever add a diagnosis, never invent one.
func networkConfigured(list func() ([]net.Interface, error), addrs func(*net.Interface) ([]net.Addr, error)) bool {
ifaces, err := list()
if err != nil {
return true
}
for i := range ifaces {
if ifaces[i].Flags&net.FlagUp == 0 || ifaces[i].Flags&net.FlagLoopback != 0 {
continue
}
as, err := addrs(&ifaces[i])
if err != nil {
return true
}
for _, a := range as {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.IsGlobalUnicast() {
return true
}
}
}
return false
}

func probeRegistry(ctx context.Context, client *http.Client, ref string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+registryHost(ref)+"/v2/", nil)
if err != nil {
Comment thread
bketelsen marked this conversation as resolved.
return nil // not a transport failure: no verdict
}
c := *client
c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
resp, err := c.Do(req)
if err != nil {
if !connectivityFailure(err) {
return nil
}
return err
}
_ = resp.Body.Close()
return nil
}

// connectivityFailure positively identifies errors that mean no route to a
// server. TLS-layer failures (trust, alerts, protocol) occur only after a
// server accepted the connection, so they are excluded explicitly.
func connectivityFailure(err error) bool {
var (
certVerify *tls.CertificateVerificationError
record tls.RecordHeaderError
opErr *net.OpError
dnsErr *net.DNSError
netErr net.Error
)
switch {
case errors.As(err, &certVerify), errors.As(err, &record),
strings.Contains(err.Error(), "server gave HTTP response to HTTPS client"):
return false
case errors.As(err, &dnsErr), errors.Is(err, context.DeadlineExceeded):
return true
case errors.As(err, &opErr) && (opErr.Op == "dial" || opErr.Op == "proxyconnect"):
return true
case errors.As(err, &netErr) && netErr.Timeout():
return true
}
// A proxy that answered CONNECT with a gateway failure could not reach
// the registry; other statuses (407, 403) are not connectivity.
msg := err.Error()
return strings.Contains(msg, "Bad Gateway") || strings.Contains(msg, "Service Unavailable") ||
strings.Contains(msg, "Gateway Timeout")
}
Loading
Loading