完成 1.0.0 首发审计与安全加固
This commit is contained in:
1 parent
357b3aa65d
commit
d0d8096663
49 files changed
+1760
-298
No files matched your search
@@ -8,9 +8,6 @@ raw=${NODEFLARE_VERSION:-}
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(git -C "$root_dir" describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(git -C "$root_dir" describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$raw" ]; then
|
||||
raw=$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",*$/\1/p' "$root_dir/package.json" | sed -n '1p')
|
||||
fi
|
||||
|
||||
+70
-6
@@ -24,9 +24,17 @@ step() {
|
||||
admin_token=
|
||||
server_id=
|
||||
latency_task_id=
|
||||
agent_latency_task_id=
|
||||
alert_rule_id=
|
||||
backup_file=
|
||||
agent_pid=
|
||||
agent_state_dir=
|
||||
cleanup() {
|
||||
if [ -n "$agent_pid" ]; then
|
||||
kill "$agent_pid" 2>/dev/null || true
|
||||
wait "$agent_pid" 2>/dev/null || true
|
||||
fi
|
||||
[ -z "$agent_state_dir" ] || rm -rf "$agent_state_dir"
|
||||
[ -z "$backup_file" ] || rm -f -- "$backup_file"
|
||||
if [ "${MONITOR_KEEP_RESOURCES:-0}" != "1" ] && [ -n "$admin_token" ]; then
|
||||
if [ -n "$alert_rule_id" ]; then
|
||||
@@ -37,6 +45,10 @@ cleanup() {
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/latency-tasks/$latency_task_id" >/dev/null || true
|
||||
fi
|
||||
if [ -n "$agent_latency_task_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/latency-tasks/$agent_latency_task_id" >/dev/null || true
|
||||
fi
|
||||
if [ -n "$server_id" ]; then
|
||||
monitor_curl --silent --show-error -H "Authorization: Bearer $admin_token" \
|
||||
-X DELETE "$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null || true
|
||||
@@ -66,13 +78,13 @@ invalid_login_payload=$(jq -nc --arg username "$MONITOR_ADMIN_USERNAME" --arg pa
|
||||
attempt=1
|
||||
while [ "$attempt" -le 5 ]; do
|
||||
invalid_login_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H 'Content-Type: application/json' -H 'X-Real-IP: 203.0.113.10' \
|
||||
-H 'Content-Type: application/json' -H 'X-Forwarded-For: 203.0.113.10' \
|
||||
--data "$invalid_login_payload" "$MONITOR_BASE_URL/api/admin/login")
|
||||
[ "$invalid_login_status" = "401" ]
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
throttled_login_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
-H 'Content-Type: application/json' -H 'X-Real-IP: 203.0.113.10' \
|
||||
-H 'Content-Type: application/json' -H 'X-Forwarded-For: 203.0.113.10' \
|
||||
--data "$invalid_login_payload" "$MONITOR_BASE_URL/api/admin/login")
|
||||
[ "$throttled_login_status" = "429" ]
|
||||
step "static assets"
|
||||
@@ -100,6 +112,9 @@ login_admin() {
|
||||
}
|
||||
login_json=$(login_admin)
|
||||
admin_token=$(printf '%s' "$login_json" | jq -er '.token')
|
||||
api_headers=$(monitor_curl --silent --show-error --dump-header - --output /dev/null \
|
||||
-H "Authorization: Bearer $admin_token" "$MONITOR_BASE_URL/api/admin/settings")
|
||||
printf '%s' "$api_headers" | grep -qi '^cache-control:.*no-store'
|
||||
|
||||
step "login devices"
|
||||
second_login_json=$(login_admin)
|
||||
@@ -125,7 +140,7 @@ settings_payload=$(request -H "Authorization: Bearer $admin_token" \
|
||||
jq -c '.site_description = "Smoke settings" | del(.admin_password_configured, .totp_login_enabled)')
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$settings_payload" \
|
||||
"$MONITOR_BASE_URL/api/admin/settings" | jq -e '.settings.site_description == "Smoke settings"' >/dev/null
|
||||
"$MONITOR_BASE_URL/api/admin/settings" | jq -e '.site_description == "Smoke settings"' >/dev/null
|
||||
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/bootstrap" | \
|
||||
@@ -145,13 +160,14 @@ request -H "Authorization: Bearer $admin_token" -X POST \
|
||||
|
||||
step "database backup and restore"
|
||||
backup_file=$(mktemp /tmp/nodeflare-backup.XXXXXX.zip)
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
request -H "Authorization: Bearer $admin_token" -H "X-NodeFlare-Password: $password_derived" \
|
||||
"$MONITOR_BASE_URL/api/admin/database/backup" > "$backup_file"
|
||||
[ -s "$backup_file" ]
|
||||
changed_settings=$(printf '%s' "$settings_payload" | jq -c '.site_description = "Changed after backup"')
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$changed_settings" "$MONITOR_BASE_URL/api/admin/settings" >/dev/null
|
||||
restore_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/zip' \
|
||||
restore_json=$(request -H "Authorization: Bearer $admin_token" \
|
||||
-H "X-NodeFlare-Password: $password_derived" -H 'Content-Type: application/zip' \
|
||||
--data-binary "@$backup_file" "$MONITOR_BASE_URL/api/admin/database/restore?filename=smoke.zip")
|
||||
printf '%s' "$restore_json" | jq -e '.restored_rows > 0' >/dev/null
|
||||
restored_session_status=$(monitor_curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
@@ -188,6 +204,8 @@ server_id=$(printf '%s' "$server_json" | jq -er '.id')
|
||||
agent_token=$(printf '%s' "$server_json" | jq -er '.agent_token')
|
||||
step "websocket report"
|
||||
MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
|
||||
MONITOR_ADMIN_USERNAME="$MONITOR_ADMIN_USERNAME" \
|
||||
MONITOR_PASSWORD_DERIVED="$password_derived" \
|
||||
MONITOR_AGENT_TOKEN="$agent_token" MONITOR_SERVER_ID="$server_id" \
|
||||
MONITOR_LATENCY_TASK_ID="$latency_task_id" \
|
||||
node scripts/websocket-smoke.mjs
|
||||
@@ -224,6 +242,51 @@ MONITOR_BASE_URL="$MONITOR_BASE_URL" MONITOR_ADMIN_TOKEN="$admin_token" \
|
||||
MONITOR_LATENCY_TASK_ID="$latency_task_id" MONITOR_EXPECT_TASK_ASSIGNED=0 \
|
||||
MONITOR_CONFIG_ONLY=1 node scripts/websocket-smoke.mjs
|
||||
|
||||
if [ -n "${MONITOR_AGENT_BINARY:-}" ]; then
|
||||
step "real Agent failed-latency and persistence"
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' -X PATCH \
|
||||
--data "$(printf '%s' "$server_input" | jq '.report_interval=15 | .collect_interval=1')" \
|
||||
"$MONITOR_BASE_URL/api/admin/servers/$server_id" >/dev/null
|
||||
agent_latency_task_id=$(request -H "Authorization: Bearer $admin_token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke failure",task_type:"tcp",target:"does-not-exist.invalid",port:443,interval_seconds:30,default_enabled:false,server_ids:[$server_id]}')" \
|
||||
"$MONITOR_BASE_URL/api/admin/latency-tasks" | jq -er '.id')
|
||||
agent_state_dir=$(mktemp -d "${TMPDIR:-/tmp}/nodeflare-agent-smoke.XXXXXX")
|
||||
NODEFLARE_STATE_DIR="$agent_state_dir" "$MONITOR_AGENT_BINARY" \
|
||||
-e "$MONITOR_BASE_URL" -t "$agent_token" -i 15 > "$agent_state_dir/agent.log" 2>&1 &
|
||||
agent_pid=$!
|
||||
failed_latency_seen=false
|
||||
attempt=1
|
||||
while [ "$attempt" -le 45 ]; do
|
||||
if request -H "Authorization: Bearer $admin_token" \
|
||||
"$MONITOR_BASE_URL/api/latency/$server_id?hours=1" | \
|
||||
jq -e --arg task_id "$agent_latency_task_id" \
|
||||
'.points | any(.task_id == $task_id and .latency_ms == -1 and .packet_loss == 100)' >/dev/null; then
|
||||
failed_latency_seen=true
|
||||
break
|
||||
fi
|
||||
kill -0 "$agent_pid" 2>/dev/null || {
|
||||
cat "$agent_state_dir/agent.log" >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
[ "$failed_latency_seen" = true ] || {
|
||||
cat "$agent_state_dir/agent.log" >&2
|
||||
echo "Failed latency result was not persisted" >&2
|
||||
exit 1
|
||||
}
|
||||
kill "$agent_pid"
|
||||
wait "$agent_pid" 2>/dev/null || true
|
||||
agent_pid=
|
||||
rm -rf "$agent_state_dir"
|
||||
agent_state_dir=
|
||||
request -H "Authorization: Bearer $admin_token" -X DELETE \
|
||||
"$MONITOR_BASE_URL/api/admin/latency-tasks/$agent_latency_task_id" >/dev/null
|
||||
agent_latency_task_id=
|
||||
fi
|
||||
|
||||
step "alerts and visibility"
|
||||
alert_rule_json=$(request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
|
||||
--data "$(jq -nc --arg server_id "$server_id" '{name:"Smoke CPU",metric:"cpu",threshold:80,duration_minutes:5,aggregation:"average",enabled:true,server_ids:[$server_id]}')" \
|
||||
@@ -243,7 +306,8 @@ hidden_history_status=$(monitor_curl --silent --output /dev/null --write-out '%{
|
||||
if [ -n "${MONITOR_MIGRATION_URL:-}" ]; then
|
||||
step "database migration"
|
||||
migration_payload=$(jq -nc --arg database_url "$MONITOR_MIGRATION_URL" '{database_url:$database_url}')
|
||||
request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \
|
||||
request -H "Authorization: Bearer $admin_token" \
|
||||
-H "X-NodeFlare-Password: $password_derived" -H 'Content-Type: application/json' \
|
||||
--data "$migration_payload" "$MONITOR_BASE_URL/api/admin/database/migrate" | \
|
||||
jq -e --arg kind "${MONITOR_MIGRATION_KIND:-postgresql}" \
|
||||
'.migrated_rows > 0 and .target_kind == $kind and .size_bytes > 0 and .restart_required == true' >/dev/null
|
||||
|
||||
@@ -2,6 +2,8 @@ import WebSocket from "ws";
|
||||
|
||||
const baseUrl = process.env.MONITOR_BASE_URL;
|
||||
const adminToken = process.env.MONITOR_ADMIN_TOKEN;
|
||||
const adminUsername = process.env.MONITOR_ADMIN_USERNAME;
|
||||
const passwordDerived = process.env.MONITOR_PASSWORD_DERIVED;
|
||||
const agentToken = process.env.MONITOR_AGENT_TOKEN;
|
||||
const serverId = process.env.MONITOR_SERVER_ID;
|
||||
const latencyTaskId = process.env.MONITOR_LATENCY_TASK_ID;
|
||||
@@ -253,7 +255,7 @@ function closeSocket(socket) {
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSocketClose(socket, expected) {
|
||||
function waitForSocketClose(socket, expected, timeoutMs = 5_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
@@ -271,7 +273,7 @@ function waitForSocketClose(socket, expected) {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`WebSocket did not close after ${expected}`));
|
||||
}, 5_000);
|
||||
}, timeoutMs);
|
||||
socket.once("close", onClose);
|
||||
socket.once("error", onError);
|
||||
});
|
||||
@@ -317,7 +319,7 @@ try {
|
||||
await oversizedClosed;
|
||||
}
|
||||
|
||||
agent = await openSocket("/api/agent/ws", agentToken, { "CF-Connecting-IP": "8.8.8.8" });
|
||||
agent = await openSocket("/api/agent/ws", agentToken, { "X-Forwarded-For": "8.8.8.8" });
|
||||
const config = await waitForJsonMessage(
|
||||
agent,
|
||||
"Agent config",
|
||||
@@ -380,9 +382,13 @@ try {
|
||||
await closeSocket(rotatedSocket);
|
||||
process.stdout.write(`${rotatedToken}\n`);
|
||||
} else if (!configOnly) {
|
||||
if (!passwordDerived) throw new Error("MONITOR_PASSWORD_DERIVED is required for TOTP setup");
|
||||
const setupResponse = await fetch(new URL("/api/admin/2fa/setup", baseUrl), {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"X-NodeFlare-Password": passwordDerived,
|
||||
},
|
||||
});
|
||||
if (!setupResponse.ok) {
|
||||
throw new Error(`TOTP setup returned HTTP ${setupResponse.status}: ${await setupResponse.text()}`);
|
||||
@@ -454,6 +460,69 @@ try {
|
||||
await setTotpEnabled(setup.secret, false);
|
||||
enabledTotpSecret = "";
|
||||
|
||||
if (!adminUsername || !passwordDerived) {
|
||||
throw new Error("MONITOR_ADMIN_USERNAME and MONITOR_PASSWORD_DERIVED are required");
|
||||
}
|
||||
const privateResponse = await fetch(new URL("/api/admin/settings", baseUrl), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ public_dashboard: false }),
|
||||
});
|
||||
if (!privateResponse.ok) {
|
||||
throw new Error(`Private dashboard update returned HTTP ${privateResponse.status}`);
|
||||
}
|
||||
try {
|
||||
const secondaryLogin = await fetch(new URL("/api/admin/login", baseUrl), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: adminUsername,
|
||||
password_derived: passwordDerived,
|
||||
turnstile_token: "",
|
||||
totp_code: "",
|
||||
}),
|
||||
});
|
||||
if (!secondaryLogin.ok) {
|
||||
throw new Error(`Secondary login returned HTTP ${secondaryLogin.status}`);
|
||||
}
|
||||
const secondaryToken = (await secondaryLogin.json()).token;
|
||||
const revokedDashboard = await openSocket("/api/ws", secondaryToken);
|
||||
const sessionsResponse = await fetch(new URL("/api/admin/sessions", baseUrl), {
|
||||
headers: { Authorization: `Bearer ${secondaryToken}` },
|
||||
});
|
||||
const sessions = await sessionsResponse.json();
|
||||
const secondarySessionId = sessions.sessions?.find((session) => session.current)?.id;
|
||||
if (!secondarySessionId) throw new Error("Secondary session was not listed");
|
||||
const revoked = waitForSocketClose(
|
||||
revokedDashboard,
|
||||
"its login session was revoked",
|
||||
20_000,
|
||||
);
|
||||
const revokeResponse = await fetch(
|
||||
new URL(`/api/admin/sessions/${encodeURIComponent(secondarySessionId)}`, baseUrl),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
},
|
||||
);
|
||||
if (!revokeResponse.ok) {
|
||||
throw new Error(`Session revocation returned HTTP ${revokeResponse.status}`);
|
||||
}
|
||||
await revoked;
|
||||
} finally {
|
||||
await fetch(new URL("/api/admin/settings", baseUrl), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ public_dashboard: true }),
|
||||
});
|
||||
}
|
||||
|
||||
const wakeHintPromise = waitForJsonMessage(
|
||||
agent,
|
||||
"batched overview wake hint",
|
||||
|
||||
Reference in new issue
Block a user