init
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Build artifacts produced by build.sh (reproducible; regenerate with ./build.sh).
|
||||||
|
# Remove these lines if you want to commit the built site (e.g. for GitHub Pages).
|
||||||
|
web/singvis.wasm
|
||||||
|
web/singvis.wasm.gz
|
||||||
|
web/wasm_exec.js
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# sing-vis
|
||||||
|
|
||||||
|
A web app that explains **how a [sing-box](https://github.com/SagerNet/sing-box) configuration routes a domain or IP**. Paste a sing-box JSON config, enter a list of domains/IPs, and sing-vis shows — for each one — which DNS rule and route rule it hits, every condition evaluated along the way, and the final DNS server and outbound.
|
||||||
|
|
||||||
|
It runs **entirely in your browser**: the matching engine is sing-box's own Go code compiled to WebAssembly. There is no backend, nothing is uploaded, and you can host it as static files (including on GitHub Pages).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Requires Go (the module targets `go 1.24.7`; the default `GOTOOLCHAIN=auto` fetches a matching toolchain automatically). No Node/npm — the frontend has no build step.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh # builds the wasm engine, serves http://127.0.0.1:8787
|
||||||
|
./run.sh 9000 # custom port
|
||||||
|
./run.sh 0.0.0.0:9000 # custom host:port (expose on your LAN)
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the printed URL. That's it.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
1. **Paste a config.** The editor opens pre-filled with a sample. Replace it with your own sing-box JSON in the config box (JSONC comments are fine), and give the profile a name.
|
||||||
|
2. **List what to check.** In *Domains / IPs to check*, put one host per line — domains (`www.google.com`) or raw IPs (`1.1.1.1`). Lines starting with `#` are ignored. Pasted URLs and `host:port` strings are accepted (scheme/path/port are stripped).
|
||||||
|
3. **Click ▶ Analyze.** The first run downloads the ~3.3 MB wasm engine (cached afterwards). For every input you get:
|
||||||
|
- **DNS routing** — which `dns.rules` rule matches, the resulting DNS **server** (or `reject`/other action), and the outbound **detour** that server is reached through.
|
||||||
|
- **Route matching** — every `route.rules` rule in order, each condition's result (**match** / no match / **`UNKNOWN?`**), which `rule_set` matched and on which headless rule, down to the **final outbound**.
|
||||||
|
- **Resolved IPs** — the A/AAAA records fetched via DoH (shown when a domain is resolved).
|
||||||
|
|
||||||
|
Click any result card to expand it, and any rule step to see its per-condition breakdown.
|
||||||
|
4. **Save the profile** with 💾. Profiles live in your browser (IndexedDB), persist across reloads, and appear in the left sidebar. Settings persist too. Everything stays on your machine.
|
||||||
|
|
||||||
|
### Toolbar options
|
||||||
|
|
||||||
|
- **Resolve IPs for IP rules** (on by default) — pre-resolves each domain via DoH so `ip_cidr` and IP rule-set rules match the resolved address, matching what you'd intuitively expect. Turn it off for strict sing-box semantics, where IP rules only match *after* an explicit `resolve` action.
|
||||||
|
- **network: any / tcp / udp** — an assumed connection network, so rules filtering on `network` can be evaluated.
|
||||||
|
- **⚙ Settings** — the DoH endpoint (default `https://1.1.1.1/dns-query`).
|
||||||
|
|
||||||
|
### Conditions that can't be known offline
|
||||||
|
|
||||||
|
Some rule conditions depend on live connection attributes that don't exist for a "what would this domain do?" query — `protocol`, `process_name`, `inbound`, `clash_mode`, source address/port, destination `port`, etc. These show as **`UNKNOWN?`** (amber). If such an undeterminable **terminal** rule sits *before* the definite match, the outcome is flagged **"depends on assumptions"**, because at runtime that rule could preempt the result.
|
||||||
|
|
||||||
|
### Rule sets
|
||||||
|
|
||||||
|
- **inline** — read straight from the config.
|
||||||
|
- **remote** — fetched live from its `url` (source `.json` or binary `.srs`, auto-detected). The URL must allow CORS (GitHub raw does).
|
||||||
|
- **local** — the browser can't read disk paths, so upload the file under *Local rule-set files* in the editor, keyed by the rule-set **tag** or its **path**. `.srs` is read as binary; anything else as source JSON.
|
||||||
|
|
||||||
|
## Why it's faithful
|
||||||
|
|
||||||
|
Rather than re-implement sing-box's matching, sing-vis **imports sing-box's own Go packages** for the version-sensitive parts:
|
||||||
|
|
||||||
|
- `option` — parses the config with sing-box's real unmarshalers (rule/action/rule-set dispatch, JSONC).
|
||||||
|
- `common/srs` — reads the binary `.srs` format (compiled domain succinct-sets and IP sets).
|
||||||
|
- `sing/common/domain` — the actual succinct-set domain/suffix matcher (the same code `route/rule.DomainItem` wraps).
|
||||||
|
|
||||||
|
sing-vis owns only the **orchestration** — AND across fields / OR within a field's array / logical `and`·`or` / `invert`, first-terminal-match-wins, the `resolve → ip_cidr` lifecycle, and the `final` fallback — so it can **instrument** every step and report exactly which condition and rule-set matched.
|
||||||
|
|
||||||
|
> The engine deliberately does **not** import sing-box's `adapter` / `route/rule` packages: they pull in the full outbound/dialer/sing-tun tree, which doesn't compile for `wasm`. The only rule fields needing a connection context are `domain` / `network` / `query_type` — the domain matcher is called directly on `sing/common/domain` (identical to `route/rule.DomainItem`), and network / query_type are plain membership tests. See `internal/engine/conditions.go`.
|
||||||
|
|
||||||
|
## Building & hosting
|
||||||
|
|
||||||
|
`./run.sh` is just `./build.sh` followed by a static file server. To build and serve separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh # -> web/singvis.wasm (+ .gz) and web/wasm_exec.js
|
||||||
|
python3 -m http.server -d web 8787 # or any static server
|
||||||
|
```
|
||||||
|
|
||||||
|
After `build.sh`, the `web/` directory is completely self-contained. To publish on **GitHub Pages**, serve `web/` (commit it, or copy it to a `gh-pages` branch / `docs/` folder). The built `web/singvis.wasm`, `web/singvis.wasm.gz`, and `web/wasm_exec.js` are generated artifacts (gitignored); regenerate them any time with `./build.sh`.
|
||||||
|
|
||||||
|
### The wasm build overlay
|
||||||
|
|
||||||
|
Three files in the `sing` dependency (`common/buf/buffer_unix.go`, `common/bufio/vectorised_unix.go`, `common/bufio/copy_direct_posix.go`) reference `golang.org/x/sys/unix`, which has no `GOARCH=wasm` equivalent. Those code paths (raw-socket `readv`/`writev`) never run in a browser. `build.sh` swaps them for wasm-safe stubs in `wasmbuild/_stubs/` via `go build -overlay` (generated by `wasmbuild/gen-overlay.sh`). **Only the wasm build uses the overlay** — native builds and `go test` are unaffected.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The engine is platform-agnostic and tested natively (no wasm needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/engine
|
||||||
|
```
|
||||||
|
|
||||||
|
## CORS
|
||||||
|
|
||||||
|
DoH resolution and remote rule-set fetches originate from the browser, so those endpoints must send `Access-Control-Allow-Origin`. The defaults do — Cloudflare/Google DoH JSON (`https://1.1.1.1/dns-query`, `https://dns.google/dns-query`) and `raw.githubusercontent.com`. Point sing-vis at an endpoint that doesn't, and that item shows a fetch error; swap it for a CORS-enabled one, or upload the rule-set file locally. The resolver uses the DoH **JSON API** (`?name=&type=&ct=application/dns-json`), a CORS "simple request" that avoids a preflight.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/wasm/ js/wasm entry point: exposes singvisAnalyze() to JS
|
||||||
|
internal/engine/ the matching engine (platform-agnostic, unit-tested)
|
||||||
|
parse.go config → option structs (route/dns rules, rule sets, servers)
|
||||||
|
conditions.go per-condition tri-state evaluation (match/no_match/unknown)
|
||||||
|
rules.go field extraction + logical-rule recursion
|
||||||
|
ruleset.go inline/remote/local rule-set loading (incl. .srs binary) + eval
|
||||||
|
route.go route-rule orchestration (actions, resolve, final)
|
||||||
|
dns.go dns-rule orchestration (actions, dns.final, server → detour)
|
||||||
|
analyze.go top-level per-input driver
|
||||||
|
internal/dnsx/ DoH (JSON API) resolver
|
||||||
|
web/ static single-page frontend (no build step)
|
||||||
|
index.html markup
|
||||||
|
app.js UI + rendering
|
||||||
|
storage.js profiles & settings in IndexedDB
|
||||||
|
worker.js Web Worker: loads the wasm engine, runs analyze off the UI thread
|
||||||
|
wasmbuild/ wasm build support (overlay generator + unix→wasm stubs)
|
||||||
|
sing-box/ upstream sing-box clone (imported via a go.mod replace)
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the sing-vis WebAssembly engine and stages the static site under web/.
|
||||||
|
# After this runs, web/ is fully self-contained and can be served by any static
|
||||||
|
# file server (including GitHub Pages) — there is no backend.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# The engine's Go dependencies (sing) contain three source files that reference
|
||||||
|
# golang.org/x/sys/unix, which has no equivalent for GOARCH=wasm. wasmbuild/
|
||||||
|
# supplies wasm-safe stubs and gen-overlay.sh maps them in via `go build
|
||||||
|
# -overlay`. Only this wasm build uses the overlay; native builds/tests do not.
|
||||||
|
overlay="$(mktemp)"
|
||||||
|
trap 'rm -f "$overlay"' EXIT
|
||||||
|
bash wasmbuild/gen-overlay.sh > "$overlay"
|
||||||
|
|
||||||
|
echo "building web/singvis.wasm (GOOS=js GOARCH=wasm)…"
|
||||||
|
GOOS=js GOARCH=wasm go build -overlay "$overlay" -trimpath -ldflags="-s -w" \
|
||||||
|
-o web/singvis.wasm ./cmd/wasm
|
||||||
|
|
||||||
|
# Ship the Go runtime's JS support shim next to the wasm. Newer Go keeps it under
|
||||||
|
# lib/wasm; older layouts use misc/wasm.
|
||||||
|
goroot="$(go env GOROOT)"
|
||||||
|
if [ -f "$goroot/lib/wasm/wasm_exec.js" ]; then
|
||||||
|
cp "$goroot/lib/wasm/wasm_exec.js" web/wasm_exec.js
|
||||||
|
elif [ -f "$goroot/misc/wasm/wasm_exec.js" ]; then
|
||||||
|
cp "$goroot/misc/wasm/wasm_exec.js" web/wasm_exec.js
|
||||||
|
else
|
||||||
|
echo "error: wasm_exec.js not found under $goroot" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pre-compress for static hosts that serve .gz when present (the wasm is large;
|
||||||
|
# gzip roughly quarters it).
|
||||||
|
if command -v gzip >/dev/null 2>&1; then
|
||||||
|
gzip -9 -f -k web/singvis.wasm
|
||||||
|
fi
|
||||||
|
|
||||||
|
size="$(du -h web/singvis.wasm | cut -f1)"
|
||||||
|
gzsize="$( [ -f web/singvis.wasm.gz ] && du -h web/singvis.wasm.gz | cut -f1 || echo n/a )"
|
||||||
|
echo "done. web/singvis.wasm=$size (gz=$gzsize), web/wasm_exec.js copied."
|
||||||
|
echo "serve the web/ directory statically, e.g.: ./run.sh"
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Command wasm is the browser entry point for sing-vis. Built with
|
||||||
|
// `GOOS=js GOARCH=wasm`, it exposes a single JS-callable function,
|
||||||
|
// `singvisAnalyze`, that runs the exact same internal/engine analysis the old
|
||||||
|
// HTTP server did — only now it runs entirely in the browser (in a Web Worker).
|
||||||
|
//
|
||||||
|
// Under js/wasm, net/http transparently uses the browser Fetch API, so the DoH
|
||||||
|
// resolver and remote rule-set fetching keep working (subject to CORS).
|
||||||
|
//
|
||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"sing-vis/internal/dnsx"
|
||||||
|
"sing-vis/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// analyzeRequest is the single JSON argument passed from JS. It mirrors the old
|
||||||
|
// POST /api/analyze body.
|
||||||
|
type analyzeRequest struct {
|
||||||
|
Config string `json:"config"`
|
||||||
|
Inputs []string `json:"inputs"`
|
||||||
|
RuleSetFiles map[string]engine.RuleSetFile `json:"ruleSetFiles"`
|
||||||
|
DoHServer string `json:"dohServer"`
|
||||||
|
Network string `json:"network"` // optional: tcp/udp assumption
|
||||||
|
AssumeResolved *bool `json:"assumeResolved"` // default true
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDoHServer = "https://1.1.1.1/dns-query"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
js.Global().Set("singvisAnalyze", js.FuncOf(analyze))
|
||||||
|
// Signal readiness to the host (worker) so it need not poll.
|
||||||
|
if ready := js.Global().Get("singvisReady"); ready.Type() == js.TypeFunction {
|
||||||
|
ready.Invoke()
|
||||||
|
}
|
||||||
|
select {} // keep the Go runtime alive to service calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyze is the JS-facing entry point. It takes one JSON string argument and
|
||||||
|
// returns a Promise<string> that resolves to the marshaled engine.Result (or
|
||||||
|
// rejects with an Error). The work runs in a goroutine so the JS event loop is
|
||||||
|
// never blocked while DoH / rule-set fetches are in flight.
|
||||||
|
func analyze(_ js.Value, args []js.Value) any {
|
||||||
|
var input string
|
||||||
|
if len(args) > 0 && args[0].Type() == js.TypeString {
|
||||||
|
input = args[0].String()
|
||||||
|
}
|
||||||
|
handler := js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
|
||||||
|
resolve := promiseArgs[0]
|
||||||
|
reject := promiseArgs[1]
|
||||||
|
go func() {
|
||||||
|
out, err := runAnalyze(input)
|
||||||
|
if err != nil {
|
||||||
|
reject.Invoke(jsError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve.Invoke(out)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return js.Global().Get("Promise").New(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAnalyze(input string) (string, error) {
|
||||||
|
var req analyzeRequest
|
||||||
|
if err := json.Unmarshal([]byte(input), &req); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid request: %w", err)
|
||||||
|
}
|
||||||
|
doh := req.DoHServer
|
||||||
|
if doh == "" {
|
||||||
|
doh = defaultDoHServer
|
||||||
|
}
|
||||||
|
assumeResolved := true
|
||||||
|
if req.AssumeResolved != nil {
|
||||||
|
assumeResolved = *req.AssumeResolved
|
||||||
|
}
|
||||||
|
result, err := engine.Analyze(context.Background(), engine.Request{
|
||||||
|
Config: req.Config,
|
||||||
|
Inputs: req.Inputs,
|
||||||
|
RuleSetFiles: req.RuleSetFiles,
|
||||||
|
Network: req.Network,
|
||||||
|
AssumeResolved: assumeResolved,
|
||||||
|
Resolver: dnsx.NewDoHResolver(doh),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsError(err error) js.Value {
|
||||||
|
return js.Global().Get("Error").New(err.Error())
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
module sing-vis
|
||||||
|
|
||||||
|
go 1.24.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4
|
||||||
|
github.com/sagernet/sing-box v0.0.0-00010101000000-000000000000
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/sagernet/sing-box => ./sing-box
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 h1:dIHd4IiQs0mtptfe8SdwDascDwh1w2LqDI8exoDuKeQ=
|
||||||
|
github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// Package dnsx provides a DNS-over-HTTPS resolver used to resolve domains to IP
|
||||||
|
// addresses when route rules depend on the resolved IP (ip_cidr, IP rule sets)
|
||||||
|
// or when a rule's action is "resolve".
|
||||||
|
//
|
||||||
|
// It uses the DoH JSON API (https://developers.google.com/speed/public-dns/docs/doh/json,
|
||||||
|
// also implemented by Cloudflare) rather than the RFC 8484 wireformat. In a
|
||||||
|
// browser this matters: a JSON GET with `Accept: application/dns-json` is a CORS
|
||||||
|
// "simple request", so it avoids the preflight that a wireformat POST with a
|
||||||
|
// custom Content-Type would trigger, and it needs no DNS message packer.
|
||||||
|
package dnsx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolver resolves a hostname to A/AAAA records.
|
||||||
|
type Resolver interface {
|
||||||
|
// Resolve returns resolved addresses for name. strategy is one of
|
||||||
|
// "", "prefer_ipv4", "prefer_ipv6", "ipv4_only", "ipv6_only".
|
||||||
|
Resolve(ctx context.Context, name string, strategy string) (*Result, error)
|
||||||
|
Server() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result holds resolved addresses and diagnostic info about the query.
|
||||||
|
type Result struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPv4 []string `json:"ipv4"`
|
||||||
|
IPv6 []string `json:"ipv6"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns v4+v6 addresses honoring the strategy ordering.
|
||||||
|
func (r *Result) All(strategy string) []string {
|
||||||
|
switch strategy {
|
||||||
|
case "ipv4_only":
|
||||||
|
return r.IPv4
|
||||||
|
case "ipv6_only":
|
||||||
|
return r.IPv6
|
||||||
|
case "prefer_ipv6":
|
||||||
|
return append(append([]string{}, r.IPv6...), r.IPv4...)
|
||||||
|
default: // prefer_ipv4 / unset
|
||||||
|
return append(append([]string{}, r.IPv4...), r.IPv6...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoHResolver implements Resolver against a DoH JSON endpoint.
|
||||||
|
type DoHResolver struct {
|
||||||
|
server string
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache map[string]*Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDoHResolver builds a resolver for the given DoH endpoint URL.
|
||||||
|
func NewDoHResolver(server string) *DoHResolver {
|
||||||
|
return &DoHResolver{
|
||||||
|
server: server,
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
cache: map[string]*Result{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DoHResolver) Server() string { return d.server }
|
||||||
|
|
||||||
|
// DNS record types used by the JSON API.
|
||||||
|
const (
|
||||||
|
typeA = 1
|
||||||
|
typeAAAA = 28
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolve queries A and AAAA records for name over DoH JSON, caching per resolver.
|
||||||
|
func (d *DoHResolver) Resolve(ctx context.Context, name string, strategy string) (*Result, error) {
|
||||||
|
name = strings.TrimSuffix(strings.ToLower(name), ".")
|
||||||
|
d.mu.Lock()
|
||||||
|
if r, ok := d.cache[name]; ok {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
res := &Result{Name: name}
|
||||||
|
var firstErr error
|
||||||
|
|
||||||
|
if strategy != "ipv6_only" {
|
||||||
|
v4, err := d.query(ctx, name, typeA)
|
||||||
|
if err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
res.IPv4 = v4
|
||||||
|
}
|
||||||
|
if strategy != "ipv4_only" {
|
||||||
|
v6, err := d.query(ctx, name, typeAAAA)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
res.IPv6 = v6
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.IPv4) == 0 && len(res.IPv6) == 0 && firstErr != nil {
|
||||||
|
res.Error = firstErr.Error()
|
||||||
|
return res, firstErr
|
||||||
|
}
|
||||||
|
d.mu.Lock()
|
||||||
|
d.cache[name] = res
|
||||||
|
d.mu.Unlock()
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonResponse is the DoH JSON API response shape (Google/Cloudflare).
|
||||||
|
type jsonResponse struct {
|
||||||
|
Status int `json:"Status"`
|
||||||
|
Answer []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"Answer"`
|
||||||
|
Comment string `json:"Comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DoHResolver) query(ctx context.Context, name string, qtype int) ([]string, error) {
|
||||||
|
endpoint, err := url.Parse(d.server)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid DoH server %q: %w", d.server, err)
|
||||||
|
}
|
||||||
|
q := endpoint.Query()
|
||||||
|
q.Set("name", name)
|
||||||
|
q.Set("type", fmt.Sprint(qtype))
|
||||||
|
q.Set("ct", "application/dns-json")
|
||||||
|
endpoint.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Accept is a CORS-safelisted header, so this stays a simple request.
|
||||||
|
req.Header.Set("Accept", "application/dns-json")
|
||||||
|
resp, err := d.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("DoH status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var parsed jsonResponse
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse DoH JSON: %w", err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, a := range parsed.Answer {
|
||||||
|
if a.Type == qtype {
|
||||||
|
out = append(out, a.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func analyze(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
cfg, err := ParseConfig(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res := &Result{Warnings: append([]string{}, cfg.Warnings...)}
|
||||||
|
if req.Resolver != nil {
|
||||||
|
res.DoHServer = req.Resolver.Server()
|
||||||
|
}
|
||||||
|
rs := newRuleSetResolver(ctx, cfg, req.RuleSetFiles, &res.Warnings)
|
||||||
|
|
||||||
|
for _, raw := range req.Inputs {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Inputs = append(res.Inputs, analyzeInput(ctx, cfg, rs, req, line))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeInput(ctx context.Context, cfg *Config, rs *ruleSetResolver, req Request, line string) InputTrace {
|
||||||
|
it := InputTrace{Input: line}
|
||||||
|
host := normalizeInput(line)
|
||||||
|
ec := &evalCtx{ctx: ctx, network: req.Network, rs: rs, resolver: req.Resolver}
|
||||||
|
|
||||||
|
if addr, ok := parseIPInput(host); ok {
|
||||||
|
it.Kind = "ip"
|
||||||
|
ec.destIsIP = true
|
||||||
|
ec.destAddr = addr
|
||||||
|
it.Route = ec.matchRoute(cfg)
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
if !looksLikeDomain(host) {
|
||||||
|
it.Kind = "invalid"
|
||||||
|
it.Error = "not a valid domain or IP address"
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
it.Kind = "domain"
|
||||||
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||||
|
ec.host = host
|
||||||
|
|
||||||
|
// Resolve via DoH for display and (optionally) to let IP rules match.
|
||||||
|
if req.Resolver != nil {
|
||||||
|
r, _ := req.Resolver.Resolve(ctx, host, "")
|
||||||
|
if r != nil {
|
||||||
|
it.Resolved = &ResolvedInfo{Server: req.Resolver.Server(), IPv4: r.IPv4, IPv6: r.IPv6, Error: r.Error}
|
||||||
|
ec.resolved = it.Resolved
|
||||||
|
if req.AssumeResolved {
|
||||||
|
if addrs := parseAddrs(r.All("")); len(addrs) > 0 {
|
||||||
|
ec.setAddresses(addrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it.Resolved = &ResolvedInfo{Server: req.Resolver.Server(), Error: "resolution failed"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it.DNS = ec.matchDNS(cfg)
|
||||||
|
it.Route = ec.matchRoute(cfg)
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeInput strips scheme, path, userinfo and a trailing :port so pasted
|
||||||
|
// URLs or host:port strings still analyze correctly.
|
||||||
|
func normalizeInput(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if i := strings.Index(s, "://"); i >= 0 {
|
||||||
|
s = s[i+3:]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(s, '/'); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
if i := strings.LastIndexByte(s, '@'); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
// Strip a trailing :port for domains / IPv4 (but not raw IPv6 which has many colons).
|
||||||
|
if strings.Count(s, ":") == 1 {
|
||||||
|
host, port, ok := strings.Cut(s, ":")
|
||||||
|
if ok && isAllDigits(port) && host != "" {
|
||||||
|
s = host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip [ ] around bracketed IPv6.
|
||||||
|
s = strings.TrimPrefix(s, "[")
|
||||||
|
s = strings.TrimSuffix(s, "]")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIPInput(s string) (netip.Addr, bool) {
|
||||||
|
addr, err := netip.ParseAddr(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}, false
|
||||||
|
}
|
||||||
|
return addr, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeDomain(s string) bool {
|
||||||
|
if s == "" || len(s) > 253 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '-' || r == '_' || r == '*') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllDigits(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing/common/domain"
|
||||||
|
"go4.org/netipx"
|
||||||
|
|
||||||
|
"sing-vis/internal/dnsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Condition groups (see docs/configuration/route/rule.md matching formula).
|
||||||
|
const (
|
||||||
|
groupDestAddr = "dest_addr"
|
||||||
|
groupSrcAddr = "src_addr"
|
||||||
|
groupDestPort = "dest_port"
|
||||||
|
groupSrcPort = "src_port"
|
||||||
|
groupRuleSet = "rule_set"
|
||||||
|
groupOther = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// evalCtx carries the per-input matching state.
|
||||||
|
//
|
||||||
|
// It intentionally does NOT use sing-box's adapter.InboundContext: importing the
|
||||||
|
// adapter package pulls in the full outbound/dialer/sing-tun dependency tree,
|
||||||
|
// which does not compile for GOARCH=wasm. The only rule fields that depend on an
|
||||||
|
// InboundContext are domain / network / query_type, and those are matched here
|
||||||
|
// directly (domain via sing/common/domain, the source of truth reused verbatim
|
||||||
|
// by route/rule.DomainItem; network / query_type are plain membership tests).
|
||||||
|
type evalCtx struct {
|
||||||
|
ctx context.Context
|
||||||
|
network string // assumed connection network ("", "tcp", "udp")
|
||||||
|
queryType uint16 // DNS query type in effect (0 = unknown)
|
||||||
|
host string // lowercased domain (empty for IP input)
|
||||||
|
destIsIP bool
|
||||||
|
destAddr netip.Addr
|
||||||
|
destResolved bool // addresses populated (via resolve/assume)
|
||||||
|
addresses []netip.Addr // resolved/assumed destination addresses
|
||||||
|
rs *ruleSetResolver
|
||||||
|
resolver dnsx.Resolver
|
||||||
|
resolved *ResolvedInfo // cached DoH result for the host
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAddresses records resolved destination addresses so IP-based conditions
|
||||||
|
// (ip_cidr, ip rule sets) can match them.
|
||||||
|
func (ec *evalCtx) setAddresses(addrs []netip.Addr) {
|
||||||
|
ec.addresses = addrs
|
||||||
|
ec.destResolved = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchFields is the normalized, matchable subset shared by route, DNS and
|
||||||
|
// headless rules.
|
||||||
|
type matchFields struct {
|
||||||
|
domain []string
|
||||||
|
domainSuffix []string
|
||||||
|
domainKeyword []string
|
||||||
|
domainRegex []string
|
||||||
|
ipCIDR []string
|
||||||
|
ipIsPrivate bool
|
||||||
|
srcIPCIDR []string
|
||||||
|
srcIPIsPriv bool
|
||||||
|
port []uint16
|
||||||
|
portRange []string
|
||||||
|
srcPort []uint16
|
||||||
|
srcPortRange []string
|
||||||
|
network []string
|
||||||
|
queryType []option.DNSQueryType
|
||||||
|
ruleSet []string
|
||||||
|
rsMatchSource bool
|
||||||
|
invert bool
|
||||||
|
|
||||||
|
// Pre-compiled matchers from binary (.srs) rule sets.
|
||||||
|
rawDomain *domain.Matcher
|
||||||
|
rawIPSet *netipx.IPSet
|
||||||
|
|
||||||
|
unknowns []condKV // fields we cannot evaluate offline (assumptions)
|
||||||
|
dnsFilter []condKV // DNS response-address filters (not applicable to query routing)
|
||||||
|
}
|
||||||
|
|
||||||
|
type condKV struct{ field, value string }
|
||||||
|
|
||||||
|
// orStatus combines OR-group members.
|
||||||
|
func orStatus(members []string) string {
|
||||||
|
unknown := false
|
||||||
|
for _, s := range members {
|
||||||
|
if s == StatusMatch {
|
||||||
|
return StatusMatch
|
||||||
|
}
|
||||||
|
if s == StatusUnknown {
|
||||||
|
unknown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unknown {
|
||||||
|
return StatusUnknown
|
||||||
|
}
|
||||||
|
return StatusNoMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// andStatus combines AND members.
|
||||||
|
func andStatus(members []string) string {
|
||||||
|
unknown := false
|
||||||
|
for _, s := range members {
|
||||||
|
if s == StatusNoMatch {
|
||||||
|
return StatusNoMatch
|
||||||
|
}
|
||||||
|
if s == StatusUnknown {
|
||||||
|
unknown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if unknown {
|
||||||
|
return StatusUnknown
|
||||||
|
}
|
||||||
|
return StatusMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func invertStatus(s string) string {
|
||||||
|
switch s {
|
||||||
|
case StatusMatch:
|
||||||
|
return StatusNoMatch
|
||||||
|
case StatusNoMatch:
|
||||||
|
return StatusMatch
|
||||||
|
default:
|
||||||
|
return StatusUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalFields evaluates a normalized rule against the context, producing an
|
||||||
|
// overall tri-state status and the per-condition breakdown. An empty rule
|
||||||
|
// (no conditions) matches everything.
|
||||||
|
func (ec *evalCtx) evalFields(mf matchFields) (string, []CondEval) {
|
||||||
|
var conds []CondEval
|
||||||
|
var groupStatuses []string // AND across non-empty groups + other conds
|
||||||
|
|
||||||
|
// --- destination address group (OR) ---
|
||||||
|
var da []string
|
||||||
|
if len(mf.domain) > 0 {
|
||||||
|
st, matched := ec.matchDomainExact(mf.domain)
|
||||||
|
conds = append(conds, CondEval{Field: "domain", Value: joinVals(mf.domain), Group: groupDestAddr, Status: st, Matched: matched})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if len(mf.domainSuffix) > 0 {
|
||||||
|
st, matched := ec.matchDomainSuffix(mf.domainSuffix)
|
||||||
|
conds = append(conds, CondEval{Field: "domain_suffix", Value: joinVals(mf.domainSuffix), Group: groupDestAddr, Status: st, Matched: matched})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if len(mf.domainKeyword) > 0 {
|
||||||
|
st, matched := ec.matchKeyword(mf.domainKeyword)
|
||||||
|
conds = append(conds, CondEval{Field: "domain_keyword", Value: joinVals(mf.domainKeyword), Group: groupDestAddr, Status: st, Matched: matched})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if len(mf.domainRegex) > 0 {
|
||||||
|
st, matched := ec.matchRegex(mf.domainRegex)
|
||||||
|
conds = append(conds, CondEval{Field: "domain_regex", Value: joinVals(mf.domainRegex), Group: groupDestAddr, Status: st, Matched: matched})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if mf.rawDomain != nil {
|
||||||
|
st := StatusNoMatch
|
||||||
|
if ec.host != "" && mf.rawDomain.Match(ec.host) {
|
||||||
|
st = StatusMatch
|
||||||
|
}
|
||||||
|
conds = append(conds, CondEval{Field: "domain/domain_suffix", Value: "«compiled set»", Group: groupDestAddr, Status: st})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if len(mf.ipCIDR) > 0 {
|
||||||
|
st, matched, note := ec.matchIPCIDR(mf.ipCIDR, false)
|
||||||
|
conds = append(conds, CondEval{Field: "ip_cidr", Value: joinVals(mf.ipCIDR), Group: groupDestAddr, Status: st, Matched: matched, Note: note})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if mf.rawIPSet != nil {
|
||||||
|
st, note := ec.matchRawIPSet(mf.rawIPSet)
|
||||||
|
conds = append(conds, CondEval{Field: "ip_cidr", Value: "«compiled set»", Group: groupDestAddr, Status: st, Note: note})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if mf.ipIsPrivate {
|
||||||
|
st, note := ec.matchIPIsPrivate(false)
|
||||||
|
conds = append(conds, CondEval{Field: "ip_is_private", Value: "true", Group: groupDestAddr, Status: st, Note: note})
|
||||||
|
da = append(da, st)
|
||||||
|
}
|
||||||
|
if len(da) > 0 {
|
||||||
|
groupStatuses = append(groupStatuses, orStatus(da))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- source address group (OR) — source is unknown offline ---
|
||||||
|
var sa []string
|
||||||
|
if len(mf.srcIPCIDR) > 0 {
|
||||||
|
conds = append(conds, CondEval{Field: "source_ip_cidr", Value: joinVals(mf.srcIPCIDR), Group: groupSrcAddr, Status: StatusUnknown, Note: "client source address is unknown"})
|
||||||
|
sa = append(sa, StatusUnknown)
|
||||||
|
}
|
||||||
|
if mf.srcIPIsPriv {
|
||||||
|
conds = append(conds, CondEval{Field: "source_ip_is_private", Value: "true", Group: groupSrcAddr, Status: StatusUnknown, Note: "client source address is unknown"})
|
||||||
|
sa = append(sa, StatusUnknown)
|
||||||
|
}
|
||||||
|
if len(sa) > 0 {
|
||||||
|
groupStatuses = append(groupStatuses, orStatus(sa))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- destination port group (OR) — port is unknown for a bare domain/IP ---
|
||||||
|
var dp []string
|
||||||
|
if len(mf.port) > 0 {
|
||||||
|
conds = append(conds, CondEval{Field: "port", Value: joinU16(mf.port), Group: groupDestPort, Status: StatusUnknown, Note: "destination port is not part of the query"})
|
||||||
|
dp = append(dp, StatusUnknown)
|
||||||
|
}
|
||||||
|
if len(mf.portRange) > 0 {
|
||||||
|
conds = append(conds, CondEval{Field: "port_range", Value: joinVals(mf.portRange), Group: groupDestPort, Status: StatusUnknown, Note: "destination port is not part of the query"})
|
||||||
|
dp = append(dp, StatusUnknown)
|
||||||
|
}
|
||||||
|
if len(dp) > 0 {
|
||||||
|
groupStatuses = append(groupStatuses, orStatus(dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- source port group (OR) — unknown ---
|
||||||
|
var sp []string
|
||||||
|
if len(mf.srcPort) > 0 {
|
||||||
|
conds = append(conds, CondEval{Field: "source_port", Value: joinU16(mf.srcPort), Group: groupSrcPort, Status: StatusUnknown, Note: "client source port is unknown"})
|
||||||
|
sp = append(sp, StatusUnknown)
|
||||||
|
}
|
||||||
|
if len(mf.srcPortRange) > 0 {
|
||||||
|
conds = append(conds, CondEval{Field: "source_port_range", Value: joinVals(mf.srcPortRange), Group: groupSrcPort, Status: StatusUnknown, Note: "client source port is unknown"})
|
||||||
|
sp = append(sp, StatusUnknown)
|
||||||
|
}
|
||||||
|
if len(sp) > 0 {
|
||||||
|
groupStatuses = append(groupStatuses, orStatus(sp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- rule_set group (OR across tags) ---
|
||||||
|
if len(mf.ruleSet) > 0 {
|
||||||
|
var rsStatuses []string
|
||||||
|
for _, tag := range mf.ruleSet {
|
||||||
|
rse := ec.rs.evaluate(tag, ec, mf.rsMatchSource)
|
||||||
|
conds = append(conds, CondEval{Field: "rule_set", Value: tag, Group: groupRuleSet, Status: rse.Status, RuleSet: rse})
|
||||||
|
rsStatuses = append(rsStatuses, rse.Status)
|
||||||
|
}
|
||||||
|
groupStatuses = append(groupStatuses, orStatus(rsStatuses))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- "other" fields (AND) ---
|
||||||
|
if len(mf.network) > 0 {
|
||||||
|
st := ec.matchNetwork(mf.network)
|
||||||
|
note := ""
|
||||||
|
if st == StatusUnknown {
|
||||||
|
note = "connection network (tcp/udp) not specified"
|
||||||
|
}
|
||||||
|
conds = append(conds, CondEval{Field: "network", Value: joinVals(mf.network), Group: groupOther, Status: st, Note: note})
|
||||||
|
groupStatuses = append(groupStatuses, st)
|
||||||
|
}
|
||||||
|
if len(mf.queryType) > 0 {
|
||||||
|
st, matched := ec.matchQueryType(mf.queryType)
|
||||||
|
conds = append(conds, CondEval{Field: "query_type", Value: queryTypeList(mf.queryType), Group: groupOther, Status: st, Matched: matched, Note: "evaluated for the DNS query type shown"})
|
||||||
|
groupStatuses = append(groupStatuses, st)
|
||||||
|
}
|
||||||
|
// DNS response-address filters: not applicable to query-routing.
|
||||||
|
for _, kv := range mf.dnsFilter {
|
||||||
|
conds = append(conds, CondEval{Field: kv.field, Value: kv.value, Group: groupOther, Status: StatusUnknown, Note: "matches the DNS response addresses, evaluated after resolution"})
|
||||||
|
groupStatuses = append(groupStatuses, StatusUnknown)
|
||||||
|
}
|
||||||
|
// Unknown/undeterminable fields (protocol, process, clash_mode, ...).
|
||||||
|
for _, kv := range mf.unknowns {
|
||||||
|
conds = append(conds, CondEval{Field: kv.field, Value: kv.value, Group: groupOther, Status: StatusUnknown, Note: "cannot be determined offline"})
|
||||||
|
groupStatuses = append(groupStatuses, StatusUnknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := StatusMatch
|
||||||
|
if len(groupStatuses) > 0 {
|
||||||
|
status = andStatus(groupStatuses)
|
||||||
|
}
|
||||||
|
if mf.invert {
|
||||||
|
status = invertStatus(status)
|
||||||
|
}
|
||||||
|
return status, conds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- individual matchers (reusing sing-box primitives where useful) ----
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchDomainExact(domains []string) (string, string) {
|
||||||
|
if ec.host == "" {
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
if domainMatcher(domains, nil).Match(ec.host) {
|
||||||
|
for _, d := range domains {
|
||||||
|
if strings.EqualFold(strings.TrimSuffix(d, "."), ec.host) {
|
||||||
|
return StatusMatch, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusMatch, ""
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchDomainSuffix(suffixes []string) (string, string) {
|
||||||
|
if ec.host == "" {
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
if domainMatcher(nil, suffixes).Match(ec.host) {
|
||||||
|
for _, s := range suffixes {
|
||||||
|
if domainMatcher(nil, []string{s}).Match(ec.host) {
|
||||||
|
return StatusMatch, s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusMatch, ""
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainMatcher builds a sing/common/domain matcher for the given exact domains
|
||||||
|
// and suffixes. This mirrors route/rule.NewDomainItem exactly (it calls
|
||||||
|
// domain.NewMatcher(domains, domainSuffixes, false)), so matching stays faithful
|
||||||
|
// to sing-box's succinct-set suffix logic without importing route/rule.
|
||||||
|
func domainMatcher(domains, suffixes []string) *domain.Matcher {
|
||||||
|
return domain.NewMatcher(domains, suffixes, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchKeyword(keywords []string) (string, string) {
|
||||||
|
if ec.host == "" {
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
for _, kw := range keywords {
|
||||||
|
if kw != "" && strings.Contains(ec.host, strings.ToLower(kw)) {
|
||||||
|
return StatusMatch, kw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchRegex(exprs []string) (string, string) {
|
||||||
|
if ec.host == "" {
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
for _, e := range exprs {
|
||||||
|
re, err := regexp.Compile(e)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if re.MatchString(ec.host) {
|
||||||
|
return StatusMatch, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchIPCIDR evaluates an ip_cidr condition. For a domain destination it is
|
||||||
|
// UNKNOWN until addresses are resolved; then it matches those addresses.
|
||||||
|
func (ec *evalCtx) matchIPCIDR(cidrs []string, isSource bool) (string, string, string) {
|
||||||
|
if isSource {
|
||||||
|
return StatusUnknown, "", "client source address is unknown"
|
||||||
|
}
|
||||||
|
addrs := ec.matchAddrs()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
if ec.host != "" {
|
||||||
|
return StatusUnknown, "", "requires the resolved IP (domain not resolved for this evaluation)"
|
||||||
|
}
|
||||||
|
return StatusNoMatch, "", ""
|
||||||
|
}
|
||||||
|
for _, cidr := range cidrs {
|
||||||
|
p, err := netip.ParsePrefix(strings.TrimSpace(cidr))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if p.Contains(a.Unmap()) || p.Contains(a) {
|
||||||
|
return StatusMatch, cidr, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchRawIPSet(set *netipx.IPSet) (string, string) {
|
||||||
|
addrs := ec.matchAddrs()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
if ec.host != "" {
|
||||||
|
return StatusUnknown, "requires the resolved IP"
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if set.Contains(a.Unmap()) || set.Contains(a) {
|
||||||
|
return StatusMatch, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchIPIsPrivate(isSource bool) (string, string) {
|
||||||
|
if isSource {
|
||||||
|
return StatusUnknown, "client source address is unknown"
|
||||||
|
}
|
||||||
|
addrs := ec.matchAddrs()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
if ec.host != "" {
|
||||||
|
return StatusUnknown, "requires the resolved IP"
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() {
|
||||||
|
return StatusMatch, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchAddrs returns the destination addresses available for IP matching.
|
||||||
|
func (ec *evalCtx) matchAddrs() []netip.Addr {
|
||||||
|
if ec.destIsIP {
|
||||||
|
return []netip.Addr{ec.destAddr}
|
||||||
|
}
|
||||||
|
return ec.addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchNetwork(networks []string) string {
|
||||||
|
if ec.network == "" {
|
||||||
|
return StatusUnknown
|
||||||
|
}
|
||||||
|
// route/rule.NetworkItem matches when the connection network is in the set.
|
||||||
|
for _, n := range networks {
|
||||||
|
if n == ec.network {
|
||||||
|
return StatusMatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) matchQueryType(types []option.DNSQueryType) (string, string) {
|
||||||
|
if ec.queryType == 0 {
|
||||||
|
return StatusUnknown, ""
|
||||||
|
}
|
||||||
|
// route/rule.QueryTypeItem matches when the query type is in the set.
|
||||||
|
for _, t := range types {
|
||||||
|
if uint16(t) == ec.queryType {
|
||||||
|
return StatusMatch, queryTypeName(ec.queryType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StatusNoMatch, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- display helpers ----
|
||||||
|
|
||||||
|
func joinVals(v []string) string {
|
||||||
|
if len(v) <= 4 {
|
||||||
|
return strings.Join(v, ", ")
|
||||||
|
}
|
||||||
|
return strings.Join(v[:4], ", ") + fmt.Sprintf(", …(+%d)", len(v)-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinU16(v []uint16) string {
|
||||||
|
parts := make([]string, 0, len(v))
|
||||||
|
for _, p := range v {
|
||||||
|
parts = append(parts, fmt.Sprint(p))
|
||||||
|
}
|
||||||
|
return joinVals(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryTypeList(types []option.DNSQueryType) string {
|
||||||
|
parts := make([]string, 0, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
parts = append(parts, queryTypeName(uint16(t)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryTypeNames = map[uint16]string{1: "A", 28: "AAAA", 5: "CNAME", 15: "MX", 16: "TXT", 12: "PTR", 33: "SRV", 65: "HTTPS", 64: "SVCB"}
|
||||||
|
|
||||||
|
func queryTypeName(t uint16) string {
|
||||||
|
if n, ok := queryTypeNames[t]; ok {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("TYPE%d", t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dnsActionOf(r option.DNSRule) actionInfo {
|
||||||
|
var a option.DNSRuleAction
|
||||||
|
if r.Type == "logical" {
|
||||||
|
a = r.LogicalOptions.DNSRuleAction
|
||||||
|
} else {
|
||||||
|
a = r.DefaultOptions.DNSRuleAction
|
||||||
|
}
|
||||||
|
typ := a.Action
|
||||||
|
if typ == "" {
|
||||||
|
typ = "route"
|
||||||
|
}
|
||||||
|
ai := actionInfo{typ: typ}
|
||||||
|
switch typ {
|
||||||
|
case "route":
|
||||||
|
ai.server = a.RouteOptions.Server
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "route → server " + orDefault(ai.server, "(default)")
|
||||||
|
case "route-options":
|
||||||
|
ai.detail = "route-options (non-terminal)"
|
||||||
|
case "reject":
|
||||||
|
m := a.RejectOptions.Method
|
||||||
|
if m == "" {
|
||||||
|
m = "default"
|
||||||
|
}
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "reject (" + m + ")"
|
||||||
|
case "predefined":
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "predefined response"
|
||||||
|
case "evaluate":
|
||||||
|
ai.server = a.RouteOptions.Server
|
||||||
|
ai.detail = "evaluate (non-terminal)"
|
||||||
|
case "respond":
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "respond"
|
||||||
|
default:
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = typ
|
||||||
|
}
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchDNS evaluates DNS rules for the host to determine which DNS server / DNS
|
||||||
|
// rule action is hit. Evaluated for A queries (the common resolution path).
|
||||||
|
func (ec *evalCtx) matchDNS(cfg *Config) *DNSTrace {
|
||||||
|
prevQT := ec.queryType
|
||||||
|
ec.queryType = 1 // dns.TypeA
|
||||||
|
defer func() { ec.queryType = prevQT }()
|
||||||
|
|
||||||
|
tr := &DNSTrace{QueryType: "A", MatchedIndex: -1, Final: cfg.effectiveDNSFinal()}
|
||||||
|
hadConditional := false
|
||||||
|
|
||||||
|
for i, r := range cfg.DNSRules {
|
||||||
|
re := ec.evalDNSRuleNode(r)
|
||||||
|
re.Index = i
|
||||||
|
re.Reached = true
|
||||||
|
a := dnsActionOf(r)
|
||||||
|
re.ActionType = a.typ
|
||||||
|
re.ActionText = a.detail
|
||||||
|
re.Terminal = a.terminal
|
||||||
|
|
||||||
|
switch re.Status {
|
||||||
|
case StatusMatch:
|
||||||
|
if !a.terminal {
|
||||||
|
re.Effect = "matched but non-terminal; continues scanning"
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
tr.MatchedIndex = i
|
||||||
|
tr.Decision = ec.dnsDecision(cfg, a, false, hadConditional)
|
||||||
|
return tr
|
||||||
|
case StatusUnknown:
|
||||||
|
if a.terminal {
|
||||||
|
re.Effect = "could match here if its undetermined conditions hold"
|
||||||
|
hadConditional = true
|
||||||
|
}
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
default:
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to dns.final.
|
||||||
|
final := cfg.effectiveDNSFinal()
|
||||||
|
tr.Decision = ec.dnsDecision(cfg, actionInfo{typ: "route", server: final, terminal: true, detail: "route → server " + orDefault(final, "(first server)")}, true, hadConditional)
|
||||||
|
if len(cfg.DNSRules) == 0 {
|
||||||
|
tr.Note = "no DNS rules; the final server is always used"
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) dnsDecision(cfg *Config, a actionInfo, fromFinal, assumed bool) *DNSDecision {
|
||||||
|
d := &DNSDecision{
|
||||||
|
ActionType: a.typ,
|
||||||
|
Server: a.server,
|
||||||
|
Detail: a.detail,
|
||||||
|
FromFinal: fromFinal,
|
||||||
|
Assumed: assumed,
|
||||||
|
}
|
||||||
|
if a.server != "" {
|
||||||
|
d.ServerInfo = cfg.findDNSServer(a.server)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package engine evaluates a sing-box configuration against a domain or IP and
|
||||||
|
// produces a step-by-step explanation of how DNS and route rules match.
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"sing-vis/internal/dnsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuleSetFile is an uploaded rule-set payload for a type:local rule set, keyed by
|
||||||
|
// the rule-set tag (or its configured path). Format is "source" or "binary"; for
|
||||||
|
// "binary" Data is base64-encoded, for "source" it is the raw JSON text.
|
||||||
|
type RuleSetFile struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request bundles everything needed to analyze a set of inputs.
|
||||||
|
type Request struct {
|
||||||
|
Config string
|
||||||
|
Inputs []string
|
||||||
|
RuleSetFiles map[string]RuleSetFile
|
||||||
|
Network string // optional assumed network: "", "tcp", "udp"
|
||||||
|
// AssumeResolved pre-resolves domains via DoH before route matching so that
|
||||||
|
// ip_cidr / IP rule-set rules can match the resolved addresses (matching user
|
||||||
|
// intuition). When false, IP rules only match after an explicit resolve action.
|
||||||
|
AssumeResolved bool
|
||||||
|
Resolver dnsx.Resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the top-level analysis response.
|
||||||
|
type Result struct {
|
||||||
|
DoHServer string `json:"dohServer"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
Inputs []InputTrace `json:"inputs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze is the entry point; implemented in analyze.go.
|
||||||
|
func Analyze(ctx context.Context, req Request) (*Result, error) {
|
||||||
|
return analyze(ctx, req)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sing-vis/internal/dnsx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeResolver returns canned DoH answers so route/DNS matching is testable
|
||||||
|
// offline (no network). Unknown names resolve to no addresses.
|
||||||
|
type fakeResolver struct {
|
||||||
|
answers map[string]*dnsx.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeResolver) Server() string { return "fake-doh" }
|
||||||
|
|
||||||
|
func (f *fakeResolver) Resolve(_ context.Context, name string, _ string) (*dnsx.Result, error) {
|
||||||
|
if r, ok := f.answers[name]; ok {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return &dnsx.Result{Name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeOne(t *testing.T, cfg, input string, assumeResolved bool, res dnsx.Resolver) InputTrace {
|
||||||
|
t.Helper()
|
||||||
|
out, err := Analyze(context.Background(), Request{
|
||||||
|
Config: cfg,
|
||||||
|
Inputs: []string{input},
|
||||||
|
AssumeResolved: assumeResolved,
|
||||||
|
Resolver: res,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Analyze(%q) error: %v", input, err)
|
||||||
|
}
|
||||||
|
if len(out.Inputs) != 1 {
|
||||||
|
t.Fatalf("Analyze(%q): expected 1 input trace, got %d", input, len(out.Inputs))
|
||||||
|
}
|
||||||
|
return out.Inputs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeOutbound(t *testing.T, it InputTrace) *RouteDecision {
|
||||||
|
t.Helper()
|
||||||
|
if it.Route == nil || it.Route.Decision == nil {
|
||||||
|
t.Fatalf("input %q: missing route decision", it.Input)
|
||||||
|
}
|
||||||
|
return it.Route.Decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainSuffix(t *testing.T) {
|
||||||
|
cfg := `{"route":{"rules":[{"domain_suffix":["google.com"],"outbound":"proxy"}],"final":"direct"}}`
|
||||||
|
|
||||||
|
it := analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
||||||
|
d := routeOutbound(t, it)
|
||||||
|
if d.Outbound != "proxy" || d.FromFinal {
|
||||||
|
t.Errorf("www.google.com: got outbound=%q fromFinal=%v, want proxy/false", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
if it.Route.SelectedIndex != 0 {
|
||||||
|
t.Errorf("www.google.com: selectedIndex=%d, want 0", it.Route.SelectedIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
it = analyzeOne(t, cfg, "example.org", true, &fakeResolver{})
|
||||||
|
d = routeOutbound(t, it)
|
||||||
|
if d.Outbound != "direct" || !d.FromFinal {
|
||||||
|
t.Errorf("example.org: got outbound=%q fromFinal=%v, want direct/true", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInlineRuleSet(t *testing.T) {
|
||||||
|
cfg := `{"route":{
|
||||||
|
"rules":[{"rule_set":["cn"],"outbound":"direct"}],
|
||||||
|
"rule_set":[{"type":"inline","tag":"cn","rules":[{"domain_suffix":["baidu.com"]}]}],
|
||||||
|
"final":"proxy"}}`
|
||||||
|
|
||||||
|
it := analyzeOne(t, cfg, "www.baidu.com", true, &fakeResolver{})
|
||||||
|
d := routeOutbound(t, it)
|
||||||
|
if d.Outbound != "direct" {
|
||||||
|
t.Errorf("www.baidu.com: outbound=%q, want direct", d.Outbound)
|
||||||
|
}
|
||||||
|
// The rule_set condition should report a match with a non-negative matched idx.
|
||||||
|
step := it.Route.Steps[0]
|
||||||
|
if step.Status != StatusMatch {
|
||||||
|
t.Errorf("rule_set step status=%q, want match", step.Status)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, c := range step.Conditions {
|
||||||
|
if c.Field == "rule_set" && c.RuleSet != nil {
|
||||||
|
found = true
|
||||||
|
if c.RuleSet.Status != StatusMatch || c.RuleSet.MatchedIdx != 0 {
|
||||||
|
t.Errorf("rule_set eval: status=%q matchedIdx=%d, want match/0", c.RuleSet.Status, c.RuleSet.MatchedIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("no rule_set condition found in step")
|
||||||
|
}
|
||||||
|
|
||||||
|
it = analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
||||||
|
t.Errorf("www.google.com: outbound=%q fromFinal=%v, want proxy/true", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogicalInvert(t *testing.T) {
|
||||||
|
// Inverted logical rule: matches everything EXCEPT *.google.com.
|
||||||
|
cfg := `{"route":{"rules":[
|
||||||
|
{"type":"logical","mode":"and","invert":true,
|
||||||
|
"rules":[{"domain_suffix":["google.com"]}],"outbound":"not-google"}
|
||||||
|
],"final":"proxy"}}`
|
||||||
|
|
||||||
|
it := analyzeOne(t, cfg, "example.com", true, &fakeResolver{})
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "not-google" {
|
||||||
|
t.Errorf("example.com: outbound=%q, want not-google (inverted match)", d.Outbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
it = analyzeOne(t, cfg, "www.google.com", true, &fakeResolver{})
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
||||||
|
t.Errorf("www.google.com: outbound=%q fromFinal=%v, want proxy/true (inverted no-match)", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveThenIPCIDR(t *testing.T) {
|
||||||
|
cfg := `{"route":{"rules":[
|
||||||
|
{"domain_suffix":["example.com"],"action":"resolve"},
|
||||||
|
{"ip_cidr":["1.2.3.0/24"],"outbound":"matched-ip"}
|
||||||
|
],"final":"proxy"}}`
|
||||||
|
res := &fakeResolver{answers: map[string]*dnsx.Result{
|
||||||
|
"host.example.com": {Name: "host.example.com", IPv4: []string{"1.2.3.4"}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
// AssumeResolved=false: ip_cidr only matches after the explicit resolve action.
|
||||||
|
it := analyzeOne(t, cfg, "host.example.com", false, res)
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "matched-ip" {
|
||||||
|
t.Errorf("host.example.com: outbound=%q, want matched-ip", d.Outbound)
|
||||||
|
}
|
||||||
|
// The resolve step should carry an effect line mentioning the resolved IP.
|
||||||
|
if it.Route.Steps[0].Effect == "" {
|
||||||
|
t.Error("resolve step missing effect line")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain that resolves outside the CIDR falls through to final.
|
||||||
|
res2 := &fakeResolver{answers: map[string]*dnsx.Result{
|
||||||
|
"other.example.com": {Name: "other.example.com", IPv4: []string{"9.9.9.9"}},
|
||||||
|
}}
|
||||||
|
it = analyzeOne(t, cfg, "other.example.com", false, res2)
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "proxy" || !d.FromFinal {
|
||||||
|
t.Errorf("other.example.com: outbound=%q fromFinal=%v, want proxy/true", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSServerSelection(t *testing.T) {
|
||||||
|
cfg := `{"dns":{
|
||||||
|
"servers":[
|
||||||
|
{"tag":"proxy-dns","type":"https","server":"1.1.1.1","detour":"proxy"},
|
||||||
|
{"tag":"local-dns","type":"udp","server":"223.5.5.5","detour":"direct"}
|
||||||
|
],
|
||||||
|
"rules":[{"domain_suffix":["cn.example"],"server":"local-dns"}],
|
||||||
|
"final":"proxy-dns"}}`
|
||||||
|
|
||||||
|
it := analyzeOne(t, cfg, "site.cn.example", true, &fakeResolver{})
|
||||||
|
if it.DNS == nil || it.DNS.Decision == nil {
|
||||||
|
t.Fatal("missing DNS decision")
|
||||||
|
}
|
||||||
|
d := it.DNS.Decision
|
||||||
|
if d.Server != "local-dns" || d.FromFinal {
|
||||||
|
t.Errorf("site.cn.example: dns server=%q fromFinal=%v, want local-dns/false", d.Server, d.FromFinal)
|
||||||
|
}
|
||||||
|
if d.ServerInfo == nil || d.ServerInfo.Detour != "direct" {
|
||||||
|
t.Errorf("site.cn.example: dns detour=%v, want direct", d.ServerInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
it = analyzeOne(t, cfg, "other.example", true, &fakeResolver{})
|
||||||
|
d = it.DNS.Decision
|
||||||
|
if d.Server != "proxy-dns" || !d.FromFinal {
|
||||||
|
t.Errorf("other.example: dns server=%q fromFinal=%v, want proxy-dns/true", d.Server, d.FromFinal)
|
||||||
|
}
|
||||||
|
if d.ServerInfo == nil || d.ServerInfo.Detour != "proxy" {
|
||||||
|
t.Errorf("other.example: dns detour=%v, want proxy", d.ServerInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawIPInput(t *testing.T) {
|
||||||
|
cfg := `{"route":{"rules":[{"ip_cidr":["10.0.0.0/8"],"outbound":"lan"}],"final":"wan"}}`
|
||||||
|
|
||||||
|
it := analyzeOne(t, cfg, "10.1.2.3", true, &fakeResolver{})
|
||||||
|
if it.Kind != "ip" {
|
||||||
|
t.Errorf("10.1.2.3: kind=%q, want ip", it.Kind)
|
||||||
|
}
|
||||||
|
if it.DNS != nil {
|
||||||
|
t.Error("raw IP should have no DNS trace")
|
||||||
|
}
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "lan" {
|
||||||
|
t.Errorf("10.1.2.3: outbound=%q, want lan", d.Outbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
it = analyzeOne(t, cfg, "8.8.8.8", true, &fakeResolver{})
|
||||||
|
if d := routeOutbound(t, it); d.Outbound != "wan" || !d.FromFinal {
|
||||||
|
t.Errorf("8.8.8.8: outbound=%q fromFinal=%v, want wan/true", d.Outbound, d.FromFinal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidInput(t *testing.T) {
|
||||||
|
cfg := `{"route":{"rules":[],"final":"proxy"}}`
|
||||||
|
it := analyzeOne(t, cfg, "not a valid host!!", true, &fakeResolver{})
|
||||||
|
if it.Kind != "invalid" {
|
||||||
|
t.Errorf("kind=%q, want invalid", it.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
sjson "github.com/sagernet/sing/common/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the parsed subset of a sing-box configuration relevant to routing.
|
||||||
|
type Config struct {
|
||||||
|
RouteRules []option.Rule
|
||||||
|
RouteRuleSets []option.RuleSet
|
||||||
|
RouteFinal string
|
||||||
|
|
||||||
|
DNSRules []option.DNSRule
|
||||||
|
DNSFinal string
|
||||||
|
DNSServers []DNSServerInfo
|
||||||
|
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfig parses a raw sing-box JSON (JSONC allowed) configuration into the
|
||||||
|
// routing-relevant structures, using sing-box's own option unmarshalers so that
|
||||||
|
// rule/action/rule-set dispatch is version-accurate.
|
||||||
|
func ParseConfig(text string) (*Config, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil, fmt.Errorf("empty configuration")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := sjson.UnmarshalContext(ctx, []byte(text), &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{}
|
||||||
|
|
||||||
|
if rm, ok := raw["route"]; ok && len(rm) > 0 {
|
||||||
|
var route struct {
|
||||||
|
Rules []option.Rule `json:"rules"`
|
||||||
|
RuleSet []option.RuleSet `json:"rule_set"`
|
||||||
|
Final string `json:"final"`
|
||||||
|
}
|
||||||
|
if err := sjson.UnmarshalContext(ctx, rm, &route); err != nil {
|
||||||
|
return nil, fmt.Errorf("route: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RouteRules = route.Rules
|
||||||
|
cfg.RouteRuleSets = route.RuleSet
|
||||||
|
cfg.RouteFinal = route.Final
|
||||||
|
}
|
||||||
|
|
||||||
|
if rm, ok := raw["dns"]; ok && len(rm) > 0 {
|
||||||
|
var dnsSec struct {
|
||||||
|
Rules []option.DNSRule `json:"rules"`
|
||||||
|
Final string `json:"final"`
|
||||||
|
Servers []json.RawMessage `json:"servers"`
|
||||||
|
}
|
||||||
|
if err := sjson.UnmarshalContext(ctx, rm, &dnsSec); err != nil {
|
||||||
|
return nil, fmt.Errorf("dns: %w", err)
|
||||||
|
}
|
||||||
|
cfg.DNSRules = dnsSec.Rules
|
||||||
|
cfg.DNSFinal = dnsSec.Final
|
||||||
|
cfg.DNSServers = parseDNSServers(dnsSec.Servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDNSServers extracts display metadata from dns.servers generically so we
|
||||||
|
// don't need the DNS transport registry (which would pull in the whole protocol
|
||||||
|
// dependency tree).
|
||||||
|
func parseDNSServers(servers []json.RawMessage) []DNSServerInfo {
|
||||||
|
var out []DNSServerInfo
|
||||||
|
for _, raw := range servers {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info := DNSServerInfo{
|
||||||
|
Tag: asString(m["tag"]),
|
||||||
|
Type: asString(m["type"]),
|
||||||
|
Detour: asString(m["detour"]),
|
||||||
|
Address: firstString(m, "server", "address"),
|
||||||
|
}
|
||||||
|
out = append(out, info)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(m map[string]any, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if s, ok := m[k].(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDNSServer returns the server info for a tag, if present.
|
||||||
|
func (c *Config) findDNSServer(tag string) *DNSServerInfo {
|
||||||
|
for i := range c.DNSServers {
|
||||||
|
if c.DNSServers[i].Tag == tag {
|
||||||
|
return &c.DNSServers[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveDNSFinal returns the DNS server tag used when no rule matches: the
|
||||||
|
// configured dns.final, or the first server tag if unset.
|
||||||
|
func (c *Config) effectiveDNSFinal() string {
|
||||||
|
if c.DNSFinal != "" {
|
||||||
|
return c.DNSFinal
|
||||||
|
}
|
||||||
|
if len(c.DNSServers) > 0 {
|
||||||
|
return c.DNSServers[0].Tag
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type actionInfo struct {
|
||||||
|
typ string
|
||||||
|
outbound string
|
||||||
|
detail string
|
||||||
|
terminal bool
|
||||||
|
isResolve bool
|
||||||
|
strategy string
|
||||||
|
server string
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeActionOf(r option.Rule) actionInfo {
|
||||||
|
var a option.RuleAction
|
||||||
|
if r.Type == "logical" {
|
||||||
|
a = r.LogicalOptions.RuleAction
|
||||||
|
} else {
|
||||||
|
a = r.DefaultOptions.RuleAction
|
||||||
|
}
|
||||||
|
typ := a.Action
|
||||||
|
if typ == "" {
|
||||||
|
typ = "route"
|
||||||
|
}
|
||||||
|
ai := actionInfo{typ: typ}
|
||||||
|
switch typ {
|
||||||
|
case "route":
|
||||||
|
ai.outbound = a.RouteOptions.Outbound
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "route → " + orDefault(ai.outbound, "(default outbound)")
|
||||||
|
case "route-options":
|
||||||
|
ai.detail = "route-options (non-terminal)"
|
||||||
|
case "reject":
|
||||||
|
m := a.RejectOptions.Method
|
||||||
|
if m == "" {
|
||||||
|
m = "default"
|
||||||
|
}
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "reject (" + m + ")"
|
||||||
|
case "hijack-dns":
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "hijack-dns"
|
||||||
|
case "sniff":
|
||||||
|
ai.detail = "sniff (non-terminal)"
|
||||||
|
case "resolve":
|
||||||
|
ai.isResolve = true
|
||||||
|
ai.strategy = safeStrategy(a.ResolveOptions.Strategy)
|
||||||
|
ai.server = a.ResolveOptions.Server
|
||||||
|
ai.detail = "resolve"
|
||||||
|
if ai.strategy != "" {
|
||||||
|
ai.detail += " (" + ai.strategy + ")"
|
||||||
|
}
|
||||||
|
case "direct":
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = "direct"
|
||||||
|
ai.outbound = "direct"
|
||||||
|
case "bypass":
|
||||||
|
ai.outbound = a.BypassOptions.Outbound
|
||||||
|
ai.terminal = ai.outbound != ""
|
||||||
|
ai.detail = "bypass"
|
||||||
|
default:
|
||||||
|
ai.terminal = true
|
||||||
|
ai.detail = typ
|
||||||
|
}
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRoute evaluates route rules top-to-bottom, first terminal match wins,
|
||||||
|
// handling non-terminal resolve/sniff actions and the final fallback.
|
||||||
|
func (ec *evalCtx) matchRoute(cfg *Config) *RouteTrace {
|
||||||
|
tr := &RouteTrace{SelectedIndex: -1, Final: cfg.RouteFinal}
|
||||||
|
hadConditional := false
|
||||||
|
|
||||||
|
for i, r := range cfg.RouteRules {
|
||||||
|
re := ec.evalRuleNode(r, false)
|
||||||
|
re.Index = i
|
||||||
|
re.Reached = true
|
||||||
|
a := routeActionOf(r)
|
||||||
|
re.ActionType = a.typ
|
||||||
|
re.ActionText = a.detail
|
||||||
|
re.Terminal = a.terminal
|
||||||
|
|
||||||
|
switch re.Status {
|
||||||
|
case StatusMatch:
|
||||||
|
if a.isResolve {
|
||||||
|
addrs := ec.performResolve(a.strategy)
|
||||||
|
if len(addrs) > 0 {
|
||||||
|
re.Effect = "resolved → " + strings.Join(addrStrings(addrs), ", ") + " (IP rules below can now match)"
|
||||||
|
} else {
|
||||||
|
re.Effect = "resolve produced no addresses"
|
||||||
|
}
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !a.terminal {
|
||||||
|
re.Effect = "matched but non-terminal; continues scanning"
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
tr.SelectedIndex = i
|
||||||
|
tr.Decision = &RouteDecision{
|
||||||
|
ActionType: a.typ,
|
||||||
|
Outbound: a.outbound,
|
||||||
|
Detail: a.detail,
|
||||||
|
Assumed: hadConditional,
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
case StatusUnknown:
|
||||||
|
if a.terminal {
|
||||||
|
re.Effect = "could match here if its undetermined conditions hold"
|
||||||
|
hadConditional = true
|
||||||
|
}
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
default:
|
||||||
|
tr.Steps = append(tr.Steps, re)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.Decision = &RouteDecision{
|
||||||
|
ActionType: "route",
|
||||||
|
Outbound: effectiveRouteFinal(cfg),
|
||||||
|
Detail: "route → " + orDefault(effectiveRouteFinal(cfg), "(first outbound)"),
|
||||||
|
FromFinal: true,
|
||||||
|
Assumed: hadConditional,
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
// performResolve resolves the host via DoH and records the addresses so IP-based
|
||||||
|
// rules below can match. It reuses any cached resolution.
|
||||||
|
func (ec *evalCtx) performResolve(strategy string) []netip.Addr {
|
||||||
|
if ec.host == "" || ec.resolver == nil {
|
||||||
|
return ec.addresses
|
||||||
|
}
|
||||||
|
res, _ := ec.resolver.Resolve(ec.ctx, ec.host, strategy)
|
||||||
|
if res == nil {
|
||||||
|
return ec.addresses
|
||||||
|
}
|
||||||
|
addrs := parseAddrs(res.All(strategy))
|
||||||
|
if len(addrs) > 0 {
|
||||||
|
ec.setAddresses(addrs)
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveRouteFinal(cfg *Config) string {
|
||||||
|
return cfg.RouteFinal // empty => sing-box uses the first outbound
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeStrategy(s option.DomainStrategy) (out string) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
return s.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAddrs(ss []string) []netip.Addr {
|
||||||
|
var out []netip.Addr
|
||||||
|
for _, s := range ss {
|
||||||
|
if a, err := netip.ParseAddr(s); err == nil {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func addrStrings(addrs []netip.Addr) []string {
|
||||||
|
out := make([]string, 0, len(addrs))
|
||||||
|
for _, a := range addrs {
|
||||||
|
out = append(out, a.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing/common/json/badoption"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- field extraction ----
|
||||||
|
|
||||||
|
func fieldsFromRoute(r option.RawDefaultRule) matchFields {
|
||||||
|
mf := matchFields{
|
||||||
|
domain: r.Domain,
|
||||||
|
domainSuffix: r.DomainSuffix,
|
||||||
|
domainKeyword: r.DomainKeyword,
|
||||||
|
domainRegex: r.DomainRegex,
|
||||||
|
ipCIDR: r.IPCIDR,
|
||||||
|
ipIsPrivate: r.IPIsPrivate,
|
||||||
|
srcIPCIDR: r.SourceIPCIDR,
|
||||||
|
srcIPIsPriv: r.SourceIPIsPrivate,
|
||||||
|
port: r.Port,
|
||||||
|
portRange: r.PortRange,
|
||||||
|
srcPort: r.SourcePort,
|
||||||
|
srcPortRange: r.SourcePortRange,
|
||||||
|
network: r.Network,
|
||||||
|
ruleSet: r.RuleSet,
|
||||||
|
rsMatchSource: r.RuleSetIPCIDRMatchSource || r.Deprecated_RulesetIPCIDRMatchSource,
|
||||||
|
invert: r.Invert,
|
||||||
|
}
|
||||||
|
addUnknownList(&mf, "inbound", r.Inbound)
|
||||||
|
addUnknownList(&mf, "protocol", r.Protocol)
|
||||||
|
addUnknownList(&mf, "client", r.Client)
|
||||||
|
addUnknownList(&mf, "auth_user", r.AuthUser)
|
||||||
|
addUnknownList(&mf, "user", r.User)
|
||||||
|
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||||
|
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||||
|
addUnknownList(&mf, "process_path_regex", r.ProcessPathRegex)
|
||||||
|
addUnknownList(&mf, "package_name", r.PackageName)
|
||||||
|
addUnknownList(&mf, "package_name_regex", r.PackageNameRegex)
|
||||||
|
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||||
|
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||||
|
addUnknownList(&mf, "source_mac_address", r.SourceMACAddress)
|
||||||
|
addUnknownList(&mf, "source_hostname", r.SourceHostname)
|
||||||
|
addUnknownList(&mf, "preferred_by", r.PreferredBy)
|
||||||
|
addUnknownDeprecated(&mf, "geosite", r.Geosite)
|
||||||
|
addUnknownDeprecated(&mf, "geoip", r.GeoIP)
|
||||||
|
addUnknownDeprecated(&mf, "source_geoip", r.SourceGeoIP)
|
||||||
|
if r.ClashMode != "" {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"clash_mode", r.ClashMode})
|
||||||
|
}
|
||||||
|
if r.IPVersion != 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"ip_version", intStr(r.IPVersion)})
|
||||||
|
}
|
||||||
|
if r.NetworkIsExpensive {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_is_expensive", "true"})
|
||||||
|
}
|
||||||
|
if r.NetworkIsConstrained {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_is_constrained", "true"})
|
||||||
|
}
|
||||||
|
if len(r.NetworkType) > 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_type", interfaceTypes(r.NetworkType)})
|
||||||
|
}
|
||||||
|
return mf
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldsFromDNS(r option.RawDefaultDNSRule) matchFields {
|
||||||
|
mf := matchFields{
|
||||||
|
domain: r.Domain,
|
||||||
|
domainSuffix: r.DomainSuffix,
|
||||||
|
domainKeyword: r.DomainKeyword,
|
||||||
|
domainRegex: r.DomainRegex,
|
||||||
|
srcIPCIDR: r.SourceIPCIDR,
|
||||||
|
srcIPIsPriv: r.SourceIPIsPrivate,
|
||||||
|
port: r.Port,
|
||||||
|
portRange: r.PortRange,
|
||||||
|
srcPort: r.SourcePort,
|
||||||
|
srcPortRange: r.SourcePortRange,
|
||||||
|
network: r.Network,
|
||||||
|
queryType: r.QueryType,
|
||||||
|
ruleSet: r.RuleSet,
|
||||||
|
rsMatchSource: r.RuleSetIPCIDRMatchSource || r.Deprecated_RulesetIPCIDRMatchSource,
|
||||||
|
invert: r.Invert,
|
||||||
|
}
|
||||||
|
// DNS ip_cidr / ip_is_private / ip_accept_any and response_* are response
|
||||||
|
// filters, not query-routing conditions.
|
||||||
|
if len(r.IPCIDR) > 0 {
|
||||||
|
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_cidr", joinVals(r.IPCIDR)})
|
||||||
|
}
|
||||||
|
if r.IPIsPrivate {
|
||||||
|
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_is_private", "true"})
|
||||||
|
}
|
||||||
|
if r.IPAcceptAny {
|
||||||
|
mf.dnsFilter = append(mf.dnsFilter, condKV{"ip_accept_any", "true"})
|
||||||
|
}
|
||||||
|
if r.ResponseRcode != nil {
|
||||||
|
mf.dnsFilter = append(mf.dnsFilter, condKV{"response_rcode", "set"})
|
||||||
|
}
|
||||||
|
if r.MatchResponse != nil {
|
||||||
|
mf.dnsFilter = append(mf.dnsFilter, condKV{"match_response", "set"})
|
||||||
|
}
|
||||||
|
addUnknownList(&mf, "inbound", r.Inbound)
|
||||||
|
addUnknownList(&mf, "protocol", r.Protocol)
|
||||||
|
addUnknownList(&mf, "auth_user", r.AuthUser)
|
||||||
|
addUnknownList(&mf, "user", r.User)
|
||||||
|
addUnknownList(&mf, "outbound", r.Outbound)
|
||||||
|
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||||
|
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||||
|
addUnknownList(&mf, "package_name", r.PackageName)
|
||||||
|
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||||
|
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||||
|
addUnknownDeprecated(&mf, "geosite", r.Geosite)
|
||||||
|
if r.ClashMode != "" {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"clash_mode", r.ClashMode})
|
||||||
|
}
|
||||||
|
if r.IPVersion != 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"ip_version", intStr(r.IPVersion)})
|
||||||
|
}
|
||||||
|
return mf
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldsFromHeadless(r option.DefaultHeadlessRule) matchFields {
|
||||||
|
mf := matchFields{
|
||||||
|
domainKeyword: r.DomainKeyword,
|
||||||
|
domainRegex: r.DomainRegex,
|
||||||
|
srcIPCIDR: r.SourceIPCIDR,
|
||||||
|
port: r.Port,
|
||||||
|
portRange: r.PortRange,
|
||||||
|
srcPort: r.SourcePort,
|
||||||
|
srcPortRange: r.SourcePortRange,
|
||||||
|
network: r.Network,
|
||||||
|
queryType: r.QueryType,
|
||||||
|
invert: r.Invert,
|
||||||
|
}
|
||||||
|
// Prefer pre-compiled matchers (present in binary .srs rule sets).
|
||||||
|
if r.DomainMatcher != nil {
|
||||||
|
mf.rawDomain = r.DomainMatcher
|
||||||
|
} else {
|
||||||
|
mf.domain = r.Domain
|
||||||
|
mf.domainSuffix = r.DomainSuffix
|
||||||
|
}
|
||||||
|
if r.IPSet != nil {
|
||||||
|
mf.rawIPSet = r.IPSet
|
||||||
|
} else {
|
||||||
|
mf.ipCIDR = r.IPCIDR
|
||||||
|
}
|
||||||
|
if r.AdGuardDomainMatcher != nil || len(r.AdGuardDomain) > 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"adguard_domain", "«set»"})
|
||||||
|
}
|
||||||
|
addUnknownList(&mf, "process_name", r.ProcessName)
|
||||||
|
addUnknownList(&mf, "process_path", r.ProcessPath)
|
||||||
|
addUnknownList(&mf, "package_name", r.PackageName)
|
||||||
|
addUnknownList(&mf, "wifi_ssid", r.WIFISSID)
|
||||||
|
addUnknownList(&mf, "wifi_bssid", r.WIFIBSSID)
|
||||||
|
if r.NetworkIsExpensive {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_is_expensive", "true"})
|
||||||
|
}
|
||||||
|
if r.NetworkIsConstrained {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_is_constrained", "true"})
|
||||||
|
}
|
||||||
|
if len(r.NetworkType) > 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{"network_type", interfaceTypes(r.NetworkType)})
|
||||||
|
}
|
||||||
|
return mf
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUnknownList(mf *matchFields, field string, v badoption.Listable[string]) {
|
||||||
|
if len(v) > 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{field, joinVals(v)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUnknownDeprecated(mf *matchFields, field string, v badoption.Listable[string]) {
|
||||||
|
if len(v) > 0 {
|
||||||
|
mf.unknowns = append(mf.unknowns, condKV{field + " (deprecated/removed)", joinVals(v)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func interfaceTypes(v badoption.Listable[option.InterfaceType]) string {
|
||||||
|
parts := make([]string, 0, len(v))
|
||||||
|
for _, t := range v {
|
||||||
|
parts = append(parts, string(t))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func intStr(i int) string { return joinVals([]string{itoa(i)}) }
|
||||||
|
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := i < 0
|
||||||
|
if neg {
|
||||||
|
i = -i
|
||||||
|
}
|
||||||
|
var b [20]byte
|
||||||
|
pos := len(b)
|
||||||
|
for i > 0 {
|
||||||
|
pos--
|
||||||
|
b[pos] = byte('0' + i%10)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
pos--
|
||||||
|
b[pos] = '-'
|
||||||
|
}
|
||||||
|
return string(b[pos:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rule-node evaluation (conditions only; action handled by caller) ----
|
||||||
|
|
||||||
|
// evalRuleNode evaluates a route/DNS rule's match conditions recursively.
|
||||||
|
func (ec *evalCtx) evalRuleNode(r option.Rule, dns bool) RuleEval {
|
||||||
|
if r.Type == "logical" {
|
||||||
|
return ec.evalLogical(r.LogicalOptions.Mode, r.LogicalOptions.Rules, r.LogicalOptions.Invert, dns)
|
||||||
|
}
|
||||||
|
var mf matchFields
|
||||||
|
if dns {
|
||||||
|
// A DNS rule's default variant is carried on a separate type; caller
|
||||||
|
// passes route-shaped rules only via evalDNSRuleNode. This branch is for
|
||||||
|
// route rules.
|
||||||
|
}
|
||||||
|
mf = fieldsFromRoute(r.DefaultOptions.RawDefaultRule)
|
||||||
|
status, conds := ec.evalFields(mf)
|
||||||
|
return RuleEval{
|
||||||
|
Type: "default",
|
||||||
|
Status: status,
|
||||||
|
Invert: mf.invert,
|
||||||
|
Conditions: conds,
|
||||||
|
Summary: summarize(conds, mf.invert),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalDNSRuleNode evaluates a DNS rule's match conditions recursively.
|
||||||
|
func (ec *evalCtx) evalDNSRuleNode(r option.DNSRule) RuleEval {
|
||||||
|
if r.Type == "logical" {
|
||||||
|
return ec.evalLogicalDNS(r.LogicalOptions.Mode, r.LogicalOptions.Rules, r.LogicalOptions.Invert)
|
||||||
|
}
|
||||||
|
mf := fieldsFromDNS(r.DefaultOptions.RawDefaultDNSRule)
|
||||||
|
status, conds := ec.evalFields(mf)
|
||||||
|
return RuleEval{
|
||||||
|
Type: "default",
|
||||||
|
Status: status,
|
||||||
|
Invert: mf.invert,
|
||||||
|
Conditions: conds,
|
||||||
|
Summary: summarize(conds, mf.invert),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) evalLogical(mode string, rules []option.Rule, invert bool, dns bool) RuleEval {
|
||||||
|
if mode == "" {
|
||||||
|
mode = "and"
|
||||||
|
}
|
||||||
|
var subs []RuleEval
|
||||||
|
var statuses []string
|
||||||
|
for _, sub := range rules {
|
||||||
|
se := ec.evalRuleNode(sub, dns)
|
||||||
|
subs = append(subs, se)
|
||||||
|
statuses = append(statuses, se.Status)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if mode == "or" {
|
||||||
|
status = orStatus(statuses)
|
||||||
|
} else {
|
||||||
|
status = andStatus(statuses)
|
||||||
|
}
|
||||||
|
if invert {
|
||||||
|
status = invertStatus(status)
|
||||||
|
}
|
||||||
|
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: invert, Sub: subs, Summary: "logical " + mode}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec *evalCtx) evalLogicalDNS(mode string, rules []option.DNSRule, invert bool) RuleEval {
|
||||||
|
if mode == "" {
|
||||||
|
mode = "and"
|
||||||
|
}
|
||||||
|
var subs []RuleEval
|
||||||
|
var statuses []string
|
||||||
|
for _, sub := range rules {
|
||||||
|
se := ec.evalDNSRuleNode(sub)
|
||||||
|
subs = append(subs, se)
|
||||||
|
statuses = append(statuses, se.Status)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if mode == "or" {
|
||||||
|
status = orStatus(statuses)
|
||||||
|
} else {
|
||||||
|
status = andStatus(statuses)
|
||||||
|
}
|
||||||
|
if invert {
|
||||||
|
status = invertStatus(status)
|
||||||
|
}
|
||||||
|
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: invert, Sub: subs, Summary: "logical " + mode}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarize(conds []CondEval, invert bool) string {
|
||||||
|
if len(conds) == 0 {
|
||||||
|
return "(match all)"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(conds))
|
||||||
|
for _, c := range conds {
|
||||||
|
v := c.Value
|
||||||
|
if len(v) > 40 {
|
||||||
|
v = v[:40] + "…"
|
||||||
|
}
|
||||||
|
parts = append(parts, c.Field+"="+v)
|
||||||
|
}
|
||||||
|
s := strings.Join(parts, " ")
|
||||||
|
if invert {
|
||||||
|
s = "NOT(" + s + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/common/srs"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
sjson "github.com/sagernet/sing/common/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ruleSetResolver loads and evaluates rule sets referenced by rules.
|
||||||
|
type ruleSetResolver struct {
|
||||||
|
ctx context.Context
|
||||||
|
byTag map[string]option.RuleSet
|
||||||
|
files map[string]RuleSetFile
|
||||||
|
loaded map[string]*loadedRuleSet
|
||||||
|
warnings *[]string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type loadedRuleSet struct {
|
||||||
|
tag string
|
||||||
|
typ string
|
||||||
|
rules []option.HeadlessRule
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuleSetResolver(ctx context.Context, cfg *Config, files map[string]RuleSetFile, warnings *[]string) *ruleSetResolver {
|
||||||
|
byTag := map[string]option.RuleSet{}
|
||||||
|
for _, rs := range cfg.RouteRuleSets {
|
||||||
|
for _, tag := range rs.Tag {
|
||||||
|
byTag[tag] = rs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ruleSetResolver{
|
||||||
|
ctx: ctx,
|
||||||
|
byTag: byTag,
|
||||||
|
files: files,
|
||||||
|
loaded: map[string]*loadedRuleSet{},
|
||||||
|
warnings: warnings,
|
||||||
|
http: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ruleSetResolver) load(tag string) *loadedRuleSet {
|
||||||
|
if l, ok := r.loaded[tag]; ok {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
l := &loadedRuleSet{tag: tag}
|
||||||
|
r.loaded[tag] = l // set early to avoid cycles
|
||||||
|
|
||||||
|
rs, ok := r.byTag[tag]
|
||||||
|
if !ok {
|
||||||
|
l.err = "rule_set not defined in route.rule_set"
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
l.typ = rs.Type
|
||||||
|
switch rs.Type {
|
||||||
|
case "inline":
|
||||||
|
l.rules = rs.InlineOptions.Rules
|
||||||
|
case "local":
|
||||||
|
r.loadFromFile(l, rs)
|
||||||
|
case "remote":
|
||||||
|
r.loadRemote(l, rs)
|
||||||
|
default:
|
||||||
|
l.err = "unsupported rule_set type: " + rs.Type
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ruleSetResolver) loadFromFile(l *loadedRuleSet, rs option.RuleSet) {
|
||||||
|
// Local rule sets read from an on-disk path we don't have; the user uploads
|
||||||
|
// the file content keyed by the rule-set tag (or its path).
|
||||||
|
f, ok := r.files[l.tag]
|
||||||
|
if !ok {
|
||||||
|
f, ok = r.files[rs.LocalOptions.Path]
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
l.err = fmt.Sprintf("local rule-set file not provided (upload the file for tag %q or path %q)", l.tag, rs.LocalOptions.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := f.Format
|
||||||
|
if format == "" {
|
||||||
|
format = rs.Format
|
||||||
|
}
|
||||||
|
if format == "" {
|
||||||
|
format = ruleSetFormatFromPath(rs.LocalOptions.Path)
|
||||||
|
}
|
||||||
|
data := []byte(f.Data)
|
||||||
|
if format == "binary" {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(f.Data)); err == nil {
|
||||||
|
data = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.parseInto(l, data, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ruleSetResolver) loadRemote(l *loadedRuleSet, rs option.RuleSet) {
|
||||||
|
url := rs.RemoteOptions.URL
|
||||||
|
if url == "" {
|
||||||
|
l.err = "remote rule-set has no url"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
format := rs.Format
|
||||||
|
if format == "" {
|
||||||
|
format = ruleSetFormatFromPath(url)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
l.err = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "sing-box")
|
||||||
|
resp, err := r.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
l.err = "fetch failed: " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
l.err = fmt.Sprintf("fetch failed: HTTP %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||||
|
if err != nil {
|
||||||
|
l.err = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.parseInto(l, data, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ruleSetResolver) parseInto(l *loadedRuleSet, data []byte, format string) {
|
||||||
|
if format == "binary" {
|
||||||
|
compat, err := srs.Read(bytes.NewReader(data), true)
|
||||||
|
if err != nil {
|
||||||
|
l.err = "parse binary rule-set: " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := compat.Upgrade()
|
||||||
|
if err != nil {
|
||||||
|
l.err = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.rules = plain.Rules
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// source format
|
||||||
|
var compat option.PlainRuleSetCompat
|
||||||
|
if err := sjson.UnmarshalContext(r.ctx, data, &compat); err != nil {
|
||||||
|
l.err = "parse source rule-set: " + err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
plain, err := compat.Upgrade()
|
||||||
|
if err != nil {
|
||||||
|
l.err = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.rules = plain.Rules
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate matches a rule-set tag against the context. A rule set matches if ANY
|
||||||
|
// of its headless rules matches (OR).
|
||||||
|
func (r *ruleSetResolver) evaluate(tag string, ec *evalCtx, matchSource bool) *RuleSetEval {
|
||||||
|
l := r.load(tag)
|
||||||
|
out := &RuleSetEval{Tag: tag, Type: l.typ, MatchedIdx: -1, Count: len(l.rules)}
|
||||||
|
if l.err != "" {
|
||||||
|
out.Status = StatusUnknown
|
||||||
|
out.Error = l.err
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
statuses := make([]string, 0, len(l.rules))
|
||||||
|
var firstMatch, firstUnknown *RuleEval
|
||||||
|
firstMatchIdx := -1
|
||||||
|
for i, hr := range l.rules {
|
||||||
|
re := ec.evalHeadless(hr)
|
||||||
|
re.Index = i
|
||||||
|
statuses = append(statuses, re.Status)
|
||||||
|
if re.Status == StatusMatch && firstMatch == nil {
|
||||||
|
c := re
|
||||||
|
firstMatch = &c
|
||||||
|
firstMatchIdx = i
|
||||||
|
}
|
||||||
|
if re.Status == StatusUnknown && firstUnknown == nil {
|
||||||
|
c := re
|
||||||
|
firstUnknown = &c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Status = orStatus(statuses)
|
||||||
|
// Attach a representative headless-rule detail (the decisive one) to keep the
|
||||||
|
// payload small for large sets.
|
||||||
|
switch out.Status {
|
||||||
|
case StatusMatch:
|
||||||
|
out.MatchedIdx = firstMatchIdx
|
||||||
|
if firstMatch != nil {
|
||||||
|
out.Rules = []RuleEval{*firstMatch}
|
||||||
|
}
|
||||||
|
case StatusUnknown:
|
||||||
|
if firstUnknown != nil {
|
||||||
|
out.Rules = []RuleEval{*firstUnknown}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalHeadless evaluates one headless rule (default or logical).
|
||||||
|
func (ec *evalCtx) evalHeadless(hr option.HeadlessRule) RuleEval {
|
||||||
|
if hr.Type == "logical" {
|
||||||
|
lo := hr.LogicalOptions
|
||||||
|
mode := lo.Mode
|
||||||
|
if mode == "" {
|
||||||
|
mode = "and"
|
||||||
|
}
|
||||||
|
var subs []RuleEval
|
||||||
|
var statuses []string
|
||||||
|
for _, sub := range lo.Rules {
|
||||||
|
se := ec.evalHeadless(sub)
|
||||||
|
subs = append(subs, se)
|
||||||
|
statuses = append(statuses, se.Status)
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
if mode == "or" {
|
||||||
|
status = orStatus(statuses)
|
||||||
|
} else {
|
||||||
|
status = andStatus(statuses)
|
||||||
|
}
|
||||||
|
if lo.Invert {
|
||||||
|
status = invertStatus(status)
|
||||||
|
}
|
||||||
|
return RuleEval{Type: "logical", Mode: mode, Status: status, Invert: lo.Invert, Sub: subs, Summary: "logical " + mode}
|
||||||
|
}
|
||||||
|
mf := fieldsFromHeadless(hr.DefaultOptions)
|
||||||
|
status, conds := ec.evalFields(mf)
|
||||||
|
return RuleEval{Type: "default", Status: status, Invert: mf.invert, Conditions: conds, Summary: summarize(conds, mf.invert)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleSetFormatFromPath(path string) string {
|
||||||
|
if strings.HasSuffix(strings.ToLower(path), ".srs") {
|
||||||
|
return "binary"
|
||||||
|
}
|
||||||
|
return "source"
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
// Tri-state status for a condition / rule evaluation.
|
||||||
|
const (
|
||||||
|
StatusMatch = "match"
|
||||||
|
StatusNoMatch = "no_match"
|
||||||
|
StatusUnknown = "unknown" // depends on connection attributes we cannot know offline
|
||||||
|
)
|
||||||
|
|
||||||
|
// InputTrace is the analysis result for one input line (a domain or IP).
|
||||||
|
type InputTrace struct {
|
||||||
|
Input string `json:"input"`
|
||||||
|
Kind string `json:"kind"` // "domain" | "ip" | "invalid"
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Resolved *ResolvedInfo `json:"resolved,omitempty"`
|
||||||
|
DNS *DNSTrace `json:"dns,omitempty"`
|
||||||
|
Route *RouteTrace `json:"route,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvedInfo holds the DoH resolution result for a domain.
|
||||||
|
type ResolvedInfo struct {
|
||||||
|
Server string `json:"server"`
|
||||||
|
IPv4 []string `json:"ipv4,omitempty"`
|
||||||
|
IPv6 []string `json:"ipv6,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSTrace explains which DNS rule (and thus which DNS server / action) a domain
|
||||||
|
// hits during DNS resolution.
|
||||||
|
type DNSTrace struct {
|
||||||
|
QueryType string `json:"queryType"` // the query type used for evaluation (A)
|
||||||
|
Steps []RuleEval `json:"steps"`
|
||||||
|
MatchedIndex int `json:"matchedIndex"` // -1 => fell through to final
|
||||||
|
Final string `json:"final"` // dns.final server tag (or effective default)
|
||||||
|
Decision *DNSDecision `json:"decision"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSDecision is the resolved outcome of DNS routing.
|
||||||
|
type DNSDecision struct {
|
||||||
|
ActionType string `json:"actionType"` // route|reject|predefined|route-options|...
|
||||||
|
Server string `json:"server,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
ServerInfo *DNSServerInfo `json:"serverInfo,omitempty"`
|
||||||
|
FromFinal bool `json:"fromFinal"` // decided by dns.final, not a rule
|
||||||
|
Assumed bool `json:"assumed"` // decision relied on unknown-condition assumptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSServerInfo describes a configured DNS server referenced by a route action.
|
||||||
|
type DNSServerInfo struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Detour string `json:"detour,omitempty"` // outbound used to reach this DNS server
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteTrace explains which route rule / rule-set a domain or IP hits and the
|
||||||
|
// final outbound.
|
||||||
|
type RouteTrace struct {
|
||||||
|
Steps []RuleEval `json:"steps"`
|
||||||
|
SelectedIndex int `json:"selectedIndex"` // -1 => fell through to final
|
||||||
|
Final string `json:"final"`
|
||||||
|
Decision *RouteDecision `json:"decision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteDecision is the resolved outcome of route matching.
|
||||||
|
type RouteDecision struct {
|
||||||
|
ActionType string `json:"actionType"` // route|reject|hijack-dns
|
||||||
|
Outbound string `json:"outbound,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
FromFinal bool `json:"fromFinal"`
|
||||||
|
Assumed bool `json:"assumed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleEval is the evaluation of one rule (default or logical) in a rule list.
|
||||||
|
type RuleEval struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Type string `json:"type"` // "default" | "logical"
|
||||||
|
Status string `json:"status"` // match|no_match|unknown
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
ActionType string `json:"actionType"`
|
||||||
|
ActionText string `json:"actionText"`
|
||||||
|
Terminal bool `json:"terminal"`
|
||||||
|
Reached bool `json:"reached"` // false for rules after the terminal match (not shown)
|
||||||
|
Invert bool `json:"invert,omitempty"`
|
||||||
|
Conditions []CondEval `json:"conditions,omitempty"`
|
||||||
|
// Logical rule fields.
|
||||||
|
Mode string `json:"mode,omitempty"` // and|or
|
||||||
|
Sub []RuleEval `json:"sub,omitempty"`
|
||||||
|
// Non-terminal side effects (resolve action results, notes).
|
||||||
|
Effect string `json:"effect,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CondEval is the evaluation of a single condition within a rule.
|
||||||
|
type CondEval struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Group string `json:"group"` // dest_addr|src_addr|dest_port|src_port|other|rule_set
|
||||||
|
Status string `json:"status"`
|
||||||
|
Matched string `json:"matched,omitempty"` // the specific value that matched, if known
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
RuleSet *RuleSetEval `json:"ruleSet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleSetEval is the evaluation of a referenced rule set.
|
||||||
|
type RuleSetEval struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MatchedIdx int `json:"matchedIdx"` // index of the matched headless rule, -1 if none
|
||||||
|
Rules []RuleEval `json:"rules,omitempty"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the WebAssembly engine and serve the static web/ directory.
|
||||||
|
#
|
||||||
|
# sing-vis has no backend: this just compiles web/singvis.wasm and starts a
|
||||||
|
# plain static file server. Any static host works (including GitHub Pages — just
|
||||||
|
# publish the web/ directory); this script uses Python's http.server for local
|
||||||
|
# development. Profiles and settings live in the browser (IndexedDB), so there is
|
||||||
|
# no server-side data directory anymore.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
export GOTOOLCHAIN=auto # go.mod needs go >= 1.24.7; auto-fetches the toolchain
|
||||||
|
|
||||||
|
# Accept either "host:port" or a bare port.
|
||||||
|
ADDR="${1:-127.0.0.1:8787}"
|
||||||
|
case "$ADDR" in
|
||||||
|
*:*) HOST="${ADDR%:*}"; PORT="${ADDR##*:}" ;;
|
||||||
|
*) HOST="127.0.0.1"; PORT="$ADDR" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
echo "Serving sing-vis (static) on http://${HOST}:${PORT}"
|
||||||
|
exec python3 -m http.server "${PORT}" --bind "${HOST}" --directory web
|
||||||
Submodule
+1
Submodule sing-box added at 0b54f57725
@@ -0,0 +1,11 @@
|
|||||||
|
// This file is substituted in via `go build -overlay` for the js/wasm build ONLY.
|
||||||
|
//
|
||||||
|
// Upstream common/buf/buffer_unix.go (build tag `!windows`) defines
|
||||||
|
// Buffer.Iovec returning golang.org/x/sys/unix.Iovec, which does not exist for
|
||||||
|
// GOARCH=wasm. Iovec's only callers are the platform-specific bufio syscall
|
||||||
|
// paths, none of which are exercised under wasm (there are no raw socket fds in
|
||||||
|
// a browser). Replacing the file with an empty package body keeps common/buf
|
||||||
|
// compilable without pulling in unix.Iovec. Native builds do not use the
|
||||||
|
// overlay and keep the real implementation.
|
||||||
|
|
||||||
|
package buf
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Substituted in via `go build -overlay` for the js/wasm build ONLY.
|
||||||
|
//
|
||||||
|
// Upstream common/bufio/copy_direct_posix.go (build tag `!windows`) implements
|
||||||
|
// the syscall read-waiters using golang.org/x/sys/unix (readv/recvmsg). wait.go
|
||||||
|
// (cross-platform) references the createSyscall*ReadWaiter constructors, so the
|
||||||
|
// symbols must exist for the package to compile. Under wasm there are no raw
|
||||||
|
// socket fds, so the constructors report "not created" and the read paths are
|
||||||
|
// never taken. This provides the same identifiers with unix stripped out.
|
||||||
|
|
||||||
|
package bufio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing/common/buf"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ N.ReadWaiter = (*syscallReadWaiter)(nil)
|
||||||
|
|
||||||
|
type syscallReadWaiter struct {
|
||||||
|
rawConn syscall.RawConn
|
||||||
|
buffer *buf.Buffer
|
||||||
|
options N.ReadWaitOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSyscallReadWaiter(any) (*syscallReadWaiter, bool) { return nil, false }
|
||||||
|
|
||||||
|
func (w *syscallReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
|
||||||
|
w.options = options
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *syscallReadWaiter) WaitReadBuffer() (buffer *buf.Buffer, err error) {
|
||||||
|
return nil, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ N.VectorisedReadWaiter = (*vectorisedSyscallReadWaiter)(nil)
|
||||||
|
|
||||||
|
type vectorisedSyscallReadWaiter struct {
|
||||||
|
rawConn syscall.RawConn
|
||||||
|
buffers []*buf.Buffer
|
||||||
|
options N.ReadWaitOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVectorisedSyscallReadWaiter(any) (*vectorisedSyscallReadWaiter, bool) { return nil, false }
|
||||||
|
|
||||||
|
func (w *vectorisedSyscallReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
|
||||||
|
w.options = options
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *vectorisedSyscallReadWaiter) WaitReadBuffers() (buffers []*buf.Buffer, err error) {
|
||||||
|
return nil, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ N.PacketReadWaiter = (*syscallPacketReadWaiter)(nil)
|
||||||
|
|
||||||
|
type syscallPacketReadWaiter struct {
|
||||||
|
rawConn syscall.RawConn
|
||||||
|
buffer *buf.Buffer
|
||||||
|
options N.ReadWaitOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSyscallPacketReadWaiter(any) (*syscallPacketReadWaiter, bool) { return nil, false }
|
||||||
|
|
||||||
|
func (w *syscallPacketReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
|
||||||
|
w.options = options
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *syscallPacketReadWaiter) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) {
|
||||||
|
return nil, M.Socksaddr{}, os.ErrInvalid
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Substituted in via `go build -overlay` for the js/wasm build ONLY.
|
||||||
|
//
|
||||||
|
// Upstream common/bufio/vectorised_unix.go (build tag `!windows`) implements the
|
||||||
|
// syscall-based vectorised writers using golang.org/x/sys/unix (Iovec, writev,
|
||||||
|
// sendmsg). Those types are referenced by the cross-platform vectorised.go, so
|
||||||
|
// the symbols must exist for the package to compile, but the code never runs
|
||||||
|
// under wasm (a browser has no raw socket fds). This provides the same
|
||||||
|
// identifiers with unix stripped out; the write paths return os.ErrInvalid.
|
||||||
|
|
||||||
|
package bufio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing/common/buf"
|
||||||
|
M "github.com/sagernet/sing/common/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syscallVectorisedWriterFields is embedded into SyscallVectorisedWriter and
|
||||||
|
// SyscallVectorisedPacketWriter (declared in vectorised.go). No fields are
|
||||||
|
// needed for the wasm stub.
|
||||||
|
type syscallVectorisedWriterFields struct{}
|
||||||
|
|
||||||
|
func (w *SyscallVectorisedWriter) WriteVectorised(buffers []*buf.Buffer) error {
|
||||||
|
buf.ReleaseMulti(buffers)
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *SyscallVectorisedPacketWriter) WriteVectorisedPacket(buffers []*buf.Buffer, destination M.Socksaddr) error {
|
||||||
|
buf.ReleaseMulti(buffers)
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates the -overlay JSON that swaps the three sing source files that pull in
|
||||||
|
# golang.org/x/sys/unix (unavailable for GOARCH=wasm) for wasm-safe stubs in
|
||||||
|
# ./_stubs. Only the js/wasm build uses this overlay; native builds and tests are
|
||||||
|
# unaffected. The stubs live in an underscore-prefixed directory so the go tool
|
||||||
|
# ignores them during ./... builds (they carry mixed package names). See
|
||||||
|
# _stubs/*.go for the rationale of each substitution.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
sing="$(cd "$here/.." && go list -m -f '{{.Dir}}' github.com/sagernet/sing)"
|
||||||
|
|
||||||
|
cat <<JSON
|
||||||
|
{
|
||||||
|
"Replace": {
|
||||||
|
"$sing/common/buf/buffer_unix.go": "$here/_stubs/buf_buffer_unix.go",
|
||||||
|
"$sing/common/bufio/vectorised_unix.go": "$here/_stubs/bufio_vectorised_unix.go",
|
||||||
|
"$sing/common/bufio/copy_direct_posix.go": "$here/_stubs/bufio_copy_direct_posix.go"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
+526
@@ -0,0 +1,526 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ---------------- utilities ---------------- */
|
||||||
|
const $ = (sel, root = document) => root.querySelector(sel);
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||||
|
));
|
||||||
|
|
||||||
|
/* ---------------- wasm engine worker ---------------- */
|
||||||
|
// The matching engine runs as WebAssembly inside a Web Worker. It is loaded
|
||||||
|
// lazily on the first Analyze (the wasm is several MB) and reused thereafter.
|
||||||
|
const engineWorker = (() => {
|
||||||
|
let worker = null;
|
||||||
|
let readyPromise = null;
|
||||||
|
let seq = 0;
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function ensure() {
|
||||||
|
if (readyPromise) return readyPromise;
|
||||||
|
worker = new Worker('worker.js');
|
||||||
|
readyPromise = new Promise((resolve, reject) => {
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const d = e.data || {};
|
||||||
|
if (d.type === 'ready') { resolve(); return; }
|
||||||
|
if (d.type === 'loaderror') { reject(new Error(d.error || 'failed to load engine')); return; }
|
||||||
|
if (d.id != null && pending.has(d.id)) {
|
||||||
|
const { resolve: res, reject: rej } = pending.get(d.id);
|
||||||
|
pending.delete(d.id);
|
||||||
|
if (d.error) rej(new Error(d.error)); else res(d.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.onerror = (e) => reject(new Error(e.message || 'engine worker error'));
|
||||||
|
});
|
||||||
|
return readyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Whether the engine is already initialized (used to tailor the spinner text).
|
||||||
|
get ready() { return readyPromise !== null; },
|
||||||
|
// Kick off loading without running an analysis.
|
||||||
|
preload() { return ensure(); },
|
||||||
|
async analyze(request) {
|
||||||
|
await ensure();
|
||||||
|
const id = ++seq;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
worker.postMessage({ id, request });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const storage = window.singvisStorage;
|
||||||
|
|
||||||
|
function toast(msg, kind = '') {
|
||||||
|
const root = $('#toast-root');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast ' + kind;
|
||||||
|
t.textContent = msg;
|
||||||
|
root.appendChild(t);
|
||||||
|
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
get(k, def) { try { const v = localStorage.getItem('singvis.' + k); return v == null ? def : JSON.parse(v); } catch { return def; } },
|
||||||
|
set(k, v) { try { localStorage.setItem('singvis.' + k, JSON.stringify(v)); } catch {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- app state ---------------- */
|
||||||
|
const state = {
|
||||||
|
profiles: [],
|
||||||
|
activeId: null,
|
||||||
|
settings: { dohServer: 'https://1.1.1.1/dns-query' },
|
||||||
|
draft: null, // { id?, name, config, inputs, ruleSetFiles }
|
||||||
|
lastResult: null,
|
||||||
|
analyzing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_CONFIG = `{
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{ "tag": "proxy-dns", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
|
||||||
|
{ "tag": "local-dns", "type": "udp", "server": "223.5.5.5", "detour": "direct" }
|
||||||
|
],
|
||||||
|
"rules": [
|
||||||
|
{ "rule_set": ["geosite-cn"], "action": "route", "server": "local-dns" },
|
||||||
|
{ "domain_keyword": ["ads"], "action": "reject" }
|
||||||
|
],
|
||||||
|
"final": "proxy-dns"
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{ "domain_suffix": ["google.com", "openai.com"], "outbound": "proxy" },
|
||||||
|
{ "rule_set": ["geosite-cn"], "outbound": "direct" },
|
||||||
|
{ "rule_set": ["geoip-cn"], "outbound": "direct" }
|
||||||
|
],
|
||||||
|
"rule_set": [
|
||||||
|
{ "type": "remote", "tag": "geosite-cn", "format": "binary",
|
||||||
|
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs" },
|
||||||
|
{ "type": "remote", "tag": "geoip-cn", "format": "binary",
|
||||||
|
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" }
|
||||||
|
],
|
||||||
|
"final": "proxy"
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/* ---------------- init ---------------- */
|
||||||
|
async function init() {
|
||||||
|
$('#btn-settings').onclick = openSettings;
|
||||||
|
$('#btn-new').onclick = () => newDraft();
|
||||||
|
try {
|
||||||
|
state.settings = await storage.getSettings();
|
||||||
|
} catch (e) { /* keep defaults */ }
|
||||||
|
await loadProfiles();
|
||||||
|
if (state.profiles.length) selectProfile(state.profiles[0].id);
|
||||||
|
else newDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
try { state.profiles = (await storage.listProfiles()) || []; }
|
||||||
|
catch (e) { state.profiles = []; }
|
||||||
|
renderSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
const ul = $('#profile-list');
|
||||||
|
if (!state.profiles.length) {
|
||||||
|
ul.innerHTML = '<li class="empty-hint">No profiles yet. Create one to get started.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ul.innerHTML = state.profiles.map((p) => `
|
||||||
|
<li class="profile-item ${p.id === state.activeId ? 'active' : ''}" data-id="${esc(p.id)}">
|
||||||
|
<span class="name">${esc(p.name || 'Untitled')}</span>
|
||||||
|
<span class="meta">updated ${new Date(p.updatedAt).toLocaleString()}</span>
|
||||||
|
</li>`).join('');
|
||||||
|
ul.querySelectorAll('.profile-item').forEach((li) => {
|
||||||
|
li.onclick = () => selectProfile(li.dataset.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectProfile(id) {
|
||||||
|
try {
|
||||||
|
const p = await storage.getProfile(id);
|
||||||
|
state.activeId = id;
|
||||||
|
state.draft = { id: p.id, name: p.name || '', config: p.config || '', inputs: p.inputs || '', ruleSetFiles: p.ruleSetFiles || {} };
|
||||||
|
state.lastResult = null;
|
||||||
|
renderSidebar();
|
||||||
|
renderEditor();
|
||||||
|
} catch (e) { toast('Failed to load profile: ' + e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDraft() {
|
||||||
|
state.activeId = null;
|
||||||
|
state.draft = { name: '', config: SAMPLE_CONFIG, inputs: 'www.google.com\nbaidu.com\nopenai.com\n1.1.1.1', ruleSetFiles: {} };
|
||||||
|
state.lastResult = null;
|
||||||
|
renderSidebar();
|
||||||
|
renderEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- editor ---------------- */
|
||||||
|
function renderEditor() {
|
||||||
|
const d = state.draft;
|
||||||
|
const network = store.get('network', '');
|
||||||
|
const assume = store.get('assumeResolved', true);
|
||||||
|
const files = Object.entries(d.ruleSetFiles || {});
|
||||||
|
$('#content').innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>${state.activeId ? 'Edit profile' : 'New profile'}</h2>
|
||||||
|
<div class="row" style="gap:8px">
|
||||||
|
<button class="btn small" id="btn-save">💾 Save</button>
|
||||||
|
${state.activeId ? '<button class="btn small danger" id="btn-delete">Delete</button>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">Profile name</label>
|
||||||
|
<input type="text" id="f-name" placeholder="My profile" value="${esc(d.name)}" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">sing-box configuration <span class="hint">JSON (comments allowed)</span></label>
|
||||||
|
<textarea id="f-config" class="code" spellcheck="false" placeholder="Paste your sing-box config…">${esc(d.config)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">Local rule-set files <span class="hint">only needed for type:"local" rule sets — key by tag or path</span></label>
|
||||||
|
<input type="file" id="f-files" multiple />
|
||||||
|
<div class="file-list" id="file-list">
|
||||||
|
${files.map(([k, f]) => `
|
||||||
|
<div class="file-row" data-key="${esc(k)}">
|
||||||
|
<span class="fname">${esc(k)}</span>
|
||||||
|
<span class="fmeta">${esc(f.format)} · ${f.data ? (f.format === 'binary' ? Math.round(f.data.length * 0.75) : f.data.length) : 0} bytes</span>
|
||||||
|
<button class="btn small danger rm-file" data-key="${esc(k)}" style="margin-left:auto">remove</button>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">Domains / IPs to check <span class="hint">one per line — domains or raw IPs; # comments ignored</span></label>
|
||||||
|
<textarea id="f-inputs" class="inputs" spellcheck="false" placeholder="example.com 1.1.1.1">${esc(d.inputs)}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn primary" id="btn-analyze">▶ Analyze</button>
|
||||||
|
<label class="check" title="Pre-resolve domains via DoH so ip_cidr / IP rule-set rules can match the resolved address">
|
||||||
|
<input type="checkbox" id="opt-assume" ${assume ? 'checked' : ''}/> Resolve IPs for IP rules
|
||||||
|
</label>
|
||||||
|
<div class="field" style="max-width:150px;margin:0">
|
||||||
|
<select id="opt-network" title="Assumed connection network for rules that filter on tcp/udp">
|
||||||
|
<option value="" ${network === '' ? 'selected' : ''}>network: any</option>
|
||||||
|
<option value="tcp" ${network === 'tcp' ? 'selected' : ''}>network: tcp</option>
|
||||||
|
<option value="udp" ${network === 'udp' ? 'selected' : ''}>network: udp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="muted mono" id="doh-indicator">DoH: ${esc(state.settings.dohServer || '—')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
$('#btn-save').onclick = saveProfile;
|
||||||
|
const del = $('#btn-delete'); if (del) del.onclick = deleteProfile;
|
||||||
|
$('#btn-analyze').onclick = analyze;
|
||||||
|
$('#f-files').onchange = handleFiles;
|
||||||
|
$('#opt-assume').onchange = (e) => store.set('assumeResolved', e.target.checked);
|
||||||
|
$('#opt-network').onchange = (e) => store.set('network', e.target.value);
|
||||||
|
$('#file-list').querySelectorAll('.rm-file').forEach((b) => {
|
||||||
|
b.onclick = () => { delete state.draft.ruleSetFiles[b.dataset.key]; syncDraftFromForm(); renderEditor(); };
|
||||||
|
});
|
||||||
|
if (state.lastResult) renderResults(state.lastResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDraftFromForm() {
|
||||||
|
const d = state.draft;
|
||||||
|
const n = $('#f-name'); if (n) d.name = n.value;
|
||||||
|
const c = $('#f-config'); if (c) d.config = c.value;
|
||||||
|
const i = $('#f-inputs'); if (i) d.inputs = i.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(e) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
syncDraftFromForm();
|
||||||
|
let pending = files.length;
|
||||||
|
if (!pending) return;
|
||||||
|
files.forEach((file) => {
|
||||||
|
const isBinary = /\.srs$/i.test(file.name);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let data, format;
|
||||||
|
if (isBinary) {
|
||||||
|
const bytes = new Uint8Array(reader.result);
|
||||||
|
let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||||||
|
data = btoa(bin); format = 'binary';
|
||||||
|
} else { data = reader.result; format = 'source'; }
|
||||||
|
state.draft.ruleSetFiles[file.name] = { format, data };
|
||||||
|
if (--pending === 0) renderEditor();
|
||||||
|
};
|
||||||
|
if (isBinary) reader.readAsArrayBuffer(file); else reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
syncDraftFromForm();
|
||||||
|
const d = state.draft;
|
||||||
|
if (!d.name.trim()) { toast('Please enter a profile name', 'err'); return; }
|
||||||
|
const payload = { name: d.name.trim(), config: d.config, inputs: d.inputs, ruleSetFiles: d.ruleSetFiles };
|
||||||
|
if (state.activeId) payload.id = state.activeId;
|
||||||
|
try {
|
||||||
|
const saved = await storage.saveProfile(payload);
|
||||||
|
state.activeId = saved.id;
|
||||||
|
await loadProfiles();
|
||||||
|
await selectProfile(saved.id);
|
||||||
|
toast('Profile saved', 'ok');
|
||||||
|
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProfile() {
|
||||||
|
if (!state.activeId) return;
|
||||||
|
if (!confirm('Delete this profile?')) return;
|
||||||
|
try {
|
||||||
|
await storage.deleteProfile(state.activeId);
|
||||||
|
await loadProfiles();
|
||||||
|
if (state.profiles.length) selectProfile(state.profiles[0].id); else newDraft();
|
||||||
|
toast('Profile deleted', 'ok');
|
||||||
|
} catch (e) { toast('Delete failed: ' + e.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- analyze ---------------- */
|
||||||
|
async function analyze() {
|
||||||
|
syncDraftFromForm();
|
||||||
|
const d = state.draft;
|
||||||
|
const inputs = d.inputs.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!inputs.length) { toast('Add at least one domain or IP', 'err'); return; }
|
||||||
|
const btn = $('#btn-analyze');
|
||||||
|
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Analyzing…';
|
||||||
|
const loadingEngine = !engineWorker.ready;
|
||||||
|
$('#results').innerHTML = `<div class="placeholder"><span class="spinner"></span> ${loadingEngine ? 'Loading engine (first run, ~a few MB)…' : 'Resolving & matching…'}</div>`;
|
||||||
|
try {
|
||||||
|
const result = await engineWorker.analyze({
|
||||||
|
config: d.config,
|
||||||
|
inputs,
|
||||||
|
ruleSetFiles: d.ruleSetFiles,
|
||||||
|
dohServer: state.settings.dohServer,
|
||||||
|
network: store.get('network', ''),
|
||||||
|
assumeResolved: store.get('assumeResolved', true),
|
||||||
|
});
|
||||||
|
state.lastResult = result;
|
||||||
|
renderResults(result);
|
||||||
|
} catch (e) {
|
||||||
|
$('#results').innerHTML = `<div class="card"><div class="card-body" style="color:var(--reject)">⚠ ${esc(e.message)}</div></div>`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.innerHTML = '▶ Analyze';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- results rendering ---------------- */
|
||||||
|
function statusBadge(status) {
|
||||||
|
const label = { match: 'MATCH', no_match: 'no match', unknown: 'UNKNOWN?' }[status] || status;
|
||||||
|
return `<span class="badge ${status}">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(result) {
|
||||||
|
const root = $('#results');
|
||||||
|
if (!result.inputs || !result.inputs.length) { root.innerHTML = '<div class="placeholder">No inputs.</div>'; return; }
|
||||||
|
const warn = (result.warnings && result.warnings.length)
|
||||||
|
? `<div class="assume-warn">⚠ ${result.warnings.map(esc).join('<br>')}</div>` : '';
|
||||||
|
root.innerHTML = warn + result.inputs.map((it, i) => renderInputCard(it, i)).join('');
|
||||||
|
root.querySelectorAll('.result-head').forEach((h) => {
|
||||||
|
h.onclick = () => h.closest('.result-card').classList.toggle('open');
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.step-head').forEach((h) => {
|
||||||
|
h.onclick = (e) => { e.stopPropagation(); h.closest('.step').classList.toggle('expanded'); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInputCard(it, idx) {
|
||||||
|
const chips = [];
|
||||||
|
if (it.kind === 'invalid') {
|
||||||
|
chips.push(`<span class="chip reject"><span class="k">error</span><span class="v">${esc(it.error || 'invalid')}</span></span>`);
|
||||||
|
} else {
|
||||||
|
if (it.dns && it.dns.decision) chips.push(dnsChip(it.dns.decision));
|
||||||
|
if (it.route && it.route.decision) chips.push(routeChip(it.route.decision));
|
||||||
|
}
|
||||||
|
const open = idx === 0 ? 'open' : '';
|
||||||
|
return `
|
||||||
|
<div class="card result-card ${open}">
|
||||||
|
<div class="result-head">
|
||||||
|
<span class="input-name">${esc(it.input)}</span>
|
||||||
|
<span class="badge kind-${esc(it.kind)}">${esc(it.kind)}</span>
|
||||||
|
<div class="outcome-chips">${chips.join('')}</div>
|
||||||
|
<span class="caret">▶</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-body">
|
||||||
|
${it.kind === 'invalid' ? `<p class="muted">${esc(it.error || 'Could not parse this input.')}</p>` : ''}
|
||||||
|
${renderResolved(it.resolved)}
|
||||||
|
${it.dns ? `<div class="section-title">DNS routing (which server / dns action)</div>${renderTrace(it.dns, 'dns')}` : ''}
|
||||||
|
${it.route ? `<div class="section-title">Route matching (which rule / outbound)</div>${renderTrace(it.route, 'route')}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dnsChip(dec) {
|
||||||
|
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">DNS</span><span class="v">reject</span></span>`;
|
||||||
|
const server = dec.server || (dec.actionType);
|
||||||
|
const detour = dec.serverInfo && dec.serverInfo.detour ? ` <span class="k">via</span> ${esc(dec.serverInfo.detour)}` : '';
|
||||||
|
return `<span class="chip dns"><span class="k">DNS</span><span class="v">${esc(server)}</span>${detour}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeChip(dec) {
|
||||||
|
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">route</span><span class="v">reject</span></span>`;
|
||||||
|
if (dec.actionType === 'hijack-dns') return `<span class="chip route"><span class="k">route</span><span class="v">hijack-dns</span></span>`;
|
||||||
|
return `<span class="chip route"><span class="k">route</span><span class="v">${esc(dec.outbound || '(default)')}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResolved(r) {
|
||||||
|
if (!r) return '';
|
||||||
|
if (r.error && !(r.ipv4 && r.ipv4.length) && !(r.ipv6 && r.ipv6.length)) {
|
||||||
|
return `<div class="section-title">Resolved (DoH)</div><p class="muted">resolution failed: ${esc(r.error)}</p>`;
|
||||||
|
}
|
||||||
|
const v4 = (r.ipv4 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
|
||||||
|
const v6 = (r.ipv6 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
|
||||||
|
if (!v4 && !v6) return `<div class="section-title">Resolved (DoH)</div><p class="muted">no A/AAAA records</p>`;
|
||||||
|
return `<div class="section-title">Resolved via ${esc(r.server)}</div><div class="ip-chips">${v4}${v6}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrace(trace, kind) {
|
||||||
|
const steps = (trace.steps || []).map((s) => renderStep(s, kind, trace)).join('');
|
||||||
|
const stepsHtml = steps ? `<div class="steps">${steps}</div>` : `<p class="muted">No ${kind === 'dns' ? 'DNS' : 'route'} rules — the final is used directly.</p>`;
|
||||||
|
const note = trace.note ? `<p class="muted" style="margin-top:6px">${esc(trace.note)}</p>` : '';
|
||||||
|
return stepsHtml + note + renderDecision(trace.decision, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep(s, kind, trace) {
|
||||||
|
const selected = (kind === 'route' && trace.selectedIndex === s.index) || (kind === 'dns' && trace.matchedIndex === s.index);
|
||||||
|
const cls = ['step', 's-' + s.status];
|
||||||
|
if (selected) cls.push('s-selected');
|
||||||
|
if (s.status === 'no_match') cls.push('dimmed');
|
||||||
|
const conds = s.type === 'logical' ? renderLogical(s) : (s.conditions || []).map(renderCond).join('');
|
||||||
|
const effect = s.effect ? `<div class="effect">↳ ${esc(s.effect)}</div>` : '';
|
||||||
|
const detailInner = conds || '<span class="muted">no conditions (matches all)</span>';
|
||||||
|
return `
|
||||||
|
<div class="step ${cls.join(' ')}">
|
||||||
|
<div class="step-head">
|
||||||
|
<span class="idx">${s.index}</span>
|
||||||
|
${statusBadge(s.status)}
|
||||||
|
<span class="summary" title="${esc(s.summary)}">${esc(s.summary)}</span>
|
||||||
|
<span class="action-text">${esc(s.actionText || '')}</span>
|
||||||
|
</div>
|
||||||
|
${effect}
|
||||||
|
<div class="step-detail">${detailInner}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogical(s) {
|
||||||
|
const subs = (s.sub || []).map((sub) => {
|
||||||
|
const inner = sub.type === 'logical' ? renderLogical(sub) : (sub.conditions || []).map(renderCond).join('');
|
||||||
|
return `<div class="ruleset-box"><div class="rs-head">${statusBadge(sub.status)} <span class="mono">${esc(sub.summary)}</span></div>${inner}</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="muted" style="margin-bottom:6px">logical <b>${esc(s.mode || 'and')}</b>${s.invert ? ' (inverted)' : ''}:</div>${subs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCond(c) {
|
||||||
|
const matched = c.matched ? `<span class="matched-val">✓ ${esc(c.matched)}</span>` : '';
|
||||||
|
const note = c.note ? `<div class="cnote">${esc(c.note)}</div>` : '';
|
||||||
|
const rs = c.ruleSet ? renderRuleSet(c.ruleSet) : '';
|
||||||
|
return `
|
||||||
|
<div class="cond">
|
||||||
|
<span class="cf">${esc(c.field)} ${statusBadge(c.status)}</span>
|
||||||
|
<span class="group-tag">${esc(c.group)}</span>
|
||||||
|
<span class="cv">${esc(c.value)} ${matched}</span>
|
||||||
|
${note}
|
||||||
|
</div>${rs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuleSet(rs) {
|
||||||
|
const inner = (rs.rules || []).map((r) => {
|
||||||
|
const cs = r.type === 'logical' ? renderLogical(r) : (r.conditions || []).map(renderCond).join('');
|
||||||
|
return `<div style="margin-top:6px">${statusBadge(r.status)} <span class="mono">${esc(r.summary)}</span>${cs}</div>`;
|
||||||
|
}).join('');
|
||||||
|
const err = rs.error ? `<div class="rs-err">⚠ ${esc(rs.error)}</div>` : '';
|
||||||
|
const matched = rs.matchedIdx >= 0 ? ` · matched rule #${rs.matchedIdx}` : '';
|
||||||
|
return `
|
||||||
|
<div class="ruleset-box">
|
||||||
|
<div class="rs-head">${statusBadge(rs.status)} <b>rule_set</b> <span class="mono">${esc(rs.tag)}</span>
|
||||||
|
<span class="rs-meta">(${esc(rs.type || '?')} · ${rs.count || 0} rules${matched})</span>
|
||||||
|
</div>
|
||||||
|
${err}${inner}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDecision(dec, kind) {
|
||||||
|
if (!dec) return '';
|
||||||
|
const assumed = dec.assumed ? `<span class="badge unknown" title="An earlier rule with undeterminable conditions (e.g. protocol/port) could change this outcome">depends on assumptions</span>` : '';
|
||||||
|
const fromFinal = dec.fromFinal ? `<span class="badge final">via ${kind === 'dns' ? 'dns.final' : 'route.final'}</span>` : '';
|
||||||
|
if (kind === 'dns') {
|
||||||
|
const cls = dec.actionType === 'reject' ? 'reject' : 'route';
|
||||||
|
let value, sub = '';
|
||||||
|
if (dec.actionType === 'reject') { value = 'reject'; }
|
||||||
|
else if (dec.actionType === 'predefined' || dec.actionType === 'respond') { value = dec.actionType; }
|
||||||
|
else {
|
||||||
|
value = dec.server || '(default)';
|
||||||
|
if (dec.serverInfo) {
|
||||||
|
const si = dec.serverInfo;
|
||||||
|
sub = `<span class="d-sub">${esc(si.type || '')}${si.address ? ' · ' + esc(si.address) : ''}${si.detour ? ' · via outbound <b>' + esc(si.detour) + '</b>' : ''}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<div class="decision ${cls}">
|
||||||
|
<span class="d-label">DNS action</span>
|
||||||
|
<span class="d-value">${esc(dec.actionType)}</span><span class="arrow">→</span>
|
||||||
|
<span class="d-value">${esc(value)}</span>${sub} ${fromFinal} ${assumed}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// route
|
||||||
|
let cls = 'route', value;
|
||||||
|
if (dec.actionType === 'reject') { cls = 'reject'; value = 'reject'; }
|
||||||
|
else if (dec.actionType === 'hijack-dns') { value = 'hijack-dns'; }
|
||||||
|
else { value = dec.outbound || '(default outbound)'; }
|
||||||
|
return `<div class="decision ${cls}">
|
||||||
|
<span class="d-label">Final outbound</span>
|
||||||
|
<span class="d-value">${esc(value)}</span>
|
||||||
|
${dec.detail && dec.actionType === 'reject' ? `<span class="d-sub">${esc(dec.detail)}</span>` : ''}
|
||||||
|
${fromFinal} ${assumed}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- settings ---------------- */
|
||||||
|
function openSettings() {
|
||||||
|
const root = $('#modal-root');
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="modal-backdrop" id="settings-backdrop">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="card-head"><h2>Settings</h2><button class="btn ghost small" id="s-close">✕</button></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">HTTPS DNS (DoH) endpoint <span class="hint">DoH JSON API · must allow CORS</span></label>
|
||||||
|
<input type="url" id="s-doh" value="${esc(state.settings.dohServer || '')}" placeholder="https://1.1.1.1/dns-query" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="lbl">Quick presets</label>
|
||||||
|
<div class="row" style="gap:6px">
|
||||||
|
${['https://1.1.1.1/dns-query', 'https://dns.google/dns-query', 'https://dns.quad9.net/dns-query', 'https://dns.alidns.com/dns-query']
|
||||||
|
.map((u) => `<button class="btn small preset" data-url="${esc(u)}">${esc(u.replace('https://', '').replace('/dns-query', ''))}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="justify-content:flex-end;margin-top:6px">
|
||||||
|
<button class="btn primary" id="s-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const close = () => { root.innerHTML = ''; };
|
||||||
|
$('#s-close').onclick = close;
|
||||||
|
$('#settings-backdrop').onclick = (e) => { if (e.target.id === 'settings-backdrop') close(); };
|
||||||
|
root.querySelectorAll('.preset').forEach((b) => b.onclick = () => { $('#s-doh').value = b.dataset.url; });
|
||||||
|
$('#s-save').onclick = async () => {
|
||||||
|
const dohServer = $('#s-doh').value.trim() || 'https://1.1.1.1/dns-query';
|
||||||
|
try {
|
||||||
|
state.settings = await storage.saveSettings({ dohServer });
|
||||||
|
const ind = $('#doh-indicator'); if (ind) ind.textContent = 'DoH: ' + state.settings.dohServer;
|
||||||
|
close(); toast('Settings saved', 'ok');
|
||||||
|
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>sing-vis · sing-box route explainer</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%97%87%3C/text%3E%3C/svg%3E" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo">◇</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">sing-vis</div>
|
||||||
|
<div class="subtitle">sing-box DNS & route rule explainer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btn-settings" class="btn ghost" title="Settings">⚙ Settings</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-head">
|
||||||
|
<span>Profiles</span>
|
||||||
|
<button id="btn-new" class="btn small">+ New</button>
|
||||||
|
</div>
|
||||||
|
<ul id="profile-list" class="profile-list"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="content" id="content"></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="modal-root"></div>
|
||||||
|
<div id="toast-root" class="toast-root"></div>
|
||||||
|
|
||||||
|
<script src="storage.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// IndexedDB-backed persistence for profiles and settings — the browser-side
|
||||||
|
// replacement for the old file store. IndexedDB (not localStorage) is used
|
||||||
|
// because uploaded .srs rule-set files can exceed localStorage's ~5MB limit.
|
||||||
|
//
|
||||||
|
// Shapes match the former Go store exactly:
|
||||||
|
// Profile { id, name, config, inputs, createdAt, updatedAt, ruleSetFiles }
|
||||||
|
// Settings { dohServer }
|
||||||
|
//
|
||||||
|
// Exposed as window.singvisStorage (app.js is a classic script).
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const DB_NAME = 'singvis';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_PROFILES = 'profiles';
|
||||||
|
const STORE_META = 'meta';
|
||||||
|
const SETTINGS_KEY = 'settings';
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = { dohServer: 'https://1.1.1.1/dns-query' };
|
||||||
|
|
||||||
|
let dbPromise = null;
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
if (dbPromise) return dbPromise;
|
||||||
|
dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_PROFILES)) {
|
||||||
|
db.createObjectStore(STORE_PROFILES, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(STORE_META)) {
|
||||||
|
db.createObjectStore(STORE_META);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error || new Error('failed to open IndexedDB'));
|
||||||
|
});
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tx runs fn(store) inside a transaction and resolves with `out` (a value fn
|
||||||
|
// can set) once the transaction completes.
|
||||||
|
async function tx(storeName, mode, fn) {
|
||||||
|
const db = await openDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = db.transaction(storeName, mode);
|
||||||
|
const store = t.objectStore(storeName);
|
||||||
|
let out;
|
||||||
|
const ret = (v) => { out = v; };
|
||||||
|
Promise.resolve(fn(store, ret)).catch(reject);
|
||||||
|
t.oncomplete = () => resolve(out);
|
||||||
|
t.onerror = () => reject(t.error);
|
||||||
|
t.onabort = () => reject(t.error || new Error('transaction aborted'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqPromise(request) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newId() {
|
||||||
|
// Millisecond timestamp plus a short random suffix to avoid collisions when
|
||||||
|
// several profiles are created within the same millisecond.
|
||||||
|
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async listProfiles() {
|
||||||
|
const list = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
|
||||||
|
ret(await reqPromise(store.getAll()));
|
||||||
|
});
|
||||||
|
const out = list || [];
|
||||||
|
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProfile(id) {
|
||||||
|
const p = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
|
||||||
|
ret(await reqPromise(store.get(id)));
|
||||||
|
});
|
||||||
|
if (!p) throw new Error('profile not found');
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
|
||||||
|
// saveProfile creates (when profile.id is empty) or updates a profile,
|
||||||
|
// assigning id/createdAt/updatedAt the same way the old server did.
|
||||||
|
async saveProfile(profile) {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = Object.assign({}, profile);
|
||||||
|
if (!p.id) {
|
||||||
|
p.id = newId();
|
||||||
|
p.createdAt = now;
|
||||||
|
}
|
||||||
|
if (!p.createdAt) p.createdAt = now;
|
||||||
|
p.updatedAt = now;
|
||||||
|
p.ruleSetFiles = p.ruleSetFiles || {};
|
||||||
|
await tx(STORE_PROFILES, 'readwrite', (store) => {
|
||||||
|
store.put(p);
|
||||||
|
});
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteProfile(id) {
|
||||||
|
await tx(STORE_PROFILES, 'readwrite', (store) => {
|
||||||
|
store.delete(id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSettings() {
|
||||||
|
const s = await tx(STORE_META, 'readonly', async (store, ret) => {
|
||||||
|
ret(await reqPromise(store.get(SETTINGS_KEY)));
|
||||||
|
});
|
||||||
|
const merged = Object.assign({}, DEFAULT_SETTINGS, s || {});
|
||||||
|
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveSettings(settings) {
|
||||||
|
const merged = Object.assign({}, DEFAULT_SETTINGS, settings || {});
|
||||||
|
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
|
||||||
|
await tx(STORE_META, 'readwrite', (store) => {
|
||||||
|
store.put(merged, SETTINGS_KEY);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultSettings() {
|
||||||
|
return Object.assign({}, DEFAULT_SETTINGS);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.singvisStorage = api;
|
||||||
|
})();
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--bg-sunken: #eef0f3;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--border-strong: #cfd4dc;
|
||||||
|
--text: #1c2128;
|
||||||
|
--text-dim: #656d78;
|
||||||
|
--text-faint: #98a0ab;
|
||||||
|
--accent: #3b6cff;
|
||||||
|
--accent-weak: #e8eeff;
|
||||||
|
--match: #1a8a4a;
|
||||||
|
--match-bg: #e5f5ea;
|
||||||
|
--nomatch: #8a919b;
|
||||||
|
--nomatch-bg: #eceef1;
|
||||||
|
--unknown: #a66a00;
|
||||||
|
--unknown-bg: #fbefd8;
|
||||||
|
--reject: #c8321f;
|
||||||
|
--reject-bg: #fce7e3;
|
||||||
|
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 1px 3px rgba(20,25,35,.06), 0 4px 16px rgba(20,25,35,.05);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0e1116;
|
||||||
|
--bg-elev: #161b22;
|
||||||
|
--bg-sunken: #0b0e13;
|
||||||
|
--border: #262c36;
|
||||||
|
--border-strong: #333b47;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-dim: #9aa4b2;
|
||||||
|
--text-faint: #6b7683;
|
||||||
|
--accent: #5b86ff;
|
||||||
|
--accent-weak: #1a2540;
|
||||||
|
--match: #4ac57e;
|
||||||
|
--match-bg: #12291d;
|
||||||
|
--nomatch: #7d8794;
|
||||||
|
--nomatch-bg: #1a1f27;
|
||||||
|
--unknown: #e0a445;
|
||||||
|
--unknown-bg: #2a2113;
|
||||||
|
--reject: #f0715c;
|
||||||
|
--reject-bg: #2c1512;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.3), 0 6px 20px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- top bar ---------- */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.logo {
|
||||||
|
font-size: 26px; color: var(--accent);
|
||||||
|
width: 40px; height: 40px; display: grid; place-items: center;
|
||||||
|
background: var(--accent-weak); border-radius: 10px;
|
||||||
|
}
|
||||||
|
.title { font-weight: 700; font-size: 17px; letter-spacing: -.2px; }
|
||||||
|
.subtitle { color: var(--text-dim); font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- layout ---------- */
|
||||||
|
.layout { display: grid; grid-template-columns: 260px 1fr; min-height: calc(100vh - 65px); }
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding: 14px 12px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.sidebar-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-weight: 600; font-size: 12px; text-transform: uppercase;
|
||||||
|
letter-spacing: .5px; color: var(--text-dim); margin-bottom: 10px; padding: 0 4px;
|
||||||
|
}
|
||||||
|
.profile-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.profile-item {
|
||||||
|
padding: 9px 10px; border-radius: 8px; cursor: pointer;
|
||||||
|
display: flex; flex-direction: column; gap: 2px; border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.profile-item:hover { background: var(--bg-sunken); }
|
||||||
|
.profile-item.active { background: var(--accent-weak); border-color: var(--accent); }
|
||||||
|
.profile-item .name { font-weight: 600; }
|
||||||
|
.profile-item .meta { font-size: 11px; color: var(--text-faint); }
|
||||||
|
.empty-hint { color: var(--text-faint); font-size: 12px; padding: 8px 4px; }
|
||||||
|
|
||||||
|
.content { padding: 22px 26px; overflow-y: auto; max-width: 1100px; width: 100%; }
|
||||||
|
|
||||||
|
/* ---------- forms ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-elev); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; font-size: 14px; font-weight: 650; }
|
||||||
|
.card-body { padding: 16px; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field:last-child { margin-bottom: 0; }
|
||||||
|
label.lbl { display: block; font-weight: 600; margin-bottom: 6px; font-size: 12.5px; }
|
||||||
|
label.lbl .hint { font-weight: 400; color: var(--text-faint); margin-left: 6px; font-size: 11.5px; }
|
||||||
|
input[type=text], input[type=url], textarea, select {
|
||||||
|
width: 100%; padding: 9px 11px; border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 8px; background: var(--bg); color: var(--text);
|
||||||
|
font-family: inherit; font-size: 13.5px; outline: none;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||||
|
textarea { resize: vertical; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
|
||||||
|
textarea.code { min-height: 180px; white-space: pre; }
|
||||||
|
textarea.inputs { min-height: 90px; }
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.row > .field { flex: 1; margin-bottom: 0; min-width: 160px; }
|
||||||
|
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||||
|
.check input { width: auto; }
|
||||||
|
|
||||||
|
/* ---------- buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--border-strong); background: var(--bg-elev); color: var(--text);
|
||||||
|
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;
|
||||||
|
font-family: inherit; transition: background .12s, border-color .12s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--bg-sunken); }
|
||||||
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn.primary:hover { filter: brightness(1.07); }
|
||||||
|
.btn.ghost { background: transparent; border-color: transparent; }
|
||||||
|
.btn.ghost:hover { background: var(--bg-sunken); }
|
||||||
|
.btn.danger { color: var(--reject); border-color: transparent; background: transparent; }
|
||||||
|
.btn.danger:hover { background: var(--reject-bg); }
|
||||||
|
.btn.small { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.toolbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- badges ---------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;
|
||||||
|
border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: .2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.kind-domain { background: var(--accent-weak); color: var(--accent); }
|
||||||
|
.badge.kind-ip { background: var(--bg-sunken); color: var(--text-dim); }
|
||||||
|
.badge.kind-invalid { background: var(--reject-bg); color: var(--reject); }
|
||||||
|
.badge.match { background: var(--match-bg); color: var(--match); }
|
||||||
|
.badge.no_match { background: var(--nomatch-bg); color: var(--nomatch); }
|
||||||
|
.badge.unknown { background: var(--unknown-bg); color: var(--unknown); }
|
||||||
|
.badge.reject { background: var(--reject-bg); color: var(--reject); }
|
||||||
|
.badge.final { background: var(--bg-sunken); color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ---------- results ---------- */
|
||||||
|
.result-card { margin-bottom: 16px; }
|
||||||
|
.result-head {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 13px 16px; cursor: pointer;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.result-card.open .result-head { border-bottom-color: var(--border); }
|
||||||
|
.result-head .input-name { font-family: var(--mono); font-weight: 700; font-size: 14px; }
|
||||||
|
.result-head .caret { color: var(--text-faint); transition: transform .15s; margin-left: auto; }
|
||||||
|
.result-card.open .result-head .caret { transform: rotate(90deg); }
|
||||||
|
.outcome-chips { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 8px;
|
||||||
|
font-size: 12px; background: var(--bg-sunken); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.chip .k { color: var(--text-faint); font-weight: 600; }
|
||||||
|
.chip .v { font-family: var(--mono); font-weight: 700; }
|
||||||
|
.chip.route .v { color: var(--accent); }
|
||||||
|
.chip.dns .v { color: var(--text); }
|
||||||
|
.chip.reject { background: var(--reject-bg); border-color: transparent; }
|
||||||
|
.chip.reject .v { color: var(--reject); }
|
||||||
|
|
||||||
|
.result-body { padding: 4px 16px 16px; display: none; }
|
||||||
|
.result-card.open .result-body { display: block; }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 12px; text-transform: uppercase; letter-spacing: .6px; color: var(--text-dim);
|
||||||
|
font-weight: 700; margin: 18px 0 8px;
|
||||||
|
}
|
||||||
|
.assume-warn {
|
||||||
|
background: var(--unknown-bg); color: var(--unknown); border: 1px solid transparent;
|
||||||
|
padding: 8px 12px; border-radius: 8px; font-size: 12.5px; margin: 6px 0 12px;
|
||||||
|
display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ip-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.ip-chip { font-family: var(--mono); font-size: 12px; background: var(--bg-sunken); border: 1px solid var(--border); padding: 3px 8px; border-radius: 6px; }
|
||||||
|
|
||||||
|
/* trace steps */
|
||||||
|
.steps { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.step {
|
||||||
|
border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: var(--bg);
|
||||||
|
}
|
||||||
|
.step.s-match { border-color: var(--match); }
|
||||||
|
.step.s-unknown { border-left: 3px solid var(--unknown); }
|
||||||
|
.step.s-selected { box-shadow: 0 0 0 2px var(--match) inset; }
|
||||||
|
.step-head {
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.step-head:hover { background: var(--bg-sunken); }
|
||||||
|
.step .idx {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--text-faint);
|
||||||
|
min-width: 26px; text-align: center; background: var(--bg-sunken); border-radius: 5px; padding: 1px 4px;
|
||||||
|
}
|
||||||
|
.step .summary { font-family: var(--mono); font-size: 12.5px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.step .action-text { font-size: 11.5px; color: var(--text-dim); white-space: nowrap; }
|
||||||
|
.step .effect { font-size: 11.5px; color: var(--unknown); padding: 0 12px 8px 48px; }
|
||||||
|
.step-detail { padding: 4px 12px 10px 12px; border-top: 1px dashed var(--border); display: none; }
|
||||||
|
.step.expanded .step-detail { display: block; }
|
||||||
|
.step.dimmed { opacity: .62; }
|
||||||
|
|
||||||
|
.cond {
|
||||||
|
display: grid; grid-template-columns: 130px auto 1fr; gap: 8px; align-items: baseline;
|
||||||
|
padding: 4px 0; font-size: 12.5px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cond:last-child { border-bottom: none; }
|
||||||
|
.cond .cf { font-weight: 600; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.cond .cv { font-family: var(--mono); color: var(--text-dim); word-break: break-word; }
|
||||||
|
.cond .cnote { color: var(--text-faint); font-size: 11.5px; grid-column: 1 / -1; margin-left: 130px; }
|
||||||
|
.cond .group-tag { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .3px; }
|
||||||
|
.cond .matched-val { color: var(--match); font-family: var(--mono); font-size: 11.5px; }
|
||||||
|
|
||||||
|
.ruleset-box { margin: 6px 0 6px 12px; padding: 8px 10px; background: var(--bg-sunken); border-radius: 8px; border: 1px solid var(--border); }
|
||||||
|
.ruleset-box .rs-head { display: flex; gap: 8px; align-items: center; font-size: 12px; }
|
||||||
|
.ruleset-box .rs-meta { color: var(--text-faint); font-size: 11px; }
|
||||||
|
.ruleset-box .rs-err { color: var(--reject); font-size: 11.5px; margin-top: 4px; }
|
||||||
|
|
||||||
|
.decision {
|
||||||
|
margin-top: 10px; padding: 12px 14px; border-radius: 9px; border: 1px solid var(--border);
|
||||||
|
background: var(--bg-sunken); display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.decision.route { border-color: var(--accent); background: var(--accent-weak); }
|
||||||
|
.decision.reject { border-color: var(--reject); background: var(--reject-bg); }
|
||||||
|
.decision .d-label { font-size: 12px; color: var(--text-dim); font-weight: 700; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.decision .d-value { font-family: var(--mono); font-weight: 700; font-size: 15px; }
|
||||||
|
.decision.route .d-value { color: var(--accent); }
|
||||||
|
.decision.reject .d-value { color: var(--reject); }
|
||||||
|
.decision .d-sub { font-size: 12px; color: var(--text-dim); }
|
||||||
|
.arrow { color: var(--text-faint); }
|
||||||
|
|
||||||
|
/* ---------- modal ---------- */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(10,14,20,.5); display: grid; place-items: center; z-index: 50;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-elev); border: 1px solid var(--border); border-radius: 14px;
|
||||||
|
width: min(560px, 92vw); box-shadow: var(--shadow); max-height: 88vh; overflow: auto;
|
||||||
|
}
|
||||||
|
.modal .card-head { position: sticky; top: 0; background: var(--bg-elev); }
|
||||||
|
|
||||||
|
/* ---------- toast ---------- */
|
||||||
|
.toast-root { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-elev); border: 1px solid var(--border-strong); border-left: 3px solid var(--accent);
|
||||||
|
padding: 10px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 13px; max-width: 340px;
|
||||||
|
animation: slidein .2s ease;
|
||||||
|
}
|
||||||
|
.toast.err { border-left-color: var(--reject); }
|
||||||
|
.toast.ok { border-left-color: var(--match); }
|
||||||
|
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } }
|
||||||
|
|
||||||
|
.muted { color: var(--text-dim); }
|
||||||
|
.mono { font-family: var(--mono); }
|
||||||
|
.spinner { display: inline-block; width: 15px; height: 15px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -3px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.placeholder { text-align: center; color: var(--text-faint); padding: 60px 20px; }
|
||||||
|
.placeholder .big { font-size: 40px; opacity: .5; margin-bottom: 10px; }
|
||||||
|
.file-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
|
.file-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; background: var(--bg-sunken); padding: 6px 10px; border-radius: 7px; }
|
||||||
|
.file-row .fname { font-family: var(--mono); font-weight: 600; }
|
||||||
|
.file-row .fmeta { color: var(--text-faint); font-size: 11px; }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Web Worker that hosts the sing-vis WebAssembly engine. It loads the Go runtime
|
||||||
|
// shim + singvis.wasm, keeps the Go program alive, and forwards analyze requests
|
||||||
|
// to the JS-exported `singvisAnalyze`. Running in a worker keeps the wasm work
|
||||||
|
// (DoH resolution, rule-set fetching, matching) off the UI thread.
|
||||||
|
//
|
||||||
|
// Message protocol:
|
||||||
|
// main → worker: { id, request } request = the analyze payload
|
||||||
|
// worker → main: { id, result } on success (result = parsed engine.Result)
|
||||||
|
// { id, error } on failure (error = message string)
|
||||||
|
// { type:'ready' } once the wasm engine is initialized
|
||||||
|
// { type:'loaderror', error } if the wasm failed to load
|
||||||
|
|
||||||
|
importScripts('wasm_exec.js');
|
||||||
|
|
||||||
|
// Resolved by the Go program (via the `singvisReady` callback it invokes in main)
|
||||||
|
// once `singvisAnalyze` has been registered on the global scope.
|
||||||
|
let signalStarted;
|
||||||
|
const started = new Promise((resolve) => { signalStarted = resolve; });
|
||||||
|
globalThis.singvisReady = () => signalStarted();
|
||||||
|
|
||||||
|
const ready = (async () => {
|
||||||
|
const go = new Go();
|
||||||
|
let instance;
|
||||||
|
try {
|
||||||
|
// Preferred path; requires the server to send Content-Type: application/wasm.
|
||||||
|
const res = await WebAssembly.instantiateStreaming(fetch('singvis.wasm'), go.importObject);
|
||||||
|
instance = res.instance;
|
||||||
|
} catch (streamErr) {
|
||||||
|
// Fallback for static servers that don't set the wasm MIME type.
|
||||||
|
const resp = await fetch('singvis.wasm');
|
||||||
|
if (!resp.ok) throw new Error('failed to load singvis.wasm (HTTP ' + resp.status + ')');
|
||||||
|
const bytes = await resp.arrayBuffer();
|
||||||
|
const res = await WebAssembly.instantiate(bytes, go.importObject);
|
||||||
|
instance = res.instance;
|
||||||
|
}
|
||||||
|
// Do NOT await: the Go main blocks forever (select{}) to keep serving calls.
|
||||||
|
go.run(instance);
|
||||||
|
await started;
|
||||||
|
})();
|
||||||
|
|
||||||
|
ready.then(
|
||||||
|
() => self.postMessage({ type: 'ready' }),
|
||||||
|
(err) => self.postMessage({ type: 'loaderror', error: (err && err.message) || String(err) })
|
||||||
|
);
|
||||||
|
|
||||||
|
self.onmessage = async (e) => {
|
||||||
|
const data = e.data || {};
|
||||||
|
if (data.id == null) return;
|
||||||
|
try {
|
||||||
|
await ready;
|
||||||
|
const out = await globalThis.singvisAnalyze(JSON.stringify(data.request || {}));
|
||||||
|
self.postMessage({ id: data.id, result: JSON.parse(out) });
|
||||||
|
} catch (err) {
|
||||||
|
self.postMessage({ id: data.id, error: (err && err.message) || String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user