Skip to content

Commit

Permalink
Allow installation on air-gapped systems
Browse files Browse the repository at this point in the history
- Verify the archive against the known public signature
- Prepare a new archive format (with signature removed)
- Load the new image and retag it with the expected tag

During this process, the signatures are lost and should instead be
converted to a known format. Additionally, the name fo the repository
should ideally come from the signatures rather than from the command
line.
  • Loading branch information
almet committed Feb 3, 2025
1 parent f7069a9 commit 087e5bd
Show file tree
Hide file tree
Showing 6 changed files with 372 additions and 47 deletions.
38 changes: 35 additions & 3 deletions dangerzone/container_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def get_expected_tag() -> str:
return f.read().strip()


def load_image_tarball() -> None:
def load_image_tarball_in_memory() -> None:
log.info("Installing Dangerzone container image...")
p = subprocess.Popen(
[get_runtime(), "load"],
Expand Down Expand Up @@ -147,6 +147,36 @@ def load_image_tarball() -> None:
log.info("Successfully installed container image from")


def load_image_tarball_file(tarball_path: str) -> None:
cmd = [get_runtime(), "load", "-i", tarball_path]
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)

log.info("Successfully installed container image from %s", tarball_path)


def tag_image_by_digest(digest: str, tag: str) -> None:
image_id = get_image_id_by_digest(digest)
cmd = [get_runtime(), "tag", image_id, tag]
log.debug(" ".join(cmd))
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)


def get_image_id_by_digest(digest: str) -> str:
cmd = [
get_runtime(),
"images",
"-f",
f"digest={digest}",
"--format",
"{{.Id}}",
]
log.debug(" ".join(cmd))
process = subprocess.run(
cmd, startupinfo=get_subprocess_startupinfo(), check=True, capture_output=True
)
return process.stdout.decode().strip()


def container_pull(image: str) -> bool:
"""Pull a container image from a registry."""
cmd = [get_runtime_name(), "pull", f"{image}"]
Expand All @@ -155,8 +185,10 @@ def container_pull(image: str) -> bool:
return process.returncode == 0


def load_image_hash(image: str) -> str:
"""Returns a image hash from a local image name"""
def get_local_image_hash(image: str) -> str:
"""
Returns a image hash from a local image name
"""
cmd = [get_runtime_name(), "image", "inspect", image, "-f", "{{.Digest}}"]
result = subprocess.run(cmd, capture_output=True, check=True)
return result.stdout.strip().decode().strip("sha256:")
2 changes: 1 addition & 1 deletion dangerzone/isolation_provider/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def install() -> bool:
return True

# Load the image tarball into the container runtime.
container_utils.load_image_tarball()
container_utils.load_image_tarball_in_memory()

# Check that the container image has the expected image tag.
# See https://github.com/freedomofpress/dangerzone/issues/988 for an example
Expand Down
189 changes: 189 additions & 0 deletions dangerzone/rntime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import gzip
import logging
import platform
import shutil
import subprocess
from typing import List, Optional, Tuple

from . import errors
from .util import get_resource_path, get_subprocess_startupinfo

CONTAINER_NAME = "dangerzone.rocks/dangerzone"

log = logging.getLogger(__name__)


def get_runtime_name() -> str:
if platform.system() == "Linux":
return "podman"
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually
return "docker"


def get_runtime_version() -> Tuple[int, int]:
"""Get the major/minor parts of the Docker/Podman version.
Some of the operations we perform in this module rely on some Podman features
that are not available across all of our platforms. In order to have a proper
fallback, we need to know the Podman version. More specifically, we're fine with
just knowing the major and minor version, since writing/installing a full-blown
semver parser is an overkill.
"""
# Get the Docker/Podman version, using a Go template.
runtime = get_runtime_name()
if runtime == "podman":
query = "{{.Client.Version}}"
else:
query = "{{.Server.Version}}"

cmd = [runtime, "version", "-f", query]
try:
version = subprocess.run(
cmd,
startupinfo=get_subprocess_startupinfo(),
capture_output=True,
check=True,
).stdout.decode()
except Exception as e:
msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}"
raise RuntimeError(msg) from e

# Parse this version and return the major/minor parts, since we don't need the
# rest.
try:
major, minor, _ = version.split(".", 3)
return (int(major), int(minor))
except Exception as e:
msg = (
f"Could not parse the version of the {runtime.capitalize()} tool"
f" (found: '{version}') due to the following error: {e}"
)
raise RuntimeError(msg)


def get_runtime() -> str:
container_tech = get_runtime_name()
runtime = shutil.which(container_tech)
if runtime is None:
raise errors.NoContainerTechException(container_tech)
return runtime


def list_image_tags() -> List[str]:
"""Get the tags of all loaded Dangerzone images.
This method returns a mapping of image tags to image IDs, for all Dangerzone
images. This can be useful when we want to find which are the local image tags,
and which image ID does the "latest" tag point to.
"""
return (
subprocess.check_output(
[
get_runtime(),
"image",
"list",
"--format",
"{{ .Tag }}",
CONTAINER_NAME,
],
text=True,
startupinfo=get_subprocess_startupinfo(),
)
.strip()
.split()
)


