commit 2275bd7dc4418fb90e5123308d38da6810625a25 Author: iceBear67 Date: Fri Jul 24 13:29:16 2026 +0000 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d61915b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f7533b --- /dev/null +++ b/README.md @@ -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). + +![overview](docs-shot.png) + +## 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) +``` diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ddf7344 --- /dev/null +++ b/build.sh @@ -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" diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..1c9d9bc --- /dev/null +++ b/cmd/wasm/main.go @@ -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 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()) +} diff --git a/docs-shot.png b/docs-shot.png new file mode 100644 index 0000000..a83bcab Binary files /dev/null and b/docs-shot.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..47dc539 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e703fa8 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/dnsx/doh.go b/internal/dnsx/doh.go new file mode 100644 index 0000000..51d184e --- /dev/null +++ b/internal/dnsx/doh.go @@ -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 +} diff --git a/internal/engine/analyze.go b/internal/engine/analyze.go new file mode 100644 index 0000000..8ab0008 --- /dev/null +++ b/internal/engine/analyze.go @@ -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 +} diff --git a/internal/engine/conditions.go b/internal/engine/conditions.go new file mode 100644 index 0000000..ff8e667 --- /dev/null +++ b/internal/engine/conditions.go @@ -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) +} diff --git a/internal/engine/dns.go b/internal/engine/dns.go new file mode 100644 index 0000000..acedee3 --- /dev/null +++ b/internal/engine/dns.go @@ -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 +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1e9e1fd --- /dev/null +++ b/internal/engine/engine.go @@ -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) +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..83b007c --- /dev/null +++ b/internal/engine/engine_test.go @@ -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) + } +} diff --git a/internal/engine/parse.go b/internal/engine/parse.go new file mode 100644 index 0000000..9599154 --- /dev/null +++ b/internal/engine/parse.go @@ -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 "" +} diff --git a/internal/engine/route.go b/internal/engine/route.go new file mode 100644 index 0000000..2062904 --- /dev/null +++ b/internal/engine/route.go @@ -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 +} diff --git a/internal/engine/rules.go b/internal/engine/rules.go new file mode 100644 index 0000000..ac00db5 --- /dev/null +++ b/internal/engine/rules.go @@ -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 +} diff --git a/internal/engine/ruleset.go b/internal/engine/ruleset.go new file mode 100644 index 0000000..b866bde --- /dev/null +++ b/internal/engine/ruleset.go @@ -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" +} diff --git a/internal/engine/types.go b/internal/engine/types.go new file mode 100644 index 0000000..4c8aef3 --- /dev/null +++ b/internal/engine/types.go @@ -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"` +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..9425ed6 --- /dev/null +++ b/run.sh @@ -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 diff --git a/sing-box b/sing-box new file mode 160000 index 0000000..0b54f57 --- /dev/null +++ b/sing-box @@ -0,0 +1 @@ +Subproject commit 0b54f577251cc7f5e18c52d71f782dd5d2451fda diff --git a/wasmbuild/_stubs/buf_buffer_unix.go b/wasmbuild/_stubs/buf_buffer_unix.go new file mode 100644 index 0000000..8c16bad --- /dev/null +++ b/wasmbuild/_stubs/buf_buffer_unix.go @@ -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 diff --git a/wasmbuild/_stubs/bufio_copy_direct_posix.go b/wasmbuild/_stubs/bufio_copy_direct_posix.go new file mode 100644 index 0000000..690bc00 --- /dev/null +++ b/wasmbuild/_stubs/bufio_copy_direct_posix.go @@ -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 +} diff --git a/wasmbuild/_stubs/bufio_vectorised_unix.go b/wasmbuild/_stubs/bufio_vectorised_unix.go new file mode 100644 index 0000000..9aff4d7 --- /dev/null +++ b/wasmbuild/_stubs/bufio_vectorised_unix.go @@ -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 +} diff --git a/wasmbuild/gen-overlay.sh b/wasmbuild/gen-overlay.sh new file mode 100755 index 0000000..a13d218 --- /dev/null +++ b/wasmbuild/gen-overlay.sh @@ -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 < 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 = '
  • No profiles yet. Create one to get started.
  • '; + return; + } + ul.innerHTML = state.profiles.map((p) => ` +
  • + ${esc(p.name || 'Untitled')} + updated ${new Date(p.updatedAt).toLocaleString()} +
  • `).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 = ` +
    +
    +

    ${state.activeId ? 'Edit profile' : 'New profile'}

    +
    + + ${state.activeId ? '' : ''} +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + ${files.map(([k, f]) => ` +
    + ${esc(k)} + ${esc(f.format)} · ${f.data ? (f.format === 'binary' ? Math.round(f.data.length * 0.75) : f.data.length) : 0} bytes + +
    `).join('')} +
    +
    +
    + + +
    +
    +
    + +
    + + +
    + +
    + + DoH: ${esc(state.settings.dohServer || '—')} +
    + +
    + `; + + $('#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 = ' Analyzing…'; + const loadingEngine = !engineWorker.ready; + $('#results').innerHTML = `
    ${loadingEngine ? 'Loading engine (first run, ~a few MB)…' : 'Resolving & matching…'}
    `; + 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 = `
    ⚠ ${esc(e.message)}
    `; + } 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 `${label}`; +} + +function renderResults(result) { + const root = $('#results'); + if (!result.inputs || !result.inputs.length) { root.innerHTML = '
    No inputs.
    '; return; } + const warn = (result.warnings && result.warnings.length) + ? `
    ⚠ ${result.warnings.map(esc).join('
    ')}
    ` : ''; + 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(`error${esc(it.error || 'invalid')}`); + } 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 ` +
    +
    + ${esc(it.input)} + ${esc(it.kind)} +
    ${chips.join('')}
    + +
    +
    + ${it.kind === 'invalid' ? `

    ${esc(it.error || 'Could not parse this input.')}

    ` : ''} + ${renderResolved(it.resolved)} + ${it.dns ? `
    DNS routing (which server / dns action)
    ${renderTrace(it.dns, 'dns')}` : ''} + ${it.route ? `
    Route matching (which rule / outbound)
    ${renderTrace(it.route, 'route')}` : ''} +
    +
    `; +} + +function dnsChip(dec) { + if (dec.actionType === 'reject') return `DNSreject`; + const server = dec.server || (dec.actionType); + const detour = dec.serverInfo && dec.serverInfo.detour ? ` via ${esc(dec.serverInfo.detour)}` : ''; + return `DNS${esc(server)}${detour}`; +} + +function routeChip(dec) { + if (dec.actionType === 'reject') return `routereject`; + if (dec.actionType === 'hijack-dns') return `routehijack-dns`; + return `route${esc(dec.outbound || '(default)')}`; +} + +function renderResolved(r) { + if (!r) return ''; + if (r.error && !(r.ipv4 && r.ipv4.length) && !(r.ipv6 && r.ipv6.length)) { + return `
    Resolved (DoH)

    resolution failed: ${esc(r.error)}

    `; + } + const v4 = (r.ipv4 || []).map((ip) => `${esc(ip)}`).join(''); + const v6 = (r.ipv6 || []).map((ip) => `${esc(ip)}`).join(''); + if (!v4 && !v6) return `
    Resolved (DoH)

    no A/AAAA records

    `; + return `
    Resolved via ${esc(r.server)}
    ${v4}${v6}
    `; +} + +function renderTrace(trace, kind) { + const steps = (trace.steps || []).map((s) => renderStep(s, kind, trace)).join(''); + const stepsHtml = steps ? `
    ${steps}
    ` : `

    No ${kind === 'dns' ? 'DNS' : 'route'} rules — the final is used directly.

    `; + const note = trace.note ? `

    ${esc(trace.note)}

    ` : ''; + 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 ? `
    ↳ ${esc(s.effect)}
    ` : ''; + const detailInner = conds || 'no conditions (matches all)'; + return ` +
    +
    + ${s.index} + ${statusBadge(s.status)} + ${esc(s.summary)} + ${esc(s.actionText || '')} +
    + ${effect} +
    ${detailInner}
    +
    `; +} + +function renderLogical(s) { + const subs = (s.sub || []).map((sub) => { + const inner = sub.type === 'logical' ? renderLogical(sub) : (sub.conditions || []).map(renderCond).join(''); + return `
    ${statusBadge(sub.status)} ${esc(sub.summary)}
    ${inner}
    `; + }).join(''); + return `
    logical ${esc(s.mode || 'and')}${s.invert ? ' (inverted)' : ''}:
    ${subs}`; +} + +function renderCond(c) { + const matched = c.matched ? `✓ ${esc(c.matched)}` : ''; + const note = c.note ? `
    ${esc(c.note)}
    ` : ''; + const rs = c.ruleSet ? renderRuleSet(c.ruleSet) : ''; + return ` +
    + ${esc(c.field)} ${statusBadge(c.status)} + ${esc(c.group)} + ${esc(c.value)} ${matched} + ${note} +
    ${rs}`; +} + +function renderRuleSet(rs) { + const inner = (rs.rules || []).map((r) => { + const cs = r.type === 'logical' ? renderLogical(r) : (r.conditions || []).map(renderCond).join(''); + return `
    ${statusBadge(r.status)} ${esc(r.summary)}${cs}
    `; + }).join(''); + const err = rs.error ? `
    ⚠ ${esc(rs.error)}
    ` : ''; + const matched = rs.matchedIdx >= 0 ? ` · matched rule #${rs.matchedIdx}` : ''; + return ` +
    +
    ${statusBadge(rs.status)} rule_set ${esc(rs.tag)} + (${esc(rs.type || '?')} · ${rs.count || 0} rules${matched}) +
    + ${err}${inner} +
    `; +} + +function renderDecision(dec, kind) { + if (!dec) return ''; + const assumed = dec.assumed ? `depends on assumptions` : ''; + const fromFinal = dec.fromFinal ? `via ${kind === 'dns' ? 'dns.final' : 'route.final'}` : ''; + 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 = `${esc(si.type || '')}${si.address ? ' · ' + esc(si.address) : ''}${si.detour ? ' · via outbound ' + esc(si.detour) + '' : ''}`; + } + } + return `
    + DNS action + ${esc(dec.actionType)} + ${esc(value)}${sub} ${fromFinal} ${assumed} +
    `; + } + // 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 `
    + Final outbound + ${esc(value)} + ${dec.detail && dec.actionType === 'reject' ? `${esc(dec.detail)}` : ''} + ${fromFinal} ${assumed} +
    `; +} + +/* ---------------- settings ---------------- */ +function openSettings() { + const root = $('#modal-root'); + root.innerHTML = ` + `; + 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(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..31d79c6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,42 @@ + + + + + + sing-vis · sing-box route explainer + + + + +
    +
    + +
    +
    sing-vis
    +
    sing-box DNS & route rule explainer
    +
    +
    +
    + +
    +
    + +
    + + +
    +
    + + +
    + + + + + diff --git a/web/storage.js b/web/storage.js new file mode 100644 index 0000000..1a0942e --- /dev/null +++ b/web/storage.js @@ -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; +})(); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..dc5d21f --- /dev/null +++ b/web/style.css @@ -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; } diff --git a/web/worker.js b/web/worker.js new file mode 100644 index 0000000..578b912 --- /dev/null +++ b/web/worker.js @@ -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) }); + } +};