diff --git a/src/core.sh b/src/core.sh index bca07e3a9..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 @@ -2099,13 +2129,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 +2155,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)) ' @@ -2190,12 +2221,125 @@ 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 } +# 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 + 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 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; } + 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" --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:(($old.node // {}) + ({node_uuid:$node_uuid, node_id:$node_id} | with_entries(select(.value != "")))), + lines:$v1lines, + 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; } + mv "$tmp" "$is_lattice_meta" + cat "$is_lattice_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 + 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" \ + '.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 || 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 +} + conncheck_now_ms() { local now now=$(date +%s%3N 2>/dev/null) @@ -2296,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 @@ -2657,6 +2792,12 @@ main() { user) cmd_json_user "$2" "$3" "$4" ;; + 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 bf8066d80..bc8ac4011 100644 --- a/src/help.sh +++ b/src/help.sh @@ -67,6 +67,9 @@ show_help() { "自动化:" " list, ls [filter] 以 JSON 列出配置" " 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 new file mode 100755 index 000000000..baba04e53 --- /dev/null +++ b/tests/design-15-user-meta.sh @@ -0,0 +1,159 @@ +#!/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 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)" +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' +{"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":"55555555-5555-4555-8555-555555555555","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")" "55555555-5555-4555-8555-555555555555" + +# --- 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"}' +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) +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.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) +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 ]