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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ From a console the same thing is:
| `destroy` | remove VM + system disk; `-Purge` also deletes the home disk |
| `fetch` | download release images into the cache without touching the VM |
| `build` | build images in WSL (developer path) |
| `gc` | report unused cached images; `-Force` deletes them |
| `manifest` | print the resolved machine config |

Useful switches: `-Release v0.2.0` (pin a release), `-VmRoot D:\vms`,
Expand All @@ -115,6 +116,36 @@ public repository needs no token. Private repositories are not supported by the
`release` source yet; use `fetch` from a machine that can reach the assets, or
the `wsl` / `file` sources.

### Reclaiming Disk Space

Every `up` or `recreate` from a `.gz` leaves an unpacked copy in the cache, and
release downloads keep one directory per tag. `gc` clears what is no longer
needed:

```bash
qubixctl -Command gc # dry run: what would go, and how much
qubixctl -Command gc -Force # delete it
qubixctl -Command gc -Force -All # drop the installed image's cache as well
```

Nothing is deleted without `-Force`, and the dry run needs no elevation. The
cache directory matching `image-version.txt` is kept by default, since
re-fetching a release means downloading the assets again; `-All` is the
`nix-collect-garbage -d` of this command. `local/` is always dropped - it only
ever holds a copy unpacked from a file the caller already has.

The VM directory is never touched: not the home disk, not the live system disk.
Images staged by hand under `vmRoot` (for `-ImageSource file`) are **reported
but never deleted** - tidying up after the controller is one thing, deleting
what a person put there is another:

```text
Disk images under C:\HyperV\Qubix that qubixctl did not create (18.67 GB):
C:\HyperV\Qubix\src\spotibox-kb.vhdx (5.57 GB)
...
These were staged by hand; delete them yourself if they are no longer needed.
```

## Persistence Model

