ref:288d70a565109809fd3b9cb25982a0c7ac6f169c

feat(packaging): finish a Moonlight pairing from the command line

Pairing needed the web UI: Moonlight shows a PIN, and only a browser could give it to Sunshine. sunshine-qemu-pair lists the waiting requests, approves one with its PIN, and lists or removes paired clients, so a headless host or a script can finish the loop. It talks to an instance's API with the web UI credentials; CSRF only applies to browser-shaped requests, so no browser is needed. With several requests waiting it asks which one rather than guess. The e2e client gains --pin-file, which leaves its request waiting instead of approving its own PIN, so the whole loop is testable. REQ-DEP-004 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 288d70a565109809fd3b9cb25982a0c7ac6f169c
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 00:15
Parents: ee6140d
4 files changed +482 -4
Type
docs/qemu.md +27 −0
@@ -14,5 +14,6 @@
| `sunshine-qemu.sysusers` | creates the `sunshine-qemu` user of the system unit |
| `sunshine-qemu-wait-vm` | the units' `ExecStartPre`: waits until the VM's display is reachable |
| `sunshine-qemu-port` | picks non-overlapping base ports for VMs and checks existing configs |
| `sunshine-qemu-pair` | finishes a Moonlight pairing from the command line, with no web UI |
| `libvirt-hook-qemu` | libvirt hook that starts and stops the unit with the domain |
| `example.conf` | a commented per-VM configuration |
@@ -132,5 +133,31 @@
- **UPnP.** Set [upnp](configuration.md#upnp) to `disabled`: instances would ask the router for mappings on each
other's behalf.
- **Audio.** With `capture = qemu` Sunshine takes the sound from QEMU and creates no PulseAudio sinks.
## Pairing
Moonlight pairs with an instance once: it shows a four-digit PIN, and Sunshine keeps that request for five
minutes while it waits for the PIN. Each VM's instance has its own pairing, so a client pairs with each VM it
streams.
With the web UI, open `https://<host>:<port + 1>`, log in and enter the PIN under **PIN**. Without one, from the
host or a script:
```bash
# start pairing in Moonlight first, then:
sunshine-qemu-pair --vm win11 --password-file /etc/sunshine-qemu/win11.pass list
sunshine-qemu-pair --vm win11 --password-file /etc/sunshine-qemu/win11.pass accept 1234
```
`accept` takes the PIN Moonlight shows. With several requests waiting it stops and asks for `--id` or `--name`
rather than guess; `--wait <seconds>` blocks until a request appears, which is useful in scripts that start the
client themselves. `clients` lists what is paired, `unpair <uuid>` (or `--all`) removes it, and `cancel` drops a
request that is still waiting.
The password is the web UI password of that instance. Give it as `--password-file`, in
`SUNSHINE_QEMU_API_PASSWORD`, or let the command prompt for it; `--user` (default `sunshine`) sets the user.
`--vm <name>` reads the base port and certificate from `/etc/sunshine-qemu/<name>.conf`, `--port` names a base
port directly, and `--host` (default `127.0.0.1`) reaches another machine. Without a certificate to verify
against, the connection isn't verified, so keep it on the host unless the instance's `cert` is readable.
## systemd
packaging/linux/qemu/sunshine-qemu-pair +251 −0
@@ -1,0 +1,251 @@
#!/usr/bin/env bash
# Finish a Moonlight pairing from the command line, with no web UI.
#
# Moonlight starts pairing and shows a PIN; Sunshine keeps that request for five minutes and waits for the
# PIN. The web UI is one way to give it; this is the other, for headless hosts and scripts.
#
# Usage:
# sunshine-qemu-pair [target] [auth] list
# Print the pairing requests that are waiting for a PIN: id, client name and address.
# sunshine-qemu-pair [target] [auth] accept PIN [--id ID | --name NAME] [--wait SECONDS] [--label LABEL]
# Give a waiting request its PIN. With one request waiting it needs no --id or --name; --wait blocks
# until a request appears (default 0, fail right away). LABEL is the name Sunshine stores for the
# client (default: the name Moonlight sent).
# sunshine-qemu-pair [target] [auth] cancel [--id ID]
# Drop a waiting request.
# sunshine-qemu-pair [target] [auth] clients
# Print the paired clients: uuid, name and whether they're enabled.
# sunshine-qemu-pair [target] [auth] unpair UUID|--all
# Remove one paired client, or every one of them.
#
# target: --vm NAME read the base port from CONF_DIR/NAME.conf (default: the host's own instance)
# --port PORT base port of the instance (the web UI is PORT + 1)
# --host HOST host to reach it on (default: 127.0.0.1)
# auth: --user USER web UI user (default: $SUNSHINE_QEMU_API_USER, else sunshine)
# --password-file FILE read the password from FILE
# The password can also come from $SUNSHINE_QEMU_API_PASSWORD; otherwise it is prompted for.
#
# Environment:
# SUNSHINE_QEMU_CONF_DIR where --vm looks for NAME.conf (default: /etc/sunshine-qemu)
# SUNSHINE_QEMU_BASE_PORT base port when the config has none (default: 47989)
# SUNSHINE_QEMU_API_USER default web UI user
# SUNSHINE_QEMU_API_PASSWORD web UI password
# SUNSHINE_QEMU_CA_CERT certificate to verify the instance with (default: the config's `cert`,
# and without one the connection is unverified, which is why the default
# host is 127.0.0.1)
set -euo pipefail
conf_dir="${SUNSHINE_QEMU_CONF_DIR:-/etc/sunshine-qemu}"
host="127.0.0.1"
port=""
vm=""
user="${SUNSHINE_QEMU_API_USER:-sunshine}"
password="${SUNSHINE_QEMU_API_PASSWORD:-}"
password_file=""
ca_cert="${SUNSHINE_QEMU_CA_CERT:-}"
die() {
echo "sunshine-qemu-pair: $*" >&2
exit 1
}
usage() {
sed -n '2,/^set -euo/p' "$0" | sed 's/^# \{0,1\}//; $d'
exit "${1:-0}"
}
# Read one `key = value` from a Sunshine config file.
conf_value() {
local file="$1" key="$2"
[[ -r "${file}" ]] || return 1
sed -n "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*//p" "${file}" | tail -1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--vm) vm="${2:?--vm needs a name}"; shift 2 ;;
--port) port="${2:?--port needs a port}"; shift 2 ;;
--host) host="${2:?--host needs a host}"; shift 2 ;;
--user) user="${2:?--user needs a name}"; shift 2 ;;
--password-file) password_file="${2:?--password-file needs a path}"; shift 2 ;;
--conf-dir) conf_dir="${2:?--conf-dir needs a path}"; shift 2 ;;
--ca-cert) ca_cert="${2:?--ca-cert needs a path}"; shift 2 ;;
-h|--help) usage 0 ;;
--) shift; break ;;
-*) die "unknown option $1 (--help for usage)" ;;
*) break ;;
esac
done
command="${1:-}"
[[ -n "${command}" ]] || usage 1
shift
if [[ -n "${vm}" ]]; then
conf="${conf_dir}/${vm}.conf"
[[ -r "${conf}" ]] || die "can't read ${conf}"
if [[ -z "${port}" ]]; then
port="$(conf_value "${conf}" port || true)"
fi
if [[ -z "${ca_cert}" ]]; then
ca_cert="$(conf_value "${conf}" cert || true)"
fi
fi
port="${port:-${SUNSHINE_QEMU_BASE_PORT:-47989}}"
[[ "${port}" =~ ^[0-9]+$ ]] || die "port must be a number, got '${port}'"
api="https://${host}:$((port + 1))/api"
if [[ -n "${password_file}" ]]; then
[[ -r "${password_file}" ]] || die "can't read ${password_file}"
password="$(tr -d '\r\n' < "${password_file}")"
fi
if [[ -z "${password}" ]]; then
[[ -t 0 ]] || die "no password: use --password-file or SUNSHINE_QEMU_API_PASSWORD"
read -r -s -p "Password for ${user} on ${api}: " password
echo >&2
fi
# curl with the instance's certificate when there is one; an unverified connection otherwise.
call() {
local method="$1" path="$2" body="${3:-}"
local -a args=(--silent --show-error --fail-with-body --max-time 30 -X "${method}" -u "${user}:${password}")
if [[ -n "${ca_cert}" && -r "${ca_cert}" ]]; then
args+=(--cacert "${ca_cert}")
else
args+=(--insecure)
fi
if [[ -n "${body}" ]]; then
args+=(-H "Content-Type: application/json" -d "${body}")
fi
curl "${args[@]}" "${api}${path}"
}
# One field out of a flat JSON object. The API sorts its keys, so never assume an order.
field() {
printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p"
}
# Print `id<TAB>name<TAB>address` for every waiting request.
pending() {
local chunk id
call GET /pin | tr '{' '\n' | while IFS= read -r chunk || [[ -n "${chunk}" ]]; do
id="$(field "${chunk}" id)"
[[ "${id}" =~ ^[0-9a-f]{32}$ ]] || continue
printf '%s\t%s\t%s\n' "${id}" "$(field "${chunk}" name)" "$(field "${chunk}" address)"
done
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
case "${command}" in
list)
entries="$(pending)"
if [[ -z "${entries}" ]]; then
echo "no pairing requests are waiting"
exit 0
fi
printf 'ID\tCLIENT\tADDRESS\n%s\n' "${entries}"
;;
accept)
pin="${1:-}"
[[ "${pin}" =~ ^[0-9]{4}$ ]] || die "accept needs the 4-digit PIN Moonlight shows"
shift
want_id="" want_name="" label="" wait_for=0
while [[ $# -gt 0 ]]; do
case "$1" in
--id) want_id="${2:?--id needs a value}"; shift 2 ;;
--name) want_name="${2:?--name needs a value}"; shift 2 ;;
--label) label="${2:?--label needs a value}"; shift 2 ;;
--wait) wait_for="${2:?--wait needs seconds}"; shift 2 ;;
*) die "unknown argument $1" ;;
esac
done
[[ "${wait_for}" =~ ^[0-9]+$ ]] || die "--wait takes seconds"
deadline=$((SECONDS + wait_for))
while :; do
matches="$(pending)"
if [[ -n "${want_id}" ]]; then
matches="$(printf '%s\n' "${matches}" | awk -F'\t' -v id="${want_id}" '$1 == id')"
elif [[ -n "${want_name}" ]]; then
matches="$(printf '%s\n' "${matches}" | awk -F'\t' -v n="${want_name}" '$2 == n')"
fi
matches="$(printf '%s' "${matches}" | sed '/^$/d')"
[[ -n "${matches}" ]] && break
((SECONDS < deadline)) || die "no pairing request is waiting; start pairing in Moonlight first"
sleep 1
done
count="$(printf '%s\n' "${matches}" | wc -l)"
if ((count > 1)); then
echo "more than one request is waiting; pick one with --id or --name:" >&2
printf '%s\n' "${matches}" >&2
exit 1
fi
id="$(printf '%s' "${matches}" | cut -f1)"
name="$(printf '%s' "${matches}" | cut -f2)"
label="${label:-${name}}"
echo "approving '${name}' (${id})" >&2
result="$(call POST /pin "{\"pairing_id\":\"${id}\",\"pin\":\"${pin}\",\"name\":\"$(json_escape "${label}")\"}")"
if [[ "${result}" == *'"status":true'* ]]; then
echo "paired '${label}'"
else
die "pairing failed: ${result}"
fi
;;
cancel)
want_id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) want_id="${2:?--id needs a value}"; shift 2 ;;
*) die "unknown argument $1" ;;
esac
done
if [[ -z "${want_id}" ]]; then
entries="$(pending)"
[[ -n "${entries}" ]] || die "no pairing request is waiting"
(($(printf '%s\n' "${entries}" | wc -l) == 1)) || die "more than one request is waiting; pick one with --id"
want_id="$(printf '%s' "${entries}" | cut -f1)"
fi
call DELETE /pin "{\"pairing_id\":\"${want_id}\"}" > /dev/null
echo "cancelled ${want_id}"
;;
clients)
entries="$(call GET /clients/list | tr '{' '\n' | while IFS= read -r chunk || [[ -n "${chunk}" ]]; do
uuid="$(field "${chunk}" uuid)"
[[ -n "${uuid}" ]] || continue
case "${chunk}" in
*'"enabled":false'*) state=disabled ;;
*) state=enabled ;;
esac
printf '%s\t%s\t%s\n' "${uuid}" "$(field "${chunk}" name)" "${state}"
done)"
if [[ -n "${entries}" ]]; then
printf 'UUID\tCLIENT\tSTATE\n%s\n' "${entries}"
else
echo "no paired clients"
fi
;;
unpair)
target="${1:-}"
[[ -n "${target}" ]] || die "unpair needs a uuid or --all"
if [[ "${target}" == "--all" ]]; then
call POST /clients/unpair-all "{}" > /dev/null
echo "unpaired every client"
else
call POST /clients/unpair "{\"uuid\":\"$(json_escape "${target}")\"}" > /dev/null
echo "unpaired ${target}"
fi
;;
*)
die "unknown command '${command}' (--help for usage)"
;;
esac
tests/e2e/moonlight_client/main.cpp +16 −1
@@ -68,6 +68,7 @@
int port {47989}; ///< Sunshine base port (HTTP).
std::string api_user; ///< Web UI user.
std::string api_pass; ///< Web UI password.
std::string pin_file; ///< When set, write the PIN here and let someone else approve it.
std::string state_dir {"e2e-client"}; ///< Directory for the client identity.
int width {1280}; ///< Stream width.
int height {800}; ///< Stream height.
@@ -622,6 +623,7 @@
{"--ready-file", &opts.ready_file},
{"--wait-file", &opts.wait_file},
{"--input", &opts.input},
{"--pin-file", &opts.pin_file},
};
std::map<std::string, int *> ints {
{"--port", &opts.port},
@@ -695,7 +697,20 @@
std::snprintf(pin, sizeof(pin), "%04d", ((pin_bytes[0] << 8) | pin_bytes[1]) % 10000);
std::fprintf(stderr, "e2e: pairing as %s\n", device_name.c_str());
if (!client.pair(pin, device_name, [&]() {
if (opts.pin_file.empty()) {
return approve_pin(opts, device_name, pin);
}
// Leave the request waiting for someone else (a person, or sunshine-qemu-pair) to give it the PIN.
const auto tmp = opts.pin_file + ".tmp";
std::ofstream out {tmp};
out << pin;
return approve_pin(opts, device_name, pin);
out.close();
if (!out || std::rename(tmp.c_str(), opts.pin_file.c_str()) != 0) {
std::fprintf(stderr, "e2e: couldn't write the PIN to %s\n", opts.pin_file.c_str());
return false;
}
std::fprintf(stderr, "e2e: PIN %s for '%s' is waiting for approval\n", pin, device_name.c_str());
return true;
})) {
std::fprintf(stderr, "e2e: pairing failed\n");
return 1;
tests/packaging/linux/qemu/test_sunshine_qemu.sh +188 −3
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-DEP-001, REQ-DEP-002]
# @tag requirements: [REQ-DEP-001, REQ-DEP-002, REQ-DEP-004]
# Tests for packaging/linux/qemu: the systemd units, the libvirt hook (with a fake systemctl), the
# VM wait helper (with fake gdbus and virsh) and the port allocation helper. Nothing is installed and no
# VM wait helper (with fake gdbus and virsh), the port allocation helper and the pairing helper (with a
# fake curl). Nothing is installed and no
# real systemd or libvirt service is started.
#
# Usage: test_sunshine_qemu.sh [test-name...] (default: every test_* function)
@@ -372,13 +373,197 @@
}
# @tag requirements: [REQ-DEP-002]
# a fake curl that records its arguments and answers from ${tmp}/curl.<method><path with / as _>
# e.g. ${tmp}/curl.GET_pin for `GET /api/pin`. Missing files answer with an empty object.
# shellcheck disable=SC2016 # the fake's body is a script; its $... must reach it unexpanded
fake_curl() {
fake_bin curl '
method=""; url=""; body=""
while [[ $# -gt 0 ]]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-d) body="$2"; shift 2 ;;
-u|-H|--cacert|--max-time) echo "$1 $2" >> "'"${tmp}"'/curl.args"; shift 2 ;;
--insecure) echo "insecure" >> "'"${tmp}"'/curl.args"; shift ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
echo "${method} ${url} ${body}" >> "'"${tmp}"'/curl.log"
key="${url#*/api}"
file="'"${tmp}"'/curl.${method}${key//\//_}"
if [[ -r "${file}" ]]; then cat "${file}"; else echo "{}"; fi
'
}
run_pair() {
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_CONF_DIR="${tmp}/etc" SUNSHINE_QEMU_API_PASSWORD=secret \
"${pkg}/sunshine-qemu-pair" "$@"
}
# The API sorts its JSON keys, so the fixtures are in that order, not the order the fields are printed in.
pending_json() {
printf '{"pairings":[%s]}' "$1" > "${tmp}/curl.GET_pin"
}
# @tag requirements: [REQ-DEP-004]
test_pair_lists_the_waiting_requests() {
fake_curl
pending_json '{"address":"192.168.1.5","id":"0123456789abcdef0123456789abcdef","name":"Living room"},{"address":"127.0.0.1","id":"fedcba9876543210fedcba9876543210","name":"Laptop"}'
local output
output="$(run_pair --port 47989 list)" || fail "list failed: ${output}" || return 1
grep -q "^0123456789abcdef0123456789abcdef Living room 192.168.1.5$" <<< "${output}" || fail "first request missing from: ${output}"
grep -q "^fedcba9876543210fedcba9876543210 Laptop 127.0.0.1$" <<< "${output}" || fail "second request missing from: ${output}"
}
# @tag requirements: [REQ-DEP-004]
test_pair_says_when_nothing_is_waiting() {
fake_curl
pending_json ''
local output
output="$(run_pair --port 47989 list)" || fail "list failed: ${output}" || return 1
expect_eq "${output}" "no pairing requests are waiting" "empty list"
}
# @tag requirements: [REQ-DEP-004]
test_pair_approves_the_only_waiting_request() {
fake_curl
pending_json '{"address":"127.0.0.1","id":"0123456789abcdef0123456789abcdef","name":"Laptop"}'
printf '{"status":true}' > "${tmp}/curl.POST_pin"
local output
output="$(run_pair --port 47989 accept 1234 2>/dev/null)" || fail "accept failed: ${output}" || return 1
expect_eq "${output}" "paired 'Laptop'" "accept output" || return 1
local post
post="$(grep '^POST ' "${tmp}/curl.log")"
grep -q 'https://127.0.0.1:47990/api/pin' <<< "${post}" || fail "wrong URL: ${post}" || return 1
grep -q '"pairing_id":"0123456789abcdef0123456789abcdef"' <<< "${post}" || fail "wrong pairing id: ${post}" || return 1
grep -q '"pin":"1234"' <<< "${post}" || fail "wrong pin: ${post}" || return 1
grep -q '"name":"Laptop"' <<< "${post}" || fail "wrong name: ${post}"
}
# @tag requirements: [REQ-DEP-004]
test_pair_refuses_to_guess_between_requests() {
fake_curl
pending_json '{"address":"127.0.0.1","id":"0123456789abcdef0123456789abcdef","name":"Laptop"},{"address":"192.168.1.5","id":"fedcba9876543210fedcba9876543210","name":"Phone"}'
local output
if output="$(run_pair --port 47989 accept 1234 2>&1)"; then
fail "accept must not pick one of two requests"
return 1
fi
grep -q "more than one request is waiting" <<< "${output}" || fail "no explanation: ${output}" || return 1
[[ ! -f "${tmp}/curl.log" ]] || ! grep -q '^POST ' "${tmp}/curl.log" || fail "it approved one anyway"
}
# @tag requirements: [REQ-DEP-004]
test_pair_picks_the_named_request() {
fake_curl
pending_json '{"address":"127.0.0.1","id":"0123456789abcdef0123456789abcdef","name":"Laptop"},{"address":"192.168.1.5","id":"fedcba9876543210fedcba9876543210","name":"Phone"}'
printf '{"status":true}' > "${tmp}/curl.POST_pin"
run_pair --port 47989 accept 4321 --name Phone > /dev/null 2>&1 || fail "accept --name failed" || return 1
grep -q '"pairing_id":"fedcba9876543210fedcba9876543210"' "${tmp}/curl.log" || fail "approved the wrong request: $(grep '^POST ' "${tmp}/curl.log")" || return 1
grep -q '"name":"Phone"' "${tmp}/curl.log" || fail "stored the wrong name"
}
# @tag requirements: [REQ-DEP-004]
test_pair_stores_the_label_when_given_one() {
fake_curl
pending_json '{"address":"127.0.0.1","id":"0123456789abcdef0123456789abcdef","name":"Laptop"}'
printf '{"status":true}' > "${tmp}/curl.POST_pin"
run_pair --port 47989 accept 1234 --label "Cole's laptop" > /dev/null 2>&1 || fail "accept --label failed" || return 1
grep -q "\"name\":\"Cole's laptop\"" "${tmp}/curl.log" || fail "label not stored: $(grep '^POST ' "${tmp}/curl.log")"
}
# @tag requirements: [REQ-DEP-004]
test_pair_fails_when_sunshine_rejects_the_pin() {
fake_curl
pending_json '{"address":"127.0.0.1","id":"0123456789abcdef0123456789abcdef","name":"Laptop"}'
printf '{"status":false}' > "${tmp}/curl.POST_pin"
local output
if output="$(run_pair --port 47989 accept 9999 2>&1)"; then
fail "a rejected PIN must fail"
return 1
fi
grep -q "pairing failed" <<< "${output}" || fail "unclear message: ${output}"
}
# @tag requirements: [REQ-DEP-004]
test_pair_reads_the_port_and_certificate_from_a_vm_config() {
fake_curl
mkdir -p "${tmp}/etc"
printf 'port = 48189\ncert = %s/etc/win11.crt\n' "${tmp}" > "${tmp}/etc/win11.conf"
: > "${tmp}/etc/win11.crt"
pending_json ''
run_pair --vm win11 list > /dev/null || fail "list through --vm failed" || return 1
grep -q 'https://127.0.0.1:48190/api/pin' "${tmp}/curl.log" || fail "wrong port: $(cat "${tmp}/curl.log")" || return 1
grep -q -- "--cacert ${tmp}/etc/win11.crt" "${tmp}/curl.args" || fail "didn't verify with the instance's certificate: $(cat "${tmp}/curl.args")"
}
# @tag requirements: [REQ-DEP-004]
test_pair_waits_for_a_request_to_appear() {
fake_curl
pending_json ''
printf '{"status":true}' > "${tmp}/curl.POST_pin"
# the request shows up only on the third poll; the fake's body is a script, so its $(...) stays unexpanded
# shellcheck disable=SC2016
fake_bin curl '
method=""; url=""; body=""
while [[ $# -gt 0 ]]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-d) body="$2"; shift 2 ;;
-u|-H|--cacert|--max-time) shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
echo "${method} ${url} ${body}" >> "'"${tmp}"'/curl.log"
if [[ "${method}" == POST ]]; then echo "{\"status\":true}"; exit 0; fi
count=$(( $(cat "'"${tmp}"'/polls" 2> /dev/null || echo 0) + 1 ))
echo "${count}" > "'"${tmp}"'/polls"
if (( count < 3 )); then
echo "{\"pairings\":[]}"
else
echo "{\"pairings\":[{\"address\":\"127.0.0.1\",\"id\":\"0123456789abcdef0123456789abcdef\",\"name\":\"Laptop\"}]}"
fi
'
local output
output="$(run_pair --port 47989 accept 1234 --wait 30 2>/dev/null)" || fail "accept --wait failed: ${output}" || return 1
expect_eq "${output}" "paired 'Laptop'" "accept --wait output" || return 1
expect_eq "$(cat "${tmp}/polls")" "3" "number of polls"
}
# @tag requirements: [REQ-DEP-004]
test_pair_lists_and_removes_paired_clients() {
fake_curl
printf '{"named_certs":[{"enabled":true,"name":"Laptop","uuid":"AAAA-1"},{"enabled":false,"name":"Phone","uuid":"BBBB-2"}],"status":true}' > "${tmp}/curl.GET_clients_list"
local output
output="$(run_pair --port 47989 clients)" || fail "clients failed: ${output}" || return 1
grep -q "^AAAA-1 Laptop enabled$" <<< "${output}" || fail "enabled client missing: ${output}" || return 1
grep -q "^BBBB-2 Phone disabled$" <<< "${output}" || fail "disabled client missing: ${output}" || return 1
run_pair --port 47989 unpair AAAA-1 > /dev/null || fail "unpair failed" || return 1
grep -q 'POST https://127.0.0.1:47990/api/clients/unpair {"uuid":"AAAA-1"}' "${tmp}/curl.log" || fail "wrong unpair request: $(cat "${tmp}/curl.log")" || return 1
run_pair --port 47989 unpair --all > /dev/null || fail "unpair --all failed" || return 1
grep -q 'POST https://127.0.0.1:47990/api/clients/unpair-all' "${tmp}/curl.log" || fail "no unpair-all request"
}
# @tag requirements: [REQ-DEP-004]
test_pair_needs_a_password() {
fake_curl
local output
if output="$(PATH="${tmp}/bin:${PATH}" "${pkg}/sunshine-qemu-pair" --port 47989 list < /dev/null 2>&1)"; then
fail "it must not run without a password"
return 1
fi
grep -q "no password" <<< "${output}" || fail "unclear message: ${output}"
}
test_scripts_are_shellcheck_clean() {
[[ -x "${shellcheck_bin}" ]] || {
failure="skip: shellcheck isn't installed"
return 2
}
local output
output="$("${shellcheck_bin}" "${pkg}/libvirt-hook-qemu" "${pkg}/sunshine-qemu-wait-vm" "${pkg}/sunshine-qemu-port" "${BASH_SOURCE[0]}" 2>&1)" || fail "${output}"
output="$("${shellcheck_bin}" "${pkg}/libvirt-hook-qemu" "${pkg}/sunshine-qemu-wait-vm" "${pkg}/sunshine-qemu-port" "${pkg}/sunshine-qemu-pair" "${BASH_SOURCE[0]}" 2>&1)" || fail "${output}"
}
names=("$@")