From cf0cc7b649840efc55067a2dd4c7b25c08f18409 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Sun, 19 Jul 2026 19:11:04 -0700 Subject: [PATCH 1/3] Add user names and design-15 v2 metadata regeneration (design-15 D3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sb user add/del payloads now carry a per-user name (the design-15 §5 derived u_) in every protocol's user object, and add/del matching also keys on name, so renamed credentials stay idempotent. name is the join key sing-box uses for auth metadata, auth_user route rules, and per-user stats counters. New `sb meta` command regenerates the sidecar in lattice.singbox-metadata.v2 shape: existing v2 line_uuid or v1 lines{}.line_id identity is preserved per conf file (v1 line_id is adopted as the v2 line_uuid), fresh uuids only for lines with no identity; the v1 .node/.lines keys are kept alongside so pre-v2 readers keep working. It is the on-box fallback writer — the Lattice server stays the authoritative one. tests/design-15-user-meta.sh is the repo's first test harness: it extracts the functions under test and asserts user-obj name handling, name-keyed matching, meta regeneration, v1 upgrade, idempotent rerun, chain preservation, and the missing-node-id failure (22 assertions). --- src/core.sh | 67 ++++++++++++++++++++-- src/help.sh | 2 + tests/design-15-user-meta.sh | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 5 deletions(-) create mode 100755 tests/design-15-user-meta.sh diff --git a/src/core.sh b/src/core.sh index bca07e3a9..2de2b3752 100644 --- a/src/core.sh +++ b/src/core.sh @@ -2099,13 +2099,13 @@ json_line_user_obj() { with_entries(select(.value != "" and .value != null and .value != [] and .value != {})); ($cfg[0].inbounds[0].type // "") as $type | if ($type == "vless" or $type == "vmess") then - {uuid:($p.uuid // ""), flow:($p.flow // "")} | compact_obj + {name:($p.name // ""), uuid:($p.uuid // ""), flow:($p.flow // "")} | compact_obj elif ($type == "tuic") then - {uuid:($p.uuid // ""), password:($p.password // "")} | compact_obj + {name:($p.name // ""), uuid:($p.uuid // ""), password:($p.password // "")} | compact_obj elif ($type == "trojan" or $type == "hysteria2" or $type == "anytls") then - {password:($p.password // "")} | compact_obj + {name:($p.name // ""), password:($p.password // "")} | compact_obj elif ($type == "socks") then - {username:($p.username // $p.email // $p.user_id // ""), password:($p.password // "")} | compact_obj + {name:($p.name // ""), username:($p.username // $p.email // $p.user_id // ""), password:($p.password // "")} | compact_obj else null end @@ -2125,7 +2125,8 @@ json_line_user_valid() { json_line_user_matches_filter=' def same_nonempty($a; $b): (($a // "") != "" and ($a // "") == ($b // "")); - (same_nonempty(.uuid; $user.uuid) + (same_nonempty(.name; $user.name) + or same_nonempty(.uuid; $user.uuid) or same_nonempty(.username; $user.username) or same_nonempty(.password; $user.password)) ' @@ -2196,6 +2197,59 @@ cmd_json_user() { exit 0 } +# meta --json -> regenerate the Lattice sidecar in design-15 v2 shape from on-box +# state, print it, and exit. Identity continuity: an existing v2 inbounds[].line_uuid +# or a v1 lines{}.line_id is preserved per conf file; only lines with no identity +# anywhere get a fresh uuid. The v1 keys (.node/.lines) are kept alongside the v2 +# shape so pre-v2 readers (create/del/inspect) keep working unchanged. The server +# remains the authoritative writer; this is the on-box fallback/repair path. +cmd_json_meta() { + is_json_out=1 + [[ -d $is_conf_dir ]] || json_err "not_found" "conf dir not found: $is_conf_dir" 2 + local old='{}' now tmp doc node_uuid node_id + [[ -s $is_lattice_meta ]] && old=$(jq -c . "$is_lattice_meta" 2>/dev/null) + [[ $old ]] || old='{}' + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + node_uuid=${LATTICE_IDENTITY_UUID:-$(jq -r '.node_uuid // .node.node_uuid // empty' <<<"$old" 2>/dev/null)} + node_id=${LATTICE_NODE_ID:-$(jq -r '.node_id // .node.node_id // empty' <<<"$old" 2>/dev/null)} + [[ $node_id ]] || json_err "missing_node_id" "node id unknown; set LATTICE_NODE_ID (or keep the existing sidecar)" 2 + + local f tag uuid chain inbounds='[]' + for f in "$is_conf_dir"/*.json; do + [[ -f $f ]] || continue + tag=$(basename "$f") + uuid=$(jq -r --arg t "$tag" '(.inbounds // []) | map(select(.tag == $t)) | .[0].line_uuid // empty' <<<"$old" 2>/dev/null) + [[ ! $uuid ]] && uuid=$(jq -r --arg t "$tag" '.lines[$t].line_id // empty' <<<"$old" 2>/dev/null) + [[ ! $uuid ]] && { get_uuid; uuid=$tmp_uuid; } + chain=$(jq -c --arg t "$tag" '(.inbounds // []) | map(select(.tag == $t)) | .[0].chain // empty' <<<"$old" 2>/dev/null) + if [[ $chain ]]; then + inbounds=$(jq -c --arg t "$tag" --arg u "$uuid" --argjson c "$chain" '. + [{tag:$t, line_uuid:$u, chain:$c}]' <<<"$inbounds") + else + inbounds=$(jq -c --arg t "$tag" --arg u "$uuid" '. + [{tag:$t, line_uuid:$u}]' <<<"$inbounds") + fi + done + + doc=$(jq -n \ + --arg now "$now" --arg node_uuid "$node_uuid" --arg node_id "$node_id" --argjson inbounds "$inbounds" ' + ($inbounds | map({key:.tag, value:{line_id:.line_uuid}}) | from_entries) as $v1lines + | { + schema:"lattice.singbox-metadata.v2", + node_id:$node_id, updated_at:$now, writer:"sb", + inbounds:$inbounds, + node:{node_uuid:$node_uuid, node_id:$node_id} | with_entries(select(.value != "")), + lines:$v1lines, + reserved:{in_config_key:"_lattice", + fields:{line_uuid:"string",node_uuid:"string",line_hash_id:"string"}} + } + | if $node_uuid != "" then .node_uuid=$node_uuid else . end') + tmp=$(mktemp "${TMPDIR:-/tmp}/lattice-meta-v2.XXXXXX") || json_err "tmp_failed" "cannot create temp file" 2 + mkdir -p "$(dirname "$is_lattice_meta")" 2>/dev/null + jq . <<<"$doc" >"$tmp" || { rm -f "$tmp"; json_err "jq_failed" "failed to render v2 metadata" 2; } + mv "$tmp" "$is_lattice_meta" + cat "$is_lattice_meta" + exit 0 +} + conncheck_now_ms() { local now now=$(date +%s%3N 2>/dev/null) @@ -2657,6 +2711,9 @@ main() { user) cmd_json_user "$2" "$3" "$4" ;; + meta) + cmd_json_meta + ;; a | add | gen | no-auto-tls) [[ $1 == 'gen' ]] && is_gen=1 [[ $1 == 'no-auto-tls' ]] && is_no_auto_tls=1 diff --git a/src/help.sh b/src/help.sh index bf8066d80..100c05ec3 100644 --- a/src/help.sh +++ b/src/help.sh @@ -67,6 +67,8 @@ show_help() { "自动化:" " list, ls [filter] 以 JSON 列出配置" " inspect [name] 以 JSON 输出 Line 形态配置" + " user add|del 增删某条线路的用户 (json 可含 name/uuid/password)" + " meta 重建并输出 design-15 v2 sidecar 元数据" " sub 以 JSON 输出全部节点订阅内容" " provision 以 JSON 检查安装与服务状态" " backup [--json] 备份配置数据 (含 lattice-metadata.json)\n" diff --git a/tests/design-15-user-meta.sh b/tests/design-15-user-meta.sh new file mode 100755 index 000000000..138d5521c --- /dev/null +++ b/tests/design-15-user-meta.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Local harness for the design-15 sb changes. Extracts the functions under test +# from src/core.sh (no Linux box needed) and asserts behavior with temp dirs. +set -u +CORE="$(cd "$(dirname "$0")/.." && pwd)/src/core.sh" +TMP=$(mktemp -d /tmp/sb-meta-test.XXXXXX) +trap 'rm -rf "$TMP"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo "ok - $1"; } +bad() { FAIL=$((FAIL+1)); echo "FAIL - $1"; } +chk() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1: got [$2] want [$3]"; fi; } + +extract_fn() { awk "/^$1\\(\\) \\{/,/^\\}/" "$CORE"; } + +# --- stubs ----------------------------------------------------------------- +is_conf_dir="$TMP/conf"; is_lattice_meta="$TMP/lattice-metadata.json" +mkdir -p "$is_conf_dir" +get_uuid() { tmp_uuid=$(uuidgen | tr 'A-F' 'a-f'); } +json_err() { printf '{"ok":false,"error":"%s","message":"%s"}\n' "$1" "$2" >&2; exit "${3:-1}"; } +warn() { echo "warn: $*" >&2; } +is_json_out=1 + +eval "$(extract_fn json_line_user_obj)" +eval "$(extract_fn json_line_user_valid)" +eval "$(awk "/^json_line_user_matches_filter='/,/^'/" "$CORE")" +eval "$(extract_fn cmd_json_meta)" + +# --- fixtures ---------------------------------------------------------------- +cat >"$is_conf_dir/vless-443.json" <<'EOF' +{"inbounds":[{"tag":"vless-443.json","type":"vless","listen":"::","listen_port":443, +"users":[{"uuid":"11111111-1111-4111-8111-111111111111","flow":"xtls-rprx-vision"}]}]} +EOF +cat >"$is_conf_dir/trojan-8443.json" <<'EOF' +{"inbounds":[{"tag":"trojan-8443.json","type":"trojan","listen":"::","listen_port":8443, +"users":[{"password":"pw1"}]}]} +EOF + +# --- 1. user obj carries name ------------------------------------------------- +out=$(json_line_user_obj "$is_conf_dir/vless-443.json" '{"name":"u_abc","uuid":"22222222-2222-4222-8222-222222222222","flow":"xtls-rprx-vision"}') +chk "vless user obj has name" "$(jq -r .name <<<"$out")" "u_abc" +chk "vless user obj has uuid" "$(jq -r .uuid <<<"$out")" "22222222-2222-4222-8222-222222222222" +out=$(json_line_user_obj "$is_conf_dir/vless-443.json" '{"uuid":"22222222-2222-4222-8222-222222222222"}') +chk "vless user obj without name omits key" "$(jq 'has("name")' <<<"$out")" "false" +out=$(json_line_user_obj "$is_conf_dir/trojan-8443.json" '{"name":"u_def","password":"pw2"}') +chk "trojan user obj has name+password" "$(jq -r '.name+"/"+.password' <<<"$out")" "u_def/pw2" + +# --- 2. match filter matches by name ------------------------------------------ +users='[{"name":"u_abc","uuid":"aaaa"},{"name":"u_bbb","uuid":"bbbb"}]' +kept=$(jq --argjson user '{"name":"u_abc"}' "map(select(($json_line_user_matches_filter) | not))" <<<"$users") +chk "del by name removes entry" "$(jq length <<<"$kept")" "1" +kept=$(jq --argjson user '{"uuid":"bbbb"}' "map(select(($json_line_user_matches_filter) | not))" <<<"$users") +chk "del by uuid still works" "$(jq -r '.[0].name' <<<"$kept")" "u_abc" + +# --- 3. meta: fresh node ------------------------------------------------------ +LATTICE_NODE_ID="test-node"; LATTICE_IDENTITY_UUID="" +out=$(cmd_json_meta 2>/dev/null) +chk "meta exit ok" "$?" "0" +chk "meta schema" "$(jq -r .schema <<<"$out")" "lattice.singbox-metadata.v2" +chk "meta writer" "$(jq -r .writer <<<"$out")" "sb" +chk "meta node_id" "$(jq -r .node_id <<<"$out")" "test-node" +chk "meta inbounds count" "$(jq '.inbounds | length' <<<"$out")" "2" +chk "meta inbound tag keeps .json" "$(jq -r '.inbounds[0].tag' <<<"$out")" "trojan-8443.json" +u1=$(jq -r '.inbounds[] | select(.tag=="vless-443.json") | .line_uuid' <<<"$out") +case "$u1" in *-*) ok "fresh line_uuid allocated";; *) bad "fresh line_uuid allocated: $u1";; esac +chk "v1 lines mirror present" "$(jq -r '.lines["vless-443.json"].line_id' <<<"$out")" "$u1" +chk "reserved block" "$(jq -r '.reserved.in_config_key' <<<"$out")" "_lattice" +[ -f "$is_lattice_meta" ] && ok "sidecar written to disk" || bad "sidecar written to disk" + +# --- 4. meta: rerun preserves uuids (idempotent) ------------------------------ +out2=$(cmd_json_meta 2>/dev/null) +u2=$(jq -r '.inbounds[] | select(.tag=="vless-443.json") | .line_uuid' <<<"$out2") +chk "rerun preserves line_uuid" "$u2" "$u1" + +# --- 5. meta: v1 legacy sidecar upgrades preserving identity ------------------ +rm -f "$is_lattice_meta" +cat >"$is_lattice_meta" <<'EOF' +{"version":1,"node":{"node_uuid":"node-uuid-legacy","node_id":"legacy-node"}, + "lines":{"vless-443.json":{"line_id":"33333333-3333-4333-8333-333333333333"}}} +EOF +LATTICE_NODE_ID="" +out3=$(cmd_json_meta 2>/dev/null) +chk "v1 line_id adopted as line_uuid" \ + "$(jq -r '.inbounds[] | select(.tag=="vless-443.json") | .line_uuid' <<<"$out3")" \ + "33333333-3333-4333-8333-333333333333" +chk "node_id adopted from v1" "$(jq -r .node_id <<<"$out3")" "legacy-node" +chk "node_uuid adopted from v1" "$(jq -r .node_uuid <<<"$out3")" "node-uuid-legacy" + +# --- 6. meta: missing node id fails loud -------------------------------------- +rm -f "$is_lattice_meta"; LATTICE_NODE_ID="" +out4=$(cmd_json_meta 2>&1); rc=$? +[ $rc -ne 0 ] && ok "missing node_id errors" || bad "missing node_id errors: rc=$rc out=$out4" + +# --- 7. chain block preserved on rerun ---------------------------------------- +cat >"$is_lattice_meta" </dev/null) +chk "chain preserved" "$(jq -c '.inbounds[] | select(.tag=="vless-443.json") | .chain' <<<"$out5")" \ + '{"downstream_line_uuid":"44444444-4444-4444-8444-444444444444","downstream_node":"qqpw"}' + +echo +echo "PASS=$PASS FAIL=$FAIL" +[ $FAIL -eq 0 ] From 16b64f4f48713c24de539025e2749648dddacffe Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 21 Jul 2026 04:32:31 -0700 Subject: [PATCH 2/3] Add sb stats on|off for the experimental V2Ray stats API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design-15 §8 / ADR-004: toggles experimental.v2ray_api in config.json through the same atomic jq + check + rollback pattern as sb user, restricted to loopback listens (a stats API must never bind a routable address) with port validation, then restarts the core. This is what the agent's singbox-stats collector dials (LATTICE_SINGBOX_STATS_API). --- src/core.sh | 57 ++++++++++++++++++++++++++++++++++++ src/help.sh | 1 + tests/design-15-user-meta.sh | 21 +++++++++++++ 3 files changed, 79 insertions(+) diff --git a/src/core.sh b/src/core.sh index 2de2b3752..da9878019 100644 --- a/src/core.sh +++ b/src/core.sh @@ -2250,6 +2250,60 @@ cmd_json_meta() { exit 0 } +# Generic atomic jq edit of a sing-box config file: backup -> jq with caller +# args -> mv -> `sing-box check` with automatic rollback on rejection. Mirrors +# json_write_config_atomically but takes arbitrary jq --arg/--argjson pairs. +json_edit_config_atomically() { + local raw_file="$1" filter="$2" + shift 2 + local tmp backup errf + tmp=$(mktemp "${TMPDIR:-/tmp}/lattice-sb-edit.XXXXXX") || json_err "tmp_failed" "cannot create temp file" 2 + backup="$raw_file.backup-$(date -u +%Y%m%d-%H%M%S)" + errf=$(mktemp "${TMPDIR:-/tmp}/lattice-sb-check.XXXXXX") || { rm -f "$tmp"; json_err "tmp_failed" "cannot create temp file" 2; } + cp -p "$raw_file" "$backup" || { rm -f "$tmp" "$errf"; json_err "backup_failed" "cannot backup $raw_file" 2; } + if ! jq "$@" "$filter" "$raw_file" >"$tmp"; then + rm -f "$tmp" "$errf" + json_err "jq_failed" "failed to update $raw_file" 2 + fi + mv "$tmp" "$raw_file" || { rm -f "$tmp" "$errf"; json_err "write_failed" "failed to replace $raw_file" 2; } + if ! "$is_core_bin" check -c "$raw_file" >"$errf" 2>&1; then + mv "$backup" "$raw_file" 2>/dev/null || true + local check_error + check_error=$(tail -n 20 "$errf" 2>/dev/null) + rm -f "$errf" + jq -nc --arg error "config_invalid" --arg message "sing-box rejected updated config; rolled back" --arg detail "$check_error" \ + '{ok:false,error:$error,message:$message,detail:$detail}' + exit 1 + fi + rm -f "$errf" +} + +# stats on|off [listen] -> toggle the experimental V2Ray stats API in config.json +# (design-15 §8 / ADR-004). Loopback listens only: a stats API must never bind a +# routable address. +cmd_json_stats() { + is_json_out=1 + local op="$1" listen="${2:-127.0.0.1:8080}" + [[ $op == "on" || $op == "off" ]] || json_err "invalid_action" "stats action must be on or off" 2 + [[ -f $is_config_json ]] || json_err "not_found" "config.json not found: $is_config_json" 2 + if [[ $op == "on" ]]; then + case "$listen" in + 127.* | localhost:* | \[::1\]:*) ;; + *) json_err "invalid_listen" "stats listen must be loopback (e.g. 127.0.0.1:8080)" 2 ;; + esac + local port="${listen##*:}" + [[ $port =~ ^[0-9]+$ && $port -ge 1 && $port -le 65535 ]] || json_err "invalid_listen" "stats listen port is invalid" 2 + json_edit_config_atomically "$is_config_json" \ + '.experimental.v2ray_api = {listen:$l, stats:{enabled:true}}' --arg l "$listen" + else + json_edit_config_atomically "$is_config_json" \ + 'del(.experimental.v2ray_api) | if (.experimental // {} | length) == 0 then del(.experimental) else . end' + fi + manage restart "$is_core" >/dev/null 2>&1 || true + jq -nc --arg action "$op" --arg listen "$listen" '{ok:true,stats:$action,listen:(if $action=="on" then $listen else "" end)}' + exit 0 +} + conncheck_now_ms() { local now now=$(date +%s%3N 2>/dev/null) @@ -2714,6 +2768,9 @@ main() { meta) cmd_json_meta ;; + stats) + cmd_json_stats "$2" "$3" + ;; a | add | gen | no-auto-tls) [[ $1 == 'gen' ]] && is_gen=1 [[ $1 == 'no-auto-tls' ]] && is_no_auto_tls=1 diff --git a/src/help.sh b/src/help.sh index 100c05ec3..bc8ac4011 100644 --- a/src/help.sh +++ b/src/help.sh @@ -69,6 +69,7 @@ show_help() { " inspect [name] 以 JSON 输出 Line 形态配置" " user add|del 增删某条线路的用户 (json 可含 name/uuid/password)" " meta 重建并输出 design-15 v2 sidecar 元数据" + " stats on|off [listen] 开关实验性 stats API (仅回环, 默认 127.0.0.1:8080)" " sub 以 JSON 输出全部节点订阅内容" " provision 以 JSON 检查安装与服务状态" " backup [--json] 备份配置数据 (含 lattice-metadata.json)\n" diff --git a/tests/design-15-user-meta.sh b/tests/design-15-user-meta.sh index 138d5521c..940b4afc4 100755 --- a/tests/design-15-user-meta.sh +++ b/tests/design-15-user-meta.sh @@ -24,6 +24,11 @@ eval "$(extract_fn json_line_user_obj)" eval "$(extract_fn json_line_user_valid)" eval "$(awk "/^json_line_user_matches_filter='/,/^'/" "$CORE")" eval "$(extract_fn cmd_json_meta)" +eval "$(extract_fn json_edit_config_atomically)" +eval "$(extract_fn cmd_json_stats)" +is_config_json="$TMP/config.json"; is_core_bin=$(command -v true); is_core=sing-box +manage() { :; } +echo '{"log":{},"dns":{}}' >"$is_config_json" # --- fixtures ---------------------------------------------------------------- cat >"$is_conf_dir/vless-443.json" <<'EOF' @@ -101,6 +106,22 @@ out5=$(cmd_json_meta 2>/dev/null) chk "chain preserved" "$(jq -c '.inbounds[] | select(.tag=="vless-443.json") | .chain' <<<"$out5")" \ '{"downstream_line_uuid":"44444444-4444-4444-8444-444444444444","downstream_node":"qqpw"}' +# --- 8. stats on/off toggles the experimental API (loopback only) ---------------- +out=$(cmd_json_stats on 2>/dev/null) +chk "stats on ok" "$(jq -r .stats <<<"$out")" "on" +chk "stats listen default" "$(jq -r .experimental.v2ray_api.listen "$is_config_json")" "127.0.0.1:8080" +chk "stats enabled" "$(jq -r .experimental.v2ray_api.stats.enabled "$is_config_json")" "true" +out=$(cmd_json_stats on 127.0.0.1:9090 2>/dev/null) +chk "stats custom listen" "$(jq -r .experimental.v2ray_api.listen "$is_config_json")" "127.0.0.1:9090" +out=$(cmd_json_stats on 0.0.0.0:8080 2>&1); rc=$? +[ $rc -ne 0 ] && ok "routable listen rejected" || bad "routable listen rejected: $out" +out=$(cmd_json_stats on 127.0.0.1:notaport 2>&1); rc=$? +[ $rc -ne 0 ] && ok "bad port rejected" || bad "bad port rejected: $out" +out=$(cmd_json_stats off 2>/dev/null) +chk "stats off ok" "$(jq -r .stats <<<"$out")" "off" +chk "experimental block removed" "$(jq 'has("experimental")' "$is_config_json")" "false" +chk "other keys intact" "$(jq 'has("log") and has("dns")' "$is_config_json")" "true" + echo echo "PASS=$PASS FAIL=$FAIL" [ $FAIL -eq 0 ] From ebfcdbc1944393e103a1b56ec62148dce7c89da9 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Wed, 22 Jul 2026 05:51:21 -0700 Subject: [PATCH 3/3] Prevent unsafe identity mutation in adopted sing-box nodes Make the sb Design 15 commands resolve configuration files and users by exact identity, reject ambiguous legacy matches and traversal, preserve validated sidecar extensions, enforce literal loopback stats listeners, and surface restart failures instead of reporting partial success. Constraint: Existing unambiguous legacy user selectors remain supported during migration. Rejected: Match name or any credential field | shared credentials can delete unrelated users. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Never turn restart failure or invalid sidecar input into ok=true. Tested: bash -n src/core.sh tests/design-15-user-meta.sh; design-15-user-meta.sh 41/41; git diff --check Not-tested: Full 233boy installer harness on HK, deferred to canary --- src/core.sh | 165 ++++++++++++++++++++--------------- tests/design-15-user-meta.sh | 38 +++++++- 2 files changed, 131 insertions(+), 72 deletions(-) diff --git a/src/core.sh b/src/core.sh index da9878019..c376e4a04 100644 --- a/src/core.sh +++ b/src/core.sh @@ -500,15 +500,37 @@ ask() { # Atomically apply a jq program to the sidecar (create-if-absent). Mirrors the # repo's backup->jq->mv pattern: jq writes a temp file and we only mv it into # place on success, so the live sidecar is never left half-written. +lattice_meta_validate() { + jq -e ' + def uuidv4: type == "string" and test("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"); + if has("schema") then + .schema == "lattice.singbox-metadata.v2" + and ((.node_uuid // .node.node_uuid // "") as $n | ($n == "" or ($n | uuidv4))) + and (.inbounds | type == "array") + and (all(.inbounds[]; + (.tag | type == "string" and length > 0) + and (.line_uuid | uuidv4) + and ((.chain.downstream_line_uuid? // null) as $d | + $d == null or (($d | uuidv4) and $d != .line_uuid)))) + and (([.inbounds[].tag] | unique | length) == (.inbounds | length)) + and (([.inbounds[].line_uuid | ascii_downcase] | unique | length) == (.inbounds | length)) + else + ((.version // 1) == 1) and ((.node // {}) | type == "object") and ((.lines // {}) | type == "object") + end + ' >/dev/null +} + lattice_meta_apply() { local filter=$1 shift local tmp base='{"version":1,"node":{},"lines":{}}' - [[ -s $is_lattice_meta ]] && base=$(jq -c . "$is_lattice_meta" 2>/dev/null) - [[ $base ]] || base='{"version":1,"node":{},"lines":{}}' + if [[ -s $is_lattice_meta ]]; then + base=$(jq -c . "$is_lattice_meta" 2>/dev/null) || return 1 + lattice_meta_validate <<<"$base" || return 1 + fi mkdir -p "$(dirname "$is_lattice_meta")" 2>/dev/null tmp=$(mktemp "${TMPDIR:-/tmp}/lattice-meta.XXXXXX") || return 1 - if jq "$@" "$filter" <<<"$base" >"$tmp" 2>/dev/null; then + if jq "$@" "$filter" <<<"$base" >"$tmp" 2>/dev/null && lattice_meta_validate <"$tmp"; then mv "$tmp" "$is_lattice_meta" else rm -f "$tmp" @@ -542,6 +564,7 @@ lattice_meta_write_node() { # Look up a line's line_id from the sidecar (keyed by conf filename). lattice_meta_line_id() { [[ -f $is_lattice_meta ]] || return 0 + lattice_meta_validate <"$is_lattice_meta" || return 0 jq -r --arg n "$1" '.lines[$n].line_id // empty' "$is_lattice_meta" 2>/dev/null } @@ -561,11 +584,17 @@ lattice_meta_del_line() { # nodes) fills any gaps so migrated readers keep emitting the same shape. lattice_meta_obj_for() { local name=$1 raw_file=$2 sidecar='{}' legacy='{}' - [[ -f $is_lattice_meta ]] && sidecar=$(jq -c --arg n "$name" ' - (.lines[$n] // {}) as $line - | (.node // {}) as $node - | {line_id:($line.line_id // ""), node_uuid:($node.node_uuid // ""), node_id:($node.node_id // "")} - | with_entries(select(.value != ""))' "$is_lattice_meta" 2>/dev/null) + if [[ -f $is_lattice_meta ]]; then + if ! lattice_meta_validate <"$is_lattice_meta"; then + printf '{}\n' + return 0 + fi + sidecar=$(jq -c --arg n "$name" ' + (.lines[$n] // {}) as $line + | (.node // {}) as $node + | {line_id:($line.line_id // ""), node_uuid:($node.node_uuid // ""), node_id:($node.node_id // "")} + | with_entries(select(.value != ""))' "$is_lattice_meta" 2>/dev/null) + fi [[ $sidecar ]] || sidecar='{}' [[ $raw_file && -f $raw_file ]] && legacy=$(jq -c '(.inbounds[0]._lattice // {}) | {line_id:(.line_id // ""), node_uuid:(.node_uuid // ""), node_id:(.node_id // "")} @@ -577,7 +606,13 @@ lattice_meta_obj_for() { # Node-level identity object for `info --json` (sidecar .node, legacy _lattice fallback). lattice_meta_node_obj() { local raw_file=$1 node='{}' legacy='{}' - [[ -f $is_lattice_meta ]] && node=$(jq -c '.node // {}' "$is_lattice_meta" 2>/dev/null) + if [[ -f $is_lattice_meta ]]; then + if ! lattice_meta_validate <"$is_lattice_meta"; then + printf '{}\n' + return 0 + fi + node=$(jq -c '.node // {}' "$is_lattice_meta" 2>/dev/null) + fi [[ $node ]] || node='{}' [[ $raw_file && -f $raw_file ]] && legacy=$(jq -c '(.inbounds[0]._lattice // {}) | {node_uuid:(.node_uuid // ""), node_id:(.node_id // "")} @@ -2006,21 +2041,12 @@ cmd_json_list() { cmd_json_inspect() { is_json_out=1 local name="$1" - local files=() matches=() cleaned=() lines=() f out + local files=() lines=() f out resolve_out resolve_rc if [[ $name ]]; then - if [[ -f $is_conf_dir/$name ]]; then - matches=("$name") - elif [[ -f $is_conf_dir/$name.json ]]; then - matches=("$name.json") - elif [[ -d $is_conf_dir ]]; then - readarray -t matches <<<"$(ls "$is_conf_dir" 2>/dev/null | grep -F -i -- "$name" | sed '/dynamic-port-.*-link/d')" - fi - for f in "${matches[@]}"; do - [[ $f && $f =~ \.json$ ]] && cleaned+=("$f") - done - [[ ${#cleaned[@]} -eq 0 ]] && json_err "not_found" "no line matches: $name" 2 - [[ ${#cleaned[@]} -gt 1 ]] && json_err "ambiguous" "multiple lines match: $name" 2 - is_config_file="${cleaned[0]}" + resolve_out=$(json_resolve_config_file "$name" 2>&1) + resolve_rc=$? + [[ $resolve_rc -eq 0 ]] || { printf '%s\n' "$resolve_out"; exit "$resolve_rc"; } + is_config_file="$resolve_out" is_dont_show_info=1 is_dont_test_host=1 info "$is_config_file" >/dev/null 2>&1 @@ -2052,14 +2078,13 @@ cmd_json_info() { is_json_out=1 local name="$1" [[ ! $name ]] && json_err "missing_name" "info --json requires a node name" 2 - local matches=() cleaned=() m - [[ -d $is_conf_dir ]] && readarray -t matches <<<"$(ls "$is_conf_dir" 2>/dev/null | grep -E -i "$name" | sed '/dynamic-port-.*-link/d')" - for m in "${matches[@]}"; do [[ $m ]] && cleaned+=("$m"); done - [[ ${#cleaned[@]} -eq 0 ]] && json_err "not_found" "no node matches: $name" 2 - [[ ${#cleaned[@]} -gt 1 ]] && json_err "ambiguous" "multiple nodes match: $name" 2 + local resolved resolve_rc + resolved=$(json_resolve_config_file "$name" 2>&1) + resolve_rc=$? + [[ $resolve_rc -eq 0 ]] || { printf '%s\n' "$resolved"; exit "$resolve_rc"; } is_dont_show_info=1 is_dont_test_host=1 - info "${cleaned[0]}" 2>/dev/null + info "$resolved" 2>/dev/null # design-09 §E.2: also surface the Lattice node object (node_uuid/node_id plus # purity_percent/quality when recorded) from the sidecar, keeping `node` as-is. local node_obj lattice_node @@ -2075,17 +2100,22 @@ cmd_json_info() { json_resolve_config_file() { local name="$1" - local matches=() cleaned=() f + local matches=() cleaned=() f exact= [[ ! $name ]] && json_err "missing_name" "line name is required" 2 - if [[ -f $is_conf_dir/$name ]]; then - matches=("$name") - elif [[ -f $is_conf_dir/$name.json ]]; then - matches=("$name.json") + [[ $name == "${name##*/}" && $name != "." && $name != ".." && $name != *$'\n'* && $name != *$'\r'* ]] \ + || json_err "invalid_name" "line name must be a basename" 2 + if [[ $name == *.json && -f "$is_conf_dir/$name" ]]; then + exact="$name" + elif [[ $name != *.json && -f "$is_conf_dir/$name.json" ]]; then + exact="$name.json" + fi + if [[ $exact ]]; then + matches=("$exact") elif [[ -d $is_conf_dir ]]; then readarray -t matches <<<"$(ls "$is_conf_dir" 2>/dev/null | grep -F -i -- "$name" | sed '/dynamic-port-.*-link/d')" fi for f in "${matches[@]}"; do - [[ $f && $f =~ \.json$ ]] && cleaned+=("$f") + [[ $f == "${f##*/}" && $f == *.json ]] && cleaned+=("$f") done [[ ${#cleaned[@]} -eq 0 ]] && json_err "not_found" "no line matches: $name" 2 [[ ${#cleaned[@]} -gt 1 ]] && json_err "ambiguous" "multiple lines match: $name" 2 @@ -2191,7 +2221,7 @@ cmd_json_user() { json_write_config_atomically "$raw_file" "$filter" "$user_json" count_after=$(jq '(.inbounds[0].users // []) | length' "$raw_file" 2>/dev/null) [[ $count_after =~ ^[0-9]+$ ]] || count_after=0 - manage restart "$is_core" >/dev/null 2>&1 || true + manage restart "$is_core" >/dev/null 2>&1 || json_err "restart_failed" "configuration changed but sing-box restart failed" 1 jq -nc --arg action "$op" --arg line "$config_file" --argjson before "$count_before" --argjson after "$count_after" \ '{ok:true,action:$action,line:$line,user_count_before:$before,user_count_after:$after}' exit 0 @@ -2207,41 +2237,41 @@ cmd_json_meta() { is_json_out=1 [[ -d $is_conf_dir ]] || json_err "not_found" "conf dir not found: $is_conf_dir" 2 local old='{}' now tmp doc node_uuid node_id - [[ -s $is_lattice_meta ]] && old=$(jq -c . "$is_lattice_meta" 2>/dev/null) - [[ $old ]] || old='{}' + if [[ -s $is_lattice_meta ]]; then + old=$(jq -c . "$is_lattice_meta" 2>/dev/null) || json_err "metadata_invalid" "existing sidecar is corrupt; refusing to overwrite it" 2 + lattice_meta_validate <<<"$old" || json_err "metadata_invalid" "existing sidecar schema or UUID data is invalid; refusing to overwrite it" 2 + fi now=$(date -u +%Y-%m-%dT%H:%M:%SZ) node_uuid=${LATTICE_IDENTITY_UUID:-$(jq -r '.node_uuid // .node.node_uuid // empty' <<<"$old" 2>/dev/null)} node_id=${LATTICE_NODE_ID:-$(jq -r '.node_id // .node.node_id // empty' <<<"$old" 2>/dev/null)} [[ $node_id ]] || json_err "missing_node_id" "node id unknown; set LATTICE_NODE_ID (or keep the existing sidecar)" 2 - local f tag uuid chain inbounds='[]' + local f tag uuid prior inbounds='[]' for f in "$is_conf_dir"/*.json; do [[ -f $f ]] || continue tag=$(basename "$f") uuid=$(jq -r --arg t "$tag" '(.inbounds // []) | map(select(.tag == $t)) | .[0].line_uuid // empty' <<<"$old" 2>/dev/null) [[ ! $uuid ]] && uuid=$(jq -r --arg t "$tag" '.lines[$t].line_id // empty' <<<"$old" 2>/dev/null) [[ ! $uuid ]] && { get_uuid; uuid=$tmp_uuid; } - chain=$(jq -c --arg t "$tag" '(.inbounds // []) | map(select(.tag == $t)) | .[0].chain // empty' <<<"$old" 2>/dev/null) - if [[ $chain ]]; then - inbounds=$(jq -c --arg t "$tag" --arg u "$uuid" --argjson c "$chain" '. + [{tag:$t, line_uuid:$u, chain:$c}]' <<<"$inbounds") - else - inbounds=$(jq -c --arg t "$tag" --arg u "$uuid" '. + [{tag:$t, line_uuid:$u}]' <<<"$inbounds") - fi + prior=$(jq -c --arg t "$tag" '(.inbounds // []) | map(select(.tag == $t)) | .[0] // {}' <<<"$old" 2>/dev/null) + [[ $prior ]] || prior='{}' + inbounds=$(jq -c --arg t "$tag" --arg u "$uuid" --argjson p "$prior" '. + [($p + {tag:$t, line_uuid:$u})]' <<<"$inbounds") done doc=$(jq -n \ - --arg now "$now" --arg node_uuid "$node_uuid" --arg node_id "$node_id" --argjson inbounds "$inbounds" ' + --arg now "$now" --arg node_uuid "$node_uuid" --arg node_id "$node_id" --argjson inbounds "$inbounds" --argjson old "$old" ' ($inbounds | map({key:.tag, value:{line_id:.line_uuid}}) | from_entries) as $v1lines - | { + | $old + { schema:"lattice.singbox-metadata.v2", node_id:$node_id, updated_at:$now, writer:"sb", inbounds:$inbounds, - node:{node_uuid:$node_uuid, node_id:$node_id} | with_entries(select(.value != "")), + node:(($old.node // {}) + ({node_uuid:$node_uuid, node_id:$node_id} | with_entries(select(.value != "")))), lines:$v1lines, - reserved:{in_config_key:"_lattice", - fields:{line_uuid:"string",node_uuid:"string",line_hash_id:"string"}} + reserved:(($old.reserved // {}) + {in_config_key:"_lattice", + fields:(($old.reserved.fields // {}) + {line_uuid:"string",node_uuid:"string",line_hash_id:"string"})}) } | if $node_uuid != "" then .node_uuid=$node_uuid else . end') + lattice_meta_validate <<<"$doc" || json_err "metadata_invalid" "generated sidecar schema or UUID data is invalid" 2 tmp=$(mktemp "${TMPDIR:-/tmp}/lattice-meta-v2.XXXXXX") || json_err "tmp_failed" "cannot create temp file" 2 mkdir -p "$(dirname "$is_lattice_meta")" 2>/dev/null jq . <<<"$doc" >"$tmp" || { rm -f "$tmp"; json_err "jq_failed" "failed to render v2 metadata" 2; } @@ -2287,10 +2317,16 @@ cmd_json_stats() { [[ $op == "on" || $op == "off" ]] || json_err "invalid_action" "stats action must be on or off" 2 [[ -f $is_config_json ]] || json_err "not_found" "config.json not found: $is_config_json" 2 if [[ $op == "on" ]]; then - case "$listen" in - 127.* | localhost:* | \[::1\]:*) ;; - *) json_err "invalid_listen" "stats listen must be loopback (e.g. 127.0.0.1:8080)" 2 ;; - esac + local host + host=${listen%:*} + if [[ $host == localhost || $host == "[::1]" ]]; then + : + elif [[ $host =~ ^127\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]] \ + && [[ ${BASH_REMATCH[1]} -le 255 && ${BASH_REMATCH[2]} -le 255 && ${BASH_REMATCH[3]} -le 255 ]]; then + : + else + json_err "invalid_listen" "stats listen must use a literal loopback address or localhost" 2 + fi local port="${listen##*:}" [[ $port =~ ^[0-9]+$ && $port -ge 1 && $port -le 65535 ]] || json_err "invalid_listen" "stats listen port is invalid" 2 json_edit_config_atomically "$is_config_json" \ @@ -2299,7 +2335,7 @@ cmd_json_stats() { json_edit_config_atomically "$is_config_json" \ 'del(.experimental.v2ray_api) | if (.experimental // {} | length) == 0 then del(.experimental) else . end' fi - manage restart "$is_core" >/dev/null 2>&1 || true + manage restart "$is_core" >/dev/null 2>&1 || json_err "restart_failed" "configuration changed but sing-box restart failed" 1 jq -nc --arg action "$op" --arg listen "$listen" '{ok:true,stats:$action,listen:(if $action=="on" then $listen else "" end)}' exit 0 } @@ -2404,21 +2440,12 @@ cmd_json_conncheck() { [[ $timeout_sec ]] || timeout_sec=10 [[ $timeout_sec =~ ^[0-9]+$ && $timeout_sec -ge 2 && $timeout_sec -le 60 ]] || json_err "invalid_timeout" "timeout_sec must be 2-60" 2 - local matches=() cleaned=() f - if [[ -f $is_conf_dir/$name ]]; then - matches=("$name") - elif [[ -f $is_conf_dir/$name.json ]]; then - matches=("$name.json") - elif [[ -d $is_conf_dir ]]; then - readarray -t matches <<<"$(ls "$is_conf_dir" 2>/dev/null | grep -F -i -- "$name" | sed '/dynamic-port-.*-link/d')" - fi - for f in "${matches[@]}"; do - [[ $f && $f =~ \.json$ ]] && cleaned+=("$f") - done - [[ ${#cleaned[@]} -eq 0 ]] && json_err "not_found" "no line matches: $name" 2 - [[ ${#cleaned[@]} -gt 1 ]] && json_err "ambiguous" "multiple lines match: $name" 2 + local resolved resolve_rc + resolved=$(json_resolve_config_file "$name" 2>&1) + resolve_rc=$? + [[ $resolve_rc -eq 0 ]] || { printf '%s\n' "$resolved"; exit "$resolve_rc"; } - is_config_file="${cleaned[0]}" + is_config_file="$resolved" is_dont_show_info=1 is_dont_test_host=1 info "$is_config_file" >/dev/null 2>&1 diff --git a/tests/design-15-user-meta.sh b/tests/design-15-user-meta.sh index 940b4afc4..baba04e53 100755 --- a/tests/design-15-user-meta.sh +++ b/tests/design-15-user-meta.sh @@ -23,6 +23,8 @@ is_json_out=1 eval "$(extract_fn json_line_user_obj)" eval "$(extract_fn json_line_user_valid)" eval "$(awk "/^json_line_user_matches_filter='/,/^'/" "$CORE")" +eval "$(extract_fn lattice_meta_validate)" +eval "$(extract_fn json_resolve_config_file)" eval "$(extract_fn cmd_json_meta)" eval "$(extract_fn json_edit_config_atomically)" eval "$(extract_fn cmd_json_stats)" @@ -79,7 +81,7 @@ chk "rerun preserves line_uuid" "$u2" "$u1" # --- 5. meta: v1 legacy sidecar upgrades preserving identity ------------------ rm -f "$is_lattice_meta" cat >"$is_lattice_meta" <<'EOF' -{"version":1,"node":{"node_uuid":"node-uuid-legacy","node_id":"legacy-node"}, +{"version":1,"node":{"node_uuid":"55555555-5555-4555-8555-555555555555","node_id":"legacy-node"}, "lines":{"vless-443.json":{"line_id":"33333333-3333-4333-8333-333333333333"}}} EOF LATTICE_NODE_ID="" @@ -88,7 +90,7 @@ chk "v1 line_id adopted as line_uuid" \ "$(jq -r '.inbounds[] | select(.tag=="vless-443.json") | .line_uuid' <<<"$out3")" \ "33333333-3333-4333-8333-333333333333" chk "node_id adopted from v1" "$(jq -r .node_id <<<"$out3")" "legacy-node" -chk "node_uuid adopted from v1" "$(jq -r .node_uuid <<<"$out3")" "node-uuid-legacy" +chk "node_uuid adopted from v1" "$(jq -r .node_uuid <<<"$out3")" "55555555-5555-4555-8555-555555555555" # --- 6. meta: missing node id fails loud -------------------------------------- rm -f "$is_lattice_meta"; LATTICE_NODE_ID="" @@ -98,13 +100,37 @@ out4=$(cmd_json_meta 2>&1); rc=$? # --- 7. chain block preserved on rerun ---------------------------------------- cat >"$is_lattice_meta" </dev/null) chk "chain preserved" "$(jq -c '.inbounds[] | select(.tag=="vless-443.json") | .chain' <<<"$out5")" \ '{"downstream_line_uuid":"44444444-4444-4444-8444-444444444444","downstream_node":"qqpw"}' +chk "unknown top-level field preserved" "$(jq -r .future_top.keep <<<"$out5")" "true" +chk "unknown inbound field preserved" "$(jq -r '.inbounds[] | select(.tag=="vless-443.json") | .future_line' <<<"$out5")" "keep" + +# --- 7b. meta: corrupt/invalid v2 fails closed ------------------------------- +printf '%s\n' '{"schema":"lattice.singbox-metadata.v2","inbounds":[{"tag":"vless-443.json","line_uuid":"bad"}]}' >"$is_lattice_meta" +before=$(cat "$is_lattice_meta") +invalid_out=$(cmd_json_meta 2>&1); invalid_rc=$? +[ $invalid_rc -ne 0 ] && ok "invalid v2 sidecar rejected" || bad "invalid v2 sidecar rejected: $invalid_out" +chk "invalid v2 sidecar not overwritten" "$(cat "$is_lattice_meta")" "$before" +rm -f "$is_lattice_meta" +LATTICE_NODE_ID="test-node"; LATTICE_IDENTITY_UUID="not-a-uuid" +invalid_out=$(cmd_json_meta 2>&1); invalid_rc=$? +[ $invalid_rc -ne 0 ] && ok "invalid environment node UUID rejected" || bad "invalid environment node UUID rejected: $invalid_out" +LATTICE_IDENTITY_UUID="" + +# --- 7c. exact lookup, ambiguity, and traversal ------------------------------ +cp "$is_conf_dir/vless-443.json" "$is_conf_dir/vless-443-copy.json" +chk "exact filename wins over fuzzy matches" "$(json_resolve_config_file vless-443.json)" "vless-443.json" +lookup_out=$(json_resolve_config_file vless 2>&1); lookup_rc=$? +[ $lookup_rc -ne 0 ] && ok "legacy fuzzy lookup rejects ambiguity" || bad "legacy fuzzy lookup rejects ambiguity: $lookup_out" +lookup_out=$(json_resolve_config_file ../vless-443.json 2>&1); lookup_rc=$? +[ $lookup_rc -ne 0 ] && ok "config traversal rejected" || bad "config traversal rejected: $lookup_out" +rm -f "$is_conf_dir/vless-443-copy.json" # --- 8. stats on/off toggles the experimental API (loopback only) ---------------- out=$(cmd_json_stats on 2>/dev/null) @@ -115,6 +141,8 @@ out=$(cmd_json_stats on 127.0.0.1:9090 2>/dev/null) chk "stats custom listen" "$(jq -r .experimental.v2ray_api.listen "$is_config_json")" "127.0.0.1:9090" out=$(cmd_json_stats on 0.0.0.0:8080 2>&1); rc=$? [ $rc -ne 0 ] && ok "routable listen rejected" || bad "routable listen rejected: $out" +out=$(cmd_json_stats on 127.evil:8080 2>&1); rc=$? +[ $rc -ne 0 ] && ok "non-literal 127 host rejected" || bad "non-literal 127 host rejected: $out" out=$(cmd_json_stats on 127.0.0.1:notaport 2>&1); rc=$? [ $rc -ne 0 ] && ok "bad port rejected" || bad "bad port rejected: $out" out=$(cmd_json_stats off 2>/dev/null) @@ -122,6 +150,10 @@ chk "stats off ok" "$(jq -r .stats <<<"$out")" "off" chk "experimental block removed" "$(jq 'has("experimental")' "$is_config_json")" "false" chk "other keys intact" "$(jq 'has("log") and has("dns")' "$is_config_json")" "true" +manage() { return 1; } +out=$(cmd_json_stats on 2>&1); rc=$? +[ $rc -ne 0 ] && [[ $out == *restart_failed* ]] && ok "restart failure cannot report ok" || bad "restart failure cannot report ok: rc=$rc out=$out" + echo echo "PASS=$PASS FAIL=$FAIL" [ $FAIL -eq 0 ]