def delete_image_tag(tag: str) -> None:
"""Delete a Dangerzone image tag."""
name = CONTAINER_NAME + ":" + tag
log.warning(f"Deleting old container image: {name}")
try:
subprocess.check_output(
[get_runtime(), "rmi", "--force", name],
startupinfo=get_subprocess_startupinfo(),
)
except Exception as e:
log.warning(
f"Couldn't delete old container image '{name}', so leaving it there."
f" Original error: {e}"
)


def get_expected_tag() -> str:
"""Get the tag of the Dangerzone image tarball from the image-id.txt file."""
with open(get_resource_path("image-id.txt")) as f:
return f.read().strip()


def tag_image_by_digest(digest: str, tag: str) -> None:
image_id = get_image_id_by_digest(digest)
cmd = [get_runtime(), "tag", image_id, tag]
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)


def get_image_id_by_digest(digest: str) -> str:
cmd = [
get_runtime(),
"image",
"tag",
"-f",
f'digest="{digest}"',
"--format ",
"{{.Id}}",
]
process = subprocess.run(
cmd, startupinfo=get_subprocess_startupinfo(), check=True, capture_output=True
)
return process.stdout.decode().strip()


def load_image_tarball_in_memory(
compressed_container_path: Optional[str] = None,
) -> None:
if compressed_container_path is None:
compressed_container_path = get_resource_path("container.tar.gz")

log.info("Installing Dangerzone container image...")
p = subprocess.Popen(
[get_runtime(), "load"],
stdin=subprocess.PIPE,
startupinfo=get_subprocess_startupinfo(),
)

chunk_size = 4 << 20

with gzip.open(compressed_container_path) as f:
while True:
chunk = f.read(chunk_size)
if len(chunk) > 0:
if p.stdin:
p.stdin.write(chunk)
else:
break
_, err = p.communicate()
if p.returncode < 0:
if err:
error = err.decode()
else:
error = "No output"
raise errors.ImageInstallationException(
f"Could not install container image: {error}"
)

log.info("Successfully installed container image from")


def load_image_tarball_file(container_path: str) -> None:
cmd = [get_runtime(), "load", "-i", container_path]
subprocess.run(cmd, startupinfo=get_subprocess_startupinfo(), check=True)

log.info("Successfully installed container image from %s", container_path)


def container_pull(image: str) -> bool:
# XXX - Move to container_utils.py
cmd = [get_runtime_name(), "pull", f"{image}"]
process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
process.communicate()
return process.returncode == 0
30 changes: 23 additions & 7 deletions dangerzone/updater/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,20 @@
from ..util import get_resource_path
from . import errors, log, registry
from .attestations import verify_attestation
from .signatures import upgrade_container_image, verify_offline_image_signature
from .signatures import (
upgrade_container_image,
upgrade_container_image_airgapped,
verify_offline_image_signature,
)

DEFAULT_REPOSITORY = "freedomofpress/dangerzone"

DEFAULT_IMAGE_NAME = "ghcr.io/freedomofpress/dangerzone"
PUBKEY_DEFAULT_LOCATION = get_resource_path("freedomofpress-dangerzone-pub.key")


@click.group()
@click.option("--debug", is_flag=True)
def main(debug=False) -> None:
def main(debug: bool) -> None:
if debug:
click.echo("Debug mode enabled")
level = logging.DEBUG
Expand All @@ -26,11 +30,9 @@ def main(debug=False) -> None:


@main.command()
@click.option("--image")
@click.argument("image")
@click.option("--pubkey", default=PUBKEY_DEFAULT_LOCATION)
@click.option("--airgap", is_flag=True)
# XXX Add options to do airgap upgrade
def upgrade(image: str, pubkey: str, airgap: bool) -> None:
def upgrade(image: str, pubkey: str) -> None:
"""Upgrade the image to the latest signed version."""
manifest_hash = registry.get_manifest_hash(image)
try:
Expand All @@ -41,6 +43,20 @@ def upgrade(image: str, pubkey: str, airgap: bool) -> None:
raise click.Abort()


@main.command()
@click.argument("image_filename")
@click.option("--pubkey", default=PUBKEY_DEFAULT_LOCATION)
@click.option("--image-name", default=DEFAULT_IMAGE_NAME)
def upgrade_airgapped(image_filename: str, pubkey: str, image_name: str) -> None:
"""Upgrade the image to the latest signed version."""
try:
upgrade_container_image_airgapped(image_filename, pubkey, image_name)
click.echo(f"✅ Installed image {image_filename} on the system")
except errors.ImageAlreadyUpToDate as e:
click.echo(f"✅ {e}")
raise click.Abort()


@main.command()
@click.argument("image")
@click.option("--pubkey", default=PUBKEY_DEFAULT_LOCATION)
Expand Down
1 change: 0 additions & 1 deletion dangerzone/updater/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"list_tags",
"get_manifest",
"get_attestation",
"Image",
"parse_image_location",
]

Expand Down
Loading

0 comments on commit 087e5bd

Please sign in to comment.