Qubes OS

Starting a Qube and its dependencies in Qubes OS

A howto for qvm-start-deps: a dom0 script that recursively starts a qube’s declared dependencies - Split SSH vaults, service qubes, custom cross-qube relationships - before the target, using qvm-features as dependency tags.

The scenario

You have a qube - call it work - that needs more than a network connection to be useful. Its SSH agent lives in a vault qube via Split SSH. Its GPG key lives in another vault via Split GPG. A third qube runs a local service it connects to over qrexec. When you start work, you want all of those started first, in the right order, without thinking about it.

Qubes starts the network chain for you. If work’s netvm is sys-firewall and sys-firewall isn’t running, qvm-start work will start it (and sys-net underneath it) automatically. But anything beyond that is your problem. Split SSH providers, Split GPG vaults, service-hosting qubes - Qubes doesn’t know about those relationships. You either start them by hand each time, or you write a script.

The dependency-tag approach

qvm-start-deps is a small Python script that runs in dom0. It reads a custom feature - dep.requires by default - from the target qube, recursively resolves all declared dependencies, topologically sorts them, and starts them in order before starting the target.

Qubes calls these “features” - key-value pairs stored on each qube via qvm-features. They’re not the same as Qubes tags (set with qvm-tags), which are simple labels with no value. We’re using a custom feature, dep.requires, as a dependency tag: its value is a whitespace-separated list of qube names that must be started before this qube.

Setting dep.requires on work to split-ssh-vault split-gpg-vault build-server means: before starting work, start those three (and their own dependencies, recursively) first.

The script uses the qubesadmin Python library - the same one that backs the qvm-* CLI tools - so it runs in dom0 with no extra dependencies.

Installing the script

Save the script to dom0 as qvm-start-deps and make it executable:

chmod +x qvm-start-deps

Place it somewhere in $PATH - /usr/local/bin/ is the usual spot for dom0 scripts that aren’t managed by the package manager.

The full script:

#!/usr/bin/python3
"""Start Qubes domains after recursively starting declared dependencies."""

from __future__ import annotations

import argparse
import fcntl
import os
import sys
from pathlib import Path
from typing import Iterable

import qubesadmin
import qubesadmin.exc

DEFAULT_FEATURE = "dep.requires"

RUNTIME_DIR = Path(
    os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
)
LOCK_PATH = RUNTIME_DIR / "qvm-start-deps.lock"


class DependencyError(RuntimeError):
    """The configured dependency graph is invalid."""


def dependency_names(vm, feature: str) -> list[str]:
    """Return the unique, ordered dependencies declared on a qube."""
    value = vm.features.get(feature, "")

    if not value:
        return []

    # Qube names cannot contain whitespace, so a whitespace-separated
    # feature value is sufficient.
    return list(dict.fromkeys(str(value).split()))


def resolve_order(
    app: qubesadmin.Qubes,
    targets: Iterable[str],
    feature: str,
) -> list[str]:
    """Topologically sort targets and their custom dependencies."""
    order: List[str] = []
    state: dict[str, int] = {}
    stack: list[str] = []

    def visit(name: str) -> None:
        mark = state.get(name, 0)

        if mark == 2:
            return

        if mark == 1:
            cycle_at = stack.index(name)
            cycle = stack[cycle_at:] + [name]
            raise DependencyError(
                "dependency cycle: " + " -> ".join(cycle)
            )

        try:
            vm = app.domains[name]
        except (
            KeyError,
            qubesadmin.exc.QubesVMNotFoundError,
        ) as exc:
            parent = stack[-1] if stack else "<command line>"
            raise DependencyError(
                f"{parent!r} requires missing qube {name!r}"
            ) from exc

        if vm.klass == "AdminVM":
            raise DependencyError("dom0 cannot be a start dependency")

        state[name] = 1
        stack.append(name)

        for dependency in dependency_names(vm, feature):
            visit(dependency)

        stack.pop()
        state[name] = 2
        order.append(name)

    for target in targets:
        visit(target)

    return order

def start_in_order(
    app: qubesadmin.Qubes,
    order: Iterable[str],
    dry_run: bool,
) -> None:
    """Start every qube in the resolved order."""
    for name in order:
        vm = app.domains[name]
        power_state = vm.get_power_state()

        if power_state == "Running":
            print(f"already running: {name}")
            continue

        if power_state == "Halted":
            action = "would start" if dry_run else "starting"
            print(f"{action}: {name}")

            if not dry_run:
                vm.start()
            continue

        raise DependencyError(
            f"{name!r} is in state {power_state!r}; "
            "refusing to treat it as ready."
        )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Start qubes recursively in dependency order."
    )
    parser.add_argument(
        "qubes",
        nargs="+",
        help="target qube or qubes",
    )
    parser.add_argument(
        "--feature",
        default=DEFAULT_FEATURE,
        help=(
            "feature containing whitespace-separated dependencies "
            f"(default: {DEFAULT_FEATURE})"
        ),
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="show the resolved order without starting anything",
    )
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    try:
        RUNTIME_DIR.mkdir(parents=True, exist_ok=True)

        with LOCK_PATH.open("w", encoding="utf-8") as lock_file:
            # Avoids races between two instances of this launcher.
            fcntl.flock(lock_file, fcntl.LOCK_EX)

            app = qubesadmin.Qubes()
            order = resolve_order(app, args.qubes, args.feature)

            print("resolved order: " + " -> ".join(order))
            start_in_order(app, order, args.dry_run)

    except (
        DependencyError,
        qubesadmin.exc.QubesException,
        OSError,
    ) as exc:
        print(f"qvm-start-deps: {exc}", file=sys.stderr)
        return 1

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Declaring dependencies

