#!/usr/bin/env bash set -euo pipefail HOST="169.254.100.2" PORT="48888" PASSWORD="hvAC2026#%" INTERFACE="" IP="" PREFIX="" GATEWAY="" DNS_CSV="" DO_APPLY=0 DHCP4=0 APPLY_DECISION="confirm" usage() { cat <<'EOF' Usage: linux-smoke-test.sh [options] Default behavior: Runs read-only smoke checks against the server: - GET /api/health - GET /api/device/info - GET /api/network/interfaces - GET /api/network/config?interface=... Validate only: linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1 linux-smoke-test.sh --interface ens33 --dhcp4 Apply and poll task: linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1 --apply linux-smoke-test.sh --interface ens33 --dhcp4 --apply Options: --host Server host, default: 169.254.100.2 --port Server port, default: 48888 --password Admin password, default: hvAC2026#% --interface Target Linux interface, e.g. ens33 --ip IPv4 address for validate/apply --prefix Prefix length for validate/apply --gateway Optional gateway --dns Optional DNS CSV, e.g. 8.8.8.8,1.1.1.1 --dhcp4 Validate/apply DHCP IPv4 mode instead of static IPv4 --apply Actually call /api/network/apply after validate --cancel-apply With --apply, cancel at confirmation step and expect rollback --help Show this help Notes: - Without --apply, the script never changes netplan. - With --apply, the server rewrites netplan YAML, runs netplan apply, then waits 20 seconds for confirmation. - By default the script confirms apply tasks automatically when they enter confirming. EOF } while [[ $# -gt 0 ]]; do case "$1" in --host) HOST="$2" shift 2 ;; --port) PORT="$2" shift 2 ;; --password) PASSWORD="$2" shift 2 ;; --interface) INTERFACE="$2" shift 2 ;; --ip) IP="$2" shift 2 ;; --prefix) PREFIX="$2" shift 2 ;; --gateway) GATEWAY="$2" shift 2 ;; --dns) DNS_CSV="$2" shift 2 ;; --dhcp4) DHCP4=1 shift ;; --apply) DO_APPLY=1 shift ;; --cancel-apply) APPLY_DECISION="cancel" shift ;; --help|-h) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac done for cmd in curl python3; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "Missing required command: $cmd" >&2 exit 2 fi done BASE_URL="http://${HOST}:${PORT}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT log() { printf '[nettool-smoke] %s\n' "$*" } request() { local method="$1" local path="$2" local payload="${3:-}" local body_file="$TMP_DIR/body.json" local http_code : > "$body_file" if [[ -n "$payload" ]]; then http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \ -H "X-Admin-Password: ${PASSWORD}" \ -H "Content-Type: application/json" \ --data "$payload" \ --connect-timeout 3 \ --max-time 15 \ "${BASE_URL}${path}" || true)" else http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \ -H "X-Admin-Password: ${PASSWORD}" \ --connect-timeout 3 \ --max-time 15 \ "${BASE_URL}${path}" || true)" fi printf '%s\n' "$http_code" cat "$body_file" } read_http_code() { sed -n '1p' } read_body() { sed '1d' } assert_api_ok() { local op="$1" local http_code="$2" local body="$3" BODY_JSON="$body" python3 - "$op" "$http_code" <<'PY' import json import os import sys op = sys.argv[1] http_code = int(sys.argv[2]) body = os.environ["BODY_JSON"] if not (200 <= http_code < 300): print(f"{op} failed: http={http_code} body={body}", file=sys.stderr) sys.exit(1) try: data = json.loads(body) except json.JSONDecodeError as exc: print(f"{op} failed: invalid json: {exc}: {body}", file=sys.stderr) sys.exit(1) if data.get("code") != 0: print(f"{op} failed: code={data.get('code')} message={data.get('message')} body={body}", file=sys.stderr) sys.exit(1) PY } json_get() { local body="$1" local expr="$2" BODY_JSON="$body" python3 - "$expr" <<'PY' import json import os import sys expr = sys.argv[1] data = json.loads(os.environ["BODY_JSON"]) value = data for part in expr.split('.'): if part: value = value[part] if isinstance(value, list): import json as _json print(_json.dumps(value, ensure_ascii=False)) else: print(value) PY } build_payload() { python3 - "$INTERFACE" "$IP" "$PREFIX" "$GATEWAY" "$DNS_CSV" "$DHCP4" <<'PY' import json import sys interface, ip, prefix, gateway, dns_csv, dhcp4 = sys.argv[1:7] is_dhcp4 = dhcp4 == "1" dns = [item.strip() for item in dns_csv.split(',') if item.strip()] payload = { "interface": interface, "dhcp4": is_dhcp4, "ip": "" if is_dhcp4 else ip, "prefix": 0 if is_dhcp4 else int(prefix), "gateway": "" if is_dhcp4 else gateway, "dns": [] if is_dhcp4 else dns, } print(json.dumps(payload, ensure_ascii=False)) PY } build_task_payload() { python3 - "$1" <<'PY' import json import sys print(json.dumps({"task_id": sys.argv[1]}, ensure_ascii=False)) PY } poll_task() { local task_id="$1" local max_attempts=20 local attempt=1 local connection_failures=0 local confirmation_sent=0 while (( attempt <= max_attempts )); do local result http_code body status step detail rollback result="$(request GET "/api/tasks/${task_id}")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" if [[ "$http_code" == "000" || "$http_code" == "0" || -z "$http_code" ]]; then connection_failures=$((connection_failures + 1)) log "task=${task_id} poll connection failed (${connection_failures}), retrying" sleep 1 attempt=$((attempt + 1)) continue fi connection_failures=0 assert_api_ok "get task ${task_id}" "$http_code" "$body" status="$(json_get "$body" "data.status")" step="$(json_get "$body" "data.step")" detail="$(json_get "$body" "data.detail")" rollback="$(json_get "$body" "data.rollback")" log "task=${task_id} status=${status} step=${step} rollback=${rollback} detail=${detail}" if [[ "$status" == "running" && "$step" == "confirming" && "$confirmation_sent" -eq 0 ]]; then local decision_path decision_op decision_result decision_code decision_body if [[ "$APPLY_DECISION" == "cancel" ]]; then decision_path="/api/network/apply/cancel" decision_op="cancel apply ${task_id}" else decision_path="/api/network/apply/confirm" decision_op="confirm apply ${task_id}" fi log "task=${task_id} ${APPLY_DECISION} at confirmation step" decision_result="$(request POST "$decision_path" "$(build_task_payload "$task_id")")" decision_code="$(printf '%s\n' "$decision_result" | read_http_code)" decision_body="$(printf '%s\n' "$decision_result" | read_body)" assert_api_ok "$decision_op" "$decision_code" "$decision_body" confirmation_sent=1 fi case "$status" in success) if [[ "$APPLY_DECISION" == "cancel" ]]; then echo "Expected rollback after cancel, but task succeeded: ${task_id}" >&2 return 1 fi return 0 ;; rolled_back) if [[ "$APPLY_DECISION" == "cancel" ]]; then return 0 fi return 1 ;; failed) return 1 ;; esac sleep 1 attempt=$((attempt + 1)) done echo "Timed out waiting for task: ${task_id}" >&2 return 1 } log "health" result="$(request GET "/api/health")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "health" "$http_code" "$body" log "server_version=$(json_get "$body" "data.server_version")" log "device info" result="$(request GET "/api/device/info")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "device info" "$http_code" "$body" log "device_id=$(json_get "$body" "data.device_id") hostname=$(json_get "$body" "data.hostname")" log "interfaces" result="$(request GET "/api/network/interfaces")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "interfaces" "$http_code" "$body" if [[ -z "$INTERFACE" ]]; then INTERFACE="$(json_get "$body" "data.suggested_target_interface")" fi if [[ -z "$INTERFACE" || "$INTERFACE" == "None" ]]; then echo "Could not determine target interface. Pass --interface explicitly." >&2 exit 1 fi log "target interface=${INTERFACE} management=$(json_get "$body" "data.management_interface")" log "read current config" result="$(request GET "/api/network/config?interface=${INTERFACE}")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "config ${INTERFACE}" "$http_code" "$body" log "current dhcp4=$(json_get "$body" "data.dhcp4") ip=$(json_get "$body" "data.ip") prefix=$(json_get "$body" "data.prefix") gateway=$(json_get "$body" "data.gateway")" if [[ -n "$IP" || -n "$PREFIX" || -n "$GATEWAY" || -n "$DNS_CSV" || "$DO_APPLY" -eq 1 || "$DHCP4" -eq 1 ]]; then if [[ "$DHCP4" -eq 0 && ( -z "$IP" || -z "$PREFIX" ) ]]; then echo "--ip and --prefix are required for static validate/apply; use --dhcp4 for DHCP mode" >&2 exit 2 fi payload="$(build_payload)" log "validate payload=${payload}" result="$(request POST "/api/network/validate" "$payload")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "validate" "$http_code" "$body" log "validate passed" if (( DO_APPLY == 1 )); then log "apply" result="$(request POST "/api/network/apply" "$payload")" http_code="$(printf '%s\n' "$result" | read_http_code)" body="$(printf '%s\n' "$result" | read_body)" assert_api_ok "apply" "$http_code" "$body" task_id="$(json_get "$body" "data.task_id")" log "apply submitted task_id=${task_id}" poll_task "$task_id" if [[ "$APPLY_DECISION" == "cancel" ]]; then log "apply task rolled back after cancellation as expected" else log "apply task completed successfully" fi fi else log "read-only smoke checks completed" fi