The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

pkg-offline(1)

NAME

pkg-offline - update an air-gapped system by hand-carried bundle

SYNOPSIS

pkg offline export-pins [--output PATH] [--hostname NAME]

pkg offline bundle-create --pins FILE --tuf-dir DIR [--output PATH] [--output-dir DIR] [--provisional]

pkg offline upgrade bundle [--trust-key HEX]... [--trust-key-file PATH]... [--no-verify] [--dry-run] [--keep-staged] [--force]

DESCRIPTION

Some systems never see a network. Maybe they shouldn't, maybe they can't, or maybe somebody broke the network config and now the box can't reach anything until it's fixed. Either way, that system still needs updates, and somebody is going to walk over with a USB stick.

That walk should happen exactly once in each direction. You carry a small file from the air-gapped system to a connected one, and you carry one bundle back. Everything in that bundle is verified on arrival, just like it would be online.

The round trip

On the air-gapped system (A), write down what's installed. Then on a connected system (C), build a bundle for exactly that system. Back on A, apply it:

A# pkg offline export-pins C# pkg offline bundle-create --pins airgap-1.pins.toml \ --tuf-dir /var/lib/pkg/repo/metadata A# pkg offline upgrade airgap-1.pkgtar

  • Three commands, two walks, one bundle

The hostname ends up in both file names, but only so you can tell your USB sticks apart. Nothing parses it, and nothing compares it. A bundle applies because its contents verify, not because its name matches.

Why not just make a bundle?

pkg-bundle(1) can already carry a closure across. The problem is that neither side of an air gap knows what the other side has.

pkg-upgrade(1) holds any package that no repository in the chain pins. So if you extract a partial bundle into a local repository, every package that didn't change suddenly looks held, and you can't tell it apart from one that really lost its pin. The pins file fixes the first half: it lets the connected side compute an actual delta. And because the bundle knows which names it speaks for, the air-gapped side can leave everything else alone.

COMMANDS

export-pins

Writes one entry per installed subpackage: name, subpackage, version, release and payload digest. It also records the chain this system follows and the keys that signed what it runs. It only reads, so it needs no privileges.

The digest isn't there for decoration. Convergence is about identity: the installed bytes either are the pinned artifact, or they aren't. A rebuild at the same N-V-R is real work, and a delta computed from version numbers alone would skip it.

bundle-create

Takes every installed subpackage in the pins file, converges it onto what this host's chain pins for it, resolves the closure, and bundles the packages the air-gapped system isn't already at.

"Already at" means the same payload digest and the same version and release. That's the same rule pkg-upgrade(1) converges by. A release bump that rebuilt byte-for-byte identical still ships, payload included: the other side has to move its recorded version, or a sibling that requires the new one exactly is left stranded. Applying it rewrites the label and extracts nothing.

The delta is decided per package name, though. If a name moves, every one of its subpackages ships, including the ones that didn't change. A pin names a name, and the receiving side builds its pin map from what's inside the bundle. Ship only half a name and it reads as a build that dropped the other half.

A name no repository in this chain pins is reported as held, and left out:

2 package(s) held -- no repo in this chain pins them: bzip2, tar

If nothing moved at all, you get no bundle:

airgap-1 is already current against this chain; no bundle written

upgrade

Reads the bundle, verifies every manifest, treats the bundle as a chain with one repository in it, and converges. It's the same planner, the same confirmation prompt and the same triggers as a normal pkg-upgrade(1).

Nothing gets extracted to disk. The payloads are served from the bundle in memory, so the system doesn't slowly fill up with superseded builds.

A name the bundle doesn't carry is left alone, and not reported as held: missing from a delta means unchanged. A name the bundle carries but this system never installed is only pulled in when something that converges needs it.

OPTIONS

export-pins

--output PATH
A directory, or a path ending in /, gets <hostname>.pins.toml. Anything else is used as the file name. Defaults to the current directory.
--hostname NAME
Use NAME instead of this machine's hostname, in the file and in its name.

