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:
+47
-2
@@ -8,8 +8,9 @@ the OpenGFW binary and served by the `web` package.
|
||||
blocked destinations and triggered rules
|
||||
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
|
||||
detail view showing raw analyzer properties
|
||||
- **Rules** — visual rule editor and a raw YAML editor, both validated by the engine
|
||||
itself; saving writes the rule file and hot reloads the running engine
|
||||
- **Rules** — a visual condition builder, a raw expression editor and a YAML editor,
|
||||
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
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
@@ -83,6 +127,7 @@ endpoints only accept the bearer token, which makes them immune to CSRF.
|
||||
| `POST` | `/logout` | invalidate the current session |
|
||||
| `GET` | `/info` | version, platform and engine configuration |
|
||||
| `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` | `/events?limit=` | recent events from the ring buffer |
|
||||
| `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 {
|
||||
Raw string `json:"raw"`
|
||||
Rules []Rule `json:"rules"`
|
||||
|
||||
+71
-13
@@ -13,13 +13,22 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"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"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -64,6 +73,7 @@ func main() {
|
||||
},
|
||||
}
|
||||
},
|
||||
Geo: geoData,
|
||||
Logf: log.Printf,
|
||||
})
|
||||
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
|
||||
// does not compile expressions, which is enough for UI work.
|
||||
// memoryRules is an in-memory web.RuleManager. Rules are compiled with the real
|
||||
// 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 {
|
||||
raw string
|
||||
rules []web.Rule
|
||||
@@ -209,21 +220,68 @@ func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
|
||||
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) {
|
||||
var rules []web.Rule
|
||||
if err := yaml.Unmarshal([]byte(raw), &rules); err != nil {
|
||||
exprRules, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||
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
|
||||
}
|
||||
for i, r := range rules {
|
||||
if r.Name == "" {
|
||||
return nil, fmt.Errorf("rule #%d has no name", i+1)
|
||||
out := make([]web.Rule, 0, len(exprRules))
|
||||
for _, r := range exprRules {
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("rule %q has no expression", r.Name)
|
||||
}
|
||||
if r.Action == "" && !r.Log {
|
||||
return nil, fmt.Errorf("rule %q must have at least one of action or log", r.Name)
|
||||
out = append(out, wr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
* 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) {}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-D3ZCYstt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CNsDVyYx.css">
|
||||
<script type="module" crossorigin src="/assets/index-BKPz7Obh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DDyrZ70V.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 Switch } from './Switch.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 TabsList } from './TabsList.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',
|
||||
empty: 'No rules yet. Everything is accepted.',
|
||||
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.',
|
||||
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: {
|
||||
title: 'Analyzers',
|
||||
|
||||
@@ -127,8 +127,83 @@ export default {
|
||||
moveDown: '下移',
|
||||
empty: '暂无规则,所有流量都会放行。',
|
||||
nameRequired: '请填写名称',
|
||||
exprRequired: '请填写表达式',
|
||||
exprRequired: '请至少添加一个条件',
|
||||
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: {
|
||||
title: '解析器',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
GeoData,
|
||||
Info,
|
||||
LiveEvent,
|
||||
MetaInfo,
|
||||
@@ -96,6 +97,7 @@ export const api = {
|
||||
metrics: () => request<Metrics>('/metrics'),
|
||||
events: (limit = 200) => request<{ events: LiveEvent[] }>(`/events?limit=${limit}`),
|
||||
rules: () => request<RulesResponse>('/rules'),
|
||||
geo: (reload = false) => request<GeoData>(`/geo${reload ? '?reload=1' : ''}`),
|
||||
|
||||
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
||||
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[]
|
||||
}
|
||||
|
||||
export interface GeoEntry {
|
||||
code: string
|
||||
count: number
|
||||
attributes?: string[]
|
||||
}
|
||||
|
||||
export interface GeoData {
|
||||
ip: GeoEntry[]
|
||||
site: GeoEntry[]
|
||||
ipError?: string
|
||||
siteError?: string
|
||||
}
|
||||
|
||||
export interface RuleModifier {
|
||||
name: string
|
||||
args?: Record<string, unknown>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import RuleBuilder from '@/components/rule/RuleBuilder.vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -32,6 +34,9 @@ import {
|
||||
import { toast } from '@/composables/useToast'
|
||||
import { t } from '@/i18n'
|
||||
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'
|
||||
|
||||
const mode = ref('visual')
|
||||
@@ -49,6 +54,14 @@ const editing = ref<Rule | null>(null)
|
||||
const editingIndex = ref(-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(() => [
|
||||
{ value: '', label: t('rules.actionNone') },
|
||||
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
|
||||
@@ -175,46 +188,96 @@ function startAdd() {
|
||||
editingIndex.value = -1
|
||||
editing.value = { name: '', action: 'block', log: false, expr: '' }
|
||||
modifierArgs.value = '{}'
|
||||
builder.value = emptyBuilderState()
|
||||
builderUnavailable.value = false
|
||||
editorTab.value = 'builder'
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
function startEdit(index: number) {
|
||||
const rule = rules.value[index]
|
||||
editingIndex.value = index
|
||||
editing.value = { ...rules.value[index] }
|
||||
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
|
||||
editing.value = { ...rule }
|
||||
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 editError = ref('')
|
||||
|
||||
function commitEdit() {
|
||||
const draftExpr = computed(() =>
|
||||
editorTab.value === 'builder' ? compileRule(builder.value) : (editing.value?.expr ?? ''),
|
||||
)
|
||||
|
||||
async function commitEdit() {
|
||||
const draft = editing.value
|
||||
if (!draft) return
|
||||
if (!draft || checking.value) return
|
||||
editError.value = ''
|
||||
if (!draft.name.trim()) {
|
||||
editError.value = t('rules.nameRequired')
|
||||
return
|
||||
}
|
||||
if (!draft.expr.trim()) {
|
||||
const expr = draftExpr.value.trim()
|
||||
if (!expr) {
|
||||
editError.value = t('rules.exprRequired')
|
||||
return
|
||||
}
|
||||
draft.expr = expr
|
||||
if (draft.action === 'modify') {
|
||||
try {
|
||||
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
|
||||
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
|
||||
} catch {
|
||||
editError.value = 'JSON'
|
||||
editError.value = t('rules.invalidJson')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
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]
|
||||
if (editingIndex.value >= 0) next[editingIndex.value] = draft
|
||||
else next.push(draft)
|
||||
rules.value = next
|
||||
dirty.value = true
|
||||
editing.value = null
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
@@ -369,9 +432,11 @@ function remove(index: number) {
|
||||
<Modal
|
||||
:open="editing !== null"
|
||||
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
|
||||
class="sm:max-w-2xl"
|
||||
@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">
|
||||
<Label for="rule-name">{{ t('rules.name') }}</Label>
|
||||
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
|
||||
@@ -412,24 +477,58 @@ function remove(index: number) {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
|
||||
<Textarea
|
||||
id="rule-expr"
|
||||
v-model="editing.expr"
|
||||
spellcheck="false"
|
||||
:placeholder="t('rules.exprPlaceholder')"
|
||||
class="min-h-28 font-mono text-xs leading-relaxed"
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label>{{ t('rules.conditions') }}</Label>
|
||||
<Tabs :model-value="editorTab" @update:model-value="switchEditorTab">
|
||||
<TabsList class="h-8">
|
||||
<TabsTrigger value="builder" class="text-xs">
|
||||
<Wand2 />
|
||||
{{ 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>
|
||||
|
||||
<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>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="((editing = null), (editError = ''))">
|
||||
{{ t('common.cancel') }}
|
||||
</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>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 (
|
||||
errBadCredentials = errors.New("invalid password")
|
||||
errTooManyAttempts = errors.New("too many failed attempts, try again later")
|
||||
@@ -88,6 +93,27 @@ type AnalyzerInfo struct {
|
||||
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.
|
||||
type Config struct {
|
||||
// Listen is the address to listen on, e.g. ":8080".
|
||||
@@ -102,6 +128,9 @@ type Config struct {
|
||||
Rules RuleManager
|
||||
Meta MetaInfo
|
||||
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 func(format string, args ...interface{})
|
||||
@@ -113,6 +142,10 @@ type Server struct {
|
||||
auth *authenticator
|
||||
mux *http.ServeMux
|
||||
static http.Handler
|
||||
|
||||
geoMu sync.Mutex
|
||||
geoCache *GeoData
|
||||
geoLoaded time.Time
|
||||
}
|
||||
|
||||
// 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/rules", s.guard(s.handleRules, false))
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user