harden docker sensing-server auth defaults
This commit is contained in:
parent
9ad550d95f
commit
fc50b9b04e
|
|
@ -115,11 +115,12 @@ jobs:
|
||||||
# RUN guard catches missing ones at build time, this re-checks the
|
# RUN guard catches missing ones at build time, this re-checks the
|
||||||
# pushed artifact post-hoc as belt-and-braces).
|
# pushed artifact post-hoc as belt-and-braces).
|
||||||
# 2. /health is up.
|
# 2. /health is up.
|
||||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
# 3. Default Docker start refuses the unsafe 0.0.0.0 unauthenticated mode.
|
||||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
# 4. Explicit LAN opt-in keeps /api/v1/info available without auth.
|
||||||
|
# 5. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
||||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
- name: Smoke-test image assets + LAN-mode HTTP
|
- name: Smoke-test image assets + secure Docker defaults
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||||
|
|
@ -128,7 +129,17 @@ jobs:
|
||||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||||
|
|
||||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
set +e
|
||||||
|
docker run --rm -e CSI_SOURCE=simulated "$IMAGE" >/tmp/ruview-default-start.log 2>&1
|
||||||
|
code=$?
|
||||||
|
set -e
|
||||||
|
test "$code" != "0" || { echo "expected unsafe default start to fail"; exit 1; }
|
||||||
|
grep -q 'RUVIEW_API_TOKEN' /tmp/ruview-default-start.log
|
||||||
|
|
||||||
|
docker run -d --name sm -p 3000:3000 \
|
||||||
|
-e CSI_SOURCE=simulated \
|
||||||
|
-e RUVIEW_ALLOW_UNAUTH_LAN=1 \
|
||||||
|
"$IMAGE"
|
||||||
# Wait up to 30 s for /health.
|
# Wait up to 30 s for /health.
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,9 @@ RUN set -e; \
|
||||||
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
|
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
|
||||||
echo "image assets OK"
|
echo "image assets OK"
|
||||||
|
|
||||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
# Bearer-token auth on /api/v1/*. Docker refuses a 0.0.0.0 sensing-server
|
||||||
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
|
# bind when this is empty unless RUVIEW_ALLOW_UNAUTH_LAN=1 is set explicitly
|
||||||
|
# for a trusted LAN deployment.
|
||||||
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
|
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
|
||||||
ENV RUVIEW_API_TOKEN=
|
ENV RUVIEW_API_TOKEN=
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ services:
|
||||||
- "5005:5005/udp"
|
- "5005:5005/udp"
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
|
# Secure default: docker-entrypoint.sh refuses to bind the sensing
|
||||||
|
# server on 0.0.0.0 unless a token is configured, or unauthenticated
|
||||||
|
# trusted-LAN mode is explicitly acknowledged.
|
||||||
|
- RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN:-}
|
||||||
|
# Uncomment only for intentionally trusted LAN deployments:
|
||||||
|
# - RUVIEW_ALLOW_UNAUTH_LAN=1
|
||||||
# CSI_SOURCE controls the data source for the sensing server.
|
# CSI_SOURCE controls the data source for the sensing server.
|
||||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,69 @@
|
||||||
# Environment variables:
|
# Environment variables:
|
||||||
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
||||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||||
|
# RUVIEW_API_TOKEN — bearer token for /api/v1/* when binding to the network
|
||||||
|
# RUVIEW_ALLOW_UNAUTH_LAN=1 — explicit opt-in for unauthenticated LAN mode
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
is_truthy() {
|
||||||
|
case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
1|true|yes|on) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
is_unspecified_bind_addr() {
|
||||||
|
case "${1:-}" in
|
||||||
|
0.0.0.0|::|\[::\]) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
sensing_bind_addr() {
|
||||||
|
bind="${SENSING_BIND_ADDR:-127.0.0.1}"
|
||||||
|
expect_value=
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$expect_value" = "--bind-addr" ]; then
|
||||||
|
bind="$arg"
|
||||||
|
expect_value=
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
--bind-addr=*) bind="${arg#--bind-addr=}" ;;
|
||||||
|
--bind-addr) expect_value="--bind-addr" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s\n' "$bind"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard_unauthenticated_network_bind() {
|
||||||
|
case "${1:-}" in
|
||||||
|
/app/sensing-server|sensing-server) ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
bind_addr="$(sensing_bind_addr "$@")"
|
||||||
|
if ! is_unspecified_bind_addr "$bind_addr"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ -n "${RUVIEW_API_TOKEN:-}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if is_truthy "${RUVIEW_ALLOW_UNAUTH_LAN:-}"; then
|
||||||
|
echo "WARN: starting unauthenticated LAN mode on ${bind_addr} because RUVIEW_ALLOW_UNAUTH_LAN=1" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<EOF
|
||||||
|
FATAL: refusing to start sensing-server on ${bind_addr} without RUVIEW_API_TOKEN.
|
||||||
|
|
||||||
|
The Docker image publishes the sensing HTTP/WebSocket surface. Set
|
||||||
|
RUVIEW_API_TOKEN to enforce bearer auth on /api/v1/*, or set
|
||||||
|
RUVIEW_ALLOW_UNAUTH_LAN=1 only for an intentionally trusted LAN deployment.
|
||||||
|
EOF
|
||||||
|
exit 64
|
||||||
|
}
|
||||||
|
|
||||||
# Route to cog-ha-matter (ADR-116) when invoked as:
|
# Route to cog-ha-matter (ADR-116) when invoked as:
|
||||||
# docker run <image> cog-ha-matter [--flags]
|
# docker run <image> cog-ha-matter [--flags]
|
||||||
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
# or via the short alias `ha-matter`. Strips the keyword and execs the
|
||||||
|
|
@ -52,4 +113,5 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||||
"$@"
|
"$@"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
guard_unauthenticated_network_bind "$@"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
# Regression tests for docker-entrypoint.sh
|
# Regression tests for docker-entrypoint.sh
|
||||||
#
|
#
|
||||||
# Validates that the entrypoint script correctly handles:
|
# Validates that the entrypoint script correctly handles:
|
||||||
# 1. No arguments → uses env var defaults
|
# 1. No arguments without auth/opt-in → refuses unsafe network bind
|
||||||
# 2. Flag arguments → prepends sensing-server binary
|
# 2. Explicit trusted-LAN opt-in → uses env var defaults
|
||||||
# 3. Explicit binary path → passes through unchanged
|
# 3. Flag arguments → prepends sensing-server binary
|
||||||
# 4. CSI_SOURCE env var substitution
|
# 4. Explicit binary path → passes through unchanged
|
||||||
# 5. MODELS_DIR env var propagation
|
# 5. CSI_SOURCE env var substitution
|
||||||
|
# 6. MODELS_DIR env var propagation
|
||||||
#
|
#
|
||||||
# These tests use a stub sensing-server that just prints its args.
|
# These tests use a stub sensing-server that just prints its args.
|
||||||
|
|
||||||
|
|
@ -66,10 +67,30 @@ chmod +x "$TEST_ENTRYPOINT"
|
||||||
|
|
||||||
echo "=== Docker entrypoint tests ==="
|
echo "=== Docker entrypoint tests ==="
|
||||||
|
|
||||||
# Test 1: No arguments — should use CSI_SOURCE default (auto)
|
# Test 1: No arguments without auth/opt-in — should fail closed because the
|
||||||
|
# Docker default binds the sensing surface to 0.0.0.0.
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 1: No arguments (default CSI_SOURCE=auto)"
|
echo "Test 1: No arguments without auth or LAN opt-in"
|
||||||
|
set +e
|
||||||
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1)
|
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
set -e
|
||||||
|
if [ "$STATUS" -ne 0 ]; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
echo " ✓ refuses unsafe default"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
echo " ✗ refuses unsafe default"
|
||||||
|
echo " expected non-zero exit"
|
||||||
|
echo " got: $OUT"
|
||||||
|
fi
|
||||||
|
assert_contains "explains RUVIEW_API_TOKEN requirement" "$OUT" "RUVIEW_API_TOKEN"
|
||||||
|
assert_contains "mentions explicit LAN opt-in" "$OUT" "RUVIEW_ALLOW_UNAUTH_LAN=1"
|
||||||
|
|
||||||
|
# Test 2: No arguments with explicit LAN opt-in — should use CSI_SOURCE default (auto)
|
||||||
|
echo ""
|
||||||
|
echo "Test 2: No arguments with RUVIEW_ALLOW_UNAUTH_LAN=1"
|
||||||
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "includes --source auto" "$OUT" "--source auto"
|
assert_contains "includes --source auto" "$OUT" "--source auto"
|
||||||
assert_contains "includes --tick-ms 100" "$OUT" "--tick-ms 100"
|
assert_contains "includes --tick-ms 100" "$OUT" "--tick-ms 100"
|
||||||
assert_contains "includes --ui-path" "$OUT" "--ui-path /app/ui"
|
assert_contains "includes --ui-path" "$OUT" "--ui-path /app/ui"
|
||||||
|
|
@ -77,49 +98,49 @@ assert_contains "includes --http-port 3000" "$OUT" "--http-port 3000"
|
||||||
assert_contains "includes --ws-port 3001" "$OUT" "--ws-port 3001"
|
assert_contains "includes --ws-port 3001" "$OUT" "--ws-port 3001"
|
||||||
assert_contains "includes --bind-addr 0.0.0.0" "$OUT" "--bind-addr 0.0.0.0"
|
assert_contains "includes --bind-addr 0.0.0.0" "$OUT" "--bind-addr 0.0.0.0"
|
||||||
|
|
||||||
# Test 2: CSI_SOURCE=esp32 — should substitute
|
# Test 3: CSI_SOURCE=esp32 — should substitute when LAN mode is explicit
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 2: CSI_SOURCE=esp32"
|
echo "Test 3: CSI_SOURCE=esp32"
|
||||||
OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1)
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "includes --source esp32" "$OUT" "--source esp32"
|
assert_contains "includes --source esp32" "$OUT" "--source esp32"
|
||||||
|
|
||||||
# Test 3: Flag arguments — should prepend binary
|
# Test 4: Flag arguments — should prepend binary
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 3: User passes --source wifi --tick-ms 500"
|
echo "Test 4: User passes --source wifi --tick-ms 500"
|
||||||
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1)
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1)
|
||||||
assert_contains "includes --source wifi" "$OUT" "--source wifi"
|
assert_contains "includes --source wifi" "$OUT" "--source wifi"
|
||||||
assert_contains "includes --tick-ms 500" "$OUT" "--tick-ms 500"
|
assert_contains "includes --tick-ms 500" "$OUT" "--tick-ms 500"
|
||||||
|
|
||||||
# Test 4: No CSI_SOURCE set — should default to auto
|
# Test 5: No CSI_SOURCE set — should default to auto
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 4: CSI_SOURCE unset"
|
echo "Test 5: CSI_SOURCE unset"
|
||||||
OUT=$(unset CSI_SOURCE; "$TEST_ENTRYPOINT" 2>&1)
|
OUT=$(unset CSI_SOURCE; RUVIEW_ALLOW_UNAUTH_LAN=1 "$TEST_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "includes --source auto (default)" "$OUT" "--source auto"
|
assert_contains "includes --source auto (default)" "$OUT" "--source auto"
|
||||||
|
|
||||||
# Test 5: User passes --model flag — should be appended
|
# Test 6: User passes --model flag — should be appended
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 5: User passes --model /app/models/my.rvf"
|
echo "Test 6: User passes --model /app/models/my.rvf"
|
||||||
OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1)
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1)
|
||||||
assert_contains "includes --model" "$OUT" "--model /app/models/my.rvf"
|
assert_contains "includes --model" "$OUT" "--model /app/models/my.rvf"
|
||||||
assert_contains "also includes default flags" "$OUT" "--source esp32"
|
assert_contains "also includes default flags" "$OUT" "--source esp32"
|
||||||
|
|
||||||
# Test 6: CSI_SOURCE=simulated
|
# Test 7: CSI_SOURCE=simulated
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 6: CSI_SOURCE=simulated"
|
echo "Test 7: CSI_SOURCE=simulated"
|
||||||
OUT=$(CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1)
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "includes --source simulated" "$OUT" "--source simulated"
|
assert_contains "includes --source simulated" "$OUT" "--source simulated"
|
||||||
|
|
||||||
# Test 7: Explicit binary path passed (e.g., docker run <image> /bin/sh)
|
# Test 8: Explicit binary path passed (e.g., docker run <image> /bin/sh)
|
||||||
# First arg does NOT start with -, so entrypoint should exec it directly
|
# First arg does NOT start with -, so entrypoint should exec it directly
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 7: Explicit command (echo hello)"
|
echo "Test 8: Explicit command (echo hello)"
|
||||||
OUT=$("$TEST_ENTRYPOINT" echo hello 2>&1)
|
OUT=$("$TEST_ENTRYPOINT" echo hello 2>&1)
|
||||||
assert_contains "passes through explicit command" "$OUT" "hello"
|
assert_contains "passes through explicit command" "$OUT" "hello"
|
||||||
assert_not_contains "does not inject sensing-server flags" "$OUT" "--source"
|
assert_not_contains "does not inject sensing-server flags" "$OUT" "--source"
|
||||||
|
|
||||||
# Test 8: MODELS_DIR env var is passed through to the process
|
# Test 9: MODELS_DIR env var is passed through to the process
|
||||||
echo ""
|
echo ""
|
||||||
echo "Test 8: MODELS_DIR env var propagation"
|
echo "Test 9: MODELS_DIR env var propagation"
|
||||||
# Create a stub that prints MODELS_DIR
|
# Create a stub that prints MODELS_DIR
|
||||||
ENV_STUB="$TMPDIR/env-sensing-server"
|
ENV_STUB="$TMPDIR/env-sensing-server"
|
||||||
cat > "$ENV_STUB" << 'ENVEOF'
|
cat > "$ENV_STUB" << 'ENVEOF'
|
||||||
|
|
@ -131,12 +152,42 @@ ENV_ENTRYPOINT="$TMPDIR/env-entrypoint.sh"
|
||||||
sed "s|/app/sensing-server|$ENV_STUB|g" "$ENTRYPOINT" > "$ENV_ENTRYPOINT"
|
sed "s|/app/sensing-server|$ENV_STUB|g" "$ENTRYPOINT" > "$ENV_ENTRYPOINT"
|
||||||
chmod +x "$ENV_ENTRYPOINT"
|
chmod +x "$ENV_ENTRYPOINT"
|
||||||
|
|
||||||
OUT=$(MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
OUT=$(RUVIEW_ALLOW_UNAUTH_LAN=1 MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "MODELS_DIR is visible" "$OUT" "MODELS_DIR=/app/models"
|
assert_contains "MODELS_DIR is visible" "$OUT" "MODELS_DIR=/app/models"
|
||||||
|
|
||||||
OUT=$(unset MODELS_DIR; CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
OUT=$(unset MODELS_DIR; RUVIEW_ALLOW_UNAUTH_LAN=1 CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
||||||
assert_contains "MODELS_DIR defaults to unset" "$OUT" "MODELS_DIR=unset"
|
assert_contains "MODELS_DIR defaults to unset" "$OUT" "MODELS_DIR=unset"
|
||||||
|
|
||||||
|
# Test 10: RUVIEW_API_TOKEN also permits the Docker network bind.
|
||||||
|
echo ""
|
||||||
|
echo "Test 10: RUVIEW_API_TOKEN permits 0.0.0.0 bind"
|
||||||
|
OUT=$(RUVIEW_API_TOKEN=test-token CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1)
|
||||||
|
assert_contains "token path includes --bind-addr" "$OUT" "--bind-addr 0.0.0.0"
|
||||||
|
|
||||||
|
# Test 11: Loopback bind remains allowed without auth.
|
||||||
|
echo ""
|
||||||
|
echo "Test 11: --bind-addr 127.0.0.1 allowed without auth"
|
||||||
|
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --bind-addr 127.0.0.1 2>&1)
|
||||||
|
assert_contains "loopback override is preserved" "$OUT" "--bind-addr 127.0.0.1"
|
||||||
|
|
||||||
|
# Test 12: Explicit sensing-server command with unsafe bind is also blocked.
|
||||||
|
echo ""
|
||||||
|
echo "Test 12: explicit sensing-server with --bind-addr 0.0.0.0 is blocked"
|
||||||
|
set +e
|
||||||
|
OUT=$("$TEST_ENTRYPOINT" "$STUB" --bind-addr 0.0.0.0 2>&1)
|
||||||
|
STATUS=$?
|
||||||
|
set -e
|
||||||
|
if [ "$STATUS" -ne 0 ]; then
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
echo " ✓ explicit unsafe bind refused"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
echo " ✗ explicit unsafe bind refused"
|
||||||
|
echo " expected non-zero exit"
|
||||||
|
echo " got: $OUT"
|
||||||
|
fi
|
||||||
|
assert_contains "explicit unsafe bind explains token requirement" "$OUT" "RUVIEW_API_TOKEN"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||||
[ "$FAIL" -eq 0 ] || exit 1
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue