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:
mei
2026-07-27 08:48:56 +08:00
co-authored by Claude Opus 5
parent 5a7722d1d2
commit f7ad3aac95
52 changed files with 2191 additions and 216 deletions
+47 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
@@ -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 @@
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 @@
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
View File
@@ -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
View File
@@ -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,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,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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
+1
View File
@@ -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'
+35
View File
@@ -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 }
}
+78 -1
View File
@@ -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',
+76 -1
View File
@@ -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: '解析器',
+2
View File
@@ -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) }),
+41
View File
@@ -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,
)
}
+113
View File
@@ -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' ? ' && ' : ' || ')
}
+126
View File
@@ -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]
}
+322
View File
@@ -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
}
+35
View File
@@ -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()] }
}
+76
View File
@@ -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 ''
}
}
+13
View File
@@ -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>
+116 -17
View File
@@ -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>
+34
View File
@@ -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)
}