diff --git a/docs/changelog-20260713-opnsense-api-client.md b/docs/changelog-20260713-opnsense-api-client.md new file mode 100644 index 0000000..1a03851 --- /dev/null +++ b/docs/changelog-20260713-opnsense-api-client.md @@ -0,0 +1,63 @@ +# 2026-07-13 -- D-113(a2) step 1: a thin OPNsense REST API client + +First delivery under the D-113 ruling (ADOPTED: option (a2) -- stay on OPNsense, move +configuration OFF hand-authored `config.xml` and ONTO the documented REST API). + +## What shipped + +- `scripts/opnsense-api.sh` -- thin REST client. `[--dry-run] [json-body]`. +- `tests/opnsense-api/run-tests.sh` -- offline harness, **21 PASS**, no network, no real key. +- Gauntlet ALL GREEN (**53** harnesses, was 52). `repo-lint` 0 fail. + +## Why this exists (the D-113 argument, in one line) + +Hand-authoring the appliance's GUI-owned `config.xml` was the SINGLE root cause of DOCFIX-191 +(no sshd/key -> lockout), DOCFIX-192 (no console), DOCFIX-193 (no DHCP), and the 667-element +migration self-heal we ended up depending on. **None of those is expressible through the API**, +where sshd, DHCP and firewall rules are typed resources with defaults. You cannot forget to +enable sshd in a format where sshd is a typed field. + +## Design decisions worth keeping + +**The secret never reaches argv.** Credentials are read from a file and passed to `curl` via +`--config` on STDIN. `curl -u key:secret` would put the secret in the process's argv, where +ANY user on the box can read it out of `ps`. Harness case **T9** stubs `curl` with an argv +recorder and asserts the secret is absent from argv and present on stdin -- so a future +"simplification" to `curl -u` turns the harness red. That test is the point of the file. + +**The API host is never inferred** (hard rule 2). `$OPNSENSE_API_HOST` unset is a loud failure, +not a default of `10.10.0.1`. Harness T3. + +**`--insecure` is deliberate and scoped.** The edge presents a self-signed cert that OPNsense +REGENERATES ON EVERY BOOT (measured: "Created web GUI TLS certificate" appears in the config +revision trail after each reboot), so pinning it is pointless. Acceptable ONLY because the +transport is a private lab LAN leg (virbr2). Not to be copied to anything tenant-facing. + +**Dry-run reads no credentials** (T11), which is what lets the harness prove URL construction +offline. + +## Not done yet -- the blocking step + +**The API is alive but has no key.** Measured on the live edge 2026-07-13: lighttpd IS listening +on 443/80 and the API answers 401 (i.e. it is up and demanding auth), and `root` has **0 API +keys**. + +Minting a key must happen in the **GUI** (System > Access > Users > root > API keys > "+"). +That is deliberate: writing `apikeys` into `config.xml` by hand would be the exact anti-pattern +D-113 just retired. The downloaded key/secret file goes to +`~/vr1-office1-creds/opnsense-api.txt` (operator-only; never read into agent context -- SEC-007 +covers that directory). + +Once the key exists, the proof-of-path is: drive the DHCP subnet we pushed by hand yesterday +through the API instead, and confirm it round-trips. If the API can express it, the template +retires to a MINIMAL bootstrap config (sshd + root key + console + the API key) and everything +else -- DHCP, firewall, interfaces -- moves to the API. If it cannot, that is the signal that +OPNsense is not buying the abstraction we are paying for, and D-113 gets revisited. + +## Revert + + git rm scripts/opnsense-api.sh + git rm -r tests/opnsense-api/ + +Nothing live is touched by this change; the client has never been run against the edge (no key +exists yet). diff --git a/docs/session-ledger.md b/docs/session-ledger.md index 5baa343..7618d68 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -1968,6 +1968,21 @@ one via the GUI is the bootstrap step (the supported path; hand-editing apikeys into config.xml would be the very anti-pattern being retired). +- **D-113(a2) step 1 SHIPPED:** `scripts/opnsense-api.sh` + `tests/opnsense-api/run-tests.sh` + (21 PASS; gauntlet 53 ALL GREEN). Thin REST client; secret passed to curl via `--config` on + STDIN so it never lands in argv/`ps` (harness T9 enforces this -- a future `curl -u` + "simplification" turns it red). Host never inferred (T3). `--insecure` is deliberate: the + edge's self-signed cert is REGENERATED EVERY BOOT, so pinning is pointless; scoped to the + private lab LAN leg only. + **BLOCKED ON:** an API key. Measured: API is alive (401 from lighttpd on 443) but root has + **0 apikeys**. Minting is a GUI action (System > Access > Users > root > API keys > "+") -- + deliberately NOT automated, since hand-writing apikeys into config.xml is the exact + anti-pattern D-113 retired. Key file -> `~/vr1-office1-creds/opnsense-api.txt` (operator-only, + never into agent context; SEC-007 covers that dir). + **Next after the key:** drive yesterday's DHCP subnet through the API as the proof-of-path. + If it round-trips, the template retires to a MINIMAL bootstrap (sshd + key + console + apikey) + and DHCP/firewall/interfaces move to the API. If it cannot express it, D-113 gets revisited. + ## State facts to remember - beta cluster left at **node_count=2** (deliberate; bonus resize acceptance coverage). diff --git a/scripts/opnsense-api.sh b/scripts/opnsense-api.sh new file mode 100755 index 0000000..4f9d9af --- /dev/null +++ b/scripts/opnsense-api.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# scripts/opnsense-api.sh [--dry-run] [json-body] +# +# Thin REST client for the OPNsense API -- the D-113 (a2) config path. +# +# D-113 ADOPTED (a2): configuration of the edge moves OFF hand-authored +# config.xml and ONTO the documented REST API. Hand-editing the appliance's +# GUI-owned config.xml was the single root cause of DOCFIX-191 (no sshd/key), +# DOCFIX-192 (no console) and DOCFIX-193 (no DHCP), plus the 667-element +# migration self-heal we ended up depending on. None of those is expressible +# through the API, where sshd/DHCP/rules are typed resources with defaults. +# +# Credentials: read from a file (NEVER from argv, NEVER echoed). Mint the key +# in the GUI -- System > Access > Users > root > API keys > "+" -- which +# downloads a file containing: +# key=... +# secret=... +# Save it to $OPNSENSE_API_CREDS (default ~/vr1-office1-creds/opnsense-api.txt). +# The secret is passed to curl via --config on STDIN, so it never appears in +# argv (i.e. never in `ps`). Do not "improve" this into `curl -u`. +# +# TLS: the edge presents a SELF-SIGNED cert that OPNsense REGENERATES ON EVERY +# BOOT (measured 2026-07-13 -- "Created web GUI TLS certificate" appears in the +# config revision trail after each boot). Pinning it is therefore pointless, so +# we use --insecure. This is acceptable ONLY because the transport is a private +# lab LAN leg (virbr2). Do NOT copy this flag to anything tenant-facing. +# +# Exit: 0 ok | 1 usage/credential/config error | 2 HTTP error from the API. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +: "${OPNSENSE_API_CREDS:=$HOME/vr1-office1-creds/opnsense-api.txt}" + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; shift; fi + +METHOD="${1:-}" +APIPATH="${2:-}" +BODY="${3:-}" + +usage() { + echo "usage: opnsense-api.sh [--dry-run] [json-body]" >&2 + echo " e.g. opnsense-api.sh GET core/firmware/status" >&2 + echo " opnsense-api.sh POST kea/dhcpv4/set '{\"dhcpv4\":{...}}'" >&2 + exit 1 +} + +[ -n "$METHOD" ] && [ -n "$APIPATH" ] || usage +case "$METHOD" in + GET|POST) ;; + *) echo "FAIL: method must be GET or POST (got '$METHOD')" >&2; usage ;; +esac + +# The API host is NEVER inferred (hard rule 2): it is measured and passed in. +if [ -z "${OPNSENSE_API_HOST:-}" ]; then + echo "FAIL: \$OPNSENSE_API_HOST not set (e.g. 10.10.0.1) -- refusing to guess the edge address" >&2 + exit 1 +fi + +# Leading slashes on the path are a common typo; normalize rather than 404. +APIPATH="${APIPATH#/}" +APIPATH="${APIPATH#api/}" +URL="https://${OPNSENSE_API_HOST}/api/${APIPATH}" + +if [ "$DRY_RUN" = "1" ]; then + # Dry-run exists so the harness can prove URL construction WITHOUT credentials + # and without touching the network. It must never read the creds file. + echo "DRY-RUN ${METHOD} ${URL}" + [ -n "$BODY" ] && echo "DRY-RUN body: ${BODY}" + exit 0 +fi + +[ -f "$OPNSENSE_API_CREDS" ] || { + echo "FAIL: credentials file not found: $OPNSENSE_API_CREDS" >&2 + echo " Mint one in the GUI: System > Access > Users > root > API keys > '+'" >&2 + exit 1 +} + +# Parse key/secret WITHOUT echoing them. Tolerate OPNsense's downloaded format +# (key=...\nsecret=...), CRLF, and surrounding quotes. +API_KEY="$(sed -n 's/\r$//; s/^[[:space:]]*key[[:space:]]*=[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$OPNSENSE_API_CREDS" | head -1)" +API_SECRET="$(sed -n 's/\r$//; s/^[[:space:]]*secret[[:space:]]*=[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$OPNSENSE_API_CREDS" | head -1)" + +[ -n "$API_KEY" ] || { echo "FAIL: no 'key=' line in $OPNSENSE_API_CREDS" >&2; exit 1; } +[ -n "$API_SECRET" ] || { echo "FAIL: no 'secret=' line in $OPNSENSE_API_CREDS" >&2; exit 1; } + +# Secret goes to curl over STDIN via --config, so it is absent from argv/ps. +CURL_ARGS=(--silent --show-error --insecure --config - --request "$METHOD" --url "$URL" + --write-out '\n__HTTP_STATUS__:%{http_code}\n') +if [ -n "$BODY" ]; then + CURL_ARGS+=(--header 'Content-Type: application/json' --data "$BODY") +fi + +RESP="$(printf 'user = "%s:%s"\n' "$API_KEY" "$API_SECRET" | curl "${CURL_ARGS[@]}")" || { + echo "FAIL: curl could not reach $URL" >&2 + exit 2 +} + +STATUS="$(printf '%s' "$RESP" | sed -n 's/^__HTTP_STATUS__:\([0-9]*\)$/\1/p' | tail -1)" +printf '%s\n' "$RESP" | grep -v '^__HTTP_STATUS__:' + +case "$STATUS" in + 2*) exit 0 ;; + 401|403) + echo "FAIL: HTTP $STATUS -- the API rejected the credential." >&2 + echo " Note OPNsense API keys authenticate as their OWNING USER; a GUI password will NOT work here." >&2 + exit 2 ;; + "") + echo "FAIL: no HTTP status returned from $URL" >&2 + exit 2 ;; + *) + echo "FAIL: HTTP $STATUS from $URL" >&2 + exit 2 ;; +esac diff --git a/tests/carve-host-interfaces/fakebin/maas b/tests/carve-host-interfaces/fakebin/maas old mode 100644 new mode 100755 diff --git a/tests/opnsense-api/run-tests.sh b/tests/opnsense-api/run-tests.sh new file mode 100755 index 0000000..8453d46 --- /dev/null +++ b/tests/opnsense-api/run-tests.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# tests/opnsense-api/run-tests.sh -- offline harness for scripts/opnsense-api.sh. +# +# Tests the guard clauses, URL construction, and credential parsing WITHOUT +# touching the network and WITHOUT a real credential. The fixture key/secret +# below are throwaway fakes, never real key material. +# +# The load-bearing case is T9: the secret must never reach argv. We assert that +# by stubbing `curl` with a recorder that dumps its own argv -- if a future edit +# "simplifies" the script to `curl -u key:secret`, T9 goes red. That is the +# point: the secret is visible in `ps` for anyone on the box otherwise. +# +# Exit: 0 all pass | 1 any case failed. ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$HERE/../../scripts/opnsense-api.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 + +FAKE_KEY="FAKEKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +FAKE_SECRET="FAKESECRETbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +ok() { PASS=$((PASS+1)); echo " PASS $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL $1"; } +check(){ if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (want '$3', got '$2')"; fi; } + +mkcreds() { printf 'key=%s\nsecret=%s\n' "$FAKE_KEY" "$FAKE_SECRET" > "$1"; } + +echo "T1: no args -> usage, exit 1" +out="$(bash "$SCRIPT" 2>&1)"; rc=$? +check "T1 exit" "$rc" "1" +case "$out" in *usage*) ok "T1 prints usage";; *) bad "T1 usage text";; esac + +echo "T2: bad method rejected" +OPNSENSE_API_HOST=10.10.0.1 out="$(bash "$SCRIPT" DELETE core/firmware/status 2>&1)"; rc=$? +check "T2 exit" "$rc" "1" +case "$out" in *"must be GET or POST"*) ok "T2 method guard";; *) bad "T2 method guard";; esac + +echo "T3: host NEVER inferred -- unset OPNSENSE_API_HOST must fail loud" +out="$(env -u OPNSENSE_API_HOST bash "$SCRIPT" GET core/firmware/status 2>&1)"; rc=$? +check "T3 exit" "$rc" "1" +case "$out" in *OPNSENSE_API_HOST*) ok "T3 refuses to guess the edge address";; *) bad "T3 host guard";; esac + +echo "T4: dry-run builds the right URL" +out="$(OPNSENSE_API_HOST=10.10.0.1 bash "$SCRIPT" --dry-run GET core/firmware/status 2>&1)"; rc=$? +check "T4 exit" "$rc" "0" +check "T4 url" "$out" "DRY-RUN GET https://10.10.0.1/api/core/firmware/status" + +echo "T5: leading '/' and 'api/' in the path are normalized, not 404'd" +out="$(OPNSENSE_API_HOST=10.10.0.1 bash "$SCRIPT" --dry-run GET /api/kea/dhcpv4/get 2>&1)" +check "T5 url" "$out" "DRY-RUN GET https://10.10.0.1/api/kea/dhcpv4/get" + +echo "T6: missing creds file -> fail loud, exit 1" +out="$(OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/nope.txt" bash "$SCRIPT" GET core/firmware/status 2>&1)"; rc=$? +check "T6 exit" "$rc" "1" +case "$out" in *"not found"*) ok "T6 says which file is missing";; *) bad "T6 message";; esac + +echo "T7: creds file with no key= -> fail loud" +printf 'secret=%s\n' "$FAKE_SECRET" > "$TMP/nokey.txt" +out="$(OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/nokey.txt" bash "$SCRIPT" GET core/firmware/status 2>&1)"; rc=$? +check "T7 exit" "$rc" "1" +case "$out" in *"no 'key='"*) ok "T7 detects missing key";; *) bad "T7 message";; esac + +echo "T8: creds file with no secret= -> fail loud" +printf 'key=%s\n' "$FAKE_KEY" > "$TMP/nosecret.txt" +out="$(OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/nosecret.txt" bash "$SCRIPT" GET core/firmware/status 2>&1)"; rc=$? +check "T8 exit" "$rc" "1" +case "$out" in *"no 'secret='"*) ok "T8 detects missing secret";; *) bad "T8 message";; esac + +echo "T9: THE SECRET MUST NEVER REACH ARGV (it would show up in \`ps\`)" +mkcreds "$TMP/creds.txt" +# stub curl: record argv, then emit a plausible response + status line +cat > "$TMP/curl" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$TMP_ARGV" +cat > "$TMP_STDIN" +echo '{"status":"ok"}' +echo '__HTTP_STATUS__:200' +STUB +chmod +x "$TMP/curl" +TMP_ARGV="$TMP/argv.txt" TMP_STDIN="$TMP/stdin.txt" \ + PATH="$TMP:$PATH" OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/creds.txt" \ + bash "$SCRIPT" GET core/firmware/status >/dev/null 2>&1 +if grep -q "$FAKE_SECRET" "$TMP/argv.txt" 2>/dev/null; then + bad "T9 SECRET LEAKED INTO ARGV -- visible in ps" +else + ok "T9 secret absent from argv" +fi +if grep -q "$FAKE_SECRET" "$TMP/stdin.txt" 2>/dev/null; then + ok "T9 secret passed via stdin --config (correct)" +else + bad "T9 secret did not reach curl via stdin -- auth would fail" +fi + +echo "T10: the secret is never printed to stdout/stderr" +out="$(TMP_ARGV="$TMP/argv2.txt" TMP_STDIN="$TMP/stdin2.txt" \ + PATH="$TMP:$PATH" OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/creds.txt" \ + bash "$SCRIPT" GET core/firmware/status 2>&1)" +case "$out" in + *"$FAKE_SECRET"*) bad "T10 SECRET PRINTED to output";; + *"$FAKE_KEY"*) bad "T10 KEY PRINTED to output";; + *) ok "T10 neither key nor secret appears in output";; +esac + +echo "T11: dry-run must NOT read the creds file at all" +out="$(OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/nope.txt" \ + bash "$SCRIPT" --dry-run GET core/firmware/status 2>&1)"; rc=$? +check "T11 exit (no creds needed)" "$rc" "0" + +echo "T12: a 401 from the API is surfaced as exit 2, not silently swallowed" +cat > "$TMP/curl" <<'STUB' +#!/usr/bin/env bash +cat > /dev/null +echo '{"status":"denied"}' +echo '__HTTP_STATUS__:401' +STUB +chmod +x "$TMP/curl" +out="$(PATH="$TMP:$PATH" OPNSENSE_API_HOST=10.10.0.1 OPNSENSE_API_CREDS="$TMP/creds.txt" \ + bash "$SCRIPT" GET core/firmware/status 2>&1)"; rc=$? +check "T12 exit" "$rc" "2" +case "$out" in *401*) ok "T12 reports the 401";; *) bad "T12 message";; esac + +echo +echo "RESULT: PASS=$PASS FAIL=$FAIL" +[ "$FAIL" -eq 0 ] && { echo "ALL PASS"; exit 0; } || { echo "FAILURES"; exit 1; } diff --git a/tests/phase-00-maas-standup/fakebin/maas b/tests/phase-00-maas-standup/fakebin/maas old mode 100644 new mode 100755 diff --git a/tests/phase-02/fakebin/jq b/tests/phase-02/fakebin/jq old mode 100644 new mode 100755 diff --git a/tests/phase-02/fakebin/juju b/tests/phase-02/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-03-adminrc/fakebin/juju b/tests/phase-03-adminrc/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-03-adminrc/fakebin/openssl b/tests/phase-03-adminrc/fakebin/openssl old mode 100644 new mode 100755 diff --git a/tests/phase-03-adminrc/fakebin/openstack b/tests/phase-03-adminrc/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-03/fakebin/jq b/tests/phase-03/fakebin/jq old mode 100644 new mode 100755 diff --git a/tests/phase-03/fakebin/juju b/tests/phase-03/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-04-create/fakebin/maas b/tests/phase-04-create/fakebin/maas old mode 100644 new mode 100755 diff --git a/tests/phase-04-create/fakebin/openstack b/tests/phase-04-create/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-04-internal-cert-san/fakebin/juju b/tests/phase-04-internal-cert-san/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-04-internal-cert-san/fakebin/openstack b/tests/phase-04-internal-cert-san/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-04/fakebin/maas b/tests/phase-04/fakebin/maas old mode 100644 new mode 100755 diff --git a/tests/phase-04/fakebin/openstack b/tests/phase-04/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-05-amphora/fakebin/curl b/tests/phase-05-amphora/fakebin/curl old mode 100644 new mode 100755 diff --git a/tests/phase-05-amphora/fakebin/juju b/tests/phase-05-amphora/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-05-amphora/fakebin/openstack b/tests/phase-05-amphora/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-05-amphora/fakebin/sha256sum b/tests/phase-05-amphora/fakebin/sha256sum old mode 100644 new mode 100755 diff --git a/tests/phase-05-amphora/fakebin/wget b/tests/phase-05-amphora/fakebin/wget old mode 100644 new mode 100755 diff --git a/tests/phase-05/fakebin/juju b/tests/phase-05/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-05/fakebin/openstack b/tests/phase-05/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-06-bootstrap/fakebin/curl b/tests/phase-06-bootstrap/fakebin/curl old mode 100644 new mode 100755 diff --git a/tests/phase-06-bootstrap/fakebin/openstack b/tests/phase-06-bootstrap/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-06-bootstrap/fakebin/sha256sum b/tests/phase-06-bootstrap/fakebin/sha256sum old mode 100644 new mode 100755 diff --git a/tests/phase-06-bootstrap/fakebin/wget b/tests/phase-06-bootstrap/fakebin/wget old mode 100644 new mode 100755 diff --git a/tests/phase-06-capi-stack/fakebin/ssh b/tests/phase-06-capi-stack/fakebin/ssh old mode 100644 new mode 100755 diff --git a/tests/phase-06-k8s-bootstrap/fakebin/ssh b/tests/phase-06-k8s-bootstrap/fakebin/ssh old mode 100644 new mode 100755 diff --git a/tests/phase-06-kubeconfig-gate/fakebin/kubectl b/tests/phase-06-kubeconfig-gate/fakebin/kubectl old mode 100644 new mode 100755 diff --git a/tests/phase-06-kubeconfig-gate/fakebin/ssh b/tests/phase-06-kubeconfig-gate/fakebin/ssh old mode 100644 new mode 100755 diff --git a/tests/phase-06-mgmt-vm/fakebin/openstack b/tests/phase-06-mgmt-vm/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-06-net-setup/fakebin/openstack b/tests/phase-06-net-setup/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/phase-07-conductor-graft/fakebin/juju b/tests/phase-07-conductor-graft/fakebin/juju old mode 100644 new mode 100755 diff --git a/tests/phase-07-conductor-graft/fakebin/kubectl b/tests/phase-07-conductor-graft/fakebin/kubectl old mode 100644 new mode 100755 diff --git a/tests/phase-07-conductor-graft/fakebin/openstack b/tests/phase-07-conductor-graft/fakebin/openstack old mode 100644 new mode 100755 diff --git a/tests/reenroll-hosts/fakebin/maas b/tests/reenroll-hosts/fakebin/maas old mode 100644 new mode 100755