```text
Expand Down Expand Up @@ -332,6 +363,30 @@ PipeWire -> disabled
EasyEffects is intentionally not the active DSP baseline here. It is
PipeWire-oriented, while this xrdp audio path expects PulseAudio.

### Keyboard groups in remote sessions

xrdp pins the guest's XKB layout to whatever the client had **at connect time**
and never revisits it: RDP carries the layout once, in the Client Info PDU, and
sends bare scancodes afterwards. Switching the layout on the Windows side does
nothing in the guest until you reconnect - which reads as "the VM ignores my
keyboard" and is really "the VM was told once and never again".

`profiles/remote/xrdp.nix` wraps the session so that, once xrdp has applied the
client's layout, a Latin group is added next to it plus a toggle. The list is
not hardcoded: whatever the client negotiated is what gets a companion group, so
a German client gets `us,de` and a Russian one `us,ru`, while a Latin-only
client keeps its single group and notices nothing. Tunable through
`qubix.keyboard.latinGroup` and `qubix.keyboard.toggle`.

Two caveats worth knowing:

- The default toggle is `grp:win_space_toggle`, and **Win keys only reach the
guest when mstsc runs full screen** (`Ctrl+Alt+Break` toggles that). In a
windowed session Windows keeps Win+Space for itself.
- On *reconnect* to an existing session the wrapper does not run again, so the
groups can collapse back to the client's single layout. Fixing that properly
belongs in xrdp, not here.

### Why PCM-only audio

nixpkgs builds xrdp with `--enable-mp3lame` and `--enable-opus`. With those
Expand Down
5 changes: 5 additions & 0 deletions machines/spotibox.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
audio = "pulseaudio-xrdp";
app = "spotify";
kernel = "default";
# RDP only reports the client's *active* layout, so a Russian typist
# connecting while Windows sits on the US layout would otherwise get a
# single Latin group and nothing to toggle to. Name both explicitly.
keyboard.layouts = "us,ru";

homeDisk.sizeMiB = 16 * 1024;
network = { staticIp = "192.168.250.10"; gateway = "192.168.250.1"; };
};
Expand Down
39 changes: 39 additions & 0 deletions modules/qubix-options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,45 @@
'';
};

keyboard = {
latinGroup = lib.mkOption {
type = lib.types.str;
default = "us";
description = ''
XKB layout kept as the first group in remote sessions, so a Latin
keyboard is always available for shell commands regardless of what
the connecting client uses.
'';
};

layouts = lib.mkOption {
type = lib.types.str;
default = "";
example = "us,ru";
description = ''
Explicit XKB layout list for remote sessions. Empty (the default)
keeps whatever layout xrdp negotiated with the client and places
`latinGroup` beside it, which keeps the image client-agnostic.

Set this when the people using the appliance need a layout their RDP
client does not announce - a Russian typist connecting while the
Windows side happens to sit on the US layout, for example. RDP only
reports the client's *active* layout, so that case cannot be guessed.
'';
};

toggle = lib.mkOption {
type = lib.types.str;
default = "grp:win_space_toggle";
example = "grp:alt_shift_toggle";
description = ''
XKB option that switches between the Latin group and the client's
own layout. Win+Space matches the Windows shortcut; note that Win
keys only reach the guest when mstsc runs full screen.
'';
};
};

homeDisk = {
enable = lib.mkOption {
type = lib.types.bool;
Expand Down
10 changes: 10 additions & 0 deletions profiles/gui/openbox.nix
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ lib.mkIf (config.qubix.gui == "openbox") {
# Remote sessions get a bare Openbox unless an app profile overrides this.
qubix.session.command = lib.mkDefault "${pkgs.openbox}/bin/openbox-session";

# xterm's compiled-in default is the bitmap "fixed" font in ISO-8859-1, which
# carries no Cyrillic (or any non-latin) glyphs, so typing Russian over RDP
# renders as boxes even though 75 Cyrillic-capable fonts are installed and the
# locale is UTF-8. Normally X resources would fix this, but nothing in this
# image loads them: there is no display manager session and no xrdb call, and
# ~/.Xresources would live on the persistent home disk rather than in Nix.
# So the defaults are baked into the binary the Openbox menu actually invokes.
environment.systemPackages = with pkgs; [
(lib.hiPrio (writeShellScriptBin "xterm" ''
exec ${xterm}/bin/xterm -fa 'DejaVu Sans Mono' -fs 11 -u8 "$@"
''))
openbox
xterm
];
Expand Down
66 changes: 64 additions & 2 deletions profiles/remote/xrdp.nix
Original file line number Diff line number Diff line change
@@ -1,13 +1,75 @@
{ config, ... }:
{ config, lib, pkgs, ... }:

let
kb = config.qubix.keyboard;

setxkbmap = "${pkgs.xorg.setxkbmap}/bin/setxkbmap";
awk = "${pkgs.gawk}/bin/awk";

# xrdp pins the guest's XKB layout to whatever the client had at connect time
# and never revisits it: RDP carries the layout once, in the Client Info PDU,
# and sends bare scancodes from then on. Switching the layout on the Windows
# side therefore changes nothing in the guest until you reconnect.
#
# Two things make this awkward to fix from startwm.sh:
#
# * X is not necessarily accepting connections yet when startwm.sh runs, so
# a single setxkbmap call can silently do nothing;
# * xrdp may apply the client's layout *after* the session script starts,
# overwriting whatever was set.
#
# So run in the background and keep checking for a while, re-applying if the
# toggle disappeared. Failures go to stderr (and thus the session log) rather
# than being swallowed, which is how the first version of this hid its own
# breakage.
session = pkgs.writeShellScript "qubix-xrdp-session" ''
(
attempts=12
while [ "$attempts" -gt 0 ]; do
attempts=$((attempts - 1))
sleep 1

query=$(${setxkbmap} -query 2>/dev/null) || continue

# Already carrying our toggle: nothing to do this round.
case "$query" in
*${kb.toggle}*) continue ;;
esac

wanted='${kb.layouts}'
if [ -z "$wanted" ]; then
# Client-agnostic default: keep the negotiated layout and put a Latin
# group next to it, so shell commands stay typable either way.
current=$(printf '%s\n' "$query" | ${awk} '/^layout:/ { print $2 }')
case "$current" in
"" | ${kb.latinGroup} | ${kb.latinGroup},*) wanted="$current" ;;
*) wanted="${kb.latinGroup},$current" ;;
esac
[ -n "$wanted" ] || wanted='${kb.latinGroup}'
fi

if ! ${setxkbmap} -layout "$wanted" -option "" -option '${kb.toggle}'; then
echo "qubix: setxkbmap -layout $wanted failed" >&2
fi
done
) &

exec ${config.qubix.session.command}
'';
in
{
# xrdp is the appliance's window to the Windows host. Whatever the active
# profiles put into qubix.session.command becomes the session: a plain
# window manager for generic images, or a single-app kiosk session such as
# Spotify. When that command exits, xrdp ends the session and mstsc closes.
#
# The session is wrapped so keyboard groups are fixed up alongside it; see
# above. services.xserver.xkb.options is deliberately NOT forwarded: its
# NixOS default is terminate:ctrl_alt_bksp, which xrdp never applies on its
# own, and arming it would let a stray keypress kill the kiosk session.
services.xrdp = {
enable = true;
defaultWindowManager = config.qubix.session.command;
defaultWindowManager = "${session}";
openFirewall = true;
};
}
41 changes: 41 additions & 0 deletions tests/qubixctl.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,47 @@ try {
Assert-True ((Get-QubixAddress -Config $config -Explicit '10.0.0.5') -eq '10.0.0.5') 'explicit address wins'
Assert-True ((Get-QubixAddress -Config $config -Explicit '') -eq (Get-Prop $config 'staticIp')) 'static IP is used without Hyper-V lookups'
Assert-True (-not (Test-TcpPort -TargetHost '127.0.0.1' -Port 1 -TimeoutMs 500)) 'closed ports are reported as closed'

# --- gc ----------------------------------------------------------------
# 'local' is a copy of something the caller already has, so it always goes.
Assert-True ((Select-QubixGarbage -CacheNames @('local') -InstalledVersion 'v1.0.0' -All $false) -contains 'local') 'gc always drops the local cache'

# A release tag matching image-version.txt is worth keeping: re-fetching it
# would mean downloading the assets again.
$g = @(Select-QubixGarbage -CacheNames @('v1.0.0', 'v0.9.0', 'local') -InstalledVersion 'v1.0.0' -All $false)
Assert-True ($g -notcontains 'v1.0.0') 'gc keeps the installed release cache'
Assert-True ($g -contains 'v0.9.0') 'gc drops release caches that are not installed'
Assert-True ($g -contains 'local') 'gc drops local alongside stale tags'

# -All is the nix-collect-garbage -d equivalent.
$gAll = @(Select-QubixGarbage -CacheNames @('v1.0.0', 'local') -InstalledVersion 'v1.0.0' -All $true)
Assert-True ($gAll -contains 'v1.0.0') '-All drops the installed release cache too'

# image-version.txt carries 'file:...' / 'wsl:...' for non-release images,
# and those can never name a cache directory.
$gFile = @(Select-QubixGarbage -CacheNames @('v1.0.0') -InstalledVersion 'file:spotibox.vhdx' -All $false)
Assert-True ($gFile -contains 'v1.0.0') 'a file: marker protects no cache directory'
Assert-True (@(Select-QubixGarbage -CacheNames @() -InstalledVersion '' -All $false).Count -eq 0) 'an empty cache yields no garbage'

# Sizes and the foreign-image report work off a real directory tree.
$gcRoot = Join-Path $tmp 'gcroot'
$vmDir = Join-Path $gcRoot 'qubix-box'
$cache = Join-Path (Join-Path $gcRoot 'images') 'box'
New-Item -ItemType Directory -Force -Path (Join-Path $cache 'local') | Out-Null
New-Item -ItemType Directory -Force -Path $vmDir | Out-Null
[System.IO.File]::WriteAllBytes((Join-Path (Join-Path $cache 'local') 'sys.vhdx'), (New-Object byte[] 2048))
[System.IO.File]::WriteAllBytes((Join-Path $vmDir 'qubix-box.vhdx'), (New-Object byte[] 512))
[System.IO.File]::WriteAllBytes((Join-Path $gcRoot 'staged.vhdx'), (New-Object byte[] 1024))

Assert-True ((Get-QubixPathSize -Path (Join-Path $cache 'local')) -eq 2048) 'cache size is measured recursively'
Assert-True ((Get-QubixPathSize -Path (Join-Path $tmp 'missing')) -eq 0) 'a missing path measures as zero'

$gcPaths = [PSCustomObject]@{ VmRoot = $gcRoot; VmDir = $vmDir; ImageCache = $cache }
$foreign = @(Get-QubixForeignImage -Paths $gcPaths)
Assert-True ($foreign.Count -eq 1) 'only hand-staged images are reported as foreign'
Assert-True ($foreign[0].Name -eq 'staged.vhdx') 'the staged image is the one reported'
Assert-True (-not ($foreign.Name -contains 'qubix-box.vhdx')) 'the live system disk is never reported as foreign'
Assert-True (-not ($foreign.Name -contains 'sys.vhdx')) 'the controller cache is not reported as foreign'
} finally {
Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Expand Down
Loading
Loading