bundle-create

--pins FILE
Required. The pins file the air-gapped system exported.
--tuf-dir DIR
Required. The directory holding targets.json, snapshot.json and timestamp.json to embed, same as for pkg-bundle(1).
--output PATH
The bundle to write. Defaults to <output-dir>/<hostname>.pkgtar.
--output-dir DIR
Where <hostname>.pkgtar goes. Defaults to the current directory, and is ignored when you pass --output.
--provisional
Build against the staged provisional pins instead of the blessed ones.

upgrade

--trust-key HEX
Can be repeated. A hex-encoded Ed25519 public key that a bundled manifest may be signed with, on top of the keys that signed what this system already runs. See TRUST, below.
--trust-key-file PATH
Can be repeated. A file with such keys, one per line. Blank lines and lines starting with # are skipped.
--no-verify
Apply without checking manifest signatures at all, not even against the keys the system already runs software from. It warns on stderr, and it should.
--dry-run
Print the plan, change nothing.
--keep-staged
Keep payloads that pkg-stage-update(1) staged, after they're installed. A bundle brings its own payloads, but applying one can still install artifacts an earlier online staging fetched. Without this, those staged copies are dropped, since they'd be the same bytes stored twice.
--force
I know, do it anyway. Gets past a dependency the bundle can't satisfy, and past a pre-flight GC blocker on a removal. It still won't install a package the bundle doesn't carry, and it won't break a hold. Everything it overrides is reported, never silenced, and passing it makes the command ask for confirmation.

TRUST

Where does the trust come from?

Online, every manifest arrives through a verified TUF chain. A bundle on a USB stick has none of that. And root.json deliberately does not travel inside a .pkgtar: a bundle that carries its own trust anchor would just verify against itself. That leaves the signature on each manifest as the only trust on this path.

Do I have to hand it a key?

Usually, no. Every installed manifest records the key that signed it, and in practice that's one key per repository. So the system already holds the anchors for the software it's running, and pkg offline upgrade reads them out of pkg-state(5).

Each of those keys is trusted for the package names it already signs here, and nothing else. The key from the repository that gives you firefox can't replace glibc through a bundle, just like it couldn't online. A name the system has never had is accepted from a key that, in the same bundle, also signs names it's trusted for. That's a new dependency from the same repository, and it's fine.

So the everyday update needs no flags at all:

pkg offline upgrade /mnt/usb/airgap-1.pkgtar

When do I need --trust-key?

When it's genuinely a new decision. A rotated signing key. A repository this system never installed from. A key reaching into names that another key owns here. Or the very first bundle onto a machine with nothing installed. A key you name this way is trusted for every name, for that one run.

When a manifest has nothing vouching for it, the error tells you which flag would accept it:

error: offline: openssl/lib 3.5.6-14 is signed by 5a31eaacac5c4b64, which signs nothing installed here; pass --trust-key 5a31...2384 to accept it

You'd rather find that out before the walk, so bundle-create predicts the refusal from the keys the pins file carries:

warning: 3 of this bundle's manifests are signed by 1 key(s) airgap-1 has never installed from (543df49455f62f44). It will refuse them unless that host is given the key: pkg offline upgrade --trust-key <hex>.

That key list in the pins file is advice, nothing more, and the receiving side never reads it. The pins file travels on the same USB stick as the bundle. If its key list conferred trust, anyone able to rewrite the signatures could rewrite the key list right alongside them. So the receiving side works the set out locally instead.

EXIT STATUS

0 on success. 1 on any failure: a pins file that can't be read or parsed, a closure that doesn't resolve, a missing payload, a bundle that fails verification, or a write error.

SEE ALSO

pkg(1), pkg-bundle(1), pkg-upgrade(1), pkg-stage-update(1), pkg-verify-sig(1), pkg-chain(7), pkg-repository-layout(7)

docs/design/offline-update.md, docs/components/pkg-tar.md