Dependencies are stored as a feature on the qube. The tool for setting features is qvm-features - not qvm-prefs, which manages properties like netvm and memory, but the separate qvm-features command that manages the key-value feature store:

qvm-features work dep.requires "split-ssh-vault split-gpg-vault build-server"

Read it back to verify:

qvm-features work dep.requires

List all features on a qube to see it in context:

qvm-features work

Dependencies are recursive. If split-ssh-vault itself declares a dependency - say it needs a backup vault running first:

qvm-features split-ssh-vault dep.requires "backup-vault"

then qvm-start-deps work starts backup-vault, then split-ssh-vault, then the rest, then work. The script does a topological sort and starts everything in order - leaves before roots.

To remove a dependency declaration:

qvm-features -D work dep.requires

A worked example

Suppose you have this setup:

  • work - your daily-driver AppVM. Declares dep.requires: split-ssh-vault build-server.
  • split-ssh-vault - holds your SSH agent via Split SSH. Declares dep.requires: backup-vault.
  • backup-vault - holds key material. No custom dependencies.
  • build-server - runs a build service work connects to. No custom dependencies (its netvm chain is handled by Qubes natively).

The dependency graph looks like:

backup-vault
    |
    v
split-ssh-vault          build-server
         \               /
          \             /
           v           v
              work

Start work with the script:

qvm-start-deps work

Output:

resolved order: backup-vault -> split-ssh-vault -> build-server -> work
starting: backup-vault
starting: split-ssh-vault
starting: build-server
starting: work

Already-running qubes are skipped silently:

resolved order: backup-vault -> split-ssh-vault -> build-server -> work
already running: backup-vault
already running: split-ssh-vault
starting: build-server
starting: work

Dry run

Before wiring a new qube into the graph, check the resolved order without starting anything:

qvm-start-deps --dry-run work

Output:

resolved order: backup-vault -> split-ssh-vault -> build-server -> work
would start: backup-vault
would start: split-ssh-vault
would start: build-server
would start: work

Multiple targets

The script accepts one or more qube names. Dependencies shared between targets are started once:

qvm-start-deps work personal banking

What it catches for you

The script refuses to start a broken dependency graph. Three classes of error are caught before anything is started.

Cycles. If a requires b and b requires a, the topological sort detects the back-edge and reports the cycle path:

qvm-features a dep.requires "b"
qvm-features b dep.requires "a"
qvm-start-deps a
qvm-start-deps: dependency cycle: a -> b -> a

Missing qubes. A typo in a dependency name, or a qube that was renamed or deleted, is caught before any qube is started. The error names the parent that declared the bad dependency:

qvm-features work dep.requires "nonexistent-qube"
qvm-start-deps work
qvm-start-deps: 'work' requires missing qube 'nonexistent-qube'

dom0 as a dependency. The script refuses to treat dom0 (AdminVM) as a start dependency. Declaring dom0 in a dep.requires value is a configuration error, not a request to start dom0.

Transient states. If a qube in the resolved order is neither Halted nor Running - it’s Transient, Dying, or similar - the script stops and reports it. Starting the next qube against a dependency that’s mid-transition is a race; the script won’t guess.

Concurrent runs. A file lock (fcntl.flock on $XDG_RUNTIME_DIR/qvm-start-deps.lock) prevents two invocations from racing each other. If one is mid-start, the other waits.

A note on the feature namespace

The script defaults to the feature name dep.requires. Qubes recommends recommends the x- prefix for user-defined features to avoid collisions with future Qubes internals. If you want to follow that convention, pass --feature x-dep.requires on every invocation, or edit the DEFAULT_FEATURE constant at the top of the script:

DEFAULT_FEATURE = "x-dep.requires"

Wiring it into your workflow

Once the script is in $PATH and your dependency features are set, starting work becomes a single command:

qvm-start-deps work

If you start qubes from the Qube Manager GUI, you can add a desktop file or a dom0 keyboard shortcut that calls the script for your most-used target. The script is idempotent - running it on an already-running qube (and its already-running dependencies) is a no-op.

At a glance
  • Runs in: dom0
  • Language: Python 3 (uses qubesadmin, no extra deps)
  • Dependency tag: dep.requires feature (whitespace-separated qube names)
  • Set with: qvm-features VMNAME dep.requires "dep1 dep2 ..."
  • Cycle detection: yes, with path reporting
  • Missing-qube detection: yes, with parent context
  • dom0 rejection: refuses to start dom0 as a dependency
  • Transient states: refuses to proceed
  • File lock: prevents concurrent runs
  • Dry run: --dry-run
  • Custom feature name: --feature x-dep.requires
Need help automating your Qubes OS workflow?
I do contract sysadmin and privacy-infrastructure work and can help design and automate Qubes OS deployments, cross-qube service architectures, and dom0 automation.
Contact me