This commit is contained in:
iceBear67
2026-07-24 13:29:16 +00:00
commit 2275bd7dc4
29 changed files with 3641 additions and 0 deletions
+5
View File
@@ -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
+108
View File
@@ -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)
```
Executable
+42
View File
@@ -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"
+103
View File
@@ -0,0 +1,103 @@
// Command wasm is the browser entry point for sing-vis. Built with
// `GOOS=js GOARCH=wasm`, it exposes a single JS-callable function,
// `singvisAnalyze`, that runs the exact same internal/engine analysis the old
// HTTP server did — only now it runs entirely in the browser (in a Web Worker).
//
// Under js/wasm, net/http transparently uses the browser Fetch API, so the DoH
// resolver and remote rule-set fetching keep working (subject to CORS).
//
//go:build js && wasm
package main
import (
"context"
"encoding/json"
"fmt"
"syscall/js"
"sing-vis/internal/dnsx"
"sing-vis/internal/engine"
)
// analyzeRequest is the single JSON argument passed from JS. It mirrors the old
// POST /api/analyze body.
type analyzeRequest struct {
Config string `json:"config"`
Inputs []string `json:"inputs"`
RuleSetFiles map[string]engine.RuleSetFile `json:"ruleSetFiles"`
DoHServer string `json:"dohServer"`
Network string `json:"network"` // optional: tcp/udp assumption
AssumeResolved *bool `json:"assumeResolved"` // default true
}
const defaultDoHServer = "https://1.1.1.1/dns-query"
func main() {
js.Global().Set("singvisAnalyze", js.FuncOf(analyze))
// Signal readiness to the host (worker) so it need not poll.
if ready := js.Global().Get("singvisReady"); ready.Type() == js.TypeFunction {
ready.Invoke()
}
select {} // keep the Go runtime alive to service calls
}
// analyze is the JS-facing entry point. It takes one JSON string argument and
// returns a Promise<string> that resolves to the marshaled engine.Result (or
// rejects with an Error). The work runs in a goroutine so the JS event loop is
// never blocked while DoH / rule-set fetches are in flight.
func analyze(_ js.Value, args []js.Value) any {
var input string
if len(args) > 0 && args[0].Type() == js.TypeString {
input = args[0].String()
}
handler := js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
out, err := runAnalyze(input)
if err != nil {
reject.Invoke(jsError(err))
return
}
resolve.Invoke(out)
}()
return nil
})
return js.Global().Get("Promise").New(handler)
}
func runAnalyze(input string) (string, error) {
var req analyzeRequest
if err := json.Unmarshal([]byte(input), &req); err != nil {
return "", fmt.Errorf("invalid request: %w", err)
}
doh := req.DoHServer
if doh == "" {
doh = defaultDoHServer
}
assumeResolved := true
if req.AssumeResolved != nil {
assumeResolved = *req.AssumeResolved
}
result, err := engine.Analyze(context.Background(), engine.Request{
Config: req.Config,
Inputs: req.Inputs,
RuleSetFiles: req.RuleSetFiles,
Network: req.Network,
AssumeResolved: assumeResolved,
Resolver: dnsx.NewDoHResolver(doh),
})
if err != nil {
return "", err
}
out, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(out), nil
}
func jsError(err error) js.Value {
return js.Global().Get("Error").New(err.Error())
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+20
View File
@@ -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
+26
View File
@@ -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=
+169
View File
@@ -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
}
+134
View File
@@ -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
}
+473
View File
@@ -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)
}
+111
View File
@@ -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
}
+42
View File
@@ -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)
}
+208
View File
@@ -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)
}
}
+128
View File
@@ -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 ""
}
+185
View File
@@ -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
}
+314
View File
@@ -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
}
+248
View File
@@ -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"
}
+114
View File
@@ -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"`
}
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Build the WebAssembly engine and serve the static web/ directory.
#
# sing-vis has no backend: this just compiles web/singvis.wasm and starts a
# plain static file server. Any static host works (including GitHub Pages — just
# publish the web/ directory); this script uses Python's http.server for local
# development. Profiles and settings live in the browser (IndexedDB), so there is
# no server-side data directory anymore.
set -euo pipefail
cd "$(dirname "$0")"
export GOTOOLCHAIN=auto # go.mod needs go >= 1.24.7; auto-fetches the toolchain
# Accept either "host:port" or a bare port.
ADDR="${1:-127.0.0.1:8787}"
case "$ADDR" in
*:*) HOST="${ADDR%:*}"; PORT="${ADDR##*:}" ;;
*) HOST="127.0.0.1"; PORT="$ADDR" ;;
esac
./build.sh
echo "Serving sing-vis (static) on http://${HOST}:${PORT}"
exec python3 -m http.server "${PORT}" --bind "${HOST}" --directory web
Submodule
+1
Submodule sing-box added at 0b54f57725
+11
View File
@@ -0,0 +1,11 @@
// This file is substituted in via `go build -overlay` for the js/wasm build ONLY.
//
// Upstream common/buf/buffer_unix.go (build tag `!windows`) defines
// Buffer.Iovec returning golang.org/x/sys/unix.Iovec, which does not exist for
// GOARCH=wasm. Iovec's only callers are the platform-specific bufio syscall
// paths, none of which are exercised under wasm (there are no raw socket fds in
// a browser). Replacing the file with an empty package body keeps common/buf
// compilable without pulling in unix.Iovec. Native builds do not use the
// overlay and keep the real implementation.
package buf
@@ -0,0 +1,76 @@
// Substituted in via `go build -overlay` for the js/wasm build ONLY.
//
// Upstream common/bufio/copy_direct_posix.go (build tag `!windows`) implements
// the syscall read-waiters using golang.org/x/sys/unix (readv/recvmsg). wait.go
// (cross-platform) references the createSyscall*ReadWaiter constructors, so the
// symbols must exist for the package to compile. Under wasm there are no raw
// socket fds, so the constructors report "not created" and the read paths are
// never taken. This provides the same identifiers with unix stripped out.
package bufio
import (
"os"
"syscall"
"github.com/sagernet/sing/common/buf"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
var _ N.ReadWaiter = (*syscallReadWaiter)(nil)
type syscallReadWaiter struct {
rawConn syscall.RawConn
buffer *buf.Buffer
options N.ReadWaitOptions
}
func createSyscallReadWaiter(any) (*syscallReadWaiter, bool) { return nil, false }
func (w *syscallReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
w.options = options
return false
}
func (w *syscallReadWaiter) WaitReadBuffer() (buffer *buf.Buffer, err error) {
return nil, os.ErrInvalid
}
var _ N.VectorisedReadWaiter = (*vectorisedSyscallReadWaiter)(nil)
type vectorisedSyscallReadWaiter struct {
rawConn syscall.RawConn
buffers []*buf.Buffer
options N.ReadWaitOptions
}
func createVectorisedSyscallReadWaiter(any) (*vectorisedSyscallReadWaiter, bool) { return nil, false }
func (w *vectorisedSyscallReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
w.options = options
return false
}
func (w *vectorisedSyscallReadWaiter) WaitReadBuffers() (buffers []*buf.Buffer, err error) {
return nil, os.ErrInvalid
}
var _ N.PacketReadWaiter = (*syscallPacketReadWaiter)(nil)
type syscallPacketReadWaiter struct {
rawConn syscall.RawConn
buffer *buf.Buffer
options N.ReadWaitOptions
}
func createSyscallPacketReadWaiter(any) (*syscallPacketReadWaiter, bool) { return nil, false }
func (w *syscallPacketReadWaiter) InitializeReadWaiter(options N.ReadWaitOptions) (needCopy bool) {
w.options = options
return false
}
func (w *syscallPacketReadWaiter) WaitReadPacket() (buffer *buf.Buffer, destination M.Socksaddr, err error) {
return nil, M.Socksaddr{}, os.ErrInvalid
}
+32
View File
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Generates the -overlay JSON that swaps the three sing source files that pull in
# golang.org/x/sys/unix (unavailable for GOARCH=wasm) for wasm-safe stubs in
# ./_stubs. Only the js/wasm build uses this overlay; native builds and tests are
# unaffected. The stubs live in an underscore-prefixed directory so the go tool
# ignores them during ./... builds (they carry mixed package names). See
# _stubs/*.go for the rationale of each substitution.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
sing="$(cd "$here/.." && go list -m -f '{{.Dir}}' github.com/sagernet/sing)"
cat <<JSON
{
"Replace": {
"$sing/common/buf/buffer_unix.go": "$here/_stubs/buf_buffer_unix.go",
"$sing/common/bufio/vectorised_unix.go": "$here/_stubs/bufio_vectorised_unix.go",
"$sing/common/bufio/copy_direct_posix.go": "$here/_stubs/bufio_copy_direct_posix.go"
}
}
JSON
+526
View File
@@ -0,0 +1,526 @@
'use strict';
/* ---------------- utilities ---------------- */
const $ = (sel, root = document) => root.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
/* ---------------- wasm engine worker ---------------- */
// The matching engine runs as WebAssembly inside a Web Worker. It is loaded
// lazily on the first Analyze (the wasm is several MB) and reused thereafter.
const engineWorker = (() => {
let worker = null;
let readyPromise = null;
let seq = 0;
const pending = new Map();
function ensure() {
if (readyPromise) return readyPromise;
worker = new Worker('worker.js');
readyPromise = new Promise((resolve, reject) => {
worker.onmessage = (e) => {
const d = e.data || {};
if (d.type === 'ready') { resolve(); return; }
if (d.type === 'loaderror') { reject(new Error(d.error || 'failed to load engine')); return; }
if (d.id != null && pending.has(d.id)) {
const { resolve: res, reject: rej } = pending.get(d.id);
pending.delete(d.id);
if (d.error) rej(new Error(d.error)); else res(d.result);
}
};
worker.onerror = (e) => reject(new Error(e.message || 'engine worker error'));
});
return readyPromise;
}
return {
// Whether the engine is already initialized (used to tailor the spinner text).
get ready() { return readyPromise !== null; },
// Kick off loading without running an analysis.
preload() { return ensure(); },
async analyze(request) {
await ensure();
const id = ++seq;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, request });
});
},
};
})();
const storage = window.singvisStorage;
function toast(msg, kind = '') {
const root = $('#toast-root');
const t = document.createElement('div');
t.className = 'toast ' + kind;
t.textContent = msg;
root.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3600);
}
const store = {
get(k, def) { try { const v = localStorage.getItem('singvis.' + k); return v == null ? def : JSON.parse(v); } catch { return def; } },
set(k, v) { try { localStorage.setItem('singvis.' + k, JSON.stringify(v)); } catch {} },
};
/* ---------------- app state ---------------- */
const state = {
profiles: [],
activeId: null,
settings: { dohServer: 'https://1.1.1.1/dns-query' },
draft: null, // { id?, name, config, inputs, ruleSetFiles }
lastResult: null,
analyzing: false,
};
const SAMPLE_CONFIG = `{
"dns": {
"servers": [
{ "tag": "proxy-dns", "type": "https", "server": "1.1.1.1", "detour": "proxy" },
{ "tag": "local-dns", "type": "udp", "server": "223.5.5.5", "detour": "direct" }
],
"rules": [
{ "rule_set": ["geosite-cn"], "action": "route", "server": "local-dns" },
{ "domain_keyword": ["ads"], "action": "reject" }
],
"final": "proxy-dns"
},
"route": {
"rules": [
{ "domain_suffix": ["google.com", "openai.com"], "outbound": "proxy" },
{ "rule_set": ["geosite-cn"], "outbound": "direct" },
{ "rule_set": ["geoip-cn"], "outbound": "direct" }
],
"rule_set": [
{ "type": "remote", "tag": "geosite-cn", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs" },
{ "type": "remote", "tag": "geoip-cn", "format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs" }
],
"final": "proxy"
}
}`;
/* ---------------- init ---------------- */
async function init() {
$('#btn-settings').onclick = openSettings;
$('#btn-new').onclick = () => newDraft();
try {
state.settings = await storage.getSettings();
} catch (e) { /* keep defaults */ }
await loadProfiles();
if (state.profiles.length) selectProfile(state.profiles[0].id);
else newDraft();
}
async function loadProfiles() {
try { state.profiles = (await storage.listProfiles()) || []; }
catch (e) { state.profiles = []; }
renderSidebar();
}
function renderSidebar() {
const ul = $('#profile-list');
if (!state.profiles.length) {
ul.innerHTML = '<li class="empty-hint">No profiles yet. Create one to get started.</li>';
return;
}
ul.innerHTML = state.profiles.map((p) => `
<li class="profile-item ${p.id === state.activeId ? 'active' : ''}" data-id="${esc(p.id)}">
<span class="name">${esc(p.name || 'Untitled')}</span>
<span class="meta">updated ${new Date(p.updatedAt).toLocaleString()}</span>
</li>`).join('');
ul.querySelectorAll('.profile-item').forEach((li) => {
li.onclick = () => selectProfile(li.dataset.id);
});
}
async function selectProfile(id) {
try {
const p = await storage.getProfile(id);
state.activeId = id;
state.draft = { id: p.id, name: p.name || '', config: p.config || '', inputs: p.inputs || '', ruleSetFiles: p.ruleSetFiles || {} };
state.lastResult = null;
renderSidebar();
renderEditor();
} catch (e) { toast('Failed to load profile: ' + e.message, 'err'); }
}
function newDraft() {
state.activeId = null;
state.draft = { name: '', config: SAMPLE_CONFIG, inputs: 'www.google.com\nbaidu.com\nopenai.com\n1.1.1.1', ruleSetFiles: {} };
state.lastResult = null;
renderSidebar();
renderEditor();
}
/* ---------------- editor ---------------- */
function renderEditor() {
const d = state.draft;
const network = store.get('network', '');
const assume = store.get('assumeResolved', true);
const files = Object.entries(d.ruleSetFiles || {});
$('#content').innerHTML = `
<div class="card">
<div class="card-head">
<h2>${state.activeId ? 'Edit profile' : 'New profile'}</h2>
<div class="row" style="gap:8px">
<button class="btn small" id="btn-save">💾 Save</button>
${state.activeId ? '<button class="btn small danger" id="btn-delete">Delete</button>' : ''}
</div>
</div>
<div class="card-body">
<div class="field">
<label class="lbl">Profile name</label>
<input type="text" id="f-name" placeholder="My profile" value="${esc(d.name)}" />
</div>
<div class="field">
<label class="lbl">sing-box configuration <span class="hint">JSON (comments allowed)</span></label>
<textarea id="f-config" class="code" spellcheck="false" placeholder="Paste your sing-box config…">${esc(d.config)}</textarea>
</div>
<div class="field">
<label class="lbl">Local rule-set files <span class="hint">only needed for type:"local" rule sets — key by tag or path</span></label>
<input type="file" id="f-files" multiple />
<div class="file-list" id="file-list">
${files.map(([k, f]) => `
<div class="file-row" data-key="${esc(k)}">
<span class="fname">${esc(k)}</span>
<span class="fmeta">${esc(f.format)} · ${f.data ? (f.format === 'binary' ? Math.round(f.data.length * 0.75) : f.data.length) : 0} bytes</span>
<button class="btn small danger rm-file" data-key="${esc(k)}" style="margin-left:auto">remove</button>
</div>`).join('')}
</div>
</div>
<div class="field">
<label class="lbl">Domains / IPs to check <span class="hint">one per line — domains or raw IPs; # comments ignored</span></label>
<textarea id="f-inputs" class="inputs" spellcheck="false" placeholder="example.com&#10;1.1.1.1">${esc(d.inputs)}</textarea>
</div>
</div>
</div>
<div class="toolbar">
<button class="btn primary" id="btn-analyze">▶ Analyze</button>
<label class="check" title="Pre-resolve domains via DoH so ip_cidr / IP rule-set rules can match the resolved address">
<input type="checkbox" id="opt-assume" ${assume ? 'checked' : ''}/> Resolve IPs for IP rules
</label>
<div class="field" style="max-width:150px;margin:0">
<select id="opt-network" title="Assumed connection network for rules that filter on tcp/udp">
<option value="" ${network === '' ? 'selected' : ''}>network: any</option>
<option value="tcp" ${network === 'tcp' ? 'selected' : ''}>network: tcp</option>
<option value="udp" ${network === 'udp' ? 'selected' : ''}>network: udp</option>
</select>
</div>
<span class="spacer"></span>
<span class="muted mono" id="doh-indicator">DoH: ${esc(state.settings.dohServer || '—')}</span>
</div>
<div id="results"></div>
`;
$('#btn-save').onclick = saveProfile;
const del = $('#btn-delete'); if (del) del.onclick = deleteProfile;
$('#btn-analyze').onclick = analyze;
$('#f-files').onchange = handleFiles;
$('#opt-assume').onchange = (e) => store.set('assumeResolved', e.target.checked);
$('#opt-network').onchange = (e) => store.set('network', e.target.value);
$('#file-list').querySelectorAll('.rm-file').forEach((b) => {
b.onclick = () => { delete state.draft.ruleSetFiles[b.dataset.key]; syncDraftFromForm(); renderEditor(); };
});
if (state.lastResult) renderResults(state.lastResult);
}
function syncDraftFromForm() {
const d = state.draft;
const n = $('#f-name'); if (n) d.name = n.value;
const c = $('#f-config'); if (c) d.config = c.value;
const i = $('#f-inputs'); if (i) d.inputs = i.value;
}
function handleFiles(e) {
const files = Array.from(e.target.files || []);
syncDraftFromForm();
let pending = files.length;
if (!pending) return;
files.forEach((file) => {
const isBinary = /\.srs$/i.test(file.name);
const reader = new FileReader();
reader.onload = () => {
let data, format;
if (isBinary) {
const bytes = new Uint8Array(reader.result);
let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
data = btoa(bin); format = 'binary';
} else { data = reader.result; format = 'source'; }
state.draft.ruleSetFiles[file.name] = { format, data };
if (--pending === 0) renderEditor();
};
if (isBinary) reader.readAsArrayBuffer(file); else reader.readAsText(file);
});
}
async function saveProfile() {
syncDraftFromForm();
const d = state.draft;
if (!d.name.trim()) { toast('Please enter a profile name', 'err'); return; }
const payload = { name: d.name.trim(), config: d.config, inputs: d.inputs, ruleSetFiles: d.ruleSetFiles };
if (state.activeId) payload.id = state.activeId;
try {
const saved = await storage.saveProfile(payload);
state.activeId = saved.id;
await loadProfiles();
await selectProfile(saved.id);
toast('Profile saved', 'ok');
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
}
async function deleteProfile() {
if (!state.activeId) return;
if (!confirm('Delete this profile?')) return;
try {
await storage.deleteProfile(state.activeId);
await loadProfiles();
if (state.profiles.length) selectProfile(state.profiles[0].id); else newDraft();
toast('Profile deleted', 'ok');
} catch (e) { toast('Delete failed: ' + e.message, 'err'); }
}
/* ---------------- analyze ---------------- */
async function analyze() {
syncDraftFromForm();
const d = state.draft;
const inputs = d.inputs.split('\n').map((s) => s.trim()).filter(Boolean);
if (!inputs.length) { toast('Add at least one domain or IP', 'err'); return; }
const btn = $('#btn-analyze');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Analyzing…';
const loadingEngine = !engineWorker.ready;
$('#results').innerHTML = `<div class="placeholder"><span class="spinner"></span> ${loadingEngine ? 'Loading engine (first run, ~a few MB)…' : 'Resolving &amp; matching…'}</div>`;
try {
const result = await engineWorker.analyze({
config: d.config,
inputs,
ruleSetFiles: d.ruleSetFiles,
dohServer: state.settings.dohServer,
network: store.get('network', ''),
assumeResolved: store.get('assumeResolved', true),
});
state.lastResult = result;
renderResults(result);
} catch (e) {
$('#results').innerHTML = `<div class="card"><div class="card-body" style="color:var(--reject)">⚠ ${esc(e.message)}</div></div>`;
} finally {
btn.disabled = false; btn.innerHTML = '▶ Analyze';
}
}
/* ---------------- results rendering ---------------- */
function statusBadge(status) {
const label = { match: 'MATCH', no_match: 'no match', unknown: 'UNKNOWN?' }[status] || status;
return `<span class="badge ${status}">${label}</span>`;
}
function renderResults(result) {
const root = $('#results');
if (!result.inputs || !result.inputs.length) { root.innerHTML = '<div class="placeholder">No inputs.</div>'; return; }
const warn = (result.warnings && result.warnings.length)
? `<div class="assume-warn">⚠ ${result.warnings.map(esc).join('<br>')}</div>` : '';
root.innerHTML = warn + result.inputs.map((it, i) => renderInputCard(it, i)).join('');
root.querySelectorAll('.result-head').forEach((h) => {
h.onclick = () => h.closest('.result-card').classList.toggle('open');
});
root.querySelectorAll('.step-head').forEach((h) => {
h.onclick = (e) => { e.stopPropagation(); h.closest('.step').classList.toggle('expanded'); };
});
}
function renderInputCard(it, idx) {
const chips = [];
if (it.kind === 'invalid') {
chips.push(`<span class="chip reject"><span class="k">error</span><span class="v">${esc(it.error || 'invalid')}</span></span>`);
} else {
if (it.dns && it.dns.decision) chips.push(dnsChip(it.dns.decision));
if (it.route && it.route.decision) chips.push(routeChip(it.route.decision));
}
const open = idx === 0 ? 'open' : '';
return `
<div class="card result-card ${open}">
<div class="result-head">
<span class="input-name">${esc(it.input)}</span>
<span class="badge kind-${esc(it.kind)}">${esc(it.kind)}</span>
<div class="outcome-chips">${chips.join('')}</div>
<span class="caret">▶</span>
</div>
<div class="result-body">
${it.kind === 'invalid' ? `<p class="muted">${esc(it.error || 'Could not parse this input.')}</p>` : ''}
${renderResolved(it.resolved)}
${it.dns ? `<div class="section-title">DNS routing (which server / dns action)</div>${renderTrace(it.dns, 'dns')}` : ''}
${it.route ? `<div class="section-title">Route matching (which rule / outbound)</div>${renderTrace(it.route, 'route')}` : ''}
</div>
</div>`;
}
function dnsChip(dec) {
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">DNS</span><span class="v">reject</span></span>`;
const server = dec.server || (dec.actionType);
const detour = dec.serverInfo && dec.serverInfo.detour ? ` <span class="k">via</span> ${esc(dec.serverInfo.detour)}` : '';
return `<span class="chip dns"><span class="k">DNS</span><span class="v">${esc(server)}</span>${detour}</span>`;
}
function routeChip(dec) {
if (dec.actionType === 'reject') return `<span class="chip reject"><span class="k">route</span><span class="v">reject</span></span>`;
if (dec.actionType === 'hijack-dns') return `<span class="chip route"><span class="k">route</span><span class="v">hijack-dns</span></span>`;
return `<span class="chip route"><span class="k">route</span><span class="v">${esc(dec.outbound || '(default)')}</span></span>`;
}
function renderResolved(r) {
if (!r) return '';
if (r.error && !(r.ipv4 && r.ipv4.length) && !(r.ipv6 && r.ipv6.length)) {
return `<div class="section-title">Resolved (DoH)</div><p class="muted">resolution failed: ${esc(r.error)}</p>`;
}
const v4 = (r.ipv4 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
const v6 = (r.ipv6 || []).map((ip) => `<span class="ip-chip">${esc(ip)}</span>`).join('');
if (!v4 && !v6) return `<div class="section-title">Resolved (DoH)</div><p class="muted">no A/AAAA records</p>`;
return `<div class="section-title">Resolved via ${esc(r.server)}</div><div class="ip-chips">${v4}${v6}</div>`;
}
function renderTrace(trace, kind) {
const steps = (trace.steps || []).map((s) => renderStep(s, kind, trace)).join('');
const stepsHtml = steps ? `<div class="steps">${steps}</div>` : `<p class="muted">No ${kind === 'dns' ? 'DNS' : 'route'} rules — the final is used directly.</p>`;
const note = trace.note ? `<p class="muted" style="margin-top:6px">${esc(trace.note)}</p>` : '';
return stepsHtml + note + renderDecision(trace.decision, kind);
}
function renderStep(s, kind, trace) {
const selected = (kind === 'route' && trace.selectedIndex === s.index) || (kind === 'dns' && trace.matchedIndex === s.index);
const cls = ['step', 's-' + s.status];
if (selected) cls.push('s-selected');
if (s.status === 'no_match') cls.push('dimmed');
const conds = s.type === 'logical' ? renderLogical(s) : (s.conditions || []).map(renderCond).join('');
const effect = s.effect ? `<div class="effect">↳ ${esc(s.effect)}</div>` : '';
const detailInner = conds || '<span class="muted">no conditions (matches all)</span>';
return `
<div class="step ${cls.join(' ')}">
<div class="step-head">
<span class="idx">${s.index}</span>
${statusBadge(s.status)}
<span class="summary" title="${esc(s.summary)}">${esc(s.summary)}</span>
<span class="action-text">${esc(s.actionText || '')}</span>
</div>
${effect}
<div class="step-detail">${detailInner}</div>
</div>`;
}
function renderLogical(s) {
const subs = (s.sub || []).map((sub) => {
const inner = sub.type === 'logical' ? renderLogical(sub) : (sub.conditions || []).map(renderCond).join('');
return `<div class="ruleset-box"><div class="rs-head">${statusBadge(sub.status)} <span class="mono">${esc(sub.summary)}</span></div>${inner}</div>`;
}).join('');
return `<div class="muted" style="margin-bottom:6px">logical <b>${esc(s.mode || 'and')}</b>${s.invert ? ' (inverted)' : ''}:</div>${subs}`;
}
function renderCond(c) {
const matched = c.matched ? `<span class="matched-val">✓ ${esc(c.matched)}</span>` : '';
const note = c.note ? `<div class="cnote">${esc(c.note)}</div>` : '';
const rs = c.ruleSet ? renderRuleSet(c.ruleSet) : '';
return `
<div class="cond">
<span class="cf">${esc(c.field)} ${statusBadge(c.status)}</span>
<span class="group-tag">${esc(c.group)}</span>
<span class="cv">${esc(c.value)} ${matched}</span>
${note}
</div>${rs}`;
}
function renderRuleSet(rs) {
const inner = (rs.rules || []).map((r) => {
const cs = r.type === 'logical' ? renderLogical(r) : (r.conditions || []).map(renderCond).join('');
return `<div style="margin-top:6px">${statusBadge(r.status)} <span class="mono">${esc(r.summary)}</span>${cs}</div>`;
}).join('');
const err = rs.error ? `<div class="rs-err">⚠ ${esc(rs.error)}</div>` : '';
const matched = rs.matchedIdx >= 0 ? ` · matched rule #${rs.matchedIdx}` : '';
return `
<div class="ruleset-box">
<div class="rs-head">${statusBadge(rs.status)} <b>rule_set</b> <span class="mono">${esc(rs.tag)}</span>
<span class="rs-meta">(${esc(rs.type || '?')} · ${rs.count || 0} rules${matched})</span>
</div>
${err}${inner}
</div>`;
}
function renderDecision(dec, kind) {
if (!dec) return '';
const assumed = dec.assumed ? `<span class="badge unknown" title="An earlier rule with undeterminable conditions (e.g. protocol/port) could change this outcome">depends on assumptions</span>` : '';
const fromFinal = dec.fromFinal ? `<span class="badge final">via ${kind === 'dns' ? 'dns.final' : 'route.final'}</span>` : '';
if (kind === 'dns') {
const cls = dec.actionType === 'reject' ? 'reject' : 'route';
let value, sub = '';
if (dec.actionType === 'reject') { value = 'reject'; }
else if (dec.actionType === 'predefined' || dec.actionType === 'respond') { value = dec.actionType; }
else {
value = dec.server || '(default)';
if (dec.serverInfo) {
const si = dec.serverInfo;
sub = `<span class="d-sub">${esc(si.type || '')}${si.address ? ' · ' + esc(si.address) : ''}${si.detour ? ' · via outbound <b>' + esc(si.detour) + '</b>' : ''}</span>`;
}
}
return `<div class="decision ${cls}">
<span class="d-label">DNS action</span>
<span class="d-value">${esc(dec.actionType)}</span><span class="arrow">→</span>
<span class="d-value">${esc(value)}</span>${sub} ${fromFinal} ${assumed}
</div>`;
}
// route
let cls = 'route', value;
if (dec.actionType === 'reject') { cls = 'reject'; value = 'reject'; }
else if (dec.actionType === 'hijack-dns') { value = 'hijack-dns'; }
else { value = dec.outbound || '(default outbound)'; }
return `<div class="decision ${cls}">
<span class="d-label">Final outbound</span>
<span class="d-value">${esc(value)}</span>
${dec.detail && dec.actionType === 'reject' ? `<span class="d-sub">${esc(dec.detail)}</span>` : ''}
${fromFinal} ${assumed}
</div>`;
}
/* ---------------- settings ---------------- */
function openSettings() {
const root = $('#modal-root');
root.innerHTML = `
<div class="modal-backdrop" id="settings-backdrop">
<div class="modal">
<div class="card-head"><h2>Settings</h2><button class="btn ghost small" id="s-close">✕</button></div>
<div class="card-body">
<div class="field">
<label class="lbl">HTTPS DNS (DoH) endpoint <span class="hint">DoH JSON API · must allow CORS</span></label>
<input type="url" id="s-doh" value="${esc(state.settings.dohServer || '')}" placeholder="https://1.1.1.1/dns-query" />
</div>
<div class="field">
<label class="lbl">Quick presets</label>
<div class="row" style="gap:6px">
${['https://1.1.1.1/dns-query', 'https://dns.google/dns-query', 'https://dns.quad9.net/dns-query', 'https://dns.alidns.com/dns-query']
.map((u) => `<button class="btn small preset" data-url="${esc(u)}">${esc(u.replace('https://', '').replace('/dns-query', ''))}</button>`).join('')}
</div>
</div>
<div class="row" style="justify-content:flex-end;margin-top:6px">
<button class="btn primary" id="s-save">Save</button>
</div>
</div>
</div>
</div>`;
const close = () => { root.innerHTML = ''; };
$('#s-close').onclick = close;
$('#settings-backdrop').onclick = (e) => { if (e.target.id === 'settings-backdrop') close(); };
root.querySelectorAll('.preset').forEach((b) => b.onclick = () => { $('#s-doh').value = b.dataset.url; });
$('#s-save').onclick = async () => {
const dohServer = $('#s-doh').value.trim() || 'https://1.1.1.1/dns-query';
try {
state.settings = await storage.saveSettings({ dohServer });
const ind = $('#doh-indicator'); if (ind) ind.textContent = 'DoH: ' + state.settings.dohServer;
close(); toast('Settings saved', 'ok');
} catch (e) { toast('Save failed: ' + e.message, 'err'); }
};
}
init();
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>sing-vis · sing-box route explainer</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%97%87%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo"></span>
<div>
<div class="title">sing-vis</div>
<div class="subtitle">sing-box DNS &amp; route rule explainer</div>
</div>
</div>
<div class="topbar-actions">
<button id="btn-settings" class="btn ghost" title="Settings">⚙ Settings</button>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<div class="sidebar-head">
<span>Profiles</span>
<button id="btn-new" class="btn small">+ New</button>
</div>
<ul id="profile-list" class="profile-list"></ul>
</aside>
<section class="content" id="content"></section>
</main>
<div id="modal-root"></div>
<div id="toast-root" class="toast-root"></div>
<script src="storage.js"></script>
<script src="app.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
'use strict';
// IndexedDB-backed persistence for profiles and settings — the browser-side
// replacement for the old file store. IndexedDB (not localStorage) is used
// because uploaded .srs rule-set files can exceed localStorage's ~5MB limit.
//
// Shapes match the former Go store exactly:
// Profile { id, name, config, inputs, createdAt, updatedAt, ruleSetFiles }
// Settings { dohServer }
//
// Exposed as window.singvisStorage (app.js is a classic script).
(function () {
const DB_NAME = 'singvis';
const DB_VERSION = 1;
const STORE_PROFILES = 'profiles';
const STORE_META = 'meta';
const SETTINGS_KEY = 'settings';
const DEFAULT_SETTINGS = { dohServer: 'https://1.1.1.1/dns-query' };
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_PROFILES)) {
db.createObjectStore(STORE_PROFILES, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('failed to open IndexedDB'));
});
return dbPromise;
}
// tx runs fn(store) inside a transaction and resolves with `out` (a value fn
// can set) once the transaction completes.
async function tx(storeName, mode, fn) {
const db = await openDB();
return new Promise((resolve, reject) => {
const t = db.transaction(storeName, mode);
const store = t.objectStore(storeName);
let out;
const ret = (v) => { out = v; };
Promise.resolve(fn(store, ret)).catch(reject);
t.oncomplete = () => resolve(out);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error || new Error('transaction aborted'));
});
}
function reqPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function newId() {
// Millisecond timestamp plus a short random suffix to avoid collisions when
// several profiles are created within the same millisecond.
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const api = {
async listProfiles() {
const list = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.getAll()));
});
const out = list || [];
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return out;
},
async getProfile(id) {
const p = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(id)));
});
if (!p) throw new Error('profile not found');
return p;
},
// saveProfile creates (when profile.id is empty) or updates a profile,
// assigning id/createdAt/updatedAt the same way the old server did.
async saveProfile(profile) {
const now = Date.now();
const p = Object.assign({}, profile);
if (!p.id) {
p.id = newId();
p.createdAt = now;
}
if (!p.createdAt) p.createdAt = now;
p.updatedAt = now;
p.ruleSetFiles = p.ruleSetFiles || {};
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.put(p);
});
return p;
},
async deleteProfile(id) {
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.delete(id);
});
},
async getSettings() {
const s = await tx(STORE_META, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(SETTINGS_KEY)));
});
const merged = Object.assign({}, DEFAULT_SETTINGS, s || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
return merged;
},
async saveSettings(settings) {
const merged = Object.assign({}, DEFAULT_SETTINGS, settings || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
await tx(STORE_META, 'readwrite', (store) => {
store.put(merged, SETTINGS_KEY);
});
return merged;
},
defaultSettings() {
return Object.assign({}, DEFAULT_SETTINGS);
},
};
window.singvisStorage = api;
})();
+282
View File
@@ -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; }
+58
View File
@@ -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) });
}
};