feat(web): visual rule builder with geo, CIDR and wildcard pickers
The rule editor no longer requires writing expr by hand. Conditions are rows of field + operator + values joined with AND or OR, each row negatable, and the generated expression is shown live and validated by the engine before a rule is accepted. Raw expression and YAML editing are still available. Fields cover the connection (protocol, source/destination IP and port), domains (TLS SNI, QUIC SNI, DNS query name), HTTP (host, path, method, User-Agent) and protocol detection. Operators cover CIDR membership, GeoIP countries, GeoSite categories, port ranges, wildcards, substrings and regular expressions. Multiple values in a row are OR-ed, so one row holds a whole domain or country list. Wildcards compile to the cheapest expression that matches them: *.x.com becomes endsWith, x.* startsWith, *ad* contains, and only a star in the middle falls back to a regular expression. Values are validated as they are typed, including a hint when a star is used with an operator that would match it literally. Country and category pickers are backed by the databases the engine actually loaded, via a new GET /api/v1/geo endpoint (cached, loaded on demand) built on new listing methods in the geo package. Country names and flags come from Intl.DisplayNames, so no name table is shipped. Note that the v2geo format has no AS numbers; the provider groups it does contain (cloudflare, google, telegram, ...) are listed alongside the countries. Opening an existing rule parses its expression back into conditions. Anything the builder cannot represent opens in the expression editor with a warning rather than being rewritten. ruleset/expr_test.go pins the canonical expressions the builder generates and compiles them with the real engine, and the devserver now uses the real ruleset compiler so the same errors show up during frontend work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -209,6 +209,10 @@ $RECYCLE.BIN/
|
|||||||
# Internal tools not ready for public use yet
|
# Internal tools not ready for public use yet
|
||||||
tools/flowseq/
|
tools/flowseq/
|
||||||
|
|
||||||
|
### Geo databases downloaded at runtime ###
|
||||||
|
geoip.dat
|
||||||
|
geosite.dat
|
||||||
|
|
||||||
### Web UI ###
|
### Web UI ###
|
||||||
# The built UI in web/dist IS committed, since it is embedded into the binary.
|
# The built UI in web/dist IS committed, since it is embedded into the binary.
|
||||||
web/frontend/node_modules/
|
web/frontend/node_modules/
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ OpenGFW にはオプションのウェブダッシュボードが同梱されて
|
|||||||
アナライザのプロパティを含むイベントフィード、そしてルールを検証してホットリロードできるルールエディタが利用できます。
|
アナライザのプロパティを含むイベントフィード、そしてルールを検証してホットリロードできるルールエディタが利用できます。
|
||||||
スマートフォンでもデスクトップでも使えるレスポンシブ対応です。
|
スマートフォンでもデスクトップでも使えるレスポンシブ対応です。
|
||||||
|
|
||||||
|
ルールは条件のリストとして作成できます。フィールド(SNI、DNS 名、HTTP Host、IP、ポート、検出されたプロトコル)と
|
||||||
|
条件(ワイルドカード、CIDR、GeoIP の国、GeoSite カテゴリ、範囲、正規表現)、そして 1 つ以上の値を選ぶだけです。
|
||||||
|
もちろん expr 式や YAML を直接編集することもできます。
|
||||||
|
|
||||||
設定ファイルで有効にします:
|
設定ファイルで有効にします:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ OpenGFW ships with an optional web dashboard: live traffic statistics, a real-ti
|
|||||||
feed with per-stream analyzer properties, and a rule editor that validates and hot reloads
|
feed with per-stream analyzer properties, and a rule editor that validates and hot reloads
|
||||||
the ruleset. It works on phones as well as on desktops.
|
the ruleset. It works on phones as well as on desktops.
|
||||||
|
|
||||||
|
Rules can be written as a list of conditions — pick a field (SNI, DNS name, HTTP host,
|
||||||
|
IP, port, detected protocol), an operator (wildcard, CIDR, GeoIP country, GeoSite
|
||||||
|
category, range, regex) and one or more values — or as raw expr expressions and YAML.
|
||||||
|
Countries and site categories are picked from the geo databases the engine loaded.
|
||||||
|
|
||||||
Enable it in your config file:
|
Enable it in your config file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Telegram 群组: https://t.me/OpGFW
|
|||||||
OpenGFW 内置了一个可选的 Web 控制面板:实时流量统计、带解析器属性的实时事件流,以及可以校验并热加载规则的规则编辑器。
|
OpenGFW 内置了一个可选的 Web 控制面板:实时流量统计、带解析器属性的实时事件流,以及可以校验并热加载规则的规则编辑器。
|
||||||
界面同时适配手机和桌面浏览器。
|
界面同时适配手机和桌面浏览器。
|
||||||
|
|
||||||
|
规则既可以用条件列表来写——选择字段(SNI、DNS 域名、HTTP Host、IP、端口、识别到的协议)、
|
||||||
|
条件(通配符、CIDR、GeoIP 国家/地区、GeoSite 分类、端口区间、正则)以及一个或多个值——
|
||||||
|
也可以直接编写 expr 表达式或 YAML。国家/地区与站点分类直接从引擎加载的 geo 数据库中选择。
|
||||||
|
|
||||||
在配置文件中启用:
|
在配置文件中启用:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+29
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/apernet/OpenGFW/engine"
|
"github.com/apernet/OpenGFW/engine"
|
||||||
"github.com/apernet/OpenGFW/modifier"
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
"github.com/apernet/OpenGFW/ruleset"
|
"github.com/apernet/OpenGFW/ruleset"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
|
||||||
"github.com/apernet/OpenGFW/web"
|
"github.com/apernet/OpenGFW/web"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -197,6 +198,7 @@ func startWebServer(ctx context.Context, config *cliConfig, rm *ruleManager) err
|
|||||||
Rules: rm,
|
Rules: rm,
|
||||||
Meta: webMeta(),
|
Meta: webMeta(),
|
||||||
Info: func() web.Info { return webInfo(config, rm) },
|
Info: func() web.Info { return webInfo(config, rm) },
|
||||||
|
Geo: func() web.GeoData { return webGeoData(config) },
|
||||||
Logf: func(format string, args ...interface{}) {
|
Logf: func(format string, args ...interface{}) {
|
||||||
logger.Info(fmt.Sprintf(format, args...))
|
logger.Info(fmt.Sprintf(format, args...))
|
||||||
},
|
},
|
||||||
@@ -266,6 +268,33 @@ func webInfo(config *cliConfig, rm *ruleManager) web.Info {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// webGeoData lists what the configured geo databases contain, so that the rule
|
||||||
|
// builder can offer a picker instead of a free text field. Loading the files is
|
||||||
|
// expensive, but the web server only calls this on demand and caches the result.
|
||||||
|
func webGeoData(config *cliConfig) web.GeoData {
|
||||||
|
matcher := geo.NewGeoMatcher(config.Ruleset.GeoSite, config.Ruleset.GeoIp)
|
||||||
|
var data web.GeoData
|
||||||
|
if entries, err := matcher.ListGeoIP(); err != nil {
|
||||||
|
data.IPError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries, err := matcher.ListGeoSite(); err != nil {
|
||||||
|
data.SiteError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.Site = append(data.Site, web.GeoEntry{
|
||||||
|
Code: e.Code,
|
||||||
|
Count: e.Domains,
|
||||||
|
Attributes: e.Attributes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// hubInfo converts engine stream info into the shape the hub understands.
|
// hubInfo converts engine stream info into the shape the hub understands.
|
||||||
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
|
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
|
||||||
return web.StreamInfo{
|
return web.StreamInfo{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package geo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -97,6 +98,69 @@ func (g *GeoMatcher) LoadGeoSite() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeoIPEntry describes one entry of a GeoIP database.
|
||||||
|
type GeoIPEntry struct {
|
||||||
|
// Code is the lowercase key to pass to geoip(), usually a country code.
|
||||||
|
Code string
|
||||||
|
// CIDRs is the number of networks the entry covers.
|
||||||
|
CIDRs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoSiteEntry describes one entry of a GeoSite database.
|
||||||
|
type GeoSiteEntry struct {
|
||||||
|
// Code is the lowercase key to pass to geosite().
|
||||||
|
Code string
|
||||||
|
// Attributes are the suffixes usable as `code@attribute`.
|
||||||
|
Attributes []string
|
||||||
|
// Domains is the number of domain rules the entry covers.
|
||||||
|
Domains int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGeoIP returns every entry of the GeoIP database, sorted by code.
|
||||||
|
// It is meant for UIs that let the user pick a country instead of typing one.
|
||||||
|
func (g *GeoMatcher) ListGeoIP() ([]GeoIPEntry, error) {
|
||||||
|
gMap, err := g.geoLoader.LoadGeoIP()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := make([]GeoIPEntry, 0, len(gMap))
|
||||||
|
for code, list := range gMap {
|
||||||
|
entries = append(entries, GeoIPEntry{Code: code, CIDRs: len(list.GetCidr())})
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGeoSite returns every entry of the GeoSite database, sorted by code,
|
||||||
|
// including the attributes each entry supports.
|
||||||
|
func (g *GeoMatcher) ListGeoSite() ([]GeoSiteEntry, error) {
|
||||||
|
gMap, err := g.geoLoader.LoadGeoSite()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := make([]GeoSiteEntry, 0, len(gMap))
|
||||||
|
for code, list := range gMap {
|
||||||
|
attrSet := make(map[string]struct{})
|
||||||
|
for _, domain := range list.GetDomain() {
|
||||||
|
for _, attr := range domain.GetAttribute() {
|
||||||
|
attrSet[strings.ToLower(attr.GetKey())] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs := make([]string, 0, len(attrSet))
|
||||||
|
for attr := range attrSet {
|
||||||
|
attrs = append(attrs, attr)
|
||||||
|
}
|
||||||
|
sort.Strings(attrs)
|
||||||
|
entries = append(entries, GeoSiteEntry{
|
||||||
|
Code: code,
|
||||||
|
Attributes: attrs,
|
||||||
|
Domains: len(list.GetDomain()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *GeoMatcher) LoadGeoIP() error {
|
func (g *GeoMatcher) LoadGeoIP() error {
|
||||||
_, err := g.geoLoader.LoadGeoIP()
|
_, err := g.geoLoader.LoadGeoIP()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ruleset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/apernet/OpenGFW/analyzer"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/tcp"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/udp"
|
||||||
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
|
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// builderExpressions are the canonical expressions produced by the visual rule
|
||||||
|
// builder of the web UI (see web/frontend/src/lib/rule/compile.ts). They are
|
||||||
|
// pinned here so that a change to the expression language, the analyzers or the
|
||||||
|
// built-in functions cannot silently break the builder.
|
||||||
|
var builderExpressions = []string{
|
||||||
|
// Domain or subdomain, single and multiple values
|
||||||
|
`(string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com")`,
|
||||||
|
`((string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com") || (string(tls?.req?.sni) == "evil.test" || string(tls?.req?.sni) endsWith ".evil.test")) && proto == "tcp"`,
|
||||||
|
// CIDR and GeoIP, including negation
|
||||||
|
`(cidr(ip.dst, "10.0.0.0/8") || cidr(ip.dst, "fd00::/8")) || !(geoip(ip.dst, "cn") || geoip(ip.dst, "hk"))`,
|
||||||
|
// Port equality and ranges
|
||||||
|
`(port.dst >= 1000 && port.dst <= 2000) && (port.src == 80 || port.src == 443)`,
|
||||||
|
// Wildcards over DNS questions
|
||||||
|
`any(dns?.questions ?? [], {(string(.name) endsWith ".ads.com" || string(.name) startsWith "x.")})`,
|
||||||
|
// GeoSite over DNS questions
|
||||||
|
`any(dns?.questions ?? [], {geosite(string(.name), "category-ads-all")})`,
|
||||||
|
// HTTP fields: negation, regular expressions and header lookups
|
||||||
|
`!(string(http?.req?.headers?.host) contains "tracker") && string(http?.req?.path) matches "^/api/v\\d+/" && string(get(http?.req?.headers, "user-agent")) startsWith "curl"`,
|
||||||
|
// Protocol detection
|
||||||
|
`(ssh != nil || trojan != nil)`,
|
||||||
|
// Wildcard edge cases: "any value" and a star in the middle
|
||||||
|
`string(quic?.req?.sni) != "" && string(tls?.req?.sni) matches "^www\\..*\\.com$" && ip.src == "1.2.3.4" && string(http?.req?.method) == "POST"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileBuilderExpressions(t *testing.T) {
|
||||||
|
analyzers := []analyzer.Analyzer{
|
||||||
|
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
|
||||||
|
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
|
||||||
|
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
|
||||||
|
}
|
||||||
|
modifiers := []modifier.Modifier{&modUDP.DNSModifier{}}
|
||||||
|
config := &BuiltinConfig{
|
||||||
|
Logger: nopLogger{},
|
||||||
|
// Point at a file that does not exist: expressions still have to
|
||||||
|
// compile, only loading the database is expected to fail.
|
||||||
|
GeoSiteFilename: "testdata/missing-geosite.dat",
|
||||||
|
GeoIpFilename: "testdata/missing-geoip.dat",
|
||||||
|
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expr := range builderExpressions {
|
||||||
|
rules := []ExprRule{{Name: "test", Action: "block", Expr: expr}}
|
||||||
|
_, err := CompileExprRules(rules, analyzers, modifiers, config)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// geoip()/geosite() need a database, which this test does not ship.
|
||||||
|
// Reaching the initialization step means the expression itself is fine.
|
||||||
|
if strings.Contains(err.Error(), "failed to initialize function") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Errorf("expression failed to compile: %s\n %v", expr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopLogger struct{}
|
||||||
|
|
||||||
|
func (nopLogger) Log(StreamInfo, string) {}
|
||||||
|
func (nopLogger) MatchError(StreamInfo, string, error) {}
|
||||||
+47
-2
@@ -8,8 +8,9 @@ the OpenGFW binary and served by the `web` package.
|
|||||||
blocked destinations and triggered rules
|
blocked destinations and triggered rules
|
||||||
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
|
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
|
||||||
detail view showing raw analyzer properties
|
detail view showing raw analyzer properties
|
||||||
- **Rules** — visual rule editor and a raw YAML editor, both validated by the engine
|
- **Rules** — a visual condition builder, a raw expression editor and a YAML editor,
|
||||||
itself; saving writes the rule file and hot reloads the running engine
|
all validated by the engine itself; saving writes the rule file and hot reloads the
|
||||||
|
running engine
|
||||||
- **Analyzers** — which analyzers are compiled in and how much traffic each one saw
|
- **Analyzers** — which analyzers are compiled in and how much traffic each one saw
|
||||||
- **Settings** — theme (light/dark/system), language (English/中文) and instance info
|
- **Settings** — theme (light/dark/system), language (English/中文) and instance info
|
||||||
|
|
||||||
@@ -30,6 +31,49 @@ web:
|
|||||||
|
|
||||||
If `secret` is empty a random password is generated and printed to the log on startup.
|
If `secret` is empty a random password is generated and printed to the log on startup.
|
||||||
|
|
||||||
|
## Rule builder
|
||||||
|
|
||||||
|
Rules are still plain expr expressions in the rule file; the builder is only a way to
|
||||||
|
write them without memorising the syntax. Conditions are rows of *field + operator +
|
||||||
|
values*, joined with AND or OR, each row negatable:
|
||||||
|
|
||||||
|
| Field group | Fields |
|
||||||
|
| ----------- | --------------------------------------------------------------- |
|
||||||
|
| Connection | transport protocol, source/destination IP, source/destination port |
|
||||||
|
| Domain | TLS SNI, QUIC SNI, DNS query name |
|
||||||
|
| HTTP | Host, path, method, User-Agent |
|
||||||
|
| Protocol | detected protocol (any analyzer) |
|
||||||
|
|
||||||
|
Operators cover the things rules usually need:
|
||||||
|
|
||||||
|
| Operator | Generated expression |
|
||||||
|
| ----------------------- | ---------------------------------------------------------- |
|
||||||
|
| domain or subdomain of | `(S == "x.com" \|\| S endsWith ".x.com")` |
|
||||||
|
| matches wildcard | `*.x.com` → `endsWith`, `x.*` → `startsWith`, `*ad*` → `contains`, `a.*.c` → `matches` |
|
||||||
|
| in CIDR | `cidr(ip.dst, "10.0.0.0/8")`, validated as you type |
|
||||||
|
| in GeoIP country | `geoip(ip.dst, "cn")`, picked from the loaded database |
|
||||||
|
| in GeoSite category | `geosite(string(.name), "category-ads-all@cn")` |
|
||||||
|
| in range | `(port.dst >= 1000 && port.dst <= 2000)` |
|
||||||
|
| is / contains / starts / ends / regex | the matching expr operator |
|
||||||
|
|
||||||
|
Multiple values in one row are OR-ed together, so one row can hold a whole domain or
|
||||||
|
country list. The generated expression is shown live and validated by the engine before
|
||||||
|
the rule is accepted.
|
||||||
|
|
||||||
|
Opening an existing rule parses its expression back into conditions. Anything the
|
||||||
|
builder cannot represent — hand written expressions, functions like `lookup()` — opens
|
||||||
|
in the expression editor with a warning instead of being rewritten.
|
||||||
|
|
||||||
|
The GeoIP picker lists whatever the configured `geoip.dat` contains: country codes plus,
|
||||||
|
with the default Loyalsoldier database, provider groups such as `cloudflare`, `google`
|
||||||
|
and `telegram`. Matching by AS number is not something the v2geo data format supports,
|
||||||
|
so use those groups or an explicit CIDR list instead.
|
||||||
|
|
||||||
|
`web/frontend/src/lib/rule/` holds the whole thing: `fields.ts` (catalog), `compile.ts`
|
||||||
|
(builder → expr), `parse.ts` (expr → builder) and `validate.ts`. The canonical
|
||||||
|
expressions are pinned in `ruleset/expr_test.go`, which compiles them with the real
|
||||||
|
engine.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -83,6 +127,7 @@ endpoints only accept the bearer token, which makes them immune to CSRF.
|
|||||||
| `POST` | `/logout` | invalidate the current session |
|
| `POST` | `/logout` | invalidate the current session |
|
||||||
| `GET` | `/info` | version, platform and engine configuration |
|
| `GET` | `/info` | version, platform and engine configuration |
|
||||||
| `GET` | `/meta` | available analyzers, modifiers, actions, functions |
|
| `GET` | `/meta` | available analyzers, modifiers, actions, functions |
|
||||||
|
| `GET` | `/geo` | GeoIP/GeoSite entries for the rule builder pickers |
|
||||||
| `GET` | `/metrics` | counters, time series and top N lists |
|
| `GET` | `/metrics` | counters, time series and top N lists |
|
||||||
| `GET` | `/events?limit=` | recent events from the ring buffer |
|
| `GET` | `/events?limit=` | recent events from the ring buffer |
|
||||||
| `GET` | `/live` | server-sent events: `event` and `metrics` frames |
|
| `GET` | `/live` | server-sent events: `event` and `metrics` frames |
|
||||||
|
|||||||
+23
@@ -204,6 +204,29 @@ func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/geo - the entries of the configured geo databases.
|
||||||
|
func (s *Server) handleGeo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config.Geo == nil {
|
||||||
|
writeJSON(w, http.StatusOK, GeoData{
|
||||||
|
IPError: "geo databases are unavailable",
|
||||||
|
SiteError: "geo databases are unavailable",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.geoMu.Lock()
|
||||||
|
defer s.geoMu.Unlock()
|
||||||
|
fresh := s.geoCache != nil && time.Since(s.geoLoaded) < geoCacheTTL
|
||||||
|
if !fresh || r.URL.Query().Get("reload") == "1" {
|
||||||
|
data := s.config.Geo()
|
||||||
|
s.geoCache = &data
|
||||||
|
s.geoLoaded = time.Now()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.geoCache)
|
||||||
|
}
|
||||||
|
|
||||||
type rulesRequest struct {
|
type rulesRequest struct {
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
Rules []Rule `json:"rules"`
|
Rules []Rule `json:"rules"`
|
||||||
|
|||||||
+71
-13
@@ -13,13 +13,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/apernet/OpenGFW/analyzer"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/tcp"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/udp"
|
||||||
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
|
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
|
||||||
"github.com/apernet/OpenGFW/web"
|
"github.com/apernet/OpenGFW/web"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +73,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Geo: geoData,
|
||||||
Logf: log.Printf,
|
Logf: log.Printf,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,8 +171,9 @@ func generate(ctx context.Context, hub *web.Hub) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// memoryRules is an in-memory web.RuleManager: it validates the YAML shape but
|
// memoryRules is an in-memory web.RuleManager. Rules are compiled with the real
|
||||||
// does not compile expressions, which is enough for UI work.
|
// ruleset compiler and the real analyzers, so expression errors show up here
|
||||||
|
// exactly like they would in the engine; only the "apply" step is faked.
|
||||||
type memoryRules struct {
|
type memoryRules struct {
|
||||||
raw string
|
raw string
|
||||||
rules []web.Rule
|
rules []web.Rule
|
||||||
@@ -209,21 +220,68 @@ func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
|
|||||||
return rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
analyzers = []analyzer.Analyzer{
|
||||||
|
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
|
||||||
|
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
|
||||||
|
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
|
||||||
|
}
|
||||||
|
modifiers = []modifier.Modifier{&modUDP.DNSModifier{}}
|
||||||
|
)
|
||||||
|
|
||||||
|
// parse compiles rules the same way the engine does.
|
||||||
func parse(raw string) ([]web.Rule, error) {
|
func parse(raw string) ([]web.Rule, error) {
|
||||||
var rules []web.Rule
|
exprRules, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||||
if err := yaml.Unmarshal([]byte(raw), &rules); err != nil {
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse rules: %w", err)
|
||||||
|
}
|
||||||
|
_, err = ruleset.CompileExprRules(exprRules, analyzers, modifiers, &ruleset.BuiltinConfig{
|
||||||
|
Logger: nopRulesetLogger{},
|
||||||
|
GeoSiteFilename: os.Getenv("OPENGFW_GEOSITE"),
|
||||||
|
GeoIpFilename: os.Getenv("OPENGFW_GEOIP"),
|
||||||
|
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for i, r := range rules {
|
out := make([]web.Rule, 0, len(exprRules))
|
||||||
if r.Name == "" {
|
for _, r := range exprRules {
|
||||||
return nil, fmt.Errorf("rule #%d has no name", i+1)
|
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
|
||||||
|
if r.Modifier.Name != "" {
|
||||||
|
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
|
||||||
}
|
}
|
||||||
if r.Expr == "" {
|
out = append(out, wr)
|
||||||
return nil, fmt.Errorf("rule %q has no expression", r.Name)
|
}
|
||||||
}
|
return out, nil
|
||||||
if r.Action == "" && !r.Log {
|
}
|
||||||
return nil, fmt.Errorf("rule %q must have at least one of action or log", r.Name)
|
|
||||||
|
type nopRulesetLogger struct{}
|
||||||
|
|
||||||
|
func (nopRulesetLogger) Log(ruleset.StreamInfo, string) {}
|
||||||
|
func (nopRulesetLogger) MatchError(ruleset.StreamInfo, string, error) {}
|
||||||
|
|
||||||
|
// geoData lists the geo databases, if they are available in the working
|
||||||
|
// directory (or wherever OPENGFW_GEOIP / OPENGFW_GEOSITE point).
|
||||||
|
func geoData() web.GeoData {
|
||||||
|
matcher := geo.NewGeoMatcher(os.Getenv("OPENGFW_GEOSITE"), os.Getenv("OPENGFW_GEOIP"))
|
||||||
|
var data web.GeoData
|
||||||
|
if entries, err := matcher.ListGeoIP(); err != nil {
|
||||||
|
data.IPError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rules, nil
|
if entries, err := matcher.ListGeoSite(); err != nil {
|
||||||
|
data.SiteError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.Site = append(data.Site, web.GeoEntry{
|
||||||
|
Code: e.Code, Count: e.Domains, Attributes: e.Attributes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as M,d as B,K as L,M as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-D3ZCYstt.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-C9uTg8Ty.js";import{c as C}from"./format-B6sc_rZ4.js";/**
|
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CPDhGTO6.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang-i2F91fYk.js";import{c as M,d as B,O as L,$ as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-BKPz7Obh.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-BHtPbA-o.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-DG3HTLWx.js";import{c as C}from"./format-B6sc_rZ4.js";/**
|
||||||
* @license lucide-vue-next v0.469.0 - ISC
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -8,4 +8,4 @@ import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js"
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
*/const I=M("SquareFunctionIcon",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["path",{d:"M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3",key:"m1af9g"}],["path",{d:"M9 11.2h5.7",key:"3zgcl2"}]]),S={key:0,class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},Z={class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},E={class:"flex items-center justify-between gap-2"},K={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(O){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",K,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
|
*/const I=M("SquareFunctionIcon",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["path",{d:"M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3",key:"m1af9g"}],["path",{d:"M9 11.2h5.7",key:"3zgcl2"}]]),S={key:0,class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},Z={class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},E={class:"flex items-center justify-between gap-2"},O={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(K){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",O,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{d as o,a as n,e as d,u as e,f as s,H as l,o as p,a0 as c}from"./index-D3ZCYstt.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
|
import{d as o,a as n,e as d,u as e,f as s,H as l,o as p,a8 as c}from"./index-BKPz7Obh.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{d as a,o as n,a as r,e as c,u as o,f as p,H as l}from"./index-D3ZCYstt.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
|
import{d as a,o as n,a as r,e as c,u as o,f as p,H as l}from"./index-BKPz7Obh.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{aB as h,aC as x,m as v,g as p,aD as R,al as S,d as g,aj as b,ap as w,aE as y,x as P,aF as k}from"./index-BKPz7Obh.js";function D(e,l){const t=typeof e=="string"&&!l?`${e}Context`:l,o=Symbol(t);return[a=>{const c=h(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>(x(o,a),a)]}function _(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[A]=D("ConfigProvider");function z(e){const l=A({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=l.dir)==null?void 0:t.value)||"ltr"})}function K(e,l="reka"){var n;let t;const o=A({useId:void 0});return o.useId?t=o.useId():t=(n=R)==null?void 0:n(),l?`${l}-${t}`:t}function I(){const e=v(),l=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:l}}const $="data-reka-collection-item";function q(e={}){const{key:l="",isProvider:t=!1}=e,o=`${l}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},x(o,n)}else n=h(o);const C=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${$}]`)),m=new Map(f.map((i,E)=>[i,E])),d=Array.from(n.itemMap.value.values()).sort((i,E)=>(m.get(i.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(i=>i.ref.dataset.disabled!=="")},a=g({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return P(s,()=>{n.collectionRef.value=s.value}),()=>w(y,{ref:m,...f},r)}}),c=g({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return b(d=>{if(s.value){const i=k(s.value);n.itemMap.value.set(i,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(i))}}),()=>w(y,{...f,[$]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),j=p(()=>n.itemMap.value.size);return{getItems:C,reactiveItems:M,itemMapSize:j,CollectionSlot:a,CollectionItem:c}}export{q as a,z as b,D as c,K as d,_ as g,A as i,I as u};
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{ax as I,ay as $,m as v,g as p,az as j,af as S,d as C,ad as b,ak as g,aA as w,x as k,aB as P}from"./index-D3ZCYstt.js";function T(e,i){const t=typeof e=="string"&&!i?`${e}Context`:i,o=Symbol(t);return[a=>{const c=I(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>($(o,a),a)]}function D(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[h]=T("ConfigProvider");function _(e){const i=h({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=i.dir)==null?void 0:t.value)||"ltr"})}function K(e,i="reka"){var n;let t;const o=h({useId:void 0});return o.useId?t=o.useId():t=(n=j)==null?void 0:n(),i?`${i}-${t}`:t}function x(){const e=v(),i=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:i}}const A="data-reka-collection-item";function q(e={}){const{key:i="",isProvider:t=!1}=e,o=`${i}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},$(o,n)}else n=I(o);const y=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${A}]`)),m=new Map(f.map((l,E)=>[l,E])),d=Array.from(n.itemMap.value.values()).sort((l,E)=>(m.get(l.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(l=>l.ref.dataset.disabled!=="")},a=C({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return k(s,()=>{n.collectionRef.value=s.value}),()=>g(w,{ref:m,...f},r)}}),c=C({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return b(d=>{if(s.value){const l=P(s.value);n.itemMap.value.set(l,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(l))}}),()=>g(w,{...f,[A]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),R=p(()=>n.itemMap.value.size);return{getItems:y,reactiveItems:M,itemMapSize:R,CollectionSlot:a,CollectionItem:c}}export{_ as a,q as b,T as c,K as d,D as g,h as i,x as u};
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{c as A,d as H,o as c,a as r,F as w,r as M,b as l,n as q,e as C,u as e,f as W,t as n,g as b,h as X,w as u,_ as z,i as ee,j as S,k as v,l as s,m as Q,p as t,T as te,q as le,s as se,v as ae}from"./index-D3ZCYstt.js";import{c as y,t as oe,p as ne,d as ue}from"./format-B6sc_rZ4.js";import{A as ce,_ as de}from"./EventItem.vue_vue_type_script_setup_true_lang-DS497KtL.js";import{_ as re}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as j,a as P,b as Z,c as T}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as ie}from"./Skeleton.vue_vue_type_script_setup_true_lang-C9uTg8Ty.js";import{_ as me,a as pe,b as G}from"./TabsTrigger.vue_vue_type_script_setup_true_lang-DTNukDpV.js";import"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import"./Collection-D-pf6tmY.js";/**
|
import{c as A,d as H,o as c,a as r,F as w,r as M,b as l,n as q,e as C,u as e,f as W,t as n,g as b,h as X,w as u,_ as z,i as ee,j as S,k as v,l as s,m as Q,p as t,T as te,q as le,s as se,v as ae}from"./index-BKPz7Obh.js";import{c as y,t as oe,p as ne,d as ue}from"./format-B6sc_rZ4.js";import{A as ce,_ as de}from"./EventItem.vue_vue_type_script_setup_true_lang-JeqwaMaY.js";import{_ as re}from"./PageHeader.vue_vue_type_script_setup_true_lang-CPDhGTO6.js";import{_ as j,a as P,b as Z,c as T}from"./CardTitle.vue_vue_type_script_setup_true_lang-BHtPbA-o.js";import{_ as ie}from"./Skeleton.vue_vue_type_script_setup_true_lang-DG3HTLWx.js";import{_ as me,a as pe,b as G}from"./TabsTrigger.vue_vue_type_script_setup_true_lang-_qQ6JBVg.js";import"./Badge.vue_vue_type_script_setup_true_lang-i2F91fYk.js";import"./Collection-BaXBMWO5.js";/**
|
||||||
* @license lucide-vue-next v0.469.0 - ISC
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-D3ZCYstt.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
|
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang-i2F91fYk.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-BKPz7Obh.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
|
||||||
* @license lucide-vue-next v0.469.0 - ISC
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
+16
File diff suppressed because one or more lines are too long
-21
File diff suppressed because one or more lines are too long
+7
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{d as c,a as t,b as n,t as i,j as a,H as d,o as s}from"./index-D3ZCYstt.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
|
import{d as c,a as t,b as n,t as i,j as a,H as d,o as s}from"./index-BKPz7Obh.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
|
||||||
-36
File diff suppressed because one or more lines are too long
+46
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,K as F,M as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a1 as E,a2 as q,g as v,a3 as H,v as K,a4 as U,a5 as A}from"./index-D3ZCYstt.js";import{_ as G}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as x,a as h,c as b,b as J}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-C15l-n0X.js";import"./Collection-D-pf6tmY.js";/**
|
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,O as F,$ as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a9 as E,a0 as q,g as v,aa as H,v as U,ab as A,ac as G}from"./index-BKPz7Obh.js";import{_ as J}from"./PageHeader.vue_vue_type_script_setup_true_lang-CPDhGTO6.js";import{_ as x,a as h,c as b,b as K}from"./CardTitle.vue_vue_type_script_setup_true_lang-BHtPbA-o.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-CONzBLld.js";import"./Collection-BaXBMWO5.js";/**
|
||||||
* @license lucide-vue-next v0.469.0 - ISC
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
@@ -13,4 +13,4 @@ import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,K as F,M as z,p as a,l a
|
|||||||
*
|
*
|
||||||
* This source code is licensed under the ISC license.
|
* This source code is licensed under the ISC license.
|
||||||
* See the LICENSE file in the root directory of this source tree.
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
*/const Y=y("MonitorIcon",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]),Z=M({__name:"Separator",props:{class:{},orientation:{default:"horizontal"}},setup(k){const g=k;return(w,S)=>(u(),r("div",{role:"separator",class:L(t(T)("bg-border shrink-0",g.orientation==="vertical"?"h-full w-px":"h-px w-full",g.class))},null,2))}}),ee={class:"max-w-3xl"},te={class:"flex flex-col gap-2"},se={class:"text-sm font-medium"},ae={class:"bg-muted grid grid-cols-3 gap-1 rounded-lg p-1"},le=["onClick"],ne={class:"flex items-center justify-between gap-4"},oe={class:"text-sm font-medium"},ie={class:"flex flex-col"},ue={class:"text-muted-foreground shrink-0"},re={class:"truncate font-mono text-xs sm:text-[13px]"},ce={class:"flex flex-col"},de={class:"text-muted-foreground shrink-0"},ge={class:"truncate font-mono text-xs sm:text-[13px]"},he=M({__name:"SettingsView",setup(k){const g=K(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:U},{value:"dark",label:e("settings.themeDark"),icon:A},{value:"system",label:e("settings.themeSystem"),icon:Y}]),Q=[{value:"en",label:"English"},{value:"zh",label:"中文"}],j=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),I=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function O(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(G,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:Q,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(J),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(I.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:O},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
|
*/const Y=y("MonitorIcon",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]),Z=M({__name:"Separator",props:{class:{},orientation:{default:"horizontal"}},setup(k){const g=k;return(w,S)=>(u(),r("div",{role:"separator",class:L(t(T)("bg-border shrink-0",g.orientation==="vertical"?"h-full w-px":"h-px w-full",g.class))},null,2))}}),ee={class:"max-w-3xl"},te={class:"flex flex-col gap-2"},se={class:"text-sm font-medium"},ae={class:"bg-muted grid grid-cols-3 gap-1 rounded-lg p-1"},le=["onClick"],ne={class:"flex items-center justify-between gap-4"},oe={class:"text-sm font-medium"},ie={class:"flex flex-col"},ue={class:"text-muted-foreground shrink-0"},re={class:"truncate font-mono text-xs sm:text-[13px]"},ce={class:"flex flex-col"},de={class:"text-muted-foreground shrink-0"},ge={class:"truncate font-mono text-xs sm:text-[13px]"},he=M({__name:"SettingsView",setup(k){const g=U(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:A},{value:"dark",label:e("settings.themeDark"),icon:G},{value:"system",label:e("settings.themeSystem"),icon:Y}]),O=[{value:"en",label:"English"},{value:"zh",label:"中文"}],Q=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),j=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function I(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(J,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:O,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(K),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(Q.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:I},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-D3ZCYstt.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-BKPz7Obh.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+106
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-106
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -24,8 +24,8 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-D3ZCYstt.js"></script>
|
<script type="module" crossorigin src="/assets/index-BKPz7Obh.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CNsDVyYx.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DDyrZ70V.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Trash2 } from 'lucide-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import GeoPicker from '@/components/rule/GeoPicker.vue'
|
||||||
|
import { Button, Input, Select, TagsInput } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { explainWildcard } from '@/lib/rule/compile'
|
||||||
|
import {
|
||||||
|
FIELDS,
|
||||||
|
FIELD_MAP,
|
||||||
|
PROTO_VALUES,
|
||||||
|
defaultOperator,
|
||||||
|
operatorsFor,
|
||||||
|
type OperatorId,
|
||||||
|
} from '@/lib/rule/fields'
|
||||||
|
import type { Condition } from '@/lib/rule/types'
|
||||||
|
import { validateValue } from '@/lib/rule/validate'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
condition: Condition
|
||||||
|
/** Analyzer names offered by the "protocol detected" field. */
|
||||||
|
analyzers: string[]
|
||||||
|
removable: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [condition: Condition]
|
||||||
|
remove: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const field = computed(() => FIELD_MAP[props.condition.field] ?? FIELDS[0])
|
||||||
|
|
||||||
|
/** Field keys contain dots, which the translation lookup uses for nesting. */
|
||||||
|
const labelKey = (key: string) => `rules.field.${key.replace(/\./g, '_')}`
|
||||||
|
|
||||||
|
const fieldOptions = computed(() =>
|
||||||
|
FIELDS.map((f) => ({
|
||||||
|
value: f.key,
|
||||||
|
label: `${t(`rules.group.${f.group}`)} · ${t(labelKey(f.key))}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const operatorOptions = computed(() =>
|
||||||
|
operatorsFor(field.value).map((op) => ({ value: op, label: t(`rules.op.${op}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function patch(changes: Partial<Condition>) {
|
||||||
|
emit('update', { ...props.condition, ...changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeField(key: string) {
|
||||||
|
const next = FIELD_MAP[key]
|
||||||
|
if (!next) return
|
||||||
|
const keepOperator = operatorsFor(next).includes(props.condition.op)
|
||||||
|
patch({
|
||||||
|
field: key,
|
||||||
|
op: keepOperator ? props.condition.op : defaultOperator(next.type),
|
||||||
|
values: keepOperator && next.type === field.value.type ? props.condition.values : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeOperator(op: string) {
|
||||||
|
patch({ op: op as OperatorId, values: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleValue(value: string) {
|
||||||
|
const values = props.condition.values.includes(value)
|
||||||
|
? props.condition.values.filter((v) => v !== value)
|
||||||
|
: [...props.condition.values, value]
|
||||||
|
patch({ values })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = (value: string) => validateValue(field.value.type, props.condition.op, value)
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
switch (props.condition.op) {
|
||||||
|
case 'cidr':
|
||||||
|
return '10.0.0.0/8, 2001:db8::/32'
|
||||||
|
case 'wildcard':
|
||||||
|
return '*.example.com'
|
||||||
|
case 'regex':
|
||||||
|
return '^/api/v\\d+/'
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch (field.value.type) {
|
||||||
|
case 'ip':
|
||||||
|
return '1.2.3.4'
|
||||||
|
case 'port':
|
||||||
|
return '443'
|
||||||
|
case 'domain':
|
||||||
|
return 'example.com'
|
||||||
|
default:
|
||||||
|
return t('rules.valuePlaceholder')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Human readable explanation of each wildcard pattern. */
|
||||||
|
const wildcardHints = computed(() => {
|
||||||
|
if (props.condition.op !== 'wildcard') return []
|
||||||
|
return props.condition.values
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((value) => {
|
||||||
|
const info = explainWildcard(value)
|
||||||
|
return `${value} → ${t(`rules.wildcard.${info.kind}`, { value: info.value })}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-muted/40 flex flex-col gap-2 rounded-lg border p-2.5">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Select
|
||||||
|
:model-value="condition.field"
|
||||||
|
:options="fieldOptions"
|
||||||
|
:aria-label="t('rules.builder.field')"
|
||||||
|
class="h-8 min-w-0 flex-1 basis-45 text-xs sm:basis-56"
|
||||||
|
@update:model-value="changeField"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
:model-value="condition.op"
|
||||||
|
:options="operatorOptions"
|
||||||
|
:aria-label="t('rules.builder.operator')"
|
||||||
|
class="h-8 min-w-0 flex-1 basis-32 text-xs sm:basis-40 sm:flex-none"
|
||||||
|
@update:model-value="changeOperator"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'h-8 shrink-0 rounded-md border px-2 text-xs font-medium transition-colors',
|
||||||
|
condition.negate
|
||||||
|
? 'border-block/40 bg-block/10 text-block'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-pressed="condition.negate"
|
||||||
|
@click="patch({ negate: !condition.negate })"
|
||||||
|
>
|
||||||
|
{{ t('rules.builder.not') }}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
v-if="removable"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protocol: fixed choices -->
|
||||||
|
<div v-if="field.type === 'proto'" class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="value in PROTO_VALUES"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-md border px-2.5 py-1 font-mono text-xs uppercase transition-colors',
|
||||||
|
condition.values.includes(value)
|
||||||
|
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleValue(value)"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protocol detection: analyzer chips -->
|
||||||
|
<div v-else-if="field.type === 'analyzer'" class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="name in analyzers"
|
||||||
|
:key="name"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors',
|
||||||
|
condition.values.includes(name)
|
||||||
|
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleValue(name)"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port range -->
|
||||||
|
<div v-else-if="condition.op === 'range'" class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="condition.values[0] ?? ''"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="1024"
|
||||||
|
class="h-8 w-28 text-xs"
|
||||||
|
@update:model-value="patch({ values: [$event, condition.values[1] ?? ''] })"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">–</span>
|
||||||
|
<Input
|
||||||
|
:model-value="condition.values[1] ?? ''"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="65535"
|
||||||
|
class="h-8 w-28 text-xs"
|
||||||
|
@update:model-value="patch({ values: [condition.values[0] ?? '', $event] })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GeoIP / GeoSite pickers -->
|
||||||
|
<GeoPicker
|
||||||
|
v-else-if="condition.op === 'geoip' || condition.op === 'geosite'"
|
||||||
|
:model-value="condition.values"
|
||||||
|
:kind="condition.op"
|
||||||
|
@update:model-value="patch({ values: $event })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Everything else: a list of values -->
|
||||||
|
<template v-else>
|
||||||
|
<TagsInput
|
||||||
|
:model-value="condition.values"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:validate="validator"
|
||||||
|
@update:model-value="patch({ values: $event })"
|
||||||
|
/>
|
||||||
|
<p v-for="hint in wildcardHints" :key="hint" class="text-muted-foreground font-mono text-[11px]">
|
||||||
|
{{ hint }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, ChevronsUpDown, Search, X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxPortal,
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxViewport,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { TagsInput } from '@/components/ui'
|
||||||
|
import { useGeo } from '@/composables/useGeo'
|
||||||
|
import { t, useI18n } from '@/i18n'
|
||||||
|
import { flagOf, regionName } from '@/lib/geo'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select for geoip() country codes and geosite() categories, backed by
|
||||||
|
* the databases the engine actually loaded. Falls back to a plain chip input
|
||||||
|
* when the databases are not available.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ modelValue: string[]; kind: 'geoip' | 'geosite' }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const { lang } = useI18n()
|
||||||
|
const { geo, error, loading, load } = useGeo()
|
||||||
|
const search = ref('')
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
flag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = computed<Option[]>(() => {
|
||||||
|
const entries = (props.kind === 'geoip' ? geo.value?.ip : geo.value?.site) ?? []
|
||||||
|
const out: Option[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (props.kind === 'geoip') {
|
||||||
|
const name = regionName(entry.code, lang.value)
|
||||||
|
out.push({
|
||||||
|
value: entry.code,
|
||||||
|
label: name ?? entry.code,
|
||||||
|
hint: `${entry.code.toUpperCase()} · ${entry.count}`,
|
||||||
|
flag: flagOf(entry.code),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
value: entry.code,
|
||||||
|
label: entry.code,
|
||||||
|
hint: String(entry.count),
|
||||||
|
flag: '',
|
||||||
|
})
|
||||||
|
for (const attr of entry.attributes ?? []) {
|
||||||
|
out.push({
|
||||||
|
value: `${entry.code}@${attr}`,
|
||||||
|
label: `${entry.code}@${attr}`,
|
||||||
|
hint: t('rules.geo.attribute'),
|
||||||
|
flag: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
const list = q
|
||||||
|
? options.value.filter((o) => o.value.includes(q) || o.label.toLowerCase().includes(q))
|
||||||
|
: options.value
|
||||||
|
return list.slice(0, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unavailable = computed(
|
||||||
|
() => !loading.value && options.value.length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
props.kind === 'geoip' ? error.value.ip : error.value.site,
|
||||||
|
)
|
||||||
|
|
||||||
|
function remove(value: string) {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((v) => v !== value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelOf(value: string): string {
|
||||||
|
const option = options.value.find((o) => o.value === value)
|
||||||
|
if (option) return `${option.flag} ${option.label}`.trim()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<template v-if="unavailable">
|
||||||
|
<TagsInput
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:placeholder="props.kind === 'geoip' ? 'cn, us, private' : 'category-ads-all'"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('rules.geo.unavailable') }}
|
||||||
|
<span v-if="errorMessage" class="font-mono">({{ errorMessage }})</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="props.modelValue.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="value in props.modelValue"
|
||||||
|
:key="value"
|
||||||
|
class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ labelOf(value) }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="opacity-60 transition-opacity hover:opacity-100"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="remove(value)"
|
||||||
|
>
|
||||||
|
<X class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-muted-foreground text-xs">{{ t('rules.geo.loading') }}</p>
|
||||||
|
|
||||||
|
<ComboboxRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
multiple
|
||||||
|
ignore-filter
|
||||||
|
:reset-search-term-on-blur="false"
|
||||||
|
@update:model-value="emit('update:modelValue', ($event as unknown as string[]) ?? [])"
|
||||||
|
>
|
||||||
|
<ComboboxAnchor
|
||||||
|
class="border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-9 w-full items-center gap-2 rounded-md border px-3 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]"
|
||||||
|
>
|
||||||
|
<Search class="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<ComboboxInput
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="
|
||||||
|
props.kind === 'geoip' ? t('rules.geo.searchCountry') : t('rules.geo.searchSite')
|
||||||
|
"
|
||||||
|
class="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
|
||||||
|
/>
|
||||||
|
<ComboboxTrigger class="text-muted-foreground shrink-0">
|
||||||
|
<ChevronsUpDown class="size-4" />
|
||||||
|
</ComboboxTrigger>
|
||||||
|
</ComboboxAnchor>
|
||||||
|
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
position="popper"
|
||||||
|
:side-offset="4"
|
||||||
|
class="bg-popover text-popover-foreground animate-fade-in z-50 max-h-72 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border shadow-md"
|
||||||
|
>
|
||||||
|
<ComboboxViewport class="max-h-72 overflow-y-auto p-1">
|
||||||
|
<ComboboxEmpty class="text-muted-foreground px-2 py-4 text-center text-xs">
|
||||||
|
{{ t('common.empty') }}
|
||||||
|
</ComboboxEmpty>
|
||||||
|
<ComboboxItem
|
||||||
|
v-for="option in filtered"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="option.flag" class="shrink-0">{{ option.flag }}</span>
|
||||||
|
<span class="truncate">{{ option.label }}</span>
|
||||||
|
<span class="text-muted-foreground ml-auto shrink-0 text-[11px] tabular-nums">
|
||||||
|
{{ option.hint }}
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="props.modelValue.includes(option.value)"
|
||||||
|
class="absolute right-2 size-4"
|
||||||
|
/>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</ComboboxRoot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import ConditionRow from '@/components/rule/ConditionRow.vue'
|
||||||
|
import { Button } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { compileRule } from '@/lib/rule/compile'
|
||||||
|
import { newCondition, type Condition, type RuleBuilderState } from '@/lib/rule/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: RuleBuilderState; analyzers: string[] }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: RuleBuilderState] }>()
|
||||||
|
|
||||||
|
const expression = computed(() => compileRule(props.modelValue))
|
||||||
|
|
||||||
|
function patch(changes: Partial<RuleBuilderState>) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(index: number, condition: Condition) {
|
||||||
|
const conditions = [...props.modelValue.conditions]
|
||||||
|
conditions[index] = condition
|
||||||
|
patch({ conditions })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(index: number) {
|
||||||
|
patch({ conditions: props.modelValue.conditions.filter((_, i) => i !== index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCondition() {
|
||||||
|
patch({ conditions: [...props.modelValue.conditions, newCondition()] })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="text-muted-foreground">{{ t('rules.builder.match') }}</span>
|
||||||
|
<div class="bg-muted inline-flex rounded-md p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="mode in ['and', 'or'] as const"
|
||||||
|
:key="mode"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
props.modelValue.mode === mode
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="patch({ mode })"
|
||||||
|
>
|
||||||
|
{{ t(`rules.builder.${mode}`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted-foreground">{{ t('rules.builder.matchSuffix') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<template v-for="(condition, index) in props.modelValue.conditions" :key="condition.id">
|
||||||
|
<div v-if="index > 0" class="flex items-center gap-2">
|
||||||
|
<span class="bg-border h-px flex-1" />
|
||||||
|
<span class="text-muted-foreground text-[11px] font-medium uppercase">
|
||||||
|
{{ t(`rules.builder.${props.modelValue.mode}`) }}
|
||||||
|
</span>
|
||||||
|
<span class="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
<ConditionRow
|
||||||
|
:condition="condition"
|
||||||
|
:analyzers="props.analyzers"
|
||||||
|
:removable="props.modelValue.conditions.length > 1"
|
||||||
|
@update="updateCondition(index, $event)"
|
||||||
|
@remove="removeCondition(index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" class="w-full" @click="addCondition">
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
{{ t('rules.builder.addCondition') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground text-xs font-medium">
|
||||||
|
{{ t('rules.builder.preview') }}
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
class="bg-muted/60 text-muted-foreground block rounded-md px-2.5 py-2 font-mono text-[11px] leading-relaxed break-all"
|
||||||
|
>
|
||||||
|
{{ expression || t('rules.builder.empty') }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** Chip style input for a list of values, with per-value validation. */
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
placeholder?: string
|
||||||
|
/** Returns an error message for an invalid value, or "" when it is fine. */
|
||||||
|
validate?: (value: string) => string
|
||||||
|
class?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const invalid = computed(() => {
|
||||||
|
if (!props.validate) return new Set<string>()
|
||||||
|
return new Set(props.modelValue.filter((v) => props.validate?.(v)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = computed(() => {
|
||||||
|
if (!props.validate) return [] as string[]
|
||||||
|
const messages = new Set<string>()
|
||||||
|
for (const value of props.modelValue) {
|
||||||
|
const message = props.validate(value)
|
||||||
|
if (message) messages.add(message)
|
||||||
|
}
|
||||||
|
return [...messages]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<TagsInputRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:delimiter="','"
|
||||||
|
:add-on-paste="true"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@update:model-value="emit('update:modelValue', $event as string[])"
|
||||||
|
>
|
||||||
|
<TagsInputItem
|
||||||
|
v-for="value in props.modelValue"
|
||||||
|
:key="value"
|
||||||
|
:value="value"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-xs',
|
||||||
|
invalid.has(value)
|
||||||
|
? 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||||
|
: 'bg-secondary text-secondary-foreground border-transparent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TagsInputItemText />
|
||||||
|
<TagsInputItemDelete class="hover:text-foreground opacity-60 transition-opacity hover:opacity-100">
|
||||||
|
<X class="size-3" />
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</TagsInputItem>
|
||||||
|
<TagsInputInput
|
||||||
|
:placeholder="props.modelValue.length ? '' : props.placeholder"
|
||||||
|
class="placeholder:text-muted-foreground min-w-24 flex-1 bg-transparent px-1 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</TagsInputRoot>
|
||||||
|
<p v-for="message in errors" :key="message" class="text-destructive text-xs">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,7 @@ export { default as Separator } from './Separator.vue'
|
|||||||
export { default as Skeleton } from './Skeleton.vue'
|
export { default as Skeleton } from './Skeleton.vue'
|
||||||
export { default as Switch } from './Switch.vue'
|
export { default as Switch } from './Switch.vue'
|
||||||
export { default as Tabs } from './Tabs.vue'
|
export { default as Tabs } from './Tabs.vue'
|
||||||
|
export { default as TagsInput } from './TagsInput.vue'
|
||||||
export { default as TabsContent } from './TabsContent.vue'
|
export { default as TabsContent } from './TabsContent.vue'
|
||||||
export { default as TabsList } from './TabsList.vue'
|
export { default as TabsList } from './TabsList.vue'
|
||||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { GeoData } from '@/lib/types'
|
||||||
|
|
||||||
|
const geo = ref<GeoData | null>(null)
|
||||||
|
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
let pending: Promise<void> | null = null
|
||||||
|
|
||||||
|
/** Loads the geo database listing once and shares it across components. */
|
||||||
|
async function load(force = false): Promise<void> {
|
||||||
|
if (geo.value && !force) return
|
||||||
|
if (pending) return pending
|
||||||
|
loading.value = true
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.geo(force)
|
||||||
|
geo.value = data
|
||||||
|
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
error.value = { ip: message, site: message }
|
||||||
|
geo.value = { ip: [], site: [] }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeo() {
|
||||||
|
return { geo, error, loading, load }
|
||||||
|
}
|
||||||
@@ -127,8 +127,85 @@ export default {
|
|||||||
moveDown: 'Move down',
|
moveDown: 'Move down',
|
||||||
empty: 'No rules yet. Everything is accepted.',
|
empty: 'No rules yet. Everything is accepted.',
|
||||||
nameRequired: 'A name is required',
|
nameRequired: 'A name is required',
|
||||||
exprRequired: 'An expression is required',
|
exprRequired: 'Add at least one condition',
|
||||||
hint: 'Saving writes the rule file and hot reloads the engine.',
|
hint: 'Saving writes the rule file and hot reloads the engine.',
|
||||||
|
conditions: 'Conditions',
|
||||||
|
exprHint: 'Any expr expression works here, the engine validates it when you save.',
|
||||||
|
invalidJson: 'Modifier arguments must be valid JSON',
|
||||||
|
invalidCidr: 'Not a valid CIDR, e.g. 10.0.0.0/8',
|
||||||
|
invalidRegex: 'Not a valid regular expression',
|
||||||
|
invalidGeo: 'Use letters, digits, - and @',
|
||||||
|
invalidIp: 'Not a valid IP address',
|
||||||
|
invalidPort: 'Port must be between 0 and 65535',
|
||||||
|
invalidDomain: 'Not a valid domain pattern',
|
||||||
|
starHint: 'Contains *: switch the operator to "matches wildcard"',
|
||||||
|
valuePlaceholder: 'Type a value and press Enter',
|
||||||
|
builder: {
|
||||||
|
tab: 'Builder',
|
||||||
|
match: 'Match',
|
||||||
|
matchSuffix: 'of the following conditions',
|
||||||
|
and: 'all',
|
||||||
|
or: 'any',
|
||||||
|
addCondition: 'Add condition',
|
||||||
|
preview: 'Generated expression',
|
||||||
|
empty: 'Fill in a condition to see the expression',
|
||||||
|
not: 'NOT',
|
||||||
|
field: 'Field',
|
||||||
|
operator: 'Operator',
|
||||||
|
notParsed:
|
||||||
|
'This rule uses an expression the builder cannot represent, so it is shown as raw expr. Editing it in the builder would change its meaning.',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
basic: 'Connection',
|
||||||
|
domain: 'Domain',
|
||||||
|
http: 'HTTP',
|
||||||
|
protocol: 'Protocol',
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
proto: 'Transport protocol',
|
||||||
|
ip_dst: 'Destination IP',
|
||||||
|
ip_src: 'Source IP',
|
||||||
|
port_dst: 'Destination port',
|
||||||
|
port_src: 'Source port',
|
||||||
|
tls_sni: 'TLS SNI',
|
||||||
|
quic_sni: 'QUIC SNI',
|
||||||
|
dns_name: 'DNS query name',
|
||||||
|
http_host: 'HTTP Host',
|
||||||
|
http_path: 'HTTP path',
|
||||||
|
http_method: 'HTTP method',
|
||||||
|
http_ua: 'User-Agent',
|
||||||
|
analyzer: 'Detected protocol',
|
||||||
|
},
|
||||||
|
op: {
|
||||||
|
is: 'is',
|
||||||
|
subdomain: 'domain or subdomain of',
|
||||||
|
wildcard: 'matches wildcard',
|
||||||
|
contains: 'contains',
|
||||||
|
startsWith: 'starts with',
|
||||||
|
endsWith: 'ends with',
|
||||||
|
regex: 'matches regex',
|
||||||
|
geosite: 'in GeoSite category',
|
||||||
|
cidr: 'in CIDR',
|
||||||
|
geoip: 'in GeoIP country',
|
||||||
|
range: 'in range',
|
||||||
|
detected: 'is',
|
||||||
|
},
|
||||||
|
wildcard: {
|
||||||
|
exact: 'exact match',
|
||||||
|
suffix: 'ends with {value}',
|
||||||
|
prefix: 'starts with {value}',
|
||||||
|
contains: 'contains {value}',
|
||||||
|
regex: 'regex {value}',
|
||||||
|
any: 'any non-empty value',
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
attribute: 'attribute',
|
||||||
|
loading: 'Loading the geo database, this can take a moment the first time…',
|
||||||
|
searchCountry: 'Search country or group…',
|
||||||
|
searchSite: 'Search category…',
|
||||||
|
unavailable:
|
||||||
|
'The geo database could not be read, enter the codes manually (e.g. cn, us).',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
analyzers: {
|
analyzers: {
|
||||||
title: 'Analyzers',
|
title: 'Analyzers',
|
||||||
|
|||||||
@@ -127,8 +127,83 @@ export default {
|
|||||||
moveDown: '下移',
|
moveDown: '下移',
|
||||||
empty: '暂无规则,所有流量都会放行。',
|
empty: '暂无规则,所有流量都会放行。',
|
||||||
nameRequired: '请填写名称',
|
nameRequired: '请填写名称',
|
||||||
exprRequired: '请填写表达式',
|
exprRequired: '请至少添加一个条件',
|
||||||
hint: '保存后将写入规则文件并热加载引擎。',
|
hint: '保存后将写入规则文件并热加载引擎。',
|
||||||
|
conditions: '匹配条件',
|
||||||
|
exprHint: '这里可以写任意 expr 表达式,保存时由引擎校验。',
|
||||||
|
invalidJson: '修改器参数必须是合法 JSON',
|
||||||
|
invalidCidr: '不是合法的 CIDR,例如 10.0.0.0/8',
|
||||||
|
invalidRegex: '不是合法的正则表达式',
|
||||||
|
invalidGeo: '只能包含字母、数字、- 和 @',
|
||||||
|
invalidIp: '不是合法的 IP 地址',
|
||||||
|
invalidPort: '端口需在 0-65535 之间',
|
||||||
|
invalidDomain: '不是合法的域名或通配符',
|
||||||
|
starHint: '包含 *,如需通配符请把条件改成「通配符匹配」',
|
||||||
|
valuePlaceholder: '输入后按回车添加',
|
||||||
|
builder: {
|
||||||
|
tab: '可视化构建',
|
||||||
|
match: '满足以下条件的',
|
||||||
|
matchSuffix: '项',
|
||||||
|
and: '全部',
|
||||||
|
or: '任一',
|
||||||
|
addCondition: '添加条件',
|
||||||
|
preview: '生成的表达式',
|
||||||
|
empty: '填写条件后这里会显示表达式',
|
||||||
|
not: '取反',
|
||||||
|
field: '字段',
|
||||||
|
operator: '条件',
|
||||||
|
notParsed: '该规则的表达式无法用可视化构建器表示,已切换到表达式模式;在构建器中编辑会改变其语义。',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
basic: '连接',
|
||||||
|
domain: '域名',
|
||||||
|
http: 'HTTP',
|
||||||
|
protocol: '协议',
|
||||||
|
},
|
||||||
|
field: {
|
||||||
|
proto: '传输层协议',
|
||||||
|
ip_dst: '目标 IP',
|
||||||
|
ip_src: '来源 IP',
|
||||||
|
port_dst: '目标端口',
|
||||||
|
port_src: '来源端口',
|
||||||
|
tls_sni: 'TLS SNI',
|
||||||
|
quic_sni: 'QUIC SNI',
|
||||||
|
dns_name: 'DNS 查询域名',
|
||||||
|
http_host: 'HTTP Host',
|
||||||
|
http_path: 'HTTP 路径',
|
||||||
|
http_method: 'HTTP 方法',
|
||||||
|
http_ua: 'User-Agent',
|
||||||
|
analyzer: '识别到的协议',
|
||||||
|
},
|
||||||
|
op: {
|
||||||
|
is: '等于',
|
||||||
|
subdomain: '是域名或其子域名',
|
||||||
|
wildcard: '通配符匹配',
|
||||||
|
contains: '包含',
|
||||||
|
startsWith: '开头是',
|
||||||
|
endsWith: '结尾是',
|
||||||
|
regex: '正则匹配',
|
||||||
|
geosite: '属于 GeoSite 分类',
|
||||||
|
cidr: '属于网段',
|
||||||
|
geoip: '属于 GeoIP 国家/地区',
|
||||||
|
range: '在区间内',
|
||||||
|
detected: '是',
|
||||||
|
},
|
||||||
|
wildcard: {
|
||||||
|
exact: '精确匹配',
|
||||||
|
suffix: '以 {value} 结尾',
|
||||||
|
prefix: '以 {value} 开头',
|
||||||
|
contains: '包含 {value}',
|
||||||
|
regex: '正则 {value}',
|
||||||
|
any: '任意非空值',
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
attribute: '属性',
|
||||||
|
loading: '正在加载 geo 数据库,首次加载可能需要一点时间…',
|
||||||
|
searchCountry: '搜索国家/地区或分组…',
|
||||||
|
searchSite: '搜索分类…',
|
||||||
|
unavailable: '无法读取 geo 数据库,请手动输入代码(如 cn、us)。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
analyzers: {
|
analyzers: {
|
||||||
title: '解析器',
|
title: '解析器',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
GeoData,
|
||||||
Info,
|
Info,
|
||||||
LiveEvent,
|
LiveEvent,
|
||||||
MetaInfo,
|
MetaInfo,
|
||||||
@@ -96,6 +97,7 @@ export const api = {
|
|||||||
metrics: () => request<Metrics>('/metrics'),
|
metrics: () => request<Metrics>('/metrics'),
|
||||||
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
|
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
|
||||||
rules: () => request<RulesResponse>('/rules'),
|
rules: () => request<RulesResponse>('/rules'),
|
||||||
|
geo: (reload = false) => request<GeoData>(`/geo${reload ? '?reload=1' : ''}`),
|
||||||
|
|
||||||
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
||||||
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
|
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** Helpers for displaying geo database keys. */
|
||||||
|
|
||||||
|
const displayNames = new Map<string, Intl.DisplayNames>()
|
||||||
|
|
||||||
|
function names(lang: string): Intl.DisplayNames | null {
|
||||||
|
const locale = lang === 'zh' ? 'zh-CN' : 'en'
|
||||||
|
if (!displayNames.has(locale)) {
|
||||||
|
try {
|
||||||
|
displayNames.set(locale, new Intl.DisplayNames([locale], { type: 'region' }))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return displayNames.get(locale) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized country name for a two letter code, or null when the code is not a
|
||||||
|
* country. GeoIP databases also contain provider groups such as "cloudflare".
|
||||||
|
*/
|
||||||
|
export function regionName(code: string, lang: string): string | null {
|
||||||
|
if (!/^[a-z]{2}$/i.test(code)) return null
|
||||||
|
const upper = code.toUpperCase()
|
||||||
|
try {
|
||||||
|
const name = names(lang)?.of(upper)
|
||||||
|
return name && name !== upper ? name : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flag emoji for a two letter country code, empty for anything else. */
|
||||||
|
export function flagOf(code: string): string {
|
||||||
|
if (!/^[a-z]{2}$/i.test(code)) return ''
|
||||||
|
const base = 0x1f1e6
|
||||||
|
const upper = code.toUpperCase()
|
||||||
|
return String.fromCodePoint(
|
||||||
|
base + upper.charCodeAt(0) - 65,
|
||||||
|
base + upper.charCodeAt(1) - 65,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
|
||||||
|
import type { Condition, RuleBuilderState } from './types'
|
||||||
|
|
||||||
|
/** Quotes a value as an expr string literal. */
|
||||||
|
export function quote(value: string): string {
|
||||||
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
|
||||||
|
|
||||||
|
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
|
||||||
|
export function wildcardToRegex(pattern: string): string {
|
||||||
|
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WildcardExplain {
|
||||||
|
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
|
||||||
|
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Describes how a wildcard pattern will be matched, for the inline hint. */
|
||||||
|
export function explainWildcard(pattern: string): WildcardExplain {
|
||||||
|
const p = pattern.trim()
|
||||||
|
if (p === '*') return { kind: 'any', value: '' }
|
||||||
|
if (!p.includes('*')) return { kind: 'exact', value: p }
|
||||||
|
const starts = p.startsWith('*')
|
||||||
|
const ends = p.endsWith('*')
|
||||||
|
const core = p.replace(/^\*/, '').replace(/\*$/, '')
|
||||||
|
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
|
||||||
|
if (starts && ends) return { kind: 'contains', value: core }
|
||||||
|
if (starts) return { kind: 'suffix', value: core }
|
||||||
|
if (ends) return { kind: 'prefix', value: core }
|
||||||
|
return { kind: 'regex', value: wildcardToRegex(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function wildcardTerm(subject: string, pattern: string): string {
|
||||||
|
const info = explainWildcard(pattern)
|
||||||
|
switch (info.kind) {
|
||||||
|
case 'any':
|
||||||
|
return `${subject} != ""`
|
||||||
|
case 'exact':
|
||||||
|
return `${subject} == ${quote(info.value)}`
|
||||||
|
case 'suffix':
|
||||||
|
return `${subject} endsWith ${quote(info.value)}`
|
||||||
|
case 'prefix':
|
||||||
|
return `${subject} startsWith ${quote(info.value)}`
|
||||||
|
case 'contains':
|
||||||
|
return `${subject} contains ${quote(info.value)}`
|
||||||
|
case 'regex':
|
||||||
|
return `${subject} matches ${quote(info.value)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the expression for one value of a condition. */
|
||||||
|
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
switch (op) {
|
||||||
|
case 'is':
|
||||||
|
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
|
||||||
|
case 'subdomain':
|
||||||
|
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
|
||||||
|
case 'wildcard':
|
||||||
|
return wildcardTerm(subject, v)
|
||||||
|
case 'contains':
|
||||||
|
case 'startsWith':
|
||||||
|
case 'endsWith':
|
||||||
|
return `${subject} ${op} ${quote(v)}`
|
||||||
|
case 'regex':
|
||||||
|
return `${subject} matches ${quote(v)}`
|
||||||
|
case 'geosite':
|
||||||
|
return `geosite(${subject}, ${quote(v.toLowerCase())})`
|
||||||
|
case 'cidr':
|
||||||
|
return `cidr(${subject}, ${quote(v)})`
|
||||||
|
case 'geoip':
|
||||||
|
return `geoip(${subject}, ${quote(v.toLowerCase())})`
|
||||||
|
case 'detected':
|
||||||
|
return `${v} != nil`
|
||||||
|
case 'range':
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compiles a single condition into an expression, or "" when incomplete. */
|
||||||
|
export function compileCondition(condition: Condition): string {
|
||||||
|
const field = FIELD_MAP[condition.field]
|
||||||
|
if (!field) return ''
|
||||||
|
const values = condition.values.map((v) => v.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
let body: string
|
||||||
|
if (condition.op === 'range') {
|
||||||
|
const [from, to] = condition.values
|
||||||
|
if (!from?.trim() || !to?.trim()) return ''
|
||||||
|
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
|
||||||
|
} else {
|
||||||
|
if (values.length === 0) return ''
|
||||||
|
const subject = field.iter ? (field.item as string) : (field.expr as string)
|
||||||
|
const terms = values.map((v) => term(field, condition.op, subject, v))
|
||||||
|
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
|
||||||
|
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!condition.negate) return body
|
||||||
|
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compiles the whole builder state into a rule expression. */
|
||||||
|
export function compileRule(state: RuleBuilderState): string {
|
||||||
|
const parts = state.conditions.map(compileCondition).filter(Boolean)
|
||||||
|
if (parts.length === 0) return ''
|
||||||
|
if (parts.length === 1) return parts[0]
|
||||||
|
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Catalog of the fields and operators the visual rule builder offers.
|
||||||
|
*
|
||||||
|
* Every field maps to a snippet of the expr expression language used by the
|
||||||
|
* engine. Optional chaining (`a?.b`) keeps rules safe when an analyzer did not
|
||||||
|
* produce any property for a stream, and `string(...)` turns a missing value
|
||||||
|
* into an empty string instead of an error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ValueType = 'proto' | 'ip' | 'port' | 'domain' | 'text' | 'analyzer'
|
||||||
|
|
||||||
|
export type OperatorId =
|
||||||
|
| 'is'
|
||||||
|
| 'subdomain'
|
||||||
|
| 'wildcard'
|
||||||
|
| 'contains'
|
||||||
|
| 'startsWith'
|
||||||
|
| 'endsWith'
|
||||||
|
| 'regex'
|
||||||
|
| 'geosite'
|
||||||
|
| 'cidr'
|
||||||
|
| 'geoip'
|
||||||
|
| 'range'
|
||||||
|
| 'detected'
|
||||||
|
|
||||||
|
export interface FieldDef {
|
||||||
|
key: string
|
||||||
|
/** Group used to organise the field picker. */
|
||||||
|
group: 'basic' | 'domain' | 'http' | 'protocol'
|
||||||
|
type: ValueType
|
||||||
|
/** Expression evaluating to the value, already coerced to a string. */
|
||||||
|
expr?: string
|
||||||
|
/** Analyzer this field depends on, shown as a hint in the UI. */
|
||||||
|
analyzer?: string
|
||||||
|
/**
|
||||||
|
* Set for fields that live in an array: `iter` is the list expression and
|
||||||
|
* `item` the expression of a single element inside the predicate.
|
||||||
|
*/
|
||||||
|
iter?: string
|
||||||
|
item?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIELDS: FieldDef[] = [
|
||||||
|
{ key: 'proto', group: 'basic', type: 'proto', expr: 'proto' },
|
||||||
|
{ key: 'ip.dst', group: 'basic', type: 'ip', expr: 'ip.dst' },
|
||||||
|
{ key: 'ip.src', group: 'basic', type: 'ip', expr: 'ip.src' },
|
||||||
|
{ key: 'port.dst', group: 'basic', type: 'port', expr: 'port.dst' },
|
||||||
|
{ key: 'port.src', group: 'basic', type: 'port', expr: 'port.src' },
|
||||||
|
|
||||||
|
{ key: 'tls.sni', group: 'domain', type: 'domain', expr: 'string(tls?.req?.sni)', analyzer: 'tls' },
|
||||||
|
{
|
||||||
|
key: 'quic.sni',
|
||||||
|
group: 'domain',
|
||||||
|
type: 'domain',
|
||||||
|
expr: 'string(quic?.req?.sni)',
|
||||||
|
analyzer: 'quic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dns.name',
|
||||||
|
group: 'domain',
|
||||||
|
type: 'domain',
|
||||||
|
analyzer: 'dns',
|
||||||
|
iter: 'dns?.questions ?? []',
|
||||||
|
item: 'string(.name)',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'http.host',
|
||||||
|
group: 'http',
|
||||||
|
type: 'domain',
|
||||||
|
expr: 'string(http?.req?.headers?.host)',
|
||||||
|
analyzer: 'http',
|
||||||
|
},
|
||||||
|
{ key: 'http.path', group: 'http', type: 'text', expr: 'string(http?.req?.path)', analyzer: 'http' },
|
||||||
|
{
|
||||||
|
key: 'http.method',
|
||||||
|
group: 'http',
|
||||||
|
type: 'text',
|
||||||
|
expr: 'string(http?.req?.method)',
|
||||||
|
analyzer: 'http',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'http.ua',
|
||||||
|
group: 'http',
|
||||||
|
type: 'text',
|
||||||
|
expr: 'string(get(http?.req?.headers, "user-agent"))',
|
||||||
|
analyzer: 'http',
|
||||||
|
},
|
||||||
|
|
||||||
|
{ key: 'analyzer', group: 'protocol', type: 'analyzer' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FIELD_MAP: Record<string, FieldDef> = Object.fromEntries(
|
||||||
|
FIELDS.map((f) => [f.key, f]),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Operators offered for each value type, in menu order. */
|
||||||
|
export const OPERATORS: Record<ValueType, OperatorId[]> = {
|
||||||
|
proto: ['is'],
|
||||||
|
ip: ['cidr', 'geoip', 'is'],
|
||||||
|
port: ['is', 'range'],
|
||||||
|
domain: ['subdomain', 'wildcard', 'is', 'contains', 'endsWith', 'startsWith', 'regex', 'geosite'],
|
||||||
|
text: ['contains', 'is', 'startsWith', 'endsWith', 'regex'],
|
||||||
|
analyzer: ['detected'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operators whose value is picked from a list rather than typed. */
|
||||||
|
export const PICKER_OPERATORS: Partial<Record<OperatorId, 'geoip' | 'geosite'>> = {
|
||||||
|
geoip: 'geoip',
|
||||||
|
geosite: 'geosite',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROTO_VALUES = ['tcp', 'udp']
|
||||||
|
|
||||||
|
/** `range` takes exactly two values (from and to); everything else is a list. */
|
||||||
|
export function isRangeOperator(op: OperatorId): boolean {
|
||||||
|
return op === 'range'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultOperator(type: ValueType): OperatorId {
|
||||||
|
return OPERATORS[type][0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operatorsFor(field: FieldDef): OperatorId[] {
|
||||||
|
return OPERATORS[field.type]
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import { FIELDS, type FieldDef, type OperatorId } from './fields'
|
||||||
|
import { newCondition } from './types'
|
||||||
|
import type { Condition, RuleBuilderState } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort conversion of an expression back into builder state.
|
||||||
|
*
|
||||||
|
* It understands the canonical shapes produced by compile.ts, which covers
|
||||||
|
* every rule created through the builder. Hand written expressions that use
|
||||||
|
* anything else simply return null, and the UI falls back to the expression
|
||||||
|
* editor instead of silently rewriting them.
|
||||||
|
*/
|
||||||
|
export function parseRule(expr: string): RuleBuilderState | null {
|
||||||
|
const source = expr.trim()
|
||||||
|
if (!source) return null
|
||||||
|
|
||||||
|
let mode: 'and' | 'or' = 'and'
|
||||||
|
let parts = splitTop(source, '&&')
|
||||||
|
if (parts.length === 1) {
|
||||||
|
const or = splitTop(source, '||')
|
||||||
|
if (or.length > 1) {
|
||||||
|
mode = 'or'
|
||||||
|
parts = or
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: Condition[] = []
|
||||||
|
for (const part of parts) {
|
||||||
|
const condition = parseCondition(part)
|
||||||
|
if (!condition) return null
|
||||||
|
conditions.push(condition)
|
||||||
|
}
|
||||||
|
return { mode, conditions }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCondition(input: string): Condition | null {
|
||||||
|
let source = input.trim()
|
||||||
|
let negate = false
|
||||||
|
if (source.startsWith('!')) {
|
||||||
|
negate = true
|
||||||
|
source = source.slice(1).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single value, including the parenthesised "domain or subdomain" form.
|
||||||
|
const single = parseTerm(source)
|
||||||
|
if (single) {
|
||||||
|
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = unwrap(source)
|
||||||
|
|
||||||
|
// Array fields: any(<iter>, {<predicate>})
|
||||||
|
const anyMatch = matchCall(body, 'any')
|
||||||
|
if (anyMatch !== null) {
|
||||||
|
const [iter, closure] = splitTop(anyMatch, ',').map((s) => s.trim())
|
||||||
|
if (!closure?.startsWith('{') || !closure.endsWith('}')) return null
|
||||||
|
const field = FIELDS.find((f) => f.iter === iter)
|
||||||
|
if (!field) return null
|
||||||
|
return finish(field, closure.slice(1, -1).trim(), negate, field.item as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port range: (port.dst >= 1 && port.dst <= 2)
|
||||||
|
const range = splitTop(body, '&&')
|
||||||
|
if (range.length === 2) {
|
||||||
|
const from = /^(\S+)\s*>=\s*(\d+)$/.exec(range[0].trim())
|
||||||
|
const to = /^(\S+)\s*<=\s*(\d+)$/.exec(range[1].trim())
|
||||||
|
if (from && to && from[1] === to[1]) {
|
||||||
|
const field = FIELDS.find((f) => f.expr === from[1] && f.type === 'port')
|
||||||
|
if (!field) return null
|
||||||
|
return newCondition({ field: field.key, op: 'range', negate, values: [from[2], to[2]] })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (range.length > 1) return null
|
||||||
|
|
||||||
|
return finish(null, body, negate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the values of a condition, which are OR-ed together and must all use
|
||||||
|
* the same field and operator.
|
||||||
|
*/
|
||||||
|
function finish(
|
||||||
|
expected: FieldDef | null,
|
||||||
|
body: string,
|
||||||
|
negate: boolean,
|
||||||
|
subject?: string,
|
||||||
|
): Condition | null {
|
||||||
|
const single = parseTerm(body.trim(), subject)
|
||||||
|
if (single && (!expected || single.field.key === expected.key)) {
|
||||||
|
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = splitTop(unwrap(body), '||')
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
|
||||||
|
const terms: ParsedTerm[] = []
|
||||||
|
let field = expected
|
||||||
|
for (const part of parts) {
|
||||||
|
const parsed = parseTerm(part.trim(), subject)
|
||||||
|
if (!parsed) return null
|
||||||
|
if (!field) field = parsed.field
|
||||||
|
if (parsed.field.key !== field.key) return null
|
||||||
|
terms.push(parsed)
|
||||||
|
}
|
||||||
|
if (!field) return null
|
||||||
|
|
||||||
|
const ops = new Set(terms.map((t) => t.op))
|
||||||
|
if (ops.size === 1) {
|
||||||
|
return newCondition({
|
||||||
|
field: field.key,
|
||||||
|
op: terms[0].op,
|
||||||
|
negate,
|
||||||
|
values: terms.map((t) => t.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values of a wildcard condition compile to different operators depending on
|
||||||
|
// where the star sits, so fold mixed operators back into wildcard patterns.
|
||||||
|
const patterns = terms.map((t) => toWildcard(t.op, t.value))
|
||||||
|
if (patterns.some((p) => p === null)) return null
|
||||||
|
return newCondition({ field: field.key, op: 'wildcard', negate, values: patterns as string[] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of the wildcard compilation, used when a condition mixes operators. */
|
||||||
|
function toWildcard(op: OperatorId, value: string): string | null {
|
||||||
|
switch (op) {
|
||||||
|
case 'wildcard':
|
||||||
|
return value
|
||||||
|
case 'is':
|
||||||
|
return value.includes('*') ? null : value
|
||||||
|
case 'endsWith':
|
||||||
|
return `*${value}`
|
||||||
|
case 'startsWith':
|
||||||
|
return `${value}*`
|
||||||
|
case 'contains':
|
||||||
|
return `*${value}*`
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedTerm {
|
||||||
|
field: FieldDef
|
||||||
|
subject: string
|
||||||
|
op: OperatorId
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CALL_OPS: Record<string, OperatorId> = {
|
||||||
|
geoip: 'geoip',
|
||||||
|
geosite: 'geosite',
|
||||||
|
cidr: 'cidr',
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMPARE_OPS: Record<string, OperatorId> = {
|
||||||
|
'==': 'is',
|
||||||
|
contains: 'contains',
|
||||||
|
startsWith: 'startsWith',
|
||||||
|
endsWith: 'endsWith',
|
||||||
|
matches: 'regex',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All expressions that can appear on the left hand side of a comparison. */
|
||||||
|
function subjects(expected?: string): { subject: string; field: FieldDef }[] {
|
||||||
|
const out: { subject: string; field: FieldDef }[] = []
|
||||||
|
for (const field of FIELDS) {
|
||||||
|
if (field.item) out.push({ subject: field.item, field })
|
||||||
|
if (field.expr) out.push({ subject: field.expr, field })
|
||||||
|
}
|
||||||
|
// Longest match wins, but the subject already established for the condition
|
||||||
|
// is always tried first so that ambiguous fields stay stable.
|
||||||
|
return out.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.subject === expected) - Number(a.subject === expected) ||
|
||||||
|
b.subject.length - a.subject.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTerm(input: string, expected?: string): ParsedTerm | null {
|
||||||
|
const source = input.trim()
|
||||||
|
|
||||||
|
// Analyzer presence: `tls != nil`
|
||||||
|
const detected = /^([a-z][\w]*)\s*!=\s*nil$/.exec(source)
|
||||||
|
if (detected) {
|
||||||
|
const field = FIELDS.find((f) => f.type === 'analyzer')
|
||||||
|
if (field) return { field, subject: '', op: 'detected', value: detected[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain-or-subdomain: (S == "v" || S endsWith ".v")
|
||||||
|
if (source.startsWith('(') && isWrapped(source)) {
|
||||||
|
const inner = splitTop(unwrap(source), '||')
|
||||||
|
if (inner.length === 2) {
|
||||||
|
const a = parseTerm(inner[0].trim(), expected)
|
||||||
|
const b = parseTerm(inner[1].trim(), expected)
|
||||||
|
if (
|
||||||
|
a &&
|
||||||
|
b &&
|
||||||
|
a.op === 'is' &&
|
||||||
|
b.op === 'endsWith' &&
|
||||||
|
a.field.key === b.field.key &&
|
||||||
|
b.value === `.${a.value}`
|
||||||
|
) {
|
||||||
|
return { ...a, op: 'subdomain' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, op] of Object.entries(CALL_OPS)) {
|
||||||
|
const args = matchCall(source, name)
|
||||||
|
if (args === null) continue
|
||||||
|
const parts = splitTop(args, ',').map((s) => s.trim())
|
||||||
|
if (parts.length !== 2) return null
|
||||||
|
const entry = subjects(expected).find((s) => s.subject === parts[0])
|
||||||
|
const value = unquote(parts[1])
|
||||||
|
if (!entry || value === null) return null
|
||||||
|
return { field: entry.field, subject: entry.subject, op, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { subject, field } of subjects(expected)) {
|
||||||
|
if (!source.startsWith(subject)) continue
|
||||||
|
const rest = source.slice(subject.length).trim()
|
||||||
|
// Wildcard "*" compiles to `S != ""`.
|
||||||
|
if (rest === '!= ""') return { field, subject, op: 'wildcard', value: '*' }
|
||||||
|
for (const [token, op] of Object.entries(COMPARE_OPS)) {
|
||||||
|
if (!rest.startsWith(token)) continue
|
||||||
|
const literal = rest.slice(token.length).trim()
|
||||||
|
if (field.type === 'port' && /^\d+$/.test(literal) && op === 'is') {
|
||||||
|
return { field, subject, op, value: literal }
|
||||||
|
}
|
||||||
|
const value = unquote(literal)
|
||||||
|
if (value === null) continue
|
||||||
|
return { field, subject, op, value }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the arguments of `name(...)` when the input is exactly that call. */
|
||||||
|
function matchCall(source: string, name: string): string | null {
|
||||||
|
const prefix = `${name}(`
|
||||||
|
if (!source.startsWith(prefix) || !source.endsWith(')')) return null
|
||||||
|
const args = source.slice(prefix.length, -1)
|
||||||
|
return balanced(args) ? args : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquote(literal: string): string | null {
|
||||||
|
if (literal.length < 2 || literal[0] !== '"' || literal[literal.length - 1] !== '"') return null
|
||||||
|
const body = literal.slice(1, -1)
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
if (body[i] === '\\' && i + 1 < body.length) {
|
||||||
|
i++
|
||||||
|
out += body[i]
|
||||||
|
} else if (body[i] === '"') {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
out += body[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits on a top level operator, ignoring anything nested or quoted. */
|
||||||
|
export function splitTop(source: string, operator: string): string[] {
|
||||||
|
const parts: string[] = []
|
||||||
|
let depth = 0
|
||||||
|
let quoted = false
|
||||||
|
let start = 0
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i]
|
||||||
|
if (quoted) {
|
||||||
|
if (ch === '\\') i++
|
||||||
|
else if (ch === '"') quoted = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '"') quoted = true
|
||||||
|
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
||||||
|
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
||||||
|
else if (depth === 0 && source.startsWith(operator, i)) {
|
||||||
|
parts.push(source.slice(start, i))
|
||||||
|
i += operator.length - 1
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(source.slice(start))
|
||||||
|
return parts.map((p) => p.trim()).filter((p) => p.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function balanced(source: string): boolean {
|
||||||
|
let depth = 0
|
||||||
|
let quoted = false
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const ch = source[i]
|
||||||
|
if (quoted) {
|
||||||
|
if (ch === '\\') i++
|
||||||
|
else if (ch === '"') quoted = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '"') quoted = true
|
||||||
|
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
||||||
|
else if (ch === ')' || ch === ']' || ch === '}') {
|
||||||
|
depth--
|
||||||
|
if (depth < 0) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return depth === 0 && !quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the whole string is wrapped in one pair of parentheses. */
|
||||||
|
function isWrapped(source: string): boolean {
|
||||||
|
if (!source.startsWith('(') || !source.endsWith(')')) return false
|
||||||
|
return balanced(source.slice(1, -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(source: string): string {
|
||||||
|
let out = source.trim()
|
||||||
|
while (isWrapped(out)) out = out.slice(1, -1).trim()
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { OperatorId } from './fields'
|
||||||
|
|
||||||
|
export type MatchMode = 'and' | 'or'
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
id: string
|
||||||
|
field: string
|
||||||
|
op: OperatorId
|
||||||
|
negate: boolean
|
||||||
|
/** One entry per value; the `range` operator uses exactly two. */
|
||||||
|
values: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleBuilderState {
|
||||||
|
mode: MatchMode
|
||||||
|
conditions: Condition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
export function newCondition(partial: Partial<Condition> = {}): Condition {
|
||||||
|
counter += 1
|
||||||
|
return {
|
||||||
|
id: `c${counter}`,
|
||||||
|
field: 'tls.sni',
|
||||||
|
op: 'subdomain',
|
||||||
|
negate: false,
|
||||||
|
values: [],
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyBuilderState(): RuleBuilderState {
|
||||||
|
return { mode: 'and', conditions: [newCondition()] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import type { OperatorId, ValueType } from './fields'
|
||||||
|
|
||||||
|
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||||||
|
|
||||||
|
export function isIPv4(value: string): boolean {
|
||||||
|
const m = IPV4.exec(value)
|
||||||
|
return m ? m.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIPv6(value: string): boolean {
|
||||||
|
if (!value.includes(':')) return false
|
||||||
|
// Reject obvious garbage; the engine does the authoritative parsing.
|
||||||
|
if (!/^[0-9a-fA-F:.]+$/.test(value)) return false
|
||||||
|
return (value.match(/::/g) ?? []).length <= 1 && value.split(':').length <= 9
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIP(value: string): boolean {
|
||||||
|
return isIPv4(value) || isIPv6(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCIDR(value: string): boolean {
|
||||||
|
const slash = value.lastIndexOf('/')
|
||||||
|
if (slash < 0) return false
|
||||||
|
const ip = value.slice(0, slash)
|
||||||
|
const bits = value.slice(slash + 1)
|
||||||
|
if (!/^\d{1,3}$/.test(bits)) return false
|
||||||
|
const prefix = Number(bits)
|
||||||
|
if (isIPv4(ip)) return prefix <= 32
|
||||||
|
if (isIPv6(ip)) return prefix <= 128
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPort(value: string): boolean {
|
||||||
|
return /^\d{1,5}$/.test(value) && Number(value) <= 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an error message for a value, or "" when it is acceptable. */
|
||||||
|
export function validateValue(type: ValueType, op: OperatorId, value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
if (!v) return ''
|
||||||
|
switch (op) {
|
||||||
|
case 'cidr':
|
||||||
|
return isCIDR(v) ? '' : t('rules.invalidCidr')
|
||||||
|
case 'regex':
|
||||||
|
try {
|
||||||
|
new RegExp(v)
|
||||||
|
return ''
|
||||||
|
} catch {
|
||||||
|
return t('rules.invalidRegex')
|
||||||
|
}
|
||||||
|
case 'geoip':
|
||||||
|
case 'geosite':
|
||||||
|
return /^[a-z0-9-]+(@[a-z0-9-]+)*$/i.test(v) ? '' : t('rules.invalidGeo')
|
||||||
|
case 'wildcard':
|
||||||
|
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// A star only means something with the wildcard operator; anywhere else it
|
||||||
|
// would be matched literally, which is never what the user meant.
|
||||||
|
if ((type === 'domain' || type === 'text') && v.includes('*')) {
|
||||||
|
return t('rules.starHint')
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case 'ip':
|
||||||
|
return isIP(v) ? '' : t('rules.invalidIp')
|
||||||
|
case 'port':
|
||||||
|
return isPort(v) ? '' : t('rules.invalidPort')
|
||||||
|
case 'domain':
|
||||||
|
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,19 @@ export interface MetaInfo {
|
|||||||
functions: string[]
|
functions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeoEntry {
|
||||||
|
code: string
|
||||||
|
count: number
|
||||||
|
attributes?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoData {
|
||||||
|
ip: GeoEntry[]
|
||||||
|
site: GeoEntry[]
|
||||||
|
ipError?: string
|
||||||
|
siteError?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuleModifier {
|
export interface RuleModifier {
|
||||||
name: string
|
name: string
|
||||||
args?: Record<string, unknown>
|
args?: Record<string, unknown>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Wand2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import RuleBuilder from '@/components/rule/RuleBuilder.vue'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -32,6 +34,9 @@ import {
|
|||||||
import { toast } from '@/composables/useToast'
|
import { toast } from '@/composables/useToast'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { api, ApiError } from '@/lib/api'
|
import { api, ApiError } from '@/lib/api'
|
||||||
|
import { compileRule } from '@/lib/rule/compile'
|
||||||
|
import { parseRule } from '@/lib/rule/parse'
|
||||||
|
import { emptyBuilderState, type RuleBuilderState } from '@/lib/rule/types'
|
||||||
import type { MetaInfo, Rule } from '@/lib/types'
|
import type { MetaInfo, Rule } from '@/lib/types'
|
||||||
|
|
||||||
const mode = ref('visual')
|
const mode = ref('visual')
|
||||||
@@ -49,6 +54,14 @@ const editing = ref<Rule | null>(null)
|
|||||||
const editingIndex = ref(-1)
|
const editingIndex = ref(-1)
|
||||||
const removing = ref<number>(-1)
|
const removing = ref<number>(-1)
|
||||||
|
|
||||||
|
// Rule editor state
|
||||||
|
const editorTab = ref('builder')
|
||||||
|
const builder = ref<RuleBuilderState>(emptyBuilderState())
|
||||||
|
const builderUnavailable = ref(false)
|
||||||
|
const checking = ref(false)
|
||||||
|
|
||||||
|
const analyzerNames = computed(() => (meta.value?.analyzers ?? []).map((a) => a.name))
|
||||||
|
|
||||||
const actionOptions = computed(() => [
|
const actionOptions = computed(() => [
|
||||||
{ value: '', label: t('rules.actionNone') },
|
{ value: '', label: t('rules.actionNone') },
|
||||||
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
|
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
|
||||||
@@ -175,46 +188,96 @@ function startAdd() {
|
|||||||
editingIndex.value = -1
|
editingIndex.value = -1
|
||||||
editing.value = { name: '', action: 'block', log: false, expr: '' }
|
editing.value = { name: '', action: 'block', log: false, expr: '' }
|
||||||
modifierArgs.value = '{}'
|
modifierArgs.value = '{}'
|
||||||
|
builder.value = emptyBuilderState()
|
||||||
|
builderUnavailable.value = false
|
||||||
|
editorTab.value = 'builder'
|
||||||
|
editError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(index: number) {
|
function startEdit(index: number) {
|
||||||
|
const rule = rules.value[index]
|
||||||
editingIndex.value = index
|
editingIndex.value = index
|
||||||
editing.value = { ...rules.value[index] }
|
editing.value = { ...rule }
|
||||||
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
|
modifierArgs.value = JSON.stringify(rule.modifier?.args ?? {}, null, 2)
|
||||||
|
const parsed = parseRule(rule.expr)
|
||||||
|
builderUnavailable.value = parsed === null
|
||||||
|
builder.value = parsed ?? emptyBuilderState()
|
||||||
|
editorTab.value = parsed ? 'builder' : 'expr'
|
||||||
|
editError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching tabs keeps both representations in sync: the builder is compiled
|
||||||
|
* into the expression, and an expression is parsed back when it uses the
|
||||||
|
* shapes the builder knows.
|
||||||
|
*/
|
||||||
|
function switchEditorTab(next: string) {
|
||||||
|
if (next === editorTab.value || !editing.value) return
|
||||||
|
if (next === 'expr') {
|
||||||
|
const expr = compileRule(builder.value)
|
||||||
|
if (expr) editing.value.expr = expr
|
||||||
|
} else {
|
||||||
|
const parsed = parseRule(editing.value.expr)
|
||||||
|
builderUnavailable.value = parsed === null
|
||||||
|
if (parsed) builder.value = parsed
|
||||||
|
}
|
||||||
|
editorTab.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifierArgs = ref('{}')
|
const modifierArgs = ref('{}')
|
||||||
const editError = ref('')
|
const editError = ref('')
|
||||||
|
|
||||||
function commitEdit() {
|
const draftExpr = computed(() =>
|
||||||
|
editorTab.value === 'builder' ? compileRule(builder.value) : (editing.value?.expr ?? ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function commitEdit() {
|
||||||
const draft = editing.value
|
const draft = editing.value
|
||||||
if (!draft) return
|
if (!draft || checking.value) return
|
||||||
|
editError.value = ''
|
||||||
if (!draft.name.trim()) {
|
if (!draft.name.trim()) {
|
||||||
editError.value = t('rules.nameRequired')
|
editError.value = t('rules.nameRequired')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!draft.expr.trim()) {
|
const expr = draftExpr.value.trim()
|
||||||
|
if (!expr) {
|
||||||
editError.value = t('rules.exprRequired')
|
editError.value = t('rules.exprRequired')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
draft.expr = expr
|
||||||
if (draft.action === 'modify') {
|
if (draft.action === 'modify') {
|
||||||
try {
|
try {
|
||||||
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
|
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
|
||||||
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
|
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
|
||||||
} catch {
|
} catch {
|
||||||
editError.value = 'JSON'
|
editError.value = t('rules.invalidJson')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
draft.modifier = null
|
draft.modifier = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let the engine have the final word on the expression before accepting it.
|
||||||
|
checking.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.validateRules({ rules: [{ ...draft, name: draft.name.trim() }] })
|
||||||
|
if (!res.valid) {
|
||||||
|
editError.value = res.error ?? t('rules.invalid')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
editError.value = e instanceof ApiError ? e.message : String(e)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const next = [...rules.value]
|
const next = [...rules.value]
|
||||||
if (editingIndex.value >= 0) next[editingIndex.value] = draft
|
if (editingIndex.value >= 0) next[editingIndex.value] = draft
|
||||||
else next.push(draft)
|
else next.push(draft)
|
||||||
rules.value = next
|
rules.value = next
|
||||||
dirty.value = true
|
dirty.value = true
|
||||||
editing.value = null
|
editing.value = null
|
||||||
editError.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(index: number) {
|
function remove(index: number) {
|
||||||
@@ -369,9 +432,11 @@ function remove(index: number) {
|
|||||||
<Modal
|
<Modal
|
||||||
:open="editing !== null"
|
:open="editing !== null"
|
||||||
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
|
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
|
||||||
|
class="sm:max-w-2xl"
|
||||||
@update:open="!$event && ((editing = null), (editError = ''))"
|
@update:open="!$event && ((editing = null), (editError = ''))"
|
||||||
>
|
>
|
||||||
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
|
<!-- Submission goes through the footer button: Enter belongs to the tag inputs. -->
|
||||||
|
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="rule-name">{{ t('rules.name') }}</Label>
|
<Label for="rule-name">{{ t('rules.name') }}</Label>
|
||||||
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
|
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
|
||||||
@@ -412,24 +477,58 @@ function remove(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<Textarea
|
<Label>{{ t('rules.conditions') }}</Label>
|
||||||
id="rule-expr"
|
<Tabs :model-value="editorTab" @update:model-value="switchEditorTab">
|
||||||
v-model="editing.expr"
|
<TabsList class="h-8">
|
||||||
spellcheck="false"
|
<TabsTrigger value="builder" class="text-xs">
|
||||||
:placeholder="t('rules.exprPlaceholder')"
|
<Wand2 />
|
||||||
class="min-h-28 font-mono text-xs leading-relaxed"
|
{{ t('rules.builder.tab') }}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="expr" class="text-xs">
|
||||||
|
<FileCode2 />
|
||||||
|
{{ t('rules.expr') }}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="builderUnavailable && editorTab === 'expr'"
|
||||||
|
class="border-drop/40 bg-drop/10 text-drop rounded-md border px-2.5 py-2 text-xs"
|
||||||
|
>
|
||||||
|
{{ t('rules.builder.notParsed') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RuleBuilder
|
||||||
|
v-if="editorTab === 'builder'"
|
||||||
|
v-model="builder"
|
||||||
|
:analyzers="analyzerNames"
|
||||||
/>
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<Textarea
|
||||||
|
v-model="editing.expr"
|
||||||
|
spellcheck="false"
|
||||||
|
:placeholder="t('rules.exprPlaceholder')"
|
||||||
|
class="min-h-28 font-mono text-xs leading-relaxed"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">{{ t('rules.exprHint') }}</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
|
<p v-if="editError" class="text-destructive font-mono text-xs break-words">
|
||||||
|
{{ editError }}
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="outline" @click="((editing = null), (editError = ''))">
|
<Button variant="outline" @click="((editing = null), (editError = ''))">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="commitEdit">{{ t('common.save') }}</Button>
|
<Button :disabled="checking" @click="commitEdit">
|
||||||
|
<Loader2 v-if="checking" class="size-4 animate-spin" />
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// geoCacheTTL is how long a loaded geo database listing is reused. Parsing the
|
||||||
|
// files is expensive, and their content only changes when they are updated.
|
||||||
|
const geoCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errBadCredentials = errors.New("invalid password")
|
errBadCredentials = errors.New("invalid password")
|
||||||
errTooManyAttempts = errors.New("too many failed attempts, try again later")
|
errTooManyAttempts = errors.New("too many failed attempts, try again later")
|
||||||
@@ -88,6 +93,27 @@ type AnalyzerInfo struct {
|
|||||||
Proto string `json:"proto"`
|
Proto string `json:"proto"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GeoData is the content of the configured GeoIP/GeoSite databases, used by the
|
||||||
|
// rule builder so that the user can pick a country or a site category from a
|
||||||
|
// list instead of typing a code.
|
||||||
|
type GeoData struct {
|
||||||
|
// IP lists the geoip() keys: country codes plus, depending on the
|
||||||
|
// database, provider groups such as "cloudflare" or "telegram".
|
||||||
|
IP []GeoEntry `json:"ip"`
|
||||||
|
// Site lists the geosite() keys along with their attributes.
|
||||||
|
Site []GeoEntry `json:"site"`
|
||||||
|
// IPError / SiteError describe why a database could not be loaded.
|
||||||
|
IPError string `json:"ipError,omitempty"`
|
||||||
|
SiteError string `json:"siteError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoEntry is a single key of a geo database.
|
||||||
|
type GeoEntry struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Attributes []string `json:"attributes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the web server configuration.
|
// Config is the web server configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Listen is the address to listen on, e.g. ":8080".
|
// Listen is the address to listen on, e.g. ":8080".
|
||||||
@@ -102,6 +128,9 @@ type Config struct {
|
|||||||
Rules RuleManager
|
Rules RuleManager
|
||||||
Meta MetaInfo
|
Meta MetaInfo
|
||||||
Info func() Info
|
Info func() Info
|
||||||
|
// Geo loads the GeoIP/GeoSite databases. It is called at most once every
|
||||||
|
// geoCacheTTL, and only when the rule builder asks for the data. Optional.
|
||||||
|
Geo func() GeoData
|
||||||
|
|
||||||
// Logf is used for the few messages the server produces. Optional.
|
// Logf is used for the few messages the server produces. Optional.
|
||||||
Logf func(format string, args ...interface{})
|
Logf func(format string, args ...interface{})
|
||||||
@@ -113,6 +142,10 @@ type Server struct {
|
|||||||
auth *authenticator
|
auth *authenticator
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
static http.Handler
|
static http.Handler
|
||||||
|
|
||||||
|
geoMu sync.Mutex
|
||||||
|
geoCache *GeoData
|
||||||
|
geoLoaded time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new web UI server.
|
// NewServer creates a new web UI server.
|
||||||
@@ -158,6 +191,7 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("/api/v1/live", s.guard(s.handleLive, false))
|
s.mux.HandleFunc("/api/v1/live", s.guard(s.handleLive, false))
|
||||||
s.mux.HandleFunc("/api/v1/rules", s.guard(s.handleRules, false))
|
s.mux.HandleFunc("/api/v1/rules", s.guard(s.handleRules, false))
|
||||||
s.mux.HandleFunc("/api/v1/rules/validate", s.guard(s.handleRulesValidate, true))
|
s.mux.HandleFunc("/api/v1/rules/validate", s.guard(s.handleRulesValidate, true))
|
||||||
|
s.mux.HandleFunc("/api/v1/geo", s.guard(s.handleGeo, false))
|
||||||
s.mux.HandleFunc("/", s.handleStatic)
|
s.mux.HandleFunc("/", s.handleStatic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user